Skip to content

Commit 77da6dc

Browse files
committed
Add Relation.belongsTo / Relation.has for FK edge ergonomics
Collapse the codegen boilerplate Target.scope(Current.contextOf(this)).where(...).cardinality(...) into: Relation.belongsTo(this, Target, { pk: this.fk }[, { card }]) Relation.has(this, Target, { fk: this.pk }[, { card }]) Helpers live as free functions under a Relation namespace (not on TableBase) so row/query autocomplete stays about columns and @expose methods. Substrate is unchanged: scope + contextOf + where + cardinality. Context threads through including undefined (same as today). Card is an opts object for debugability and later attenuation params. Defaults: belongsTo → "one", has → "many"; codegen only emits { card } when non-default. Single-column FK maps only. Codegen emits the helpers, ensures Relation is imported in update mode, and stamps direction on derived relations. Examples (basic, sqlite) regenerated. Unit tests cover SQL parity, hydrate context, and card typing.
1 parent 4bc9a71 commit 77da6dc

15 files changed

Lines changed: 515 additions & 69 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@ site/public/typegres.js
1313
site/public/typegres.d.ts
1414
packages/
1515
.claude/
16+
17+
# Local planning notes (not for the repo)
18+
ergo-plan.md
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db } from "../db";
2-
import { expose } from "typegres";
2+
import { Relation, expose } from "typegres";
33
import { Int8, Text } from "typegres/postgres";
44
import { Dogs } from "./dogs";
55

@@ -9,6 +9,6 @@ export class Collars extends db.Table("collars") {
99
@expose() color = Text.column({ nonNull: true });
1010
@expose() dog_id = Int8.column({ nonNull: true });
1111
// relations
12-
@expose() dog() { return Dogs.scope(Collars.contextOf(this)).where(({ dogs }) => dogs.id.eq(this.dog_id)).cardinality("one"); }
12+
@expose() dog() { return Relation.belongsTo(this, Dogs, { id: this.dog_id }); }
1313
// @generated-end
1414
}

examples/basic/src/tables/dogs.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db } from "../db";
2-
import { expose, sql } from "typegres";
2+
import { Relation, expose, sql } from "typegres";
33
import { Int8, Text, Timestamptz } from "typegres/postgres";
44
import { Teams } from "./teams";
55
import { Collars } from "./collars";
@@ -15,11 +15,11 @@ export class Dogs extends db.Table("dogs") {
1515
@expose() team_id = Int8.column({ nonNull: true });
1616
@expose() rival_id = Int8.column();
1717
// relations
18-
@expose() rival() { return Dogs.scope(Dogs.contextOf(this)).where(({ dogs }) => dogs.id.eq(this.rival_id)).cardinality("maybe"); }
19-
@expose() team() { return Teams.scope(Dogs.contextOf(this)).where(({ teams }) => teams.id.eq(this.team_id)).cardinality("one"); }
20-
@expose() collars() { return Collars.scope(Dogs.contextOf(this)).where(({ collars }) => collars.dog_id.eq(this.id)).cardinality("one"); }
21-
@expose() dogs() { return Dogs.scope(Dogs.contextOf(this)).where(({ dogs }) => dogs.rival_id.eq(this.id)).cardinality("many"); }
22-
@expose() microchips() { return Microchips.scope(Dogs.contextOf(this)).where(({ microchips }) => microchips.dog_id.eq(this.id)).cardinality("maybe"); }
23-
@expose() toys() { return Toys.scope(Dogs.contextOf(this)).where(({ toys }) => toys.dog_id.eq(this.id)).cardinality("many"); }
18+
@expose() rival() { return Relation.belongsTo(this, Dogs, { id: this.rival_id }, { card: "maybe" }); }
19+
@expose() team() { return Relation.belongsTo(this, Teams, { id: this.team_id }); }
20+
@expose() collars() { return Relation.has(this, Collars, { dog_id: this.id }, { card: "one" }); }
21+
@expose() dogs() { return Relation.has(this, Dogs, { rival_id: this.id }); }
22+
@expose() microchips() { return Relation.has(this, Microchips, { dog_id: this.id }, { card: "maybe" }); }
23+
@expose() toys() { return Relation.has(this, Toys, { dog_id: this.id }); }
2424
// @generated-end
2525
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db } from "../db";
2-
import { expose } from "typegres";
2+
import { Relation, expose } from "typegres";
33
import { Int8, Text } from "typegres/postgres";
44
import { Dogs } from "./dogs";
55

@@ -9,6 +9,6 @@ export class Microchips extends db.Table("microchips") {
99
@expose() serial = Text.column({ nonNull: true });
1010
@expose() dog_id = Int8.column();
1111
// relations
12-
@expose() dog() { return Dogs.scope(Microchips.contextOf(this)).where(({ dogs }) => dogs.id.eq(this.dog_id)).cardinality("maybe"); }
12+
@expose() dog() { return Relation.belongsTo(this, Dogs, { id: this.dog_id }, { card: "maybe" }); }
1313
// @generated-end
1414
}

