Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 259 additions & 0 deletions src/grammar/insert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
});
});
25 changes: 23 additions & 2 deletions src/grammar/insert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,28 @@ export class Insert<
R extends Types.RowLike = I,
V extends TableSchemaToRowLikeStrict<TS> = TableSchemaToRowLikeStrict<TS>,
> {
constructor(public clause: Parameters<typeof insert<I, TS, K, R, V>>) {}
public clause: Parameters<typeof insert<I, TS, K, R, V>>;

constructor(clause: Parameters<typeof insert<I, TS, K, R, V>>) {
this.clause = [...clause];
if (!this.clause[1]) {
// insertion values are required, but we allow omitting them to make the
// query builder more ergonomic:
this.clause[1] = "defaultValues";
}
}

values(values: "defaultValues" | Values<V> | Select<V, any, any>) {
return new Insert<I, TS, K, R, V>([this.clause[0], values, this.clause[2]]);
}

onConflict(onConflict: OnConflictInput<I>) {
return new Insert<I, TS, K, R, V>([this.clause[0], this.clause[1], { ...this.clause[2], onConflict }]);
Copy link

Copilot AI Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] When this.clause[2] is undefined, spreading it with { ...this.clause[2], onConflict } will result in { onConflict } only. However, if other properties were set previously (like returning), they will be preserved. But if this.clause[2] is undefined initially, this works correctly. Consider being more explicit: { ...(this.clause[2] || {}), onConflict } to make the intent clearer and avoid potential confusion.

Suggested change
return new Insert<I, TS, K, R, V>([this.clause[0], this.clause[1], { ...this.clause[2], onConflict }]);
return new Insert<I, TS, K, R, V>([this.clause[0], this.clause[1], { ...(this.clause[2] || {}), onConflict }]);

Copilot uses AI. Check for mistakes.
}

returning<R2 extends Types.RowLike = I>(fn?: (insertRow: I) => R2): Insert<I, TS, K, R2, V> {
return new Insert<I, TS, K, R2, V>([this.clause[0], this.clause[1], { ...this.clause[2], returning: fn }]);
Copy link

Copilot AI Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] When this.clause[2] is undefined, spreading it with { ...this.clause[2], returning: fn } will result in { returning: fn } only. However, if other properties were set previously (like onConflict), they will be preserved. But if this.clause[2] is undefined initially, this works correctly. Consider being more explicit: { ...(this.clause[2] || {}), returning: fn } to make the intent clearer and avoid potential confusion.

Suggested change
return new Insert<I, TS, K, R2, V>([this.clause[0], this.clause[1], { ...this.clause[2], returning: fn }]);
return new Insert<I, TS, K, R2, V>([this.clause[0], this.clause[1], { ...(this.clause[2] || {}), returning: fn }]);

Copilot uses AI. Check for mistakes.
}

compile(ctxIn = Context.new()) {
const [{ into, overriding }, values, { onConflict, returning } = {}] = this.clause;
Expand Down Expand Up @@ -238,7 +259,7 @@ export const insert = <
into: Types.Table<I, TS>;
overriding?: ["system" | "user", "value"];
},
values: "defaultValues" | Values<V> | Select<V, any, any>,
values?: "defaultValues" | Values<V> | Select<V, any, any>,
opts?: {
onConflict?: OnConflictInput<I>;
returning?: (insertRow: I) => R;
Expand Down
Loading