diff --git a/.changeset/swift-houses-attack.md b/.changeset/swift-houses-attack.md new file mode 100644 index 00000000..99f82471 --- /dev/null +++ b/.changeset/swift-houses-attack.md @@ -0,0 +1,7 @@ +--- +"@graphprotocol/hypergraph": minor +"@graphprotocol/hypergraph-react": minor +--- + +GraphQL relation and backlink queries now filter for entity types for more correct resuls. This applies across findOne, findMany, searchMany, useEntity and useEntities + \ No newline at end of file diff --git a/packages/hypergraph/src/utils/get-relation-type-ids.ts b/packages/hypergraph/src/utils/get-relation-type-ids.ts index 3a8f764e..2cf828fb 100644 --- a/packages/hypergraph/src/utils/get-relation-type-ids.ts +++ b/packages/hypergraph/src/utils/get-relation-type-ids.ts @@ -12,6 +12,7 @@ export type RelationTypeIdInfo = { listField: RelationListField; includeNodes: boolean; includeTotalCount: boolean; + targetTypeIds?: readonly string[]; relationSpaces?: RelationSpacesOverride; valueSpaces?: RelationSpacesOverride; children?: RelationTypeIdInfo[]; @@ -23,6 +24,35 @@ const isRelationIncludeBranch = (value: unknown): value is RelationIncludeBranch const hasTotalCountFlag = (include: Record | undefined, key: string) => Boolean(include?.[`${key}TotalCount`]); +const getRelationTupleType = (ast: SchemaAST.AST): SchemaAST.TupleType | undefined => { + if (SchemaAST.isTupleType(ast)) { + return ast; + } + if (SchemaAST.isUnion(ast)) { + for (const member of ast.types) { + if (SchemaAST.isTupleType(member)) { + return member; + } + } + } + return undefined; +}; + +const getRelationTargetTypeIds = (relationType: SchemaAST.AST) => { + const tupleType = getRelationTupleType(relationType); + if (!tupleType) { + return undefined; + } + const relationTransformation = tupleType.rest[0]?.type; + if (!relationTransformation || !SchemaAST.isTypeLiteral(relationTransformation)) { + return undefined; + } + const typeIds = SchemaAST.getAnnotation(Constants.TypeIdsSymbol)(relationTransformation).pipe( + Option.getOrElse(() => []), + ); + return typeIds.length > 0 ? (typeIds as readonly string[]) : undefined; +}; + export const getRelationTypeIds = ( type: S, include: EntityInclude | undefined, @@ -53,12 +83,15 @@ export const getRelationTypeIds = ( const relationSpaces = includeBranch?._config?.relationSpaces; const valueSpaces = includeBranch?._config?.valueSpaces; + const targetTypeIds = getRelationTargetTypeIds(prop.type); + const level1InfoBase: RelationTypeIdInfo = { typeId: result.value, propertyName, listField, includeNodes, includeTotalCount, + ...(targetTypeIds ? { targetTypeIds } : {}), }; const level1Info: RelationTypeIdInfo = relationSpaces === undefined && valueSpaces === undefined @@ -70,19 +103,17 @@ export const getRelationTypeIds = ( }; const nestedRelations: RelationTypeIdInfo[] = []; - if (!SchemaAST.isTupleType(prop.type)) { + const relationTuple = getRelationTupleType(prop.type); + if (!relationTuple) { relationInfo.push(level1Info); continue; } - const relationTransformation = prop.type.rest[0]?.type; + const relationTransformation = relationTuple.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) { + if (!targetTypeIds || targetTypeIds.length === 0) { relationInfo.push(level1Info); continue; } @@ -109,12 +140,15 @@ export const getRelationTypeIds = ( const nestedListField: RelationListField = nestedIsBacklink ? 'backlinks' : 'relations'; const nestedRelationSpaces = nestedIncludeBranch?._config?.relationSpaces; const nestedValueSpaces = nestedIncludeBranch?._config?.valueSpaces; + const nestedTargetTypeIds = getRelationTargetTypeIds(nestedProp.type); + const nestedInfoBase: RelationTypeIdInfo = { typeId: nestedResult.value, propertyName: nestedPropertyName, listField: nestedListField, includeNodes: nestedIncludeNodes, includeTotalCount: nestedIncludeTotalCount, + ...(nestedTargetTypeIds ? { targetTypeIds: nestedTargetTypeIds } : {}), }; const nestedInfo: RelationTypeIdInfo = nestedRelationSpaces === undefined && nestedValueSpaces === undefined diff --git a/packages/hypergraph/src/utils/relation-query-helpers.ts b/packages/hypergraph/src/utils/relation-query-helpers.ts index 0bb16b0e..bbf4516f 100644 --- a/packages/hypergraph/src/utils/relation-query-helpers.ts +++ b/packages/hypergraph/src/utils/relation-query-helpers.ts @@ -58,6 +58,12 @@ const buildRelationSpaceFilter = ( export const getRelationAlias = (typeId: string) => `relations_${typeId.replace(/-/g, '_')}`; +const buildRelationTypeIdsFilter = (listField: RelationTypeIdInfo['listField'], typeIds?: readonly string[]) => { + if (!typeIds || typeIds.length === 0) return ''; + const relationField = listField === 'backlinks' ? 'fromEntity' : 'toEntity'; + return `${relationField}: { typeIds: { in: ${formatGraphQLStringArray(typeIds)} } }, `; +}; + const buildRelationsListFragment = (info: RelationTypeIdInfo, level: 1 | 2, spaceSelectionMode: SpaceSelectionMode) => { const alias = getRelationAlias(info.typeId); const nestedPlaceholder = info.includeNodes && level === 1 ? '__LEVEL2_RELATIONS__' : ''; @@ -67,6 +73,7 @@ const buildRelationsListFragment = (info: RelationTypeIdInfo, level: 1 | 2, spac const toEntitySelectionHeader = toEntityField === 'toEntity' ? 'toEntity' : `toEntity: ${toEntityField}`; const valuesListFilter = buildValuesListFilter(spaceSelectionMode, info.valueSpaces); const relationSpaceFilter = buildRelationSpaceFilter(spaceSelectionMode, info.relationSpaces); + const relationEntityTypeFilter = buildRelationTypeIdsFilter(listField, info.targetTypeIds); if (!info.includeNodes && !info.includeTotalCount) { return ''; @@ -110,7 +117,7 @@ const buildRelationsListFragment = (info: RelationTypeIdInfo, level: 1 | 2, spac return ` ${alias}: ${connectionField}( - filter: {${relationSpaceFilter}typeId: {is: "${info.typeId}"}}, + filter: {${relationSpaceFilter}${relationEntityTypeFilter}typeId: {is: "${info.typeId}"}}, ) {${totalCountSelection}${nodesSelection} }`; }; diff --git a/packages/hypergraph/test/utils/relation-config-overrides.test.ts b/packages/hypergraph/test/utils/relation-config-overrides.test.ts index 90ef0900..aa4ae600 100644 --- a/packages/hypergraph/test/utils/relation-config-overrides.test.ts +++ b/packages/hypergraph/test/utils/relation-config-overrides.test.ts @@ -10,7 +10,12 @@ const CHILDREN_RELATION_PROPERTY_ID = Id('8a6dcb99-9c7b-4ca9-9f7b-98f2f404b405') const PARENT_TYPES = [Id('842e8ae0-9904-40a8-9bfe-19e1f4400c5e')]; const CHILD_TYPES = [Id('cd9a2ae2-831c-4fa2-b714-ad3aa254db7d')]; const FRIEND_TYPES = [Id('35ac5c3e-4f31-466e-b3da-51fdfbb4b38e')]; +const PODCAST_TYPES = [Id('f347d2a2-cc18-4d45-aa9a-0df3ba40f4ad')]; +const EPISODE_TYPES = [Id('b1fe2f9e-1f6a-4f07-a0fb-3f5d463f98f1')]; const NAME_PROPERTY_ID = Id('9f5e7ea4-51bb-4c9f-8739-7fa0aa695d02'); +const PODCAST_EPISODES_RELATION_PROPERTY_ID = Id('88f24615-58b1-4d6c-a45e-81ab9582c282'); + +const stringifyTypeIds = (typeIds: readonly string[]) => `[${typeIds.map((id) => JSON.stringify(id)).join(', ')}]`; const Friend = Entity.Schema( { @@ -52,6 +57,32 @@ const Parent = Entity.Schema( }, ); +const Episode = Entity.Schema( + { + name: Type.String, + }, + { + types: EPISODE_TYPES, + properties: { + name: NAME_PROPERTY_ID, + }, + }, +); + +const Podcast = Entity.Schema( + { + title: Type.String, + episodes: Type.Backlink(Episode), + }, + { + types: PODCAST_TYPES, + properties: { + title: NAME_PROPERTY_ID, + episodes: PODCAST_EPISODES_RELATION_PROPERTY_ID, + }, + }, +); + describe('relation include config overrides', () => { it('propagates spaces overrides to query fragments', () => { const include = { @@ -81,6 +112,8 @@ describe('relation include config overrides', () => { expect(selection).toContain('spaceId: {in: ["space-rel", "space-rel-2"]}'); expect(selection).toContain('valuesList(filter: {spaceId: {in: ["space-values"]}})'); + expect(selection).toContain(`toEntity: { typeIds: { in: ${stringifyTypeIds(CHILD_TYPES)} } }`); + expect(selection).toContain(`toEntity: { typeIds: { in: ${stringifyTypeIds(FRIEND_TYPES)} } }`); expect(selection.split('relations_f44ae32a_2f13_4d3f_875f_19d2338a32b8')[0]).not.toContain( 'spaceId: {is: $spaceId}', ); @@ -121,4 +154,15 @@ describe('relation include config overrides', () => { expect(selection).toContain('spaceId: {in: []}'); expect(selection).toContain('valuesList(filter: {spaceId: {in: []}})'); }); + + it('adds typeIds filter for backlinks', () => { + const include = { + episodes: {}, + } satisfies Entity.EntityInclude; + + const relationInfo = getRelationTypeIds(Podcast, include); + const selection = buildRelationsSelection(relationInfo, 'single'); + + expect(selection).toContain(`fromEntity: { typeIds: { in: ${stringifyTypeIds(EPISODE_TYPES)} } }`); + }); });