examples/basic/src/tables/teams.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db } from "../db";
2-
import { expose } from "typegres";
2+
import { Relation, expose } from "typegres";
33
import { Int8, Text } from "typegres/postgres";
44
import { Dogs } from "./dogs";
55

@@ -8,6 +8,6 @@ export class Teams extends db.Table("teams") {
88
@expose() id = Int8.column({ nonNull: true, generated: true });
99
@expose() name = Text.column({ nonNull: true });
1010
// relations
11-
@expose() dogs() { return Dogs.scope(Teams.contextOf(this)).where(({ dogs }) => dogs.team_id.eq(this.id)).cardinality("many"); }
11+
@expose() dogs() { return Relation.has(this, Dogs, { team_id: this.id }); }
1212
// @generated-end
1313
}

examples/basic/src/tables/toys.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db } from "../db";
2-
import { expose } from "typegres";
2+
import { Relation, expose } from "typegres";
33
import { Int8, Text } from "typegres/postgres";
44
import { Dogs } from "./dogs";
55

@@ -9,6 +9,6 @@ export class Toys extends db.Table("toys") {
99
@expose() name = Text.column({ nonNull: true });
1010
@expose() dog_id = Int8.column({ nonNull: true });
1111
// relations
12-
@expose() dog() { return Dogs.scope(Toys.contextOf(this)).where(({ dogs }) => dogs.id.eq(this.dog_id)).cardinality("one"); }
12+
@expose() dog() { return Relation.belongsTo(this, Dogs, { id: this.dog_id }); }
1313
// @generated-end
1414
}

examples/sqlite/src/tables/dogs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db } from "../db";
2-
import { expose } from "typegres";
2+
import { Relation, expose } from "typegres";
33
import { Integer, Text } from "typegres/sqlite";
44
import { Teams } from "./teams";
55

@@ -10,6 +10,6 @@ export class Dogs extends db.Table("dogs") {
1010
@expose() breed = Text.column();
1111
@expose() team_id = Integer.column({ nonNull: true });
1212
// relations
13-
@expose() team() { return Teams.scope(Dogs.contextOf(this)).where(({ teams }) => teams.id.eq(this.team_id)).cardinality("one"); }
13+
@expose() team() { return Relation.belongsTo(this, Teams, { id: this.team_id }); }
1414
// @generated-end
1515
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db } from "../db";
2-
import { expose } from "typegres";
2+
import { Relation, expose } from "typegres";
33
import { Integer, Text } from "typegres/sqlite";
44
import { Dogs } from "./dogs";
55

