turbine-orm 0.65.0 → 0.66.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 +34 -32
- package/dist/adapters/cockroachdb.js +21 -3
- package/dist/adapters/index.d.ts +15 -0
- package/dist/adapters/yugabytedb.js +20 -3
- package/dist/cjs/adapters/cockroachdb.js +21 -3
- package/dist/cjs/adapters/index.d.ts +15 -0
- package/dist/cjs/adapters/yugabytedb.js +20 -3
- package/dist/cjs/cli/destructive.d.ts +18 -4
- package/dist/cjs/cli/destructive.js +230 -122
- package/dist/cjs/cli/index.d.ts +21 -4
- package/dist/cjs/cli/index.js +119 -22
- package/dist/cjs/cli/mcp.d.ts +28 -8
- package/dist/cjs/cli/mcp.js +170 -127
- package/dist/cjs/cli/migrate.d.ts +134 -13
- package/dist/cjs/cli/migrate.js +349 -241
- package/dist/cjs/cli/pii-predicate-guard.d.ts +112 -0
- package/dist/cjs/cli/pii-predicate-guard.js +390 -0
- package/dist/cjs/cli/prisma-resolve.js +75 -4
- package/dist/cjs/cli/prisma-schema.d.ts +17 -1
- package/dist/cjs/cli/prisma-schema.js +83 -17
- package/dist/cjs/cli/sql-statements.d.ts +125 -0
- package/dist/cjs/cli/sql-statements.js +378 -0
- package/dist/cjs/cli/studio.js +49 -118
- package/dist/cjs/cli/ui.d.ts +1 -1
- package/dist/cjs/client.d.ts +43 -0
- package/dist/cjs/client.js +125 -6
- package/dist/cjs/dialect.d.ts +123 -0
- package/dist/cjs/dialect.js +33 -0
- package/dist/cjs/errors.d.ts +74 -1
- package/dist/cjs/errors.js +239 -25
- package/dist/cjs/index-advisor.d.ts +33 -1
- package/dist/cjs/index-advisor.js +32 -1
- package/dist/cjs/introspect.d.ts +48 -0
- package/dist/cjs/introspect.js +222 -91
- package/dist/cjs/mssql.js +43 -1
- package/dist/cjs/mysql.d.ts +5 -2
- package/dist/cjs/mysql.js +202 -17
- package/dist/cjs/nested-write.js +6 -1
- package/dist/cjs/pipeline-submittable.js +17 -3
- package/dist/cjs/pipeline.js +75 -9
- package/dist/cjs/powdb.d.ts +23 -0
- package/dist/cjs/powdb.js +33 -1
- package/dist/cjs/powql.d.ts +61 -9
- package/dist/cjs/powql.js +186 -49
- package/dist/cjs/prisma-compat.js +160 -41
- package/dist/cjs/query/aggregates.d.ts +1 -1
- package/dist/cjs/query/aggregates.js +80 -18
- package/dist/cjs/query/batched-loader.d.ts +10 -0
- package/dist/cjs/query/batched-loader.js +268 -7
- package/dist/cjs/query/builder.d.ts +73 -0
- package/dist/cjs/query/builder.js +225 -28
- package/dist/cjs/query/filters.d.ts +162 -0
- package/dist/cjs/query/filters.js +250 -1
- package/dist/cjs/query/relations.d.ts +10 -10
- package/dist/cjs/query/relations.js +93 -12
- package/dist/cjs/query/types.d.ts +14 -1
- package/dist/cjs/query/utils.d.ts +146 -2
- package/dist/cjs/query/utils.js +210 -4
- package/dist/cjs/query/warn-registry.d.ts +10 -0
- package/dist/cjs/query/warn-registry.js +10 -0
- package/dist/cjs/query/where-compile.d.ts +30 -0
- package/dist/cjs/query/where-compile.js +41 -0
- package/dist/cjs/query/where.d.ts +128 -13
- package/dist/cjs/query/where.js +215 -77
- package/dist/cjs/query/writes.d.ts +1 -1
- package/dist/cjs/query/writes.js +39 -15
- package/dist/cjs/schema-builder.d.ts +2 -1
- package/dist/cjs/schema-sql.d.ts +94 -4
- package/dist/cjs/schema-sql.js +506 -30
- package/dist/cjs/schema.d.ts +3 -1
- package/dist/cjs/sqlite.d.ts +6 -0
- package/dist/cjs/sqlite.js +151 -10
- package/dist/cjs/typed-sql.d.ts +29 -1
- package/dist/cjs/typed-sql.js +30 -12
- package/dist/cli/destructive.d.ts +18 -4
- package/dist/cli/destructive.js +229 -121
- package/dist/cli/index.d.ts +21 -4
- package/dist/cli/index.js +120 -24
- package/dist/cli/mcp.d.ts +28 -8
- package/dist/cli/mcp.js +172 -129
- package/dist/cli/migrate.d.ts +134 -13
- package/dist/cli/migrate.js +347 -238
- package/dist/cli/pii-predicate-guard.d.ts +112 -0
- package/dist/cli/pii-predicate-guard.js +386 -0
- package/dist/cli/prisma-resolve.js +75 -4
- package/dist/cli/prisma-schema.d.ts +17 -1
- package/dist/cli/prisma-schema.js +83 -17
- package/dist/cli/sql-statements.d.ts +125 -0
- package/dist/cli/sql-statements.js +373 -0
- package/dist/cli/studio.js +49 -118
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +43 -0
- package/dist/client.js +126 -7
- package/dist/dialect.d.ts +123 -0
- package/dist/dialect.js +33 -0
- package/dist/errors.d.ts +74 -1
- package/dist/errors.js +228 -19
- package/dist/index-advisor.d.ts +33 -1
- package/dist/index-advisor.js +31 -1
- package/dist/introspect.d.ts +48 -0
- package/dist/introspect.js +221 -91
- package/dist/mssql.js +44 -2
- package/dist/mysql.d.ts +5 -2
- package/dist/mysql.js +203 -18
- package/dist/nested-write.js +7 -2
- package/dist/pipeline-submittable.js +18 -4
- package/dist/pipeline.js +76 -10
- package/dist/powdb.d.ts +23 -0
- package/dist/powdb.js +33 -2
- package/dist/powql.d.ts +61 -9
- package/dist/powql.js +187 -50
- package/dist/prisma-compat.js +160 -41
- package/dist/query/aggregates.d.ts +1 -1
- package/dist/query/aggregates.js +82 -20
- package/dist/query/batched-loader.d.ts +10 -0
- package/dist/query/batched-loader.js +270 -9
- package/dist/query/builder.d.ts +73 -0
- package/dist/query/builder.js +226 -30
- package/dist/query/filters.d.ts +162 -0
- package/dist/query/filters.js +246 -1
- package/dist/query/relations.d.ts +10 -10
- package/dist/query/relations.js +94 -14
- package/dist/query/types.d.ts +14 -1
- package/dist/query/utils.d.ts +146 -2
- package/dist/query/utils.js +204 -3
- package/dist/query/warn-registry.d.ts +10 -0
- package/dist/query/warn-registry.js +10 -0
- package/dist/query/where-compile.d.ts +30 -0
- package/dist/query/where-compile.js +40 -1
- package/dist/query/where.d.ts +128 -13
- package/dist/query/where.js +216 -80
- package/dist/query/writes.d.ts +1 -1
- package/dist/query/writes.js +40 -16
- package/dist/schema-builder.d.ts +2 -1
- package/dist/schema-sql.d.ts +94 -4
- package/dist/schema-sql.js +505 -30
- package/dist/schema.d.ts +3 -1
- package/dist/sqlite.d.ts +6 -0
- package/dist/sqlite.js +151 -10
- package/dist/typed-sql.d.ts +29 -1
- package/dist/typed-sql.js +30 -12
- package/package.json +6 -4
package/dist/cli/destructive.js
CHANGED
|
@@ -18,9 +18,17 @@
|
|
|
18
18
|
* Row removal hides in more than a leading `DELETE`, so the scan also covers:
|
|
19
19
|
* the optional-`COLUMN` shorthand (`ALTER TABLE t DROP email`), data-modifying
|
|
20
20
|
* CTEs (`WITH d AS (DELETE ...) SELECT ...`), `MERGE ... THEN DELETE`, dynamic
|
|
21
|
-
* SQL inside a `DO`/function body,
|
|
22
|
-
*
|
|
21
|
+
* SQL inside a `DO`/function body, an `UPDATE` whose only WHERE sits inside a
|
|
22
|
+
* subquery (which restricts nothing), a `DROP ... CASCADE` of a non-table
|
|
23
|
+
* object (which takes dependent COLUMNS with it), a detached partition, and an
|
|
24
|
+
* `EXPLAIN ANALYZE` of any of the above (which really executes it).
|
|
25
|
+
*
|
|
26
|
+
* The lexing, "where does a statement end" and "which characters are code",
|
|
27
|
+
* is NOT done here: it lives in `sql-statements.ts` and is shared with the
|
|
28
|
+
* migration runner. Two lexers is how this guard came to disagree with the
|
|
29
|
+
* executor about what a file contained (see that module's header).
|
|
23
30
|
*/
|
|
31
|
+
import { tokenizeSql } from './sql-statements.js';
|
|
24
32
|
/** Human explanation per kind, used in CLI output. */
|
|
25
33
|
export const DESTRUCTIVE_KIND_LABEL = {
|
|
26
34
|
'drop-table': 'drops a table and ALL its rows',
|
|
@@ -29,106 +37,36 @@ export const DESTRUCTIVE_KIND_LABEL = {
|
|
|
29
37
|
'drop-owned': 'drops every object owned by a role, and their rows',
|
|
30
38
|
'drop-matview': 'drops a materialized view and its stored rows',
|
|
31
39
|
'drop-column': 'drops a column and its data in every row',
|
|
40
|
+
'drop-cascade': 'drops an object AND every dependent object, columns and their data included',
|
|
41
|
+
'detach-partition': 'detaches a partition, every row in it leaves the table',
|
|
32
42
|
truncate: 'deletes every row',
|
|
33
43
|
delete: 'deletes rows',
|
|
34
44
|
'update-without-where': 'rewrites every row (no WHERE clause)',
|
|
35
45
|
'alter-column-type': 'rewrites a column type (cast may truncate or fail)',
|
|
36
46
|
'merge-delete': 'deletes matched rows (MERGE ... THEN DELETE)',
|
|
47
|
+
rename: 'renames a table or column, every query and view referencing the old name breaks',
|
|
48
|
+
'dynamic-destructive': 'runs destructive SQL assembled at run time, what it destroys cannot be known without running it',
|
|
37
49
|
};
|
|
50
|
+
/** Unquote a "quoted" identifier for display. */
|
|
51
|
+
const ident = (raw) => (raw ?? '?').replace(/^"|"$/g, '');
|
|
52
|
+
const IDENT = String.raw `("[^"]+"|[a-zA-Z_][\w$]*)(\.("[^"]+"|[a-zA-Z_][\w$]*))?`;
|
|
38
53
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* misses everything inside it. Same shape as the splitter in `migrate.ts`.
|
|
54
|
+
* Render one `IDENT` capture triple (name, `.qualifier`, qualifier) as text,
|
|
55
|
+
* where `base` is the 1-based index of the triple's first group.
|
|
42
56
|
*/
|
|
43
|
-
const
|
|
44
|
-
/** Strip -- line comments, C-style block comments, and quoted literals. */
|
|
45
|
-
function stripCommentsAndStrings(sql) {
|
|
46
|
-
const blocks = [];
|
|
47
|
-
let out = '';
|
|
48
|
-
let i = 0;
|
|
49
|
-
while (i < sql.length) {
|
|
50
|
-
const two = sql.slice(i, i + 2);
|
|
51
|
-
if (two === '--') {
|
|
52
|
-
const nl = sql.indexOf('\n', i);
|
|
53
|
-
i = nl === -1 ? sql.length : nl; // keep the newline
|
|
54
|
-
}
|
|
55
|
-
else if (two === '/*') {
|
|
56
|
-
const end = sql.indexOf('*/', i + 2);
|
|
57
|
-
i = end === -1 ? sql.length : end + 2;
|
|
58
|
-
out += ' ';
|
|
59
|
-
}
|
|
60
|
-
else if (sql[i] === "'") {
|
|
61
|
-
// Single-quoted literal. `''` always escapes a quote; inside an E-string
|
|
62
|
-
// (`E'...'`) a backslash escapes the next character too, so `E'a\'b'` is
|
|
63
|
-
// ONE literal. Without the E-string case the scan ends the literal at the
|
|
64
|
-
// backslash-quote and treats the rest of the file as code, which
|
|
65
|
-
// (worse) then hides every following statement from the guard.
|
|
66
|
-
const escapes = isEscapeStringPrefix(sql, i);
|
|
67
|
-
let j = i + 1;
|
|
68
|
-
while (j < sql.length) {
|
|
69
|
-
if (escapes && sql[j] === '\\')
|
|
70
|
-
j += 2;
|
|
71
|
-
else if (sql[j] === "'" && sql[j + 1] === "'")
|
|
72
|
-
j += 2;
|
|
73
|
-
else if (sql[j] === "'")
|
|
74
|
-
break;
|
|
75
|
-
else
|
|
76
|
-
j++;
|
|
77
|
-
}
|
|
78
|
-
i = j + 1;
|
|
79
|
-
out += "''";
|
|
80
|
-
}
|
|
81
|
-
else if (sql[i] === '"') {
|
|
82
|
-
// Quoted identifier. Kept VERBATIM (rules match on identifiers), but it
|
|
83
|
-
// has to be consumed as one token: an apostrophe inside a quoted name
|
|
84
|
-
// (`"customer's_orders"`) would otherwise open a string literal and hide
|
|
85
|
-
// every statement after it from the scan.
|
|
86
|
-
let j = i + 1;
|
|
87
|
-
while (j < sql.length) {
|
|
88
|
-
if (sql[j] === '"' && sql[j + 1] === '"')
|
|
89
|
-
j += 2;
|
|
90
|
-
else if (sql[j] === '"')
|
|
91
|
-
break;
|
|
92
|
-
else
|
|
93
|
-
j++;
|
|
94
|
-
}
|
|
95
|
-
out += sql.slice(i, Math.min(j + 1, sql.length));
|
|
96
|
-
i = j + 1;
|
|
97
|
-
}
|
|
98
|
-
else if (sql[i] === '$' && DOLLAR_TAG.test(sql.slice(i))) {
|
|
99
|
-
// dollar-quoted literal ($$...$$ / $tag$...$tag$)
|
|
100
|
-
const tag = sql.slice(i).match(DOLLAR_TAG)?.[0] ?? '$$';
|
|
101
|
-
const end = sql.indexOf(tag, i + tag.length);
|
|
102
|
-
blocks.push({ at: out.length, body: sql.slice(i + tag.length, end === -1 ? sql.length : end) });
|
|
103
|
-
i = end === -1 ? sql.length : end + tag.length;
|
|
104
|
-
out += "''";
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
107
|
-
out += sql[i];
|
|
108
|
-
i++;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
return { text: out, blocks };
|
|
112
|
-
}
|
|
57
|
+
const qualified = (m, base) => m[base + 2] ? `${ident(m[base])}.${ident(m[base + 2])}` : ident(m[base]);
|
|
113
58
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
59
|
+
* Filler between an `ALTER TABLE <name>` head and the sub-action keyword that
|
|
60
|
+
* follows it, for the multi-action forms.
|
|
61
|
+
*
|
|
62
|
+
* NOT `[\s\S]*?`: that lets the lazy walk stop INSIDE a quoted identifier,
|
|
63
|
+
* because a quoted name is kept verbatim in the stripped text. `ALTER TABLE t
|
|
64
|
+
* ADD COLUMN "drop me" int` was flagged `drop-column` on `t.me`, a pure false
|
|
65
|
+
* positive on a statement that adds data and removes none. Consuming either a
|
|
66
|
+
* WHOLE quoted identifier or a single non-quote character means the scan can
|
|
67
|
+
* never begin a keyword match part-way through a quoted name.
|
|
121
68
|
*/
|
|
122
|
-
|
|
123
|
-
const prev = sql[quoteAt - 1];
|
|
124
|
-
if (prev !== 'E' && prev !== 'e')
|
|
125
|
-
return false;
|
|
126
|
-
const before = sql[quoteAt - 2];
|
|
127
|
-
return before === undefined || !/[A-Za-z0-9_$"]/.test(before);
|
|
128
|
-
}
|
|
129
|
-
/** Unquote a "quoted" identifier for display. */
|
|
130
|
-
const ident = (raw) => (raw ?? '?').replace(/^"|"$/g, '');
|
|
131
|
-
const IDENT = String.raw `("[^"]+"|[a-zA-Z_][\w$]*)(\.("[^"]+"|[a-zA-Z_][\w$]*))?`;
|
|
69
|
+
const OUTSIDE_QUOTES = '(?:"[^"]*"|[^"])*?';
|
|
132
70
|
/** Ordered rules, first match per statement wins. */
|
|
133
71
|
const RULES = [
|
|
134
72
|
{
|
|
@@ -157,6 +95,17 @@ const RULES = [
|
|
|
157
95
|
regex: new RegExp(String.raw `^DROP\s+OWNED\s+BY\s+${IDENT}`, 'i'),
|
|
158
96
|
target: (m) => ident(m[1]),
|
|
159
97
|
},
|
|
98
|
+
{
|
|
99
|
+
// `DROP <object> ... CASCADE` on anything OTHER than a table. Without
|
|
100
|
+
// CASCADE these are safe: Postgres refuses the drop while a dependency
|
|
101
|
+
// exists. With it, the dependents go too, and a dependent of a TYPE or a
|
|
102
|
+
// DOMAIN is typically a COLUMN, so `DROP TYPE order_status CASCADE`
|
|
103
|
+
// silently removes `orders.status` and every value in it. The presence of
|
|
104
|
+
// CASCADE is therefore the whole rule, not an extra detail.
|
|
105
|
+
kind: 'drop-cascade',
|
|
106
|
+
regex: new RegExp(String.raw `^DROP\s+(TYPE|DOMAIN|EXTENSION|SEQUENCE|FUNCTION|PROCEDURE|ROUTINE|AGGREGATE)\s+(IF\s+EXISTS\s+)?${IDENT}${OUTSIDE_QUOTES}\bCASCADE\b`, 'i'),
|
|
107
|
+
target: (m) => `${m[1].toUpperCase()} ${qualified(m, 3)}`,
|
|
108
|
+
},
|
|
160
109
|
{
|
|
161
110
|
kind: 'truncate',
|
|
162
111
|
regex: new RegExp(String.raw `^TRUNCATE\s+(TABLE\s+)?(ONLY\s+)?${IDENT}`, 'i'),
|
|
@@ -167,12 +116,19 @@ const RULES = [
|
|
|
167
116
|
// column and its data exactly like the spelled-out form. The lookahead
|
|
168
117
|
// excludes the other `DROP <thing>` sub-actions, none of which lose rows.
|
|
169
118
|
kind: 'drop-column',
|
|
170
|
-
regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}
|
|
119
|
+
regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}${OUTSIDE_QUOTES}\bDROP\s+(?!CONSTRAINT\b|DEFAULT\b|NOT\b|IDENTITY\b|EXPRESSION\b)(COLUMN\s+)?(IF\s+EXISTS\s+)?${IDENT}`, 'i'),
|
|
171
120
|
target: (m) => `${ident(m[3])}.${ident(m[8])}`,
|
|
172
121
|
},
|
|
122
|
+
{
|
|
123
|
+
// A detached partition keeps its rows, but they leave the parent table:
|
|
124
|
+
// every query against the parent stops seeing them the moment this runs.
|
|
125
|
+
kind: 'detach-partition',
|
|
126
|
+
regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}\s+DETACH\s+PARTITION\s+${IDENT}`, 'i'),
|
|
127
|
+
target: (m) => `${qualified(m, 3)}.${qualified(m, 6)}`,
|
|
128
|
+
},
|
|
173
129
|
{
|
|
174
130
|
kind: 'alter-column-type',
|
|
175
|
-
regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}
|
|
131
|
+
regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}${OUTSIDE_QUOTES}\bALTER\s+(COLUMN\s+)?${IDENT}\s+(SET\s+DATA\s+)?TYPE\b`, 'i'),
|
|
176
132
|
target: (m) => `${ident(m[3])}.${ident(m[7])}`,
|
|
177
133
|
},
|
|
178
134
|
{
|
|
@@ -183,7 +139,7 @@ const RULES = [
|
|
|
183
139
|
{
|
|
184
140
|
// MERGE's DELETE action removes rows from the target table.
|
|
185
141
|
kind: 'merge-delete',
|
|
186
|
-
regex: new RegExp(String.raw `^MERGE\s+INTO\s+(ONLY\s+)?${IDENT}\b
|
|
142
|
+
regex: new RegExp(String.raw `^MERGE\s+INTO\s+(ONLY\s+)?${IDENT}\b${OUTSIDE_QUOTES}\bTHEN\s+DELETE\b`, 'i'),
|
|
187
143
|
target: (m) => (m[4] ? `${ident(m[2])}.${ident(m[4])}` : ident(m[2])),
|
|
188
144
|
},
|
|
189
145
|
{
|
|
@@ -194,6 +150,25 @@ const RULES = [
|
|
|
194
150
|
target: (m) => (m[4] ? `${ident(m[2])}.${ident(m[4])}` : ident(m[2])),
|
|
195
151
|
also: (stmt) => !hasTopLevelWhere(stmt),
|
|
196
152
|
},
|
|
153
|
+
// Renames come LAST: they destroy no data, so any statement that is BOTH a
|
|
154
|
+
// rename and a data-loss operation should report the data loss instead.
|
|
155
|
+
{
|
|
156
|
+
// `ALTER TABLE t RENAME TO u`. Nothing is lost, but every query, view,
|
|
157
|
+
// function, and application reference to `t` breaks the moment it lands,
|
|
158
|
+
// and it is the second half of the sanctioned backfill swap, so an operator
|
|
159
|
+
// confirming a migration deserves to see it in the inventory.
|
|
160
|
+
kind: 'rename',
|
|
161
|
+
regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}\s+RENAME\s+TO\s+${IDENT}`, 'i'),
|
|
162
|
+
target: (m) => qualified(m, 3),
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
// `ALTER TABLE t RENAME [COLUMN] old TO new`. The lookahead keeps the
|
|
166
|
+
// table-rename form (handled above) and `RENAME CONSTRAINT` (which breaks
|
|
167
|
+
// nothing a query can name) out of this rule.
|
|
168
|
+
kind: 'rename',
|
|
169
|
+
regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}\s+RENAME\s+(COLUMN\s+)?(?!TO\b|CONSTRAINT\b)${IDENT}\s+TO\s+${IDENT}`, 'i'),
|
|
170
|
+
target: (m) => `${qualified(m, 3)}.${qualified(m, 7)}`,
|
|
171
|
+
},
|
|
197
172
|
];
|
|
198
173
|
/** True when the statement has a `WHERE` outside every parenthesized group. */
|
|
199
174
|
function hasTopLevelWhere(stmt) {
|
|
@@ -280,25 +255,79 @@ function closingParenIndex(stmt, openAt) {
|
|
|
280
255
|
}
|
|
281
256
|
return stmt.length;
|
|
282
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* An `EXPLAIN` prefix, either the parenthesized option list or the bare keyword
|
|
260
|
+
* form. Used to answer one question: does this EXPLAIN actually RUN the
|
|
261
|
+
* statement it wraps?
|
|
262
|
+
*/
|
|
263
|
+
const EXPLAIN_PREFIX = /^EXPLAIN\s*(?:\(([^)]*)\)|((?:(?:ANALYZE|ANALYSE|VERBOSE)\s+)*))/i;
|
|
264
|
+
/**
|
|
265
|
+
* `EXPLAIN ANALYZE <DML>` EXECUTES the statement, it does not merely plan it.
|
|
266
|
+
* `EXPLAIN ANALYZE DELETE FROM t` empties `t`, and the guard used to see a
|
|
267
|
+
* statement whose head was `EXPLAIN` and match no rule at all. Returned is the
|
|
268
|
+
* inner statement when the EXPLAIN executes, otherwise the statement unchanged
|
|
269
|
+
* (a plain `EXPLAIN DELETE ...` only plans, and must NOT be flagged).
|
|
270
|
+
*
|
|
271
|
+
* `ANALYZE false` / `ANALYZE off` is treated as executing even though it does
|
|
272
|
+
* not: this module's asymmetry is that a false positive costs a confirmation
|
|
273
|
+
* prompt while a false negative costs data, and spelling out boolean option
|
|
274
|
+
* values here would trade the cheap error for the expensive one.
|
|
275
|
+
*/
|
|
276
|
+
function stripExecutingExplain(stmt) {
|
|
277
|
+
const m = EXPLAIN_PREFIX.exec(stmt);
|
|
278
|
+
if (!m)
|
|
279
|
+
return stmt;
|
|
280
|
+
const options = m[1] ?? m[2] ?? '';
|
|
281
|
+
if (!/\bANALY[SZ]E\b/i.test(options))
|
|
282
|
+
return stmt;
|
|
283
|
+
return stmt.slice(m[0].length).trimStart();
|
|
284
|
+
}
|
|
283
285
|
/** Statements whose dollar-quoted body is executable SQL rather than data. */
|
|
284
286
|
const PROCEDURAL_STATEMENT = /^(DO\b|CREATE\s+(OR\s+REPLACE\s+)?(FUNCTION|PROCEDURE)\b)/i;
|
|
285
287
|
/**
|
|
286
|
-
* Candidate fragments inside a procedural body (a `DO $$ ... $$` block
|
|
287
|
-
* function source). The body's own string
|
|
288
|
-
* whole point is dynamic SQL, whose payload
|
|
289
|
-
* (`EXECUTE 'DROP TABLE users'`). Rules are anchored, so
|
|
290
|
-
* position in the body is offered as its own candidate.
|
|
291
|
-
* over-reports (a body that merely mentions "drop table" in a
|
|
292
|
-
* flagged) in keeping with the module's
|
|
288
|
+
* Candidate fragments inside a procedural body (a `DO $$ ... $$` block, a
|
|
289
|
+
* function source, or a PG14+ `BEGIN ATOMIC` body). The body's own string
|
|
290
|
+
* literals are NOT stripped: the whole point is dynamic SQL, whose payload
|
|
291
|
+
* lives in a literal (`EXECUTE 'DROP TABLE users'`). Rules are anchored, so
|
|
292
|
+
* every keyword-leading position in the body is offered as its own candidate.
|
|
293
|
+
* This deliberately over-reports (a body that merely mentions "drop table" in a
|
|
294
|
+
* message string is flagged) in keeping with the module's
|
|
295
|
+
* false-positives-only asymmetry.
|
|
296
|
+
*
|
|
297
|
+
* Comments come out via the SHARED tokenizer, never a regex. The pair that used
|
|
298
|
+
* to do it here, `/\/\*[\s\S]*?\*\//g` and `/--[^\n]*\/g`, was the exact
|
|
299
|
+
* hand-written lexer the tokenizer was written to delete, still in place one
|
|
300
|
+
* level down and on the path that exists specifically to catch dynamic SQL.
|
|
301
|
+
* Neither pattern nests and neither respects string literals, so ONE earlier
|
|
302
|
+
* literal containing `--` or an unclosed `/*` blanked every destructive
|
|
303
|
+
* statement after it. Both of these reported an empty inventory and dropped the
|
|
304
|
+
* table on PostgreSQL 16.14:
|
|
305
|
+
*
|
|
306
|
+
* DO $$ DECLARE s text := 'x --'; BEGIN EXECUTE 'DROP TABLE users'; END $$;
|
|
307
|
+
* DO $$ DECLARE s text := 'a /*'; BEGIN EXECUTE 'DROP TABLE users';
|
|
308
|
+
* RAISE NOTICE '% b *\/', s; END $$;
|
|
309
|
+
*
|
|
310
|
+
* The `--` shape is the worse of the two because it is reachable by ACCIDENT:
|
|
311
|
+
* any single-line body whose earlier literal holds a `--` (a date range, a
|
|
312
|
+
* separator, a placeholder) hides everything that follows it.
|
|
313
|
+
*
|
|
314
|
+
* Tokenizing also bounds each candidate at its own statement instead of at the
|
|
315
|
+
* end of the body, so a later `WHERE` can no longer talk the `update-without-
|
|
316
|
+
* where` rule out of an earlier unrestricted UPDATE.
|
|
293
317
|
*/
|
|
294
318
|
function proceduralCandidates(body) {
|
|
295
|
-
const withoutComments = body.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/--[^\n]*/g, ' ');
|
|
296
319
|
const out = [];
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
320
|
+
for (const statement of tokenizeSql(body)) {
|
|
321
|
+
// `code` is the statement with comments gone and everything else verbatim.
|
|
322
|
+
// Literals have to survive: they are where dynamic SQL keeps its payload,
|
|
323
|
+
// and `stripped` would have emptied exactly them.
|
|
324
|
+
const text = statement.code;
|
|
325
|
+
const re = /\b(?:DROP|TRUNCATE|DELETE|ALTER|UPDATE|MERGE)\s/gi;
|
|
326
|
+
let m = re.exec(text);
|
|
327
|
+
while (m !== null) {
|
|
328
|
+
out.push(text.slice(m.index));
|
|
329
|
+
m = re.exec(text);
|
|
330
|
+
}
|
|
302
331
|
}
|
|
303
332
|
return out;
|
|
304
333
|
}
|
|
@@ -308,43 +337,122 @@ function proceduralCandidates(body) {
|
|
|
308
337
|
*/
|
|
309
338
|
export function scanDestructiveSql(sql) {
|
|
310
339
|
const found = [];
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
const end = offset + rawStmt.length;
|
|
316
|
-
offset = end + 1; // the ';' consumed by split
|
|
317
|
-
const stmt = rawStmt.trim();
|
|
340
|
+
for (const statement of tokenizeSql(sql)) {
|
|
341
|
+
if (statement.commentOnly)
|
|
342
|
+
continue;
|
|
343
|
+
const stmt = statement.stripped;
|
|
318
344
|
if (!stmt)
|
|
319
345
|
continue;
|
|
346
|
+
// `EXPLAIN ANALYZE` is a wrapper that RUNS its argument, so the rules are
|
|
347
|
+
// matched against what it wraps. `display` keeps the EXPLAIN, so the
|
|
348
|
+
// inventory shows the operator the statement they actually wrote.
|
|
349
|
+
const body = stripExecutingExplain(stmt);
|
|
320
350
|
// Top level, then data-modifying CTEs, then any procedural body this
|
|
321
351
|
// statement blanked. First match per statement wins, as before.
|
|
322
352
|
const display = stmt.replace(/\s+/g, ' ');
|
|
323
353
|
const candidates = [
|
|
324
|
-
stripLeadingCtes(
|
|
325
|
-
...cteSubstatements(
|
|
354
|
+
stripLeadingCtes(body),
|
|
355
|
+
...cteSubstatements(body),
|
|
326
356
|
].map((text) => ({
|
|
327
357
|
text,
|
|
328
358
|
display,
|
|
329
359
|
}));
|
|
330
360
|
// Only a DO block / routine body is procedural SQL. A dollar-quoted literal
|
|
331
361
|
// used as DATA (`INSERT ... VALUES ($$DELETE FROM x$$)`) stays a literal.
|
|
362
|
+
// The tokenizer hands back each statement's OWN blocks, so which body
|
|
363
|
+
// belongs to which statement is no longer an offset calculation that can
|
|
364
|
+
// disagree with the statement split.
|
|
332
365
|
const procedural = PROCEDURAL_STATEMENT.test(stmt);
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
for (const text of proceduralCandidates(block.body)) {
|
|
366
|
+
const proceduralTexts = [];
|
|
367
|
+
for (const block of procedural ? statement.blocks : []) {
|
|
368
|
+
for (const text of proceduralCandidates(block)) {
|
|
337
369
|
// The body was blanked in `display`, so name the fragment that matched.
|
|
338
370
|
candidates.push({ text, display: `${display} [in block: ${text.replace(/\s+/g, ' ').slice(0, 60)}]` });
|
|
371
|
+
proceduralTexts.push(text);
|
|
339
372
|
}
|
|
340
373
|
}
|
|
374
|
+
let matched = false;
|
|
341
375
|
for (const candidate of candidates) {
|
|
342
376
|
const hit = matchRules(candidate.text);
|
|
343
377
|
if (!hit)
|
|
344
378
|
continue;
|
|
345
379
|
found.push({ statement: candidate.display, kind: hit.kind, target: hit.target });
|
|
380
|
+
matched = true;
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
if (matched)
|
|
384
|
+
continue;
|
|
385
|
+
// Nothing matched a rule. Inside a PROCEDURAL body that is not the end of
|
|
386
|
+
// the question, because the rules all need a parseable object name and
|
|
387
|
+
// dynamic SQL does not have one until it runs. All three of these execute a
|
|
388
|
+
// real `DROP TABLE` on PostgreSQL 16 and reported NOTHING:
|
|
389
|
+
//
|
|
390
|
+
// DO $$ BEGIN EXECUTE format('DROP TABLE %I', 'users'); END $$;
|
|
391
|
+
// DO $$ BEGIN EXECUTE 'DROP ' || 'TABLE users'; END $$;
|
|
392
|
+
// DO $$ BEGIN EXECUTE 'DROP TABLE ' || quote_ident('users'); END $$;
|
|
393
|
+
//
|
|
394
|
+
// A clean inventory for a file that drops a table is the exact failure this
|
|
395
|
+
// module exists to prevent: the operator confirms what they were shown, and
|
|
396
|
+
// the unlisted statement runs under that confirmation. So report it with an
|
|
397
|
+
// explicit unknown target and let them decide.
|
|
398
|
+
//
|
|
399
|
+
// Gated on evidence of RUNTIME ASSEMBLY rather than on the verb alone, and
|
|
400
|
+
// that gate is complete rather than heuristic: a destructive statement whose
|
|
401
|
+
// object name is written out literally already matches a rule above, so the
|
|
402
|
+
// only way to be dynamic is to concatenate or format. Without the gate,
|
|
403
|
+
// `RAISE NOTICE 'DROP the mic'` would prompt, and a guard that fires on
|
|
404
|
+
// prose teaches operators to confirm without reading, which costs more than
|
|
405
|
+
// it saves.
|
|
406
|
+
for (const text of proceduralTexts) {
|
|
407
|
+
const kind = dynamicDestructiveKind(text);
|
|
408
|
+
if (!kind)
|
|
409
|
+
continue;
|
|
410
|
+
found.push({
|
|
411
|
+
statement: `${display} [in block: ${text.replace(/\s+/g, ' ').slice(0, 60)}]`,
|
|
412
|
+
kind,
|
|
413
|
+
target: DYNAMIC_TARGET,
|
|
414
|
+
});
|
|
346
415
|
break;
|
|
347
416
|
}
|
|
348
417
|
}
|
|
349
418
|
return found;
|
|
350
419
|
}
|
|
420
|
+
/** Shown in place of an object name that does not exist until the block runs. */
|
|
421
|
+
export const DYNAMIC_TARGET = '<name assembled at run time>';
|
|
422
|
+
/** `||`, `format(...)`, or a `quote_*` helper: the ways a body builds SQL. */
|
|
423
|
+
const DYNAMIC_ASSEMBLY = /\|\||\bformat\s*\(|\bquote_(?:ident|literal|nullable)\s*\(|%[IsL]/;
|
|
424
|
+
/**
|
|
425
|
+
* The kind a runtime-assembled procedural fragment should be reported as, or
|
|
426
|
+
* `null` when it is not dynamic (so a rule already had its chance) or its verb
|
|
427
|
+
* is not one that destroys data on its own.
|
|
428
|
+
*
|
|
429
|
+
* `ALTER` and `UPDATE` are deliberately absent even though
|
|
430
|
+
* {@link proceduralCandidates} collects them: their destructive forms are
|
|
431
|
+
* narrow (`ALTER COLUMN ... TYPE`, an `UPDATE` with no `WHERE`) and neither is
|
|
432
|
+
* decidable from a fragment whose tail is a runtime expression, so including
|
|
433
|
+
* them would flag every dynamic `UPDATE ... WHERE` in the file.
|
|
434
|
+
*/
|
|
435
|
+
function dynamicDestructiveKind(text) {
|
|
436
|
+
if (!DYNAMIC_ASSEMBLY.test(text))
|
|
437
|
+
return null;
|
|
438
|
+
if (/^DROP\s+TABLE\b/i.test(text))
|
|
439
|
+
return 'drop-table';
|
|
440
|
+
if (/^DROP\s+SCHEMA\b/i.test(text))
|
|
441
|
+
return 'drop-schema';
|
|
442
|
+
if (/^DROP\s+DATABASE\b/i.test(text))
|
|
443
|
+
return 'drop-database';
|
|
444
|
+
if (/^DROP\s+MATERIALIZED\s+VIEW\b/i.test(text))
|
|
445
|
+
return 'drop-matview';
|
|
446
|
+
// A bare `DROP` whose object keyword is itself part of the runtime expression
|
|
447
|
+
// (`'DROP ' || 'TABLE users'`). Deliberately NOT reported as `drop-cascade`:
|
|
448
|
+
// that label claims dependent objects go too, which would be a factual claim
|
|
449
|
+
// about the operator's migration that we cannot support. Being alarming is
|
|
450
|
+
// fine here; being wrong is not.
|
|
451
|
+
if (/^DROP\b/i.test(text))
|
|
452
|
+
return 'dynamic-destructive';
|
|
453
|
+
if (/^TRUNCATE\b/i.test(text))
|
|
454
|
+
return 'truncate';
|
|
455
|
+
if (/^DELETE\b/i.test(text))
|
|
456
|
+
return 'delete';
|
|
457
|
+
return null;
|
|
458
|
+
}
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -393,10 +393,16 @@ export declare function initEnvNotice(input: {
|
|
|
393
393
|
* database (unless `--no-db`), and emit (a) a Markdown resolution report and
|
|
394
394
|
* (b) a typed `prisma-map.ts` name map next to the generated client.
|
|
395
395
|
*
|
|
396
|
-
* NOTE: within THIS command `--schema` names the Prisma schema FILE
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
396
|
+
* NOTE: within THIS command `--schema` names the Prisma schema FILE, or the
|
|
397
|
+
* DIRECTORY of a multi-file schema (Prisma >= 5.15), not the Postgres namespace
|
|
398
|
+
* that the rest of the CLI's `--schema` means. The Postgres namespace is
|
|
399
|
+
* `public` here.
|
|
400
|
+
*
|
|
401
|
+
* Multi-schema (`@@schema`) is unsupported: the parser emits a warning naming
|
|
402
|
+
* the attribute, which lands in the report's "Parser notes" section, and two
|
|
403
|
+
* models that resolve to the same bare table name are both marked UNRESOLVED
|
|
404
|
+
* rather than silently collapsed (see flagDuplicateTables in prisma-resolve.ts).
|
|
405
|
+
* This JSDoc used to claim the note existed before either half was written.
|
|
400
406
|
*/
|
|
401
407
|
/** Outcome of {@link resolveMigrateFromPrismaUrl}. */
|
|
402
408
|
export interface MigrateFromPrismaUrl {
|
|
@@ -420,6 +426,17 @@ export interface MigrateFromPrismaUrl {
|
|
|
420
426
|
* pass on every run.
|
|
421
427
|
*/
|
|
422
428
|
export declare function resolveMigrateFromPrismaUrl(configUrl: string | undefined, ast: Pick<PrismaSchemaAst, 'datasources'>, env: Record<string, string | undefined>): MigrateFromPrismaUrl;
|
|
429
|
+
/**
|
|
430
|
+
* Read a Prisma schema from a FILE or from a multi-file schema DIRECTORY
|
|
431
|
+
* (Prisma >= 5.15, GA). A directory used to reach `readFileSync` unchanged and
|
|
432
|
+
* die with a raw `EISDIR`, which is a stack trace rather than an answer.
|
|
433
|
+
*
|
|
434
|
+
* Prisma treats every `.prisma` file under the directory as one logical schema,
|
|
435
|
+
* so they are concatenated in sorted order (stable across machines) and parsed
|
|
436
|
+
* as a single source. Line numbers in any parse error then refer to that
|
|
437
|
+
* concatenation, so each file is introduced by a comment naming it.
|
|
438
|
+
*/
|
|
439
|
+
export declare function readPrismaSchemaSource(path: string): string;
|
|
423
440
|
export declare function buildMigrateDeployOptions(args: CliArgs): {
|
|
424
441
|
allowDrift: boolean;
|
|
425
442
|
allowDestructive: true;
|