@profullstack/libsql-pg 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/rewrite.js ADDED
@@ -0,0 +1,549 @@
1
+ import { ftsError } from './errors.js';
2
+ import {
3
+ codeMask,
4
+ findCall,
5
+ literalValue,
6
+ matchParen,
7
+ quoteIdent,
8
+ replaceCalls,
9
+ replaceCode,
10
+ splitTopLevel,
11
+ unquote,
12
+ } from './sqlparse.js';
13
+
14
+ /**
15
+ * SQLite-flavoured SQL rewritten for Postgres, one statement at a time.
16
+ *
17
+ * The rules are the idioms that turned up across the apps being ported: they
18
+ * are textual, deliberately conservative (a call the rule does not recognise
19
+ * is left as it was) and each is covered by a test that needs no database.
20
+ * What is not rewritten is listed in the README's dialect table.
21
+ *
22
+ * @typedef {{ pk: string[], unique: string[][], columns: string[] }} TableKeys
23
+ * @typedef {(table: string) => TableKeys | undefined} KeysLookup
24
+ * @typedef {{ sql: string, noop: boolean, warnings: string[], kind: 'pragma' | 'ddl' | 'dml' | 'comment' }} Rewritten
25
+ */
26
+
27
+ /** Function names SQLite has and Postgres does not, that nothing here rewrites. */
28
+ const UNSUPPORTED_FUNCTIONS = [
29
+ 'printf',
30
+ 'format',
31
+ 'typeof',
32
+ 'last_insert_rowid',
33
+ 'changes',
34
+ 'total_changes',
35
+ 'sqlite_version',
36
+ 'sqlite_source_id',
37
+ 'zeroblob',
38
+ 'quote',
39
+ 'char',
40
+ 'unicode',
41
+ 'likelihood',
42
+ 'likely',
43
+ 'unlikely',
44
+ 'load_extension',
45
+ 'json_each',
46
+ 'json_tree',
47
+ 'json_set',
48
+ 'json_insert',
49
+ 'json_replace',
50
+ 'json_remove',
51
+ 'json_patch',
52
+ 'json_type',
53
+ 'json_valid',
54
+ 'json_quote',
55
+ 'bm25',
56
+ 'highlight',
57
+ 'snippet',
58
+ ];
59
+
60
+ /** Statement-level things that are also unsupported and worth a warning. */
61
+ const UNSUPPORTED_PATTERNS = [
62
+ [/\bcollate\s+nocase\b/i, 'COLLATE NOCASE has no Postgres equivalent: use lower()/ilike or the citext extension'],
63
+ [/\bglob\b/i, 'GLOB: use LIKE with % and _ or a ~ regex'],
64
+ [/\bis\s+not\s+(?!null\b|true\b|false\b|distinct\b)/i, 'IS NOT <value>: Postgres only has IS NOT NULL/TRUE/FALSE; use IS DISTINCT FROM'],
65
+ [/->>?\s*'\$/i, "-> / ->> with a '$.path': Postgres takes a key or index, use #>> '{a,b}'"],
66
+ [/\brandom\s*\(\s*\)/i, 'random(): Postgres returns a double in [0,1), SQLite a 64-bit integer'],
67
+ ];
68
+
69
+ /**
70
+ * Where the top-level RETURNING clause starts in the mask, or -1.
71
+ * @param {string} mask
72
+ */
73
+ function returningIndex(mask) {
74
+ const re = /\breturning\b/gi;
75
+ let depth = 0;
76
+ let found = -1;
77
+ let last = 0;
78
+ for (let m = re.exec(mask); m; m = re.exec(mask)) {
79
+ for (let i = last; i < m.index; i++) {
80
+ if (mask[i] === '(') depth++;
81
+ else if (mask[i] === ')') depth--;
82
+ }
83
+ last = m.index;
84
+ if (depth === 0) found = m.index;
85
+ }
86
+ return found;
87
+ }
88
+
89
+ /**
90
+ * The INSERT verb at parenthesis depth 0 (so a `WITH (...)` prefix or a
91
+ * subquery does not fool it).
92
+ * @param {string} mask
93
+ * @returns {{ index: number, length: number, verb: string } | null}
94
+ */
95
+ function locateInsertVerb(mask) {
96
+ const re = /\b(insert\s+or\s+(?:ignore|replace|abort|fail|rollback)|replace|insert)\s+into\s+/gi;
97
+ let depth = 0;
98
+ let last = 0;
99
+ for (let m = re.exec(mask); m; m = re.exec(mask)) {
100
+ for (let i = last; i < m.index; i++) {
101
+ if (mask[i] === '(') depth++;
102
+ else if (mask[i] === ')') depth--;
103
+ }
104
+ last = m.index;
105
+ if (depth === 0) return { index: m.index, length: m[0].length, verb: m[1].toLowerCase().replace(/\s+/g, ' ') };
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Split a statement into the part before a top-level RETURNING and the
112
+ * RETURNING clause itself (with a leading space), or ''.
113
+ * @param {string} sql
114
+ */
115
+ export function splitReturning(sql) {
116
+ const mask = codeMask(sql);
117
+ const at = returningIndex(mask);
118
+ if (at === -1) return { body: sql.trimEnd(), returning: '' };
119
+ return { body: sql.slice(0, at).trimEnd(), returning: ` ${sql.slice(at).trim()}` };
120
+ }
121
+
122
+ /**
123
+ * The table an INSERT targets, in Postgres-folded form, and its column list.
124
+ *
125
+ * @param {string} sql
126
+ * @returns {{ table: string, tableSql: string, columns: string[] | null, verb: string, verbIndex: number, verbLength: number, prefixEnd: number } | null}
127
+ */
128
+ export function insertTarget(sql) {
129
+ const mask = codeMask(sql);
130
+ if (!/^\s*(insert|replace|with)\b/i.test(mask)) return null;
131
+ const at = locateInsertVerb(mask);
132
+ if (!at) return null;
133
+ const i = at.index + at.length;
134
+ // Table name: a quoted or bare identifier, possibly schema-qualified. In
135
+ // the mask a quoted identifier keeps its quotes and blanks its inside, so a
136
+ // quote jumps to its partner.
137
+ let j = i;
138
+ while (j < mask.length) {
139
+ const c = mask[j];
140
+ if (c === '"' || c === '`' || c === '[') {
141
+ const end = mask.indexOf(c === '[' ? ']' : c, j + 1);
142
+ j = end === -1 ? mask.length : end + 1;
143
+ } else if (/[A-Za-z0-9_.$]/.test(c)) j++;
144
+ else break;
145
+ }
146
+ const tableSql = sql.slice(i, j);
147
+ if (!tableSql) return null;
148
+ const table = tableSql.split('.').map(unquote).join('.');
149
+ // Optional column list.
150
+ let k = j;
151
+ while (k < mask.length && /\s/.test(mask[k])) k++;
152
+ let columns = null;
153
+ let prefixEnd = k;
154
+ if (mask[k] === '(') {
155
+ const close = matchParen(mask, k);
156
+ if (close !== -1) {
157
+ const inner = sql.slice(k + 1, close);
158
+ // `(select ...)` after the table is not a column list.
159
+ if (!/^\s*select\b/i.test(codeMask(inner))) {
160
+ columns = splitTopLevel(inner).map(unquote);
161
+ prefixEnd = close + 1;
162
+ }
163
+ }
164
+ }
165
+ return { table, tableSql, columns, verb: at.verb, verbIndex: at.index, verbLength: at.length, prefixEnd };
166
+ }
167
+
168
+ /**
169
+ * `INSERT OR IGNORE` / `INSERT OR REPLACE` / `REPLACE INTO` to Postgres
170
+ * ON CONFLICT forms.
171
+ *
172
+ * @param {string} sql
173
+ * @param {KeysLookup | undefined} keys
174
+ * @returns {string}
175
+ */
176
+ function rewriteInsert(sql, keys) {
177
+ const target = insertTarget(sql);
178
+ if (!target) return sql;
179
+ const verb = target.verb;
180
+ if (verb === 'insert') return sql;
181
+ const mask = codeMask(sql);
182
+ const hasConflict = /\bon\s+conflict\b/i.test(mask);
183
+ // Drop the OR xxx / REPLACE verb.
184
+ const out = `${sql.slice(0, target.verbIndex)}INSERT INTO ${sql.slice(target.verbIndex + target.verbLength)}`;
185
+ if (verb === 'insert or abort' || verb === 'insert or fail' || verb === 'insert or rollback') return out;
186
+ if (hasConflict) return out;
187
+ const { body, returning } = splitReturning(out);
188
+ if (verb === 'insert or ignore') return `${body} ON CONFLICT DO NOTHING${returning}`;
189
+
190
+ // INSERT OR REPLACE / REPLACE INTO.
191
+ const info = keys?.(target.table);
192
+ const conflictCols = info?.pk?.length ? info.pk : info?.unique?.[0];
193
+ if (!conflictCols?.length) {
194
+ throw new Error(
195
+ `cannot rewrite INSERT OR REPLACE for "${target.table}": no primary key or unique index found ` +
196
+ '(the table must exist in Postgres with a primary key or a unique index).',
197
+ );
198
+ }
199
+ const listed = target.columns ?? info?.columns;
200
+ if (!listed?.length) {
201
+ throw new Error(
202
+ `cannot rewrite INSERT OR REPLACE for "${target.table}": the statement lists no columns and the table's columns are unknown.`,
203
+ );
204
+ }
205
+ const updates = listed.filter((c) => !conflictCols.includes(c));
206
+ const action = updates.length
207
+ ? `DO UPDATE SET ${updates.map((c) => `${quoteIdent(c)} = EXCLUDED.${quoteIdent(c)}`).join(', ')}`
208
+ : 'DO NOTHING';
209
+ return `${body} ON CONFLICT (${conflictCols.map(quoteIdent).join(', ')}) ${action}${returning}`;
210
+ }
211
+
212
+ /** SQLite date modifier (`'-7 days'`, `'+1 month'`) to a Postgres interval expression. */
213
+ function modifierToInterval(mod) {
214
+ const m = /^\s*([+-]?)\s*(\d+(?:\.\d+)?)\s+(second|minute|hour|day|month|year)s?\s*$/i.exec(mod);
215
+ if (!m) return null;
216
+ const sign = m[1] === '-' ? '-' : '+';
217
+ return ` ${sign} interval '${m[2]} ${m[3].toLowerCase()}${m[2] === '1' ? '' : 's'}'`;
218
+ }
219
+
220
+ /**
221
+ * Apply SQLite date modifiers (`'-7 days'`, `'start of day'`, `'localtime'`)
222
+ * to a timestamptz expression. Null when a modifier is not understood.
223
+ * @param {string} expr
224
+ * @param {string[]} modifiers raw SQL arguments
225
+ * @returns {{ expr: string, shifted: boolean } | null}
226
+ */
227
+ function applyModifiers(expr, modifiers) {
228
+ let shifted = false;
229
+ for (const mod of modifiers) {
230
+ const v = literalValue(mod);
231
+ if (v === null) return null;
232
+ const lower = v.toLowerCase();
233
+ if (lower === 'localtime' || lower === 'utc') continue;
234
+ const unit = /^start of (day|month|year)$/.exec(lower);
235
+ if (unit) {
236
+ expr = `date_trunc('${unit[1]}', ${expr})`;
237
+ shifted = true;
238
+ continue;
239
+ }
240
+ const interval = modifierToInterval(v);
241
+ if (!interval) return null;
242
+ expr += interval;
243
+ shifted = true;
244
+ }
245
+ return { expr, shifted };
246
+ }
247
+
248
+ /**
249
+ * A SQLite time value plus modifiers as a timestamptz expression: `'now'`
250
+ * becomes now(), anything else is cast. Null when not understood.
251
+ * @param {string[]} args the time argument followed by modifiers
252
+ */
253
+ function timeExpr(args) {
254
+ if (!args.length) return { expr: 'now()', shifted: false };
255
+ const first = literalValue(args[0]);
256
+ const base = first !== null && first.toLowerCase() === 'now' ? 'now()' : `(${args[0].trim()})::timestamptz`;
257
+ const out = applyModifiers(base, args.slice(1));
258
+ if (!out) return null;
259
+ return out.shifted ? { expr: `(${out.expr})`, shifted: true } : out;
260
+ }
261
+
262
+ /**
263
+ * `datetime('now', ...)` and `date('now', ...)`. Only the 'now' forms are
264
+ * rewritten: `datetime(col)` in SQLite reformats a stored text, which has no
265
+ * one Postgres spelling.
266
+ * @param {string[]} args
267
+ * @param {'timestamptz' | 'date'} as
268
+ */
269
+ function nowExpression(args, as) {
270
+ if (!args.length) return as === 'date' ? 'current_date' : 'now()';
271
+ const first = literalValue(args[0]);
272
+ if (first === null || first.toLowerCase() !== 'now') return null;
273
+ const t = timeExpr(args);
274
+ if (!t) return null;
275
+ if (as === 'date') return t.shifted ? `${t.expr}::date` : 'current_date';
276
+ return t.expr;
277
+ }
278
+
279
+ /** strftime format specifiers to to_char() patterns; null when one is unknown. */
280
+ function strftimeToChar(format) {
281
+ const map = {
282
+ Y: 'YYYY',
283
+ m: 'MM',
284
+ d: 'DD',
285
+ H: 'HH24',
286
+ M: 'MI',
287
+ S: 'SS',
288
+ f: 'SS.MS',
289
+ j: 'DDD',
290
+ e: 'FMDD',
291
+ I: 'HH12',
292
+ p: 'AM',
293
+ W: 'IW',
294
+ };
295
+ let out = '';
296
+ let literal = '';
297
+ const flush = () => {
298
+ if (!literal) return;
299
+ // Letters are patterns to to_char; quote any run that carries one.
300
+ out += /[A-Za-z]/.test(literal) ? `"${literal.replace(/"/g, '')}"` : literal;
301
+ literal = '';
302
+ };
303
+ for (let i = 0; i < format.length; i++) {
304
+ const c = format[i];
305
+ if (c === '%') {
306
+ const spec = format[i + 1];
307
+ if (spec === '%') {
308
+ literal += '%';
309
+ i++;
310
+ continue;
311
+ }
312
+ if (!(spec in map)) return null;
313
+ flush();
314
+ out += map[spec];
315
+ i++;
316
+ } else literal += c;
317
+ }
318
+ flush();
319
+ return out;
320
+ }
321
+
322
+ /** `'$.a.b[0]'` to `'{a,b,0}'`, or null for a path this does not understand. */
323
+ export function jsonPathToArray(path) {
324
+ if (path === '$') return '{}';
325
+ if (!path.startsWith('$')) return null;
326
+ const parts = [];
327
+ let i = 1;
328
+ while (i < path.length) {
329
+ const c = path[i];
330
+ if (c === '.') {
331
+ i++;
332
+ if (path[i] === '"') {
333
+ const j = path.indexOf('"', i + 1);
334
+ if (j === -1) return null;
335
+ parts.push(path.slice(i + 1, j));
336
+ i = j + 1;
337
+ } else {
338
+ const m = /^[^.\[]+/.exec(path.slice(i));
339
+ if (!m) return null;
340
+ parts.push(m[0]);
341
+ i += m[0].length;
342
+ }
343
+ } else if (c === '[') {
344
+ const j = path.indexOf(']', i);
345
+ if (j === -1) return null;
346
+ const idx = path.slice(i + 1, j);
347
+ if (!/^\d+$/.test(idx)) return null; // '#-1' and friends are not handled
348
+ parts.push(idx);
349
+ i = j + 1;
350
+ } else return null;
351
+ }
352
+ const quoted = parts.map((p) => (/^[A-Za-z0-9_]+$/.test(p) ? p : `"${p.replace(/"/g, '\\"')}"`));
353
+ return `{${quoted.join(',')}}`;
354
+ }
355
+
356
+ /**
357
+ * The function-level rewrites; also used on DEFAULT expressions and view
358
+ * bodies by the schema converter.
359
+ *
360
+ * @param {string} sql
361
+ * @returns {string}
362
+ */
363
+ export function rewriteFunctions(sql) {
364
+ // lower(hex(randomblob(N))) is the SQLite idiom for a random hex id.
365
+ sql = replaceCalls(sql, 'lower', ({ args }) => {
366
+ if (args.length !== 1) return null;
367
+ const m = /^\s*hex\s*\(\s*randomblob\s*\(\s*(\d+)\s*\)\s*\)\s*$/i.exec(args[0]);
368
+ return m ? `encode(gen_random_bytes(${m[1]}), 'hex')` : null;
369
+ });
370
+ sql = replaceCalls(sql, 'hex', ({ args }) => {
371
+ if (args.length !== 1) return null;
372
+ const m = /^\s*randomblob\s*\(\s*(\d+)\s*\)\s*$/i.exec(args[0]);
373
+ if (m) return `upper(encode(gen_random_bytes(${m[1]}), 'hex'))`;
374
+ return `upper(encode((${args[0].trim()})::bytea, 'hex'))`;
375
+ });
376
+ sql = replaceCalls(sql, 'randomblob', ({ args }) => (args.length === 1 ? `gen_random_bytes(${args[0].trim()})` : null));
377
+
378
+ sql = replaceCalls(sql, 'datetime', ({ args }) => nowExpression(args, 'timestamptz'));
379
+ sql = replaceCalls(sql, 'date', ({ args }) => nowExpression(args, 'date'));
380
+ sql = replaceCalls(sql, 'unixepoch', ({ args }) => {
381
+ const t = timeExpr(args);
382
+ return t ? `extract(epoch from ${t.expr})::bigint` : null;
383
+ });
384
+ sql = replaceCalls(sql, 'strftime', ({ args }) => {
385
+ if (args.length < 1) return null;
386
+ const format = literalValue(args[0]);
387
+ if (format === null) return null;
388
+ const t = timeExpr(args.slice(1));
389
+ if (!t) return null;
390
+ if (format === '%s') return `extract(epoch from ${t.expr})::bigint`;
391
+ const pattern = strftimeToChar(format);
392
+ if (pattern === null) return null;
393
+ // SQLite formats in UTC; to_char formats in the session zone.
394
+ return `to_char(${t.expr} at time zone 'utc', '${pattern.replace(/'/g, "''")}')`;
395
+ });
396
+ sql = replaceCalls(sql, 'julianday', ({ args }) => {
397
+ const t = timeExpr(args);
398
+ return t ? `(extract(epoch from ${t.expr}) / 86400.0 + 2440587.5)` : null;
399
+ });
400
+
401
+ sql = replaceCalls(sql, 'json_extract', ({ args }) => {
402
+ if (args.length !== 2) return null;
403
+ const path = literalValue(args[1]);
404
+ if (path === null) return null;
405
+ const arr = jsonPathToArray(path);
406
+ if (arr === null) return null;
407
+ return `((${args[0].trim()})::jsonb #>> '${arr}')`;
408
+ });
409
+ sql = replaceCalls(sql, 'json_array_length', ({ args }) => {
410
+ if (args.length === 1) return `jsonb_array_length((${args[0].trim()})::jsonb)`;
411
+ if (args.length === 2) {
412
+ const path = literalValue(args[1]);
413
+ const arr = path === null ? null : jsonPathToArray(path);
414
+ if (arr === null) return null;
415
+ return `jsonb_array_length((${args[0].trim()})::jsonb #> '${arr}')`;
416
+ }
417
+ return null;
418
+ });
419
+ sql = replaceCode(sql, /\bjson_object\s*\(/gi, 'json_build_object(');
420
+ sql = replaceCode(sql, /\bjson_array\s*\(/gi, 'json_build_array(');
421
+ sql = replaceCode(sql, /\bjson_group_array\s*\(/gi, 'json_agg(');
422
+ sql = replaceCode(sql, /\bjson_group_object\s*\(/gi, 'json_object_agg(');
423
+
424
+ sql = replaceCalls(sql, 'group_concat', ({ args, argsText }) => {
425
+ const distinct = /^\s*distinct\b/i.test(argsText);
426
+ const first = args[0]?.replace(/^\s*distinct\s+/i, '').trim();
427
+ if (!first) return null;
428
+ const sep = args.length > 1 ? args[1].trim() : "','";
429
+ if (args.length > 2) return null;
430
+ return `string_agg(${distinct ? 'distinct ' : ''}(${first})::text, ${sep})`;
431
+ });
432
+ sql = replaceCode(sql, /\bifnull\s*\(/gi, 'coalesce(');
433
+ sql = replaceCalls(sql, 'instr', ({ args }) => (args.length === 2 ? `position(${args[1].trim()} in ${args[0].trim()})` : null));
434
+ sql = replaceCalls(sql, 'max', ({ args }) => (args.length >= 2 ? `greatest(${args.map((a) => a.trim()).join(', ')})` : null));
435
+ sql = replaceCalls(sql, 'min', ({ args }) => (args.length >= 2 ? `least(${args.map((a) => a.trim()).join(', ')})` : null));
436
+ sql = replaceCalls(sql, 'total', ({ args }) => (args.length === 1 ? `coalesce(sum(${args[0].trim()}), 0)` : null));
437
+
438
+ // CAST(x AS INTEGER): Postgres's integer is 32-bit; SQLite's is 64.
439
+ sql = replaceCode(sql, /\bas\s+(integer|int|real|blob)\s*\)/gi, (_m, type) => {
440
+ const t = type.toLowerCase();
441
+ return `as ${t === 'real' ? 'double precision' : t === 'blob' ? 'bytea' : 'bigint'})`;
442
+ });
443
+ sql = replaceCode(sql, /\bregexp\b/gi, '~');
444
+ return sql;
445
+ }
446
+
447
+ /**
448
+ * Warnings for things the rewriter leaves alone and Postgres will refuse.
449
+ * @param {string} sql
450
+ */
451
+ export function unsupportedIdioms(sql) {
452
+ const mask = codeMask(sql);
453
+ const out = [];
454
+ for (const name of UNSUPPORTED_FUNCTIONS) {
455
+ if (findCall(sql, mask, name)) out.push(`${name}() has no direct Postgres equivalent; see the README dialect table`);
456
+ }
457
+ for (const [re, message] of UNSUPPORTED_PATTERNS) if (re.test(mask)) out.push(message);
458
+ if (/\browid\b/i.test(mask)) out.push('rowid: Postgres tables have no implicit rowid; give the table an identity column named rowid or use the primary key');
459
+ return out;
460
+ }
461
+
462
+ /**
463
+ * The table named on the left of `MATCH`, for the FTS5 error.
464
+ * @param {string} sql
465
+ * @param {string} mask
466
+ */
467
+ function matchTable(sql, mask) {
468
+ const m = /([A-Za-z_][A-Za-z0-9_]*|"[^"]*")\s*(?:\.\s*(?:[A-Za-z_][A-Za-z0-9_]*|"[^"]*"))?\s+match\b/i.exec(mask);
469
+ if (!m) return 'unknown';
470
+ return unquote(sql.slice(m.index, m.index + m[1].length));
471
+ }
472
+
473
+ /**
474
+ * Rewrite one statement.
475
+ *
476
+ * @param {string} sql
477
+ * @param {{ keys?: KeysLookup, ddl?: (sql: string) => string }} [opts]
478
+ * `keys` answers INSERT OR REPLACE's conflict target; `ddl` converts a
479
+ * CREATE/ALTER statement (the schema converter, wired in by the client).
480
+ * @returns {Rewritten}
481
+ */
482
+ export function rewriteStatement(sql, opts = {}) {
483
+ let text = sql.trim().replace(/;\s*$/, '');
484
+ const mask = codeMask(text);
485
+ if (!text) return { sql: '', noop: true, warnings: [], kind: 'comment' };
486
+ if (!/\S/.test(mask)) return { sql: text, noop: true, warnings: [], kind: 'comment' };
487
+ if (/^\s*pragma\b/i.test(mask)) return { sql: text, noop: true, warnings: [], kind: 'pragma' };
488
+ if (/\bmatch\b/i.test(mask)) throw ftsError(matchTable(text, mask), text);
489
+ if (/^\s*(create\s+(virtual\s+|unique\s+|temp(orary)?\s+)?(table|index|trigger|view)|alter\s+table|drop\s+(table|index|view|trigger))\b/i.test(mask)) {
490
+ const converted = opts.ddl ? opts.ddl(text) : rewriteFunctions(text);
491
+ const onlyComments = !/\S/.test(codeMask(converted));
492
+ return { sql: converted, noop: onlyComments, warnings: [], kind: 'ddl' };
493
+ }
494
+
495
+ // Backticked identifiers are fine in SQLite and MySQL, not Postgres.
496
+ text = replaceCode(text, /`([^`]*)`/g, (_m, name) => quoteIdent(name));
497
+ text = replaceCode(text, /\s+(not\s+indexed|indexed\s+by\s+[A-Za-z0-9_"]+)\b/gi, '');
498
+ text = replaceCode(text, /\blimit\s+-1\b/gi, 'LIMIT ALL');
499
+ text = rewriteInsert(text, opts.keys);
500
+ text = rewriteFunctions(text);
501
+ return { sql: text, noop: false, warnings: unsupportedIdioms(text), kind: 'dml' };
502
+ }
503
+
504
+ /**
505
+ * A rewriter with a cache keyed by statement text. INSERT OR REPLACE needs the
506
+ * target's keys, which `needsKeys()` announces so the caller can fetch them
507
+ * (asynchronously) before calling `rewrite()`.
508
+ *
509
+ * @param {{ keys?: KeysLookup, ddl?: (sql: string) => string, cacheSize?: number }} [opts]
510
+ */
511
+ export function createRewriter(opts = {}) {
512
+ const cache = new Map();
513
+ const max = opts.cacheSize ?? 2000;
514
+ return {
515
+ /**
516
+ * The table whose keys an INSERT OR REPLACE / REPLACE INTO needs, or null.
517
+ * @param {string} sql
518
+ */
519
+ needsKeys(sql) {
520
+ if (cache.has(sql)) return null;
521
+ if (!/^\s*(insert\s+or\s+replace|replace)\s+into\b/i.test(codeMask(sql))) return null;
522
+ return insertTarget(sql)?.table ?? null;
523
+ },
524
+ /**
525
+ * @param {string} sql
526
+ * @returns {Rewritten}
527
+ */
528
+ rewrite(sql) {
529
+ const hit = cache.get(sql);
530
+ if (hit) return hit;
531
+ const out = rewriteStatement(sql, opts);
532
+ if (cache.size >= max) cache.clear();
533
+ cache.set(sql, out);
534
+ return out;
535
+ },
536
+ clear() {
537
+ cache.clear();
538
+ },
539
+ };
540
+ }
541
+
542
+ /**
543
+ * Convenience: rewrite one statement with optional keys, no cache.
544
+ * @param {string} sql
545
+ * @param {{ keys?: KeysLookup, ddl?: (sql: string) => string }} [opts]
546
+ */
547
+ export function rewriteSql(sql, opts = {}) {
548
+ return rewriteStatement(sql, opts);
549
+ }