Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 4 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ driver's pool) or **transaction-bound** (carries a single-connection

```ts
await conn.transaction(async (tx) => {
await tx.execute(User.insert(...));
await User.from().execute(tx); // fluent form
await User.insert(...).execute(tx);
await User.from().execute(tx);
});
```

Expand Down Expand Up @@ -124,8 +124,8 @@ type level.
or rows outside the scope it was handed.
- `.execute(conn)`, `.hydrate(conn)`, `.one(conn)`, `.maybeOne(conn)`,
`.live(conn)` are fluent terminators that accept any `Connection` (pool or
tx), or none at all to use `db.defaultConnection`; `conn.execute(...)` /
`conn.hydrate(...)` are the non-fluent equivalents.
tx), or none at all to use `db.defaultConnection`. `conn.execute(...)`
remains the direct path for raw `Sql` statements.

`hydrate` materializes rows as class instances — each column field is an
`Any` wrapping a `CAST(param)` of the value, so methods on the class
Expand Down
77 changes: 42 additions & 35 deletions src/builder/delete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@ test("delete with where", async () => {
await tx.execute(sql`INSERT INTO logs (msg) VALUES ('keep'), ('remove'), ('keep2')`);

class Logs extends db.Table("logs") {
id = Int8.column({ nonNull: true, generated: true }); msg = Text.column({ nonNull: true }); }
id = Int8.column({ nonNull: true, generated: true });
msg = Text.column({ nonNull: true });
}

await tx.execute(Logs.delete().where(({ logs }) => logs.msg["="]("remove")));
await Logs.delete()
.where(({ logs }) => logs.msg["="]("remove"))
.execute(tx);

const rows = await tx.execute(
Logs.from().select(({ logs }) => ({ msg: logs.msg })),
);
const rows = await Logs.from()
.select(({ logs }) => ({ msg: logs.msg }))
.execute(tx);

expect(rows).toEqual([{ msg: "keep" }, { msg: "keep2" }]);
});
Expand All @@ -34,13 +38,14 @@ test("delete returning", async () => {
await tx.execute(sql`INSERT INTO tags (name) VALUES ('a'), ('b'), ('c')`);

class Tags extends db.Table("tags") {
id = Int8.column({ nonNull: true, generated: true }); name = Text.column({ nonNull: true }); }
id = Int8.column({ nonNull: true, generated: true });
name = Text.column({ nonNull: true });
}

const rows = await tx.execute(
Tags.delete()
.where(({ tags }) => tags.name["="]("b"))
.returning(({ tags }) => ({ id: tags.id, name: tags.name })),
);
const rows = await Tags.delete()
.where(({ tags }) => tags.name["="]("b"))
.returning(({ tags }) => ({ id: tags.id, name: tags.name }))
.execute(tx);

expectTypeOf(rows).toEqualTypeOf<{ id: string; name: string }[]>();
expect(rows).toEqual([{ id: "2", name: "b" }]);
Expand All @@ -54,22 +59,25 @@ test("delete: multiple where calls AND-combine", async () => {
name text NOT NULL,
score int8 NOT NULL DEFAULT 0
)`);
await tx.execute(sql`INSERT INTO items (name, score) VALUES ('a', 10), ('b', 20), ('c', 10), ('d', 30)`);
await tx.execute(
sql`INSERT INTO items (name, score) VALUES ('a', 10), ('b', 20), ('c', 10), ('d', 30)`,
);

class Items extends db.Table("items") {
id = Int8.column({ nonNull: true, generated: true }); name = Text.column({ nonNull: true }); score = Int8.column({ nonNull: true, default: sql`0` }); }
id = Int8.column({ nonNull: true, generated: true });
name = Text.column({ nonNull: true });
score = Int8.column({ nonNull: true, default: sql`0` });
}

await tx.execute(
Items.delete()
.where(({ items }) => items.score["="]("10"))
.where(({ items }) => items.name["="]("a")),
);
await Items.delete()
.where(({ items }) => items.score["="]("10"))
.where(({ items }) => items.name["="]("a"))
.execute(tx);

const rows = await tx.execute(
Items.from()
.select(({ items }) => ({ name: items.name }))
.orderBy(({ items }) => items.name),
);
const rows = await Items.from()
.select(({ items }) => ({ name: items.name }))
.orderBy(({ items }) => items.name)
.execute(tx);

expect(rows).toEqual([{ name: "b" }, { name: "c" }, { name: "d" }]);
});
Expand All @@ -92,17 +100,15 @@ test("delete: where(true) after a real .where() is a no-op", async () => {
name = Text.column({ nonNull: true });
}

await tx.execute(
Guards.delete()
.where(({ guards }) => guards.name["="]("doomed"))
.where(true),
);
await Guards.delete()
.where(({ guards }) => guards.name["="]("doomed"))
.where(true)
.execute(tx);

const rows = await tx.execute(
Guards.from()
.select(({ guards }) => ({ name: guards.name }))
.orderBy(({ guards }) => guards.name),
);
const rows = await Guards.from()
.select(({ guards }) => ({ name: guards.name }))
.orderBy(({ guards }) => guards.name)
.execute(tx);

expect(rows).toEqual([{ name: "keep" }, { name: "keep2" }]);
});
Expand All @@ -113,8 +119,9 @@ test("delete without where throws", async () => {
await tx.execute(sql`CREATE TABLE noop2 (id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY)`);

class Noop2 extends db.Table("noop2") {
id = Int8.column({ nonNull: true, generated: true }); }
id = Int8.column({ nonNull: true, generated: true });
}

await expect(tx.execute(Noop2.delete())).rejects.toThrow("requires .where()");
await expect(Noop2.delete().execute(tx)).rejects.toThrow("requires .where()");
});
});
7 changes: 6 additions & 1 deletion src/builder/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,15 @@ export class FinalizedDelete<Name extends string, T extends TableBase, R extends
bind(): BoundSql {
const { tableName, alias, where, returning, instance } = this.opts;
const tableCls = instance.constructor;
const oracle = tableCls.database.dialect === "oracle";
if (oracle && returning) {
throw new Error(".returning() is not yet supported on oracle mutations");
}
// See UpdateBuilder for the matchAll semantics: a real predicate
// always takes precedence over the matchAll flag.
const aliasClause = oracle ? sql`${alias}` : sql`AS ${alias}`;
const inner = sql.join([
sql`DELETE FROM ${tableCls.ident(tableName)} AS ${alias}`,
sql`DELETE FROM ${tableCls.ident(tableName)} ${aliasClause}`,
where && sql`WHERE ${where.toSql()}`,
returning && sql`RETURNING ${compileSelectList(returning)}`,
], sql` `);
Expand Down
98 changes: 58 additions & 40 deletions src/builder/insert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,20 @@ test("insert", async () => {
)`);

class Cats extends db.Table("cats") {
id = Int8.column({ nonNull: true, generated: true }); name = Text.column({ nonNull: true }); color = Text.column(); }
id = Int8.column({ nonNull: true, generated: true });
name = Text.column({ nonNull: true });
color = Text.column();
}

