From abab374c27e8ef11a5375dd761354f265fbd0b29 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Sun, 8 Feb 2026 12:29:21 +0100 Subject: [PATCH 1/6] add entity id filter for relation and backlink fetching --- .changeset/add-entity-id-relation-filter.md | 5 + docs/docs/filtering-query-results.md | 68 ++++++- packages/hypergraph/src/entity/types.ts | 6 + .../src/utils/translate-filter-to-graphql.ts | 92 +++++++-- .../utils/translate-filter-to-graphql.test.ts | 183 +++++++++++++++++- 5 files changed, 337 insertions(+), 17 deletions(-) create mode 100644 .changeset/add-entity-id-relation-filter.md diff --git a/.changeset/add-entity-id-relation-filter.md b/.changeset/add-entity-id-relation-filter.md new file mode 100644 index 00000000..a0c2fe11 --- /dev/null +++ b/.changeset/add-entity-id-relation-filter.md @@ -0,0 +1,5 @@ +--- +"@graphprotocol/hypergraph": minor +--- + +add entityId filter for relation and backlink fields diff --git a/docs/docs/filtering-query-results.md b/docs/docs/filtering-query-results.md index 55d5f1d5..b52c6690 100644 --- a/docs/docs/filtering-query-results.md +++ b/docs/docs/filtering-query-results.md @@ -1,6 +1,6 @@ # Filtering Query Results -The filter API allows you to filter the results of a query by property values and in the future also by relations. +The filter API allows you to filter the results of a query by property values and relations. ## Filtering by property values @@ -145,6 +145,72 @@ const { data } = useEntities(Person, { ## Relation filtering +### Filter by existence + +You can filter entities based on whether a relation or backlink exists: + +```tsx +const { data } = useEntities(Todo, { + filter: { + assignees: { exists: true }, + }, +}); +``` + +### Filter by entity ID + +You can filter entities by which specific entity is on the other end of a relation or backlink. This is useful when you want to find all entities connected to a specific entity. + +```tsx +// string shorthand +const { data } = useEntities(Todo, { + filter: { + assignees: { entityId: 'user-id' }, + }, +}); + +// object form with `is` +const { data } = useEntities(Todo, { + filter: { + assignees: { entityId: { is: 'user-id' } }, + }, +}); + +// object form with `in` to match multiple entities +const { data } = useEntities(Todo, { + filter: { + assignees: { entityId: { in: ['user-1', 'user-2'] } }, + }, +}); +``` + +This works the same way for backlink fields: + +```tsx +export const Bounty = Entity.Schema( + { name: Type.String, interestedIn: Type.Backlink(Person) }, + { + types: [Id('bounty-type-id')], + properties: { + name: Id('name-property-id'), + interestedIn: Id('interested-in-property-id'), + }, + }, +); + +// find all bounties where a specific person expressed interest +const { data } = useEntities(Bounty, { + filter: { + interestedIn: { entityId: myPersonId }, + }, +}); +``` + +The framework automatically maps `entityId` to the correct GraphQL field: + +- **Forward relations** (e.g. `Type.Relation(...)`) use `toEntityId` +- **Backlinks** (e.g. `Type.Backlink(...)`) use `fromEntityId` + ### Filter on values of the to entity ```tsx diff --git a/packages/hypergraph/src/entity/types.ts b/packages/hypergraph/src/entity/types.ts index 159c9fb6..39e9c19a 100644 --- a/packages/hypergraph/src/entity/types.ts +++ b/packages/hypergraph/src/entity/types.ts @@ -74,6 +74,11 @@ export type EntityIdFilter = { is?: string; }; +export type RelationEntityIdFilter = { + is?: string; + in?: readonly string[]; +}; + export type CrossFieldFilter> = { [K in keyof T]?: EntityFieldFilter; } & Extra & { @@ -84,6 +89,7 @@ export type CrossFieldFilter> = { type RelationExistsFilter = [T] extends [readonly unknown[] | undefined] ? { exists?: boolean; + entityId?: string | RelationEntityIdFilter; } : Record; diff --git a/packages/hypergraph/src/utils/translate-filter-to-graphql.ts b/packages/hypergraph/src/utils/translate-filter-to-graphql.ts index b9004d6e..d7d1eb2e 100644 --- a/packages/hypergraph/src/utils/translate-filter-to-graphql.ts +++ b/packages/hypergraph/src/utils/translate-filter-to-graphql.ts @@ -3,6 +3,18 @@ import * as Option from 'effect/Option'; import type * as Schema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; +type EntityIdGraphqlFilter = { is: string } | { in: readonly string[] }; + +type RelationSomeFilter = { + typeId: { is: string }; + toEntityId?: EntityIdGraphqlFilter; +}; + +type BacklinkSomeFilter = { + typeId: { is: string }; + fromEntityId?: EntityIdGraphqlFilter; +}; + type GraphqlFilterEntry = | { values: { @@ -32,9 +44,12 @@ type GraphqlFilterEntry = } | { relations: { - some: { - typeId: { is: string }; - }; + some: RelationSomeFilter; + }; + } + | { + backlinks: { + some: BacklinkSomeFilter; }; } | { @@ -58,14 +73,6 @@ export function translateFilterToGraphql( const graphqlFilter: GraphqlFilterEntry[] = []; - const buildRelationExistsFilter = (propertyId: string): GraphqlFilterEntry => ({ - relations: { - some: { - typeId: { is: propertyId }, - }, - }, - }); - for (const [fieldName, fieldFilter] of Object.entries(filter)) { if (fieldName === 'or') { graphqlFilter.push({ @@ -107,20 +114,77 @@ export function translateFilterToGraphql( if (!Option.isSome(propertyId) || !Option.isSome(propertyType)) continue; if (propertyType.value === 'relation') { - const relationFilter = fieldFilter as { exists?: boolean }; + const relationFilter = fieldFilter as { + exists?: boolean; + entityId?: string | { is?: string; in?: readonly string[] }; + }; + + const isBacklink = SchemaAST.getAnnotation(Constants.RelationBacklinkSymbol)( + propertySignature.type, + ).pipe(Option.getOrElse(() => false)); + + // Normalize entityId shorthand: string → { is: string } + const entityIdFilter = + typeof relationFilter.entityId === 'string' ? { is: relationFilter.entityId } : relationFilter.entityId; + + if (entityIdFilter) { + const entityIdValue: EntityIdGraphqlFilter = entityIdFilter.is + ? { is: entityIdFilter.is } + : { in: entityIdFilter.in! }; + + if (isBacklink) { + graphqlFilter.push({ + backlinks: { + some: { + typeId: { is: propertyId.value }, + fromEntityId: entityIdValue, + }, + }, + }); + } else { + graphqlFilter.push({ + relations: { + some: { + typeId: { is: propertyId.value }, + toEntityId: entityIdValue, + }, + }, + }); + } + } if (relationFilter.exists === true) { - graphqlFilter.push(buildRelationExistsFilter(propertyId.value)); + if (isBacklink) { + graphqlFilter.push({ + backlinks: { + some: { + typeId: { is: propertyId.value }, + }, + }, + }); + } else { + graphqlFilter.push({ + relations: { + some: { + typeId: { is: propertyId.value }, + }, + }, + }); + } continue; } if (relationFilter.exists === false) { - const existsFilter = buildRelationExistsFilter(propertyId.value); + const existsFilter: GraphqlFilterEntry = isBacklink + ? { backlinks: { some: { typeId: { is: propertyId.value } } } } + : { relations: { some: { typeId: { is: propertyId.value } } } }; graphqlFilter.push({ not: existsFilter, }); continue; } + + continue; } if ( diff --git a/packages/hypergraph/test/utils/translate-filter-to-graphql.test.ts b/packages/hypergraph/test/utils/translate-filter-to-graphql.test.ts index 772e166b..b5722e31 100644 --- a/packages/hypergraph/test/utils/translate-filter-to-graphql.test.ts +++ b/packages/hypergraph/test/utils/translate-filter-to-graphql.test.ts @@ -34,7 +34,22 @@ export const Todo = Entity.Schema( }, ); +export const Project = Entity.Schema( + { + title: Type.String, + todos: Type.Backlink(Todo), + }, + { + types: [Id('b388444f06a340379ace66fe325864d1')], + properties: { + title: Id('b126ca530c8e48d5b88882c734c38936'), + todos: Id('b399677c2bf940c39622815be7b83345'), + }, + }, +); + type TodoFilter = Entity.EntityFilter>; +type ProjectFilter = Entity.EntityFilter>; describe('translateFilterToGraphql string filters', () => { it('should translate string `is` filter correctly', () => { @@ -201,7 +216,6 @@ describe('translateFilterToGraphql id filters', () => { describe('translateFilterToGraphql relation filters', () => { it('should translate relation `exists` filter correctly', () => { const filter: TodoFilter = { - // @ts-expect-error - this is a test assignees: { exists: true }, }; @@ -218,7 +232,6 @@ describe('translateFilterToGraphql relation filters', () => { it('should translate relation `exists: false` filter correctly', () => { const filter: TodoFilter = { - // @ts-expect-error - this is a test assignees: { exists: false }, }; @@ -234,6 +247,172 @@ describe('translateFilterToGraphql relation filters', () => { }, }); }); + + it('should translate relation `entityId` string shorthand filter correctly', () => { + const filter: TodoFilter = { + assignees: { entityId: 'user-123' }, + }; + + const result = translateFilterToGraphql(filter, Todo); + + expect(result).toEqual({ + relations: { + some: { + typeId: { is: 'f399677c2bf940c39622815be7b83344' }, + toEntityId: { is: 'user-123' }, + }, + }, + }); + }); + + it('should translate relation `entityId: { is }` filter correctly', () => { + const filter: TodoFilter = { + assignees: { entityId: { is: 'user-123' } }, + }; + + const result = translateFilterToGraphql(filter, Todo); + + expect(result).toEqual({ + relations: { + some: { + typeId: { is: 'f399677c2bf940c39622815be7b83344' }, + toEntityId: { is: 'user-123' }, + }, + }, + }); + }); + + it('should translate relation `entityId: { in }` filter correctly', () => { + const filter: TodoFilter = { + assignees: { entityId: { in: ['user-1', 'user-2'] } }, + }; + + const result = translateFilterToGraphql(filter, Todo); + + expect(result).toEqual({ + relations: { + some: { + typeId: { is: 'f399677c2bf940c39622815be7b83344' }, + toEntityId: { in: ['user-1', 'user-2'] }, + }, + }, + }); + }); + + it('should combine entityId with exists filter on a relation', () => { + const filter: TodoFilter = { + assignees: { exists: true, entityId: 'user-123' }, + }; + + const result = translateFilterToGraphql(filter, Todo); + + expect(result).toEqual({ + and: [ + { + relations: { + some: { + typeId: { is: 'f399677c2bf940c39622815be7b83344' }, + toEntityId: { is: 'user-123' }, + }, + }, + }, + { + relations: { + some: { + typeId: { is: 'f399677c2bf940c39622815be7b83344' }, + }, + }, + }, + ], + }); + }); +}); + +describe('translateFilterToGraphql backlink filters', () => { + it('should translate backlink `exists` filter using `backlinks` key', () => { + const filter: ProjectFilter = { + todos: { exists: true }, + }; + + const result = translateFilterToGraphql(filter, Project); + + expect(result).toEqual({ + backlinks: { + some: { + typeId: { is: 'b399677c2bf940c39622815be7b83345' }, + }, + }, + }); + }); + + it('should translate backlink `exists: false` filter using `backlinks` key', () => { + const filter: ProjectFilter = { + todos: { exists: false }, + }; + + const result = translateFilterToGraphql(filter, Project); + + expect(result).toEqual({ + not: { + backlinks: { + some: { + typeId: { is: 'b399677c2bf940c39622815be7b83345' }, + }, + }, + }, + }); + }); + + it('should translate backlink `entityId` string shorthand using `fromEntityId`', () => { + const filter: ProjectFilter = { + todos: { entityId: 'todo-123' }, + }; + + const result = translateFilterToGraphql(filter, Project); + + expect(result).toEqual({ + backlinks: { + some: { + typeId: { is: 'b399677c2bf940c39622815be7b83345' }, + fromEntityId: { is: 'todo-123' }, + }, + }, + }); + }); + + it('should translate backlink `entityId: { is }` using `fromEntityId`', () => { + const filter: ProjectFilter = { + todos: { entityId: { is: 'todo-123' } }, + }; + + const result = translateFilterToGraphql(filter, Project); + + expect(result).toEqual({ + backlinks: { + some: { + typeId: { is: 'b399677c2bf940c39622815be7b83345' }, + fromEntityId: { is: 'todo-123' }, + }, + }, + }); + }); + + it('should translate backlink `entityId: { in }` using `fromEntityId`', () => { + const filter: ProjectFilter = { + todos: { entityId: { in: ['todo-1', 'todo-2'] } }, + }; + + const result = translateFilterToGraphql(filter, Project); + + expect(result).toEqual({ + backlinks: { + some: { + typeId: { is: 'b399677c2bf940c39622815be7b83345' }, + fromEntityId: { in: ['todo-1', 'todo-2'] }, + }, + }, + }); + }); }); describe('translateFilterToGraphql multiple filters', () => { From 404e09b2640c10f0dfa5b8cd1605c545712cd903 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Sun, 8 Feb 2026 12:32:41 +0100 Subject: [PATCH 2/6] fix --- packages/hypergraph/src/utils/translate-filter-to-graphql.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hypergraph/src/utils/translate-filter-to-graphql.ts b/packages/hypergraph/src/utils/translate-filter-to-graphql.ts index d7d1eb2e..d57de050 100644 --- a/packages/hypergraph/src/utils/translate-filter-to-graphql.ts +++ b/packages/hypergraph/src/utils/translate-filter-to-graphql.ts @@ -130,7 +130,7 @@ export function translateFilterToGraphql( if (entityIdFilter) { const entityIdValue: EntityIdGraphqlFilter = entityIdFilter.is ? { is: entityIdFilter.is } - : { in: entityIdFilter.in! }; + : { in: entityIdFilter.in ?? [] }; if (isBacklink) { graphqlFilter.push({ From 098fce21bb7380a0dfa65327db7bec3fb5742cd2 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Sun, 8 Feb 2026 12:53:05 +0100 Subject: [PATCH 3/6] add bounties page for testing --- .claude/settings.local.json | 7 +++- apps/events/src/routeTree.gen.ts | 21 +++++++++++ apps/events/src/routes/__root.tsx | 3 ++ apps/events/src/routes/bounties.lazy.tsx | 47 ++++++++++++++++++++++++ apps/events/src/schema.ts | 28 ++++++++++++++ 5 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 apps/events/src/routes/bounties.lazy.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 9358046e..5de25639 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,7 +15,12 @@ "Bash(node -e:*)", "Bash(pnpm build:*)", "mcp__chrome-devtools__list_pages", - "Bash(pnpm lint:*)" + "Bash(pnpm lint:*)", + "Bash(npx @tanstack/router-cli generate:*)", + "mcp__chrome-devtools__navigate_page", + "mcp__chrome-devtools__list_network_requests", + "mcp__chrome-devtools__get_network_request", + "mcp__chrome-devtools__evaluate_script" ], "deny": [], "ask": [] diff --git a/apps/events/src/routeTree.gen.ts b/apps/events/src/routeTree.gen.ts index 2e315526..b5ea8889 100644 --- a/apps/events/src/routeTree.gen.ts +++ b/apps/events/src/routeTree.gen.ts @@ -27,6 +27,7 @@ const PodcastsInfiniteLazyRouteImport = createFileRoute('/podcasts-infinite')() const PodcastsLazyRouteImport = createFileRoute('/podcasts')() const PlaygroundLazyRouteImport = createFileRoute('/playground')() const LoginLazyRouteImport = createFileRoute('/login')() +const BountiesLazyRouteImport = createFileRoute('/bounties')() const PodcastsInfiniteLazyRoute = PodcastsInfiniteLazyRouteImport.update({ id: '/podcasts-infinite', @@ -50,6 +51,11 @@ const LoginLazyRoute = LoginLazyRouteImport.update({ path: '/login', getParentRoute: () => rootRouteImport, } as any).lazy(() => import('./routes/login.lazy').then((d) => d.Route)) +const BountiesLazyRoute = BountiesLazyRouteImport.update({ + id: '/bounties', + path: '/bounties', + getParentRoute: () => rootRouteImport, +} as any).lazy(() => import('./routes/bounties.lazy').then((d) => d.Route)) const AuthenticateSuccessRoute = AuthenticateSuccessRouteImport.update({ id: '/authenticate-success', path: '/authenticate-success', @@ -110,6 +116,7 @@ const SpaceSpaceIdChatRoute = SpaceSpaceIdChatRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/authenticate-success': typeof AuthenticateSuccessRoute + '/bounties': typeof BountiesLazyRoute '/login': typeof LoginLazyRoute '/playground': typeof PlaygroundLazyRoute '/podcasts': typeof PodcastsLazyRoute @@ -127,6 +134,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/authenticate-success': typeof AuthenticateSuccessRoute + '/bounties': typeof BountiesLazyRoute '/login': typeof LoginLazyRoute '/playground': typeof PlaygroundLazyRoute '/podcasts': typeof PodcastsLazyRoute @@ -144,6 +152,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/authenticate-success': typeof AuthenticateSuccessRoute + '/bounties': typeof BountiesLazyRoute '/login': typeof LoginLazyRoute '/playground': typeof PlaygroundLazyRoute '/podcasts': typeof PodcastsLazyRoute @@ -163,6 +172,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/authenticate-success' + | '/bounties' | '/login' | '/playground' | '/podcasts' @@ -180,6 +190,7 @@ export interface FileRouteTypes { to: | '/' | '/authenticate-success' + | '/bounties' | '/login' | '/playground' | '/podcasts' @@ -196,6 +207,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/authenticate-success' + | '/bounties' | '/login' | '/playground' | '/podcasts' @@ -214,6 +226,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute AuthenticateSuccessRoute: typeof AuthenticateSuccessRoute + BountiesLazyRoute: typeof BountiesLazyRoute LoginLazyRoute: typeof LoginLazyRoute PlaygroundLazyRoute: typeof PlaygroundLazyRoute PodcastsLazyRoute: typeof PodcastsLazyRoute @@ -253,6 +266,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginLazyRouteImport parentRoute: typeof rootRouteImport } + '/bounties': { + id: '/bounties' + path: '/bounties' + fullPath: '/bounties' + preLoaderRoute: typeof BountiesLazyRouteImport + parentRoute: typeof rootRouteImport + } '/authenticate-success': { id: '/authenticate-success' path: '/authenticate-success' @@ -358,6 +378,7 @@ const SpaceSpaceIdRouteWithChildren = SpaceSpaceIdRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AuthenticateSuccessRoute: AuthenticateSuccessRoute, + BountiesLazyRoute: BountiesLazyRoute, LoginLazyRoute: LoginLazyRoute, PlaygroundLazyRoute: PlaygroundLazyRoute, PodcastsLazyRoute: PodcastsLazyRoute, diff --git a/apps/events/src/routes/__root.tsx b/apps/events/src/routes/__root.tsx index 282f24fe..3f99c313 100644 --- a/apps/events/src/routes/__root.tsx +++ b/apps/events/src/routes/__root.tsx @@ -33,6 +33,9 @@ export const Route = createRootRoute({