ReviewOS

also looking at this

stacks/bun-query-builder

fix(client): unsafe() refuses ?-style bindings on Postgres

#1172
Merged glennmichael123 wants to merge fix/unsafe-placeholder-style into main
3 files +247 -13
8 days ago

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 37

Postgres 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 = $1 is valid, and seeing $n is 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 through getOrCreateBunSql() directly and already converts via toPostgresPlaceholders, 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

CriterionCovered by
? + bindings on Postgres throws at the call site, naming the expected stylethrows on the exact call from the issue, names the real problem…
MySQL and SQLite unaffectedSQLite and MySQL still take ?
No params is never inspected, raw DDL untouchednever inspects a query with no bindings

Verification

  • Full suite: 2331 pass, 10 skip, 0 fail
  • bun --bun tsc clean; pickier clean on all three files
  • The two runtime-entry failures I hit mid-work were a missing dist/, pre-existing on main and green after bun run build — not related to this change
  • Mutation-checked the new tests so the negative cases are not vacuous: removing the $n escape 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

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

Sign in to comment on this pull request.