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
5 changes: 5 additions & 0 deletions .changeset/add-entity-id-relation-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@graphprotocol/hypergraph": minor
---

add entityId filter for relation and backlink fields
7 changes: 6 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
Expand Down
21 changes: 21 additions & 0 deletions apps/events/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -163,6 +172,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/authenticate-success'
| '/bounties'
| '/login'
| '/playground'
| '/podcasts'
Expand All @@ -180,6 +190,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/authenticate-success'
| '/bounties'
| '/login'
| '/playground'
| '/podcasts'
Expand All @@ -196,6 +207,7 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/authenticate-success'
| '/bounties'
| '/login'
| '/playground'
| '/podcasts'
Expand All @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -358,6 +378,7 @@ const SpaceSpaceIdRouteWithChildren = SpaceSpaceIdRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuthenticateSuccessRoute: AuthenticateSuccessRoute,
BountiesLazyRoute: BountiesLazyRoute,
LoginLazyRoute: LoginLazyRoute,
PlaygroundLazyRoute: PlaygroundLazyRoute,
PodcastsLazyRoute: PodcastsLazyRoute,
Expand Down
3 changes: 3 additions & 0 deletions apps/events/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export const Route = createRootRoute({
<nav className="ml-auto flex gap-4 sm:gap-6">
{authenticated ? (
<div className="flex items-center gap-4">
<Link className="text-xs" to="/bounties">
Bounties
</Link>
<Link className="text-xs" to="/podcasts">
Podcasts
</Link>
Expand Down
45 changes: 45 additions & 0 deletions apps/events/src/routes/bounties.lazy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useEntities } from '@graphprotocol/hypergraph-react';
import { createLazyFileRoute } from '@tanstack/react-router';
import { Bounty } from '@/schema';

export const Route = createLazyFileRoute('/bounties')({
component: RouteComponent,
});

const PERSON_ENTITY_ID = '7728d2458ae842d3a90a37e0bb8ee676';

function RouteComponent() {
const {
data: bounties,
isLoading,
isError,
} = useEntities(Bounty, {
mode: 'public',
spaces: 'all',
filter: {
interestedIn: { entityId: PERSON_ENTITY_ID },
},
});

return (
<div className="flex flex-col gap-4 max-w-(--breakpoint-sm) mx-auto py-8">
<h1 className="text-2xl font-bold">Bounties</h1>
<p className="text-sm text-gray-500">Bounties where person {PERSON_ENTITY_ID} expressed interest</p>

{isLoading && <div>Loading...</div>}
{isError && <div>Error loading bounties</div>}

{!isLoading && bounties.length === 0 && <div className="text-gray-500">No bounties found</div>}

<ul className="flex flex-col gap-2">
{bounties.map((bounty) => (
<li key={bounty.id} className="border rounded p-4">
<h2 className="font-semibold">{bounty.name}</h2>
{bounty.description && <p className="text-sm text-gray-600">{bounty.description}</p>}
<p className="text-xs text-gray-400 mt-1">{bounty.id}</p>
</li>
))}
</ul>
</div>
);
}
28 changes: 28 additions & 0 deletions apps/events/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,34 @@ export const PodcastHostTest = Entity.Schema(
},
);

export const PersonBacklink = Entity.Schema(
{
name: Type.String,
},
{
types: [Id('7ed45f2bc48b419e8e4664d5ff680b0d')],
properties: {
name: Id(SystemIds.NAME_PROPERTY),
},
},
);

export const Bounty = Entity.Schema(
{
name: Type.String,
description: Type.optional(Type.String),
interestedIn: Type.Backlink(PersonBacklink),
},
{
types: [Id('808af0bad5884e3391f09dd4b25e18be')],
properties: {
name: Id(SystemIds.NAME_PROPERTY),
description: Id(SystemIds.DESCRIPTION_PROPERTY),
interestedIn: Id('ff7e1b4444a2419187324e6c222afe07'),
},
},
);

export const PersonHostTest = Entity.Schema(
{
name: Type.String,
Expand Down
68 changes: 67 additions & 1 deletion docs/docs/filtering-query-results.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions packages/hypergraph/src/entity/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ export type EntityIdFilter = {
is?: string;
};

export type RelationEntityIdFilter =
| {
is: string;
in?: never;
}
| {
in: readonly string[];
is?: never;
};

export type CrossFieldFilter<T, Extra extends object = Record<never, never>> = {
[K in keyof T]?: EntityFieldFilter<T[K]>;
} & Extra & {
Expand All @@ -84,6 +94,7 @@ export type CrossFieldFilter<T, Extra extends object = Record<never, never>> = {
type RelationExistsFilter<T> = [T] extends [readonly unknown[] | undefined]
? {
exists?: boolean;
entityId?: string | RelationEntityIdFilter;
}
: Record<never, never>;

Expand Down
Loading
Loading