also looking at this
fix(client): place clauses by statement structure, not by text search
#1129
3 files
+392
-72
| @@ -188,3 +188,151 @@ function closingQuote(sql: string, start: number, quote: string): number { | ||
| 188 | 188 | |
| 189 | 189 | return sql.length |
| 190 | 190 | } |
| 191 | ||
| 192 | /** | |
| 193 | * A top-level keyword found by {@link scanTopLevelKeywords}. | |
| 194 | */ | |
| 195 | export 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 | */ | |
| 245 | export 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 | } | |