Skip to content

Commit 8d8998b

Browse files
authored
Add Relation.belongsTo / Relation.has for FK edge ergonomics (#89)
1 parent 4bc9a71 commit 8d8998b

15 files changed

Lines changed: 540 additions & 109 deletions

File tree

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)