Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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"
Comment thread
nikgraf marked this conversation as resolved.
],
"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
6 changes: 6 additions & 0 deletions packages/hypergraph/src/entity/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ export type EntityIdFilter = {
is?: string;
};

export type RelationEntityIdFilter = {
is?: string;
in?: readonly string[];
};
Comment thread
nikgraf marked this conversation as resolved.
Outdated

export type CrossFieldFilter<T, Extra extends object = Record<never, never>> = {
[K in keyof T]?: EntityFieldFilter<T[K]>;
} & Extra & {
Expand All @@ -84,6 +89,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