// name is required, id and color are optional
// @ts-expect-error — missing required field 'name'
const _bad: InsertRow<InstanceType<typeof Cats>> = { color: "black" };

await tx.execute(Cats.insert({ name: "Whiskers" }, { name: "Tom", color: "orange" }));
await Cats.insert({ name: "Whiskers" }, { name: "Tom", color: "orange" }).execute(tx);

const rows = await tx.execute(
Cats.from().select(({ cats }) => ({ name: cats.name, color: cats.color })),
);
const rows = await Cats.from()
.select(({ cats }) => ({ name: cats.name, color: cats.color }))
.execute(tx);

expect(rows).toEqual([
{ name: "Whiskers", color: null },
Expand Down Expand Up @@ -59,21 +62,20 @@ test("VALUES accept typegres expressions, not just primitives", async () => {

// A hydrated row's columns are typegres expressions, not primitives —
// and they flow straight into another table's VALUES (parity with SET).
await tx.execute(Users.insert({ name: "alice" }));
await Users.insert({ name: "alice" }).execute(tx);
const [alice] = await tx.hydrate(Users.from().where(({ users }) => users.name.eq("alice")));

const [post] = await tx.execute(
Posts.insert({ author_id: alice!.id, body: "hi" }).returning(({ posts }) => ({
const [post] = await Posts.insert({ author_id: alice!.id, body: "hi" })
.returning(({ posts }) => ({
author_id: posts.author_id,
})),
);
}))
.execute(tx);

// The FK landed alice's id: joining back recovers her name.
const [row] = await tx.execute(
Users.from()
.where(({ users }) => users.id.eq(post!.author_id))
.select(({ users }) => ({ name: users.name })),
);
const [row] = await Users.from()
.where(({ users }) => users.id.eq(post!.author_id))
.select(({ users }) => ({ name: users.name }))
.execute(tx);
expect(row).toEqual({ name: "alice" });
});
});
Expand All @@ -86,12 +88,13 @@ test("insert returning", async () => {
)`);

class Items extends db.Table("items") {
id = Int8.column({ nonNull: true, generated: true }); label = Text.column({ nonNull: true }); }
id = Int8.column({ nonNull: true, generated: true });
label = Text.column({ nonNull: true });
}

const rows = await tx.execute(
Items.insert({ label: "A" }, { label: "B" })
.returning(({ items }) => ({ id: items.id, label: items.label })),
);
const rows = await Items.insert({ label: "A" }, { label: "B" })
.returning(({ items }) => ({ id: items.id, label: items.label }))
.execute(tx);

expectTypeOf(rows).toEqualTypeOf<{ id: string; label: string }[]>();
expect(rows).toEqual([
Expand All @@ -110,14 +113,16 @@ test("columns no row provides are pruned so DB defaults apply", async () => {
)`);

