also looking at this
fix(client): unsafe() refuses ?-style bindings on Postgres
#1172
3 files
+247
-13
| @@ -96,6 +96,29 @@ const result = await db.unsafe(` | ||
| 96 | 96 | Always prefer parameterized queries when possible to prevent SQL injection. Only use `unsafe` when absolutely necessary and never with user-provided input. |
| 97 | 97 | ::: |
| 98 | 98 | |
| 99 | ### Placeholder style is the connection's, not the builder's | |
| 100 | ||
| 101 | When you pass bindings to `unsafe`, you are writing for the driver directly, so the | |
| 102 | placeholders have to be the ones that connection understands. Postgres numbers its | |
| 103 | parameters; SQLite and MySQL take `?`: | |
| 104 | ||
| 105 | ```typescript | |
| 106 | // Postgres | |
| 107 | await db.unsafe('SELECT * FROM users WHERE email = $1 LIMIT 1', [email]) | |
| 108 | ||
| 109 | // SQLite and MySQL | |
| 110 | await db.unsafe('SELECT * FROM users WHERE email = ? LIMIT 1', [email]) | |
| 111 | ``` | |
| 112 | ||
| 113 | Postgres has no `?` parameter — it reads the token as an operator and rejects the | |
| 114 | word that follows, so the error names whatever comes next (`LIMIT`, above) rather | |
| 115 | than the placeholder. Because the same call works on SQLite and MySQL, this shows | |
| 116 | up as a one-dialect failure. `unsafe` therefore refuses bindings on Postgres when | |
| 117 | the query has no `$n`, and names the real problem at the call site. | |
| 118 | ||
| 119 | A query with no bindings is never inspected, so raw DDL and a literal `?` inside a | |
| 120 | string are unaffected. | |
| 121 | ||
| 99 | 122 | ## Execute Raw SQL |
| 100 | 123 | |
| 101 | 124 | Execute non-query SQL statements: |
| @@ -235,24 +258,31 @@ stats.forEach((s) => { | ||
| 235 | 258 | |
| 236 | 259 | ``` |
| 237 | 260 | |
| 238 | ## Prepared Statements | |
| 261 | ## Repeated Queries | |
| 239 | 262 | |
| 240 | Use prepared statements for repeated queries: | |
| 263 | `unsafe` is not a prepared-statement handle. It returns a query that runs when you | |
| 264 | await it and resolves to an array of rows, so there is no `.get()` to call per | |
| 265 | execution and nothing to finalize: | |
| 241 | 266 | |
| 242 | 267 | ```typescript |
| 268 | // Run it once per set of values, awaiting each. Postgres placeholders shown — | |
| 269 | // see "Placeholder style is the connection's, not the builder's" above. | |
| 270 | const user1 = await db.unsafe('SELECT * FROM users WHERE id = $1', [1]) | |
| 271 | const user2 = await db.unsafe('SELECT * FROM users WHERE id = $1', [2]) | |
| 243 | 272 | |
| 244 | // Prepare a statement | |
| 245 | const stmt = db.unsafe('SELECT * FROM users WHERE id = ?') | |
| 246 | ||
| 247 | // Execute multiple times efficiently | |
| 248 | const user1 = await stmt.get([1]) | |
| 249 | const user2 = await stmt.get([2]) | |
| 250 | const user3 = await stmt.get([3]) | |
| 273 | // Each resolves to an array of rows — index in, or use the builder's first(). | |
| 274 | console.log(user1[0]?.name) | |
| 275 | ``` | |
| 251 | 276 | |
| 252 | // Finalize when done | |
| 253 | stmt.finalize() | |
| 277 | Keep the SQL text identical across those calls and vary only the bindings. That is | |
| 278 | what lets the driver reuse its plan, and it is also what keeps the values | |
| 279 | parameterized rather than concatenated in. | |
| 254 | 280 | |
| 255 | ``` | |
| 281 | ::: tip | |
| 282 | The object `unsafe` returns before you await it is the driver's own, and its shape | |
| 283 | differs between `bun:sqlite` and `Bun.sql`. Await it and work with the rows; don't | |
| 284 | reach for methods on the unawaited value. | |
| 285 | ::: | |
| 256 | 286 | |
| 257 | 287 | ## Transaction with Raw Queries |
| 258 | 288 | |
| @@ -14,7 +14,7 @@ import { bunSql, getOrCreateBunSql, resetConnection } from './db' | ||
| 14 | 14 | import { resolvePivot } from './pivot' |
| 15 | 15 | import { singularizerFor } from './inflect' |
| 16 | 16 | import type { WhereTerm } from './sql-fragments' |
| 17 | import { FALSE_PREDICATE, mapPlaceholders, renderInPredicate, renderWhereTerms, scanTopLevelKeywords } from './sql-fragments' | |
| 17 | import { countPlaceholders, FALSE_PREDICATE, mapPlaceholders, renderInPredicate, renderWhereTerms, scanTopLevelKeywords } from './sql-fragments' | |
| 18 | 18 | |
| 19 | 19 | export { resetConnection } |
| 20 | 20 | |
| @@ -583,6 +583,55 @@ function renderRawFragment(fragment: unknown, context: string, extra: readonly u | ||
| 583 | 583 | ) |
| 584 | 584 | } |
| 585 | 585 | |
| 586 | /** | |
| 587 | * Refuse a raw query whose bindings the connection could never reach. | |
| 588 | * | |
| 589 | * Postgres has no `?` parameter. It parses the token as an operator, looks for a | |
| 590 | * right operand, and rejects the next word — so | |
| 591 | * `unsafe('SELECT * FROM users WHERE email = ? LIMIT 1', [email])` reports a | |
| 592 | * syntax error at `LIMIT`, 37 characters past the actual fault, while the bound | |
| 593 | * value in `params` was never going to arrive. The same call passes on SQLite and | |
| 594 | * MySQL, which both take `?`, so a cross-dialect suite goes red on one dialect | |
| 595 | * only and the first hour goes into `LIMIT`. stacksjs/bun-query-builder#1171 | |
| 596 | * | |
| 597 | * Keyed on the ABSENCE of `$n` rather than the presence of `?`, which is what | |
| 598 | * makes it free of false positives: | |
| 599 | * | |
| 600 | * - `?` is a real Postgres operator — `data ? 'key'` on jsonb, and several of | |
| 601 | * the geometric types — so a query carrying both `$1` and a `?` operator is | |
| 602 | * legitimate, and seeing `$n` is enough to leave it alone. | |
| 603 | * - A `?` inside a string literal (`WHERE name = 'what?'`) is not a placeholder | |
| 604 | * either, and such a query would not be passing bindings in the first place. | |
| 605 | * | |
| 606 | * Bindings with no `$n` anywhere, meanwhile, cannot be a working query on | |
| 607 | * Postgres whatever the text does: either a placeholder is written in the wrong | |
| 608 | * style, or nothing references the values. Both are worth naming here rather | |
| 609 | * than at a token the server picked. | |
| 610 | * | |
| 611 | * A call with no bindings is never inspected, so raw DDL keeps working untouched. | |
| 612 | */ | |
| 613 | function assertBindablePlaceholders(query: string, params: readonly unknown[] | undefined): void { | |
| 614 | if (!params || params.length === 0) return | |
| 615 | if (config.dialect !== 'postgres') return | |
| 616 | // Not scanned for quoting: a `$1` buried in a string literal only costs this | |
| 617 | // guard a catch, where a false positive would cost a working query. | |
| 618 | if (/\$\d/.test(query)) return | |
| 619 | ||
| 620 | // countPlaceholders skips `?` in quoted runs and comments, so this reports the | |
| 621 | // ones that were actually meant as placeholders. | |
| 622 | const marks = countPlaceholders(query) | |
| 623 | const bindings = `${params.length} binding${params.length === 1 ? '' : 's'}` | |
| 624 | const cause = marks > 0 | |
| 625 | ? `the query binds them with ${marks} \`?\` placeholder${marks === 1 ? '' : 's'}, which Postgres reads as an operator rather than a parameter` | |
| 626 | : 'the query contains no $n placeholder for them to bind to' | |
| 627 | throw new TypeError( | |
| 628 | `[query-builder] unsafe(): ${bindings} supplied, but ${cause}. Postgres numbers ` | |
| 629 | + `its parameters, so write $1, $2 … in the order the bindings should bind: ` | |
| 630 | + `unsafe('SELECT * FROM users WHERE email = $1 LIMIT 1', [email]). ` | |
| 631 | + `\`?\` is correct on SQLite and MySQL, which is why this call may work there.`, | |
| 632 | ) | |
| 633 | } | |
| 634 | ||
| 586 | 635 | // Module-scoped Set so we warn at most once per call site per process |
| 587 | 636 | // lifetime (previously this lived inside makeSelect, so it actually |
| 588 | 637 | // warned once per BUILDER — i.e. on every query). |
| @@ -8265,6 +8314,9 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial | ||
| 8265 | 8314 | return false |
| 8266 | 8315 | }, |
| 8267 | 8316 | unsafe(query: string, params?: any[]) { |
| 8317 | // Throws at the call site, before the connection sees a query whose | |
| 8318 | // bindings it cannot bind — see assertBindablePlaceholders. | |
| 8319 | assertBindablePlaceholders(query, params) | |
| 8268 | 8320 | // Use this builder's connection. Transaction callbacks receive a |
| 8269 | 8321 | // builder whose `_sql` is the reserved transaction connection; routing |
| 8270 | 8322 | // raw SQL through the process-wide pool lets it escape the transaction |
| @@ -0,0 +1,152 @@ | ||
| 1 | /** | |
| 2 | * `unsafe()` refuses `?`-style bindings on Postgres — stacksjs/bun-query-builder#1171. | |
| 3 | * | |
| 4 | * `unsafe('SELECT * FROM users WHERE email = ? LIMIT 1', [email])` typechecks, | |
| 5 | * passes on SQLite and MySQL, and on Postgres reached the server as a bare `?`. | |
| 6 | * Postgres parses that as an operator, hunts for a right operand and rejects the | |
| 7 | * next word, so the error named `LIMIT` while the fault was the `?` 37 characters | |
| 8 | * earlier — and the bound value was never going to arrive. Downstream that took | |
| 9 | * out every login, password recovery, magic link and two-factor exchange in | |
| 10 | * Stacks at once: 126 failures in one CI run, all from this one shape. | |
| 11 | * | |
| 12 | * The check is keyed on bindings arriving with no `$n` in the query, not on the | |
| 13 | * presence of `?`, so a jsonb `?` operator alongside `$1` is still allowed | |
| 14 | * through and raw DDL is never inspected. | |
| 15 | */ | |
| 16 | ||
| 17 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' | |
| 18 | import { buildDatabaseSchema, buildSchemaMeta, createQueryBuilder } from '../src' | |
| 19 | import { config } from '../src/config' | |
| 20 | ||
| 21 | function qb(): any { | |
| 22 | const models = { | |
| 23 | users: { | |
| 24 | columns: { | |
| 25 | id: { type: 'integer', isPrimaryKey: true }, | |
| 26 | email: { type: 'text' }, | |
| 27 | }, | |
| 28 | }, | |
| 29 | } as any | |
| 30 | return createQueryBuilder<any>({ | |
| 31 | schema: buildDatabaseSchema(models), | |
| 32 | meta: buildSchemaMeta(models), | |
| 33 | autoMigration: { enabled: false } as any, | |
| 34 | }) | |
| 35 | } | |
| 36 | ||
| 37 | const REFUSED = /\[query-builder\] unsafe\(\)/ | |
| 38 | ||
| 39 | describe('unsafe() placeholder style (#1171)', () => { | |
| 40 | let dialect: string | |
| 41 | beforeEach(() => { dialect = config.dialect }) | |
| 42 | afterEach(() => { config.dialect = dialect as any }) | |
| 43 | ||
| 44 | describe('Postgres refuses bindings it cannot bind', () => { | |
| 45 | beforeEach(() => { config.dialect = 'postgres' as any }) | |
| 46 | ||
| 47 | it('throws on the exact call from the issue', () => { | |
| 48 | expect(() => qb().unsafe('SELECT * FROM users WHERE email = ? LIMIT 1', ['a@b.c'])).toThrow(REFUSED) | |
| 49 | }) | |
| 50 | ||
| 51 | it('names the real problem rather than the token Postgres would pick', () => { | |
| 52 | let message = '' | |
| 53 | try { | |
| 54 | qb().unsafe('SELECT * FROM users WHERE email = ? LIMIT 1', ['a@b.c']) | |
| 55 | } | |
| 56 | catch (error) { | |
| 57 | message = (error as Error).message | |
| 58 | } | |
| 59 | // The placeholder style the connection expects, and the one that was used. | |
| 60 | expect(message).toContain('$1') | |
| 61 | expect(message).toContain('1 binding supplied') | |
| 62 | // It blames the placeholder, where the server's own error blamed `LIMIT`. | |
| 63 | expect(message).toContain('`?` placeholder') | |
| 64 | expect(message).not.toContain('syntax error') | |
| 65 | // And it says why the same call passes elsewhere, which is the part that | |
| 66 | // makes a one-dialect-only CI failure legible. | |
| 67 | expect(message).toMatch(/SQLite and MySQL/) | |
| 68 | }) | |
| 69 | ||
| 70 | it('throws a TypeError, synchronously, so the stack names the caller', () => { | |
| 71 | // Not a rejected promise: the point is to fail where the query was written. | |
| 72 | expect(() => qb().unsafe('SELECT ?', [1])).toThrow(TypeError) | |
| 73 | }) | |
| 74 | ||
| 75 | it('counts the placeholders and pluralises', () => { | |
| 76 | let message = '' | |
| 77 | try { | |
| 78 | qb().unsafe('SELECT * FROM users WHERE email = ? OR id = ?', ['a@b.c', 1]) | |
| 79 | } | |
| 80 | catch (error) { | |
| 81 | message = (error as Error).message | |
| 82 | } | |
| 83 | expect(message).toContain('2 bindings supplied') | |
| 84 | expect(message).toContain('2 `?` placeholders') | |
| 85 | }) | |
| 86 | ||
| 87 | it('refuses bindings that nothing in the query references', () => { | |
| 88 | // No `?` either — the values still cannot arrive, and Postgres would have | |
| 89 | // said "bind message supplies 1 parameters, but prepared statement requires 0". | |
| 90 | expect(() => qb().unsafe('SELECT 1', [1])).toThrow(/no \$n placeholder/) | |
| 91 | }) | |
| 92 | }) | |
| 93 | ||
| 94 | describe('Postgres leaves correct and unrelated queries alone', () => { | |
| 95 | beforeEach(() => { config.dialect = 'postgres' as any }) | |
| 96 | ||
| 97 | it('accepts $n, in any order', () => { | |
| 98 | // The shape db.d.ts documents. A throw here is the false positive that | |
| 99 | // would matter, so this asserts nothing is thrown at all. | |
| 100 | expect(refusal(() => qb().unsafe('SELECT $2 AS a, $1 AS b', ['ONE', 'TWO']))).toBeNull() | |
| 101 | }) | |
| 102 | ||
| 103 | it('accepts a jsonb ? operator when $n is also present', () => { | |
| 104 | // `data ? 'key'` is a real Postgres operator, not a placeholder. Keying the | |
| 105 | // check on `?` instead of on the absence of $n would reject this. | |
| 106 | expect(refusal(() => qb().unsafe('SELECT * FROM t WHERE data ? \'key\' AND id = $1', [7]))).toBeNull() | |
| 107 | }) | |
| 108 | ||
| 109 | it('never inspects a query with no bindings', () => { | |
| 110 | // Raw DDL, and the `?` in a literal that is data rather than a placeholder. | |
| 111 | expect(refusal(() => qb().unsafe('CREATE TABLE t (id int)'))).toBeNull() | |
| 112 | expect(refusal(() => qb().unsafe('SELECT * FROM users WHERE name = \'what?\''))).toBeNull() | |
| 113 | expect(refusal(() => qb().unsafe('SELECT * FROM users WHERE email = ?', []))).toBeNull() | |
| 114 | }) | |
| 115 | }) | |
| 116 | ||
| 117 | describe('other dialects are unaffected', () => { | |
| 118 | it('SQLite and MySQL still take ?', () => { | |
| 119 | for (const d of ['sqlite', 'mysql'] as const) { | |
| 120 | config.dialect = d as any | |
| 121 | expect(refusal(() => qb().unsafe('SELECT * FROM users WHERE email = ? LIMIT 1', ['a@b.c']))).toBeNull() | |
| 122 | } | |
| 123 | }) | |
| 124 | }) | |
| 125 | }) | |
| 126 | ||
| 127 | /** | |
| 128 | * The message `fn` threw, or null if it returned. | |
| 129 | * | |
| 130 | * `unsafe` returns a LAZY query: it reaches a database when something awaits it, | |
| 131 | * and not before. So these cases call it and discard the result, which exercises | |
| 132 | * the guard while executing nothing. | |
| 133 | * | |
| 134 | * That is load-bearing, not tidiness. An earlier version of this helper did | |
| 135 | * `void Promise.resolve(out).catch(…)` to ignore driver errors — but | |
| 136 | * `Promise.resolve` calls `.then` on the thenable, so every "leaves it alone" | |
| 137 | * case below really ran, `CREATE TABLE t (id int)` included, against whatever | |
| 138 | * database the environment pointed at. In CI that is the shared `test_db`, and | |
| 139 | * the unawaited queries outlived the file and timed out the next one's | |
| 140 | * `beforeAll` (`closeConnection` → `resetDatabase` → `generateMigration`, on a | |
| 141 | * 5s hook budget). Returning the message instead of swallowing it also means an | |
| 142 | * unexpected throw fails loudly rather than passing as "not refused". | |
| 143 | */ | |
| 144 | function refusal(fn: () => unknown): string | null { | |
| 145 | try { | |
| 146 | fn() | |
| 147 | return null | |
| 148 | } | |
| 149 | catch (error) { | |
| 150 | return (error as Error).message | |
| 151 | } | |
| 152 | } | |