diff --git a/.changeset/plain-turkeys-matter.md b/.changeset/plain-turkeys-matter.md new file mode 100644 index 00000000..0ab34dc2 --- /dev/null +++ b/.changeset/plain-turkeys-matter.md @@ -0,0 +1,7 @@ +--- +"@graphprotocol/hypergraph-react": patch +"@graphprotocol/hypergraph": patch +--- + +add Type.Backlink + \ 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 5ba4f1b4..eb8081bb 100644 --- a/apps/events/src/routes/podcasts.lazy.tsx +++ b/apps/events/src/routes/podcasts.lazy.tsx @@ -1,7 +1,5 @@ -import { Entity } from '@graphprotocol/hypergraph'; import { useEntities } from '@graphprotocol/hypergraph-react'; import { createLazyFileRoute } from '@tanstack/react-router'; -import { useEffect } from 'react'; import { Podcast } from '@/schema'; export const Route = createLazyFileRoute('/podcasts')({ @@ -11,25 +9,43 @@ export const Route = createLazyFileRoute('/podcasts')({ function RouteComponent() { const space = 'e252f9e1-d3ad-4460-8bf1-54f93b02f220'; - useEffect(() => { - setTimeout(async () => { - const result = await Entity.findOnePublic(Podcast, { - id: 'f5d27d3e-3a51-452d-bac2-702574381633', - space: space, - include: { - listenOn: {}, - }, - }); - console.log('findOnePublic result:', result); - }, 1000); - }, []); + // useEffect(() => { + // setTimeout(async () => { + // const result = await Entity.searchManyPublic(Podcast, { + // query: 'Joe', + // space: space, + // // include: { + // // listenOn: {}, + // // }, + // }); + // console.log('searchManyPublic result:', result); + // }, 1000); + // }, []); + + // const { data: podcast } = useEntity(Podcast, { + // id: 'f5d27d3e-3a51-452d-bac2-702574381633', + // mode: 'public', + // space: space, + // include: { + // listenOn: {}, + // hosts: { + // avatar: {}, + // }, + // episodes: {}, + // }, + // }); + // console.log({ podcast }); const { data, isLoading, isError } = useEntities(Podcast, { mode: 'public', - first: 100, + first: 10, space: space, include: { listenOn: {}, + hosts: { + avatar: {}, + }, + episodes: {}, }, orderBy: { property: 'dateFounded', direction: 'asc' }, backlinksTotalCountsTypeId1: '972d201a-d780-4568-9e01-543f67b26bee', diff --git a/apps/events/src/schema.ts b/apps/events/src/schema.ts index 956ead92..1ac60c30 100644 --- a/apps/events/src/schema.ts +++ b/apps/events/src/schema.ts @@ -162,6 +162,39 @@ export const GenericEntity = Entity.Schema( }, ); +export const Episode2 = Entity.Schema( + { + name: Type.String, + description: Type.optional(Type.String), + airDate: Type.Date, + avatar: Type.Relation(Image), + duration: Type.optional(Type.Number), // in seconds + audioUrl: Type.optional(Type.String), + listenOn: Type.Relation(GenericEntity, { + properties: { + website: Type.optional(Type.String), + }, + }), + }, + { + types: [Id('972d201a-d780-4568-9e01-543f67b26bee')], + properties: { + name: Id('a126ca53-0c8e-48d5-b888-82c734c38935'), + description: Id('9b1f76ff-9711-404c-861e-59dc3fa7d037'), + airDate: Id('77999397-f78d-44a7-bbc5-d93a617af47c'), + duration: Id('76996acc-d10f-4cd5-9ac9-4a705b8e03b4'), + audioUrl: Id('87f919d5-560b-408c-be8d-318e2c5c098b'), + avatar: Id('1155beff-fad5-49b7-a2e0-da4777b8792c'), + listenOn: { + propertyId: Id('1367bac7-dcea-4b80-86ad-a4a4cdd7c2cb'), + properties: { + website: Id('eed38e74-e679-46bf-8a42-ea3e4f8fb5fb'), + }, + }, + }, + }, +); + export const Podcast = Entity.Schema( { name: Type.String, @@ -176,6 +209,7 @@ export const Podcast = Entity.Schema( website: Type.optional(Type.String), }, }), + episodes: Type.Backlink(Episode2), }, { types: [Id('4c81561d-1f95-4131-9cdd-dd20ab831ba2')], @@ -193,6 +227,7 @@ export const Podcast = Entity.Schema( website: Id('eed38e74-e679-46bf-8a42-ea3e4f8fb5fb'), }, }, + episodes: Id('f1873bbc-381f-4604-abad-76fed4f6d73f'), }, }, ); 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 ed77d5bc..c5b6ad1d 100644 --- a/packages/hypergraph-react/src/hooks/use-entities-public-infinite.ts +++ b/packages/hypergraph-react/src/hooks/use-entities-public-infinite.ts @@ -1,4 +1,4 @@ -import { Constants, Entity, Utils } from '@graphprotocol/hypergraph'; +import { Constants, Entity } from '@graphprotocol/hypergraph'; import { useInfiniteQuery as useInfiniteQueryTanstack } from '@tanstack/react-query'; import * as Option from 'effect/Option'; import type * as Schema from 'effect/Schema'; @@ -13,24 +13,12 @@ export const useEntitiesPublicInfinite = ( const { enabled = true, filter, include, space: spaceFromParams, first = 2, offset = 0 } = params ?? {}; const { space: spaceFromContext } = useHypergraphSpaceInternal(); const space = spaceFromParams ?? spaceFromContext; - - // constructing the relation type ids for the query - const relationTypeIds = Utils.getRelationTypeIds(type, include); - const typeIds = SchemaAST.getAnnotation(Constants.TypeIdsSymbol)(type.ast as SchemaAST.TypeLiteral).pipe( Option.getOrElse(() => []), ); const result = useInfiniteQueryTanstack({ - queryKey: [ - 'hypergraph-public-entities', - space, - typeIds, - relationTypeIds.level1, - relationTypeIds.level2, - filter, - 'infinite', - ], + queryKey: ['hypergraph-public-entities', space, typeIds, include, filter, 'infinite'], queryFn: async ({ pageParam }) => { return Entity.findManyPublic(type, { filter, include, space, first, offset: pageParam }); }, diff --git a/packages/hypergraph-react/src/internal/use-entities-public.tsx b/packages/hypergraph-react/src/internal/use-entities-public.tsx index fc1dee5d..e78b7aa2 100644 --- a/packages/hypergraph-react/src/internal/use-entities-public.tsx +++ b/packages/hypergraph-react/src/internal/use-entities-public.tsx @@ -1,4 +1,4 @@ -import { Constants, Entity, Utils } from '@graphprotocol/hypergraph'; +import { Constants, Entity } from '@graphprotocol/hypergraph'; import { useQuery as useQueryTanstack } from '@tanstack/react-query'; import * as Option from 'effect/Option'; import type * as Schema from 'effect/Schema'; @@ -19,10 +19,6 @@ export const useEntitiesPublic = (type: S, } = params ?? {}; const { space: spaceFromContext } = useHypergraphSpaceInternal(); const space = spaceFromParams ?? spaceFromContext; - - // constructing the relation type ids for the query - const relationTypeIds = Utils.getRelationTypeIds(type, include); - const typeIds = SchemaAST.getAnnotation(Constants.TypeIdsSymbol)(type.ast as SchemaAST.TypeLiteral).pipe( Option.getOrElse(() => []), ); @@ -32,8 +28,7 @@ export const useEntitiesPublic = (type: S, 'hypergraph-public-entities', space, typeIds, - relationTypeIds.level1, - relationTypeIds.level2, + include, filter, first, offset, diff --git a/packages/hypergraph-react/src/internal/use-entity-public.tsx b/packages/hypergraph-react/src/internal/use-entity-public.tsx index c790a536..f723f368 100644 --- a/packages/hypergraph-react/src/internal/use-entity-public.tsx +++ b/packages/hypergraph-react/src/internal/use-entity-public.tsx @@ -1,277 +1,10 @@ -import { Graph } from '@graphprotocol/grc-20'; -import { Constants, type Entity, Utils } from '@graphprotocol/hypergraph'; +import { Constants, Entity } from '@graphprotocol/hypergraph'; import { useQuery as useQueryTanstack } from '@tanstack/react-query'; -import * as Either from 'effect/Either'; import * as Option from 'effect/Option'; -import * as Schema from 'effect/Schema'; +import type * as Schema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; -import { gql, request } from 'graphql-request'; -import { useMemo } from 'react'; import { useHypergraphSpaceInternal } from './use-hypergraph-space-internal.js'; -const entityQueryDocumentLevel0 = gql` -query entity($id: UUID!, $spaceId: UUID!) { - entity( - id: $id, - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } -} -`; - -const entityQueryDocumentLevel1 = gql` -query entity($id: UUID!, $spaceId: UUID!, $relationTypeIdsLevel1: [UUID!]!) { - entity( - id: $id, - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - typeId - } - } -} -`; - -const entityQueryDocumentLevel2 = gql` -query entity($id: UUID!, $spaceId: UUID!, $relationTypeIdsLevel1: [UUID!]!, $relationTypeIdsLevel2: [UUID!]!) { - entity( - id: $id, - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel2}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - typeId - } - } - typeId - } - } -} -`; - -type EntityQueryResult = { - entity: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - relationsList?: { - id: string; - entity: { - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - toEntity: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - relationsList?: { - id: string; - entity: { - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - toEntity: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - typeId: string; - }[]; - }; - typeId: string; - }[]; - } | null; -}; - -export const parseResult = (queryData: EntityQueryResult, type: S) => { - if (!queryData.entity) { - return { data: null, invalidEntity: null }; - } - - const schemaWithId = Utils.addIdSchemaField(type); - const decode = Schema.decodeUnknownEither(schemaWithId); - const queryEntity = queryData.entity; - let rawEntity: Record = { - id: queryEntity.id, - }; - - const ast = type.ast as SchemaAST.TypeLiteral; - - for (const prop of ast.propertySignatures) { - const propType = - prop.isOptional && SchemaAST.isUnion(prop.type) - ? (prop.type.types.find((member) => !SchemaAST.isUndefinedKeyword(member)) ?? prop.type) - : prop.type; - - const result = SchemaAST.getAnnotation(Constants.PropertyIdSymbol)(propType); - - if (Option.isSome(result)) { - const value = queryEntity.valuesList.find((a) => a.propertyId === result.value); - if (value) { - const rawValue = Utils.convertPropertyValue(value, propType); - if (rawValue) { - rawEntity[String(prop.name)] = rawValue; - } - } - } - } - - // @ts-expect-error - rawEntity = { - ...rawEntity, - ...Utils.convertRelations(queryEntity, ast), - }; - - const decodeResult = decode({ - ...rawEntity, - __deleted: false, - }); - - if (Either.isRight(decodeResult)) { - return { - // injecting the schema to the entity to be able to access it in the preparePublish function - data: { ...decodeResult.right, __schema: type } as Entity.Entity, - invalidEntity: null, - }; - } - - return { data: null, invalidEntity: rawEntity }; -}; - type UseEntityPublicParams = { id: string; enabled?: boolean; @@ -285,41 +18,21 @@ export const useEntityPublic = (type: S, p const { space: spaceFromContext } = useHypergraphSpaceInternal(); const space = spaceFromParams ?? spaceFromContext; - // constructing the relation type ids for the query - const relationTypeIds = Utils.getRelationTypeIds(type, include); - const typeIds = SchemaAST.getAnnotation(Constants.TypeIdsSymbol)(type.ast as SchemaAST.TypeLiteral).pipe( Option.getOrElse(() => []), ); const result = useQueryTanstack({ - queryKey: ['hypergraph-public-entity', id, typeIds, space, relationTypeIds.level1, relationTypeIds.level2, include], + queryKey: ['hypergraph-public-entity', id, typeIds, space, include], queryFn: async () => { - let queryDocument = entityQueryDocumentLevel0; - if (relationTypeIds.level1.length > 0) { - queryDocument = entityQueryDocumentLevel1; - } - if (relationTypeIds.level2.length > 0) { - queryDocument = entityQueryDocumentLevel2; - } - - const result = await request(`${Graph.TESTNET_API_ORIGIN}/graphql`, queryDocument, { + return Entity.findOnePublic(type, { id, - spaceId: space, - relationTypeIdsLevel1: relationTypeIds.level1, - relationTypeIdsLevel2: relationTypeIds.level2, + space, + include, }); - return result; }, enabled: enabled && !!id && !!space, }); - const { data, invalidEntity } = useMemo(() => { - if (result.data) { - return parseResult(result.data, type); - } - return { data: null, invalidEntity: null }; - }, [result.data, type]); - - return { ...result, data, invalidEntity }; + return { ...result, data: result.data ?? null, invalidEntity: null }; }; diff --git a/packages/hypergraph/src/constants.ts b/packages/hypergraph/src/constants.ts index 73f39e20..bf73c48b 100644 --- a/packages/hypergraph/src/constants.ts +++ b/packages/hypergraph/src/constants.ts @@ -9,3 +9,5 @@ export const RelationSchemaSymbol = Symbol.for('grc-20/relation/schema'); export const PropertyTypeSymbol = Symbol.for('grc-20/property/type'); export const RelationPropertiesSymbol = Symbol.for('grc-20/relation/properties'); + +export const RelationBacklinkSymbol = Symbol.for('grc-20/relation/backlink'); diff --git a/packages/hypergraph/src/entity/find-many-public.ts b/packages/hypergraph/src/entity/find-many-public.ts index 0098363f..87fcb016 100644 --- a/packages/hypergraph/src/entity/find-many-public.ts +++ b/packages/hypergraph/src/entity/find-many-public.ts @@ -4,7 +4,9 @@ import * as Either from 'effect/Either'; import * as Option from 'effect/Option'; import * as Schema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; -import { gql, request } from 'graphql-request'; +import { request } from 'graphql-request'; +import type { RelationTypeIdInfo } from '../utils/get-relation-type-ids.js'; +import { buildRelationsSelection } from '../utils/relation-query-helpers.js'; export type FindManyPublicParams = { filter?: Entity.EntityFilter> | undefined; @@ -22,186 +24,22 @@ export type FindManyPublicParams = { backlinksTotalCountsTypeId1?: string | undefined; }; -const entitiesQueryDocumentLevel0 = gql` -query entities($spaceId: UUID!, $typeIds: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int, $backlinksTotalCountsTypeId1: UUID, $backlinksTotalCountsTypeId1Present: Boolean!) { - entities( - filter: { and: [{ - relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $typeIds}}}, - spaceIds: {in: [$spaceId]}, - }, $filter]} - first: $first - offset: $offset - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - backlinksTotalCountsTypeId1: backlinks(filter: { spaceId: {is: $spaceId}, fromEntity: { typeIds: { is: [$backlinksTotalCountsTypeId1] } }}) @include(if: $backlinksTotalCountsTypeId1Present) { - totalCount - } - } -} -`; +const buildEntitiesQuery = (relationInfoLevel1: RelationTypeIdInfo[], useOrderBy: boolean) => { + const level1Relations = buildRelationsSelection(relationInfoLevel1); -const entitiesQueryDocumentLevel1 = gql` -query entities($spaceId: UUID!, $typeIds: [UUID!]!, $relationTypeIdsLevel1: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int, $backlinksTotalCountsTypeId1: UUID, $backlinksTotalCountsTypeId1Present: Boolean!) { - entities( - first: $first - filter: { and: [{ - relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $typeIds}}}, - spaceIds: {in: [$spaceId]}, - }, $filter]} - offset: $offset - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - backlinksTotalCountsTypeId1: backlinks(filter: { spaceId: {is: $spaceId}, fromEntity: { typeIds: { is: [$backlinksTotalCountsTypeId1] } }}) @include(if: $backlinksTotalCountsTypeId1Present) { - totalCount - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - typeId - } - } -} -`; - -const entitiesQueryDocumentLevel2 = gql` -query entities($spaceId: UUID!, $typeIds: [UUID!]!, $relationTypeIdsLevel1: [UUID!]!, $relationTypeIdsLevel2: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int, $backlinksTotalCountsTypeId1: UUID, $backlinksTotalCountsTypeId1Present: Boolean!) { - entities( - first: $first - filter: { and: [{ - relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $typeIds}}}, - spaceIds: {in: [$spaceId]}, - }, $filter]} - offset: $offset - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - backlinksTotalCountsTypeId1: backlinks(filter: { spaceId: {is: $spaceId}, fromEntity: { typeIds: { is: [$backlinksTotalCountsTypeId1] } }}) @include(if: $backlinksTotalCountsTypeId1Present) { - totalCount - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - backlinksTotalCountsTypeId1: backlinks(filter: { spaceId: {is: $spaceId}, fromEntity: { typeIds: { is: [$backlinksTotalCountsTypeId1] } }}) @include(if: $backlinksTotalCountsTypeId1Present) { - totalCount - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel2}}, - # filter: {spaceId: {is: $spaceId}, toEntity: {relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $relationTypeIdsLevel2}}}}} - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - typeId - } - } - typeId - } - } -} -`; + const queryName = useOrderBy ? 'entitiesOrderedByProperty' : 'entities'; + const orderByParams = useOrderBy ? '$propertyId: UUID!, $sortDirection: SortOrder!, ' : ''; + const orderByArgs = useOrderBy ? 'propertyId: $propertyId\n sortDirection: $sortDirection\n ' : ''; -const entitiesOrderedByPropertyQueryDocumentLevel0 = gql` -query entitiesOrderedByProperty($spaceId: UUID!, $typeIds: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int, $propertyId: UUID!, $sortDirection: SortOrder!, $backlinksTotalCountsTypeId1: UUID, $backlinksTotalCountsTypeId1Present: Boolean!) { - entities: entitiesOrderedByProperty( - filter: { and: [{ + return ` +query ${queryName}($spaceId: UUID!, $typeIds: [UUID!]!, ${orderByParams}$first: Int, $filter: EntityFilter!, $offset: Int, $backlinksTotalCountsTypeId1: UUID, $backlinksTotalCountsTypeId1Present: Boolean!) { + entities: ${queryName}( + ${orderByArgs}filter: { and: [{ relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $typeIds}}}, spaceIds: {in: [$spaceId]}, }, $filter]} first: $first offset: $offset - propertyId: $propertyId - sortDirection: $sortDirection ) { id name @@ -216,228 +54,61 @@ query entitiesOrderedByProperty($spaceId: UUID!, $typeIds: [UUID!]!, $first: Int backlinksTotalCountsTypeId1: backlinks(filter: { spaceId: {is: $spaceId}, fromEntity: { typeIds: { is: [$backlinksTotalCountsTypeId1] } }}) @include(if: $backlinksTotalCountsTypeId1Present) { totalCount } + ${level1Relations} } -} -`; +}`; +}; -const entitiesOrderedByPropertyQueryDocumentLevel1 = gql` -query entitiesOrderedByProperty($spaceId: UUID!, $typeIds: [UUID!]!, $relationTypeIdsLevel1: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int, $propertyId: UUID!, $sortDirection: SortOrder!, $backlinksTotalCountsTypeId1: UUID, $backlinksTotalCountsTypeId1Present: Boolean!) { - entities: entitiesOrderedByProperty( - first: $first - filter: { and: [{ - relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $typeIds}}}, - spaceIds: {in: [$spaceId]}, - }, $filter]} - offset: $offset - propertyId: $propertyId - sortDirection: $sortDirection - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - backlinksTotalCountsTypeId1: backlinks(filter: { spaceId: {is: $spaceId}, fromEntity: { typeIds: { is: [$backlinksTotalCountsTypeId1] } }}) @include(if: $backlinksTotalCountsTypeId1Present) { - totalCount - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - typeId - } - } -} -`; +type ValuesList = { + propertyId: string; + string: string; + boolean: boolean; + number: number; + time: string; + point: string; +}[]; + +type RelationsListItem = { + id: string; + entity: { + valuesList: ValuesList; + }; + toEntity: { + id: string; + name: string; + valuesList: ValuesList; + } & { + // For nested aliased relationsList_* fields at level 2 + [K: `relationsList_${string}`]: RelationsListWithTotalCount; + }; + typeId: string; +}; -const entitiesOrderedByPropertyQueryDocumentLevel2 = gql` -query entitiesOrderedByProperty($spaceId: UUID!, $typeIds: [UUID!]!, $relationTypeIdsLevel1: [UUID!]!, $relationTypeIdsLevel2: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int, $propertyId: UUID!, $sortDirection: SortOrder!, $backlinksTotalCountsTypeId1: UUID, $backlinksTotalCountsTypeId1Present: Boolean!) { - entities: entitiesOrderedByProperty( - first: $first - filter: { and: [{ - relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $typeIds}}}, - spaceIds: {in: [$spaceId]}, - }, $filter]} - offset: $offset - propertyId: $propertyId - sortDirection: $sortDirection - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - backlinksTotalCountsTypeId1: backlinks(filter: { spaceId: {is: $spaceId}, fromEntity: { typeIds: { is: [$backlinksTotalCountsTypeId1] } }}) @include(if: $backlinksTotalCountsTypeId1Present) { - totalCount - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - backlinksTotalCountsTypeId1: backlinks(filter: { spaceId: {is: $spaceId}, fromEntity: { typeIds: { is: [$backlinksTotalCountsTypeId1] } }}) @include(if: $backlinksTotalCountsTypeId1Present) { - totalCount - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel2}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - typeId - } - } - typeId - } - } -} -`; +type RelationsListWithTotalCount = { + totalCount: number; +} & RelationsListItem[]; -type EntityQueryResult = { - entities: { +export type EntityQueryResult = { + entities: ({ id: string; name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; + valuesList: ValuesList; backlinksTotalCountsTypeId1: { totalCount: number; } | null; - relationsList: { - id: string; - entity: { - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - toEntity: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - relationsList: { - id: string; - entity: { - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - toEntity: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - typeId: string; - }[]; - }; - typeId: string; - }[]; - }[]; + } & { + // For aliased relationsList_* fields - provides proper typing with totalCount + [K: `relationsList_${string}`]: RelationsListWithTotalCount; + })[]; }; type GraphSortDirection = 'ASC' | 'DESC'; -export const parseResult = (queryData: EntityQueryResult, type: S) => { +export const parseResult = ( + queryData: EntityQueryResult, + type: S, + relationInfoLevel1: RelationTypeIdInfo[], +) => { const schemaWithId = Utils.addIdSchemaField(type); const decode = Schema.decodeUnknownEither(schemaWithId); const data: (Entity.Entity & { backlinksTotalCountsTypeId1?: number })[] = []; @@ -472,7 +143,7 @@ export const parseResult = (queryData: Ent // @ts-expect-error rawEntity = { ...rawEntity, - ...Utils.convertRelations(queryEntity, ast), + ...Utils.convertRelations(queryEntity, ast, relationInfoLevel1), }; const decodeResult = decode({ @@ -507,8 +178,6 @@ export const findManyPublic = async ( Option.getOrElse(() => []), ); - const relationLevel = relationTypeIds.level2.length > 0 ? 2 : relationTypeIds.level1.length > 0 ? 1 : 0; - let orderByPropertyId: string | undefined; let sortDirection: GraphSortDirection | undefined; @@ -536,26 +205,14 @@ export const findManyPublic = async ( sortDirection = orderBy.direction === 'asc' ? 'ASC' : 'DESC'; } - const queryDocument = - relationLevel === 2 - ? orderBy - ? entitiesOrderedByPropertyQueryDocumentLevel2 - : entitiesQueryDocumentLevel2 - : relationLevel === 1 - ? orderBy - ? entitiesOrderedByPropertyQueryDocumentLevel1 - : entitiesQueryDocumentLevel1 - : orderBy - ? entitiesOrderedByPropertyQueryDocumentLevel0 - : entitiesQueryDocumentLevel0; + // Build the query dynamically with aliases for each relation type ID + const queryDocument = buildEntitiesQuery(relationTypeIds, Boolean(orderBy)); const filterParams = filter ? Utils.translateFilterToGraphql(filter, type) : {}; const queryVariables: Record = { spaceId: space, typeIds, - relationTypeIdsLevel1: relationTypeIds.level1, - relationTypeIdsLevel2: relationTypeIds.level2, first, filter: filterParams, offset, @@ -575,6 +232,6 @@ export const findManyPublic = async ( const result = await request(`${Graph.TESTNET_API_ORIGIN}/graphql`, queryDocument, queryVariables); - const { data, invalidEntities } = parseResult(result, type); + const { data, invalidEntities } = parseResult(result, type, relationTypeIds); return { data, invalidEntities }; }; diff --git a/packages/hypergraph/src/entity/find-one-public.ts b/packages/hypergraph/src/entity/find-one-public.ts index eed30cf8..b37934c0 100644 --- a/packages/hypergraph/src/entity/find-one-public.ts +++ b/packages/hypergraph/src/entity/find-one-public.ts @@ -4,73 +4,13 @@ import * as Either from 'effect/Either'; import * as Option from 'effect/Option'; import * as Schema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; -import { gql, request } from 'graphql-request'; +import { request } from 'graphql-request'; +import type { RelationTypeIdInfo } from '../utils/get-relation-type-ids.js'; +import { buildRelationsSelection } from '../utils/relation-query-helpers.js'; +import type { EntityQueryResult as MultiEntityQueryResult } from './find-many-public.js'; type EntityQueryResult = { - entity: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - relationsList?: { - id: string; - entity: { - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - toEntity: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - relationsList?: { - id: string; - entity: { - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - toEntity: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - typeId: string; - }[]; - }; - typeId: string; - }[]; - } | null; + entity: MultiEntityQueryResult['entities'][number] | null; }; export type FindOnePublicParams = { @@ -80,7 +20,10 @@ export type FindOnePublicParams = { include?: { [K in keyof Schema.Schema.Type]?: Record> } | undefined; }; -const entityQueryDocumentLevel0 = gql` +const buildEntityQuery = (relationInfoLevel1: RelationTypeIdInfo[]) => { + const relationsSelection = buildRelationsSelection(relationInfoLevel1); + const relationsSelectionBlock = relationsSelection ? `\n ${relationsSelection}\n` : ''; + return ` query entity($id: UUID!, $spaceId: UUID!) { entity( id: $id, @@ -94,134 +37,17 @@ query entity($id: UUID!, $spaceId: UUID!) { number time point - } - } -} -`; - -const entityQueryDocumentLevel1 = gql` -query entity($id: UUID!, $spaceId: UUID!, $relationTypeIdsLevel1: [UUID!]!) { - entity( - id: $id, - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - typeId - } - } -} -`; - -const entityQueryDocumentLevel2 = gql` -query entity($id: UUID!, $spaceId: UUID!, $relationTypeIdsLevel1: [UUID!]!, $relationTypeIdsLevel2: [UUID!]!) { - entity( - id: $id, - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel2}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - typeId - } - } - typeId - } + }${relationsSelectionBlock} } } `; +}; -const parseResult = (queryData: EntityQueryResult, type: S) => { +const parseResult = ( + queryData: EntityQueryResult, + type: S, + relationInfoLevel1: RelationTypeIdInfo[], +) => { if (!queryData.entity) { return null; } @@ -257,7 +83,7 @@ const parseResult = (queryData: EntityQuer // @ts-expect-error rawEntity = { ...rawEntity, - ...Utils.convertRelations(queryEntity, ast), + ...Utils.convertRelations(queryEntity, ast, relationInfoLevel1), }; const decodeResult = decode({ @@ -281,21 +107,12 @@ export const findOnePublic = async (type: // constructing the relation type ids for the query const relationTypeIds = Utils.getRelationTypeIds(type, include); - const relationLevel = relationTypeIds.level2.length > 0 ? 2 : relationTypeIds.level1.length > 0 ? 1 : 0; - - const queryDocument = - relationLevel === 2 - ? entityQueryDocumentLevel2 - : relationLevel === 1 - ? entityQueryDocumentLevel1 - : entityQueryDocumentLevel0; + const queryDocument = buildEntityQuery(relationTypeIds); const result = await request(`${Graph.TESTNET_API_ORIGIN}/graphql`, queryDocument, { id, spaceId: space, - relationTypeIdsLevel1: relationTypeIds.level1, - relationTypeIdsLevel2: relationTypeIds.level2, }); - return parseResult(result, type); + return parseResult(result, type, relationTypeIds); }; diff --git a/packages/hypergraph/src/entity/search-many-public.ts b/packages/hypergraph/src/entity/search-many-public.ts index 781ce573..8c6c08fd 100644 --- a/packages/hypergraph/src/entity/search-many-public.ts +++ b/packages/hypergraph/src/entity/search-many-public.ts @@ -3,8 +3,10 @@ import { Constants, type Entity, Utils } from '@graphprotocol/hypergraph'; import * as Option from 'effect/Option'; import type * as Schema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; -import { gql, request } from 'graphql-request'; -import { parseResult } from './find-many-public.js'; +import { request } from 'graphql-request'; +import type { RelationTypeIdInfo } from '../utils/get-relation-type-ids.js'; +import { buildRelationsSelection } from '../utils/relation-query-helpers.js'; +import { type EntityQueryResult, parseResult } from './find-many-public.js'; export type SearchManyPublicParams = { query: string; @@ -16,89 +18,12 @@ export type SearchManyPublicParams = { offset?: number | undefined; }; -const searchQueryDocumentLevel0 = gql` -query search($query: String!, $spaceId: UUID!, $typeIds: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int) { - search( - query: $query - filter: { and: [{ - typeIds: {in: $typeIds}, - spaceIds: {in: [$spaceId]}, - }, $filter]} - spaceId: $spaceId - first: $first - offset: $offset - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } -} -`; - -const searchQueryDocumentLevel1 = gql` -query search($query: String!, $spaceId: UUID!, $typeIds: [UUID!]!, $relationTypeIdsLevel1: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int) { - search( - query: $query - filter: { and: [{ - typeIds: {in: $typeIds}, - spaceIds: {in: [$spaceId]}, - }, $filter]} - spaceId: $spaceId - first: $first - offset: $offset - ) { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - typeId - } - } -} -`; +const buildSearchQuery = (relationInfoLevel1: RelationTypeIdInfo[]) => { + const relationsSelection = buildRelationsSelection(relationInfoLevel1); -const searchQueryDocumentLevel2 = gql` -query search($query: String!, $spaceId: UUID!, $typeIds: [UUID!]!, $relationTypeIdsLevel1: [UUID!]!, $relationTypeIdsLevel2: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int) { - search( + return ` +query searchEntities($query: String!, $spaceId: UUID!, $typeIds: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int) { + entities: search( query: $query filter: { and: [{ typeIds: {in: $typeIds}, @@ -118,131 +43,9 @@ query search($query: String!, $spaceId: UUID!, $typeIds: [UUID!]!, $relationType time point } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel1}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - relationsList( - filter: {spaceId: {is: $spaceId}, typeId:{ in: $relationTypeIdsLevel2}}, - ) { - id - entity { - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - toEntity { - id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point - } - } - typeId - } - } - typeId - } + ${relationsSelection} } -} -`; - -type SearchQueryResult = { - search: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - relationsList: { - id: string; - entity: { - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - toEntity: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - relationsList: { - id: string; - entity: { - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - toEntity: { - id: string; - name: string; - valuesList: { - propertyId: string; - string: string; - boolean: boolean; - number: number; - time: string; - point: string; - }[]; - }; - typeId: string; - }[]; - }; - typeId: string; - }[]; - }[]; +}`; }; export const searchManyPublic = async ( @@ -258,28 +61,19 @@ export const searchManyPublic = async ( Option.getOrElse(() => []), ); - let queryDocument = searchQueryDocumentLevel0; - if (relationTypeIds.level1.length > 0) { - queryDocument = searchQueryDocumentLevel1; - } - if (relationTypeIds.level2.length > 0) { - queryDocument = searchQueryDocumentLevel2; - } + const queryDocument = buildSearchQuery(relationTypeIds); const filterParams = filter ? Utils.translateFilterToGraphql(filter, type) : {}; - const result = await request(`${Graph.TESTNET_API_ORIGIN}/graphql`, queryDocument, { + const result = await request(`${Graph.TESTNET_API_ORIGIN}/graphql`, queryDocument, { spaceId: space, typeIds, query, - relationTypeIdsLevel1: relationTypeIds.level1, - relationTypeIdsLevel2: relationTypeIds.level2, first, filter: filterParams, offset, }); - // @ts-expect-error TODO: fix this - const { data, invalidEntities } = parseResult({ entities: result.search }, type); + const { data, invalidEntities } = parseResult(result, type, relationTypeIds); return { data, invalidEntities }; }; diff --git a/packages/hypergraph/src/type/type.ts b/packages/hypergraph/src/type/type.ts index bf767c44..c6651ade 100644 --- a/packages/hypergraph/src/type/type.ts +++ b/packages/hypergraph/src/type/type.ts @@ -4,6 +4,7 @@ import * as SchemaAST from 'effect/SchemaAST'; import { PropertyIdSymbol, PropertyTypeSymbol, + RelationBacklinkSymbol, RelationPropertiesSymbol, RelationSchemaSymbol, RelationSymbol, @@ -20,10 +21,21 @@ type SchemaBuilder = (propertyId: any) => SchemaBuilderReturn; type RelationPropertiesDefinition = Record; -type RelationOptions = { +type RelationOptionsBase = { + backlink?: boolean; +}; + +type RelationOptions = RelationOptionsBase & { properties: RP; }; +const hasRelationProperties = ( + options: RelationOptionsBase | RelationOptions | undefined, +): options is RelationOptions => { + if (!options) return false; + return 'properties' in options; +}; + type RelationMappingInput = RP extends RelationPropertiesDefinition ? { propertyId: string; @@ -98,6 +110,7 @@ export const Point = (propertyId: string) => export function Relation( schema: S, + options?: RelationOptionsBase, ): (mapping: RelationMappingInput) => Schema.Schema< readonly (Schema.Schema.Type & { readonly id: string; readonly _relation: RelationMetadata })[], readonly (Schema.Schema.Encoded & { @@ -120,7 +133,7 @@ export function Relation< S extends Schema.Schema.AnyNoContext, RP extends RelationPropertiesDefinition | undefined = undefined, // biome-ignore lint/suspicious/noExplicitAny: implementation signature for overloads must use any ->(schema: S, options?: RP extends RelationPropertiesDefinition ? RelationOptions : undefined): any { +>(schema: S, options?: RP extends RelationPropertiesDefinition ? RelationOptions : RelationOptionsBase): any { return (mapping: RelationMappingInput) => { const { propertyId, relationPropertyIds } = typeof mapping === 'string' @@ -135,8 +148,15 @@ export function Relation< const relationEntityPropertiesSchemas: Record = {}; - if (options?.properties) { - for (const [key, schemaType] of Object.entries(options.properties)) { + const normalizedOptions = options as + | RelationOptionsBase + | RelationOptions + | undefined; + + const relationProperties = hasRelationProperties(normalizedOptions) ? normalizedOptions.properties : undefined; + + if (relationProperties) { + for (const [key, schemaType] of Object.entries(relationProperties)) { const propertyMapping = relationPropertyIds?.[key]; relationEntityPropertiesSchemas[key] = schemaType(propertyMapping); } @@ -169,12 +189,15 @@ export function Relation< never >; + const isBacklinkRelation = !!normalizedOptions?.backlink; + return Schema.Array(schemaWithId).pipe( Schema.annotations({ [PropertyIdSymbol]: propertyId, [RelationSchemaSymbol]: schema, [RelationSymbol]: true, [PropertyTypeSymbol]: 'relation', + [RelationBacklinkSymbol]: isBacklinkRelation, }), ) as Schema.Schema< readonly (Schema.Schema.Type & { readonly id: string; readonly _relation: RelationMetadata })[], @@ -184,6 +207,17 @@ export function Relation< }; } +export function Backlink< + S extends Schema.Schema.AnyNoContext, + RP extends RelationPropertiesDefinition | undefined = undefined, +>(schema: S, options?: RP extends RelationPropertiesDefinition ? RelationOptions : RelationOptionsBase) { + const normalizedOptions = { + ...(options ?? {}), + backlink: true, + } as RP extends RelationPropertiesDefinition ? RelationOptions : RelationOptionsBase; + return Relation(schema, normalizedOptions); +} + export const optional = (schemaFn: (propertyId: string) => S) => (propertyId: string) => { diff --git a/packages/hypergraph/src/utils/convert-relations.ts b/packages/hypergraph/src/utils/convert-relations.ts index c3146431..09cabf19 100644 --- a/packages/hypergraph/src/utils/convert-relations.ts +++ b/packages/hypergraph/src/utils/convert-relations.ts @@ -3,6 +3,8 @@ import * as Option from 'effect/Option'; import type * as Schema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; import { convertPropertyValue } from './convert-property-value.js'; +import type { RelationTypeIdInfo } from './get-relation-type-ids.js'; +import { getRelationAlias } from './relation-query-helpers.js'; type ValueList = { propertyId: string; @@ -13,6 +15,19 @@ type ValueList = { point: string; }[]; +type RelationsListItem = { + id: string; + toEntity: RecursiveQueryEntity; + entity: { + valuesList?: ValueList; + }; + typeId: string; +}; + +type RelationsListWithTotalCount = { + totalCount: number; +} & RelationsListItem[]; + // A recursive representation of the entity structure returned by the public GraphQL // endpoint. `values` and `relations` are optional because the nested `to` selections // get slimmer the deeper we traverse in the query. This type intentionally mirrors @@ -21,14 +36,10 @@ type RecursiveQueryEntity = { id: string; name: string; valuesList?: ValueList; - relationsList?: { - id: string; - toEntity: RecursiveQueryEntity; - entity: { - valuesList?: ValueList; - }; - typeId: string; - }[]; + relationsList?: RelationsListItem[]; +} & { + // For aliased relationsList_* fields with proper typing + [K: `relationsList_${string}`]: RelationsListWithTotalCount; }; type RawEntityValue = string | boolean | number | unknown[] | Date | { id: string }; @@ -38,6 +49,7 @@ type NestedRawEntity = RawEntity & { _relation: { id: string } & Record( queryEntity: RecursiveQueryEntity, ast: SchemaAST.TypeLiteral, + relationInfo: RelationTypeIdInfo[] = [], ) => { const rawEntity: RawEntity = {}; @@ -64,9 +76,24 @@ export const convertRelations = <_S extends Schema.Schema.AnyNoContext>( continue; } - const allRelationsWithTheCorrectPropertyTypeId = queryEntity.relationsList?.filter( - (a) => a.typeId === result.value, + const relationMetadata = relationInfo.find( + (info) => info.typeId === result.value && info.propertyName === String(prop.name), ); + + // Get relations from aliased field if we have relationInfo for this property, otherwise fallback to old behavior + let allRelationsWithTheCorrectPropertyTypeId: RecursiveQueryEntity['relationsList']; + + if (relationMetadata) { + // Use the aliased field to get relations for this specific type ID + const alias = getRelationAlias(result.value); + allRelationsWithTheCorrectPropertyTypeId = queryEntity[ + alias as keyof RecursiveQueryEntity + ] as RecursiveQueryEntity['relationsList']; + } else { + // Fallback to old behavior (filtering from a single relationsList) + allRelationsWithTheCorrectPropertyTypeId = queryEntity.relationsList?.filter((a) => a.typeId === result.value); + } + if (allRelationsWithTheCorrectPropertyTypeId) { for (const relationEntry of allRelationsWithTheCorrectPropertyTypeId) { let nestedRawEntity: NestedRawEntity = { @@ -76,7 +103,11 @@ export const convertRelations = <_S extends Schema.Schema.AnyNoContext>( }, }; - const relationsForRawNestedEntity = convertRelations(relationEntry.toEntity, relationTransformation); + const relationsForRawNestedEntity = convertRelations( + relationEntry.toEntity, + relationTransformation, + relationMetadata?.children ?? [], + ); nestedRawEntity = { ...nestedRawEntity, diff --git a/packages/hypergraph/src/utils/get-relation-type-ids.ts b/packages/hypergraph/src/utils/get-relation-type-ids.ts index 75ca0c2a..737295ae 100644 --- a/packages/hypergraph/src/utils/get-relation-type-ids.ts +++ b/packages/hypergraph/src/utils/get-relation-type-ids.ts @@ -3,14 +3,22 @@ import * as Option from 'effect/Option'; import type * as Schema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; +export type RelationListField = 'relationsList' | 'backlinksList'; + +export type RelationTypeIdInfo = { + typeId: string; + propertyName: string; + listField: RelationListField; + children?: RelationTypeIdInfo[]; +}; + export const getRelationTypeIds = ( type: Schema.Schema.AnyNoContext, include: | { [K in keyof Schema.Schema.Type]?: Record> } | undefined, ) => { - const relationTypeIdsLevel1: string[] = []; - const relationTypeIdsLevel2: string[] = []; + const relationInfo: RelationTypeIdInfo[] = []; const ast = type.ast as SchemaAST.TypeLiteral; @@ -19,18 +27,31 @@ export const getRelationTypeIds = ( const result = SchemaAST.getAnnotation(Constants.PropertyIdSymbol)(prop.type); if (Option.isSome(result) && include?.[String(prop.name)]) { - relationTypeIdsLevel1.push(result.value); + const isBacklink = SchemaAST.getAnnotation(Constants.RelationBacklinkSymbol)(prop.type).pipe( + Option.getOrElse(() => false), + ); + const listField: RelationListField = isBacklink ? 'backlinksList' : 'relationsList'; + const level1Info: RelationTypeIdInfo = { + typeId: result.value, + propertyName: String(prop.name), + listField, + }; + const nestedRelations: RelationTypeIdInfo[] = []; + if (!SchemaAST.isTupleType(prop.type)) { + relationInfo.push(level1Info); continue; } const relationTransformation = prop.type.rest[0]?.type; if (!relationTransformation || !SchemaAST.isTypeLiteral(relationTransformation)) { + relationInfo.push(level1Info); continue; } const typeIds2: string[] = SchemaAST.getAnnotation(Constants.TypeIdsSymbol)( relationTransformation, ).pipe(Option.getOrElse(() => [])); if (typeIds2.length === 0) { + relationInfo.push(level1Info); continue; } for (const nestedProp of relationTransformation.propertySignatures) { @@ -38,14 +59,24 @@ export const getRelationTypeIds = ( const nestedResult = SchemaAST.getAnnotation(Constants.PropertyIdSymbol)(nestedProp.type); if (Option.isSome(nestedResult) && include?.[String(prop.name)]?.[String(nestedProp.name)]) { - relationTypeIdsLevel2.push(nestedResult.value); + const nestedIsBacklink = SchemaAST.getAnnotation(Constants.RelationBacklinkSymbol)( + nestedProp.type, + ).pipe(Option.getOrElse(() => false)); + const nestedListField: RelationListField = nestedIsBacklink ? 'backlinksList' : 'relationsList'; + const nestedInfo: RelationTypeIdInfo = { + typeId: nestedResult.value, + propertyName: String(nestedProp.name), + listField: nestedListField, + }; + nestedRelations.push(nestedInfo); } } + if (nestedRelations.length > 0) { + level1Info.children = nestedRelations; + } + relationInfo.push(level1Info); } } - return { - level1: relationTypeIdsLevel1, - level2: relationTypeIdsLevel2, - }; + return relationInfo; }; diff --git a/packages/hypergraph/src/utils/relation-query-helpers.ts b/packages/hypergraph/src/utils/relation-query-helpers.ts new file mode 100644 index 00000000..1508ee25 --- /dev/null +++ b/packages/hypergraph/src/utils/relation-query-helpers.ts @@ -0,0 +1,63 @@ +import type { RelationTypeIdInfo } from './get-relation-type-ids.js'; + +export const getRelationAlias = (typeId: string) => `relationsList_${typeId.replace(/-/g, '_')}`; + +const buildRelationsListFragment = (info: RelationTypeIdInfo, level: 1 | 2) => { + const alias = getRelationAlias(info.typeId); + const nestedPlaceholder = level === 1 ? '__LEVEL2_RELATIONS__' : ''; + const listField = info.listField ?? 'relationsList'; + const toEntityField = listField === 'backlinksList' ? 'fromEntity' : 'toEntity'; + const toEntitySelectionHeader = toEntityField === 'toEntity' ? 'toEntity' : `toEntity: ${toEntityField}`; + + return ` + ${alias}: ${listField}( + filter: {spaceId: {is: $spaceId}, typeId: {is: "${info.typeId}"}}, + ) { + id + entity { + valuesList(filter: {spaceId: {is: $spaceId}}) { + propertyId + string + boolean + number + time + point + } + } + ${toEntitySelectionHeader} { + id + name + valuesList(filter: {spaceId: {is: $spaceId}}) { + propertyId + string + boolean + number + time + point + } + ${nestedPlaceholder} + } + typeId + }`; +}; + +const buildLevel2RelationsFragment = (relationInfoLevel2: RelationTypeIdInfo[]) => { + if (relationInfoLevel2.length === 0) return ''; + + return relationInfoLevel2.map((info) => buildRelationsListFragment(info, 2)).join('\n'); +}; + +const buildLevel1RelationsFragment = (relationInfoLevel1: RelationTypeIdInfo[]) => { + if (relationInfoLevel1.length === 0) return ''; + + return relationInfoLevel1 + .map((info) => { + const level2Fragment = buildLevel2RelationsFragment(info.children ?? []); + const fragment = buildRelationsListFragment(info, 1); + return fragment.replace('__LEVEL2_RELATIONS__', level2Fragment); + }) + .join('\n'); +}; + +export const buildRelationsSelection = (relationInfoLevel1: RelationTypeIdInfo[]) => + buildLevel1RelationsFragment(relationInfoLevel1);