ReviewOS

also looking at this

stacks/bun-query-builder

fix(client): place clauses by statement structure, not by text search

#1129
Merged chris wants to merge fix/1121-shared-sql-scanner into main
3 files +392 -72
packages/bun-query-builder/src/sql-fragments.tsmodified+148-0
Changes to packages/bun-query-builder/src/sql-fragments.ts
@@ -188,3 +188,151 @@ function closingQuote(sql: string, start: number, quote: string): number {
188188
189189 return sql.length
190190}
191
192/**
193 * A top-level keyword found by {@link scanTopLevelKeywords}.
194 */
195export interface TopLevelKeyword {
196 /** The caller's label for the pattern that matched. */
197 key: string
198 /** Index in the source SQL where the keyword starts. */
199 start: number
200 /** Length of the matched text. */
201 length: number
202}
203
204/**
205 * Find clause keywords in `sql` that are genuinely clause keywords.
206 *
207 * Three separate scanners used to answer this question, each with its own idea
208 * of what SQL is, and a raw fragment is arbitrary caller text:
209 *
210 * firstTailIndex paren-depth only
211 * insertJoin paren-depth only
212 * computeReorderedClauses paren-depth + string literals
213 *
214 * So `selectRaw(raw("'a limit 3 b' as t"))` put the word `limit` where the
215 * first two would read it as a LIMIT clause, and the WHERE was spliced into
216 * the middle of a string literal — leaving a statement that is still valid,
217 * has no WHERE, and returns every row. The JOIN splice had the same hole, and
218 * the third scanner missed comments. See stacksjs/bun-query-builder#1121.
219 *
220 * This skips everything that is not executable statement structure:
221 *
222 * - parenthesised sub-expressions, so a subquery's own ORDER BY is not ours
223 * - `'…'`, `"…"` and `` `…` `` runs, where doubling escapes the delimiter
224 * - `-- …` line comments and `/* … *\/` block comments, which nest
225 * - `$$…$$` / `$tag$…$tag$` dollar-quoted strings, without mistaking a `$1`
226 * placeholder for the start of one
227 *
228 * Backslash escapes inside a literal are deliberately NOT honoured. Whether
229 * `'a\'` ends the string depends on the dialect and, on Postgres, on a server
230 * setting — so this matches the standard (and what the previous literal-aware
231 * scanner did) rather than inventing a rule that would be wrong somewhere.
232 *
233 * A keyword must be preceded by a non-word character, and never matches at
234 * index 0: a statement that opens with a clause keyword has nothing before it
235 * for the clause to attach to.
236 *
237 * @param sql the statement to scan
238 * @param keywords patterns to look for. Each MUST carry the sticky (`y`) flag;
239 * they are matched at a position rather than against a slice, and are tried
240 * in the order given — so longer keywords must come before any single-word
241 * prefix of them (`ORDER BY` before a hypothetical `ORDER`).
242 * @param limit stop after this many hits. Callers that only want the first
243 * clause boundary pass 1 rather than scanning the rest of the statement.
244 */
245export function scanTopLevelKeywords(
246 sql: string,
247 keywords: ReadonlyArray<{ key: string, pattern: RegExp }>,
248 limit = Number.POSITIVE_INFINITY,
249): TopLevelKeyword[] {
250 const hits: TopLevelKeyword[] = []
251 let depth = 0
252
253 // charCode comparisons rather than single-character strings and regexes:
254 // this walks every character of every statement the builder emits, and
255 // `sql[i]` allocates. See the note on the lead-letter check below.
256 const isWordCode = (c: number): boolean =>
257 (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 95
258
259 for (let i = 0; i < sql.length; i++) {
260 const code = sql.charCodeAt(i)
261
262 // -- line comment: everything to the newline is prose.
263 if (code === 45 && sql.charCodeAt(i + 1) === 45) {
264 const newline = sql.indexOf('\n', i + 2)
265 if (newline === -1)
266 return hits
267 i = newline
268 continue
269 }
270
271 // /* block comment */ — Postgres nests these, so a naive indexOf('*\/')
272 // would stop at the inner terminator and resume scanning inside prose.
273 if (code === 47 && sql.charCodeAt(i + 1) === 42) {
274 let nesting = 1
275 let j = i + 2
276 while (j < sql.length && nesting > 0) {
277 if (sql[j] === '/' && sql[j + 1] === '*') { nesting++; j += 2 }
278 else if (sql[j] === '*' && sql[j + 1] === '/') { nesting--; j += 2 }
279 else j++
280 }
281 i = j - 1
282 continue
283 }
284
285 // Quoted run. A doubled delimiter is an escaped one and stays inside.
286 if (code === 39 || code === 34 || code === 96) {
287 let j = i + 1
288 while (j < sql.length) {
289 if (sql.charCodeAt(j) === code) {
290 // A doubled delimiter is an escaped one and stays inside.
291 if (sql.charCodeAt(j + 1) === code) { j += 2; continue }
292 break
293 }
294 j++
295 }
296 i = j
297 continue
298 }
299
300 // Dollar-quoted string. The tag is empty or an identifier, which is what
301 // separates `$$…$$` from the `$1` placeholders Postgres binds with.
302 if (code === 36) {
303 const opening = /^\$(?:[A-Z_][A-Z0-9_]*)?\$/i.exec(sql.slice(i))
304 if (opening) {
305 const closing = sql.indexOf(opening[0], i + opening[0].length)
306 i = closing === -1 ? sql.length : closing + opening[0].length - 1
307 continue
308 }
309 }
310
311 if (code === 40) { depth++; continue }
312 if (code === 41) { depth = Math.max(0, depth - 1); continue }
313 if (depth !== 0) continue
314 if (i === 0) continue
315
316 // Every clause keyword starts with a letter, so this rejects the large
317 // majority of positions before any regex runs. The scan replaced three
318 // separate ones, two of which jumped between matches of a single
319 // alternation regex — without this it walks characters they skipped.
320 const upper = code & ~32
321 if (upper < 65 || upper > 90) continue
322 if (isWordCode(sql.charCodeAt(i - 1))) continue
323
324 for (const { key, pattern } of keywords) {
325 pattern.lastIndex = i
326 const match = pattern.exec(sql)
327 if (!match) continue
328 hits.push({ key, start: i, length: match[0].length })
329 if (hits.length >= limit)
330 return hits
331 // Advance past it, so a multi-word keyword is not re-detected mid-match.
332 i += match[0].length - 1
333 break
334 }
335 }
336
337 return hits
338}