Closes #1171.
unsafe(query, params) accepted a query whose placeholders the connection could not possibly bind and passed it through to the server:
await db.primary.unsafe('SELECT * FROM users WHERE email = ? LIMIT 1', [email])
// PostgresError: syntax error at or near "LIMIT" at character 37Postgres has no ? parameter. It reads the token as an operator, looks for a right operand and rejects the next word — so the error named LIMIT while the fault was the ? 37 characters earlier, and the bound value was never going to arrive.
Now the call itself throws, naming the real problem:
[query-builder] unsafe(): 1 binding supplied, but the query binds them with 1 `?`
placeholder, which Postgres reads as an operator rather than a parameter. Postgres
numbers its parameters, so write $1, $2 … in the order the bindings should bind:
unsafe('SELECT * FROM users WHERE email = $1 LIMIT 1', [email]). `?` is correct on
SQLite and MySQL, which is why this call may work there.The last sentence is deliberate — the shape of this bug is a suite that is green on two dialects and red on the third, so the message says why.
Why it is keyed on the absence of $n
Not on the presence of ?, which would produce false positives:
?is a real Postgres operator —data ? 'key'on jsonb, plus several geometric types.SELECT * FROM t WHERE data ? 'key' AND id = $1is valid, and seeing$nis enough to leave it alone.- A
?inside a string literal (WHERE name = 'what?') is not a placeholder either, and such a query would not be passing bindings anyway.
Bindings with no $n anywhere cannot be a working Postgres query whatever the text does: either a placeholder is in the wrong style, or nothing references the values. Both are worth naming here rather than at a token the server happened to pick.
Where the message counts placeholders it uses the existing countPlaceholders, which skips ? in quoted runs and comments, so the count reflects what was actually meant as a placeholder.
Scope
- Only the public builder
unsafe. The ORM's raw path goes throughgetOrCreateBunSql()directly and already converts viatoPostgresPlaceholders, so it is unaffected. - A call with no bindings is never inspected — raw DDL keeps working untouched.
- MySQL and SQLite are not inspected at all.
- Throws synchronously, so the stack names the caller rather than surfacing as a rejected promise.
Acceptance criteria from the issue
| Criterion | Covered by |
|---|---|
? + bindings on Postgres throws at the call site, naming the expected style | throws on the exact call from the issue, names the real problem… |
| MySQL and SQLite unaffected | SQLite and MySQL still take ? |
| No params is never inspected, raw DDL untouched | never inspects a query with no bindings |
Verification
- Full suite: 2331 pass, 10 skip, 0 fail
bun --bun tscclean;pickierclean on all three files- The two
runtime-entryfailures I hit mid-work were a missingdist/, pre-existing onmainand green afterbun run build— not related to this change - Mutation-checked the new tests so the negative cases are not vacuous: removing the
$nescape hatch fails the two "accepts$n" tests, and removing the dialect check fails the SQLite/MySQL test
Also documents the placeholder contract in docs/raw-queries.md, which previously showed only a ? example.
🤖 Generated with Claude Code