diff --git a/.changeset/shy-bugs-obey.md b/.changeset/shy-bugs-obey.md new file mode 100644 index 00000000..9a279c14 --- /dev/null +++ b/.changeset/shy-bugs-obey.md @@ -0,0 +1,7 @@ +--- +"@graphprotocol/hypergraph-react": patch +"@graphprotocol/hypergraph": patch +--- + +Add support for querying public entities across multiple spaces (including an `all` scope) and expose the new API through the React hooks + \ No newline at end of file diff --git a/apps/events/src/routes/podcasts.lazy.tsx b/apps/events/src/routes/podcasts.lazy.tsx index 0288ef92..c8512b3c 100644 --- a/apps/events/src/routes/podcasts.lazy.tsx +++ b/apps/events/src/routes/podcasts.lazy.tsx @@ -1,6 +1,6 @@ import { useEntities } from '@graphprotocol/hypergraph-react'; import { createLazyFileRoute } from '@tanstack/react-router'; -import { Podcast, Topic } from '@/schema'; +import { Podcast, Space, Topic } from '@/schema'; export const Route = createLazyFileRoute('/podcasts')({ component: RouteComponent, @@ -54,6 +54,8 @@ function RouteComponent() { backlinksTotalCountsTypeId1: '972d201a-d780-4568-9e01-543f67b26bee', }); + console.log({ data, isLoading, isError }); + const { data: topics } = useEntities(Topic, { mode: 'public', first: 10, @@ -74,7 +76,15 @@ function RouteComponent() { console.log({ topics }); - console.log({ data, isLoading, isError }); + const { data: spaces } = useEntities(Space, { + mode: 'public', + spaces: 'all', + include: { + avatar: {}, + }, + }); + console.log('spaces', spaces); + return ( <>

Podcasts

diff --git a/apps/events/src/schema.ts b/apps/events/src/schema.ts index 1ac60c30..87f0c45c 100644 --- a/apps/events/src/schema.ts +++ b/apps/events/src/schema.ts @@ -1,3 +1,4 @@ +import { SystemIds } from '@graphprotocol/grc-20'; import { Entity, Id, Type } from '@graphprotocol/hypergraph'; export const User = Entity.Schema( @@ -304,3 +305,19 @@ export const Episode = Entity.Schema( }, }, ); + +export const Space = Entity.Schema( + { + name: Type.String, + avatar: Type.Relation(Image), + }, + { + types: [Id('362c1dbd-dc64-44bb-a3c4-652f38a642d7')], + properties: { + name: Id(SystemIds.NAME_PROPERTY), + avatar: Id('1155beff-fad5-49b7-a2e0-da4777b8792c'), + }, + }, +); + +export type Space = Entity.Entity; diff --git a/packages/hypergraph-react/src/hooks/use-entities-public-infinite.ts b/packages/hypergraph-react/src/hooks/use-entities-public-infinite.ts index c5b6ad1d..5ce1a48c 100644 --- a/packages/hypergraph-react/src/hooks/use-entities-public-infinite.ts +++ b/packages/hypergraph-react/src/hooks/use-entities-public-infinite.ts @@ -10,17 +10,24 @@ export const useEntitiesPublicInfinite = ( type: S, params?: QueryPublicParams, ) => { - const { enabled = true, filter, include, space: spaceFromParams, first = 2, offset = 0 } = params ?? {}; + const { enabled = true, filter, include, space: spaceFromParams, spaces, first = 2, offset = 0 } = params ?? {}; const { space: spaceFromContext } = useHypergraphSpaceInternal(); const space = spaceFromParams ?? spaceFromContext; + const spaceSelectionKey = spaces ?? space; const typeIds = SchemaAST.getAnnotation(Constants.TypeIdsSymbol)(type.ast as SchemaAST.TypeLiteral).pipe( Option.getOrElse(() => []), ); const result = useInfiniteQueryTanstack({ - queryKey: ['hypergraph-public-entities', space, typeIds, include, filter, 'infinite'], + queryKey: ['hypergraph-public-entities', spaceSelectionKey, typeIds, include, filter, 'infinite'], queryFn: async ({ pageParam }) => { - return Entity.findManyPublic(type, { filter, include, space, first, offset: pageParam }); + return Entity.findManyPublic(type, { + filter, + include, + ...(spaces ? { spaces } : { space }), + first, + offset: pageParam, + }); }, getNextPageParam: (_lastPage, pages) => { return offset + pages.length * first; diff --git a/packages/hypergraph-react/src/hooks/use-entities.tsx b/packages/hypergraph-react/src/hooks/use-entities.tsx index 0d7673f3..883702b9 100644 --- a/packages/hypergraph-react/src/hooks/use-entities.tsx +++ b/packages/hypergraph-react/src/hooks/use-entities.tsx @@ -2,13 +2,21 @@ import type { Entity } from '@graphprotocol/hypergraph'; import type * as Schema from 'effect/Schema'; import { useEntitiesPrivate } from '../internal/use-entities-private.js'; import { useEntitiesPublic } from '../internal/use-entities-public.js'; +import { useHypergraphSpaceInternal } from '../internal/use-hypergraph-space-internal.js'; -type UseEntitiesParams = { +type SpaceSelectionInputOptionInContext = { + space?: never; + spaces?: never; +}; + +type UseEntitiesParams = ( + | Entity.SpaceSelectionInput + | SpaceSelectionInputOptionInContext +) & { mode: 'public' | 'private'; filter?: Entity.EntityFilter> | undefined; // TODO: restrict multi-level nesting to the actual relation keys include?: Entity.EntityInclude | undefined; - space?: string | undefined; first?: number | undefined; offset?: number | undefined; orderBy?: @@ -21,18 +29,21 @@ type UseEntitiesParams = { }; export function useEntities(type: S, params: UseEntitiesParams) { - const { mode, filter, include, space, first, offset, orderBy, backlinksTotalCountsTypeId1 } = params; + const { mode, filter, include, space, spaces, first, offset, orderBy, backlinksTotalCountsTypeId1 } = params; + const { space: spaceFromContext } = useHypergraphSpaceInternal(); + const resolvedSpace = space ?? spaceFromContext; + const publicSpaceParams = spaces ? { spaces } : { space: resolvedSpace }; const publicResult = useEntitiesPublic(type, { enabled: mode === 'public', filter, include, first, offset, - space, orderBy, backlinksTotalCountsTypeId1, + ...publicSpaceParams, }); - const localResult = useEntitiesPrivate(type, { enabled: mode === 'private', filter, include, space }); + const localResult = useEntitiesPrivate(type, { enabled: mode === 'private', filter, include, space: resolvedSpace }); if (mode === 'public') { return { diff --git a/packages/hypergraph-react/src/internal/types.ts b/packages/hypergraph-react/src/internal/types.ts index f0bfdde3..80bca8ff 100644 --- a/packages/hypergraph-react/src/internal/types.ts +++ b/packages/hypergraph-react/src/internal/types.ts @@ -3,17 +3,4 @@ import type * as Schema from 'effect/Schema'; export type QueryPublicParams = { enabled?: boolean | undefined; - filter?: Entity.EntityFilter> | undefined; - // TODO: restrict multi-level nesting to the actual relation keys - include?: Entity.EntityInclude | undefined; - space?: string | undefined; - first?: number | undefined; - offset?: number | undefined; - orderBy?: - | { - property: keyof Schema.Schema.Type; - direction: 'asc' | 'desc'; - } - | undefined; - backlinksTotalCountsTypeId1?: string | undefined; -}; +} & Entity.FindManyPublicParams; diff --git a/packages/hypergraph-react/src/internal/use-entities-public.tsx b/packages/hypergraph-react/src/internal/use-entities-public.tsx index e78b7aa2..d50b7106 100644 --- a/packages/hypergraph-react/src/internal/use-entities-public.tsx +++ b/packages/hypergraph-react/src/internal/use-entities-public.tsx @@ -12,6 +12,7 @@ export const useEntitiesPublic = (type: S, filter, include, space: spaceFromParams, + spaces, first = 100, offset, orderBy, @@ -19,6 +20,7 @@ export const useEntitiesPublic = (type: S, } = params ?? {}; const { space: spaceFromContext } = useHypergraphSpaceInternal(); const space = spaceFromParams ?? spaceFromContext; + const spaceSelectionKey = spaces ?? space; const typeIds = SchemaAST.getAnnotation(Constants.TypeIdsSymbol)(type.ast as SchemaAST.TypeLiteral).pipe( Option.getOrElse(() => []), ); @@ -26,7 +28,7 @@ export const useEntitiesPublic = (type: S, const result = useQueryTanstack({ queryKey: [ 'hypergraph-public-entities', - space, + spaceSelectionKey, typeIds, include, filter, @@ -39,7 +41,7 @@ export const useEntitiesPublic = (type: S, return Entity.findManyPublic(type, { filter, include, - space, + ...(spaces ? { spaces } : { space }), first, offset, orderBy, diff --git a/packages/hypergraph/src/entity/find-many-public.ts b/packages/hypergraph/src/entity/find-many-public.ts index 7219796a..7cd7da5b 100644 --- a/packages/hypergraph/src/entity/find-many-public.ts +++ b/packages/hypergraph/src/entity/find-many-public.ts @@ -8,12 +8,14 @@ import { request } from 'graphql-request'; import type { RelationsListWithNodes } from '../utils/convert-relations.js'; import type { RelationTypeIdInfo } from '../utils/get-relation-type-ids.js'; import { buildRelationsSelection } from '../utils/relation-query-helpers.js'; +import type { SpaceSelection } from './internal/space-selection.js'; +import { normalizeSpaceSelection } from './internal/space-selection.js'; +import type { SpaceSelectionInput } from './types.js'; -export type FindManyPublicParams = { +export type FindManyPublicParams = SpaceSelectionInput & { filter?: Entity.EntityFilter> | undefined; // TODO: restrict multi-level nesting to the actual relation keys include?: Entity.EntityInclude | undefined; - space: string; first?: number | undefined; offset?: number | undefined; orderBy?: @@ -25,26 +27,65 @@ export type FindManyPublicParams = { backlinksTotalCountsTypeId1?: string | undefined; }; -const buildEntitiesQuery = (relationInfoLevel1: RelationTypeIdInfo[], useOrderBy: boolean) => { - const level1Relations = buildRelationsSelection(relationInfoLevel1); +const buildEntitiesQuery = ( + relationInfoLevel1: RelationTypeIdInfo[], + useOrderBy: boolean, + spaceSelection: SpaceSelection, +) => { + const level1Relations = buildRelationsSelection(relationInfoLevel1, spaceSelection.mode); const queryName = useOrderBy ? 'entitiesOrderedByProperty' : 'entities'; - const orderByParams = useOrderBy ? '$propertyId: UUID!, $sortDirection: SortOrder!, ' : ''; + const variableDefinitions = [ + spaceSelection.mode === 'single' + ? '$spaceId: UUID!' + : spaceSelection.mode === 'many' + ? '$spaceIds: [UUID!]!' + : undefined, + '$typeIds: [UUID!]!', + useOrderBy ? '$propertyId: UUID!' : undefined, + useOrderBy ? '$sortDirection: SortOrder!' : undefined, + '$first: Int', + '$filter: EntityFilter!', + '$offset: Int', + '$backlinksTotalCountsTypeId1: UUID', + '$backlinksTotalCountsTypeId1Present: Boolean!', + ] + .filter(Boolean) + .join(', '); + const orderByArgs = useOrderBy ? 'propertyId: $propertyId\n sortDirection: $sortDirection\n ' : ''; + const entitySpaceFilter = + spaceSelection.mode === 'single' + ? 'spaceIds: {in: [$spaceId]},' + : spaceSelection.mode === 'many' + ? 'spaceIds: {in: $spaceIds},' + : ''; + const valuesListFilter = + spaceSelection.mode === 'single' + ? '(filter: { spaceId: { is: $spaceId } })' + : spaceSelection.mode === 'many' + ? '(filter: { spaceId: { in: $spaceIds } })' + : ''; + const backlinksSpaceFilter = + spaceSelection.mode === 'single' + ? 'spaceId: {is: $spaceId}, ' + : spaceSelection.mode === 'many' + ? 'spaceId: {in: $spaceIds}, ' + : ''; return ` -query ${queryName}($spaceId: UUID!, $typeIds: [UUID!]!, ${orderByParams}$first: Int, $filter: EntityFilter!, $offset: Int, $backlinksTotalCountsTypeId1: UUID, $backlinksTotalCountsTypeId1Present: Boolean!) { +query ${queryName}(${variableDefinitions}) { entities: ${queryName}( ${orderByArgs}filter: { and: [{ relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $typeIds}}}, - spaceIds: {in: [$spaceId]}, + ${entitySpaceFilter} }, $filter]} first: $first offset: $offset ) { id name - valuesList(filter: {spaceId: {is: $spaceId}}) { + valuesList${valuesListFilter} { propertyId string boolean @@ -52,7 +93,7 @@ query ${queryName}($spaceId: UUID!, $typeIds: [UUID!]!, ${orderByParams}$first: time point } - backlinksTotalCountsTypeId1: backlinks(filter: { spaceId: {is: $spaceId}, fromEntity: { typeIds: { is: [$backlinksTotalCountsTypeId1] } }}) @include(if: $backlinksTotalCountsTypeId1Present) { + backlinksTotalCountsTypeId1: backlinks(filter: { ${backlinksSpaceFilter}fromEntity: { typeIds: { is: [$backlinksTotalCountsTypeId1] } }}) @include(if: $backlinksTotalCountsTypeId1Present) { totalCount } ${level1Relations} @@ -150,7 +191,16 @@ export const findManyPublic = async ( type: S, params?: FindManyPublicParams, ) => { - const { filter, include, space, first = 100, offset = 0, orderBy, backlinksTotalCountsTypeId1 } = params ?? {}; + const { + filter, + include, + space, + spaces, + first = 100, + offset = 0, + orderBy, + backlinksTotalCountsTypeId1, + } = params ?? {}; // constructing the relation type ids for the query const relationTypeIds = Utils.getRelationTypeIds(type, include); @@ -187,18 +237,26 @@ export const findManyPublic = async ( } // Build the query dynamically with aliases for each relation type ID - const queryDocument = buildEntitiesQuery(relationTypeIds, Boolean(orderBy)); + const spaceSelection = normalizeSpaceSelection(space, spaces); + + // Build the query dynamically with aliases for each relation type ID + const queryDocument = buildEntitiesQuery(relationTypeIds, Boolean(orderBy), spaceSelection); const filterParams = filter ? Utils.translateFilterToGraphql(filter, type) : {}; const queryVariables: Record = { - spaceId: space, typeIds, first, filter: filterParams, offset, }; + if (spaceSelection.mode === 'single') { + queryVariables.spaceId = spaceSelection.spaceId; + } else if (spaceSelection.mode === 'many') { + queryVariables.spaceIds = spaceSelection.spaceIds; + } + if (orderByPropertyId && sortDirection) { queryVariables.propertyId = orderByPropertyId; queryVariables.sortDirection = sortDirection; diff --git a/packages/hypergraph/src/entity/find-one-public.ts b/packages/hypergraph/src/entity/find-one-public.ts index 2dc26c08..055a883a 100644 --- a/packages/hypergraph/src/entity/find-one-public.ts +++ b/packages/hypergraph/src/entity/find-one-public.ts @@ -21,7 +21,7 @@ export type FindOnePublicParams = { }; const buildEntityQuery = (relationInfoLevel1: RelationTypeIdInfo[]) => { - const relationsSelection = buildRelationsSelection(relationInfoLevel1); + const relationsSelection = buildRelationsSelection(relationInfoLevel1, 'single'); const relationsSelectionBlock = relationsSelection ? `\n ${relationsSelection}\n` : ''; return ` query entity($id: UUID!, $spaceId: UUID!) { diff --git a/packages/hypergraph/src/entity/internal/space-selection.ts b/packages/hypergraph/src/entity/internal/space-selection.ts new file mode 100644 index 00000000..791702d0 --- /dev/null +++ b/packages/hypergraph/src/entity/internal/space-selection.ts @@ -0,0 +1,35 @@ +export type SpaceSelection = + | { + mode: 'single'; + spaceId: string; + } + | { + mode: 'many'; + spaceIds: readonly [string, ...string[]]; + } + | { + mode: 'all'; + }; + +export const normalizeSpaceSelection = ( + space: string | undefined, + spaces: readonly [string, ...string[]] | 'all' | undefined, +): SpaceSelection => { + if (space && spaces) { + throw new Error('Provide either "space" or "spaces", not both.'); + } + + if (space) { + return { mode: 'single', spaceId: space }; + } + + if (spaces === 'all') { + return { mode: 'all' }; + } + + if (spaces && spaces.length > 0) { + return { mode: 'many', spaceIds: spaces }; + } + + throw new Error('Either "space" or non-empty "spaces" must be provided.'); +}; diff --git a/packages/hypergraph/src/entity/search-many-public.ts b/packages/hypergraph/src/entity/search-many-public.ts index 5d45a8e2..bad8d144 100644 --- a/packages/hypergraph/src/entity/search-many-public.ts +++ b/packages/hypergraph/src/entity/search-many-public.ts @@ -19,7 +19,7 @@ export type SearchManyPublicParams = { }; const buildSearchQuery = (relationInfoLevel1: RelationTypeIdInfo[]) => { - const relationsSelection = buildRelationsSelection(relationInfoLevel1); + const relationsSelection = buildRelationsSelection(relationInfoLevel1, 'single'); return ` query searchEntities($query: String!, $spaceId: UUID!, $typeIds: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int) { diff --git a/packages/hypergraph/src/entity/types.ts b/packages/hypergraph/src/entity/types.ts index 2a0a56b8..f665c47b 100644 --- a/packages/hypergraph/src/entity/types.ts +++ b/packages/hypergraph/src/entity/types.ts @@ -104,3 +104,17 @@ export type EntityFilter = CrossFieldFilter< id?: EntityIdFilter; } >; + +export type SpaceSelectionInput = + | { + space: string; + spaces?: never; + } + | { + space?: never; + spaces: readonly [string, ...string[]]; + } + | { + space?: never; + spaces: 'all'; + }; diff --git a/packages/hypergraph/src/utils/relation-query-helpers.ts b/packages/hypergraph/src/utils/relation-query-helpers.ts index c6cf5359..c934cd26 100644 --- a/packages/hypergraph/src/utils/relation-query-helpers.ts +++ b/packages/hypergraph/src/utils/relation-query-helpers.ts @@ -1,14 +1,38 @@ import type { RelationTypeIdInfo } from './get-relation-type-ids.js'; +type SpaceSelectionMode = 'single' | 'many' | 'all'; + +const buildValuesListFilter = (spaceSelectionMode: SpaceSelectionMode) => { + if (spaceSelectionMode === 'single') { + return '(filter: {spaceId: {is: $spaceId}})'; + } + if (spaceSelectionMode === 'many') { + return '(filter: {spaceId: {in: $spaceIds}})'; + } + return ''; +}; + +const buildRelationSpaceFilter = (spaceSelectionMode: SpaceSelectionMode) => { + if (spaceSelectionMode === 'single') { + return 'spaceId: {is: $spaceId}, '; + } + if (spaceSelectionMode === 'many') { + return 'spaceId: {in: $spaceIds}, '; + } + return ''; +}; + export const getRelationAlias = (typeId: string) => `relations_${typeId.replace(/-/g, '_')}`; -const buildRelationsListFragment = (info: RelationTypeIdInfo, level: 1 | 2) => { +const buildRelationsListFragment = (info: RelationTypeIdInfo, level: 1 | 2, spaceSelectionMode: SpaceSelectionMode) => { const alias = getRelationAlias(info.typeId); const nestedPlaceholder = info.includeNodes && level === 1 ? '__LEVEL2_RELATIONS__' : ''; const listField = info.listField ?? 'relations'; const connectionField = listField === 'backlinks' ? 'backlinks' : 'relations'; const toEntityField = listField === 'backlinks' ? 'fromEntity' : 'toEntity'; const toEntitySelectionHeader = toEntityField === 'toEntity' ? 'toEntity' : `toEntity: ${toEntityField}`; + const valuesListFilter = buildValuesListFilter(spaceSelectionMode); + const relationSpaceFilter = buildRelationSpaceFilter(spaceSelectionMode); if (!info.includeNodes && !info.includeTotalCount) { return ''; @@ -24,7 +48,7 @@ const buildRelationsListFragment = (info: RelationTypeIdInfo, level: 1 | 2) => { nodes { id entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { + valuesList${valuesListFilter} { propertyId string boolean @@ -36,7 +60,7 @@ const buildRelationsListFragment = (info: RelationTypeIdInfo, level: 1 | 2) => { ${toEntitySelectionHeader} { id name - valuesList(filter: {spaceId: {is: $spaceId}}) { + valuesList${valuesListFilter} { propertyId string boolean @@ -52,28 +76,36 @@ const buildRelationsListFragment = (info: RelationTypeIdInfo, level: 1 | 2) => { return ` ${alias}: ${connectionField}( - filter: {spaceId: {is: $spaceId}, typeId: {is: "${info.typeId}"}}, + filter: {${relationSpaceFilter}typeId: {is: "${info.typeId}"}}, ) {${totalCountSelection}${nodesSelection} }`; }; -const buildLevel2RelationsFragment = (relationInfoLevel2: RelationTypeIdInfo[]) => { +const buildLevel2RelationsFragment = ( + relationInfoLevel2: RelationTypeIdInfo[], + spaceSelectionMode: SpaceSelectionMode, +) => { if (relationInfoLevel2.length === 0) return ''; - return relationInfoLevel2.map((info) => buildRelationsListFragment(info, 2)).join('\n'); + return relationInfoLevel2.map((info) => buildRelationsListFragment(info, 2, spaceSelectionMode)).join('\n'); }; -const buildLevel1RelationsFragment = (relationInfoLevel1: RelationTypeIdInfo[]) => { +const buildLevel1RelationsFragment = ( + relationInfoLevel1: RelationTypeIdInfo[], + spaceSelectionMode: SpaceSelectionMode, +) => { if (relationInfoLevel1.length === 0) return ''; return relationInfoLevel1 .map((info) => { - const level2Fragment = buildLevel2RelationsFragment(info.children ?? []); - const fragment = buildRelationsListFragment(info, 1); + const level2Fragment = buildLevel2RelationsFragment(info.children ?? [], spaceSelectionMode); + const fragment = buildRelationsListFragment(info, 1, spaceSelectionMode); return fragment.replace('__LEVEL2_RELATIONS__', level2Fragment); }) .join('\n'); }; -export const buildRelationsSelection = (relationInfoLevel1: RelationTypeIdInfo[]) => - buildLevel1RelationsFragment(relationInfoLevel1); +export const buildRelationsSelection = ( + relationInfoLevel1: RelationTypeIdInfo[], + spaceSelectionMode: SpaceSelectionMode, +) => buildLevel1RelationsFragment(relationInfoLevel1, spaceSelectionMode); diff --git a/packages/hypergraph/test/entity/space-selection.test.ts b/packages/hypergraph/test/entity/space-selection.test.ts new file mode 100644 index 00000000..8a4144af --- /dev/null +++ b/packages/hypergraph/test/entity/space-selection.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeSpaceSelection } from '../../src/entity/internal/space-selection.js'; + +describe('normalizeSpaceSelection', () => { + it('returns single selection when only space is provided', () => { + expect(normalizeSpaceSelection('space-id', undefined)).toEqual({ + mode: 'single', + spaceId: 'space-id', + }); + }); + + it('returns many selection when spaces array is provided', () => { + expect(normalizeSpaceSelection(undefined, ['space-1', 'space-2'])).toEqual({ + mode: 'many', + spaceIds: ['space-1', 'space-2'], + }); + }); + + it('returns all selection when spaces is the string "all"', () => { + expect(normalizeSpaceSelection(undefined, 'all')).toEqual({ mode: 'all' }); + }); + + it('throws when both space and spaces are provided', () => { + expect(() => normalizeSpaceSelection('space-id', ['space-2'])).toThrowError( + 'Provide either "space" or "spaces", not both.', + ); + }); + + it('throws when neither space nor spaces are provided', () => { + expect(() => normalizeSpaceSelection(undefined, undefined)).toThrowError( + 'Either "space" or non-empty "spaces" must be provided.', + ); + }); + + it('throws when spaces array is empty despite being typed differently', () => { + expect(() => normalizeSpaceSelection(undefined, [] as unknown as readonly [string, ...string[]])).toThrowError( + 'Either "space" or non-empty "spaces" must be provided.', + ); + }); +});