Skip to content

Commit 8478897

Browse files
nikgrafCopilot
andauthored
Add entity id for relation and backlink (#590)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent c99c502 commit 8478897

10 files changed

Lines changed: 452 additions & 18 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@graphprotocol/hypergraph": minor
3+
---
4+
5+
add entityId filter for relation and backlink fields

.claude/settings.local.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@
1515
"Bash(node -e:*)",
1616
"Bash(pnpm build:*)",
1717
"mcp__chrome-devtools__list_pages",
18-
"Bash(pnpm lint:*)"
18+
"Bash(pnpm lint:*)",
19+
"Bash(npx @tanstack/router-cli generate:*)",
20+
"mcp__chrome-devtools__navigate_page",
21+
"mcp__chrome-devtools__list_network_requests",
22+
"mcp__chrome-devtools__get_network_request",
23+
"mcp__chrome-devtools__evaluate_script"
1924
],
2025
"deny": [],
2126
"ask": []

apps/events/src/routeTree.gen.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const PodcastsInfiniteLazyRouteImport = createFileRoute('/podcasts-infinite')()
2727
const PodcastsLazyRouteImport = createFileRoute('/podcasts')()
2828
const PlaygroundLazyRouteImport = createFileRoute('/playground')()
2929
const LoginLazyRouteImport = createFileRoute('/login')()
30+
const BountiesLazyRouteImport = createFileRoute('/bounties')()
3031