class Tagged extends db.Table("tagged") {
id = Int8.column({ nonNull: true, generated: true }); label = Text.column({ nonNull: true }); status = Text.column({ nonNull: true, default: sql`'new'` }); }
id = Int8.column({ nonNull: true, generated: true });
label = Text.column({ nonNull: true });
status = Text.column({ nonNull: true, default: sql`'new'` });
}

// `status` appears in no row → pruned from the column list → the
// DB's DEFAULT 'new' applies (not NULL, not an error).
const rows = await tx.execute(
Tagged.insert({ label: "A" }, { label: "B" })
.returning(({ tagged }) => ({ label: tagged.label, status: tagged.status })),
);
const rows = await Tagged.insert({ label: "A" }, { label: "B" })
.returning(({ tagged }) => ({ label: tagged.label, status: tagged.status }))
.execute(tx);
expect(rows).toEqual([
{ label: "A", status: "new" },
{ label: "B", status: "new" },
Expand All @@ -134,12 +139,14 @@ test("postgres: column provided in some rows but not others → DEFAULT keyword
)`);

class Mixed extends db.Table("mixed") {
id = Int8.column({ nonNull: true, generated: true }); label = Text.column({ nonNull: true }); status = Text.column({ nonNull: true, default: sql`'new'` }); }
id = Int8.column({ nonNull: true, generated: true });
label = Text.column({ nonNull: true });
status = Text.column({ nonNull: true, default: sql`'new'` });
}

const rows = await tx.execute(
Mixed.insert({ label: "A" }, { label: "B", status: "old" })
.returning(({ mixed }) => ({ label: mixed.label, status: mixed.status })),
);
const rows = await Mixed.insert({ label: "A" }, { label: "B", status: "old" })
.returning(({ mixed }) => ({ label: mixed.label, status: mixed.status }))
.execute(tx);
expect(rows).toEqual([
{ label: "A", status: "new" },
{ label: "B", status: "old" },
Expand All @@ -151,14 +158,19 @@ test("sqlite: pruning defers to rowid autoincrement and declared defaults", asyn
const sdb = typegres();
const conn = sdb.connect(SqliteDriver.create(":memory:"));
try {
await conn.execute(sql.raw(`CREATE TABLE tagged (
await conn.execute(
sql.raw(`CREATE TABLE tagged (
id INTEGER PRIMARY KEY,
label TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'new'
)`));
)`),
);

class Tagged extends sdb.Table("tagged") {
id = (sqlite.Integer<1>).column({ nonNull: true, generated: true }); label = (sqlite.Text<1>).column({ nonNull: true }); status = (sqlite.Text<1>).column({ nonNull: true, default: sql`'new'` }); }
id = (sqlite.Integer<1>).column({ nonNull: true, generated: true });
label = (sqlite.Text<1>).column({ nonNull: true });
status = (sqlite.Text<1>).column({ nonNull: true, default: sql`'new'` });
}

// Previously this inserted NULL for id (ok, rowid quirk) AND for
// status (NOT NULL violation). Pruning makes both work natively.
Expand All @@ -178,14 +190,19 @@ test("sqlite: heterogeneous rows raise instead of silently inserting NULL", asyn
const sdb = typegres();
const conn = sdb.connect(SqliteDriver.create(":memory:"));
try {
await conn.execute(sql.raw(`CREATE TABLE mixed (
await conn.execute(
sql.raw(`CREATE TABLE mixed (
id INTEGER PRIMARY KEY,
label TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'new'
)`));
)`),
);

class Mixed extends sdb.Table("mixed") {
id = (sqlite.Integer<1>).column({ nonNull: true, generated: true }); label = (sqlite.Text<1>).column({ nonNull: true }); status = (sqlite.Text<1>).column({ nonNull: true, default: sql`'new'` }); }
id = (sqlite.Integer<1>).column({ nonNull: true, generated: true });
label = (sqlite.Text<1>).column({ nonNull: true });
status = (sqlite.Text<1>).column({ nonNull: true, default: sql`'new'` });
}

await expect(
Mixed.insert({ label: "A" }, { label: "B", status: "old" }).execute(conn),
Expand All @@ -202,11 +219,12 @@ test("all-default single row uses DEFAULT VALUES; multi-row raises", async () =>
)`);

class Counters extends db.Table("counters") {
id = Int8.column({ nonNull: true, generated: true }); }
id = Int8.column({ nonNull: true, generated: true });
}

