Skip to content

Commit 39617e4

Browse files
ryanrasticlaude
andcommitted
insert: VALUES accept typegres expressions (parity with SET)
InsertRow widens each column to `TsTypeOf | StripRequired` (same shape as SetRow), and the compile path embeds an incoming SqlValue via toSql() instead of re-wrapping it as a param. A hydrated column from one row now composes straight into another table's insert — no unwrap-to-primitive step. Covered in insert.test.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3940059 commit 39617e4

3 files changed

Lines changed: 54 additions & 4 deletions

File tree

src/builder/insert.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,48 @@ test("insert", async () => {
3535
});
3636
});
3737

38+
test("VALUES accept typegres expressions, not just primitives", async () => {
39+
await withinTransaction(async (tx) => {
40+
await tx.execute(sql`CREATE TABLE users (
41+
id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
42+
name text NOT NULL
43+
)`);
44+
await tx.execute(sql`CREATE TABLE posts (
45+
id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
46+
author_id int8 NOT NULL,
47+
body text NOT NULL
48+
)`);
49+
class Users extends db.Table("users") {
50+
id = Int8.column({ nonNull: true, generated: true });
51+
name = Text.column({ nonNull: true });
52+
}
53+
class Posts extends db.Table("posts") {
54+
id = Int8.column({ nonNull: true, generated: true });
55+
author_id = Int8.column({ nonNull: true });
56+
body = Text.column({ nonNull: true });
57+
}
58+
59+
// A hydrated row's columns are typegres expressions, not primitives —
60+
// and they flow straight into another table's VALUES (parity with SET).
61+
await tx.execute(Users.insert({ name: "alice" }));
62+
const [alice] = await tx.hydrate(Users.from().where(({ users }) => users.name.eq("alice")));
63+
64+
const [post] = await tx.execute(
65+
Posts.insert({ author_id: alice!.id, body: "hi" }).returning(({ posts }) => ({
66+
author_id: posts.author_id,
67+
})),
68+
);
69+
70+
// The FK landed alice's id: joining back recovers her name.
71+
const [row] = await tx.execute(
72+
Users.from()
73+
.where(({ users }) => users.id.eq(post!.author_id))
74+
.select(({ users }) => ({ name: users.name })),
75+
);
76+
expect(row).toEqual({ name: "alice" });
77+
});
78+
});
79+
3880
test("insert returning", async () => {
3981
await withinTransaction(async (tx) => {
4082
await tx.execute(sql`CREATE TABLE items (

src/builder/insert.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { RowType, RowTypeToTsType } from "./query";
44
import { compileSelectList, isRowType, mergeReturning, reAlias } from "./query";
55
import type { TableBase } from "../table";
66
import { Connection } from "../database";
7-
import { getColumn } from "../types/sql-value";
7+
import { getColumn, SqlValue } from "../types/sql-value";
88
import { meta } from "../types/sql-value";
99
import { fn, expose } from "../exoeval/tool";
1010
import z from "zod";
@@ -63,6 +63,11 @@ export class FinalizedInsert<Name extends string, T extends TableBase, R extends
6363
`provide '${k}' in every row or in none.`,
6464
);
6565
}
66+
if (v instanceof SqlValue) {
67+
// Already a typegres expression (e.g. a hydrated column from
68+
// another row) — embed it directly, don't re-wrap as a param.
69+
return v.toSql();
70+
}
6671
const col = getColumn(instance, k);
6772
return col[meta].__class.from(v).toSql();
6873
});

src/types/runtime.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,12 @@ export type OptionalKeys<R> = {
7070
[K in ColumnKeys<R>]: IsRequired<R[K]> extends true ? never : K;
7171
}[ColumnKeys<R>];
7272

73-
// Insert row: required columns + optional columns (as TsTypeOf)
74-
export type InsertRow<R> = { [K in RequiredKeys<R>]: TsTypeOf<R[K]> } & {
75-
[K in OptionalKeys<R>]?: TsTypeOf<R[K]>;
73+
// Insert row: required columns + optional columns. Like SET (SetRow), each
74+
// value is either the JS-side primitive or a typegres expression of the
75+
// same class & nullability — a hydrated column from another row composes
76+
// straight into VALUES.
77+
export type InsertRow<R> = { [K in RequiredKeys<R>]: TsTypeOf<R[K]> | StripRequired<R[K]> } & {
78+
[K in OptionalKeys<R>]?: TsTypeOf<R[K]> | StripRequired<R[K]>;
7679
};
7780

7881
// Drop the column-only `__required` marker from a column type's meta.

0 commit comments

Comments
 (0)