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
| Call | SQLite | Postgres |
|---|---|---|
set({ stock: db.raw('stock - ?', [n]) }) | identifier marker, value dropped, 0 rows changed | Query not called as a tagged template literal |
where(db.sql\name = ${v}`)` | near "values": syntax error | every row returned — the query object read as an empty column map |
whereRaw(db.sql\name = ${v}`)` | name = ? unbound → [], no error | cannot render this value |
orderByRaw(db.sql\${col}`)` | ORDER BY ? → constant order | cannot render this value |
set({ n: db.sql\n + ${1}` })` | expected 2 values, received 1 | Unknown object is not a valid PostgreSQL type |
where({ sql, parameters }) | no such column: sql | column "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. Usedb.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. *Rawgiven 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