Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/swift-houses-attack.md
Original file line number Diff line number Diff line change
@@ -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

46 changes: 40 additions & 6 deletions packages/hypergraph/src/utils/get-relation-type-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type RelationTypeIdInfo = {
listField: RelationListField;
includeNodes: boolean;
includeTotalCount: boolean;
targetTypeIds?: readonly string[];
relationSpaces?: RelationSpacesOverride;
valueSpaces?: RelationSpacesOverride;
children?: RelationTypeIdInfo[];
Expand All @@ -23,6 +24,35 @@ const isRelationIncludeBranch = (value: unknown): value is RelationIncludeBranch
const hasTotalCountFlag = (include: Record<string, unknown> | 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<string[]>(Constants.TypeIdsSymbol)(relationTransformation).pipe(
Option.getOrElse(() => []),
);
return typeIds.length > 0 ? (typeIds as readonly string[]) : undefined;
};

export const getRelationTypeIds = <S extends Schema.Schema.AnyNoContext>(
type: S,
include: EntityInclude<S> | undefined,
Expand Down Expand Up @@ -53,12 +83,15 @@ export const getRelationTypeIds = <S extends Schema.Schema.AnyNoContext>(
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
Expand All @@ -70,19 +103,17 @@ export const getRelationTypeIds = <S extends Schema.Schema.AnyNoContext>(
};
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<string[]>(Constants.TypeIdsSymbol)(
relationTransformation,
).pipe(Option.getOrElse(() => []));
if (typeIds2.length === 0) {
if (!targetTypeIds || targetTypeIds.length === 0) {
relationInfo.push(level1Info);
continue;
}
Expand All @@ -109,12 +140,15 @@ export const getRelationTypeIds = <S extends Schema.Schema.AnyNoContext>(
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
Expand Down
9 changes: 8 additions & 1 deletion packages/hypergraph/src/utils/relation-query-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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__' : '';
Expand All @@ -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 '';
Expand Down Expand Up @@ -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}
}`;
};
Expand Down
44 changes: 44 additions & 0 deletions packages/hypergraph/test/utils/relation-config-overrides.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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}',
);
Expand Down Expand Up @@ -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<typeof Podcast>;

const relationInfo = getRelationTypeIds(Podcast, include);
const selection = buildRelationsSelection(relationInfo, 'single');

expect(selection).toContain(`fromEntity: { typeIds: { in: ${stringifyTypeIds(EPISODE_TYPES)} } }`);
});
});
Loading