Skip to content

Commit 3f8c22d

Browse files
authored
narrow types (#570)
1 parent e04e477 commit 3f8c22d

4 files changed

Lines changed: 99 additions & 7 deletions

File tree

.changeset/swift-houses-attack.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@graphprotocol/hypergraph": minor
3+
"@graphprotocol/hypergraph-react": minor
4+
---
5+
6+
GraphQL relation and backlink queries now filter for entity types for more correct resuls. This applies across findOne, findMany, searchMany, useEntity and useEntities
7+

packages/hypergraph/src/utils/get-relation-type-ids.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type RelationTypeIdInfo = {
1212
listField: RelationListField;
1313
includeNodes: boolean;
1414
includeTotalCount: boolean;
15+
targetTypeIds?: readonly string[];
1516
relationSpaces?: RelationSpacesOverride;
1617
valueSpaces?: RelationSpacesOverride;
1718
children?: RelationTypeIdInfo[];
@@ -23,6 +24,35 @@ const isRelationIncludeBranch = (value: unknown): value is RelationIncludeBranch
2324
const hasTotalCountFlag = (include: Record<string, unknown> | undefined, key: string) =>
2425
Boolean(include?.[`${key}TotalCount`]);
2526

27+
const getRelationTupleType = (ast: SchemaAST.AST): SchemaAST.TupleType | undefined => {
28+
if (SchemaAST.isTupleType(ast)) {
29+
return ast;
30+
}
31+
if (SchemaAST.isUnion(ast)) {
32+
for (const member of ast.types) {
33+
if (SchemaAST.isTupleType(member)) {
34+
return member;
35+
}
36+
}
37+
}
38+
return undefined;
39+
};
40+
41+
const getRelationTargetTypeIds = (relationType: SchemaAST.AST) => {
42+
const tupleType = getRelationTupleType(relationType);
43+
if (!tupleType) {
44+
return undefined;
45+
}
46+
const relationTransformation = tupleType.rest[0]?.type;
47+
if (!relationTransformation || !SchemaAST.isTypeLiteral(relationTransformation)) {
48+
return undefined;
49+
}
50+
const typeIds = SchemaAST.getAnnotation<string[]>(Constants.TypeIdsSymbol)(relationTransformation).pipe(
51+
Option.getOrElse(() => []),
52+
);
53+
return typeIds.length > 0 ? (typeIds as readonly string[]) : undefined;
54+
};
55+
2656
export const getRelationTypeIds = <S extends Schema.Schema.AnyNoContext>(
2757
type: S,
2858
include: EntityInclude<S> | undefined,
@@ -53,12 +83,15 @@ export const getRelationTypeIds = <S extends Schema.Schema.AnyNoContext>(
5383
const relationSpaces = includeBranch?._config?.relationSpaces;
5484
const valueSpaces = includeBranch?._config?.valueSpaces;
5585

86+
const targetTypeIds = getRelationTargetTypeIds(prop.type);
87+
5688
const level1InfoBase: RelationTypeIdInfo = {
5789
typeId: result.value,
5890
propertyName,
5991
listField,
6092
includeNodes,
6193
includeTotalCount,
94+
...(targetTypeIds ? { targetTypeIds } : {}),
6295
};
6396
const level1Info: RelationTypeIdInfo =
6497
relationSpaces === undefined && valueSpaces === undefined
@@ -70,19 +103,17 @@ export const getRelationTypeIds = <S extends Schema.Schema.AnyNoContext>(
70103
};
71104
const nestedRelations: RelationTypeIdInfo[] = [];
72105

73-
if (!SchemaAST.isTupleType(prop.type)) {
106+
const relationTuple = getRelationTupleType(prop.type);
107+
if (!relationTuple) {
74108
relationInfo.push(level1Info);
75109
continue;
76110
}
77-
const relationTransformation = prop.type.rest[0]?.type;
111+
const relationTransformation = relationTuple.rest[0]?.type;
78112
if (!relationTransformation || !SchemaAST.isTypeLiteral(relationTransformation)) {
79113
relationInfo.push(level1Info);
80114
continue;
81115
}
82-
const typeIds2: string[] = SchemaAST.getAnnotation<string[]>(Constants.TypeIdsSymbol)(
83-
relationTransformation,
84-
).pipe(Option.getOrElse(() => []));
85-
if (typeIds2.length === 0) {
116+
if (!targetTypeIds || targetTypeIds.length === 0) {
86117
relationInfo.push(level1Info);
87118
continue;
88119
}
@@ -109,12 +140,15 @@ export const getRelationTypeIds = <S extends Schema.Schema.AnyNoContext>(
109140
const nestedListField: RelationListField = nestedIsBacklink ? 'backlinks' : 'relations';
110141
const nestedRelationSpaces = nestedIncludeBranch?._config?.relationSpaces;
111142
const nestedValueSpaces = nestedIncludeBranch?._config?.valueSpaces;
143+
const nestedTargetTypeIds = getRelationTargetTypeIds(nestedProp.type);
144+
112145
const nestedInfoBase: RelationTypeIdInfo = {
113146
typeId: nestedResult.value,
114147
propertyName: nestedPropertyName,
115148
listField: nestedListField,
116149
includeNodes: nestedIncludeNodes,
117150
includeTotalCount: nestedIncludeTotalCount,
151+
...(nestedTargetTypeIds ? { targetTypeIds: nestedTargetTypeIds } : {}),
118152
};
119153
const nestedInfo: RelationTypeIdInfo =
120154
nestedRelationSpaces === undefined && nestedValueSpaces === undefined

packages/hypergraph/src/utils/relation-query-helpers.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ const buildRelationSpaceFilter = (
5858

5959
export const getRelationAlias = (typeId: string) => `relations_${typeId.replace(/-/g, '_')}`;
6060

61+
const buildRelationTypeIdsFilter = (listField: RelationTypeIdInfo['listField'], typeIds?: readonly string[]) => {
62+
if (!typeIds || typeIds.length === 0) return '';
63+
const relationField = listField === 'backlinks' ? 'fromEntity' : 'toEntity';
64+
return `${relationField}: { typeIds: { in: ${formatGraphQLStringArray(typeIds)} } }, `;
65+
};
66+
6167
const buildRelationsListFragment = (info: RelationTypeIdInfo, level: 1 | 2, spaceSelectionMode: SpaceSelectionMode) => {
6268
const alias = getRelationAlias(info.typeId);
6369
const nestedPlaceholder = info.includeNodes && level === 1 ? '__LEVEL2_RELATIONS__' : '';
@@ -67,6 +73,7 @@ const buildRelationsListFragment = (info: RelationTypeIdInfo, level: 1 | 2, spac
6773
const toEntitySelectionHeader = toEntityField === 'toEntity' ? 'toEntity' : `toEntity: ${toEntityField}`;
6874
const valuesListFilter = buildValuesListFilter(spaceSelectionMode, info.valueSpaces);
6975
const relationSpaceFilter = buildRelationSpaceFilter(spaceSelectionMode, info.relationSpaces);
76+
const relationEntityTypeFilter = buildRelationTypeIdsFilter(listField, info.targetTypeIds);
7077

7178
if (!info.includeNodes && !info.includeTotalCount) {
7279
return '';
@@ -110,7 +117,7 @@ const buildRelationsListFragment = (info: RelationTypeIdInfo, level: 1 | 2, spac
110117

111118
return `
112119
${alias}: ${connectionField}(
113-
filter: {${relationSpaceFilter}typeId: {is: "${info.typeId}"}},
120+
filter: {${relationSpaceFilter}${relationEntityTypeFilter}typeId: {is: "${info.typeId}"}},
114121
) {${totalCountSelection}${nodesSelection}
115122
}`;
116123
};

packages/hypergraph/test/utils/relation-config-overrides.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ const CHILDREN_RELATION_PROPERTY_ID = Id('8a6dcb99-9c7b-4ca9-9f7b-98f2f404b405')
1010
const PARENT_TYPES = [Id('842e8ae0-9904-40a8-9bfe-19e1f4400c5e')];
1111
const CHILD_TYPES = [Id('cd9a2ae2-831c-4fa2-b714-ad3aa254db7d')];
1212
const FRIEND_TYPES = [Id('35ac5c3e-4f31-466e-b3da-51fdfbb4b38e')];
13+
const PODCAST_TYPES = [Id('f347d2a2-cc18-4d45-aa9a-0df3ba40f4ad')];
14+
const EPISODE_TYPES = [Id('b1fe2f9e-1f6a-4f07-a0fb-3f5d463f98f1')];
1315
const NAME_PROPERTY_ID = Id('9f5e7ea4-51bb-4c9f-8739-7fa0aa695d02');
16+
const PODCAST_EPISODES_RELATION_PROPERTY_ID = Id('88f24615-58b1-4d6c-a45e-81ab9582c282');
17+
18+
const stringifyTypeIds = (typeIds: readonly string[]) => `[${typeIds.map((id) => JSON.stringify(id)).join(', ')}]`;
1419

1520
const Friend = Entity.Schema(
1621
{
@@ -52,6 +57,32 @@ const Parent = Entity.Schema(
5257
},
5358
);
5459

60+
const Episode = Entity.Schema(
61+
{
62+
name: Type.String,
63+
},
64+
{
65+
types: EPISODE_TYPES,
66+
properties: {
67+
name: NAME_PROPERTY_ID,
68+
},
69+
},
70+
);
71+
72+
const Podcast = Entity.Schema(
73+
{
74+
title: Type.String,
75+
episodes: Type.Backlink(Episode),
76+
},
77+
{
78+
types: PODCAST_TYPES,
79+
properties: {
80+
title: NAME_PROPERTY_ID,
81+
episodes: PODCAST_EPISODES_RELATION_PROPERTY_ID,
82+
},
83+
},
84+
);
85+
5586
describe('relation include config overrides', () => {
5687
it('propagates spaces overrides to query fragments', () => {
5788
const include = {
@@ -81,6 +112,8 @@ describe('relation include config overrides', () => {
81112

82113
expect(selection).toContain('spaceId: {in: ["space-rel", "space-rel-2"]}');
83114
expect(selection).toContain('valuesList(filter: {spaceId: {in: ["space-values"]}})');
115+
expect(selection).toContain(`toEntity: { typeIds: { in: ${stringifyTypeIds(CHILD_TYPES)} } }`);
116+
expect(selection).toContain(`toEntity: { typeIds: { in: ${stringifyTypeIds(FRIEND_TYPES)} } }`);
84117
expect(selection.split('relations_f44ae32a_2f13_4d3f_875f_19d2338a32b8')[0]).not.toContain(
85118
'spaceId: {is: $spaceId}',
86119
);
@@ -121,4 +154,15 @@ describe('relation include config overrides', () => {
121154
expect(selection).toContain('spaceId: {in: []}');
122155
expect(selection).toContain('valuesList(filter: {spaceId: {in: []}})');
123156
});
157+
158+
it('adds typeIds filter for backlinks', () => {
159+
const include = {
160+
episodes: {},
161+
} satisfies Entity.EntityInclude<typeof Podcast>;
162+
163+
const relationInfo = getRelationTypeIds(Podcast, include);
164+
const selection = buildRelationsSelection(relationInfo, 'single');
165+
166+
expect(selection).toContain(`fromEntity: { typeIds: { in: ${stringifyTypeIds(EPISODE_TYPES)} } }`);
167+
});
124168
});

0 commit comments

Comments
 (0)