diff --git a/.changeset/four-papers-thank.md b/.changeset/four-papers-thank.md new file mode 100644 index 00000000..119f9a36 --- /dev/null +++ b/.changeset/four-papers-thank.md @@ -0,0 +1,35 @@ +--- +"@graphprotocol/hypergraph-react": patch +"@graphprotocol/hypergraph": patch +--- + +add fetching of totalCount on relations + +For: + +```ts +export const Podcast = Entity.Schema( + { + name: Type.String, + hosts: Type.Relation(Person), + }, + { + types: [Id('4c81561d-1f95-4131-9cdd-dd20ab831ba2')], + properties: { + name: Id('a126ca53-0c8e-48d5-b888-82c734c38935'), + hosts: Id('c72d9abb-bca8-4e86-b7e8-b71e91d2b37e'), + }, + }, +); +``` + +you can now use: + +```ts +useEntities(Podcast, { + mode: 'public', + include: { + hostsTotalCount: true, + }, +}); +``` \ 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 eb8081bb..8c68c873 100644 --- a/apps/events/src/routes/podcasts.lazy.tsx +++ b/apps/events/src/routes/podcasts.lazy.tsx @@ -44,8 +44,11 @@ function RouteComponent() { listenOn: {}, hosts: { avatar: {}, + avatarTotalCount: true, }, + hostsTotalCount: true, episodes: {}, + episodesTotalCount: true, }, orderBy: { property: 'dateFounded', direction: 'asc' }, backlinksTotalCountsTypeId1: '972d201a-d780-4568-9e01-543f67b26bee', @@ -67,6 +70,8 @@ function RouteComponent() {
--{listenOn._relation.website}
))} +
Total hosts: {podcast.hostsTotalCount ?? 0}
+
Total episodes: {podcast.episodesTotalCount ?? 0}
))} diff --git a/packages/hypergraph-react/src/hooks/use-entities.tsx b/packages/hypergraph-react/src/hooks/use-entities.tsx index 79ffbb7a..0d7673f3 100644 --- a/packages/hypergraph-react/src/hooks/use-entities.tsx +++ b/packages/hypergraph-react/src/hooks/use-entities.tsx @@ -6,8 +6,8 @@ import { useEntitiesPublic } from '../internal/use-entities-public.js'; type UseEntitiesParams = { mode: 'public' | 'private'; filter?: Entity.EntityFilter> | undefined; - // TODO: for multi-level nesting it should only allow the allowed properties instead of Record> - include?: { [K in keyof Schema.Schema.Type]?: Record> } | undefined; + // TODO: restrict multi-level nesting to the actual relation keys + include?: Entity.EntityInclude | undefined; space?: string | undefined; first?: number | undefined; offset?: number | undefined; diff --git a/packages/hypergraph-react/src/hooks/use-entity.tsx b/packages/hypergraph-react/src/hooks/use-entity.tsx index ef00a285..2f97f97d 100644 --- a/packages/hypergraph-react/src/hooks/use-entity.tsx +++ b/packages/hypergraph-react/src/hooks/use-entity.tsx @@ -1,4 +1,4 @@ -import type { Id } from '@graphprotocol/hypergraph'; +import type { Entity, Id } from '@graphprotocol/hypergraph'; import type * as Schema from 'effect/Schema'; import { useEntityPrivate } from '../internal/use-entity-private.js'; import { useEntityPublic } from '../internal/use-entity-public.js'; @@ -9,7 +9,7 @@ export function useEntity( id: string | Id; space?: string; mode: 'private' | 'public'; - include?: { [K in keyof Schema.Schema.Type]?: Record> } | undefined; + include?: Entity.EntityInclude | undefined; }, ) { const resultPublic = useEntityPublic(type, { ...params, enabled: params.mode === 'public' }); diff --git a/packages/hypergraph-react/src/internal/types.ts b/packages/hypergraph-react/src/internal/types.ts index 7760b684..f0bfdde3 100644 --- a/packages/hypergraph-react/src/internal/types.ts +++ b/packages/hypergraph-react/src/internal/types.ts @@ -4,8 +4,8 @@ import type * as Schema from 'effect/Schema'; export type QueryPublicParams = { enabled?: boolean | undefined; filter?: Entity.EntityFilter> | undefined; - // TODO: for multi-level nesting it should only allow the allowed properties instead of Record> - include?: { [K in keyof Schema.Schema.Type]?: Record> } | undefined; + // TODO: restrict multi-level nesting to the actual relation keys + include?: Entity.EntityInclude | undefined; space?: string | undefined; first?: number | undefined; offset?: number | undefined; diff --git a/packages/hypergraph-react/src/internal/use-entities-private.tsx b/packages/hypergraph-react/src/internal/use-entities-private.tsx index 85bf9801..023fa3a7 100644 --- a/packages/hypergraph-react/src/internal/use-entities-private.tsx +++ b/packages/hypergraph-react/src/internal/use-entities-private.tsx @@ -8,7 +8,7 @@ type QueryParams = { space?: string | undefined; enabled: boolean; filter?: Entity.EntityFilter> | undefined; - include?: { [K in keyof Schema.Schema.Type]?: Record> } | undefined; + include?: Entity.EntityInclude | undefined; }; export function useEntitiesPrivate(type: S, params?: QueryParams) { diff --git a/packages/hypergraph-react/src/internal/use-entity-private.tsx b/packages/hypergraph-react/src/internal/use-entity-private.tsx index ee61e475..6ea1f038 100644 --- a/packages/hypergraph-react/src/internal/use-entity-private.tsx +++ b/packages/hypergraph-react/src/internal/use-entity-private.tsx @@ -10,7 +10,7 @@ export function useEntityPrivate( id: string | Id; enabled?: boolean; space?: string; - include?: { [K in keyof Schema.Schema.Type]?: Record> } | undefined; + include?: Entity.EntityInclude | undefined; }, ) { const { space: spaceFromContext } = useHypergraphSpaceInternal(); diff --git a/packages/hypergraph-react/src/internal/use-entity-public.tsx b/packages/hypergraph-react/src/internal/use-entity-public.tsx index f723f368..d78f4a52 100644 --- a/packages/hypergraph-react/src/internal/use-entity-public.tsx +++ b/packages/hypergraph-react/src/internal/use-entity-public.tsx @@ -9,8 +9,8 @@ type UseEntityPublicParams = { id: string; enabled?: boolean; space?: string; - // TODO: for multi-level nesting it should only allow the allowed properties instead of Record> - include?: { [K in keyof Schema.Schema.Type]?: Record> } | undefined; + // TODO: restrict multi-level nesting to the actual relation keys + include?: Entity.EntityInclude | undefined; }; export const useEntityPublic = (type: S, params: UseEntityPublicParams) => { diff --git a/packages/hypergraph/src/entity/decodedEntitiesCache.ts b/packages/hypergraph/src/entity/decodedEntitiesCache.ts index ba62d7fd..31bb09c0 100644 --- a/packages/hypergraph/src/entity/decodedEntitiesCache.ts +++ b/packages/hypergraph/src/entity/decodedEntitiesCache.ts @@ -1,11 +1,11 @@ import type * as Schema from 'effect/Schema'; -import type { Entity } from './types.js'; +import type { Entity, EntityInclude } from './types.js'; export type QueryEntry = { data: Array>; // holds the decoded entities of this query and must be a stable reference and use the same reference for the `entities` array listeners: Array<() => void>; // listeners to this query isInvalidated: boolean; - include: { [K in keyof Schema.Schema.Type]?: Record> }; + include: EntityInclude; }; export type DecodedEntitiesCacheEntry = { diff --git a/packages/hypergraph/src/entity/find-many-private.ts b/packages/hypergraph/src/entity/find-many-private.ts index 38088bac..ba25a959 100644 --- a/packages/hypergraph/src/entity/find-many-private.ts +++ b/packages/hypergraph/src/entity/find-many-private.ts @@ -16,6 +16,7 @@ import type { Entity, EntityFieldFilter, EntityFilter, + EntityInclude, EntityNumberFilter, EntityStringFilter, } from './types.js'; @@ -77,7 +78,7 @@ const subscribeToDocumentChanges = (handle: DocHandle) => { const cacheEntry = decodedEntitiesCache.get(typeId); if (!cacheEntry) continue; - let includeFromAllQueries = {}; + let includeFromAllQueries: EntityInclude = {}; for (const [, query] of cacheEntry.queries) { includeFromAllQueries = deepMerge(includeFromAllQueries, query.include); } @@ -244,7 +245,7 @@ export function findManyPrivate( handle: DocHandle, type: S, filter: EntityFilter> | undefined, - include: { [K in keyof Schema.Schema.Type]?: Record> } | undefined, + include: EntityInclude | undefined, ): { entities: Readonly>>; corruptEntityIds: Readonly> } { const typeId = SchemaAST.getAnnotation(TypeIdsSymbol)(type.ast as SchemaAST.TypeLiteral).pipe( Option.getOrElse(() => []), @@ -413,7 +414,7 @@ export function subscribeToFindMany( handle: DocHandle, type: S, filter: { [K in keyof Schema.Schema.Type]?: EntityFieldFilter[K]> } | undefined, - include: { [K in keyof Schema.Schema.Type]?: Record> } | undefined, + include: EntityInclude | undefined, ): FindManySubscription { const queryKey = filter ? canonicalize(filter) : 'all'; const typeIds = SchemaAST.getAnnotation(TypeIdsSymbol)(type.ast as SchemaAST.TypeLiteral).pipe( diff --git a/packages/hypergraph/src/entity/find-many-public.ts b/packages/hypergraph/src/entity/find-many-public.ts index 87fcb016..916ea2e5 100644 --- a/packages/hypergraph/src/entity/find-many-public.ts +++ b/packages/hypergraph/src/entity/find-many-public.ts @@ -5,13 +5,14 @@ import * as Option from 'effect/Option'; import * as Schema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; 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'; export type FindManyPublicParams = { filter?: Entity.EntityFilter> | undefined; - // TODO: for multi-level nesting it should only allow the allowed properties instead of Record> - include?: { [K in keyof Schema.Schema.Type]?: Record> } | undefined; + // TODO: restrict multi-level nesting to the actual relation keys + include?: Entity.EntityInclude | undefined; space: string; first?: number | undefined; offset?: number | undefined; @@ -68,26 +69,6 @@ type ValuesList = { 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; -}; - -type RelationsListWithTotalCount = { - totalCount: number; -} & RelationsListItem[]; - export type EntityQueryResult = { entities: ({ id: string; @@ -97,8 +78,8 @@ export type EntityQueryResult = { totalCount: number; } | null; } & { - // For aliased relationsList_* fields - provides proper typing with totalCount - [K: `relationsList_${string}`]: RelationsListWithTotalCount; + // For aliased relations_* fields - provides proper typing with totalCount + [K: `relations_${string}`]: RelationsListWithNodes | undefined; })[]; }; @@ -146,6 +127,8 @@ export const parseResult = ( ...Utils.convertRelations(queryEntity, ast, relationInfoLevel1), }; + console.log('rawEntity', rawEntity); + const decodeResult = decode({ ...rawEntity, __deleted: false, diff --git a/packages/hypergraph/src/entity/find-one-public.ts b/packages/hypergraph/src/entity/find-one-public.ts index b37934c0..2dc26c08 100644 --- a/packages/hypergraph/src/entity/find-one-public.ts +++ b/packages/hypergraph/src/entity/find-one-public.ts @@ -16,8 +16,8 @@ type EntityQueryResult = { export type FindOnePublicParams = { id: string; space: string; - // TODO: for multi-level nesting it should only allow the allowed properties instead of Record> - include?: { [K in keyof Schema.Schema.Type]?: Record> } | undefined; + // TODO: restrict multi-level nesting to the actual relation keys + include?: Entity.EntityInclude | undefined; }; const buildEntityQuery = (relationInfoLevel1: RelationTypeIdInfo[]) => { diff --git a/packages/hypergraph/src/entity/findOne.ts b/packages/hypergraph/src/entity/findOne.ts index b256a4b9..9ec5302e 100644 --- a/packages/hypergraph/src/entity/findOne.ts +++ b/packages/hypergraph/src/entity/findOne.ts @@ -6,12 +6,12 @@ import { TypeIdsSymbol } from '../constants.js'; import { getEntityRelations } from './getEntityRelations.js'; import { hasValidTypesProperty } from './hasValidTypesProperty.js'; import { decodeFromGrc20Json } from './schema.js'; -import type { DocumentContent, Entity } from './types.js'; +import type { DocumentContent, Entity, EntityInclude } from './types.js'; export const findOne = ( handle: DocHandle, type: S, - include: { [K in keyof Schema.Schema.Type]?: Record> } | undefined = undefined, + include: EntityInclude | undefined = undefined, ) => { return (id: string): Entity | undefined => { // TODO: Instead of this insane filtering logic, we should be keeping track of the entities in diff --git a/packages/hypergraph/src/entity/getEntityRelations.ts b/packages/hypergraph/src/entity/getEntityRelations.ts index 412a7949..62ea95e4 100644 --- a/packages/hypergraph/src/entity/getEntityRelations.ts +++ b/packages/hypergraph/src/entity/getEntityRelations.ts @@ -5,28 +5,33 @@ import { PropertyIdSymbol, RelationSchemaSymbol } from '../constants.js'; import { isRelation } from '../utils/isRelation.js'; import { hasValidTypesProperty } from './hasValidTypesProperty.js'; import { decodeFromGrc20Json } from './schema.js'; -import type { DocumentContent, Entity } from './types.js'; +import type { DocumentContent, Entity, EntityInclude } from './types.js'; export const getEntityRelations = ( entityId: string, type: S, doc: DocumentContent, - include: { [K in keyof Schema.Schema.Type]?: Record> } | undefined, + include: EntityInclude | undefined, ) => { - const relations: Record> = {}; + const relations: Record = {}; const ast = type.ast as SchemaAST.TypeLiteral; for (const prop of ast.propertySignatures) { if (!isRelation(prop.type)) continue; const fieldName = String(prop.name); - if (!include?.[fieldName]) { + const includeNodes = Boolean(include?.[fieldName]); + const includeTotalCount = Boolean(include?.[`${fieldName}TotalCount`]); + + if (!includeNodes && !includeTotalCount) { relations[fieldName] = []; continue; } const relationEntities: Array> = []; + let relationCount = 0; + for (const [relationId, relation] of Object.entries(doc.relations ?? {})) { const result = SchemaAST.getAnnotation(PropertyIdSymbol)(prop.type); const schema = SchemaAST.getAnnotation(RelationSchemaSymbol)(prop.type); @@ -38,10 +43,16 @@ export const getEntityRelations = ( const decodedRelationEntity = { ...decodeFromGrc20Json(schema.value, { ...relationEntity, id: relation.to }) }; if (!hasValidTypesProperty(relationEntity)) continue; - relationEntities.push({ ...decodedRelationEntity, id: relation.to, _relation: { id: relationId } }); + relationCount += 1; + if (includeNodes) { + relationEntities.push({ ...decodedRelationEntity, id: relation.to, _relation: { id: relationId } }); + } } } - relations[String(prop.name)] = relationEntities; + relations[String(prop.name)] = includeNodes ? relationEntities : []; + if (includeTotalCount) { + relations[`${fieldName}TotalCount`] = relationCount; + } } return relations; diff --git a/packages/hypergraph/src/entity/schema.ts b/packages/hypergraph/src/entity/schema.ts index 796ac0e8..42812503 100644 --- a/packages/hypergraph/src/entity/schema.ts +++ b/packages/hypergraph/src/entity/schema.ts @@ -3,6 +3,8 @@ import * as Option from 'effect/Option'; import * as EffectSchema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; import { PropertyIdSymbol, TypeIdsSymbol } from '../constants.js'; +import type { AnyRelationSchema } from '../type/type.js'; +import { relationSchemaBrand } from '../type/type.js'; /** * Entity function for creating schemas with a nicer API. @@ -19,12 +21,25 @@ import { PropertyIdSymbol, TypeIdsSymbol } from '../constants.js'; * }); * ``` */ +type SchemaBuilder = ( + // biome-ignore lint/suspicious/noExplicitAny: property builders accept varied property ids + propertyId: any, + // biome-ignore lint/suspicious/noExplicitAny: property builders accept varied property ids +) => EffectSchema.Schema | EffectSchema.PropertySignature; + +type RelationKeys> = { + [K in keyof T]: ReturnType extends AnyRelationSchema ? K : never; +}[keyof T]; + +const relationTotalCountSchema = EffectSchema.optional(EffectSchema.Number); +type RelationTotalCountSchema = typeof relationTotalCountSchema; + +type RelationTotalCountFields> = { + [K in RelationKeys as `${Extract}TotalCount`]: RelationTotalCountSchema; +}; + export function Schema< - const T extends Record< - string, - // biome-ignore lint/suspicious/noExplicitAny: any - (propertyId: any) => EffectSchema.Schema | EffectSchema.PropertySignature - >, + const T extends Record, const P extends { [K in keyof T]: Parameters[0]; }, @@ -34,18 +49,26 @@ export function Schema< types: Array; properties: P; }, -): EffectSchema.Struct<{ - [K in keyof T]: ReturnType & { id: string }; -}> { +): EffectSchema.Struct< + { + [K in keyof T]: ReturnType & { id: string }; + } & RelationTotalCountFields +> { const properties: Record< string, - // biome-ignore lint/suspicious/noExplicitAny: any - EffectSchema.Schema | EffectSchema.PropertySignature + // biome-ignore lint/suspicious/noExplicitAny: schema map intentionally loose + EffectSchema.Schema | EffectSchema.PropertySignature > = {}; for (const [key, schemaType] of Object.entries(schemaTypes)) { const propertyMapping = mapping.properties[key as keyof P]; - properties[key] = schemaType(propertyMapping); + const builtSchema = schemaType(propertyMapping); + properties[key] = builtSchema; + + if (relationSchemaBrand in (builtSchema as object)) { + const totalCountKey = `${key}TotalCount`; + properties[totalCountKey] = relationTotalCountSchema; + } } // biome-ignore lint/suspicious/noExplicitAny: any diff --git a/packages/hypergraph/src/entity/search-many-public.ts b/packages/hypergraph/src/entity/search-many-public.ts index 8c6c08fd..5d45a8e2 100644 --- a/packages/hypergraph/src/entity/search-many-public.ts +++ b/packages/hypergraph/src/entity/search-many-public.ts @@ -11,8 +11,8 @@ import { type EntityQueryResult, parseResult } from './find-many-public.js'; export type SearchManyPublicParams = { query: string; filter?: Entity.EntityFilter> | undefined; - // TODO: for multi-level nesting it should only allow the allowed properties instead of Record> - include?: { [K in keyof Schema.Schema.Type]?: Record> } | undefined; + // TODO: restrict multi-level nesting to the actual relation keys + include?: Entity.EntityInclude | undefined; space: string | undefined; first?: number | undefined; offset?: number | undefined; diff --git a/packages/hypergraph/src/entity/types.ts b/packages/hypergraph/src/entity/types.ts index 1ed5cec3..28b0587c 100644 --- a/packages/hypergraph/src/entity/types.ts +++ b/packages/hypergraph/src/entity/types.ts @@ -1,5 +1,15 @@ import type * as Schema from 'effect/Schema'; +type SchemaKey = Extract, string>; + +export type RelationIncludeBranch = { + [key: string]: RelationIncludeBranch | boolean | undefined; +}; + +export type EntityInclude = Partial< + Record, RelationIncludeBranch | boolean> +>; + export type Entity = Schema.Schema.Type & { id: string; }; diff --git a/packages/hypergraph/src/type/type.ts b/packages/hypergraph/src/type/type.ts index c6651ade..ba230206 100644 --- a/packages/hypergraph/src/type/type.ts +++ b/packages/hypergraph/src/type/type.ts @@ -29,6 +29,16 @@ type RelationOptions = RelationOptionsB properties: RP; }; +export const relationSchemaBrand = '__hypergraphRelationSchema' as const; +type RelationSchemaMarker = { + readonly [relationSchemaBrand]: true; +}; + +export const relationBuilderBrand = '__hypergraphRelationBuilder' as const; +type RelationBuilderMarker = { + readonly [relationBuilderBrand]: true; +}; + const hasRelationProperties = ( options: RelationOptionsBase | RelationOptions | undefined, ): options is RelationOptions => { @@ -67,6 +77,32 @@ type RelationMetadataEncoded; +type RelationSchema< + S extends Schema.Schema.AnyNoContext, + RP extends RelationPropertiesDefinition | undefined = undefined, +> = Schema.Schema< + readonly (Schema.Schema.Type & { readonly id: string; readonly _relation: RelationMetadata })[], + readonly (Schema.Schema.Encoded & { readonly id: string; readonly _relation: RelationMetadataEncoded })[], + never +> & + RelationSchemaMarker; + +type RelationSchemaBuilder< + S extends Schema.Schema.AnyNoContext, + RP extends RelationPropertiesDefinition | undefined = undefined, +> = ((mapping: RelationMappingInput) => RelationSchema) & RelationBuilderMarker; + +export type AnyRelationSchema = + // biome-ignore lint/suspicious/noExplicitAny: relation schema branding requires broad typing + | (Schema.Schema & RelationSchemaMarker) + // biome-ignore lint/suspicious/noExplicitAny: relation schema branding requires broad typing + | (Schema.PropertySignature & RelationSchemaMarker); + +export type AnyRelationBuilder = RelationSchemaBuilder< + Schema.Schema.AnyNoContext, + RelationPropertiesDefinition | undefined +>; + /** * Creates a String schema with the specified GRC-20 property ID */ @@ -111,24 +147,11 @@ 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 & { - readonly id: string; - readonly _relation: RelationMetadataEncoded; - })[], - never ->; +): RelationSchemaBuilder; export function Relation( schema: S, options: RelationOptions, -): ( - mapping: RelationMappingInput, -) => Schema.Schema< - readonly (Schema.Schema.Type & { readonly id: string; readonly _relation: RelationMetadata })[], - readonly (Schema.Schema.Encoded & { readonly id: string; readonly _relation: RelationMetadataEncoded })[], - never ->; +): RelationSchemaBuilder; export function Relation< S extends Schema.Schema.AnyNoContext, RP extends RelationPropertiesDefinition | undefined = undefined, @@ -191,7 +214,7 @@ export function Relation< const isBacklinkRelation = !!normalizedOptions?.backlink; - return Schema.Array(schemaWithId).pipe( + const relationSchema = Schema.Array(schemaWithId).pipe( Schema.annotations({ [PropertyIdSymbol]: propertyId, [RelationSchemaSymbol]: schema, @@ -199,11 +222,15 @@ export function Relation< [PropertyTypeSymbol]: 'relation', [RelationBacklinkSymbol]: isBacklinkRelation, }), - ) as Schema.Schema< - readonly (Schema.Schema.Type & { readonly id: string; readonly _relation: RelationMetadata })[], - readonly (Schema.Schema.Encoded & { readonly id: string; readonly _relation: RelationMetadataEncoded })[], - never - >; + ); + + Object.defineProperty(relationSchema, relationSchemaBrand, { + value: true, + enumerable: false, + configurable: false, + }); + + return relationSchema as unknown as RelationSchema; }; } @@ -222,5 +249,13 @@ export const optional = (schemaFn: (propertyId: string) => S) => (propertyId: string) => { const innerSchema = schemaFn(propertyId); - return Schema.optional(innerSchema); + const optionalSchema = Schema.optional(innerSchema); + if (relationSchemaBrand in (innerSchema as object)) { + Object.defineProperty(optionalSchema, relationSchemaBrand, { + value: true, + enumerable: false, + configurable: false, + }); + } + return optionalSchema as typeof optionalSchema & RelationSchemaMarker; }; diff --git a/packages/hypergraph/src/utils/convert-relations.ts b/packages/hypergraph/src/utils/convert-relations.ts index 09cabf19..385ebadd 100644 --- a/packages/hypergraph/src/utils/convert-relations.ts +++ b/packages/hypergraph/src/utils/convert-relations.ts @@ -24,9 +24,10 @@ type RelationsListItem = { typeId: string; }; -type RelationsListWithTotalCount = { - totalCount: number; -} & RelationsListItem[]; +export type RelationsListWithNodes = { + nodes?: RelationsListItem[]; + totalCount?: number; +}; // A recursive representation of the entity structure returned by the public GraphQL // endpoint. `values` and `relations` are optional because the nested `to` selections @@ -36,16 +37,15 @@ type RecursiveQueryEntity = { id: string; name: string; valuesList?: ValueList; - relationsList?: RelationsListItem[]; + relations?: RelationsListItem[]; } & { - // For aliased relationsList_* fields with proper typing - [K: `relationsList_${string}`]: RelationsListWithTotalCount; + // For aliased relations_* fields with proper typing + [K: `relations_${string}`]: RelationsListWithNodes | undefined; }; type RawEntityValue = string | boolean | number | unknown[] | Date | { id: string }; type RawEntity = Record; type NestedRawEntity = RawEntity & { _relation: { id: string } & Record }; - export const convertRelations = <_S extends Schema.Schema.AnyNoContext>( queryEntity: RecursiveQueryEntity, ast: SchemaAST.TypeLiteral, @@ -81,17 +81,16 @@ export const convertRelations = <_S extends Schema.Schema.AnyNoContext>( ); // Get relations from aliased field if we have relationInfo for this property, otherwise fallback to old behavior - let allRelationsWithTheCorrectPropertyTypeId: RecursiveQueryEntity['relationsList']; + let allRelationsWithTheCorrectPropertyTypeId: RelationsListItem[] | undefined; + let relationConnection: RelationsListWithNodes | undefined; 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); + relationConnection = queryEntity[alias as keyof RecursiveQueryEntity] as RelationsListWithNodes | undefined; + if (relationMetadata.includeNodes) { + allRelationsWithTheCorrectPropertyTypeId = relationConnection?.nodes; + } } if (allRelationsWithTheCorrectPropertyTypeId) { @@ -161,6 +160,10 @@ export const convertRelations = <_S extends Schema.Schema.AnyNoContext>( rawEntity[String(prop.name)] = [...(rawEntity[String(prop.name)] as unknown[]), nestedRawEntity]; } } + + if (relationMetadata?.includeTotalCount) { + rawEntity[`${String(prop.name)}TotalCount`] = relationConnection?.totalCount ?? 0; + } } } diff --git a/packages/hypergraph/src/utils/get-relation-type-ids.ts b/packages/hypergraph/src/utils/get-relation-type-ids.ts index 737295ae..06487bee 100644 --- a/packages/hypergraph/src/utils/get-relation-type-ids.ts +++ b/packages/hypergraph/src/utils/get-relation-type-ids.ts @@ -2,21 +2,28 @@ import { Constants, Utils } from '@graphprotocol/hypergraph'; import * as Option from 'effect/Option'; import type * as Schema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; +import type { EntityInclude, RelationIncludeBranch } from '../entity/types.js'; -export type RelationListField = 'relationsList' | 'backlinksList'; +export type RelationListField = 'relations' | 'backlinks'; export type RelationTypeIdInfo = { typeId: string; propertyName: string; listField: RelationListField; + includeNodes: boolean; + includeTotalCount: boolean; children?: RelationTypeIdInfo[]; }; -export const getRelationTypeIds = ( - type: Schema.Schema.AnyNoContext, - include: - | { [K in keyof Schema.Schema.Type]?: Record> } - | undefined, +const isRelationIncludeBranch = (value: unknown): value is RelationIncludeBranch => + typeof value === 'object' && value !== null; + +const hasTotalCountFlag = (include: Record | undefined, key: string) => + Boolean(include?.[`${key}TotalCount`]); + +export const getRelationTypeIds = ( + type: S, + include: EntityInclude | undefined, ) => { const relationInfo: RelationTypeIdInfo[] = []; @@ -26,15 +33,26 @@ export const getRelationTypeIds = ( if (!Utils.isRelation(prop.type)) continue; const result = SchemaAST.getAnnotation(Constants.PropertyIdSymbol)(prop.type); - if (Option.isSome(result) && include?.[String(prop.name)]) { + if (Option.isSome(result)) { + const propertyName = String(prop.name); + const includeBranch = include?.[propertyName as keyof EntityInclude] as RelationIncludeBranch | undefined; + const includeNodes = isRelationIncludeBranch(includeBranch); + const includeTotalCount = hasTotalCountFlag(include as Record | undefined, propertyName); + + if (!includeNodes && !includeTotalCount) { + continue; + } + const isBacklink = SchemaAST.getAnnotation(Constants.RelationBacklinkSymbol)(prop.type).pipe( Option.getOrElse(() => false), ); - const listField: RelationListField = isBacklink ? 'backlinksList' : 'relationsList'; + const listField: RelationListField = isBacklink ? 'backlinks' : 'relations'; const level1Info: RelationTypeIdInfo = { typeId: result.value, - propertyName: String(prop.name), + propertyName, listField, + includeNodes, + includeTotalCount, }; const nestedRelations: RelationTypeIdInfo[] = []; @@ -54,21 +72,33 @@ export const getRelationTypeIds = ( relationInfo.push(level1Info); continue; } - for (const nestedProp of relationTransformation.propertySignatures) { - if (!Utils.isRelation(nestedProp.type)) continue; + if (includeNodes && includeBranch) { + for (const nestedProp of relationTransformation.propertySignatures) { + if (!Utils.isRelation(nestedProp.type)) continue; + + const nestedResult = SchemaAST.getAnnotation(Constants.PropertyIdSymbol)(nestedProp.type); + const nestedPropertyName = String(nestedProp.name); + const nestedIncludeBranch = includeBranch?.[nestedPropertyName]; + const nestedIncludeNodes = isRelationIncludeBranch(nestedIncludeBranch); + const nestedIncludeTotalCount = hasTotalCountFlag( + includeBranch as Record | undefined, + nestedPropertyName, + ); - const nestedResult = SchemaAST.getAnnotation(Constants.PropertyIdSymbol)(nestedProp.type); - if (Option.isSome(nestedResult) && include?.[String(prop.name)]?.[String(nestedProp.name)]) { - 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 (Option.isSome(nestedResult) && (nestedIncludeNodes || nestedIncludeTotalCount)) { + const nestedIsBacklink = SchemaAST.getAnnotation(Constants.RelationBacklinkSymbol)( + nestedProp.type, + ).pipe(Option.getOrElse(() => false)); + const nestedListField: RelationListField = nestedIsBacklink ? 'backlinks' : 'relations'; + const nestedInfo: RelationTypeIdInfo = { + typeId: nestedResult.value, + propertyName: nestedPropertyName, + listField: nestedListField, + includeNodes: nestedIncludeNodes, + includeTotalCount: nestedIncludeTotalCount, + }; + nestedRelations.push(nestedInfo); + } } } if (nestedRelations.length > 0) { diff --git a/packages/hypergraph/src/utils/relation-query-helpers.ts b/packages/hypergraph/src/utils/relation-query-helpers.ts index 1508ee25..c6cf5359 100644 --- a/packages/hypergraph/src/utils/relation-query-helpers.ts +++ b/packages/hypergraph/src/utils/relation-query-helpers.ts @@ -1,43 +1,59 @@ import type { RelationTypeIdInfo } from './get-relation-type-ids.js'; -export const getRelationAlias = (typeId: string) => `relationsList_${typeId.replace(/-/g, '_')}`; +export const getRelationAlias = (typeId: string) => `relations_${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 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}`; - 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} { + if (!info.includeNodes && !info.includeTotalCount) { + return ''; + } + + const totalCountSelection = info.includeTotalCount + ? ` + totalCount` + : ''; + + const nodesSelection = info.includeNodes + ? ` + nodes { id - name - valuesList(filter: {spaceId: {is: $spaceId}}) { - propertyId - string - boolean - number - time - point + entity { + valuesList(filter: {spaceId: {is: $spaceId}}) { + propertyId + string + boolean + number + time + point + } } - ${nestedPlaceholder} - } - typeId + ${toEntitySelectionHeader} { + id + name + valuesList(filter: {spaceId: {is: $spaceId}}) { + propertyId + string + boolean + number + time + point + } + ${nestedPlaceholder} + } + typeId + }` + : ''; + + return ` + ${alias}: ${connectionField}( + filter: {spaceId: {is: $spaceId}, typeId: {is: "${info.typeId}"}}, + ) {${totalCountSelection}${nodesSelection} }`; };