diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4ed4834..b31c208 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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); }); ``` @@ -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 diff --git a/src/builder/delete.test.ts b/src/builder/delete.test.ts index 90e463c..6cddb6b 100644 --- a/src/builder/delete.test.ts +++ b/src/builder/delete.test.ts @@ -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" }]); }); @@ -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" }]); @@ -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" }]); }); @@ -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" }]); }); @@ -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()"); }); }); diff --git a/src/builder/delete.ts b/src/builder/delete.ts index 265d5f0..d4ec4e4 100644 --- a/src/builder/delete.ts +++ b/src/builder/delete.ts @@ -40,10 +40,15 @@ export class FinalizedDelete { )`); 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> = { 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 }, @@ -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" }); }); }); @@ -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([ @@ -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" }, @@ -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" }, @@ -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. @@ -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), @@ -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( diff --git a/src/builder/insert.ts b/src/builder/insert.ts index 009068d..41eac76 100644 --- a/src/builder/insert.ts +++ b/src/builder/insert.ts @@ -49,12 +49,11 @@ export class FinalizedInsert 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. ` + @@ -88,8 +101,9 @@ export class FinalizedInsert 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); diff --git a/src/builder/oracle-live.test.ts b/src/builder/oracle-live.test.ts new file mode 100644 index 0000000..d68b048 --- /dev/null +++ b/src/builder/oracle-live.test.ts @@ -0,0 +1,112 @@ +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { Database, type Connection } from "../database"; +import { OracleDriver } from "../drivers/oracle"; +import { requireOraclePoolAttributes } from "../drivers/oracle-url"; +import { Number as OraNumber, Varchar2 } from "../types/oracle"; +import { sql } from "./sql"; + +const enabled = process.env["ORACLE_URL"] !== undefined; +const db = new Database(); + +class BuilderUsers extends db.Table("oracle_builder_users") { + id = OraNumber.column({ nonNull: true }); + name = Varchar2.column({ nonNull: true }); + nickname = Varchar2.column(); +} + +class BuilderDefaults extends db.Table("oracle_builder_defaults") { + id = OraNumber.column({ nonNull: true, generated: true }); + nickname = Varchar2.column(); +} + +describe.skipIf(!enabled)("Oracle builder execution", () => { + let conn: Connection; + + beforeAll(async () => { + conn = db.connect(await OracleDriver.create(requireOraclePoolAttributes())); + for (const table of ["oracle_builder_users", "oracle_builder_defaults"]) { + try { + await conn.execute(sql`DROP TABLE ${db.scopedIdent(table)} PURGE`); + } catch { + // The table does not exist on the first run. + } + } + await conn.execute(sql` + CREATE TABLE ${db.scopedIdent("oracle_builder_users")} ( + ${db.scopedIdent("id")} NUMBER NOT NULL, + ${db.scopedIdent("name")} VARCHAR2(100) NOT NULL, + ${db.scopedIdent("nickname")} VARCHAR2(100) DEFAULT 'anonymous' + ) + `); + await conn.execute(sql` + CREATE TABLE ${db.scopedIdent("oracle_builder_defaults")} ( + ${db.scopedIdent("id")} NUMBER GENERATED BY DEFAULT AS IDENTITY, + ${db.scopedIdent("nickname")} VARCHAR2(100) DEFAULT 'anonymous' + ) + `); + }); + + afterAll(async () => { + for (const table of ["oracle_builder_users", "oracle_builder_defaults"]) { + await conn.execute(sql`DROP TABLE ${db.scopedIdent(table)} PURGE`); + } + await conn.close(); + }); + + test("insert, DEFAULT, pagination, update, and delete", async () => { + await BuilderUsers.insert( + { id: "1", name: "alice", nickname: "ally" }, + { id: "2", name: "bob" }, + { id: "3", name: "carol", nickname: "c" }, + ).execute(); + + expect( + await BuilderUsers.from() + .select(({ oracle_builder_users: users }) => ({ id: users.id, name: users.name })) + .orderBy(({ oracle_builder_users: users }) => users.id) + .offset(1) + .limit(1) + .execute(), + ).toEqual([{ id: "2", name: "bob" }]); + + await BuilderUsers.update() + .where(({ oracle_builder_users: users }) => users.id.eq(2)) + .set(() => ({ nickname: "bobby" })) + .execute(); + expect( + await BuilderUsers.from() + .where(({ oracle_builder_users: users }) => users.id.eq(2)) + .select(({ oracle_builder_users: users }) => ({ nickname: users.nickname })) + .execute(), + ).toEqual([{ nickname: "bobby" }]); + + await BuilderUsers.delete() + .where(({ oracle_builder_users: users }) => users.id.eq(1)) + .execute(); + expect( + await BuilderUsers.from() + .select(({ oracle_builder_users: users }) => ({ id: users.id })) + .execute(), + ).toHaveLength(2); + }); + + test("all-default and VALUES-source inserts execute", async () => { + await BuilderDefaults.insert({}).execute(); + expect( + await BuilderDefaults.from() + .select(({ oracle_builder_defaults: row }) => ({ nickname: row.nickname })) + .execute(), + ).toEqual([{ nickname: "anonymous" }]); + + expect( + await db + .values({ n: OraNumber.from("1"), label: Varchar2.from("one") }, { n: "2", label: "two" }) + .select(({ values }) => values) + .orderBy(({ values }) => values.n) + .execute(), + ).toEqual([ + { n: "1", label: "one" }, + { n: "2", label: "two" }, + ]); + }); +}); diff --git a/src/builder/oracle.test.ts b/src/builder/oracle.test.ts new file mode 100644 index 0000000..42b9ad9 --- /dev/null +++ b/src/builder/oracle.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "vitest"; +import { compileOnlyDb } from "../test-helpers"; +import { Number as OraNumber, Varchar2 } from "../types/oracle"; +import { compile, sql } from "./sql"; + +const db = compileOnlyDb("oracle"); +const oracleCtx = { database: db }; + +class Users extends db.Table("users") { + id = OraNumber.column({ nonNull: true, generated: true }); + name = Varchar2.column({ nonNull: true }); + nickname = Varchar2.column({ default: Varchar2.from("anonymous").toSql() }); +} + +class Defaults extends db.Table("defaults") { + id = OraNumber.column({ nonNull: true, generated: true }); + nickname = Varchar2.column({ default: Varchar2.from("anonymous").toSql() }); +} + +describe("Oracle builder SQL", () => { + test("table aliases omit AS", () => { + const query = Users.from().select(({ users }) => ({ id: users.id })); + expect(compile(query, oracleCtx)).toEqual({ + text: '(SELECT "users"."id" as "id"\nFROM "users" "users")', + values: [], + }); + }); + + test("pagination uses OFFSET/FETCH in Oracle order", () => { + const query = Users.from() + .select(({ users }) => ({ id: users.id })) + .orderBy(({ users }) => users.id) + .limit(10) + .offset(20); + expect(compile(query, oracleCtx)).toEqual({ + text: '(SELECT "users"."id" as "id"\nFROM "users" "users"\nORDER BY "users"."id"\nOFFSET :1 ROWS\nFETCH NEXT :2 ROWS ONLY)', + values: [20, 10], + }); + }); + + test("single-part pagination uses the appropriate Oracle clause", () => { + const selected = Users.from().select(({ users }) => ({ id: users.id })); + expect(compile(selected.limit(1), oracleCtx)).toEqual({ + text: '(SELECT "users"."id" as "id"\nFROM "users" "users"\nFETCH FIRST :1 ROWS ONLY)', + values: [1], + }); + expect(compile(selected.offset(2), oracleCtx)).toEqual({ + text: '(SELECT "users"."id" as "id"\nFROM "users" "users"\nOFFSET :1 ROWS)', + values: [2], + }); + }); + + test("VALUES aliases omit AS", () => { + const query = db.values({ n: OraNumber.from(1) }).select(({ values }) => values); + expect(compile(query, oracleCtx)).toEqual({ + text: '(SELECT "values"."n" as "n"\nFROM (VALUES (CAST(:1 AS NUMBER))) "values"("n"))', + values: [1], + }); + }); + + test("nested queries retain their parentheses", () => { + const query = Users.from().select(({ users }) => ({ id: users.id })); + expect(compile(sql`SELECT * FROM ${query} nested`, oracleCtx)).toEqual({ + text: 'SELECT * FROM (SELECT "users"."id" as "id"\nFROM "users" "users") nested', + values: [], + }); + }); + + test("insert supports multi-row values and per-row DEFAULT", () => { + const mutation = Users.insert( + { name: "alice", nickname: "ally" }, + { name: "bob" }, + ); + expect(compile(mutation, oracleCtx)).toEqual({ + text: 'INSERT INTO "users" "users" ("name", "nickname") VALUES (CAST(:1 AS VARCHAR2(4000)), CAST(:2 AS VARCHAR2(4000))), (CAST(:3 AS VARCHAR2(4000)), DEFAULT)', + values: ["alice", "ally", "bob"], + }); + }); + + test("all-default inserts name every declared column", () => { + expect(compile(Defaults.insert({}), oracleCtx)).toEqual({ + text: 'INSERT INTO "defaults" "defaults" ("id", "nickname") VALUES (DEFAULT, DEFAULT)', + values: [], + }); + }); + + test("update and delete aliases omit AS", () => { + const update = Users.update() + .where(({ users }) => users.id.eq(1)) + .set(() => ({ name: "alice" })); + expect(compile(update, oracleCtx)).toEqual({ + text: 'UPDATE "users" "users" SET "name" = CAST(:1 AS VARCHAR2(4000)) WHERE ("users"."id" = :2)', + values: ["alice", 1], + }); + + const deletion = Users.delete().where(({ users }) => users.id.eq(1)); + expect(compile(deletion, oracleCtx)).toEqual({ + text: 'DELETE FROM "users" "users" WHERE ("users"."id" = :1)', + values: [1], + }); + }); + + test("RETURNING is rejected until Oracle OUT binds are supported", () => { + expect(() => compile( + Users.insert({ name: "alice" }).returning(({ users }) => ({ id: users.id })), + oracleCtx, + )).toThrow(".returning() is not yet supported on oracle mutations"); + expect(() => compile( + Users.update().where(true).set(() => ({ name: "alice" })).returning(({ users }) => ({ id: users.id })), + oracleCtx, + )).toThrow(".returning() is not yet supported on oracle mutations"); + expect(() => compile( + Users.delete().where(true).returning(({ users }) => ({ id: users.id })), + oracleCtx, + )).toThrow(".returning() is not yet supported on oracle mutations"); + }); +}); diff --git a/src/builder/query.test.ts b/src/builder/query.test.ts index d6ae7eb..39f0d85 100644 --- a/src/builder/query.test.ts +++ b/src/builder/query.test.ts @@ -7,7 +7,11 @@ setupDb(); // `db` is populated inside setupDb's beforeAll — but each `compile(q, pgCtx)` // call is inside a test body which runs after beforeAll, so this lazy getter // captures the current value each time. -const pgCtx = { get database() { return db; } }; +const pgCtx = { + get database() { + return db; + }, +}; // --- values() --- @@ -45,13 +49,15 @@ test("values with select computed column", async () => { // --- e2e --- test("e2e: values single row", async () => { - const result = await conn.execute(db.values({ a: Int4.from(1), b: Text.from("hello") })); + const result = await db.values({ a: Int4.from(1), b: Text.from("hello") }).execute(); expectTypeOf(result).toEqualTypeOf<{ a: number; b: string }[]>(); expect(result).toEqual([{ a: 1, b: "hello" }]); }); test("e2e: values multiple rows", async () => { - const result = await conn.execute(db.values({ x: Int4.from(1), y: Text.from("a") }, { x: 2, y: "b" })); + const result = await db + .values({ x: Int4.from(1), y: Text.from("a") }, { x: 2, y: "b" }) + .execute(); expectTypeOf(result).toEqualTypeOf<{ x: number; y: string }[]>(); expect(result).toEqual([ { x: 1, y: "a" }, @@ -60,37 +66,37 @@ test("e2e: values multiple rows", async () => { }); test("e2e: values with select expression", async () => { - const result = await conn.execute(db + const result = await db .values({ a: Int4.from(10), b: Int4.from(20) }) .select((n) => ({ sum: n.values.a["+"](n.values.b), })) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ sum: number }[]>(); expect(result).toEqual([{ sum: 30 }]); }); test("e2e: values with string upper", async () => { - const result = await conn.execute(db + const result = await db .values({ name: Text.from("hello") }) .select((n) => ({ upper: n.values.name.upper(), })) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ upper: string }[]>(); expect(result).toEqual([{ upper: "HELLO" }]); }); test("e2e: values with mixed types", async () => { - const result = await conn.execute(db + const result = await db .values({ num: Int4.from(42), str: Text.from("test"), flag: Bool.from(true) }) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ num: number; str: string; flag: boolean }[]>(); expect(result).toEqual([{ num: 42, str: "test", flag: true }]); }); test("e2e: values with primitive second row", async () => { - const result = await conn.execute(db.values({ a: Int4.from(1) }, { a: 2 }, { a: 3 })); + const result = await db.values({ a: Int4.from(1) }, { a: 2 }, { a: 3 }).execute(); expectTypeOf(result).toEqualTypeOf<{ a: number }[]>(); expect(result).toEqual([{ a: 1 }, { a: 2 }, { a: 3 }]); }); @@ -98,19 +104,19 @@ test("e2e: values with primitive second row", async () => { // --- where --- test("e2e: where filters rows", async () => { - const result = await conn.execute(db + const result = await db .values({ a: Int4.from(1), b: Text.from("yes") }, { a: 2, b: "no" }, { a: 3, b: "yes" }) .where((n) => n.values.a[">"](2)) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ a: number; b: string }[]>(); expect(result).toEqual([{ a: 3, b: "yes" }]); }); test("e2e: where with equality", async () => { - const result = await conn.execute(db + const result = await db .values({ x: Int4.from(10) }, { x: 20 }, { x: 10 }) .where((n) => n.values.x["="](10)) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ x: number }[]>(); expect(result).toEqual([{ x: 10 }, { x: 10 }]); }); @@ -137,7 +143,7 @@ test("groupBy compiles to SQL", () => { test("e2e: groupBy select using numeric index", async () => { // n.values.category is the same expression used in groupBy — should work directly - const result = await conn.execute(db + const result = await db .values( { category: Text.from("x"), val: Int4.from(1) }, { category: "x", val: 2 }, @@ -147,13 +153,13 @@ test("e2e: groupBy select using numeric index", async () => { .select(({ 0: cat }) => ({ cat: cat, })) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ cat: string }[]>(); expect(result.sort((a, b) => a.cat.localeCompare(b.cat))).toEqual([{ cat: "x" }, { cat: "y" }]); }); test("e2e: groupBy with select", async () => { - const result = await conn.execute(db + const result = await db .values( { category: Text.from("a"), amount: Int4.from(10) }, { category: "a", amount: 20 }, @@ -163,7 +169,7 @@ test("e2e: groupBy with select", async () => { .select((n) => ({ category: n.values.category, })) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ category: string }[]>(); expect(result.sort((a, b) => a.category.localeCompare(b.category))).toEqual([ { category: "a" }, @@ -189,7 +195,7 @@ test("having compiles to SQL", () => { test("e2e: having filters groups", async () => { // Group by category, only keep groups where category > 'a' - const result = await conn.execute(db + const result = await db .values( { category: Text.from("a"), val: Int4.from(1) }, { category: "b", val: 2 }, @@ -200,16 +206,13 @@ test("e2e: having filters groups", async () => { .select(({ 0: cat }) => ({ cat, })) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ cat: string }[]>(); - expect(result.sort((a, b) => a.cat.localeCompare(b.cat))).toEqual([ - { cat: "b" }, - { cat: "c" }, - ]); + expect(result.sort((a, b) => a.cat.localeCompare(b.cat))).toEqual([{ cat: "b" }, { cat: "c" }]); }); test("e2e: where + groupBy + having", async () => { - const result = await conn.execute(db + const result = await db .values( { category: Text.from("a"), amount: Int4.from(10) }, { category: "a", amount: 20 }, @@ -223,53 +226,44 @@ test("e2e: where + groupBy + having", async () => { .select(({ 0: cat }) => ({ cat, })) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ cat: string }[]>(); - expect(result.sort((a, b) => a.cat.localeCompare(b.cat))).toEqual([ - { cat: "a" }, - { cat: "b" }, - ]); + expect(result.sort((a, b) => a.cat.localeCompare(b.cat))).toEqual([{ cat: "a" }, { cat: "b" }]); }); // --- orderBy --- test("orderBy compiles to SQL", () => { - const q = db - .values({ a: Int4.from(1) }) - .orderBy((n) => [n.values.a, "desc"]); + const q = db.values({ a: Int4.from(1) }).orderBy((n) => [n.values.a, "desc"]); const compiled = compile(q, pgCtx); expect(compiled.text).toContain("ORDER BY"); expect(compiled.text).toContain("DESC"); }); test("e2e: orderBy single expr (default asc)", async () => { - const result = await conn.execute(db + const result = await db .values({ x: Int4.from(3) }, { x: 1 }, { x: 2 }) .orderBy((n) => n.values.x) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ x: number }[]>(); expect(result).toEqual([{ x: 1 }, { x: 2 }, { x: 3 }]); }); test("e2e: orderBy single tuple", async () => { - const result = await conn.execute(db + const result = await db .values({ x: Int4.from(3) }, { x: 1 }, { x: 2 }) .orderBy((n) => [n.values.x, "desc"]) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ x: number }[]>(); expect(result).toEqual([{ x: 3 }, { x: 2 }, { x: 1 }]); }); test("e2e: orderBy stacking", async () => { - const result = await conn.execute(db - .values( - { a: Text.from("x"), b: Int4.from(2) }, - { a: "x", b: 1 }, - { a: "y", b: 3 }, - ) + const result = await db + .values({ a: Text.from("x"), b: Int4.from(2) }, { a: "x", b: 1 }, { a: "y", b: 3 }) .orderBy((n) => n.values.a) .orderBy((n) => [n.values.b, "desc"]) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ a: string; b: number }[]>(); expect(result).toEqual([ { a: "x", b: 2 }, @@ -279,17 +273,13 @@ test("e2e: orderBy stacking", async () => { }); test("e2e: orderBy multiple columns", async () => { - const result = await conn.execute(db - .values( - { a: Text.from("x"), b: Int4.from(2) }, - { a: "x", b: 1 }, - { a: "y", b: 3 }, - ) + const result = await db + .values({ a: Text.from("x"), b: Int4.from(2) }, { a: "x", b: 1 }, { a: "y", b: 3 }) .orderBy((n) => [ [n.values.a, "asc"], [n.values.b, "desc"], ]) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ a: string; b: number }[]>(); expect(result).toEqual([ { a: "x", b: 2 }, @@ -301,43 +291,43 @@ test("e2e: orderBy multiple columns", async () => { // --- limit / offset --- test("e2e: limit", async () => { - const result = await conn.execute(db + const result = await db .values({ x: Int4.from(1) }, { x: 2 }, { x: 3 }) .orderBy((n) => [[n.values.x, "asc"]]) .limit(2) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ x: number }[]>(); expect(result).toEqual([{ x: 1 }, { x: 2 }]); }); test("e2e: offset", async () => { - const result = await conn.execute(db + const result = await db .values({ x: Int4.from(1) }, { x: 2 }, { x: 3 }) .orderBy((n) => [[n.values.x, "asc"]]) .offset(1) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ x: number }[]>(); expect(result).toEqual([{ x: 2 }, { x: 3 }]); }); test("e2e: limit + offset (pagination)", async () => { - const result = await conn.execute(db + const result = await db .values({ x: Int4.from(1) }, { x: 2 }, { x: 3 }, { x: 4 }, { x: 5 }) .orderBy((n) => [[n.values.x, "asc"]]) .limit(2) .offset(2) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ x: number }[]>(); expect(result).toEqual([{ x: 3 }, { x: 4 }]); }); test("e2e: where + orderBy + limit", async () => { - const result = await conn.execute(db + const result = await db .values({ x: Int4.from(10) }, { x: 5 }, { x: 20 }, { x: 1 }, { x: 15 }) .where((n) => n.values.x[">"](5)) .orderBy((n) => [[n.values.x, "asc"]]) .limit(2) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ x: number }[]>(); expect(result).toEqual([{ x: 10 }, { x: 15 }]); }); @@ -345,14 +335,16 @@ test("e2e: where + orderBy + limit", async () => { // --- joins --- const withinTransaction = async (fn: (tx: typeof conn) => Promise) => { - await conn.transaction(async (tx) => { - await fn(tx); - throw new Error("__test_rollback__"); - }).catch((e) => { - if ((e as Error).message !== "__test_rollback__") { - throw e; - } - }); + await conn + .transaction(async (tx) => { + await fn(tx); + throw new Error("__test_rollback__"); + }) + .catch((e) => { + if ((e as Error).message !== "__test_rollback__") { + throw e; + } + }); }; test("inner join", async () => { @@ -367,20 +359,27 @@ test("inner join", async () => { owner_id int8 NOT NULL REFERENCES owners(id) )`); await tx.execute(sql`INSERT INTO owners (name) VALUES ('Alice'), ('Bob')`); - await tx.execute(sql`INSERT INTO pets (name, owner_id) VALUES ('Rex', 1), ('Fido', 2), ('Buddy', 1)`); + await tx.execute( + sql`INSERT INTO pets (name, owner_id) VALUES ('Rex', 1), ('Fido', 2), ('Buddy', 1)`, + ); class Owners extends db.Table("owners") { - id = Int8.column({ nonNull: true }); name = Text.column({ nonNull: true }); } + id = Int8.column({ nonNull: true }); + name = Text.column({ nonNull: true }); + } class Pets extends db.Table("pets") { - id = Int8.column({ nonNull: true }); name = Text.column({ nonNull: true }); owner_id = Int8.column({ nonNull: true }); } + id = Int8.column({ nonNull: true }); + name = Text.column({ nonNull: true }); + owner_id = Int8.column({ nonNull: true }); + } - const rows = await tx.execute(Pets.from() + const rows = await Pets.from() .join(Owners, ({ pets, owners }) => pets.owner_id["="](owners.id)) .select(({ pets, owners }) => ({ pet: pets.name, owner: owners.name, })) - ); + .execute(tx); expectTypeOf(rows).toEqualTypeOf<{ pet: string; owner: string }[]>(); expect(rows).toEqual([ @@ -406,17 +405,22 @@ test("left join — unmatched rows return null", async () => { await tx.execute(sql`INSERT INTO books (title, author_id) VALUES ('Book A', 1)`); class Authors extends db.Table("authors") { - id = Int8.column({ nonNull: true }); name = Text.column({ nonNull: true }); } + id = Int8.column({ nonNull: true }); + name = Text.column({ nonNull: true }); + } class Books extends db.Table("books") { - id = Int8.column({ nonNull: true }); title = Text.column({ nonNull: true }); author_id = Int8.column({ nonNull: true }); } + id = Int8.column({ nonNull: true }); + title = Text.column({ nonNull: true }); + author_id = Int8.column({ nonNull: true }); + } - const rows = await tx.execute(Authors.from() + const rows = await Authors.from() .leftJoin(Books, ({ authors, books }) => authors.id["="](books.author_id)) .select(({ authors, books }) => ({ author: authors.name, title: books.title, })) - ); + .execute(tx); expectTypeOf(rows).toEqualTypeOf<{ author: string; title: string | null }[]>(); expect(rows).toEqual([ @@ -438,21 +442,28 @@ test("join with where on joined table", async () => { dept_id int8 REFERENCES departments(id) )`); await tx.execute(sql`INSERT INTO departments (name) VALUES ('Engineering'), ('Sales')`); - await tx.execute(sql`INSERT INTO employees (name, dept_id) VALUES ('Alice', 1), ('Bob', 1), ('Carol', 2)`); + await tx.execute( + sql`INSERT INTO employees (name, dept_id) VALUES ('Alice', 1), ('Bob', 1), ('Carol', 2)`, + ); class Departments extends db.Table("departments") { - id = Int8.column({ nonNull: true }); name = Text.column({ nonNull: true }); } + id = Int8.column({ nonNull: true }); + name = Text.column({ nonNull: true }); + } class Employees extends db.Table("employees") { - id = Int8.column({ nonNull: true }); name = Text.column({ nonNull: true }); dept_id = Int8.column(); } + id = Int8.column({ nonNull: true }); + name = Text.column({ nonNull: true }); + dept_id = Int8.column(); + } - const rows = await tx.execute(Departments.from() + const rows = await Departments.from() .join(Employees, ({ departments, employees }) => departments.id["="](employees.dept_id)) .select(({ departments, employees }) => ({ dept: departments.name, emp: employees.name, })) .where(({ departments }) => departments.name["="]("Engineering")) - ); + .execute(tx); expectTypeOf(rows).toEqualTypeOf<{ dept: string; emp: string }[]>(); expect(rows).toEqual([ @@ -479,12 +490,17 @@ test("scalar with cardinality 'one'", async () => { await tx.execute(sql`INSERT INTO books (title, author_id) VALUES ('Book A', 1), ('Book B', 1)`); class Authors extends db.Table("authors") { - id = Int8.column({ nonNull: true, generated: true }); name = Text.column({ nonNull: true }); } + id = Int8.column({ nonNull: true, generated: true }); + name = Text.column({ nonNull: true }); + } class Books extends db.Table("books") { - id = Int8.column({ nonNull: true, generated: true }); title = Text.column({ nonNull: true }); author_id = Int8.column({ nonNull: true }); } + id = Int8.column({ nonNull: true, generated: true }); + title = Text.column({ nonNull: true }); + author_id = Int8.column({ nonNull: true }); + } // Scalar subquery: get author for a book (cardinality 'one') - const rows = await tx.execute(Books.from() + const rows = await Books.from() .select(({ books }) => ({ title: books.title, author: Authors.from() @@ -493,7 +509,7 @@ test("scalar with cardinality 'one'", async () => { .cardinality("one") .scalar(), })) - ); + .execute(tx); expectTypeOf(rows[0]!.title).toEqualTypeOf(); expectTypeOf(rows[0]!.author).toEqualTypeOf<{ name: string }>(); @@ -518,11 +534,16 @@ test("scalar with cardinality 'maybe' — null when no match", async () => { await tx.execute(sql`INSERT INTO profiles (person_id, bio) VALUES (1, 'Hello')`); class People extends db.Table("people") { - id = Int8.column({ nonNull: true, generated: true }); name = Text.column({ nonNull: true }); } + id = Int8.column({ nonNull: true, generated: true }); + name = Text.column({ nonNull: true }); + } class Profiles extends db.Table("profiles") { - id = Int8.column({ nonNull: true, generated: true }); person_id = Int8.column({ nonNull: true }); bio = Text.column({ nonNull: true }); } + id = Int8.column({ nonNull: true, generated: true }); + person_id = Int8.column({ nonNull: true }); + bio = Text.column({ nonNull: true }); + } - const rows = await tx.execute(People.from() + const rows = await People.from() .select(({ people }) => ({ name: people.name, profile: Profiles.from() @@ -532,12 +553,14 @@ test("scalar with cardinality 'maybe' — null when no match", async () => { .scalar(), })) .orderBy(({ people }) => people.name) - ); - - expectTypeOf(rows).toEqualTypeOf<{ - name: string; - profile: { bio: string } | null - }[]>(); + .execute(tx); + + expectTypeOf(rows).toEqualTypeOf< + { + name: string; + profile: { bio: string } | null; + }[] + >(); expect(rows).toEqual([ { name: "Alice", profile: { bio: "Hello" } }, { name: "Bob", profile: null }, @@ -557,14 +580,21 @@ test("scalar with cardinality 'many' — array result", async () => { parent_id int8 NOT NULL REFERENCES parents(id) )`); await tx.execute(sql`INSERT INTO parents (name) VALUES ('Alice'), ('Bob')`); - await tx.execute(sql`INSERT INTO children (name, parent_id) VALUES ('Charlie', 1), ('Diana', 1)`); + await tx.execute( + sql`INSERT INTO children (name, parent_id) VALUES ('Charlie', 1), ('Diana', 1)`, + ); class Parents extends db.Table("parents") { - id = Int8.column({ nonNull: true, generated: true }); name = Text.column({ nonNull: true }); } + id = Int8.column({ nonNull: true, generated: true }); + name = Text.column({ nonNull: true }); + } class Children extends db.Table("children") { - id = Int8.column({ nonNull: true, generated: true }); name = Text.column({ nonNull: true }); parent_id = Int8.column({ nonNull: true }); } + id = Int8.column({ nonNull: true, generated: true }); + name = Text.column({ nonNull: true }); + parent_id = Int8.column({ nonNull: true }); + } - const rows = await tx.execute(Parents.from() + const rows = await Parents.from() .select(({ parents }) => ({ name: parents.name, kids: Children.from() @@ -574,12 +604,14 @@ test("scalar with cardinality 'many' — array result", async () => { .scalar(), })) .orderBy(({ parents }) => parents.name) - ); - - expectTypeOf(rows).toEqualTypeOf<{ - name: string; - kids: { name: string }[]; - }[]>(); + .execute(tx); + + expectTypeOf(rows).toEqualTypeOf< + { + name: string; + kids: { name: string }[]; + }[] + >(); expect(rows).toEqual([ { name: "Alice", kids: [{ name: "Charlie" }, { name: "Diana" }] }, { name: "Bob", kids: [] }, @@ -590,34 +622,30 @@ test("scalar with cardinality 'many' — array result", async () => { // --- aggregates --- test("count on values", async () => { - const result = await conn.execute(db + const result = await db .values({ x: Int4.from(1) }, { x: 2 }, { x: 3 }) .groupBy() .select((n) => ({ total: n.values.x.count() })) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ total: string }[]>(); expect(result).toEqual([{ total: "3" }]); }); test("sum and avg", async () => { - const result = await conn.execute(db + const result = await db .values({ x: Int4.from(10) }, { x: 20 }, { x: 30 }) .groupBy() .select((n) => ({ total: n.values.x.sum(), average: n.values.x.avg() })) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ total: string | null; average: string | null }[]>(); expect(result).toEqual([{ total: "60", average: "20.0000000000000000" }]); }); test("groupBy with count", async () => { - const result = await conn.execute(db - .values( - { cat: Text.from("a"), val: Int4.from(1) }, - { cat: "a", val: 2 }, - { cat: "b", val: 3 }, - ) + const result = await db + .values({ cat: Text.from("a"), val: Int4.from(1) }, { cat: "a", val: 2 }, { cat: "b", val: 3 }) .groupBy((n) => [n.values.cat]) .select(({ 0: cat, values }) => ({ cat, @@ -625,7 +653,7 @@ test("groupBy with count", async () => { total: values.val.sum(), })) .orderBy(({ 0: cat }) => cat) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ cat: string; count: string; total: string | null }[]>(); expect(result).toEqual([ @@ -635,11 +663,11 @@ test("groupBy with count", async () => { }); test("max and min", async () => { - const result = await conn.execute(db + const result = await db .values({ x: Int4.from(5) }, { x: 1 }, { x: 9 }) .groupBy() .select((n) => ({ hi: n.values.x.max(), lo: n.values.x.min() })) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ hi: number | null; lo: number | null }[]>(); expect(result).toEqual([{ hi: 9, lo: 1 }]); @@ -649,22 +677,19 @@ test("max and min", async () => { test("generate_series as Fromable via db.from()", async () => { const series = Int4.from(1).generateSeries(3, 1); - const result = await conn.execute(db.from(series)); + const result = await db.from(series).execute(); expectTypeOf(result).toEqualTypeOf<{ generate_series: number }[]>(); - expect(result).toEqual([ - { generate_series: 1 }, - { generate_series: 2 }, - { generate_series: 3 }, - ]); + expect(result).toEqual([{ generate_series: 1 }, { generate_series: 2 }, { generate_series: 3 }]); }); test("jsonb_each_text as multi-column SRF", async () => { const jsonVal = Jsonb.from('{"a": 1, "b": 2}'); const each = jsonVal.jsonbEachText(); - const result = await conn.execute(db.from(each) + const result = await db + .from(each) .orderBy(({ jsonb_each_text }) => jsonb_each_text.key) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ key: string; value: string }[]>(); expect(result).toEqual([ @@ -676,28 +701,28 @@ test("jsonb_each_text as multi-column SRF", async () => { // --- method idempotency --- test("select: last call wins", async () => { - const result = await conn.execute(db + const result = await db .values({ a: Int4.from(1), b: Text.from("x") }) .select((n) => ({ first: n.values.a })) .select((n) => ({ second: n.values.b })) - ); + .execute(); expectTypeOf(result).toEqualTypeOf<{ second: string }[]>(); expect(result).toEqual([{ second: "x" }]); }); test("where: multiple calls AND-combine", async () => { - const result = await conn.execute(db + const result = await db .values({ x: Int4.from(1) }, { x: 2 }, { x: 3 }, { x: 4 }) .where((n) => n.values.x[">"](1)) .where((n) => n.values.x["<"](4)) - ); + .execute(); expect(result).toEqual([{ x: 2 }, { x: 3 }]); }); test("orderBy: multiple calls stack", async () => { - const result = await conn.execute(db + const result = await db .values( { a: Text.from("b"), b: Int4.from(2) }, { a: "a", b: 1 }, @@ -706,7 +731,7 @@ test("orderBy: multiple calls stack", async () => { ) .orderBy((n) => [n.values.a, "asc"]) .orderBy((n) => [n.values.b, "asc"]) - ); + .execute(); expect(result).toEqual([ { a: "a", b: 1 }, @@ -717,29 +742,29 @@ test("orderBy: multiple calls stack", async () => { }); test("limit: multiple calls take MIN", async () => { - const result = await conn.execute(db + const result = await db .values({ x: Int4.from(1) }, { x: 2 }, { x: 3 }, { x: 4 }, { x: 5 }) .orderBy((n) => n.values.x) .limit(3) .limit(2) - ); + .execute(); expect(result).toEqual([{ x: 1 }, { x: 2 }]); }); test("offset: multiple calls sum", async () => { - const result = await conn.execute(db + const result = await db .values({ x: Int4.from(1) }, { x: 2 }, { x: 3 }, { x: 4 }, { x: 5 }) .orderBy((n) => n.values.x) .offset(1) .offset(2) - ); + .execute(); expect(result).toEqual([{ x: 4 }, { x: 5 }]); }); test("groupBy: multiple calls stack", async () => { - const result = await conn.execute(db + const result = await db .values( { a: Text.from("x"), b: Text.from("1"), c: Int4.from(10) }, { a: "x", b: "1", c: 20 }, @@ -752,7 +777,7 @@ test("groupBy: multiple calls stack", async () => { // quite correct .select(({ 0: a, 1: b, values }) => ({ a, b, total: values.c.sum() })) .orderBy((n) => [n[0] as any, "asc"]) - ); + .execute(); expect(result).toEqual([ { a: "x", b: "1", total: "30" }, @@ -761,7 +786,7 @@ test("groupBy: multiple calls stack", async () => { }); test("having: multiple calls AND-combine", async () => { - const result = await conn.execute(db + const result = await db .values( { cat: Text.from("a"), val: Int4.from(1) }, { cat: "a", val: 2 }, @@ -772,7 +797,7 @@ test("having: multiple calls AND-combine", async () => { .having((n) => n.values.val.count()[">"](Int8.from("1"))) .having((n) => n.values.val.sum()["<"](Int8.from("50"))) .select(({ 0: cat, values }) => ({ cat, total: values.val.sum() })) - ); + .execute(); expect(result).toEqual([{ cat: "a", total: "3" }]); }); @@ -795,7 +820,11 @@ test("having: multiple calls AND-combine", async () => { const expectArgValidationError = (fn: () => unknown, contentRe: RegExp) => { let err: unknown; - try { fn(); } catch (e) { err = e; } + try { + fn(); + } catch (e) { + err = e; + } expect(err).toBeInstanceOf(TypeError); expect((err as TypeError).message).toMatch(/^Invalid value: /); expect((err as TypeError).message).toMatch(contentRe); @@ -803,7 +832,11 @@ const expectArgValidationError = (fn: () => unknown, contentRe: RegExp) => { const expectReturnValidationError = (fn: () => unknown, contentRe: RegExp) => { let err: unknown; - try { fn(); } catch (e) { err = e; } + try { + fn(); + } catch (e) { + err = e; + } expect((err as Error)?.constructor?.name).toBe("ZodError"); expect((err as Error).message).toMatch(contentRe); }; @@ -851,7 +884,7 @@ test("groupBy() with no args is allowed (optional callback)", () => { expect(() => compile(q, pgCtx)).not.toThrow(); }); -test("type test: conn.execute(Table.from()) row methods are never-typed (uncallable)", async () => { +test("type test: Table.from().execute() row methods are never-typed (uncallable)", async () => { await withinTransaction(async (tx) => { await tx.execute(sql`CREATE TABLE widgets ( id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY, @@ -861,7 +894,7 @@ test("type test: conn.execute(Table.from()) row methods are never-typed (uncalla class Widgets extends db.Table("widgets") { @expose() - id = Int8.column({ nonNull: true, generated: true }); + id = Int8.column({ nonNull: true, generated: true }); @expose() name = Text.column({ nonNull: true }); @@ -877,7 +910,7 @@ test("type test: conn.execute(Table.from()) row methods are never-typed (uncalla } } - const rows = await tx.execute(Widgets.from()); + const rows = await Widgets.from().execute(tx); // 1. Column fields type as their deserialized values. expectTypeOf(rows[0]!.id).toEqualTypeOf(); diff --git a/src/builder/query.ts b/src/builder/query.ts index 92a8488..3cfbbe3 100644 --- a/src/builder/query.ts +++ b/src/builder/query.ts @@ -618,14 +618,17 @@ export class FinalizedQuery extends Sql { for (const t of this.opts.tables) { const alias = t.alias; const sourceSql = t.source.bind(ctx); - const asClause = t.source.emitColumnNamesWithAlias - ? sql`AS ${alias}(${sql.join(Object.keys(t.source.rowType()).map((col) => new Ident(col)))})` - : sql`AS ${alias}`; + // `AS` is optional for table aliases in PostgreSQL and SQLite, + // while Oracle rejects it. The alias-only form is portable across + // all three dialects; column aliases still use `AS`. + const aliasClause = t.source.emitColumnNamesWithAlias + ? sql`${alias}(${sql.join(Object.keys(t.source.rowType()).map((col) => new Ident(col)))})` + : sql`${alias}`; if (t.type === "from") { - tableSql.push(sql`FROM ${sourceSql} ${asClause}`); + tableSql.push(sql`FROM ${sourceSql} ${aliasClause}`); } else { tableSql.push( - sql` ${t.type === "leftJoin" ? sql`LEFT JOIN` : sql`JOIN`} ${sourceSql} ${asClause} ON ${t.on.toSql()}`, + sql` ${t.type === "leftJoin" ? sql`LEFT JOIN` : sql`JOIN`} ${sourceSql} ${aliasClause} ON ${t.on.toSql()}`, ); } } @@ -650,8 +653,12 @@ export class FinalizedQuery extends Sql { return sql`${expr.toSql()} ASC`; }), )}`, - this.opts.limit !== undefined && sql`LIMIT ${sql.param(this.opts.limit)}`, - this.opts.offset !== undefined && sql`OFFSET ${sql.param(this.opts.offset)}`, + ctx.database.dialect === "oracle" + ? this.opts.offset !== undefined && sql`OFFSET ${sql.param(this.opts.offset)} ROWS` + : this.opts.limit !== undefined && sql`LIMIT ${sql.param(this.opts.limit)}`, + ctx.database.dialect === "oracle" + ? this.opts.limit !== undefined && sql`${this.opts.offset === undefined ? sql`FETCH FIRST` : sql`FETCH NEXT`} ${sql.param(this.opts.limit)} ROWS ONLY` + : this.opts.offset !== undefined && sql`OFFSET ${sql.param(this.opts.offset)}`, ], sql`\n`, ); diff --git a/src/builder/update.test.ts b/src/builder/update.test.ts index e24feb7..da1f601 100644 --- a/src/builder/update.test.ts +++ b/src/builder/update.test.ts @@ -14,19 +14,20 @@ test("update with where", async () => { await tx.execute(sql`INSERT INTO users (name) VALUES ('Alice'), ('Bob')`); class Users extends db.Table("users") { - id = Int8.column({ nonNull: true, generated: true }); name = Text.column({ nonNull: true }); active = Text.column({ nonNull: true, default: sql`'yes'` }); } + id = Int8.column({ nonNull: true, generated: true }); + name = Text.column({ nonNull: true }); + active = Text.column({ nonNull: true, default: sql`'yes'` }); + } - await tx.execute( - Users.update() - .where(({ users }) => users.name["="]("Bob")) - .set(() => ({ active: "no" })), - ); + await Users.update() + .where(({ users }) => users.name["="]("Bob")) + .set(() => ({ active: "no" })) + .execute(tx); - const rows = await tx.execute( - Users.from() - .select(({ users }) => ({ name: users.name, active: users.active })) - .orderBy(({ users }) => users.name), - ); + const rows = await Users.from() + .select(({ users }) => ({ name: users.name, active: users.active })) + .orderBy(({ users }) => users.name) + .execute(tx); expect(rows).toEqual([ { name: "Alice", active: "yes" }, @@ -44,13 +45,18 @@ test("update all with where(true)", async () => { await tx.execute(sql`INSERT INTO flags (active) VALUES ('yes'), ('yes')`); class Flags extends db.Table("flags") { - id = Int8.column({ nonNull: true, generated: true }); active = Text.column({ nonNull: true, default: sql`'yes'` }); } + id = Int8.column({ nonNull: true, generated: true }); + active = Text.column({ nonNull: true, default: sql`'yes'` }); + } - await tx.execute(Flags.update().where(true).set(() => ({ active: "no" }))); + await Flags.update() + .where(true) + .set(() => ({ active: "no" })) + .execute(tx); - const rows = await tx.execute( - Flags.from().select(({ flags }) => ({ active: flags.active })), - ); + const rows = await Flags.from() + .select(({ flags }) => ({ active: flags.active })) + .execute(tx); expect(rows).toEqual([{ active: "no" }, { active: "no" }]); }); @@ -75,18 +81,16 @@ test("update: where(true) after a real .where() is a no-op", async () => { active = Text.column({ nonNull: true, default: sql`'yes'` }); } - await tx.execute( - Guards.update() - .where(({ guards }) => guards.name["="]("touch")) - .where(true) - .set(() => ({ active: "no" })), - ); + await Guards.update() + .where(({ guards }) => guards.name["="]("touch")) + .where(true) + .set(() => ({ active: "no" })) + .execute(tx); - const rows = await tx.execute( - Guards.from() - .select(({ guards }) => ({ name: guards.name, active: guards.active })) - .orderBy(({ guards }) => guards.name), - ); + const rows = await Guards.from() + .select(({ guards }) => ({ name: guards.name, active: guards.active })) + .orderBy(({ guards }) => guards.name) + .execute(tx); expect(rows).toEqual([ { name: "leave", active: "yes" }, @@ -106,14 +110,16 @@ test("update returning", async () => { await tx.execute(sql`INSERT INTO scores (name) VALUES ('Alice'), ('Bob')`); class Scores extends db.Table("scores") { - id = Int8.column({ nonNull: true, generated: true }); name = Text.column({ nonNull: true }); score = Text.column({ nonNull: true, default: sql`'0'` }); } + id = Int8.column({ nonNull: true, generated: true }); + name = Text.column({ nonNull: true }); + score = Text.column({ nonNull: true, default: sql`'0'` }); + } - const rows = await tx.execute( - Scores.update() - .where(({ scores }) => scores.name["="]("Alice")) - .set(() => ({ score: "100" })) - .returning(({ scores }) => ({ name: scores.name, score: scores.score })), - ); + const rows = await Scores.update() + .where(({ scores }) => scores.name["="]("Alice")) + .set(() => ({ score: "100" })) + .returning(({ scores }) => ({ name: scores.name, score: scores.score })) + .execute(tx); expectTypeOf(rows).toEqualTypeOf<{ name: string; score: string }[]>(); expect(rows).toEqual([{ name: "Alice", score: "100" }]); @@ -128,23 +134,27 @@ test("update: multiple where calls AND-combine", async () => { price int8 NOT NULL DEFAULT 0, active text NOT NULL DEFAULT 'yes' )`); - await tx.execute(sql`INSERT INTO products (name, price) VALUES ('a', 10), ('b', 10), ('c', 20)`); + await tx.execute( + sql`INSERT INTO products (name, price) VALUES ('a', 10), ('b', 10), ('c', 20)`, + ); class Products extends db.Table("products") { - id = Int8.column({ nonNull: true, generated: true }); name = Text.column({ nonNull: true }); price = Int8.column({ nonNull: true, default: sql`0` }); active = Text.column({ nonNull: true, default: sql`'yes'` }); } + id = Int8.column({ nonNull: true, generated: true }); + name = Text.column({ nonNull: true }); + price = Int8.column({ nonNull: true, default: sql`0` }); + active = Text.column({ nonNull: true, default: sql`'yes'` }); + } - await tx.execute( - Products.update() - .where(({ products }) => products.price["="]("10")) - .where(({ products }) => products.name["="]("a")) - .set(() => ({ active: "no" })), - ); + await Products.update() + .where(({ products }) => products.price["="]("10")) + .where(({ products }) => products.name["="]("a")) + .set(() => ({ active: "no" })) + .execute(tx); - const rows = await tx.execute( - Products.from() - .select(({ products }) => ({ name: products.name, active: products.active })) - .orderBy(({ products }) => products.name), - ); + const rows = await Products.from() + .select(({ products }) => ({ name: products.name, active: products.active })) + .orderBy(({ products }) => products.name) + .execute(tx); expect(rows).toEqual([ { name: "a", active: "no" }, @@ -171,20 +181,18 @@ test("set: arithmetic on existing column (col = col + 1)", async () => { class Counters extends db.Table("counters") { id = Int8.column({ nonNull: true, generated: true }); - n = Int8.column({ nonNull: true }); + n = Int8.column({ nonNull: true }); } - await tx.execute( - Counters.update() - .where(({ counters }) => counters.id["="]("1")) - .set(({ counters }) => ({ n: counters.n["+"]("5") })), - ); + await Counters.update() + .where(({ counters }) => counters.id["="]("1")) + .set(({ counters }) => ({ n: counters.n["+"]("5") })) + .execute(tx); - const rows = await tx.execute( - Counters.from() - .select(({ counters }) => ({ id: counters.id, n: counters.n })) - .orderBy(({ counters }) => counters.id), - ); + const rows = await Counters.from() + .select(({ counters }) => ({ id: counters.id, n: counters.n })) + .orderBy(({ counters }) => counters.id) + .execute(tx); expect(rows).toEqual([ { id: "1", n: "15" }, { id: "2", n: "20" }, @@ -201,19 +209,20 @@ test("set: typegres function call (literal expression via Text.from)", async () await tx.execute(sql`INSERT INTO labels (tag) VALUES ('alpha')`); class Labels extends db.Table("labels") { - id = Int8.column({ nonNull: true, generated: true }); + id = Int8.column({ nonNull: true, generated: true }); tag = Text.column({ nonNull: true }); } // `tag.upper()` is a typegres expression — should compile to // `SET tag = upper(tag)`, not be rejected as "not a SetRow". - await tx.execute( - Labels.update() - .where(({ labels }) => labels.id["="]("1")) - .set(({ labels }) => ({ tag: labels.tag.upper() })), - ); - - const rows = await tx.execute(Labels.from().select(({ labels }) => ({ tag: labels.tag }))); + await Labels.update() + .where(({ labels }) => labels.id["="]("1")) + .set(({ labels }) => ({ tag: labels.tag.upper() })) + .execute(tx); + + const rows = await Labels.from() + .select(({ labels }) => ({ tag: labels.tag })) + .execute(tx); expect(rows).toEqual([{ tag: "ALPHA" }]); }); }); @@ -228,20 +237,21 @@ test("set: mixing primitive and expression values in one call", async () => { await tx.execute(sql`INSERT INTO mixed (n, label) VALUES (1, 'a')`); class Mixed extends db.Table("mixed") { - id = Int8.column({ nonNull: true, generated: true }); - n = Int8.column({ nonNull: true }); + id = Int8.column({ nonNull: true, generated: true }); + n = Int8.column({ nonNull: true }); label = Text.column({ nonNull: true }); } // `n` set via expression (n + 100), `label` via primitive ("z"). // Both should land in the same UPDATE. - await tx.execute( - Mixed.update() - .where(({ mixed }) => mixed.id["="]("1")) - .set(({ mixed }) => ({ n: mixed.n["+"]("100"), label: "z" })), - ); - - const rows = await tx.execute(Mixed.from().select(({ mixed }) => ({ n: mixed.n, label: mixed.label }))); + await Mixed.update() + .where(({ mixed }) => mixed.id["="]("1")) + .set(({ mixed }) => ({ n: mixed.n["+"]("100"), label: "z" })) + .execute(tx); + + const rows = await Mixed.from() + .select(({ mixed }) => ({ n: mixed.n, label: mixed.label })) + .execute(tx); expect(rows).toEqual([{ n: "101", label: "z" }]); }); }); @@ -255,7 +265,7 @@ test("returning: expression in projection (not just bare columns)", async () => await tx.execute(sql`INSERT INTO notes (tag) VALUES ('hello')`); class Notes extends db.Table("notes") { - id = Int8.column({ nonNull: true, generated: true }); + id = Int8.column({ nonNull: true, generated: true }); tag = Text.column({ nonNull: true }); } @@ -264,15 +274,14 @@ test("returning: expression in projection (not just bare columns)", async () => // `tag.upper()`) should compile to `RETURNING upper(tag) AS shouted`. // The Class.from(v) Any-rewrap bug fixed in compileSetClauses doesn't // apply here. Verify by round-tripping the expression value. - const [updated] = await tx.execute( - Notes.update() - .where(({ notes }) => notes.id["="]("1")) - .set(() => ({ tag: "world" })) - .returning(({ notes }) => ({ - id: notes.id, - shouted: notes.tag.upper(), - })), - ); + const [updated] = await Notes.update() + .where(({ notes }) => notes.id["="]("1")) + .set(() => ({ tag: "world" })) + .returning(({ notes }) => ({ + id: notes.id, + shouted: notes.tag.upper(), + })) + .execute(tx); expect(updated).toEqual({ id: "1", shouted: "WORLD" }); }); }); @@ -286,16 +295,15 @@ test("set: expression with returning round-trips the new value", async () => { await tx.execute(sql`INSERT INTO balances (cents) VALUES (1000)`); class Balances extends db.Table("balances") { - id = Int8.column({ nonNull: true, generated: true }); + id = Int8.column({ nonNull: true, generated: true }); cents = Int8.column({ nonNull: true }); } - const [updated] = await tx.execute( - Balances.update() - .where(({ balances }) => balances.id["="]("1")) - .set(({ balances }) => ({ cents: balances.cents["-"]("250") })) - .returning(({ balances }) => ({ id: balances.id, cents: balances.cents })), - ); + const [updated] = await Balances.update() + .where(({ balances }) => balances.id["="]("1")) + .set(({ balances }) => ({ cents: balances.cents["-"]("250") })) + .returning(({ balances }) => ({ id: balances.id, cents: balances.cents })) + .execute(tx); expect(updated).toEqual({ id: "1", cents: "750" }); expectTypeOf(updated!.cents).toEqualTypeOf(); }); @@ -306,8 +314,13 @@ test("update without where throws", async () => { await tx.execute(sql`CREATE TABLE noop (id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY)`); class Noop extends db.Table("noop") { - id = Int8.column({ nonNull: true, generated: true }); } + id = Int8.column({ nonNull: true, generated: true }); + } - await expect(tx.execute(Noop.update().set(() => ({})))).rejects.toThrow("requires .where()"); + await expect( + Noop.update() + .set(() => ({})) + .execute(tx), + ).rejects.toThrow("requires .where()"); }); }); diff --git a/src/builder/update.ts b/src/builder/update.ts index d693656..f59e22e 100644 --- a/src/builder/update.ts +++ b/src/builder/update.ts @@ -49,13 +49,18 @@ export class FinalizedUpdate extends Sql implements Fromable { readonly tsAlias = "values"; - // VALUES emits `AS q(col1, col2, ...)` — column names go into the AS clause. + // VALUES emits `q(col1, col2, ...)` — column names go into the alias clause. readonly emitColumnNamesWithAlias = true; private vals0: R; private valsRest: (R | RowTypeToTsType)[]; @@ -32,7 +32,7 @@ export class Values extends Sql implements Fromable { ) as R; } - // Return the pre-AS VALUES fragment. QB appends `AS q(col1, col2, ...)`. + // Return the pre-alias VALUES fragment. QB appends `q(col1, col2, ...)`. bind(): BoundSql { const columnNames = Object.keys(this.vals0); const rowSqls = [this.vals0, ...this.valsRest].map((row) => { diff --git a/src/database.test.ts b/src/database.test.ts index 2caeb82..23c5a19 100644 --- a/src/database.test.ts +++ b/src/database.test.ts @@ -25,40 +25,48 @@ afterAll(async () => { }); test("transaction commits on success", async () => { - await conn.execute(sql`CREATE TABLE txtest (id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL)`); + await conn.execute( + sql`CREATE TABLE txtest (id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL)`, + ); class TxTest extends db.Table("txtest") { - id = Int8.column({ nonNull: true, generated: true }); name = Text.column({ nonNull: true }); } + id = Int8.column({ nonNull: true, generated: true }); + name = Text.column({ nonNull: true }); + } await conn.transaction(async (tx) => { - await tx.execute(TxTest.insert({ name: "Alice" })); - await tx.execute(TxTest.insert({ name: "Bob" })); + await TxTest.insert({ name: "Alice" }).execute(tx); + await TxTest.insert({ name: "Bob" }).execute(tx); }); - const rows = await conn.execute( - TxTest.from().select(({ txtest }) => ({ name: txtest.name })), - ); + const rows = await TxTest.from() + .select(({ txtest }) => ({ name: txtest.name })) + .execute(); expect(rows).toEqual([{ name: "Alice" }, { name: "Bob" }]); await conn.execute(sql`DROP TABLE txtest`); }); test("transaction rollbacks on error", async () => { - await conn.execute(sql`CREATE TABLE txtest2 (id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL)`); + await conn.execute( + sql`CREATE TABLE txtest2 (id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL)`, + ); class TxTest2 extends db.Table("txtest2") { - id = Int8.column({ nonNull: true, generated: true }); name = Text.column({ nonNull: true }); } + id = Int8.column({ nonNull: true, generated: true }); + name = Text.column({ nonNull: true }); + } await expect( conn.transaction(async (tx) => { - await tx.execute(TxTest2.insert({ name: "Alice" })); + await TxTest2.insert({ name: "Alice" }).execute(tx); throw new Error("rollback!"); }), ).rejects.toThrow("rollback!"); - const rows = await conn.execute( - TxTest2.from().select(({ txtest2 }) => ({ name: txtest2.name })), - ); + const rows = await TxTest2.from() + .select(({ txtest2 }) => ({ name: txtest2.name })) + .execute(); expect(rows).toEqual([]); await conn.execute(sql`DROP TABLE txtest2`); diff --git a/src/drivers/do.ts b/src/drivers/do.ts index 2614e63..4550aa0 100644 --- a/src/drivers/do.ts +++ b/src/drivers/do.ts @@ -1,6 +1,7 @@ import type { CompiledSql } from "../builder/sql"; import type { ExecuteFn, ExecuteSyncFn, QueryResult, SyncDriver } from "./types"; -import { normalizeRow, stripMatchedOuterParens } from "./shared-sqlite"; +import { normalizeRow } from "./shared-sqlite"; +import { stripMatchedOuterParens } from "./shared"; // Duck-typed Cloudflare SqlStorage — no @cloudflare/workers-types dependency. export interface SqlStorageLike { diff --git a/src/drivers/oracle.ts b/src/drivers/oracle.ts index 752124a..e49f30f 100644 --- a/src/drivers/oracle.ts +++ b/src/drivers/oracle.ts @@ -2,6 +2,7 @@ import type { CompiledSql } from "../builder/sql"; import type { DialectName } from "../builder/sql"; import oracledb from "oracledb"; import type { Driver, ExecuteFn, QueryResult } from "./types"; +import { stripMatchedOuterParens } from "./shared"; // node-oracledb adapter (thin mode — no Instant Client). Optional peer, // imported statically because this module only loads when the caller @@ -52,7 +53,7 @@ export class OracleDriver implements Driver { async execute({ text, values }: CompiledSql): Promise { const conn = await this.pool.getConnection(); try { - const result = await conn.execute(text, oracleBinds(values), { + const result = await conn.execute(stripMatchedOuterParens(text), oracleBinds(values), { outFormat: oracledb.OUT_FORMAT_OBJECT, autoCommit: true, }); @@ -66,7 +67,7 @@ export class OracleDriver implements Driver { const conn = await this.pool.getConnection(); try { return await cb(async ({ text, values }) => { - const result = await conn.execute(text, oracleBinds(values), { + const result = await conn.execute(stripMatchedOuterParens(text), oracleBinds(values), { outFormat: oracledb.OUT_FORMAT_OBJECT, autoCommit: false, }); diff --git a/src/drivers/shared-sqlite.ts b/src/drivers/shared-sqlite.ts index e61e343..6bca284 100644 --- a/src/drivers/shared-sqlite.ts +++ b/src/drivers/shared-sqlite.ts @@ -1,24 +1,6 @@ // Shared sqlite driver helpers (better-sqlite3 + SqlStorage). // No optional peer imports — safe for workerd. -// Strip one outer pair of parentheses iff they balance to enclose the -// entire string. `(SELECT 1)` → `SELECT 1`; `(SELECT 1) UNION (SELECT 2)` -// stays as-is. QueryBuilder.bind() wraps statements in `(...)` for -// subquery splicing; SQLite refuses top-level parenthesized statements. -export const stripMatchedOuterParens = (s: string): string => { - const t = s.trim(); - if (!t.startsWith("(") || !t.endsWith(")")) {return s;} - let depth = 0; - for (let i = 0; i < t.length; i++) { - if (t[i] === "(") {depth++;} - else if (t[i] === ")") { - depth--; - if (depth === 0 && i !== t.length - 1) {return s;} - } - } - return t.slice(1, -1); -}; - // PG bytea text-protocol form. Pure JS so it runs in workerd (no Buffer). const toHex = (bytes: Uint8Array): string => { let s = "\\x"; diff --git a/src/drivers/shared.test.ts b/src/drivers/shared.test.ts new file mode 100644 index 0000000..7fb7efe --- /dev/null +++ b/src/drivers/shared.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "vitest"; +import { stripMatchedOuterParens } from "./shared"; + +describe("stripMatchedOuterParens", () => { + test("strips one pair enclosing the complete statement", () => { + expect(stripMatchedOuterParens("(SELECT (1))")).toBe("SELECT (1)"); + expect(stripMatchedOuterParens(" (SELECT 1) ")).toBe("SELECT 1"); + }); + + test("preserves partial and unbalanced wrappers", () => { + expect(stripMatchedOuterParens("(SELECT 1) UNION (SELECT 2)")).toBe( + "(SELECT 1) UNION (SELECT 2)", + ); + expect(stripMatchedOuterParens("(SELECT 1")).toBe("(SELECT 1"); + expect(stripMatchedOuterParens("SELECT 1")).toBe("SELECT 1"); + }); +}); diff --git a/src/drivers/shared.ts b/src/drivers/shared.ts new file mode 100644 index 0000000..f4dbbf8 --- /dev/null +++ b/src/drivers/shared.ts @@ -0,0 +1,18 @@ +// Strip one outer pair of parentheses iff they balance to enclose the +// entire string. `(SELECT 1)` → `SELECT 1`; `(SELECT 1) UNION (SELECT 2)` +// stays as-is. QueryBuilder.bind() wraps statements in `(...)` for +// subquery splicing; SQLite and Oracle reject some top-level parenthesized +// statements. +export const stripMatchedOuterParens = (s: string): string => { + const t = s.trim(); + if (!t.startsWith("(") || !t.endsWith(")")) { return s; } + let depth = 0; + for (let i = 0; i < t.length; i++) { + if (t[i] === "(") { depth++; } + else if (t[i] === ")") { + depth--; + if (depth === 0 && i !== t.length - 1) { return s; } + } + } + return t.slice(1, -1); +}; diff --git a/src/drivers/sqlite.ts b/src/drivers/sqlite.ts index 52e4a20..f3a67e2 100644 --- a/src/drivers/sqlite.ts +++ b/src/drivers/sqlite.ts @@ -2,7 +2,8 @@ import type { CompiledSql } from "../builder/sql"; import type { DialectName } from "../builder/sql"; import BetterSqlite3 from "better-sqlite3"; import type { ExecuteSyncFn, QueryResult, SyncDriver } from "./types"; -import { normalizeRow, stripMatchedOuterParens } from "./shared-sqlite"; +import { normalizeRow } from "./shared-sqlite"; +import { stripMatchedOuterParens } from "./shared"; // better-sqlite3 adapter. Synchronous under the hood; wrapped in // Promise.resolve for the async Driver contract. `better-sqlite3` is an diff --git a/src/hydrate.test.ts b/src/hydrate.test.ts index 2b29e9b..e5f5082 100644 --- a/src/hydrate.test.ts +++ b/src/hydrate.test.ts @@ -74,7 +74,9 @@ describe("db.hydrate", () => { test("hydrated column is an Any wrapping the deserialized value", async () => { const [user] = await conn.hydrate( - User.from().where((ns) => ns.users.id["="]("1")).limit(1), + User.from() + .where((ns) => ns.users.id["="]("1")) + .limit(1), ); // id stays a typed Any after hydrate — that's what lets relation methods // ("this.id.eq(...)") compose into follow-up queries. @@ -85,13 +87,15 @@ describe("db.hydrate", () => { test("relation method on a hydrated instance runs as a real query", async () => { const [alice] = await conn.hydrate( - User.from().where((ns) => ns.users.id["="]("1")).limit(1), + User.from() + .where((ns) => ns.users.id["="]("1")) + .limit(1), ); // Call the relation method on the materialized instance. The method // composes `this.id` (an Any wrapping the param) into a fresh // QueryBuilder which we then run. expectTypeOf(alice!.todos()).toEqualTypeOf>(); - const aliceTodos = await conn.execute(alice!.todos()); + const aliceTodos = await alice!.todos().execute(); // db.execute returns deserialized JS values, not Any wrappers — so // .title is `string`, not `Text<1>`. (RowTypeToTsType also threads // class methods through, so the row type is wider than just columns; @@ -105,37 +109,44 @@ describe("db.hydrate", () => { test("instance mutation method runs as a real query", async () => { const [todo] = await conn.hydrate( - Todo.from().where((ns) => ns.todos.title["="](Text.from("a-one"))).limit(1), + Todo.from() + .where((ns) => ns.todos.title["="](Text.from("a-one"))) + .limit(1), ); expectTypeOf(todo!.completed).toMatchTypeOf>(); expect(todo!.completed).toBeDefined(); - await conn.execute(todo!.update({ completed: true })); + await todo!.update({ completed: true }).execute(); - const [after] = await conn.execute( - Todo.from().where((ns) => ns.todos.title["="](Text.from("a-one"))), - ); + const [after] = await Todo.from() + .where((ns) => ns.todos.title["="](Text.from("a-one"))) + .execute(); expectTypeOf(after!.completed).toEqualTypeOf(); expect(after!.completed).toBe(true); }); test("chained hydrate -> method -> hydrate -> method", async () => { const [alice] = await conn.hydrate( - User.from().where((ns) => ns.users.id["="]("1")).limit(1), + User.from() + .where((ns) => ns.users.id["="]("1")) + .limit(1), ); const [firstTodo] = await conn.hydrate( - alice!.todos().orderBy((ns) => ns.todos.id).limit(1), + alice! + .todos() + .orderBy((ns) => ns.todos.id) + .limit(1), ); // Hydrate yields a Todo instance, not a plain row — methods on Todo // (update(), the column accessors) must be callable on `firstTodo`. expectTypeOf(firstTodo!).toMatchTypeOf(); expect(firstTodo).toBeInstanceOf(Todo); - await conn.execute(firstTodo!.update({ title: "renamed" })); + await firstTodo!.update({ title: "renamed" }).execute(); - const [reloaded] = await conn.execute( - Todo.from().where((ns) => ns.todos.id["="](firstTodo!.id)), - ); + const [reloaded] = await Todo.from() + .where((ns) => ns.todos.id["="](firstTodo!.id)) + .execute(); expectTypeOf(reloaded!.title).toEqualTypeOf(); expect(reloaded!.title).toBe("renamed"); }); diff --git a/src/live/extractor.ts b/src/live/extractor.ts index 7a627ac..99a1021 100644 --- a/src/live/extractor.ts +++ b/src/live/extractor.ts @@ -67,13 +67,11 @@ export type TraverseEntry = { export type TraverseResult = Map; -const addRelative = ( - result: TraverseResult, - alias: Alias, - rel: RelativePredicate, -): void => { +const addRelative = (result: TraverseResult, alias: Alias, rel: RelativePredicate): void => { const entry = result.get(alias); - if (!entry) { return; } // alias didn't have a registered table — skip + if (!entry) { + return; + } // alias didn't have a registered table — skip entry.predicates.push(rel); if (rel.to instanceof Column && rel.to.tableAlias !== alias) { entry.edges.add(rel.to.tableAlias); @@ -161,9 +159,7 @@ export const sortAliases = (traversal: TraverseResult): CteSpec[] => { } if (orderSet.size !== traversal.size) { - const missing = [...traversal.keys()] - .filter((a) => !orderSet.has(a)) - .map((a) => a.tsAlias); + const missing = [...traversal.keys()].filter((a) => !orderSet.has(a)).map((a) => a.tsAlias); throw new Error(`Aliases unreachable from any literal anchor: ${missing.join(", ")}`); } @@ -313,7 +309,9 @@ export const materializePredicateSet = ( // find them, since group is the survivor. for (const byCol of groups.values()) { for (const [c, g] of byCol) { - if (g === otherGroup) { byCol.set(c, group); } + if (g === otherGroup) { + byCol.set(c, group); + } } } } @@ -355,7 +353,7 @@ export const runExtraction = async ( const extracted = extractedResult.rows as ExtractedRow[]; const predicateSet = materializePredicateSet(extracted, traversal); - const rows = await conn.execute(query); + const rows = await query.execute(conn); return { rows, predicateSet }; }; diff --git a/src/table.test.ts b/src/table.test.ts index 4a40b97..468e49b 100644 --- a/src/table.test.ts +++ b/src/table.test.ts @@ -26,14 +26,19 @@ test("Table.from().select()", async () => { name text NOT NULL, breed text )`); - await tx.execute(sql`INSERT INTO dogs (name, breed) VALUES ('Rex', 'Labrador'), ('Fido', NULL)`); + await tx.execute( + sql`INSERT INTO dogs (name, breed) VALUES ('Rex', 'Labrador'), ('Fido', NULL)`, + ); class Dogs extends db.Table("dogs") { - id = Int8.column({ nonNull: true }); name = Text.column({ nonNull: true }); breed = Text.column(); } + id = Int8.column({ nonNull: true }); + name = Text.column({ nonNull: true }); + breed = Text.column(); + } - const rows = await tx.execute(Dogs.from() + const rows = await Dogs.from() .select(({ dogs }) => ({ id: dogs.id, name: dogs.name, breed: dogs.breed })) - ); + .execute(tx); expectTypeOf(rows).toEqualTypeOf<{ id: string; name: string; breed: string | null }[]>(); expect(rows).toEqual([ @@ -51,14 +56,20 @@ test("Table.as() alias", async () => { name text NOT NULL, breed text )`); - await tx.execute(sql`INSERT INTO dogs (name, breed) VALUES ('Rex', 'Labrador'), ('Fido', NULL)`); + await tx.execute( + sql`INSERT INTO dogs (name, breed) VALUES ('Rex', 'Labrador'), ('Fido', NULL)`, + ); class Dogs extends db.Table("dogs") { - id = Int8.column({ nonNull: true }); name = Text.column({ nonNull: true }); breed = Text.column(); } + id = Int8.column({ nonNull: true }); + name = Text.column({ nonNull: true }); + breed = Text.column(); + } - const rows = await tx.execute(Dogs.as("d").from() + const rows = await Dogs.as("d") + .from() .select(({ d }) => ({ id: d.id, name: d.name, breed: d.breed })) - ); + .execute(tx); expectTypeOf(rows).toEqualTypeOf<{ id: string; name: string; breed: string | null }[]>(); expect(rows).toEqual([ @@ -88,7 +99,10 @@ test("Table class is a Fromable and self-joins via .as()", async () => { `); class Employees extends db.Table("employees") { - id = Int8.column({ nonNull: true }); name = Text.column({ nonNull: true }); manager_id = Int8.column(); } + id = Int8.column({ nonNull: true }); + name = Text.column({ nonNull: true }); + manager_id = Int8.column(); + } // 1. The class itself has the Fromable-shaped statics. expect(Employees.tsAlias).toBe("employees"); @@ -101,9 +115,10 @@ test("Table class is a Fromable and self-joins via .as()", async () => { void _fromableCheck; // 2. db.from(Class) / Class.from() consume the statics directly. - const allNames = await tx.execute( - Employees.from().select(({ employees }) => ({ name: employees.name })).orderBy(({ employees }) => employees.id), - ); + const allNames = await Employees.from() + .select(({ employees }) => ({ name: employees.name })) + .orderBy(({ employees }) => employees.id) + .execute(tx); expect(allNames.map((r) => r.name)).toEqual(["Alice", "Bob", "Carol"]); // 3. Self-join via .as() — the same table used twice must register as @@ -115,15 +130,14 @@ test("Table class is a Fromable and self-joins via .as()", async () => { // Pass classes to .join, not `.from()` subqueries — the class IS the // Fromable, so this emits `JOIN employees AS mgr ON ...` directly. - const reports = await tx.execute( - Employees.from() - .join(Mgr, ({ employees, mgr }) => employees.manager_id["="](mgr.id)) - .select(({ employees, mgr }) => ({ - employee: employees.name, - manager: mgr.name, - })) - .orderBy(({ employees }) => employees.id), - ); + const reports = await Employees.from() + .join(Mgr, ({ employees, mgr }) => employees.manager_id["="](mgr.id)) + .select(({ employees, mgr }) => ({ + employee: employees.name, + manager: mgr.name, + })) + .orderBy(({ employees }) => employees.id) + .execute(tx); expect(reports).toEqual([ { employee: "Bob", manager: "Alice" }, { employee: "Carol", manager: "Alice" }, diff --git a/src/types/sqlite/smoke.test.ts b/src/types/sqlite/smoke.test.ts index 56fbcc2..c0d3ed1 100644 --- a/src/types/sqlite/smoke.test.ts +++ b/src/types/sqlite/smoke.test.ts @@ -62,9 +62,7 @@ test("Text.lower composed with Text.upper (round-trip)", async () => { test("isNull returns 1 for NULL, 0 for a value", async () => { const nullExpr = Text.from(sql`NULL`).isNull(); const notNullExpr = Text.from("x").isNull(); - const r = await conn.execute( - sql`SELECT ${nullExpr.toSql()} AS n, ${notNullExpr.toSql()} AS nn`, - ); + const r = await conn.execute(sql`SELECT ${nullExpr.toSql()} AS n, ${notNullExpr.toSql()} AS nn`); // SQLite returns 1/0 as integer for boolean expressions; we normalize to strings expect(r.rows[0]!["n"]).toBe("1"); expect(r.rows[0]!["nn"]).toBe("0"); @@ -127,7 +125,9 @@ test("integer positions never silently truncate fractional numbers", async () => test("Blob roundtrip: driver normalizes to \\x-hex; deserialize parses back", async () => { const r = await conn.execute(sql`SELECT ${Blob.from(new Uint8Array([1, 255])).toSql()} as v`); expect(r.rows[0]!["v"]).toBe("\\x01ff"); - expect(Blob.from(new Uint8Array()).deserialize(r.rows[0]!["v"]!)).toEqual(new Uint8Array([1, 255])); + expect(Blob.from(new Uint8Array()).deserialize(r.rows[0]!["v"]!)).toEqual( + new Uint8Array([1, 255]), + ); }); test(".in() accepts Uint8Array (the blob primitive)", async () => { @@ -137,7 +137,10 @@ test(".in() accepts Uint8Array (the blob primitive)", async () => { test("json_each: table-valued function via db.from", async () => { const each = Text.from('{"a":1,"b":2}').jsonEach(); - const rows = await conn.execute(db.from(each).orderBy(({ json_each }) => json_each.key)); + const rows = await db + .from(each) + .orderBy(({ json_each }) => json_each.key) + .execute(); expect(rows).toMatchObject([ { key: "a", value: "1", type: "integer" }, { key: "b", value: "2", type: "integer" }, @@ -234,7 +237,9 @@ test("numeric binop primitives: integral numbers cast to the claim; fractional r const one = Integer.from(1); const viaPrim = one.minus(2); const _viaPrim: Integer<1> = viaPrim; - const r1 = await conn.execute(sql`SELECT ${viaPrim.toSql()} as v, typeof(${viaPrim.toSql()}) as t`); + const r1 = await conn.execute( + sql`SELECT ${viaPrim.toSql()} as v, typeof(${viaPrim.toSql()}) as t`, + ); expect(r1.rows[0]).toEqual({ v: "-1", t: "integer" }); // The compiled SQL carries the forced cast. const compiled = compile(viaPrim.toSql(), { database: db }); @@ -244,7 +249,9 @@ test("numeric binop primitives: integral numbers cast to the claim; fractional r // Cross-type stays honest via instances — distinguishable in TS. const viaReal = one.minus(Real.from(2.5)); const _viaReal: Real<1> = viaReal; - const r2 = await conn.execute(sql`SELECT ${viaReal.toSql()} as v, typeof(${viaReal.toSql()}) as t`); + const r2 = await conn.execute( + sql`SELECT ${viaReal.toSql()} as v, typeof(${viaReal.toSql()}) as t`, + ); expect(r2.rows[0]).toEqual({ v: "-1.5", t: "real" }); });