const rows = await tx.execute(
Counters.insert({}).returning(({ counters }) => ({ id: counters.id })),
);
const rows = await Counters.insert({})
.returning(({ counters }) => ({ id: counters.id }))
.execute(tx);
expect(rows).toEqual([{ id: "1" }]);

expect(() => Counters.insert({}, {}).finalize().bind()).toThrow(
Expand Down
32 changes: 23 additions & 9 deletions src/builder/insert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,11 @@ export class FinalizedInsert<Name extends string, T extends TableBase, R extends
const v = row[k];
if (v === undefined) {
// Some other row provides this column, this one doesn't.
// Only PG is known to spell that `DEFAULT`; for any other
// dialect (SQLite has no per-row spelling for it), silently
// inserting NULL would diverge from what omitting the column
// means (rowid auto-fill, declared DEFAULT) — make the
// PostgreSQL and Oracle can spell that `DEFAULT`; SQLite
// cannot. Silently inserting NULL would diverge from what
// omitting the column means, so unsupported dialects make the
// caller decide.
if (tableCls.database.dialect === "postgres") {
if (tableCls.database.dialect === "postgres" || tableCls.database.dialect === "oracle") {
return sql`DEFAULT`;
}
throw new Error(
Expand All @@ -73,10 +72,24 @@ export class FinalizedInsert<Name extends string, T extends TableBase, R extends
});
return sql`(${sql.join(vals)})`;
});
// Zero provided columns can't be spelled `(cols) VALUES (...)`;
// both dialects use `DEFAULT VALUES`, which is single-row only.
const oracle = tableCls.database.dialect === "oracle";
if (oracle && returning) {
throw new Error(".returning() is not yet supported on oracle mutations");
}

let body: Sql;
if (usedColumns.length === 0) {
if (usedColumns.length === 0 && oracle) {
// Oracle has no `DEFAULT VALUES`. Name every declared column and
// provide DEFAULT for each; Oracle 23 supports this in multi-row
// VALUES lists as well.
if (columnNames.length === 0) {
throw new Error(`Insert into '${tableName}': Oracle all-default inserts require at least one declared column.`);
}
const columns = columnNames.map((k) => tableCls.database.scopedIdent(k));
const defaults = sql`(${sql.join(columnNames.map(() => sql`DEFAULT`))})`;
body = sql`(${sql.join(columns)}) VALUES ${sql.join(rows.map(() => defaults))}`;
} else if (usedColumns.length === 0) {
// PostgreSQL and SQLite use `DEFAULT VALUES`, which is single-row.
if (rows.length > 1) {
throw new Error(
`Insert into '${tableName}': multi-row insert with no columns provided. ` +
Expand All @@ -88,8 +101,9 @@ export class FinalizedInsert<Name extends string, T extends TableBase, R extends
const columns = usedColumns.map((k) => tableCls.database.scopedIdent(k));
body = sql`(${sql.join(columns)}) VALUES ${sql.join(rowSqls)}`;
}
const aliasClause = oracle ? sql`${alias}` : sql`AS ${alias}`;
const inner = sql.join([
sql`INSERT INTO ${tableCls.ident(tableName)} AS ${alias} ${body}`,
sql`INSERT INTO ${tableCls.ident(tableName)} ${aliasClause} ${body}`,
returning && sql`RETURNING ${compileSelectList(returning)}`,
], sql` `);
return sql.withScope([alias], inner);
Expand Down
Loading
Loading