turbine-orm 0.77.1 → 0.78.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/README.md +13 -9
- package/dist/cjs/cli/config.d.ts +7 -1
- package/dist/cjs/cli/config.js +11 -2
- package/dist/cjs/cli/destructive.d.ts +1 -1
- package/dist/cjs/cli/destructive.js +307 -9
- package/dist/cjs/cli/index.js +252 -42
- package/dist/cjs/cli/mcp.d.ts +23 -0
- package/dist/cjs/cli/mcp.js +190 -152
- package/dist/cjs/cli/migrate.d.ts +243 -3
- package/dist/cjs/cli/migrate.js +432 -43
- package/dist/cjs/cli/sql-statements.js +27 -0
- package/dist/cjs/cli/studio.d.ts +0 -1
- package/dist/cjs/cli/studio.js +9 -7
- package/dist/cjs/client.d.ts +8 -1
- package/dist/cjs/client.js +7 -0
- package/dist/cjs/connection-url.d.ts +82 -0
- package/dist/cjs/connection-url.js +187 -1
- package/dist/cjs/errors.d.ts +112 -12
- package/dist/cjs/errors.js +558 -114
- package/dist/cjs/generate.js +47 -15
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/introspect.d.ts +33 -0
- package/dist/cjs/introspect.js +54 -1
- package/dist/cjs/mssql.js +21 -1
- package/dist/cjs/nested-write.js +85 -14
- package/dist/cjs/pipeline-submittable.d.ts +2 -0
- package/dist/cjs/pipeline-submittable.js +88 -3
- package/dist/cjs/pipeline.js +13 -1
- package/dist/cjs/powdb-introspect.d.ts +5 -1
- package/dist/cjs/powdb-introspect.js +5 -1
- package/dist/cjs/powql.d.ts +14 -0
- package/dist/cjs/powql.js +44 -4
- package/dist/cjs/prisma-compat.js +95 -8
- package/dist/cjs/query/aggregates.js +56 -6
- package/dist/cjs/query/builder.d.ts +76 -13
- package/dist/cjs/query/builder.js +188 -58
- package/dist/cjs/query/compound-unique.d.ts +76 -0
- package/dist/cjs/query/compound-unique.js +129 -0
- package/dist/cjs/query/index.d.ts +1 -1
- package/dist/cjs/query/types.d.ts +65 -11
- package/dist/cjs/query/where.d.ts +85 -19
- package/dist/cjs/query/where.js +262 -47
- package/dist/cjs/query/writes.d.ts +11 -2
- package/dist/cjs/query/writes.js +116 -21
- package/dist/cjs/seed.d.ts +16 -0
- package/dist/cjs/seed.js +16 -0
- package/dist/cli/config.d.ts +7 -1
- package/dist/cli/config.js +11 -2
- package/dist/cli/destructive.d.ts +1 -1
- package/dist/cli/destructive.js +307 -9
- package/dist/cli/index.js +254 -44
- package/dist/cli/mcp.d.ts +23 -0
- package/dist/cli/mcp.js +187 -150
- package/dist/cli/migrate.d.ts +243 -3
- package/dist/cli/migrate.js +423 -45
- package/dist/cli/sql-statements.js +27 -0
- package/dist/cli/studio.d.ts +0 -1
- package/dist/cli/studio.js +10 -7
- package/dist/client.d.ts +8 -1
- package/dist/client.js +7 -0
- package/dist/connection-url.d.ts +82 -0
- package/dist/connection-url.js +183 -0
- package/dist/errors.d.ts +112 -12
- package/dist/errors.js +558 -114
- package/dist/generate.js +47 -15
- package/dist/index.d.ts +1 -1
- package/dist/introspect.d.ts +33 -0
- package/dist/introspect.js +53 -1
- package/dist/mssql.js +21 -1
- package/dist/nested-write.js +85 -14
- package/dist/pipeline-submittable.d.ts +2 -0
- package/dist/pipeline-submittable.js +87 -3
- package/dist/pipeline.js +14 -2
- package/dist/powdb-introspect.d.ts +5 -1
- package/dist/powdb-introspect.js +5 -1
- package/dist/powql.d.ts +14 -0
- package/dist/powql.js +45 -5
- package/dist/prisma-compat.js +96 -9
- package/dist/query/aggregates.js +56 -6
- package/dist/query/builder.d.ts +76 -13
- package/dist/query/builder.js +188 -58
- package/dist/query/compound-unique.d.ts +76 -0
- package/dist/query/compound-unique.js +126 -1
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +65 -11
- package/dist/query/where.d.ts +85 -19
- package/dist/query/where.js +260 -47
- package/dist/query/writes.d.ts +11 -2
- package/dist/query/writes.js +117 -22
- package/dist/seed.d.ts +16 -0
- package/dist/seed.js +16 -0
- package/package.json +3 -3
- package/skills/turbine-orm/SKILL.md +37 -10
package/dist/cli/destructive.js
CHANGED
|
@@ -45,7 +45,9 @@ export const DESTRUCTIVE_KIND_LABEL = {
|
|
|
45
45
|
'alter-column-type': 'rewrites a column type (cast may truncate or fail)',
|
|
46
46
|
'merge-delete': 'deletes matched rows (MERGE ... THEN DELETE)',
|
|
47
47
|
rename: 'renames a table or column, every query and view referencing the old name breaks',
|
|
48
|
+
'rewrite-rule': 'installs a rewrite rule that runs a destructive statement every time a later ordinary statement matches it',
|
|
48
49
|
'dynamic-destructive': 'runs destructive SQL assembled at run time, what it destroys cannot be known without running it',
|
|
50
|
+
'dynamic-unclassified': 'runs SQL assembled at run time whose statement the scanner cannot read; confirm what it does yourself',
|
|
49
51
|
};
|
|
50
52
|
/** Unquote a "quoted" identifier for display. */
|
|
51
53
|
const ident = (raw) => (raw ?? '?').replace(/^"|"$/g, '');
|
|
@@ -150,6 +152,25 @@ const RULES = [
|
|
|
150
152
|
target: (m) => (m[4] ? `${ident(m[2])}.${ident(m[4])}` : ident(m[2])),
|
|
151
153
|
also: (stmt) => !hasTopLevelWhere(stmt),
|
|
152
154
|
},
|
|
155
|
+
{
|
|
156
|
+
// `CREATE RULE r AS ON INSERT TO t DO INSTEAD DELETE FROM u`. The rule
|
|
157
|
+
// destroys nothing when it is created, which is why it passed: the DELETE
|
|
158
|
+
// runs later, on every ordinary INSERT into `t`, and the first one emptied
|
|
159
|
+
// the table. Deferred destruction is still destruction the migration
|
|
160
|
+
// installs. `DO ALSO` counts too (the action runs in addition), and the
|
|
161
|
+
// action may be a parenthesized list, whose FIRST statement decides.
|
|
162
|
+
kind: 'rewrite-rule',
|
|
163
|
+
regex: new RegExp(String.raw `^CREATE\s+(?:OR\s+REPLACE\s+)?RULE\s+${IDENT}\s+AS\s+ON\s+(?:SELECT|INSERT|UPDATE|DELETE)\s+TO\s+${IDENT}${OUTSIDE_QUOTES}\bDO\s+(?:ALSO\s+|INSTEAD\s+)?\(?\s*(DELETE|UPDATE|TRUNCATE|DROP)\b`, 'i'),
|
|
164
|
+
target: (m) => `${qualified(m, 4)} (${(m[7] ?? '').toUpperCase()} on every matching statement)`,
|
|
165
|
+
// An UPDATE action is judged the way a top-level UPDATE is: destructive
|
|
166
|
+
// without a WHERE, ordinary with one. `CREATE RULE v_upd AS ON UPDATE TO v
|
|
167
|
+
// DO INSTEAD UPDATE base SET n = 1 WHERE id = 2` is the standard updatable
|
|
168
|
+
// -view idiom and was being reported, which is a false positive on a shape
|
|
169
|
+
// that appears in ordinary migrations. DELETE, TRUNCATE and DROP stay
|
|
170
|
+
// unconditional: a DELETE inside a rule destroys on every matching
|
|
171
|
+
// statement whether or not it is narrowed, and the other two take no WHERE.
|
|
172
|
+
also: (stmt) => !/\bDO\s+(?:ALSO\s+|INSTEAD\s+)?\(?\s*UPDATE\b/i.test(stmt) || !hasTopLevelWhere(stmt),
|
|
173
|
+
},
|
|
153
174
|
// Renames come LAST: they destroy no data, so any statement that is BOTH a
|
|
154
175
|
// rename and a data-loss operation should report the data loss instead.
|
|
155
176
|
{
|
|
@@ -222,6 +243,28 @@ function matchRules(candidate) {
|
|
|
222
243
|
}
|
|
223
244
|
return null;
|
|
224
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* The DML inside `COPY (DELETE FROM t RETURNING *) TO ...`.
|
|
248
|
+
*
|
|
249
|
+
* The same trick as a data-modifying CTE, through a different wrapper: the
|
|
250
|
+
* statement reads as a COPY, which nothing here treats as destructive, and the
|
|
251
|
+
* DELETE inside it runs and empties the table. Verified live on PostgreSQL 17
|
|
252
|
+
* through node-postgres, `TO STDOUT` included, so it needs no server-side file
|
|
253
|
+
* permission and no `TO PROGRAM`. `cteSubstatements` does not reach it because
|
|
254
|
+
* that one only fires on a leading `WITH`.
|
|
255
|
+
*/
|
|
256
|
+
function copySubstatements(stmt) {
|
|
257
|
+
if (!/^COPY\s*\(/i.test(stmt))
|
|
258
|
+
return [];
|
|
259
|
+
const open = stmt.indexOf('(');
|
|
260
|
+
if (open === -1)
|
|
261
|
+
return [];
|
|
262
|
+
const inner = stmt.slice(open + 1, closingParenIndex(stmt, open)).trim();
|
|
263
|
+
// A `COPY (SELECT ...) TO` is the ordinary export form and destroys nothing.
|
|
264
|
+
// A leading WITH is handed on so its own CTE bodies are read by the rule
|
|
265
|
+
// above rather than being judged as one blob here.
|
|
266
|
+
return /^(?:DELETE|UPDATE|INSERT|MERGE|WITH)\b/i.test(inner) ? [inner] : [];
|
|
267
|
+
}
|
|
225
268
|
/**
|
|
226
269
|
* Data-modifying CTE bodies: `WITH d AS (DELETE FROM t ...) SELECT ...` runs a
|
|
227
270
|
* real DELETE even though the statement reads as a SELECT. Each candidate is cut
|
|
@@ -294,7 +337,7 @@ function proceduralCandidates(body) {
|
|
|
294
337
|
const re = /\b(?:DROP|TRUNCATE|DELETE|ALTER|UPDATE|MERGE)\s/gi;
|
|
295
338
|
let m = re.exec(text);
|
|
296
339
|
while (m !== null) {
|
|
297
|
-
out.push({ text: text.slice(m.index), before: text.slice(0, m.index) });
|
|
340
|
+
out.push({ text: text.slice(m.index), before: text.slice(0, m.index), statementCode: text });
|
|
298
341
|
m = re.exec(text);
|
|
299
342
|
}
|
|
300
343
|
}
|
|
@@ -322,6 +365,7 @@ export function scanDestructiveSql(sql) {
|
|
|
322
365
|
const candidates = [
|
|
323
366
|
stripLeadingCtes(body),
|
|
324
367
|
...cteSubstatements(body),
|
|
368
|
+
...copySubstatements(body).flatMap((inner) => [inner, ...cteSubstatements(inner)]),
|
|
325
369
|
].map((text) => ({
|
|
326
370
|
text,
|
|
327
371
|
display,
|
|
@@ -339,10 +383,20 @@ export function scanDestructiveSql(sql) {
|
|
|
339
383
|
candidates.push({
|
|
340
384
|
text: part.text,
|
|
341
385
|
display: `${display} [in block: ${part.text.replace(/\s+/g, ' ').slice(0, 60)}]`,
|
|
386
|
+
statementCode: part.statementCode,
|
|
342
387
|
});
|
|
343
388
|
proceduralParts.push(part);
|
|
344
389
|
}
|
|
345
390
|
}
|
|
391
|
+
// Sub-statements of a procedural body that some pass has already named.
|
|
392
|
+
// ONE body holds several statements and each is judged on its own, so a
|
|
393
|
+
// pass that reports one must not silence the others: `DO $$ BEGIN EXECUTE
|
|
394
|
+
// 'ALTER TABLE t DROP COLUMN x'; EXECUTE 'D'||'ROP TABLE victim'; END $$`
|
|
395
|
+
// reported only the DROP COLUMN, and the DROP TABLE then ran under a
|
|
396
|
+
// confirmation the operator gave for something else. Equally, the
|
|
397
|
+
// unclassified backstop must not re-report a statement an earlier pass
|
|
398
|
+
// already named, which is what this set is for.
|
|
399
|
+
const spokenFor = new Set();
|
|
346
400
|
let matched = false;
|
|
347
401
|
for (const candidate of candidates) {
|
|
348
402
|
const hit = matchRules(candidate.text);
|
|
@@ -350,9 +404,15 @@ export function scanDestructiveSql(sql) {
|
|
|
350
404
|
continue;
|
|
351
405
|
found.push({ statement: candidate.display, kind: hit.kind, target: hit.target });
|
|
352
406
|
matched = true;
|
|
353
|
-
|
|
407
|
+
if (candidate.statementCode !== undefined)
|
|
408
|
+
spokenFor.add(candidate.statementCode);
|
|
409
|
+
// A statement outside a procedural body has exactly one verdict, as
|
|
410
|
+
// before. Inside one, keep going: the remaining sub-statements have not
|
|
411
|
+
// been judged yet.
|
|
412
|
+
if (!procedural)
|
|
413
|
+
break;
|
|
354
414
|
}
|
|
355
|
-
if (matched)
|
|
415
|
+
if (matched && !procedural)
|
|
356
416
|
continue;
|
|
357
417
|
// Nothing matched a rule. Inside a PROCEDURAL body that is not the end of
|
|
358
418
|
// the question, because the rules all need a parseable object name and
|
|
@@ -375,7 +435,10 @@ export function scanDestructiveSql(sql) {
|
|
|
375
435
|
// `RAISE NOTICE 'DROP the mic'` would prompt, and a guard that fires on
|
|
376
436
|
// prose teaches operators to confirm without reading, which costs more than
|
|
377
437
|
// it saves.
|
|
438
|
+
let dynamicHit = false;
|
|
378
439
|
for (const part of proceduralParts) {
|
|
440
|
+
if (spokenFor.has(part.statementCode))
|
|
441
|
+
continue;
|
|
379
442
|
const kind = dynamicDestructiveKind(part);
|
|
380
443
|
if (!kind)
|
|
381
444
|
continue;
|
|
@@ -384,11 +447,210 @@ export function scanDestructiveSql(sql) {
|
|
|
384
447
|
kind,
|
|
385
448
|
target: DYNAMIC_TARGET,
|
|
386
449
|
});
|
|
387
|
-
|
|
450
|
+
dynamicHit = true;
|
|
451
|
+
spokenFor.add(part.statementCode);
|
|
452
|
+
}
|
|
453
|
+
// Still nothing, in a procedural body. Every pass above needs to SEE a verb,
|
|
454
|
+
// and an EXECUTE whose text is assembled so that no verb is visible
|
|
455
|
+
// (`'D' || 'ROP TABLE t'`, `chr(68) || ...`, `reverse(...)`, an escape-
|
|
456
|
+
// encoded literal, a variable built across statements) walked past all of
|
|
457
|
+
// them and dropped the table live. The scanner cannot classify such a
|
|
458
|
+
// statement, and "cannot classify" must not be reported as "clean": that is
|
|
459
|
+
// the consent gate deciding in the author's favour on no evidence. So it
|
|
460
|
+
// asks, with a kind whose label says exactly that.
|
|
461
|
+
for (const block of procedural ? statement.blocks : []) {
|
|
462
|
+
for (const unreadable of unclassifiableExecutes(block, matched || dynamicHit)) {
|
|
463
|
+
if (spokenFor.has(unreadable.code))
|
|
464
|
+
continue;
|
|
465
|
+
spokenFor.add(unreadable.code);
|
|
466
|
+
found.push({
|
|
467
|
+
statement: `${display} [in block: ${`EXECUTE ${unreadable.expr}`.replace(/\s+/g, ' ').slice(0, 60)}]`,
|
|
468
|
+
kind: 'dynamic-unclassified',
|
|
469
|
+
target: DYNAMIC_TARGET,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
388
472
|
}
|
|
389
473
|
}
|
|
390
474
|
return found;
|
|
391
475
|
}
|
|
476
|
+
/**
|
|
477
|
+
* Statement openers that destroy nothing, so an assembled `EXECUTE` whose text
|
|
478
|
+
* visibly begins with one of them is left alone. `UPDATE` is here because its
|
|
479
|
+
* destructive form is the ABSENCE of a `WHERE`, which the tail of an assembled
|
|
480
|
+
* fragment cannot answer; flagging every dynamic `UPDATE ... WHERE` is the
|
|
481
|
+
* false-positive cost this module refuses to pay.
|
|
482
|
+
*/
|
|
483
|
+
const HARMLESS_OPENER = /^(?:SELECT|INSERT|UPDATE|CREATE|COMMENT|GRANT|REVOKE|ANALYZE|ANALYSE|REFRESH|VACUUM|REINDEX|CLUSTER|SET|RESET|SHOW|NOTIFY|LOCK|CALL|PERFORM|EXPLAIN)\b/i;
|
|
484
|
+
/**
|
|
485
|
+
* Openers whose rules deliberately stay SILENT on a benign form, so silence
|
|
486
|
+
* from them is a decision rather than a failure to parse. `ALTER TABLE ... ADD
|
|
487
|
+
* COLUMN` loses nothing and is not reported on purpose; a `MERGE` without a
|
|
488
|
+
* `THEN DELETE` likewise; a `WITH` whose CTE bodies are all reads likewise.
|
|
489
|
+
* Reporting these again here would contradict that silence.
|
|
490
|
+
*/
|
|
491
|
+
const VERB_SILENT_WHEN_BENIGN = /^(?:ALTER|MERGE|WITH)\b/i;
|
|
492
|
+
/**
|
|
493
|
+
* Openers whose rules ALWAYS report when they can read the statement. Silence
|
|
494
|
+
* from one of these is not a verdict, it is the rules failing to parse, so the
|
|
495
|
+
* skip is conditional on an earlier pass having actually reported.
|
|
496
|
+
*
|
|
497
|
+
* The shape that made the distinction necessary: two adjacent string literals
|
|
498
|
+
* are ONE string in PostgreSQL, so `EXECUTE 'DROP TABLE '\n'victims'` reads as
|
|
499
|
+
* `DROP` to the opener test while every earlier pass declines, the literal
|
|
500
|
+
* rules because a quote is not an identifier and the assembly test because
|
|
501
|
+
* continuation is lexical and carries no `||`, `concat` or `format`. The
|
|
502
|
+
* opener was trusted, the statement was skipped, and the table went.
|
|
503
|
+
*/
|
|
504
|
+
const VERB_ALWAYS_REPORTS = /^(?:DROP|TRUNCATE|DELETE)\b/i;
|
|
505
|
+
/**
|
|
506
|
+
* `EXECUTE` in a position where plpgsql can begin a statement.
|
|
507
|
+
*
|
|
508
|
+
* Matched against a statement's `stripped` view, so the word can never be one
|
|
509
|
+
* that lived inside a string literal. `GRANT`/`REVOKE EXECUTE ON FUNCTION` are
|
|
510
|
+
* excluded by construction: their preceding token is a verb, not one of the
|
|
511
|
+
* introducers here.
|
|
512
|
+
*/
|
|
513
|
+
const EXECUTE_AT_STATEMENT_START = /(?:^|;|\bTHEN\b|\bELSE\b|\bLOOP\b|\bBEGIN\b|\bDECLARE\b)\s*EXECUTE\s/i;
|
|
514
|
+
/**
|
|
515
|
+
* Functions whose FIRST literal argument is the statement text (or its
|
|
516
|
+
* template), so the opener can be read through the call.
|
|
517
|
+
*/
|
|
518
|
+
const TEMPLATE_FN = /^(?:format|replace|regexp_replace|concat|concat_ws|array_to_string)\s*\(/i;
|
|
519
|
+
/** The leading string literal of `text`: its raw content and whether it can hide a verb. */
|
|
520
|
+
function leadingLiteral(text) {
|
|
521
|
+
const m = /^(U&'|[EeBbXx]'|'|\$([A-Za-z_][\w]*)?\$)/.exec(text);
|
|
522
|
+
if (!m)
|
|
523
|
+
return null;
|
|
524
|
+
const open = m[1];
|
|
525
|
+
if (open.startsWith('$')) {
|
|
526
|
+
const close = text.indexOf(open, open.length);
|
|
527
|
+
if (close === -1)
|
|
528
|
+
return null;
|
|
529
|
+
return { content: text.slice(open.length, close), rest: text.slice(close + open.length), escaped: false };
|
|
530
|
+
}
|
|
531
|
+
// Single-quoted: `''` is an escaped quote inside the literal.
|
|
532
|
+
let i = open.length;
|
|
533
|
+
let content = '';
|
|
534
|
+
while (i < text.length) {
|
|
535
|
+
const ch = text[i];
|
|
536
|
+
if (ch === "'") {
|
|
537
|
+
if (text[i + 1] === "'") {
|
|
538
|
+
content += "'";
|
|
539
|
+
i += 2;
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
content += ch;
|
|
545
|
+
i++;
|
|
546
|
+
}
|
|
547
|
+
if (i >= text.length)
|
|
548
|
+
return null;
|
|
549
|
+
// `E'\x44ROP'` and `U&'\0044ROP'` spell a verb the scanner cannot see; an
|
|
550
|
+
// escape string with no backslash in it hides nothing.
|
|
551
|
+
const escaped = open !== "'" && content.includes('\\');
|
|
552
|
+
return { content, rest: text.slice(i + 1), escaped };
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* The first word of the statement an `EXECUTE` argument would run, read as far
|
|
556
|
+
* as the text allows, plus whether the argument is one PLAIN literal (in which
|
|
557
|
+
* case the literal passes already had their chance and this pass stays out).
|
|
558
|
+
* `null` opener = the scanner cannot see a verb at all.
|
|
559
|
+
*/
|
|
560
|
+
function executeOpener(expr) {
|
|
561
|
+
const text = expr.trim();
|
|
562
|
+
const lit = leadingLiteral(text);
|
|
563
|
+
if (lit) {
|
|
564
|
+
const word = /^\s*([A-Za-z_][\w]*)/.exec(lit.content)?.[1] ?? null;
|
|
565
|
+
// Plain: the literal IS the whole argument (bar an INTO / USING clause),
|
|
566
|
+
// spelled without escapes.
|
|
567
|
+
const plain = !lit.escaped && /^\s*(?:;|INTO\b|USING\b|$)/i.test(lit.rest);
|
|
568
|
+
return { opener: lit.escaped ? null : word, plain };
|
|
569
|
+
}
|
|
570
|
+
if (TEMPLATE_FN.test(text)) {
|
|
571
|
+
// Read the opener from the first literal argument that carries a word: a
|
|
572
|
+
// `concat_ws(' ', ...)` separator or a `format('%s', ...)` placeholder is
|
|
573
|
+
// not a verb, and neither is a `concat('DR', 'OP', ...)` fragment.
|
|
574
|
+
const inner = text.slice(text.indexOf('(') + 1);
|
|
575
|
+
let cursor = inner;
|
|
576
|
+
for (let guard = 0; guard < 16; guard++) {
|
|
577
|
+
const lit2 = leadingLiteral(cursor.trimStart());
|
|
578
|
+
if (!lit2)
|
|
579
|
+
break;
|
|
580
|
+
const word = /^\s*([A-Za-z_][\w]*)/.exec(lit2.content)?.[1];
|
|
581
|
+
if (word)
|
|
582
|
+
return { opener: lit2.escaped ? null : word, plain: false };
|
|
583
|
+
const comma = lit2.rest.indexOf(',');
|
|
584
|
+
if (comma === -1)
|
|
585
|
+
break;
|
|
586
|
+
cursor = lit2.rest.slice(comma + 1);
|
|
587
|
+
}
|
|
588
|
+
return { opener: null, plain: false };
|
|
589
|
+
}
|
|
590
|
+
// Any other function call, a parenthesised expression, or a bare variable
|
|
591
|
+
// (`EXECUTE s`, built across earlier statements): nothing readable.
|
|
592
|
+
return { opener: null, plain: false };
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* The EXECUTE argument in `body`, when it is assembled or encoded such that no
|
|
596
|
+
* statement verb is visible to the passes above. Returns the offending
|
|
597
|
+
* fragment for display, or `null` when every EXECUTE in the body either is a
|
|
598
|
+
* plain literal (the literal rules own it) or visibly begins with a verb some
|
|
599
|
+
* other rule has already judged.
|
|
600
|
+
*/
|
|
601
|
+
function unclassifiableExecutes(body, verbPassesReported) {
|
|
602
|
+
const out = [];
|
|
603
|
+
for (const statement of tokenizeSql(body)) {
|
|
604
|
+
// Decide PRESENCE against `stripped`, whose literals are emptied, and read
|
|
605
|
+
// the ARGUMENT out of `code`, which keeps them. Reading both out of `code`
|
|
606
|
+
// fired on the word rather than on the statement: `RAISE NOTICE 'EXECUTE
|
|
607
|
+
// the plan'` and `INSERT INTO log(msg) VALUES ('EXECUTE me later')` both
|
|
608
|
+
// prompted, and a guard that fires on prose teaches operators to confirm
|
|
609
|
+
// without reading, which is the cost this module's own doctrine refuses to
|
|
610
|
+
// pay. `stripped` cannot contain a word that only ever appeared inside a
|
|
611
|
+
// literal, so it settles the question the offsets cannot.
|
|
612
|
+
//
|
|
613
|
+
// The keyword must also sit where a plpgsql STATEMENT can start, not
|
|
614
|
+
// merely somewhere in the text: `GRANT EXECUTE ON FUNCTION f() TO app` is
|
|
615
|
+
// an ordinary permission grant and was being reported as unreadable
|
|
616
|
+
// dynamic SQL, which fails an ordinary migration under `migrate deploy`
|
|
617
|
+
// where there is no terminal to confirm at. The introducers below are the
|
|
618
|
+
// positions plpgsql actually allows one at, since `tokenizeSql` splits on
|
|
619
|
+
// top-level semicolons and a body's `IF ... THEN EXECUTE ...` therefore
|
|
620
|
+
// arrives as one statement.
|
|
621
|
+
if (!EXECUTE_AT_STATEMENT_START.test(statement.stripped))
|
|
622
|
+
continue;
|
|
623
|
+
const m = /\bEXECUTE\s+([\s\S]+)$/i.exec(statement.code);
|
|
624
|
+
if (!m)
|
|
625
|
+
continue;
|
|
626
|
+
const expr = m[1];
|
|
627
|
+
const { opener, plain } = executeOpener(expr);
|
|
628
|
+
if (plain)
|
|
629
|
+
continue;
|
|
630
|
+
// `EXECUTE s` where `s` is a bare variable: the statement it runs was
|
|
631
|
+
// assembled by some EARLIER sub-statement of the same block, so when a pass
|
|
632
|
+
// has already named one, the operator has been told what this body does and
|
|
633
|
+
// a second "cannot classify" entry for the same thing is noise that
|
|
634
|
+
// contradicts the first. When nothing was named, this is the whole finding
|
|
635
|
+
// and it stands: an assignment the rules could not read leaves
|
|
636
|
+
// `verbPassesReported` false.
|
|
637
|
+
if (opener === null && verbPassesReported && /^[A-Za-z_]\w*\s*;?$/.test(expr.trim()))
|
|
638
|
+
continue;
|
|
639
|
+
if (opener !== null) {
|
|
640
|
+
if (HARMLESS_OPENER.test(opener))
|
|
641
|
+
continue;
|
|
642
|
+
if (VERB_SILENT_WHEN_BENIGN.test(opener))
|
|
643
|
+
continue;
|
|
644
|
+
// The remaining skip is a claim that an earlier pass already reported
|
|
645
|
+
// this statement, so it holds only when one actually did. See
|
|
646
|
+
// VERB_ALWAYS_REPORTS for the shape where none does.
|
|
647
|
+
if (verbPassesReported && VERB_ALWAYS_REPORTS.test(opener))
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
out.push({ code: statement.code, expr });
|
|
651
|
+
}
|
|
652
|
+
return out;
|
|
653
|
+
}
|
|
392
654
|
/** Shown in place of an object name that does not exist until the block runs. */
|
|
393
655
|
export const DYNAMIC_TARGET = '<name assembled at run time>';
|
|
394
656
|
/**
|
|
@@ -453,16 +715,41 @@ const ASSEMBLY_FN = /\b(?:format|concat_ws|concat|quote_ident|quote_literal|quot
|
|
|
453
715
|
const WRAPPING_ASSEMBLY_FN = /\b(?:format|concat_ws|concat|quote_ident|quote_literal|quote_nullable|array_to_string|regexp_replace|replace)\s*\([^)]*$/i;
|
|
454
716
|
/** Dynamic SQL only runs if something runs it. Scoped to the candidate's own statement. */
|
|
455
717
|
const RUNS_DYNAMIC_SQL = /\bEXECUTE\b/i;
|
|
718
|
+
/**
|
|
719
|
+
* `ALTER TABLE <assembled name> DROP [COLUMN] ...`: the sub-action that loses
|
|
720
|
+
* rows, with the same exclusions as the static `drop-column` rule (`DROP
|
|
721
|
+
* CONSTRAINT` / `DEFAULT` / `NOT NULL` / `IDENTITY` / `EXPRESSION` lose none).
|
|
722
|
+
* Matches when the keyword `COLUMN`, an `IF EXISTS`, or a literal column name
|
|
723
|
+
* follows the `DROP`; a `DROP` followed by nothing literal is handled below.
|
|
724
|
+
* {@link OUTSIDE_QUOTES} keeps `ADD COLUMN "drop me"` out, as it does statically.
|
|
725
|
+
*/
|
|
726
|
+
const DYNAMIC_ALTER_DROP_COLUMN = new RegExp(String.raw `^ALTER\s+TABLE\b${OUTSIDE_QUOTES}\bDROP\s+(?!CONSTRAINT\b|DEFAULT\b|NOT\b|IDENTITY\b|EXPRESSION\b)(?:COLUMN\b|IF\s+EXISTS\b|${IDENT})`, 'i');
|
|
727
|
+
/** `ALTER TABLE <assembled name> ALTER [COLUMN] <col> [SET DATA] TYPE ...`. */
|
|
728
|
+
const DYNAMIC_ALTER_COLUMN_TYPE = new RegExp(String.raw `^ALTER\s+TABLE\b${OUTSIDE_QUOTES}\bALTER\s+(?:COLUMN\s+)?${IDENT}\s+(?:SET\s+DATA\s+)?TYPE\b`, 'i');
|
|
729
|
+
/** The `DROP <thing>` sub-actions of ALTER TABLE that lose no rows. */
|
|
730
|
+
const DYNAMIC_ALTER_DROP_HARMLESS = new RegExp(String.raw `^ALTER\s+TABLE\b${OUTSIDE_QUOTES}\bDROP\s+(?:CONSTRAINT|DEFAULT|NOT\s+NULL|IDENTITY|EXPRESSION)\b`, 'i');
|
|
456
731
|
/**
|
|
457
732
|
* The kind a runtime-assembled procedural fragment should be reported as, or
|
|
458
733
|
* `null` when it is not dynamic (so a rule already had its chance) or its verb
|
|
459
734
|
* is not one that destroys data on its own.
|
|
460
735
|
*
|
|
461
|
-
* `ALTER`
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
736
|
+
* `ALTER TABLE` IS decided here, by the sub-action that follows the assembled
|
|
737
|
+
* table name, because in the shape that matters that sub-action is literal
|
|
738
|
+
* text: a multi-tenant loop assembles the TABLE (`'ALTER TABLE ' ||
|
|
739
|
+
* quote_ident(t) || ' DROP COLUMN legacy_phone'`) and writes out what it does
|
|
740
|
+
* to it. That statement dropped the column from every tenant table while the
|
|
741
|
+
* scan reported a clean inventory, and its `DROP TABLE` twin was already being
|
|
742
|
+
* flagged, so an operator who had seen the guard fire once would assume this
|
|
743
|
+
* was covered. Only the forms that lose rows are reported (`DROP [COLUMN]`,
|
|
744
|
+
* `ALTER COLUMN ... TYPE`), with the same exclusions as their static rules; an
|
|
745
|
+
* assembled `ADD COLUMN` stays silent. A `DROP` whose object is itself in the
|
|
746
|
+
* runtime expression (`' DROP ' || what`) is `dynamic-destructive`, the bare
|
|
747
|
+
* `DROP` precedent below: alarming is fine, wrong is not.
|
|
748
|
+
*
|
|
749
|
+
* `UPDATE` is still deliberately absent even though {@link proceduralCandidates}
|
|
750
|
+
* collects it: its destructive form is the ABSENCE of a `WHERE`, and absence
|
|
751
|
+
* is not decidable from a fragment whose tail is a runtime expression, so
|
|
752
|
+
* including it would flag every dynamic `UPDATE ... WHERE` in the file.
|
|
466
753
|
*/
|
|
467
754
|
function dynamicDestructiveKind({ text, before }) {
|
|
468
755
|
const assembled = DYNAMIC_ASSEMBLY.test(text) ||
|
|
@@ -470,6 +757,17 @@ function dynamicDestructiveKind({ text, before }) {
|
|
|
470
757
|
(RUNS_DYNAMIC_SQL.test(before) && WRAPPING_ASSEMBLY_FN.test(before));
|
|
471
758
|
if (!assembled)
|
|
472
759
|
return null;
|
|
760
|
+
if (/^ALTER\s+TABLE\b/i.test(text)) {
|
|
761
|
+
if (DYNAMIC_ALTER_DROP_COLUMN.test(text))
|
|
762
|
+
return 'drop-column';
|
|
763
|
+
if (DYNAMIC_ALTER_COLUMN_TYPE.test(text))
|
|
764
|
+
return 'alter-column-type';
|
|
765
|
+
if (DYNAMIC_ALTER_DROP_HARMLESS.test(text))
|
|
766
|
+
return null;
|
|
767
|
+
if (new RegExp(String.raw `^ALTER\s+TABLE\b${OUTSIDE_QUOTES}\bDROP\b`, 'i').test(text))
|
|
768
|
+
return 'dynamic-destructive';
|
|
769
|
+
return null;
|
|
770
|
+
}
|
|
473
771
|
if (/^DROP\s+TABLE\b/i.test(text))
|
|
474
772
|
return 'drop-table';
|
|
475
773
|
if (/^DROP\s+SCHEMA\b/i.test(text))
|