@@ -8,6 +8,6 @@ export class Teams extends db.Table("teams") {
88
@expose() id = Integer.column({ nonNull: true, generated: true });
99
@expose() name = Text.column({ nonNull: true });
1010
// relations
11-
@expose() dogs() { return Dogs.scope(Teams.contextOf(this)).where(({ dogs }) => dogs.team_id.eq(this.id)).cardinality("many"); }
11+
@expose() dogs() { return Relation.has(this, Dogs, { team_id: this.id }); }
1212
// @generated-end
1313
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
export { Database, Connection } from "./database";
99
export type { TransactionIsolation, TransactionOptions } from "./database";
1010
export { Table } from "./table";
11+
export { Relation } from "./relation";
1112
export { sql, Sql } from "./builder/sql";
1213
export { QueryBuilder } from "./builder/query";
1314
export { TypegresLiveEvents } from "./live/events";

src/relation.test.ts

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { describe, test, expect, expectTypeOf } from "vitest";
2+
import { sql } from ".";
3+
import { Int8, Text } from "./types/postgres";
4+
import type { Record, Anyarray } from "./types/postgres";
5+
import { compile } from "./builder/sql";
6+
import { Relation } from "./relation";
7+
import { setupDb, withinTransaction, db } from "./test-helpers";
8+
setupDb();
9+
10+
// Relation.* collapses the scope+contextOf+where+cardinality boilerplate
11+
// used by codegen'd FK edges. Behavior must match the hand-written form.
12+
13+
describe("Relation helpers", () => {
14+
test("belongsTo / has emit the same SQL as scope+where+cardinality", async () => {
15+
await withinTransaction(async (tx) => {
16+
await tx.execute(sql`CREATE TABLE authors (
17+
id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
18+
name text NOT NULL
19+
)`);
20+
await tx.execute(sql`CREATE TABLE books (
21+
id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
22+
title text NOT NULL,
23+
author_id int8 NOT NULL REFERENCES authors(id)
24+
)`);
25+
await tx.execute(sql`INSERT INTO authors (name) VALUES ('Asimov')`);
26+
await tx.execute(sql`INSERT INTO books (title, author_id) VALUES ('Foundation', 1)`);
27+
28+
class Authors extends db.Table("authors") {
29+
id = Int8.column({ nonNull: true, generated: true });
30+
name = Text.column({ nonNull: true });
31+
}
32+
class Books extends db.Table("books") {
33+
id = Int8.column({ nonNull: true, generated: true });
34+
title = Text.column({ nonNull: true });
35+
author_id = Int8.column({ nonNull: true });
36+
37+
authorHelper() {
38+
return Relation.belongsTo(this, Authors, { id: this.author_id });
39+
}
40+
authorHand() {
41+
return Authors.scope(Books.contextOf(this))
42+
.where(({ authors }) => authors.id.eq(this.author_id))
43+
.cardinality("one");
44+
}
45+
}
46+
47+
const [book] = await tx.hydrate(Books.from());
48+
const ctx = { database: db };
49+
50+
const viaHelper = book!.authorHelper();
51+
const viaHand = book!.authorHand();
52+
expect(compile(viaHelper as any, ctx).text).toBe(compile(viaHand as any, ctx).text);
53+
expect(compile(viaHelper as any, ctx).values).toEqual(compile(viaHand as any, ctx).values);
54+
55+
const [author] = await tx.hydrate(Authors.from());
56+
const manyHelper = Relation.has(author!, Books, { author_id: author!.id });
57+
const manyHand = Books.scope(Authors.contextOf(author!))
58+
.where(({ books }) => books.author_id.eq(author!.id))
59+
.cardinality("many");
60+
expect(compile(manyHelper as any, ctx).text).toBe(compile(manyHand as any, ctx).text);
61+
});
62+
});
63+
64+
test("has with card one / maybe emit the same SQL as hand-written", async () => {
65+
await withinTransaction(async (tx) => {
66+
await tx.execute(sql`CREATE TABLE dogs (
67+
id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY
68+
)`);
69+
await tx.execute(sql`CREATE TABLE collars (
70+
id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
71+
dog_id int8 NOT NULL REFERENCES dogs(id) UNIQUE
72+
)`);
73+
await tx.execute(sql`CREATE TABLE microchips (
74+
id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
75+
dog_id int8 UNIQUE REFERENCES dogs(id)
76+
)`);
77+
await tx.execute(sql`INSERT INTO dogs DEFAULT VALUES`);
78+
await tx.execute(sql`INSERT INTO collars (dog_id) VALUES (1)`);
79+
80+
class Dogs extends db.Table("dogs") {
81+
id = Int8.column({ nonNull: true, generated: true });
82+
}
83+
class Collars extends db.Table("collars") {
84+
id = Int8.column({ nonNull: true, generated: true });
85+
dog_id = Int8.column({ nonNull: true });
86+
}
87+
class Microchips extends db.Table("microchips") {
88+
id = Int8.column({ nonNull: true, generated: true });
89+
dog_id = Int8.column();
90+
}
91+
92+
const [dog] = await tx.hydrate(Dogs.from());
93+
const ctx = { database: db };
94+
95+
const oneHelper = Relation.has(dog!, Collars, { dog_id: dog!.id }, { card: "one" });
96+
const oneHand = Collars.scope(Dogs.contextOf(dog!))
97+
.where(({ collars }) => collars.dog_id.eq(dog!.id))
98+
.cardinality("one");
99+
expect(compile(oneHelper as any, ctx).text).toBe(compile(oneHand as any, ctx).text);
100+
101+
const maybeHelper = Relation.has(dog!, Microchips, { dog_id: dog!.id }, { card: "maybe" });
102+
const maybeHand = Microchips.scope(Dogs.contextOf(dog!))
103+
.where(({ microchips }) => microchips.dog_id.eq(dog!.id))
104+
.cardinality("maybe");
105+
expect(compile(maybeHelper as any, ctx).text).toBe(compile(maybeHand as any, ctx).text);
106+
// one vs maybe share SQL shape (card only affects .scalar typing);
107+
// different target tables → different table names, but same structure.
108+
expect(compile(oneHelper as any, ctx).text.replaceAll("collars", "T"))
109+
.toBe(compile(maybeHelper as any, ctx).text.replaceAll("microchips", "T"));
110+
});
111+
});
112+
113+
test("threads parent context through hydrate (belongsTo hop)", async () => {
114+
await withinTransaction(async (tx) => {
115+
await tx.execute(sql`CREATE TABLE authors (
116+
id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
117+
name text NOT NULL
118+
)`);
119+
await tx.execute(sql`CREATE TABLE books (
120+
id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
121+
title text NOT NULL,
122+
author_id int8 NOT NULL REFERENCES authors(id)
123+
)`);
124+
await tx.execute(sql`INSERT INTO authors (name) VALUES ('Asimov')`);
125+
await tx.execute(sql`INSERT INTO books (title, author_id) VALUES ('Foundation', 1)`);
126+
127+
type P = { user: string };
128+
class Authors extends db.Table<"authors", P>("authors") {
129+
id = Int8.column({ nonNull: true, generated: true });
130+
name = Text.column({ nonNull: true });
131+
}
132+
class Books extends db.Table<"books", P>("books") {
133+
id = Int8.column({ nonNull: true, generated: true });
134+
title = Text.column({ nonNull: true });
135+
author_id = Int8.column({ nonNull: true });
136+
137+
author() {
138+
return Relation.belongsTo(this, Authors, { id: this.author_id });
139+
}
140+
}
141+
142+
const principal: P = { user: "alice" };
143+
const [book] = await tx.hydrate(Books.scope(principal).orderBy(({ books }) => books.id));
144+
expect(Books.contextOf(book!)).toBe(principal);
145+
146+
const [author] = await tx.hydrate(book!.author());
147+
expect(Authors.contextOf(author!)).toBe(principal);
148+
});
149+
});
150+
151+
test("undefined parent context passes through (same as scope(undefined))", async () => {
152+
await withinTransaction(async (tx) => {
153+
await tx.execute(sql`CREATE TABLE authors (
154+
id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
155+
name text NOT NULL
156+
)`);
157+
await tx.execute(sql`CREATE TABLE books (
158+
id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
159+
title text NOT NULL,
160+
author_id int8 NOT NULL REFERENCES authors(id)
161+
)`);
162+
await tx.execute(sql`INSERT INTO authors (name) VALUES ('Asimov')`);
163+
await tx.execute(sql`INSERT INTO books (title, author_id) VALUES ('Foundation', 1)`);
164+
165+
class Authors extends db.Table("authors") {
166+
id = Int8.column({ nonNull: true, generated: true });
167+
name = Text.column({ nonNull: true });
168+
}
169+
class Books extends db.Table("books") {
170+
id = Int8.column({ nonNull: true, generated: true });
171+
title = Text.column({ nonNull: true });
172+
author_id = Int8.column({ nonNull: true });
173+
174+
author() {
175+
return Relation.belongsTo(this, Authors, { id: this.author_id });
176+
}
177+
}
178+
179+
// Plain from() — no scope tag on the parent row.
180+
const [book] = await tx.hydrate(Books.from());
181+
expect(Books.contextOf(book!)).toBeUndefined();
182+
183+
const [author] = await tx.hydrate(book!.author());
184+
expect(Authors.contextOf(author!)).toBeUndefined();
185+
});
186+
});
187+
188+
test("belongsTo / has card opts are typed into QueryBuilder", () => {
189+
class Authors extends db.Table("authors") {
190+
id = Int8.column({ nonNull: true, generated: true });
191+
}
192+
class Books extends db.Table("books") {
193+
author_id = Int8.column({ nonNull: true });
194+
rival_id = Int8.column();
195+
}
196+
const book = Books.rowType();
197+
const author = Authors.rowType();
198+
199+
// Card is the 4th type param on QueryBuilder; .scalar() return type
200+
// is the public probe (same pattern as rpc-exoeval tests).
201+
const one = Relation.belongsTo(book, Authors, { id: book.author_id });
202+
const maybe = Relation.belongsTo(book, Authors, { id: book.rival_id }, { card: "maybe" });
203+
const many = Relation.has(author, Books, { author_id: author.id });
204+
const hasOne = Relation.has(author, Books, { author_id: author.id }, { card: "one" });
205+
206+
expectTypeOf(one.scalar()).toMatchTypeOf<Record<1, any>>();
207+
expectTypeOf(maybe.scalar()).toMatchTypeOf<Record<0 | 1, any>>();
208+
expectTypeOf(hasOne.scalar()).toMatchTypeOf<Record<1, any>>();
209+
expectTypeOf(many.scalar()).toMatchTypeOf<Anyarray<any, 1>>();
210+
});
211+
212+
test("rejects multi-column FK maps at runtime", () => {
213+
class A extends db.Table("a") {
214+
id = Int8.column({ nonNull: true });
215+
x = Int8.column({ nonNull: true });
216+
}
217+
class B extends db.Table("b") {
218+
a_id = Int8.column({ nonNull: true });
219+
a_x = Int8.column({ nonNull: true });
220+
}
221+
const b = B.rowType();
222+
expect(() =>
223+
Relation.belongsTo(b, A, { id: b.a_id, x: b.a_x } as any),
224+
).toThrow(/single-column FK map/);
225+
});
226+
});

0 commit comments

Comments
 (0)