Skip to content

Commit 38f70f3

Browse files
authored
fix bug for multiple backlinks with the same property id, but different type targets (#573)
1 parent 643734c commit 38f70f3

File tree

8 files changed

+114
-10
lines changed

8 files changed

+114
-10
lines changed

apps/events/src/routes/podcasts.lazy.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEntities, useEntity, usePublicSpaces } from '@graphprotocol/hypergraph-react';
22
import { createLazyFileRoute } from '@tanstack/react-router';
3-
import { Person, Podcast, Topic } from '@/schema';
3+
import { Person, PersonHostTest, Podcast, Topic } from '@/schema';
44

55
export const Route = createLazyFileRoute('/podcasts')({
66
component: RouteComponent,
@@ -64,6 +64,22 @@ function RouteComponent() {
6464
});
6565
console.log({ podcast, invalidEntity, invalidRelationEntities });
6666

67+
const { data: personHostTest } = useEntities(PersonHostTest, {
68+
mode: 'public',
69+
first: 10,
70+
filter: {
71+
name: {
72+
startsWith: 'Joe',
73+
},
74+
},
75+
space: space,
76+
include: {
77+
hostedPodcasts: {},
78+
hostedEpisodes: {},
79+
},
80+
});
81+
console.log({ personHostTest });
82+
6783
const { data, isLoading, isError } = useEntities(Podcast, {
6884
mode: 'public',
6985
first: 10,

apps/events/src/schema.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,3 +335,43 @@ export const Space = Entity.Schema(
335335
);
336336

337337
export type Space = Entity.Entity<typeof Space>;
338+
339+
export const EpisodeHostTest = Entity.Schema(
340+
{
341+
name: Type.String,
342+
},
343+
{
344+
types: [Id('972d201a-d780-4568-9e01-543f67b26bee')],
345+
properties: {
346+
name: Id(SystemIds.NAME_PROPERTY),
347+
},
348+
},
349+
);
350+
351+
export const PodcastHostTest = Entity.Schema(
352+
{
353+
name: Type.String,
354+
},
355+
{
356+
types: [Id('4c81561d-1f95-4131-9cdd-dd20ab831ba2')],
357+
properties: {
358+
name: Id(SystemIds.NAME_PROPERTY),
359+
},
360+
},
361+
);
362+
363+
export const PersonHostTest = Entity.Schema(
364+
{
365+
name: Type.String,
366+
hostedPodcasts: Type.Backlink(PodcastHostTest),
367+
hostedEpisodes: Type.Backlink(EpisodeHostTest),
368+
},
369+
{
370+
types: [Id('7ed45f2b-c48b-419e-8e46-64d5ff680b0d')],
371+
properties: {
372+
name: Id(SystemIds.NAME_PROPERTY),
373+
hostedPodcasts: Id('c72d9abb-bca8-4e86-b7e8-b71e91d2b37e'),
374+
hostedEpisodes: Id('c72d9abb-bca8-4e86-b7e8-b71e91d2b37e'),
375+
},
376+
},
377+
);

packages/hypergraph/src/utils/convert-relations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export const convertRelations = <_S extends Schema.Schema.AnyNoContext>(
106106

107107
if (relationMetadata) {
108108
// Use the aliased field to get relations for this specific type ID
109-
const alias = getRelationAlias(result.value);
109+
const alias = getRelationAlias(result.value, relationMetadata.targetTypeIds);
110110
relationConnection = queryEntity[alias as keyof RecursiveQueryEntity] as RelationsListWithNodes | undefined;
111111
if (relationMetadata.includeNodes) {
112112
allRelationsWithTheCorrectPropertyTypeId = relationConnection?.nodes;

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { RelationTypeIdInfo } from './get-relation-type-ids.js';
2+
import { canonicalize } from './jsc.js';
23

34
type SpaceSelectionMode = 'single' | 'many' | 'all';
45

@@ -56,7 +57,24 @@ const buildRelationSpaceFilter = (
5657
return `spaceId: {in: ${formatGraphQLStringArray(override)}}, `;
5758
};
5859

59-
export const getRelationAlias = (typeId: string) => `relations_${typeId.replace(/-/g, '_')}`;
60+
const sanitizeAliasComponent = (value: string) => {
61+
const collapsed = value.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
62+
return collapsed.length > 0 ? collapsed : 'all';
63+
};
64+
65+
const buildTargetTypeAliasComponent = (targetTypeIds?: readonly string[]) => {
66+
if (!targetTypeIds || targetTypeIds.length === 0) {
67+
return '';
68+
}
69+
70+
const canonicalTypeIds = canonicalize(Array.from(new Set(targetTypeIds)).sort());
71+
const sanitized = sanitizeAliasComponent(canonicalTypeIds);
72+
73+
return sanitized.length > 0 ? `_${sanitized}` : '';
74+
};
75+
76+
export const getRelationAlias = (typeId: string, targetTypeIds?: readonly string[]) =>
77+
`relations_${typeId.replace(/-/g, '_')}${buildTargetTypeAliasComponent(targetTypeIds)}`;
6078

6179
const buildRelationTypeIdsFilter = (listField: RelationTypeIdInfo['listField'], typeIds?: readonly string[]) => {
6280
if (!typeIds || typeIds.length === 0) return '';
@@ -65,7 +83,7 @@ const buildRelationTypeIdsFilter = (listField: RelationTypeIdInfo['listField'],
6583
};
6684

6785
const buildRelationsListFragment = (info: RelationTypeIdInfo, level: 1 | 2, spaceSelectionMode: SpaceSelectionMode) => {
68-
const alias = getRelationAlias(info.typeId);
86+
const alias = getRelationAlias(info.typeId, info.targetTypeIds);
6987
const nestedPlaceholder = info.includeNodes && level === 1 ? '__LEVEL2_RELATIONS__' : '';
7088
const listField = info.listField ?? 'relations';
7189
const connectionField = listField === 'backlinks' ? 'backlinks' : 'relations';

packages/hypergraph/test/entity/find-many-public.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,9 @@ describe('findManyPublic parseResult', () => {
7676
});
7777

7878
it('collects invalidRelationEntities when nested relations fail to decode', () => {
79-
const relationAlias = getRelationAlias(CHILDREN_RELATION_PROPERTY_ID);
8079
const relationInfo = getRelationTypeIds(Parent, { children: {} });
80+
const childrenRelationInfo = relationInfo.find((info) => info.propertyName === 'children');
81+
const relationAlias = getRelationAlias(CHILDREN_RELATION_PROPERTY_ID, childrenRelationInfo?.targetTypeIds);
8182

8283
const queryData = {
8384
entities: [

packages/hypergraph/test/entity/find-one-public.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
33
import { findOnePublic } from '../../src/entity/find-one-public.js';
44
import * as Entity from '../../src/entity/index.js';
55
import * as Type from '../../src/type/type.js';
6+
import { getRelationTypeIds } from '../../src/utils/get-relation-type-ids.js';
67
import { getRelationAlias } from '../../src/utils/relation-query-helpers.js';
78

89
const mockRequest = vi.hoisted(() => vi.fn());
@@ -76,7 +77,9 @@ describe('findOnePublic', () => {
7677
});
7778

7879
it('collects invalidRelationEntities when nested relations fail to decode', async () => {
79-
const relationAlias = getRelationAlias(CHILDREN_RELATION_PROPERTY_ID);
80+
const relationInfo = getRelationTypeIds(Parent, { children: {} });
81+
const childrenRelationInfo = relationInfo.find((info) => info.propertyName === 'children');
82+
const relationAlias = getRelationAlias(CHILDREN_RELATION_PROPERTY_ID, childrenRelationInfo?.targetTypeIds);
8083

8184
const entity = {
8285
id: 'parent-2',

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
33
import * as Entity from '../../src/entity/index.js';
44
import * as Type from '../../src/type/type.js';
55
import { getRelationTypeIds } from '../../src/utils/get-relation-type-ids.js';
6-
import { buildRelationsSelection } from '../../src/utils/relation-query-helpers.js';
6+
import { buildRelationsSelection, getRelationAlias } from '../../src/utils/relation-query-helpers.js';
77

88
const FRIENDS_RELATION_PROPERTY_ID = Id('f44ae32a-2f13-4d3f-875f-19d2338a32b8');
99
const CHILDREN_RELATION_PROPERTY_ID = Id('8a6dcb99-9c7b-4ca9-9f7b-98f2f404b405');
@@ -100,6 +100,11 @@ describe('relation include config overrides', () => {
100100
} satisfies Entity.EntityInclude<typeof Parent>;
101101

102102
const relationInfo = getRelationTypeIds(Parent, include);
103+
const childrenRelationInfo = relationInfo.find((info) => info.propertyName === 'children');
104+
const childrenAlias = getRelationAlias(
105+
childrenRelationInfo?.typeId ?? CHILDREN_RELATION_PROPERTY_ID,
106+
childrenRelationInfo?.targetTypeIds,
107+
);
103108
expect(relationInfo[0]).toMatchObject({
104109
relationSpaces: ['space-rel', 'space-rel-2'],
105110
valueSpaces: ['space-values'],
@@ -114,9 +119,7 @@ describe('relation include config overrides', () => {
114119
expect(selection).toContain('valuesList(filter: {spaceId: {in: ["space-values"]}})');
115120
expect(selection).toContain(`toEntity: { typeIds: { in: ${stringifyTypeIds(CHILD_TYPES)} } }`);
116121
expect(selection).toContain(`toEntity: { typeIds: { in: ${stringifyTypeIds(FRIEND_TYPES)} } }`);
117-
expect(selection.split('relations_f44ae32a_2f13_4d3f_875f_19d2338a32b8')[0]).not.toContain(
118-
'spaceId: {is: $spaceId}',
119-
);
122+
expect(selection.split(childrenAlias)[0]).not.toContain('spaceId: {is: $spaceId}');
120123
});
121124

122125
it('omits filters entirely when overrides use "all"', () => {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { getRelationAlias } from '../../src/utils/relation-query-helpers.js';
3+
4+
describe('getRelationAlias', () => {
5+
it('falls back to property typeId when no target typeIds are provided', () => {
6+
expect(getRelationAlias('f44ae32a-2f13-4d3f-875f-19d2338a32b8')).toBe(
7+
'relations_f44ae32a_2f13_4d3f_875f_19d2338a32b8',
8+
);
9+
});
10+
11+
it('includes canonicalized target typeIds in the alias', () => {
12+
const alias = getRelationAlias('f44ae32a-2f13-4d3f-875f-19d2338a32b8', ['type-b', 'type-a']);
13+
14+
expect(alias).toBe('relations_f44ae32a_2f13_4d3f_875f_19d2338a32b8_type_a_type_b');
15+
});
16+
17+
it('produces the same alias regardless of target typeId order or duplicates', () => {
18+
const aliasA = getRelationAlias('type-id', ['type-b', 'type-a', 'type-b']);
19+
const aliasB = getRelationAlias('type-id', ['type-a', 'type-b']);
20+
21+
expect(aliasA).toBe(aliasB);
22+
});
23+
});

0 commit comments

Comments
 (0)