ReviewOS

also looking at this

stacks/bun-query-builder

fix(client)!: bind db.raw and db.sql fragment values or refuse them

#1152
Merged glennmichael123 wants to merge fix/db-sql-raw-bindings into main
16 files +455 -118
8 days ago

Follow-up to #1148, from the review of that fix. Same class of bug, one level down: a fragment carrying values, rendered for its text alone.

What the docs called, and what happened

CallSQLitePostgres
set({ stock: db.raw('stock - ?', [n]) })identifier marker, value dropped, 0 rows changedQuery not called as a tagged template literal
where(db.sql\name = ${v}`)`near "values": syntax errorevery row returned — the query object read as an empty column map
whereRaw(db.sql\name = ${v}`)`name = ? unbound → [], no errorcannot render this value
orderByRaw(db.sql\${col}`)`ORDER BY ? → constant ordercannot render this value
set({ n: db.sql\n + ${1}` })`expected 2 values, received 1Unknown object is not a valid PostgreSQL type
where({ sql, parameters })no such column: sqlcolumn "sql" does not exist

Three of those are silent wrong results, and the Postgres where() one drops the filter entirely.

Bind it, or refuse it

db.raw(sql, bindings) now builds a { sql, parameters } fragment. set(), where(), orWhere() and the update/delete builders render it at its position and bind its values, renumbering to $n on Postgres. The tagged form, db.raw`SELECT ${1}`, still passes through to the driver's sql tag.

It is a fragment, not a query, so await db.raw(sql, [..]) rejects with a TypeError naming db.unsafe(sql, params) — otherwise it would quietly resolve to the fragment object.

{ sql, parameters } now works on the select builder's where/orWhere, as it already did on update and delete.

SQLite db.sql fragments carry their bindings in values; bound expressions read that, so set({ n: db.sql\n + ${5}` })andwhere(db.sql`name = ${v}`)` bind on SQLite.

Bun network-driver queries (db.sql on Postgres/MySQL) are Promise subclasses with no readable SQL text. They now throw in where(), orWhere(), set(), and both write builders' where(), instead of silently matching everything or reaching the driver as an opaque value.

*Raw methods throw when the fragment carries values, the same rule #1148 set for trailing arguments — they take one fragment and bind nothing.

Docs

13 files still taught these forms. They now use db.raw('… ?', [..]) for bound values in set()/where(), the exported raw tag for *Raw fragments, and db.unsafe(sql, params) to run SQL. The JSDoc on SqlFragment and the *Raw methods no longer recommends Bun's sql tag, which those methods reject.

Each changed pattern was executed against SQLite and live Postgres.

Breaking

  • await db.raw(sql, [bindings]) rejects instead of returning a marker object or throwing a driver error. Use db.unsafe.
  • A Bun sql\`query passed towhere/set/*Rawthrows. On SQLitewhere(db.sql`…`)andset({ c: db.sql`…` })` now bind and work; every other combination already failed.
  • *Raw given a fragment that carries values throws rather than dropping them.

Tests

test/client.bound-fragments.test.ts runs each dialect in a subprocess, since the connection config is global, and skips Postgres when no server answers: db.raw in set/where/orWhere/delete, { sql, parameters }, the refusals above, and the SQLite db.sql cases. Both probes fail against main.

Locally: full suite 2220 pass / 0 fail / 10 skip, tsc clean, pickier . at its 330-warning baseline, docs-lint passes.

Not fixed here, found while checking the docs: updateTable().andWhere and insertInto().onConflict().doUpdate do not exist; raw-queries.md's prepared-statement section calls get/finalize on what db.unsafe returns; and an aggregate-only selectRaw adds to SELECT * instead of replacing it, which fails on Postgres with a GROUP BY error.

🤖 Generated with Claude Code

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

Sign in to comment on this pull request.