Skip to content

Commit 3848e82

Browse files
committed
first iteration for relations
1 parent 8ddea57 commit 3848e82

File tree

8 files changed

+486
-8
lines changed

8 files changed

+486
-8
lines changed

apps/events/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"lucide-react": "^0.471.1",
2828
"react": "^19.0.0",
2929
"react-dom": "^19.0.0",
30+
"react-select": "^5.10.0",
3031
"siwe": "^2.3.2",
3132
"tailwind-merge": "^2.6.0",
3233
"tailwindcss-animate": "^1.0.7",

apps/events/src/components/todos.tsx

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,34 @@
11
import { useCreateEntity, useDeleteEntity, useQueryEntities, useUpdateEntity } from '@graphprotocol/hypergraph-react';
22
import { useState } from 'react';
3-
import { Todo } from '../schema';
3+
import Select from 'react-select';
4+
import { Todo, User } from '../schema';
45
import { Button } from './ui/button';
56
import { Input } from './ui/input';
67

78
export const Todos = () => {
89
const todos = useQueryEntities(Todo);
10+
const users = useQueryEntities(User);
911
const createEntity = useCreateEntity(Todo);
1012
const updateEntity = useUpdateEntity(Todo);
1113
const deleteEntity = useDeleteEntity();
12-
const [newTodoTitle, setNewTodoTitle] = useState('');
14+
const [newTodoName, setNewTodoName] = useState('');
15+
const [assignees, setAssignees] = useState<{ value: string; label: string }[]>([]);
1316

17+
const userOptions = users.map((user) => ({ value: user.id, label: user.name }));
1418
return (
1519
<>
1620
<h1 className="text-2xl font-bold">Todos</h1>
17-
<div className="flex flex-row gap-2">
18-
<Input type="text" value={newTodoTitle} onChange={(e) => setNewTodoTitle(e.target.value)} />
21+
<div className="flex flex-col gap-2">
22+
<Input type="text" value={newTodoName} onChange={(e) => setNewTodoName(e.target.value)} />
23+
<Select isMulti value={assignees} onChange={(e) => setAssignees(e.map((a) => a))} options={userOptions} />
1924
<Button
2025
onClick={() => {
21-
createEntity({ name: newTodoTitle, completed: false });
22-
setNewTodoTitle('');
26+
if (newTodoName === '') {
27+
alert('Todo text is required');
28+
return;
29+
}
30+
createEntity({ name: newTodoName, completed: false, assignees: assignees.map(({ value }) => value) });
31+
setNewTodoName('');
2332
}}
2433
>
2534
Create Todo
@@ -28,6 +37,11 @@ export const Todos = () => {
2837
{todos.map((todo) => (
2938
<div key={todo.id} className="flex flex-row items-center gap-2">
3039
<h2>{todo.name}</h2>
40+
{todo.assignees.length > 0 && (
41+
<span className="text-xs text-gray-500">
42+
Assigned to: {todo.assignees.map((assignee) => assignee.name).join(', ')}
43+
</span>
44+
)}
3145
<input
3246
type="checkbox"
3347
checked={todo.completed}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { useCreateEntity, useDeleteEntity, useQueryEntities } from '@graphprotocol/hypergraph-react';
2+
import { useState } from 'react';
3+
import { User } from '../schema';
4+
import { Button } from './ui/button';
5+
import { Input } from './ui/input';
6+
7+
export const Users = () => {
8+
const users = useQueryEntities(User);
9+
const createEntity = useCreateEntity(User);
10+
const deleteEntity = useDeleteEntity();
11+
const [newUserName, setNewUserName] = useState('');
12+
13+
return (
14+
<>
15+
<h1 className="text-2xl font-bold">Users</h1>
16+
<div className="flex flex-row gap-2">
17+
<Input type="text" value={newUserName} onChange={(e) => setNewUserName(e.target.value)} />
18+
<Button
19+
onClick={() => {
20+
createEntity({ name: newUserName });
21+
setNewUserName('');
22+
}}
23+
>
24+
Create User
25+
</Button>
26+
</div>
27+
{users.map((user) => (
28+
<div key={user.id} className="flex flex-row items-center gap-2">
29+
<h2>
30+
{user.name} <span className="text-xs text-gray-500">({user.id})</span>
31+
</h2>
32+
<Button onClick={() => deleteEntity(user.id)}>Delete</Button>
33+
</div>
34+
))}
35+
</>
36+
);
37+
};

apps/events/src/routes/space/$spaceId.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { DevTool } from '@/components/dev-tool';
77
import { Todos } from '@/components/todos';
88
import { TodosReadOnly } from '@/components/todos-read-only';
99
import { Button } from '@/components/ui/button';
10+
import { Users } from '@/components/users';
1011
import { availableAccounts } from '@/lib/availableAccounts';
1112
import { useEffect, useState } from 'react';
1213
import { getAddress } from 'viem';
@@ -39,6 +40,7 @@ function Space() {
3940
return (
4041
<div className="flex flex-col gap-4 max-w-screen-sm mx-auto py-8">
4142
<HypergraphSpaceProvider space={spaceId}>
43+
<Users />
4244
<Todos />
4345
<TodosReadOnly />
4446
{show2ndTodos && <Todos />}

apps/events/src/schema.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { Entity } from '@graphprotocol/hypergraph';
22

3+
export class User extends Entity.Class<User>('User')({
4+
id: Entity.Generated(Entity.Text),
5+
name: Entity.Text,
6+
}) {}
7+
38
export class Todo extends Entity.Class<Todo>('Todo')({
49
id: Entity.Generated(Entity.Text),
510
name: Entity.Text,
611
completed: Entity.Checkbox,
12+
assignees: Entity.Reference(Entity.ReferenceArray(User)),
713
}) {}

packages/hypergraph/src/Entity.ts

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as VariantSchema from '@effect/experimental/VariantSchema';
33
import * as Data from 'effect/Data';
44
import * as Schema from 'effect/Schema';
55
import { generateId } from './utils/generateId.js';
6+
import { hasArrayField } from './utils/hasArrayField.js';
67

78
const {
89
Class,
@@ -35,7 +36,6 @@ export type AnyNoContext = Schema.Schema.AnyNoContext & {
3536

3637
export type Update<S extends Any> = S['update'];
3738
export type Insert<S extends Any> = S['insert'];
38-
3939
export interface Generated<S extends Schema.Schema.All | Schema.PropertySignature.All>
4040
extends VariantSchema.Field<{
4141
readonly select: S;
@@ -88,6 +88,7 @@ type DecodedEntitiesCache = Map<
8888
string, // type name
8989
{
9090
decoder: (data: unknown) => unknown;
91+
type: Any; // TODO should be the type of the entity
9192
entities: Map<string, Entity<AnyNoContext>>; // holds all entities of this type
9293
queries: Map<
9394
string, // instead of serializedQueryKey as string we could also have the actual params
@@ -147,7 +148,39 @@ export const subscribeToDocumentChanges = (handle: DocHandle<DocumentContent>) =
147148
const cacheEntry = decodedEntitiesCache.get(typeName);
148149
if (!cacheEntry) continue;
149150

150-
const decoded = cacheEntry.decoder({ ...entity, id: entityId });
151+
const relations: Record<string, Entity<AnyNoContext>> = {};
152+
for (const [fieldName, field] of Object.entries(cacheEntry.type.fields)) {
153+
// check if the type exists in the cach and is a proper relation
154+
// TODO: what's the right way to get the name of the type?
155+
// @ts-expect-error name is defined
156+
const fieldCacheEntry = decodedEntitiesCache.get(field.name);
157+
if (!fieldCacheEntry) continue;
158+
159+
const relationEntities: Array<Entity<AnyNoContext>> = [];
160+
161+
if (hasArrayField(entity, fieldName)) {
162+
for (const relationEntityId of entity[fieldName]) {
163+
const relationEntity = doc.entities?.[relationEntityId];
164+
if (
165+
!relationEntity ||
166+
typeof relationEntity !== 'object' ||
167+
!('@@types@@' in relationEntity) ||
168+
!Array.isArray(relationEntity['@@types@@'])
169+
)
170+
continue;
171+
172+
relationEntities.push({ ...relationEntity, id: relationEntityId });
173+
}
174+
}
175+
176+
relations[fieldName] = relationEntities;
177+
}
178+
179+
const decoded = cacheEntry.decoder({
180+
...entity,
181+
...relations,
182+
id: entityId,
183+
});
151184
cacheEntry.entities.set(entityId, decoded);
152185

153186
const query = cacheEntry.queries.get('all');
@@ -379,11 +412,54 @@ export function subscribeToFindMany<const S extends AnyNoContext>(
379412

380413
decodedEntitiesCache.set(typeName, {
381414
decoder: decode,
415+
type,
382416
entities: entitiesMap,
383417
queries,
384418
});
385419
}
386420

421+
const allTypes = new Set<S>();
422+
for (const [_key, field] of Object.entries(type.fields)) {
423+
// TODO check if it is a class instead of specific name
424+
// TODO: what's the right way to extract the name from the ast
425+
// @ts-expect-error rest is defined
426+
if (field.ast.rest) {
427+
// @ts-expect-error name is defined
428+
const typeName = field.ast.rest[0].type.to.toString();
429+
if (typeName === 'User') {
430+
allTypes.add(field as S);
431+
}
432+
}
433+
}
434+
435+
for (const type of allTypes) {
436+
// TODO: what's the right way to get the name of the type?
437+
// @ts-expect-error name is defined
438+
const typeName = type.name;
439+
const entities = findMany(handle, type);
440+
441+
if (decodedEntitiesCache.has(typeName)) {
442+
// add a listener to the existing query
443+
const cacheEntry = decodedEntitiesCache.get(typeName);
444+
445+
for (const entity of entities) {
446+
cacheEntry?.entities.set(entity.id, entity);
447+
}
448+
} else {
449+
const entitiesMap = new Map();
450+
for (const entity of entities) {
451+
entitiesMap.set(entity.id, entity);
452+
}
453+
454+
decodedEntitiesCache.set(typeName, {
455+
decoder: decode,
456+
type,
457+
entities: entitiesMap,
458+
queries: new Map(),
459+
});
460+
}
461+
}
462+
387463
const subscribe = (callback: () => void) => {
388464
const query = decodedEntitiesCache.get(typeName)?.queries.get(queryKey);
389465
if (query?.listeners) {
@@ -435,3 +511,12 @@ export const findOne =
435511

436512
return undefined;
437513
};
514+
515+
export const Reference = <S extends Schema.Schema.All | Schema.PropertySignature.All>(schema: S) =>
516+
Field({
517+
select: schema,
518+
insert: Schema.optional(Schema.Array(Schema.String)),
519+
update: Schema.optional(Schema.Array(Schema.String)),
520+
});
521+
522+
export const ReferenceArray = Schema.Array;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const hasArrayField = (obj: unknown, key: string): obj is { [K in string]: string[] } => {
2+
// biome-ignore lint/suspicious/noExplicitAny: any is fine here
3+
return obj !== null && typeof obj === 'object' && key in obj && Array.isArray((obj as any)[key]);
4+
};

0 commit comments

Comments
 (0)