From 085e005b5cf7584759f982446e72713647f71823 Mon Sep 17 00:00:00 2001 From: Ryan Rasti Date: Tue, 28 Oct 2025 15:29:13 -0700 Subject: [PATCH 1/2] Insert fluent API --- src/grammar/insert.test.ts | 259 +++++++++++++++++++++++++++++++++++++ src/grammar/insert.ts | 22 +++- 2 files changed, 279 insertions(+), 2 deletions(-) diff --git a/src/grammar/insert.test.ts b/src/grammar/insert.test.ts index f41058a..3bc6478 100644 --- a/src/grammar/insert.test.ts +++ b/src/grammar/insert.test.ts @@ -374,4 +374,263 @@ describe("INSERT parser", () => { }); }); }); + + describe("INSERT fluent API", () => { + describe("Basic fluent API", () => { + it("should support insert with default values", () => { + const query = insert({ into: db.Users }); + + const compiled = query.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe('INSERT INTO "users" DEFAULT VALUES'); + expect(result.parameters).toEqual([]); + }); + + it("should support adding values after construction", () => { + const query = insert({ into: db.Users }).values( + values({ + name: Text.new("John"), + email: Text.new("john@example.com"), + }), + ); + + const compiled = query.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe('INSERT INTO "users" ("email", "name") (VALUES (cast($1 as text), cast($2 as text)))'); + expect(result.parameters).toEqual(["john@example.com", "John"]); + }); + + it("should support adding SELECT as values", () => { + const query = insert({ into: db.Users }).values( + select(() => ({ + name: Text.new("John"), + email: Text.new("john@example.com"), + })), + ); + + const compiled = query.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'INSERT INTO "users" ("email", "name") (SELECT cast($1 as text) AS "email", cast($2 as text) AS "name")', + ); + expect(result.parameters).toEqual(["john@example.com", "John"]); + }); + + it("should support returning clause with entire row", () => { + const query = insert({ into: db.Users }) + .values( + values({ + name: Text.new("John"), + email: Text.new("john@example.com"), + }), + ) + .returning((row) => row); + + const compiled = query.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'INSERT INTO "users" ("email", "name") (VALUES (cast($1 as text), cast($2 as text))) RETURNING "users"."active" AS "active", "users"."email" AS "email", "users"."id" AS "id", "users"."name" AS "name", "users"."role" AS "role"', + ); + expect(result.parameters).toEqual(["john@example.com", "John"]); + }); + + it("should support returning clause with custom selection", () => { + const query = insert({ into: db.Users }) + .values( + values({ + name: Text.new("John"), + email: Text.new("john@example.com"), + }), + ) + .returning((row) => ({ id: row.id, name: row.name })); + + const compiled = query.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'INSERT INTO "users" ("email", "name") (VALUES (cast($1 as text), cast($2 as text))) RETURNING "users"."id" AS "id", "users"."name" AS "name"', + ); + expect(result.parameters).toEqual(["john@example.com", "John"]); + }); + + it("should support onConflict clause", () => { + const query = insert({ into: db.Users }) + .values( + values({ + name: Text.new("John"), + email: Text.new("john@example.com"), + }), + ) + .onConflict({ doNothing: true }); + + const compiled = query.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'INSERT INTO "users" ("email", "name") (VALUES (cast($1 as text), cast($2 as text))) ON CONFLICT DO NOTHING', + ); + expect(result.parameters).toEqual(["john@example.com", "John"]); + }); + }); + + describe("Method chaining order independence", () => { + it("should work with values -> returning -> onConflict", () => { + const query = insert({ into: db.Users }) + .values(values({ name: Text.new("John"), email: Text.new("john@example.com") })) + .returning((row) => ({ id: row.id })) + .onConflict({ doNothing: true }); + + const result = query.compile().compile(dummyDb); + expect(result.sql).toBe( + 'INSERT INTO "users" ("email", "name") (VALUES (cast($1 as text), cast($2 as text))) ON CONFLICT DO NOTHING RETURNING "users"."id" AS "id"', + ); + expect(result.parameters).toEqual(["john@example.com", "John"]); + }); + + it("should work with onConflict -> values -> returning", () => { + const query = insert({ into: db.Users }) + .onConflict({ doNothing: true }) + .values(values({ name: Text.new("John"), email: Text.new("john@example.com") })) + .returning((row) => ({ id: row.id })); + + const result = query.compile().compile(dummyDb); + expect(result.sql).toBe( + 'INSERT INTO "users" ("email", "name") (VALUES (cast($1 as text), cast($2 as text))) ON CONFLICT DO NOTHING RETURNING "users"."id" AS "id"', + ); + expect(result.parameters).toEqual(["john@example.com", "John"]); + }); + + it("should work with returning -> onConflict -> values", () => { + const query = insert({ into: db.Users }) + .returning((row) => ({ id: row.id })) + .onConflict({ doNothing: true }) + .values(values({ name: Text.new("John"), email: Text.new("john@example.com") })); + + const result = query.compile().compile(dummyDb); + expect(result.sql).toBe( + 'INSERT INTO "users" ("email", "name") (VALUES (cast($1 as text), cast($2 as text))) ON CONFLICT DO NOTHING RETURNING "users"."id" AS "id"', + ); + expect(result.parameters).toEqual(["john@example.com", "John"]); + }); + }); + + describe("Latest value wins", () => { + it("should use the latest values", () => { + const query = insert({ into: db.Users }) + .values(values({ name: Text.new("John"), email: Text.new("john@example.com") })) + .values(values({ name: Text.new("Jane"), email: Text.new("jane@example.com") })); + + const result = query.compile().compile(dummyDb); + expect(result.sql).toBe('INSERT INTO "users" ("email", "name") (VALUES (cast($1 as text), cast($2 as text)))'); + expect(result.parameters).toEqual(["jane@example.com", "Jane"]); + }); + + it("should use the latest returning", () => { + const query = insert({ into: db.Users }) + .values(values({ name: Text.new("John"), email: Text.new("john@example.com") })) + .returning((row) => ({ id: row.id })) + .returning((row) => ({ name: row.name })); + + const result = query.compile().compile(dummyDb); + expect(result.sql).toBe( + 'INSERT INTO "users" ("email", "name") (VALUES (cast($1 as text), cast($2 as text))) RETURNING "users"."name" AS "name"', + ); + expect(result.parameters).toEqual(["john@example.com", "John"]); + }); + + it("should use the latest onConflict", () => { + const query = insert({ into: db.Users }) + .values(values({ name: Text.new("John"), email: Text.new("john@example.com") })) + .onConflict({ doNothing: true }) + .onConflict({ + target: (row) => row.email, + doUpdateSet: (_, excluded) => ({ name: excluded.name }), + }); + + const result = query.compile().compile(dummyDb); + expect(result.sql).toBe( + 'INSERT INTO "users" ("email", "name") (VALUES (cast($1 as text), cast($2 as text))) ON CONFLICT ("email") DO UPDATE SET "name" = "excluded"."name"', + ); + expect(result.parameters).toEqual(["john@example.com", "John"]); + }); + }); + + describe("Complex scenarios", () => { + it("should support VALUES with multiple rows and returning", () => { + const query = insert({ into: db.Users }) + .values( + values( + { name: Text.new("John"), email: Text.new("john@example.com") }, + { name: Text.new("Jane"), email: Text.new("jane@example.com") }, + ), + ) + .returning((row) => ({ id: row.id, name: row.name })); + + const result = query.compile().compile(dummyDb); + expect(result.sql).toBe( + 'INSERT INTO "users" ("email", "name") (VALUES (cast($1 as text), cast($2 as text)), (cast($3 as text), cast($4 as text))) RETURNING "users"."id" AS "id", "users"."name" AS "name"', + ); + expect(result.parameters).toEqual(["john@example.com", "John", "jane@example.com", "Jane"]); + }); + + it("should support SELECT with WHERE and returning", () => { + const query = insert({ into: db.Users }) + .values( + select((u) => ({ name: u.name, email: u.email }), { + from: db.UpdateTestUsers, + where: (u) => u.active["="](Int4.new(1)), + }), + ) + .returning((row) => row); + + const result = query.compile().compile(dummyDb); + expect(result.sql).toBe( + 'INSERT INTO "users" ("email", "name") (SELECT "update_test_users"."email" AS "email", "update_test_users"."name" AS "name" FROM "update_test_users" as "update_test_users" WHERE ("update_test_users"."active" = cast($1 as int4))) RETURNING "users"."active" AS "active", "users"."email" AS "email", "users"."id" AS "id", "users"."name" AS "name", "users"."role" AS "role"', + ); + expect(result.parameters).toEqual([1]); + }); + + it("should support onConflict with DO UPDATE and WHERE", () => { + const query = insert({ into: db.Users }) + .values(values({ name: Text.new("John"), email: Text.new("john@example.com") })) + .onConflict({ + target: (row) => row.email, + doUpdateSet: [(_, excluded) => ({ name: excluded.name }), { where: (row) => row.active["="](Int4.new(1)) }], + }) + .returning((row) => ({ id: row.id, name: row.name })); + + const result = query.compile().compile(dummyDb); + expect(result.sql).toBe( + 'INSERT INTO "users" ("email", "name") (VALUES (cast($1 as text), cast($2 as text))) ON CONFLICT ("email") DO UPDATE SET "name" = "excluded"."name" WHERE ("users"."active" = cast($3 as int4)) RETURNING "users"."id" AS "id", "users"."name" AS "name"', + ); + expect(result.parameters).toEqual(["john@example.com", "John", 1]); + }); + + it("should switch from default values to explicit values", () => { + const query1 = insert({ into: db.Users }); + const query2 = query1.values(values({ name: Text.new("John"), email: Text.new("john@example.com") })); + + const result1 = query1.compile().compile(dummyDb); + const result2 = query2.compile().compile(dummyDb); + + expect(result1.sql).toBe('INSERT INTO "users" DEFAULT VALUES'); + expect(result2.sql).toBe('INSERT INTO "users" ("email", "name") (VALUES (cast($1 as text), cast($2 as text)))'); + expect(result2.parameters).toEqual(["john@example.com", "John"]); + }); + + it("should switch from explicit values back to default values", () => { + const query = insert({ into: db.Users }) + .values(values({ name: Text.new("John"), email: Text.new("john@example.com") })) + .values("defaultValues"); + + const result = query.compile().compile(dummyDb); + expect(result.sql).toBe('INSERT INTO "users" DEFAULT VALUES'); + expect(result.parameters).toEqual([]); + }); + }); + }); }); diff --git a/src/grammar/insert.ts b/src/grammar/insert.ts index 41fc446..80563db 100644 --- a/src/grammar/insert.ts +++ b/src/grammar/insert.ts @@ -40,7 +40,25 @@ export class Insert< R extends Types.RowLike = I, V extends TableSchemaToRowLikeStrict = TableSchemaToRowLikeStrict, > { - constructor(public clause: Parameters>) {} + constructor(public clause: Parameters>) { + if (!clause[1]) { + // insertion values are required, but we allow omitting them to make the + // query builder more ergonomic: + clause[1] = "defaultValues"; + } + } + + values(values: "defaultValues" | Values | Select) { + return new Insert([this.clause[0], values, this.clause[2]]); + } + + onConflict(onConflict: OnConflictInput) { + return new Insert([this.clause[0], this.clause[1], { ...this.clause[2], onConflict }]); + } + + returning(fn?: (insertRow: I) => R2): Insert { + return new Insert([this.clause[0], this.clause[1], { ...this.clause[2], returning: fn }]); + } compile(ctxIn = Context.new()) { const [{ into, overriding }, values, { onConflict, returning } = {}] = this.clause; @@ -238,7 +256,7 @@ export const insert = < into: Types.Table; overriding?: ["system" | "user", "value"]; }, - values: "defaultValues" | Values | Select, + values?: "defaultValues" | Values | Select, opts?: { onConflict?: OnConflictInput; returning?: (insertRow: I) => R; From edfd218ff5c56343448fe3b930f319733993726a Mon Sep 17 00:00:00 2001 From: Ryan Rasti Date: Wed, 29 Oct 2025 09:14:26 -0700 Subject: [PATCH 2/2] review feedback --- src/grammar/insert.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/grammar/insert.ts b/src/grammar/insert.ts index 80563db..74fcaa5 100644 --- a/src/grammar/insert.ts +++ b/src/grammar/insert.ts @@ -40,11 +40,14 @@ export class Insert< R extends Types.RowLike = I, V extends TableSchemaToRowLikeStrict = TableSchemaToRowLikeStrict, > { - constructor(public clause: Parameters>) { - if (!clause[1]) { + public clause: Parameters>; + + constructor(clause: Parameters>) { + this.clause = [...clause]; + if (!this.clause[1]) { // insertion values are required, but we allow omitting them to make the // query builder more ergonomic: - clause[1] = "defaultValues"; + this.clause[1] = "defaultValues"; } }