Closes #1097.
insertInto(t).values([]) ran SELECT 1. The database is never left wrong — no row is written either way — but every layer above it was:
| call | before | after |
|---|---|---|
.execute() (SQLite) | [{ '1': 1 }] — a fabricated row | { changes: 0, lastInsertRowid: 0 } |
.execute() (Postgres) | [{ '?column?': 1 }] | [] |
| query hooks | fires insert for a query that never happened | nothing fires |
.returning('id') | throws near "RETURNING": syntax error | [] |
The first row is the one that bites: a caller checking result.length saw 1 where the truth was 0.
The .returning() failure came from appending RETURNING to the SELECT 1 stand-in.
The empty result is not invented
.execute() returns what each dialect returns for an insert that affected no rows, which I measured rather than picked:
PG values([{name:'a'}]).execute() -> [] # real insert, no RETURNING
PG values([]).execute() -> [] # now identical
SQLite values([{name:'a'}]).execute() -> { changes: 1, lastInsertRowid: 1 }
SQLite values([]).execute() -> { changes: 0, lastInsertRowid: 0 }So on Postgres an empty batch is deliberately indistinguishable from a real one that inserted nothing — which is the correct reading of "insert these zero rows".
.returning() / .returningAll() hand back a builder carrying the same method set as the real one, so .returning('id').first() still resolves instead of throwing "first is not a function" — the failure mode that was already fixed once for the non-empty path.
Empty, not a throw
values(rows) where rows came back empty is ordinary calling code. The seeder scaffold this package generates (src/actions/seed.ts:213) is written exactly that way:
await qb.insertInto('table_name').values(records).execute()A throw would turn an empty dataset into a failed seed. The database is never at risk here — the row count is 0 before and after in every probe — so there is no correctness argument for making callers guard.
Tests
test/client.insert-empty-values.test.ts — 9 tests, 6 of which fail without the src change. The other three are invariant guards that must pass both ways: non-empty inserts unaffected, no SQL emitted, and builder reuse (values([]) then values([...])) still inserting, so the new flag cannot strand a later call.
Checks
- typecheck and pickier clean
bun test— 1963 pass, 4 skip, 2 fail
Both failures are pre-existing and reproduce on clean main: the stale-bin/qbx CLI > version test, and migration locking (#1067), which passes in isolation and fails only in the full suite under load. Neither is touched here.
🤖 Generated with Claude Code