3132
const PodcastsInfiniteLazyRoute = PodcastsInfiniteLazyRouteImport.update({
3233
id: '/podcasts-infinite',
@@ -50,6 +51,11 @@ const LoginLazyRoute = LoginLazyRouteImport.update({
5051
path: '/login',
5152
getParentRoute: () => rootRouteImport,
5253
} as any).lazy(() => import('./routes/login.lazy').then((d) => d.Route))
54+
const BountiesLazyRoute = BountiesLazyRouteImport.update({
55+
id: '/bounties',
56+
path: '/bounties',
57+
getParentRoute: () => rootRouteImport,
58+
} as any).lazy(() => import('./routes/bounties.lazy').then((d) => d.Route))
5359
const AuthenticateSuccessRoute = AuthenticateSuccessRouteImport.update({
5460
id: '/authenticate-success',
5561
path: '/authenticate-success',
@@ -110,6 +116,7 @@ const SpaceSpaceIdChatRoute = SpaceSpaceIdChatRouteImport.update({
110116
export interface FileRoutesByFullPath {
111117
'/': typeof IndexRoute
112118
'/authenticate-success': typeof AuthenticateSuccessRoute
119+
'/bounties': typeof BountiesLazyRoute
113120
'/login': typeof LoginLazyRoute
114121
'/playground': typeof PlaygroundLazyRoute
115122
'/podcasts': typeof PodcastsLazyRoute
@@ -127,6 +134,7 @@ export interface FileRoutesByFullPath {
127134
export interface FileRoutesByTo {
128135
'/': typeof IndexRoute
129136
'/authenticate-success': typeof AuthenticateSuccessRoute
137+
'/bounties': typeof BountiesLazyRoute
130138
'/login': typeof LoginLazyRoute
131139
'/playground': typeof PlaygroundLazyRoute
132140
'/podcasts': typeof PodcastsLazyRoute
@@ -144,6 +152,7 @@ export interface FileRoutesById {
144152
__root__: typeof rootRouteImport
145153
'/': typeof IndexRoute
146154
'/authenticate-success': typeof AuthenticateSuccessRoute
155+
'/bounties': typeof BountiesLazyRoute
147156
'/login': typeof LoginLazyRoute
148157
'/playground': typeof PlaygroundLazyRoute
149158
'/podcasts': typeof PodcastsLazyRoute
@@ -163,6 +172,7 @@ export interface FileRouteTypes {
163172
fullPaths:
164173
| '/'
165174
| '/authenticate-success'
175+
| '/bounties'
166176
| '/login'
167177
| '/playground'
168178
| '/podcasts'
@@ -180,6 +190,7 @@ export interface FileRouteTypes {
180190
to:
181191
| '/'
182192
| '/authenticate-success'
193+
| '/bounties'
183194
| '/login'
184195
| '/playground'
185196
| '/podcasts'
@@ -196,6 +207,7 @@ export interface FileRouteTypes {
196207
| '__root__'
197208
| '/'
198209
| '/authenticate-success'
210+
| '/bounties'
199211
| '/login'
200212
| '/playground'
201213
| '/podcasts'
@@ -214,6 +226,7 @@ export interface FileRouteTypes {
214226
export interface RootRouteChildren {
215227
IndexRoute: typeof IndexRoute
216228
AuthenticateSuccessRoute: typeof AuthenticateSuccessRoute
229+
BountiesLazyRoute: typeof BountiesLazyRoute
217230
LoginLazyRoute: typeof LoginLazyRoute
218231
PlaygroundLazyRoute: typeof PlaygroundLazyRoute
219232
PodcastsLazyRoute: typeof PodcastsLazyRoute
@@ -253,6 +266,13 @@ declare module '@tanstack/react-router' {
253266
preLoaderRoute: typeof LoginLazyRouteImport
254267
parentRoute: typeof rootRouteImport
255268
}
269+
'/bounties': {
270+
id: '/bounties'
271+
path: '/bounties'
272+
fullPath: '/bounties'
273+
preLoaderRoute: typeof BountiesLazyRouteImport
274+
parentRoute: typeof rootRouteImport
275+
}
256276
'/authenticate-success': {
257277
id: '/authenticate-success'
258278
path: '/authenticate-success'
@@ -358,6 +378,7 @@ const SpaceSpaceIdRouteWithChildren = SpaceSpaceIdRoute._addFileChildren(
358378
const rootRouteChildren: RootRouteChildren = {
359379
IndexRoute: IndexRoute,
360380
AuthenticateSuccessRoute: AuthenticateSuccessRoute,
381+
BountiesLazyRoute: BountiesLazyRoute,
361382
LoginLazyRoute: LoginLazyRoute,
362383
PlaygroundLazyRoute: PlaygroundLazyRoute,
363384
PodcastsLazyRoute: PodcastsLazyRoute,

apps/events/src/routes/__root.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ export const Route = createRootRoute({
3333
<nav className="ml-auto flex gap-4 sm:gap-6">
3434
{authenticated ? (
3535
<div className="flex items-center gap-4">
36+
<Link className="text-xs" to="/bounties">
37+
Bounties
38+
</Link>
3639
<Link className="text-xs" to="/podcasts">
3740
Podcasts
3841
</Link>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { useEntities } from '@graphprotocol/hypergraph-react';
2+
import { createLazyFileRoute } from '@tanstack/react-router';
3+
import { Bounty } from '@/schema';
4+
5+
export const Route = createLazyFileRoute('/bounties')({
6+
component: RouteComponent,
7+
});
8+
9+
const PERSON_ENTITY_ID = '7728d2458ae842d3a90a37e0bb8ee676';
10+
11+
function RouteComponent() {
12+
const {
13+
data: bounties,
14+
isLoading,
15+
isError,
16+
} = useEntities(Bounty, {
17+
mode: 'public',
18+
spaces: 'all',
19+
filter: {
20+
interestedIn: { entityId: PERSON_ENTITY_ID },
21+
},
22+
});
23+
24+
return (
25+
<div className="flex flex-col gap-4 max-w-(--breakpoint-sm) mx-auto py-8">
26+
<h1 className="text-2xl font-bold">Bounties</h1>
27+
<p className="text-sm text-gray-500">Bounties where person {PERSON_ENTITY_ID} expressed interest</p>
28+
29+
{isLoading && <div>Loading...</div>}
30+
{isError && <div>Error loading bounties</div>}
31+
32+
{!isLoading && bounties.length === 0 && <div className="text-gray-500">No bounties found</div>}
33+
34+
<ul className="flex flex-col gap-2">
35+
{bounties.map((bounty) => (
36+
<li key={bounty.id} className="border rounded p-4">
37+
<h2 className="font-semibold">{bounty.name}</h2>
38+
{bounty.description && <p className="text-sm text-gray-600">{bounty.description}</p>}
39+
<p className="text-xs text-gray-400 mt-1">{bounty.id}</p>
40+
</li>
41+
))}
42+
</ul>
43+
</div>
44+
);
45+
}

apps/events/src/schema.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,34 @@ export const PodcastHostTest = Entity.Schema(
360360
},
361361
);
362362

363+
export const PersonBacklink = Entity.Schema(
364+
{
365+
name: Type.String,
366+
},
367+
{
368+
types: [Id('7ed45f2bc48b419e8e4664d5ff680b0d')],
369+
properties: {
370+
name: Id(SystemIds.NAME_PROPERTY),
371+
},
372+
},
373+
);
374+
375+
export const Bounty = Entity.Schema(
376+
{
377+
name: Type.String,
378+
description: Type.optional(Type.String),
379+
interestedIn: Type.Backlink(PersonBacklink),
380+
},
381+
{
382+
types: [Id('808af0bad5884e3391f09dd4b25e18be')],
383+
properties: {
384+
name: Id(SystemIds.NAME_PROPERTY),
385+
description: Id(SystemIds.DESCRIPTION_PROPERTY),
386+
interestedIn: Id('ff7e1b4444a2419187324e6c222afe07'),
387+
},
388+
},
389+
);
390+
363391
export const PersonHostTest = Entity.Schema(
364392
{
365393
name: Type.String,

docs/docs/filtering-query-results.md

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Filtering Query Results
22

3-
The filter API allows you to filter the results of a query by property values and in the future also by relations.
3+
The filter API allows you to filter the results of a query by property values and relations.
44

55
## Filtering by property values
66

@@ -145,6 +145,72 @@ const { data } = useEntities(Person, {
145145

146146
## Relation filtering
147147

148+
### Filter by existence
149+
150+
You can filter entities based on whether a relation or backlink exists:
151+
152+
```tsx
153+
const { data } = useEntities(Todo, {
154+
filter: {
155+
assignees: { exists: true },
156+
},
157+
});
158+
```
159+
160+
### Filter by entity ID
161+
162+
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.
163+
164+
```tsx
165+
// string shorthand
166+
const { data } = useEntities(Todo, {
167+
filter: {
168+
assignees: { entityId: 'user-id' },
169+
},
170+
});
171+
172+
// object form with `is`
173+
const { data } = useEntities(Todo, {
174+
filter: {
175+
assignees: { entityId: { is: 'user-id' } },
176+
},
177+
});
178+
179+
// object form with `in` to match multiple entities
180+
const { data } = useEntities(Todo, {
181+
filter: {
182+
assignees: { entityId: { in: ['user-1', 'user-2'] } },
183+
},
184+
});
185+
```
186+
187+
This works the same way for backlink fields:
188+
189+
```tsx
190+
export const Bounty = Entity.Schema(
191+
{ name: Type.String, interestedIn: Type.Backlink(Person) },
192+
{
193+
types: [Id('bounty-type-id')],
194+
properties: {
195+
name: Id('name-property-id'),
196+
interestedIn: Id('interested-in-property-id'),
197+
},
198+
},
199+
);
200+
201+
// find all bounties where a specific person expressed interest
202+
const { data } = useEntities(Bounty, {
203+
filter: {
204+
interestedIn: { entityId: myPersonId },
205+
},
206+
});
207+
```
208+
209+
The framework automatically maps `entityId` to the correct GraphQL field:
210+
211+
- **Forward relations** (e.g. `Type.Relation(...)`) use `toEntityId`
212+
- **Backlinks** (e.g. `Type.Backlink(...)`) use `fromEntityId`
213+
148214
### Filter on values of the to entity
149215

150216
```tsx

packages/hypergraph/src/entity/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,16 @@ export type EntityIdFilter = {
7474
is?: string;
7575
};
7676

77+
export type RelationEntityIdFilter =
78+
| {
79+
is: string;
80+
in?: never;
81+
}
82+
| {
83+
in: readonly string[];
84+
is?: never;
85+
};
86+
7787
export type CrossFieldFilter<T, Extra extends object = Record<never, never>> = {
7888
[K in keyof T]?: EntityFieldFilter<T[K]>;
7989
} & Extra & {
@@ -84,6 +94,7 @@ export type CrossFieldFilter<T, Extra extends object = Record<never, never>> = {
8494
type RelationExistsFilter<T> = [T] extends [readonly unknown[] | undefined]
8595
? {
8696
exists?: boolean;
97+
entityId?: string | RelationEntityIdFilter;
8798
}
8899
: Record<never, never>;
89100

0 commit comments

Comments
 (0)