ReviewOS

also looking at this

stacks/bun-query-builder

fix(client): an empty insert is a no-op, not a stand-in query

#1107
Merged glennmichael123 wants to merge fix/1097-empty-values into main
2 files +244 -1
10 days ago

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:

callbeforeafter
.execute() (SQLite)[{ '1': 1 }] — a fabricated row{ changes: 0, lastInsertRowid: 0 }
.execute() (Postgres)[{ '?column?': 1 }][]
query hooksfires insert for a query that never happenednothing 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

2 changed files on the files tab, with 0 review threads.

Sign in to comment on this pull request.