also looking at this
fix(postgres): skip literals and comments when numbering placeholders
#1151
7 files
+224
-45
| @@ -239,6 +239,11 @@ const tagged = await Post.query() | ||
| 239 | 239 | .get() |
| 240 | 240 | ``` |
| 241 | 241 | |
| 242 | A `?` inside a string literal, quoted identifier or comment is not a | |
| 243 | placeholder. Postgres' jsonb `?`, `?|` and `?&` operators can't be told apart | |
| 244 | from placeholders, so use their function forms, `jsonb_exists(tags, ?)`, | |
| 245 | `jsonb_exists_any(tags, ?)` and `jsonb_exists_all(tags, ?)`. | |
| 246 | ||
| 242 | 247 | ## Date Conditions |
| 243 | 248 | |
| 244 | 249 | ```typescript |
| @@ -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, renderInPredicate, renderWhereTerms, scanTopLevelKeywords } from './sql-fragments' | |
| 17 | import { FALSE_PREDICATE, mapPlaceholders, renderInPredicate, renderWhereTerms, scanTopLevelKeywords } from './sql-fragments' | |
| 18 | 18 | |
| 19 | 19 | export { resetConnection } |
| 20 | 20 | |
| @@ -81,12 +81,11 @@ function renderBoundSqlExpression(expression: BoundSqlExpression, startIndex: nu | ||
| 81 | 81 | if (config.dialect !== 'postgres' || parameters.length === 0) |
| 82 | 82 | return { text: expression.sql, parameters } |
| 83 | 83 | |
| 84 | // Skips literals and comments: a `?` in `'why?'` is data, and numbering it | |
| 85 | // shifted every later placeholder onto the wrong parameter. | |
| 84 | 86 | let parameterIndex = 0 |
| 85 | const text = expression.sql.replace(/\?/g, (placeholder) => { | |
| 86 | if (parameterIndex >= parameters.length) | |
| 87 | return placeholder | |
| 88 | return getPlaceholder(startIndex + parameterIndex++) | |
| 89 | }) | |
| 87 | const { text } = mapPlaceholders(expression.sql, () => | |
| 88 | parameterIndex < parameters.length ? getPlaceholder(startIndex + parameterIndex++) : undefined) | |
| 90 | 89 | |
| 91 | 90 | if (parameterIndex !== parameters.length) { |
| 92 | 91 | throw new TypeError( |
| @@ -38,7 +38,7 @@ import { createFakerCompatLayer } from './faker-compat' | ||
| 38 | 38 | import { normalizeRelationList } from './relation-utils' |
| 39 | 39 | import { singularizerFor, toTableName as sharedToTableName } from './inflect' |
| 40 | 40 | import type { WhereTerm } from './sql-fragments' |
| 41 | import { renderInPredicate, renderWhereTerms } from './sql-fragments' | |
| 41 | import { countPlaceholders, renderInPredicate, renderWhereTerms, toDialectPlaceholders } from './sql-fragments' | |
| 42 | 42 | |
| 43 | 43 | /** |
| 44 | 44 | * Current timestamp formatted for the active dialect. |
| @@ -136,7 +136,7 @@ function resolveRawBindings(fragment: string, params: unknown[]): unknown[] { | ||
| 136 | 136 | const only = params[0] |
| 137 | 137 | if (Array.isArray(only)) |
| 138 | 138 | return [...only] |
| 139 | if (only === undefined && !fragment.includes('?')) | |
| 139 | if (only === undefined && countPlaceholders(fragment) === 0) | |
| 140 | 140 | return [] |
| 141 | 141 | return params |
| 142 | 142 | } |
| @@ -630,9 +630,13 @@ interface OrmExecutor { | ||
| 630 | 630 | } |
| 631 | 631 | |
| 632 | 632 | /** |
| 633 | * Rewrite `?` placeholders to Postgres `$1, $2, …` form. The ORM only ever | |
| 634 | * emits `?` as a bound-parameter marker (values are always parameterised and | |
| 635 | * identifiers are validated), so a sequential left-to-right pass is safe. | |
| 633 | * Rewrite `?` placeholders to Postgres `$1, $2, …` form. | |
| 634 | * | |
| 635 | * This was a bare `sql.replace(/\?/g, …)`, on the reasoning that the ORM only | |
| 636 | * emits `?` as a parameter marker. But `whereRaw` fragments are caller text, so | |
| 637 | * `whereRaw("name = '?'")` went out as `name = '$1'` and matched nothing, and a | |
| 638 | * `?` in a comment took a binding meant for a later placeholder. It now skips | |
| 639 | * literals, quoted identifiers and comments like `toDialectPlaceholders`. | |
| 636 | 640 | * |
| 637 | 641 | * Memoized: query TEXT repeats heavily (values are placeholders), and this |
| 638 | 642 | * regex pass runs on every Postgres query the ORM executes. Bounded; cleared |
| @@ -644,8 +648,7 @@ const PG_PLACEHOLDER_CACHE_MAX = 500 | ||
| 644 | 648 | function toPostgresPlaceholders(sql: string): string { |
| 645 | 649 | const hit = pgPlaceholderCache.get(sql) |
| 646 | 650 | if (hit !== undefined) return hit |
| 647 | let i = 0 | |
| 648 | const out = sql.replace(/\?/g, () => `$${++i}`) | |
| 651 | const out = toDialectPlaceholders(sql, 'postgres') | |
| 649 | 652 | if (pgPlaceholderCache.size >= PG_PLACEHOLDER_CACHE_MAX) |
| 650 | 653 | pgPlaceholderCache.clear() |
| 651 | 654 | pgPlaceholderCache.set(sql, out) |
| @@ -109,10 +109,10 @@ export function renderInPredicate(column: string, values: readonly unknown[], ne | ||
| 109 | 109 | * application that developed against SQLite therefore finds out at the point |
| 110 | 110 | * its raw queries first meet Postgres, and the error does not name the cause. |
| 111 | 111 | * |
| 112 | * Only unquoted placeholders are rewritten. A `?` inside a string literal, a | |
| 113 | * quoted identifier, or a dollar-quoted block is data and is left exactly as | |
| 114 | * written — the naive global replace is the reason this needs to be a function | |
| 115 | * with tests rather than one line at a call site. | |
| 112 | * Only unquoted placeholders are rewritten; see {@link mapPlaceholders} for | |
| 113 | * what counts as quoted. Postgres' jsonb `?`, `?|` and `?&` operators cannot be | |
| 114 | * told apart from placeholders, so write them as `jsonb_exists(col, key)`, | |
| 115 | * `jsonb_exists_any(col, keys)` and `jsonb_exists_all(col, keys)`. | |
| 116 | 116 | * |
| 117 | 117 | * Dialects that already take `?` get the string back unchanged, so this is safe |
| 118 | 118 | * to apply unconditionally. |
| @@ -120,54 +120,88 @@ export function renderInPredicate(column: string, values: readonly unknown[], ne | ||
| 120 | 120 | export function toDialectPlaceholders(sql: string, dialect: SupportedDialect): string { |
| 121 | 121 | if (dialect !== 'postgres') |
| 122 | 122 | return sql |
| 123 | return mapPlaceholders(sql, ordinal => `$${ordinal}`).text | |
| 124 | } | |
| 123 | 125 | |
| 126 | /** | |
| 127 | * How many `?` placeholders `sql` binds, by the same rules as | |
| 128 | * {@link toDialectPlaceholders}. | |
| 129 | */ | |
| 130 | export function countPlaceholders(sql: string): number { | |
| 131 | return mapPlaceholders(sql, () => undefined).count | |
| 132 | } | |
| 133 | ||
| 134 | /** | |
| 135 | * Walk `sql` and hand each unquoted `?` to `replace`, with its 1-based ordinal. | |
| 136 | * A string it returns is spliced in; `undefined` keeps the `?`. | |
| 137 | * | |
| 138 | * Quoted, and so left alone, is everything that is data or prose rather than a | |
| 139 | * placeholder: | |
| 140 | * | |
| 141 | * - `'…'`, `"…"` and `` `…` `` runs, where doubling escapes the delimiter | |
| 142 | * - `$$…$$` / `$tag$…$tag$` dollar-quoted strings | |
| 143 | * - `-- …` line comments and `/* … *\/` block comments, which nest | |
| 144 | * | |
| 145 | * The ORM used a bare `sql.replace(/\?/g, …)`, so on Postgres | |
| 146 | * `whereRaw("name = '?'")` was sent as `name = '$1'` and quietly matched nothing, | |
| 147 | * and a `?` in a comment consumed a binding. The comment rules are the ones | |
| 148 | * {@link scanTopLevelKeywords} uses; backslash escapes are not honoured, for the | |
| 149 | * reason given there. | |
| 150 | */ | |
| 151 | export function mapPlaceholders( | |
| 152 | sql: string, | |
| 153 | replace: (ordinal: number) => string | undefined, | |
| 154 | ): { text: string, count: number } { | |
| 124 | 155 | let out = '' |
| 125 | let index = 0 | |
| 156 | let count = 0 | |
| 157 | let copied = 0 | |
| 126 | 158 | let position = 0 |
| 127 | 159 | |
| 128 | 160 | while (position < sql.length) { |
| 129 | 161 | const char = sql[position]! |
| 162 | let end = -1 | |
| 130 | 163 | |
| 131 | // Single-quoted string literal, with '' as the escape. | |
| 132 | if (char === '\'') { | |
| 133 | const end = closingQuote(sql, position, '\'') | |
| 134 | out += sql.slice(position, end) | |
| 135 | position = end | |
| 136 | continue | |
| 164 | if (char === '\'' || char === '"' || char === '`') { | |
| 165 | end = closingQuote(sql, position, char) | |
| 137 | 166 | } |
| 138 | ||
| 139 | // Double-quoted identifier, with "" as the escape. | |
| 140 | if (char === '"') { | |
| 141 | const end = closingQuote(sql, position, '"') | |
| 142 | out += sql.slice(position, end) | |
| 143 | position = end | |
| 144 | continue | |
| 167 | else if (char === '-' && sql[position + 1] === '-') { | |
| 168 | const newline = sql.indexOf('\n', position + 2) | |
| 169 | end = newline === -1 ? sql.length : newline | |
| 145 | 170 | } |
| 146 | ||
| 147 | // Dollar-quoted block: $tag$ … $tag$, where the tag may be empty. | |
| 148 | if (char === '$') { | |
| 171 | else if (char === '/' && sql[position + 1] === '*') { | |
| 172 | let nesting = 1 | |
| 173 | end = position + 2 | |
| 174 | while (end < sql.length && nesting > 0) { | |
| 175 | if (sql[end] === '/' && sql[end + 1] === '*') { nesting++; end += 2 } | |
| 176 | else if (sql[end] === '*' && sql[end + 1] === '/') { nesting--; end += 2 } | |
| 177 | else end++ | |
| 178 | } | |
| 179 | } | |
| 180 | else if (char === '$') { | |
| 149 | 181 | const tag = sql.slice(position).match(/^\$[A-Z_a-z]\w*\$|^\$\$/)?.[0] |
| 150 | 182 | if (tag) { |
| 151 | 183 | const close = sql.indexOf(tag, position + tag.length) |
| 152 | const end = close === -1 ? sql.length : close + tag.length | |
| 153 | out += sql.slice(position, end) | |
| 154 | position = end | |
| 155 | continue | |
| 184 | end = close === -1 ? sql.length : close + tag.length | |
| 156 | 185 | } |
| 157 | 186 | } |
| 158 | 187 | |
| 159 | if (char === '?') { | |
| 160 | index++ | |
| 161 | out += `$${index}` | |
| 162 | position++ | |
| 188 | if (end !== -1) { | |
| 189 | position = end | |
| 163 | 190 | continue |
| 164 | 191 | } |
| 165 | 192 | |
| 166 | out += char | |
| 193 | if (char === '?') { | |
| 194 | count++ | |
| 195 | const replacement = replace(count) | |
| 196 | if (replacement !== undefined) { | |
| 197 | out += sql.slice(copied, position) + replacement | |
| 198 | copied = position + 1 | |
| 199 | } | |
| 200 | } | |
| 167 | 201 | position++ |
| 168 | 202 | } |
| 169 | 203 | |
| 170 | return out | |
| 204 | return { text: copied === 0 ? sql : out + sql.slice(copied), count } | |
| 171 | 205 | } |
| 172 | 206 | |
| 173 | 207 | /** Index just past the closing quote of the literal starting at `start`. */ |
| @@ -0,0 +1,97 @@ | ||
| 1 | /** | |
| 2 | * `?` inside literals and comments on Postgres. | |
| 3 | * | |
| 4 | * The ORM rewrote `?` to `$n` with a bare `sql.replace(/\?/g, …)`. `whereRaw` | |
| 5 | * fragments are caller text, so on live Postgres: | |
| 6 | * | |
| 7 | * - `whereRaw("label = '?'")` went out as `label = '$1'` and matched nothing; | |
| 8 | * - a `?` in a comment took a number, pushing the real placeholders past the | |
| 9 | * bindings: `could not determine data type of parameter $2`. | |
| 10 | * | |
| 11 | * The select/update builders' `{ sql, parameters }` expressions numbered the | |
| 12 | * first N `?` the same way, so a literal `'?'` before a placeholder stole its | |
| 13 | * number and left the placeholder unbound. | |
| 14 | * | |
| 15 | * Runs in a subprocess: an earlier configureOrm() in the same process pins the | |
| 16 | * model layer to sqlite and would mask the network-driver path. | |
| 17 | */ | |
| 18 | ||
| 19 | import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' | |
| 20 | import { tmpdir } from 'node:os' | |
| 21 | import { join, resolve } from 'node:path' | |
| 22 | import process from 'node:process' | |
| 23 | import { describe, expect, it } from 'bun:test' | |
| 24 | import { PG_URL, probePostgres } from './pg' | |
| 25 | ||
| 26 | const pgAvailable = await probePostgres() | |
| 27 | ||
| 28 | describe.skipIf(!pgAvailable)('? inside literals and comments on live Postgres', () => { | |
| 29 | it('binds only the placeholders, in the ORM and the builders', () => { | |
| 30 | const srcEntry = resolve(import.meta.dir, '../src/index.ts') | |
| 31 | const dir = mkdtempSync(join(tmpdir(), 'qb-pgph-')) | |
| 32 | const scriptPath = join(dir, 'probe.ts') | |
| 33 | ||
| 34 | writeFileSync(scriptPath, ` | |
| 35 | import { SQL } from 'bun' | |
| 36 | import { setConfig, resetConnection, defineModel, clearModelRegistry, createQueryBuilder, buildDatabaseSchema, buildSchemaMeta } from ${JSON.stringify(srcEntry)} | |
| 37 | ||
| 38 | const URL = ${JSON.stringify(PG_URL)} | |
| 39 | const failures = [] | |
| 40 | const check = (label, got, want) => { | |
| 41 | const g = JSON.stringify(got), w = JSON.stringify(want) | |
| 42 | if (g !== w) failures.push(label + ': got ' + g + ', want ' + w) | |
| 43 | } | |
| 44 | const attempt = async (label, fn) => { | |
| 45 | try { return await fn() } | |
| 46 | catch (e) { failures.push(label + ': threw ' + (e && e.message)); return undefined } | |
| 47 | } | |
| 48 | ||
| 49 | const raw = new SQL(URL) | |
| 50 | await raw.unsafe('DROP TABLE IF EXISTS _qb_pgph') | |
| 51 | await raw.unsafe('CREATE TABLE _qb_pgph (id int primary key, label text not null, tags jsonb not null)') | |
| 52 | await raw.unsafe(\`INSERT INTO _qb_pgph VALUES (1, '?', '["bun"]'), (2, 'b', '["node"]'), (3, 'c', '[]')\`) | |
| 53 | ||
| 54 | setConfig({ dialect: 'postgres', database: { url: URL } }) | |
| 55 | resetConnection() | |
| 56 | clearModelRegistry() | |
| 57 | const Row = defineModel({ name: 'Pgph', table: '_qb_pgph', primaryKey: 'id', | |
| 58 | attributes: { label: { type: 'string', fillable: true }, tags: { type: 'json', fillable: true } } }) | |
| 59 | const ids = rows => (rows ?? []).map(r => Number(r.id ?? r.get('id'))).sort((a, b) => a - b) | |
| 60 | ||
| 61 | // ORM whereRaw. Matched nothing / threw 42P18 before. | |
| 62 | check('literal ?', ids(await attempt('literal ?', () => Row.query().whereRaw("label = '?'").get())), [1]) | |
| 63 | check('comment ?', ids(await attempt('comment ?', () => | |
| 64 | Row.query().whereRaw('id = ? /* or ? */ OR label = ?', 1, 'b').get())), [1, 2]) | |
| 65 | check('literal ? then where', ids(await attempt('literal ? then where', () => | |
| 66 | Row.query().whereRaw("label <> '?'").where('id', 2).get())), [2]) | |
| 67 | check('jsonb_exists', ids(await attempt('jsonb_exists', () => | |
| 68 | Row.query().whereRaw('jsonb_exists(tags, ?)', 'bun').get())), [1]) | |
| 69 | ||
| 70 | // Builder { sql, parameters } expressions. | |
| 71 | const models = { _qb_pgph: { columns: { id: { type: 'integer', isPrimaryKey: true }, label: { type: 'text' }, tags: { type: 'json' } } } } | |
| 72 | const db = createQueryBuilder({ schema: buildDatabaseSchema(models), meta: buildSchemaMeta(models), autoMigration: { enabled: false } }) | |
| 73 | await attempt('update with bound expression', () => | |
| 74 | db.updateTable('_qb_pgph').set({ label: 'z' }).where({ sql: "label <> '?' AND id = ?", parameters: [3] }).execute()) | |
| 75 | check('update landed', (await raw.unsafe('SELECT id, label FROM _qb_pgph ORDER BY id')).map(r => r.label), ['?', 'b', 'z']) | |
| 76 | ||
| 77 | await raw.unsafe('DROP TABLE IF EXISTS _qb_pgph') | |
| 78 | await raw.end() | |
| 79 | ||
| 80 | if (failures.length) { | |
| 81 | console.error('FAILURES:\\n' + failures.join('\\n')) | |
| 82 | process.exit(1) | |
| 83 | } | |
| 84 | console.log('OK') | |
| 85 | process.exit(0) | |
| 86 | `) | |
| 87 | ||
| 88 | const proc = Bun.spawnSync({ cmd: ['bun', scriptPath], stdout: 'pipe', stderr: 'pipe', cwd: process.cwd(), env: { ...process.env } }) | |
| 89 | const dec = new TextDecoder() | |
| 90 | const out = dec.decode(proc.stdout).trim() | |
| 91 | const err = dec.decode(proc.stderr).trim() | |
| 92 | rmSync(dir, { recursive: true, force: true }) | |
| 93 | ||
| 94 | expect(proc.exitCode, `pg placeholder probe failed.\nstdout: ${out}\nstderr: ${err}`).toBe(0) | |
| 95 | expect(out).toContain('OK') | |
| 96 | }, 30_000) | |
| 97 | }) | |
| @@ -221,6 +221,12 @@ describe('whereRaw forwarded an undefined bindings argument (#1146)', () => { | ||
| 221 | 221 | expect(User.query().where('id', 1).orWhereRaw('id = 2', undefined).toSql().params).toEqual([1]) |
| 222 | 222 | }) |
| 223 | 223 | |
| 224 | it('ignores a question mark inside a literal or comment when deciding', async () => { | |
| 225 | const q = forward("name <> '?' /* why? */").where('country', 'UK') | |
| 226 | expect(q.toSql().params).toEqual(['UK']) | |
| 227 | expect(ids(await q.get())).toEqual([2]) | |
| 228 | }) | |
| 229 | ||
| 224 | 230 | it('stays a NULL binding when the fragment has one', async () => { |
| 225 | 231 | const q = User.query().whereRaw('? IS NULL', undefined).where('country', 'UK') |
| 226 | 232 | expect(q.toSql().params).toEqual([undefined, 'UK']) |
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | * the simple cases and corrupts a query the day somebody stores a question mark. |
| 11 | 11 | */ |
| 12 | 12 | import { describe, expect, it } from 'bun:test' |
| 13 | import { toDialectPlaceholders } from '../src/sql-fragments' | |
| 13 | import { countPlaceholders, mapPlaceholders, toDialectPlaceholders } from '../src/sql-fragments' | |
| 14 | 14 | |
| 15 | 15 | describe('toDialectPlaceholders', () => { |
| 16 | 16 | it('numbers placeholders in order for postgres', () => { |
| @@ -81,4 +81,39 @@ describe('toDialectPlaceholders', () => { | ||
| 81 | 81 | it('returns an empty string unchanged', () => { |
| 82 | 82 | expect(toDialectPlaceholders('', 'postgres')).toBe('') |
| 83 | 83 | }) |
| 84 | ||
| 85 | it('leaves a question mark in a line comment alone', () => { | |
| 86 | expect(toDialectPlaceholders('SELECT ? -- why?\n, ?', 'postgres')) | |
| 87 | .toBe('SELECT $1 -- why?\n, $2') | |
| 88 | }) | |
| 89 | ||
| 90 | it('leaves a question mark in a block comment alone, including nested ones', () => { | |
| 91 | expect(toDialectPlaceholders('SELECT /* a? /* b? */ c? */ ?', 'postgres')) | |
| 92 | .toBe('SELECT /* a? /* b? */ c? */ $1') | |
| 93 | }) | |
| 94 | ||
| 95 | it('leaves a backtick-quoted run alone', () => { | |
| 96 | expect(toDialectPlaceholders('SELECT `a?b`, ?', 'postgres')).toBe('SELECT `a?b`, $1') | |
| 97 | }) | |
| 98 | ||
| 99 | it('survives an unterminated comment', () => { | |
| 100 | expect(toDialectPlaceholders('SELECT ? /* open ?', 'postgres')).toBe('SELECT $1 /* open ?') | |
| 101 | }) | |
| 102 | }) | |
| 103 | ||
| 104 | describe('countPlaceholders', () => { | |
| 105 | it('counts only unquoted placeholders', () => { | |
| 106 | expect(countPlaceholders('a = ? AND b = ?')).toBe(2) | |
| 107 | expect(countPlaceholders("label = '?' /* ? */ -- ?")).toBe(0) | |
| 108 | expect(countPlaceholders('$$ ? $$ "?" ?')).toBe(1) | |
| 109 | expect(countPlaceholders('')).toBe(0) | |
| 110 | }) | |
| 111 | }) | |
| 112 | ||
| 113 | describe('mapPlaceholders', () => { | |
| 114 | it('replaces only the placeholders the callback answers for', () => { | |
| 115 | const { text, count } = mapPlaceholders("'?' ? ? ?", ordinal => ordinal === 2 ? '$9' : undefined) | |
| 116 | expect(text).toBe("'?' ? $9 ?") | |
| 117 | expect(count).toBe(3) | |
| 118 | }) | |
| 84 | 119 | }) |