turbine-orm 0.48.0 → 0.49.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 +58 -39
- package/dist/cjs/cli/destructive.js +233 -18
- package/dist/cjs/cli/index.js +56 -12
- package/dist/cjs/cli/mcp.js +23 -2
- package/dist/cjs/cli/migrate.js +28 -1
- package/dist/cjs/cli/pii-tags.js +111 -0
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +158 -0
- package/dist/cjs/cli/ui.js +8 -3
- package/dist/cjs/client.js +21 -1
- package/dist/cjs/dialect.js +2 -0
- package/dist/cjs/index-advisor.js +0 -0
- package/dist/cjs/index-stats.js +118 -6
- package/dist/cjs/mssql.js +5 -0
- package/dist/cjs/mysql.js +5 -0
- package/dist/cjs/nested-write.js +248 -18
- package/dist/cjs/observe.js +21 -15
- package/dist/cjs/powdb.js +3 -0
- package/dist/cjs/powql.js +13 -0
- package/dist/cjs/prisma-compat.js +9 -0
- package/dist/cjs/query/aggregates.js +41 -1
- package/dist/cjs/query/batched-loader.js +70 -6
- package/dist/cjs/query/builder.js +3 -3
- package/dist/cjs/query/relations.js +12 -2
- package/dist/cjs/query/where.js +36 -1
- package/dist/cjs/sqlite.js +5 -0
- package/dist/cli/destructive.d.ts +9 -3
- package/dist/cli/destructive.js +233 -18
- package/dist/cli/index.js +57 -13
- package/dist/cli/mcp.d.ts +7 -0
- package/dist/cli/mcp.js +23 -2
- package/dist/cli/migrate.d.ts +2 -1
- package/dist/cli/migrate.js +28 -1
- package/dist/cli/pii-tags.d.ts +53 -0
- package/dist/cli/pii-tags.js +106 -0
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +42 -0
- package/dist/cli/studio.js +157 -0
- package/dist/cli/ui.js +8 -3
- package/dist/client.js +21 -1
- package/dist/dialect.d.ts +19 -0
- package/dist/dialect.js +2 -0
- package/dist/index-advisor.d.ts +7 -0
- package/dist/index-advisor.js +0 -0
- package/dist/index-stats.d.ts +52 -1
- package/dist/index-stats.js +117 -5
- package/dist/mssql.js +5 -0
- package/dist/mysql.js +5 -0
- package/dist/nested-write.js +249 -19
- package/dist/observe.d.ts +0 -1
- package/dist/observe.js +21 -15
- package/dist/powdb.js +3 -0
- package/dist/powql.js +13 -0
- package/dist/prisma-compat.js +9 -0
- package/dist/query/aggregates.d.ts +18 -0
- package/dist/query/aggregates.js +40 -1
- package/dist/query/batched-loader.d.ts +29 -1
- package/dist/query/batched-loader.js +69 -6
- package/dist/query/builder.js +4 -4
- package/dist/query/relations.js +12 -2
- package/dist/query/types.d.ts +16 -0
- package/dist/query/where.d.ts +18 -1
- package/dist/query/where.js +34 -1
- package/dist/sqlite.js +5 -0
- package/package.json +3 -2
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
* @module
|
|
49
49
|
*/
|
|
50
50
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
51
|
+
exports.defaultProjectionFields = defaultProjectionFields;
|
|
51
52
|
exports.includeKeysForBatching = includeKeysForBatching;
|
|
52
53
|
exports.stripFields = stripFields;
|
|
53
54
|
exports.neededParentKeyFields = neededParentKeyFields;
|
|
@@ -69,6 +70,29 @@ const utils_js_1 = require("./utils.js");
|
|
|
69
70
|
const MAX_RELATION_KEYS = 32_000;
|
|
70
71
|
/** Nesting cap — parity with the join strategy's depth-10 guard. */
|
|
71
72
|
const MAX_DEPTH = 10;
|
|
73
|
+
/**
|
|
74
|
+
* The default projection of `meta` expressed in FIELD names: which fields the
|
|
75
|
+
* default (no `select`/`omit`) projection hides, and which it returns. Today the
|
|
76
|
+
* only hidden class is PII-tagged columns, and only when `includePii` is off.
|
|
77
|
+
*
|
|
78
|
+
* Returns `undefined` for the overwhelmingly common untagged case, so callers
|
|
79
|
+
* keep the `select: undefined, omit: undefined` fast path and the emitted SQL
|
|
80
|
+
* stays byte-identical.
|
|
81
|
+
*/
|
|
82
|
+
function defaultProjectionFields(meta, includePii) {
|
|
83
|
+
if (includePii)
|
|
84
|
+
return undefined;
|
|
85
|
+
const hidden = new Set();
|
|
86
|
+
const visible = [];
|
|
87
|
+
for (const col of meta.columns) {
|
|
88
|
+
const field = meta.reverseColumnMap[col.name] ?? col.name;
|
|
89
|
+
if (col.pii)
|
|
90
|
+
hidden.add(field);
|
|
91
|
+
else
|
|
92
|
+
visible.push(field);
|
|
93
|
+
}
|
|
94
|
+
return hidden.size === 0 ? undefined : { hidden, visible };
|
|
95
|
+
}
|
|
72
96
|
/**
|
|
73
97
|
* Adjust a `select`/`omit` pair so that `fields` are guaranteed present in the
|
|
74
98
|
* query result, returning the adjusted projection plus the list of fields that
|
|
@@ -78,7 +102,19 @@ const MAX_DEPTH = 10;
|
|
|
78
102
|
* keys) so a caller's `select: { title: true }` on a relation still stitches even
|
|
79
103
|
* though the FK was not requested — and the FK never appears in the output.
|
|
80
104
|
*/
|
|
81
|
-
function includeKeysForBatching(select, omit, fields
|
|
105
|
+
function includeKeysForBatching(select, omit, fields,
|
|
106
|
+
/**
|
|
107
|
+
* The default projection for this table when it is NOT `select`/`omit`-driven:
|
|
108
|
+
* `hidden` are fields the default projection leaves out (today: PII-tagged
|
|
109
|
+
* columns without `includePii`), `visible` is everything it does return.
|
|
110
|
+
*
|
|
111
|
+
* Without this, a correlation key that is itself PII-tagged is absent from
|
|
112
|
+
* every row, the loader sees no keys, and it silently hands back empty
|
|
113
|
+
* relation arrays. Passing it turns that case into an explicit select that
|
|
114
|
+
* re-adds only the key, which is then stripped like any other stitch-only
|
|
115
|
+
* field, so no PII value ever reaches the caller.
|
|
116
|
+
*/
|
|
117
|
+
defaultProjection) {
|
|
82
118
|
const unique = [...new Set(fields)];
|
|
83
119
|
if (select) {
|
|
84
120
|
const next = { ...select };
|
|
@@ -102,7 +138,19 @@ function includeKeysForBatching(select, omit, fields) {
|
|
|
102
138
|
}
|
|
103
139
|
return { select, omit: next, strip };
|
|
104
140
|
}
|
|
105
|
-
// Neither select nor omit
|
|
141
|
+
// Neither select nor omit. Every column the DEFAULT projection returns is
|
|
142
|
+
// already present, so normally there is nothing to strip; the exception is a
|
|
143
|
+
// key the default projection hides (a PII-tagged correlation column), which
|
|
144
|
+
// has to be asked for explicitly.
|
|
145
|
+
const hiddenKeys = defaultProjection ? unique.filter((f) => defaultProjection.hidden.has(f)) : [];
|
|
146
|
+
if (hiddenKeys.length > 0 && defaultProjection) {
|
|
147
|
+
const explicit = {};
|
|
148
|
+
for (const f of defaultProjection.visible)
|
|
149
|
+
explicit[f] = true;
|
|
150
|
+
for (const f of hiddenKeys)
|
|
151
|
+
explicit[f] = true;
|
|
152
|
+
return { select: explicit, omit: undefined, strip: hiddenKeys };
|
|
153
|
+
}
|
|
106
154
|
return { select, omit, strip: [] };
|
|
107
155
|
}
|
|
108
156
|
/** Delete stitch-only key fields from each row (no-op when `fields` is empty). */
|
|
@@ -280,7 +328,7 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
|
|
|
280
328
|
}
|
|
281
329
|
// The follow-up must project the child correlation key even if the caller's
|
|
282
330
|
// select/omit excluded it; strip it back off afterwards so the shape matches join.
|
|
283
|
-
const proj = includeKeysForBatching(options.select, options.omit, [childKeyField]);
|
|
331
|
+
const proj = includeKeysForBatching(options.select, options.omit, [childKeyField], defaultProjectionFields(targetMeta, ctx.includePii));
|
|
284
332
|
const child = ctx.makeChild(rel.to);
|
|
285
333
|
const chunks = [];
|
|
286
334
|
for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS)
|
|
@@ -382,7 +430,7 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
|
|
|
382
430
|
}
|
|
383
431
|
}
|
|
384
432
|
// (2) Target rows by PK, honouring the relation's own where/select/omit/orderBy.
|
|
385
|
-
const proj = includeKeysForBatching(options.select, options.omit, [targetPkField]);
|
|
433
|
+
const proj = includeKeysForBatching(options.select, options.omit, [targetPkField], defaultProjectionFields(targetMeta, ctx.includePii));
|
|
386
434
|
const child = ctx.makeChild(rel.to);
|
|
387
435
|
const targetVals = [...targetValSet];
|
|
388
436
|
const tChunks = [];
|
|
@@ -535,9 +583,25 @@ async function loadOneCount(ctx, parents, rel) {
|
|
|
535
583
|
// ---------------------------------------------------------------------------
|
|
536
584
|
// Small helpers
|
|
537
585
|
// ---------------------------------------------------------------------------
|
|
538
|
-
/**
|
|
586
|
+
/**
|
|
587
|
+
* AND the batched correlation predicate (`key IN chunk`) onto the relation's own
|
|
588
|
+
* `where`, matching the join strategy, which appends the correlation with
|
|
589
|
+
* ` AND <extra>` and so never lets one predicate replace the other.
|
|
590
|
+
*
|
|
591
|
+
* A flat spread is kept for the overwhelmingly common case where the caller's
|
|
592
|
+
* `where` does not name the correlation field, so the emitted SQL is unchanged
|
|
593
|
+
* there. When it DOES name it (e.g. `with: { posts: { where: { userId: 1 } } }`,
|
|
594
|
+
* or a belongsTo `where` on the child's PK), a bare spread would let the chunk
|
|
595
|
+
* predicate silently overwrite the caller's filter and return rows the join
|
|
596
|
+
* strategy excludes; the two are combined with `AND` instead so both apply.
|
|
597
|
+
*/
|
|
539
598
|
function mergeChildWhere(where, keyField, chunk) {
|
|
540
|
-
|
|
599
|
+
const correlation = { [keyField]: { in: chunk } };
|
|
600
|
+
if (!where)
|
|
601
|
+
return correlation;
|
|
602
|
+
if (Object.hasOwn(where, keyField))
|
|
603
|
+
return { AND: [where, correlation] };
|
|
604
|
+
return { ...where, ...correlation };
|
|
541
605
|
}
|
|
542
606
|
/** Distinct, non-null values of `field` across `rows`. */
|
|
543
607
|
function uniqueKeys(rows, field) {
|
|
@@ -781,7 +781,7 @@ class QueryInterface {
|
|
|
781
781
|
(0, batched_loader_js_1.rejectNestedPickOrder)(batchedWith);
|
|
782
782
|
const skip = args.skipGlobalFilters;
|
|
783
783
|
const needed = (0, batched_loader_js_1.neededParentKeyFields)(this.tableMeta, batchedWith);
|
|
784
|
-
const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed);
|
|
784
|
+
const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed, (0, batched_loader_js_1.defaultProjectionFields)(this.tableMeta, args.includePii));
|
|
785
785
|
const hasJoin = Object.keys(joinWith).length > 0;
|
|
786
786
|
// Force the residual `with` onto the join plan so the base query never
|
|
787
787
|
// re-enters this auto planning.
|
|
@@ -894,7 +894,7 @@ class QueryInterface {
|
|
|
894
894
|
*/
|
|
895
895
|
prepareBatchedBase(args, withClause) {
|
|
896
896
|
const needed = (0, batched_loader_js_1.neededParentKeyFields)(this.tableMeta, withClause);
|
|
897
|
-
const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed);
|
|
897
|
+
const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed, (0, batched_loader_js_1.defaultProjectionFields)(this.tableMeta, args.includePii));
|
|
898
898
|
const baseArgs = {
|
|
899
899
|
...args,
|
|
900
900
|
with: undefined,
|
|
@@ -1200,7 +1200,7 @@ class QueryInterface {
|
|
|
1200
1200
|
// Same scope-rule parity as runFindManyBatched: reject before querying.
|
|
1201
1201
|
(0, batched_loader_js_1.rejectNestedPickOrder)(withClause);
|
|
1202
1202
|
const needed = (0, batched_loader_js_1.neededParentKeyFields)(this.tableMeta, withClause);
|
|
1203
|
-
const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed);
|
|
1203
|
+
const proj = (0, batched_loader_js_1.includeKeysForBatching)(args.select, args.omit, needed, (0, batched_loader_js_1.defaultProjectionFields)(this.tableMeta, args.includePii));
|
|
1204
1204
|
const baseArgs = { ...args, with: undefined, select: proj.select, omit: proj.omit };
|
|
1205
1205
|
const deferred = this.buildFindUnique(baseArgs);
|
|
1206
1206
|
const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
|
|
@@ -551,8 +551,18 @@ function buildRelationOrderBy(qi, relName, value, alias, params, ctx, lateralSin
|
|
|
551
551
|
const parentRef = ctx?.parentRef ?? qi.table;
|
|
552
552
|
const relDef = ownerMeta.relations[relName];
|
|
553
553
|
if (!relDef) {
|
|
554
|
-
|
|
555
|
-
|
|
554
|
+
// A table with no relations at all would otherwise render a dangling
|
|
555
|
+
// "Available: " and read as a broken message; and the most likely cause of
|
|
556
|
+
// landing here on such a table is an orderBy VALUE of the wrong shape on a
|
|
557
|
+
// scalar column, which deserves to be named rather than reported as a
|
|
558
|
+
// missing relation.
|
|
559
|
+
const known = Object.keys(ownerMeta.relations);
|
|
560
|
+
const isColumn = Object.hasOwn(ownerMeta.columnMap, relName) || ownerMeta.allColumns.includes(relName);
|
|
561
|
+
throw new errors_js_1.RelationError(isColumn
|
|
562
|
+
? `[turbine] orderBy on "${ownerTable}.${relName}" got a relation-shaped value, but "${relName}" is a ` +
|
|
563
|
+
`column. Order a column with 'asc' / 'desc' (or { sort, nulls }); the object form is for relations.`
|
|
564
|
+
: `[turbine] Unknown relation "${relName}" in orderBy on table "${ownerTable}". ` +
|
|
565
|
+
(known.length > 0 ? `Available: ${known.join(', ')}` : `"${ownerTable}" has no relations.`));
|
|
556
566
|
}
|
|
557
567
|
// Pick-row ordering (`{ pick, by }`): order by a value from ONE related
|
|
558
568
|
// row: a correlated scalar subquery with its own ORDER BY … LIMIT 1.
|
package/dist/cjs/query/where.js
CHANGED
|
@@ -56,6 +56,8 @@ exports.fingerprintAliasWhere = fingerprintAliasWhere;
|
|
|
56
56
|
exports.resolveColumnRef = resolveColumnRef;
|
|
57
57
|
exports.columnRefSql = columnRefSql;
|
|
58
58
|
exports.buildOperatorClauses = buildOperatorClauses;
|
|
59
|
+
exports.requireFullTextSearch = requireFullTextSearch;
|
|
60
|
+
exports.requireArrayColumns = requireArrayColumns;
|
|
59
61
|
exports.vectorOperator = vectorOperator;
|
|
60
62
|
exports.pushVectorParam = pushVectorParam;
|
|
61
63
|
exports.normalizeRelationFilter = normalizeRelationFilter;
|
|
@@ -208,6 +210,9 @@ function collectScalarParams(qi, key, value, params) {
|
|
|
208
210
|
collectArrayFilterParams(qi, value, params);
|
|
209
211
|
return;
|
|
210
212
|
case 'textsearch':
|
|
213
|
+
// Same gate the build path applies, so the collect path never diverges
|
|
214
|
+
// (it throws before any param is pushed).
|
|
215
|
+
requireFullTextSearch(qi);
|
|
211
216
|
params.push(value.search);
|
|
212
217
|
return;
|
|
213
218
|
case 'operator':
|
|
@@ -344,7 +349,8 @@ function collectJsonFilterParams(qi, filter, params, column) {
|
|
|
344
349
|
}
|
|
345
350
|
}
|
|
346
351
|
/** Collect params from array filter. Mirrors buildArrayFilterClauses. */
|
|
347
|
-
function collectArrayFilterParams(
|
|
352
|
+
function collectArrayFilterParams(qi, filter, params) {
|
|
353
|
+
requireArrayColumns(qi);
|
|
348
354
|
if (filter.has !== undefined)
|
|
349
355
|
params.push(filter.has);
|
|
350
356
|
if (filter.hasEvery !== undefined)
|
|
@@ -1221,6 +1227,33 @@ function buildOperatorClauses(qi, column, op, params, refCtx) {
|
|
|
1221
1227
|
}
|
|
1222
1228
|
return clauses;
|
|
1223
1229
|
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Gate the full-text `search` filter on {@link Dialect.supportsFullTextSearch}.
|
|
1232
|
+
* The clause it guards is `to_tsvector(...) @@ to_tsquery(...)`, which only
|
|
1233
|
+
* PostgreSQL parses, so every other engine gets a typed
|
|
1234
|
+
* {@link UnsupportedFeatureError} (E017) instead of a raw driver syntax error.
|
|
1235
|
+
* Called from BOTH the build and the param-collect side (mirroring the vector
|
|
1236
|
+
* gate) so the two paths can never diverge.
|
|
1237
|
+
*/
|
|
1238
|
+
function requireFullTextSearch(qi) {
|
|
1239
|
+
if (qi.dialect.supportsFullTextSearch)
|
|
1240
|
+
return;
|
|
1241
|
+
throw new errors_js_1.UnsupportedFeatureError('the full-text search filter (`search`)', qi.dialect.name, 'Full-text `search` compiles to PostgreSQL to_tsvector/to_tsquery. ' +
|
|
1242
|
+
'Use `contains` (LIKE) on this engine, or run the query on PostgreSQL.');
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Gate the array filter operators (`has` / `hasEvery` / `hasSome` / `isEmpty`)
|
|
1246
|
+
* on {@link Dialect.supportsArrayColumns}. They compile to PostgreSQL array
|
|
1247
|
+
* operators (`= ANY(col)`, `@>`, `&&`, `cardinality(col)`) over a native array
|
|
1248
|
+
* column, which no other supported engine has. Called from BOTH the build and
|
|
1249
|
+
* the param-collect side.
|
|
1250
|
+
*/
|
|
1251
|
+
function requireArrayColumns(qi) {
|
|
1252
|
+
if (qi.dialect.supportsArrayColumns)
|
|
1253
|
+
return;
|
|
1254
|
+
throw new errors_js_1.UnsupportedFeatureError('the array column filter set (`has` / `hasEvery` / `hasSome` / `isEmpty`)', qi.dialect.name, 'Array filters compile to PostgreSQL array operators over a native array ' +
|
|
1255
|
+
'column; this engine has no array column type.');
|
|
1256
|
+
}
|
|
1224
1257
|
/**
|
|
1225
1258
|
* Resolve a {@link VectorMetric} to its pgvector distance operator from a
|
|
1226
1259
|
* fixed allow-list, validating the target column is actually a `vector`
|
|
@@ -1438,6 +1471,7 @@ function castJsonNumeric(qi, extract) {
|
|
|
1438
1471
|
* Supports: has, hasEvery, hasSome, isEmpty.
|
|
1439
1472
|
*/
|
|
1440
1473
|
function buildArrayFilterClauses(qi, column, filter, params, pgType) {
|
|
1474
|
+
requireArrayColumns(qi);
|
|
1441
1475
|
const clauses = [];
|
|
1442
1476
|
const elementType = getArrayElementType(qi, pgType);
|
|
1443
1477
|
if (filter.has !== undefined) {
|
|
@@ -1503,6 +1537,7 @@ function buildVectorFilterClauses(qi, field, rawColumn, filter, params) {
|
|
|
1503
1537
|
* The config name is validated to prevent injection (only alphanumeric + underscore).
|
|
1504
1538
|
*/
|
|
1505
1539
|
function buildTextSearchClause(qi, column, filter, params) {
|
|
1540
|
+
requireFullTextSearch(qi);
|
|
1506
1541
|
const config = filter.config ?? 'english';
|
|
1507
1542
|
if (!(0, filters_js_1.validateTextSearchConfig)(config)) {
|
|
1508
1543
|
throw new errors_js_1.ValidationError(`[turbine] Invalid text search config "${config}": only alphanumeric characters and underscores are allowed.`);
|
package/dist/cjs/sqlite.js
CHANGED
|
@@ -375,6 +375,11 @@ exports.sqliteDialect = {
|
|
|
375
375
|
supportsReturning: true,
|
|
376
376
|
supportsILike: false,
|
|
377
377
|
supportsVector: false,
|
|
378
|
+
// FTS5 is a virtual-table feature with its own MATCH syntax, not the
|
|
379
|
+
// `to_tsvector @@ to_tsquery` shape Turbine's `search` filter emits.
|
|
380
|
+
supportsFullTextSearch: false,
|
|
381
|
+
// No array column type (a JSON column is not an array column).
|
|
382
|
+
supportsArrayColumns: false,
|
|
378
383
|
supportsListenNotify: false,
|
|
379
384
|
supportsRLS: false,
|
|
380
385
|
supportsAdvisoryLock: false,
|
|
@@ -10,12 +10,18 @@
|
|
|
10
10
|
* Deliberately conservative in BOTH directions:
|
|
11
11
|
* - comments and string literals are stripped first, so `-- DROP TABLE foo`
|
|
12
12
|
* or `INSERT ... VALUES ('DROP TABLE x')` never false-positive;
|
|
13
|
-
* - anything that removes rows, columns, tables, or schemas
|
|
14
|
-
* column's type (a potentially lossy cast)
|
|
13
|
+
* - anything that removes rows, columns, tables, or schemas, or rewrites a
|
|
14
|
+
* column's type (a potentially lossy cast), is flagged. `DROP INDEX`,
|
|
15
15
|
* `DROP CONSTRAINT`, and `DROP TRIGGER` are NOT flagged (recreatable
|
|
16
16
|
* structures; no row data lost).
|
|
17
|
+
*
|
|
18
|
+
* Row removal hides in more than a leading `DELETE`, so the scan also covers:
|
|
19
|
+
* the optional-`COLUMN` shorthand (`ALTER TABLE t DROP email`), data-modifying
|
|
20
|
+
* CTEs (`WITH d AS (DELETE ...) SELECT ...`), `MERGE ... THEN DELETE`, dynamic
|
|
21
|
+
* SQL inside a `DO`/function body, and an `UPDATE` whose only WHERE sits inside
|
|
22
|
+
* a subquery (which restricts nothing).
|
|
17
23
|
*/
|
|
18
|
-
export type DestructiveKind = 'drop-table' | 'drop-schema' | 'drop-column' | 'truncate' | 'delete' | 'update-without-where' | 'alter-column-type';
|
|
24
|
+
export type DestructiveKind = 'drop-table' | 'drop-schema' | 'drop-database' | 'drop-owned' | 'drop-matview' | 'drop-column' | 'truncate' | 'delete' | 'update-without-where' | 'alter-column-type' | 'merge-delete';
|
|
19
25
|
export interface DestructiveStatement {
|
|
20
26
|
/** The offending SQL statement (trimmed, possibly long — display truncated) */
|
|
21
27
|
statement: string;
|
package/dist/cli/destructive.js
CHANGED
|
@@ -10,23 +10,40 @@
|
|
|
10
10
|
* Deliberately conservative in BOTH directions:
|
|
11
11
|
* - comments and string literals are stripped first, so `-- DROP TABLE foo`
|
|
12
12
|
* or `INSERT ... VALUES ('DROP TABLE x')` never false-positive;
|
|
13
|
-
* - anything that removes rows, columns, tables, or schemas
|
|
14
|
-
* column's type (a potentially lossy cast)
|
|
13
|
+
* - anything that removes rows, columns, tables, or schemas, or rewrites a
|
|
14
|
+
* column's type (a potentially lossy cast), is flagged. `DROP INDEX`,
|
|
15
15
|
* `DROP CONSTRAINT`, and `DROP TRIGGER` are NOT flagged (recreatable
|
|
16
16
|
* structures; no row data lost).
|
|
17
|
+
*
|
|
18
|
+
* Row removal hides in more than a leading `DELETE`, so the scan also covers:
|
|
19
|
+
* the optional-`COLUMN` shorthand (`ALTER TABLE t DROP email`), data-modifying
|
|
20
|
+
* CTEs (`WITH d AS (DELETE ...) SELECT ...`), `MERGE ... THEN DELETE`, dynamic
|
|
21
|
+
* SQL inside a `DO`/function body, and an `UPDATE` whose only WHERE sits inside
|
|
22
|
+
* a subquery (which restricts nothing).
|
|
17
23
|
*/
|
|
18
24
|
/** Human explanation per kind, used in CLI output. */
|
|
19
25
|
export const DESTRUCTIVE_KIND_LABEL = {
|
|
20
26
|
'drop-table': 'drops a table and ALL its rows',
|
|
21
27
|
'drop-schema': 'drops an entire schema',
|
|
28
|
+
'drop-database': 'drops an entire database and everything in it',
|
|
29
|
+
'drop-owned': 'drops every object owned by a role, and their rows',
|
|
30
|
+
'drop-matview': 'drops a materialized view and its stored rows',
|
|
22
31
|
'drop-column': 'drops a column and its data in every row',
|
|
23
32
|
truncate: 'deletes every row',
|
|
24
33
|
delete: 'deletes rows',
|
|
25
34
|
'update-without-where': 'rewrites every row (no WHERE clause)',
|
|
26
35
|
'alter-column-type': 'rewrites a column type (cast may truncate or fail)',
|
|
36
|
+
'merge-delete': 'deletes matched rows (MERGE ... THEN DELETE)',
|
|
27
37
|
};
|
|
38
|
+
/**
|
|
39
|
+
* A dollar-quote tag. Postgres allows digits after the first character
|
|
40
|
+
* (`$do1$`), so a tag regex that stops at letters reads the body as code and
|
|
41
|
+
* misses everything inside it. Same shape as the splitter in `migrate.ts`.
|
|
42
|
+
*/
|
|
43
|
+
const DOLLAR_TAG = /^\$([A-Za-z_][A-Za-z_0-9]*)?\$/;
|
|
28
44
|
/** Strip -- line comments, C-style block comments, and quoted literals. */
|
|
29
45
|
function stripCommentsAndStrings(sql) {
|
|
46
|
+
const blocks = [];
|
|
30
47
|
let out = '';
|
|
31
48
|
let i = 0;
|
|
32
49
|
while (i < sql.length) {
|
|
@@ -41,10 +58,17 @@ function stripCommentsAndStrings(sql) {
|
|
|
41
58
|
out += ' ';
|
|
42
59
|
}
|
|
43
60
|
else if (sql[i] === "'") {
|
|
44
|
-
//
|
|
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);
|
|
45
67
|
let j = i + 1;
|
|
46
68
|
while (j < sql.length) {
|
|
47
|
-
if (
|
|
69
|
+
if (escapes && sql[j] === '\\')
|
|
70
|
+
j += 2;
|
|
71
|
+
else if (sql[j] === "'" && sql[j + 1] === "'")
|
|
48
72
|
j += 2;
|
|
49
73
|
else if (sql[j] === "'")
|
|
50
74
|
break;
|
|
@@ -54,10 +78,28 @@ function stripCommentsAndStrings(sql) {
|
|
|
54
78
|
i = j + 1;
|
|
55
79
|
out += "''";
|
|
56
80
|
}
|
|
57
|
-
else if (sql[i] === '
|
|
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))) {
|
|
58
99
|
// dollar-quoted literal ($$...$$ / $tag$...$tag$)
|
|
59
|
-
const tag = sql.slice(i).match(
|
|
100
|
+
const tag = sql.slice(i).match(DOLLAR_TAG)?.[0] ?? '$$';
|
|
60
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) });
|
|
61
103
|
i = end === -1 ? sql.length : end + tag.length;
|
|
62
104
|
out += "''";
|
|
63
105
|
}
|
|
@@ -66,7 +108,23 @@ function stripCommentsAndStrings(sql) {
|
|
|
66
108
|
i++;
|
|
67
109
|
}
|
|
68
110
|
}
|
|
69
|
-
return out;
|
|
111
|
+
return { text: out, blocks };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* True when the quote at `quoteAt` opens an E-string (`E'...'`), where a
|
|
115
|
+
* backslash escapes the next character. The preceding `E` must not itself be
|
|
116
|
+
* part of an identifier, so `some_table'` never turns the following literal
|
|
117
|
+
* into an E-string. Ordinary literals are left alone on purpose: with the
|
|
118
|
+
* modern `standard_conforming_strings = on` default, `'a\'` IS a complete
|
|
119
|
+
* string. Mirrors the same-named helper in `migrate.ts`; kept local so this
|
|
120
|
+
* module stays a pure leaf with no CLI imports of its own.
|
|
121
|
+
*/
|
|
122
|
+
function isEscapeStringPrefix(sql, quoteAt) {
|
|
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);
|
|
70
128
|
}
|
|
71
129
|
/** Unquote a "quoted" identifier for display. */
|
|
72
130
|
const ident = (raw) => (raw ?? '?').replace(/^"|"$/g, '');
|
|
@@ -83,15 +141,34 @@ const RULES = [
|
|
|
83
141
|
regex: new RegExp(String.raw `^DROP\s+SCHEMA\s+(IF\s+EXISTS\s+)?${IDENT}`, 'i'),
|
|
84
142
|
target: (m) => ident(m[2]),
|
|
85
143
|
},
|
|
144
|
+
{
|
|
145
|
+
kind: 'drop-matview',
|
|
146
|
+
regex: new RegExp(String.raw `^DROP\s+MATERIALIZED\s+VIEW\s+(IF\s+EXISTS\s+)?${IDENT}`, 'i'),
|
|
147
|
+
target: (m) => (m[4] ? `${ident(m[2])}.${ident(m[4])}` : ident(m[2])),
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
kind: 'drop-database',
|
|
151
|
+
regex: new RegExp(String.raw `^DROP\s+DATABASE\s+(IF\s+EXISTS\s+)?${IDENT}`, 'i'),
|
|
152
|
+
target: (m) => ident(m[2]),
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
// `DROP OWNED BY role` removes every object that role owns, rows included.
|
|
156
|
+
kind: 'drop-owned',
|
|
157
|
+
regex: new RegExp(String.raw `^DROP\s+OWNED\s+BY\s+${IDENT}`, 'i'),
|
|
158
|
+
target: (m) => ident(m[1]),
|
|
159
|
+
},
|
|
86
160
|
{
|
|
87
161
|
kind: 'truncate',
|
|
88
162
|
regex: new RegExp(String.raw `^TRUNCATE\s+(TABLE\s+)?(ONLY\s+)?${IDENT}`, 'i'),
|
|
89
163
|
target: (m) => (m[5] ? `${ident(m[3])}.${ident(m[5])}` : ident(m[3])),
|
|
90
164
|
},
|
|
91
165
|
{
|
|
166
|
+
// `COLUMN` is OPTIONAL in Postgres: `ALTER TABLE t DROP email` drops the
|
|
167
|
+
// column and its data exactly like the spelled-out form. The lookahead
|
|
168
|
+
// excludes the other `DROP <thing>` sub-actions, none of which lose rows.
|
|
92
169
|
kind: 'drop-column',
|
|
93
|
-
regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}[\s\S]*?\bDROP\s+COLUMN\s+(IF\s+EXISTS\s+)?${IDENT}`, 'i'),
|
|
94
|
-
target: (m) => `${ident(m[3])}.${ident(m[
|
|
170
|
+
regex: new RegExp(String.raw `^ALTER\s+TABLE\s+(IF\s+EXISTS\s+)?(ONLY\s+)?${IDENT}[\s\S]*?\bDROP\s+(?!CONSTRAINT\b|DEFAULT\b|NOT\b|IDENTITY\b|EXPRESSION\b)(COLUMN\s+)?(IF\s+EXISTS\s+)?${IDENT}`, 'i'),
|
|
171
|
+
target: (m) => `${ident(m[3])}.${ident(m[8])}`,
|
|
95
172
|
},
|
|
96
173
|
{
|
|
97
174
|
kind: 'alter-column-type',
|
|
@@ -101,15 +178,130 @@ const RULES = [
|
|
|
101
178
|
{
|
|
102
179
|
kind: 'delete',
|
|
103
180
|
regex: new RegExp(String.raw `^DELETE\s+FROM\s+(ONLY\s+)?${IDENT}`, 'i'),
|
|
104
|
-
target: (m) => ident(m[2]),
|
|
181
|
+
target: (m) => (m[4] ? `${ident(m[2])}.${ident(m[4])}` : ident(m[2])),
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
// MERGE's DELETE action removes rows from the target table.
|
|
185
|
+
kind: 'merge-delete',
|
|
186
|
+
regex: new RegExp(String.raw `^MERGE\s+INTO\s+(ONLY\s+)?${IDENT}\b[\s\S]*?\bTHEN\s+DELETE\b`, 'i'),
|
|
187
|
+
target: (m) => (m[4] ? `${ident(m[2])}.${ident(m[4])}` : ident(m[2])),
|
|
105
188
|
},
|
|
106
189
|
{
|
|
190
|
+
// A WHERE inside a scalar subquery (`SET x = (SELECT ... WHERE ...)`) does
|
|
191
|
+
// NOT restrict the rows updated, so the guard tests only the TOP level.
|
|
107
192
|
kind: 'update-without-where',
|
|
108
193
|
regex: new RegExp(String.raw `^UPDATE\s+(ONLY\s+)?${IDENT}\b`, 'i'),
|
|
109
|
-
target: (m) => ident(m[2]),
|
|
110
|
-
also: (stmt) =>
|
|
194
|
+
target: (m) => (m[4] ? `${ident(m[2])}.${ident(m[4])}` : ident(m[2])),
|
|
195
|
+
also: (stmt) => !hasTopLevelWhere(stmt),
|
|
111
196
|
},
|
|
112
197
|
];
|
|
198
|
+
/** True when the statement has a `WHERE` outside every parenthesized group. */
|
|
199
|
+
function hasTopLevelWhere(stmt) {
|
|
200
|
+
const re = /[()]|\bWHERE\b/gi;
|
|
201
|
+
let depth = 0;
|
|
202
|
+
let m = re.exec(stmt);
|
|
203
|
+
while (m !== null) {
|
|
204
|
+
if (m[0] === '(')
|
|
205
|
+
depth++;
|
|
206
|
+
else if (m[0] === ')')
|
|
207
|
+
depth = Math.max(0, depth - 1);
|
|
208
|
+
else if (depth === 0)
|
|
209
|
+
return true;
|
|
210
|
+
m = re.exec(stmt);
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* A leading CTE list is a prefix, not a statement: `WITH c AS (SELECT 1) DELETE
|
|
216
|
+
* FROM users` is a plain DELETE that the anchored rules would otherwise skip.
|
|
217
|
+
* Strip balanced `WITH name AS ( ... )` groups (and their comma-separated
|
|
218
|
+
* siblings) so the real statement head is what gets matched. The CTE bodies
|
|
219
|
+
* themselves are handled separately by {@link cteSubstatements}.
|
|
220
|
+
*/
|
|
221
|
+
function stripLeadingCtes(stmt) {
|
|
222
|
+
if (!/^WITH\b/i.test(stmt))
|
|
223
|
+
return stmt;
|
|
224
|
+
let rest = stmt.replace(/^WITH\s+(RECURSIVE\s+)?/i, '');
|
|
225
|
+
for (;;) {
|
|
226
|
+
const open = rest.indexOf('(');
|
|
227
|
+
if (open === -1)
|
|
228
|
+
return stmt;
|
|
229
|
+
const close = closingParenIndex(rest, open);
|
|
230
|
+
rest = rest.slice(close + 1).trimStart();
|
|
231
|
+
if (rest.startsWith(',')) {
|
|
232
|
+
rest = rest.slice(1).trimStart();
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
return rest;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** First matching rule for one candidate fragment, or null. */
|
|
239
|
+
function matchRules(candidate) {
|
|
240
|
+
for (const rule of RULES) {
|
|
241
|
+
const m = candidate.match(rule.regex);
|
|
242
|
+
if (!m)
|
|
243
|
+
continue;
|
|
244
|
+
if (rule.also && !rule.also(candidate))
|
|
245
|
+
continue;
|
|
246
|
+
return { kind: rule.kind, target: rule.target(m) };
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Data-modifying CTE bodies: `WITH d AS (DELETE FROM t ...) SELECT ...` runs a
|
|
252
|
+
* real DELETE even though the statement reads as a SELECT. Each candidate is cut
|
|
253
|
+
* at the paren that closes its CTE, so the outer query's WHERE cannot mask a
|
|
254
|
+
* `WITH u AS (UPDATE t SET ...) SELECT ... WHERE ...`.
|
|
255
|
+
*/
|
|
256
|
+
function cteSubstatements(stmt) {
|
|
257
|
+
if (!/^WITH\b/i.test(stmt))
|
|
258
|
+
return [];
|
|
259
|
+
const out = [];
|
|
260
|
+
const re = /\(\s*(?=(?:DELETE|UPDATE|INSERT|TRUNCATE|DROP|ALTER|MERGE)\b)/gi;
|
|
261
|
+
let m = re.exec(stmt);
|
|
262
|
+
while (m !== null) {
|
|
263
|
+
const start = m.index + m[0].length;
|
|
264
|
+
out.push(stmt.slice(start, closingParenIndex(stmt, m.index)));
|
|
265
|
+
m = re.exec(stmt);
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
/** Index of the `)` closing the `(` at `openAt`, or the end of the string. */
|
|
270
|
+
function closingParenIndex(stmt, openAt) {
|
|
271
|
+
let depth = 0;
|
|
272
|
+
for (let i = openAt; i < stmt.length; i++) {
|
|
273
|
+
if (stmt[i] === '(')
|
|
274
|
+
depth++;
|
|
275
|
+
else if (stmt[i] === ')') {
|
|
276
|
+
depth--;
|
|
277
|
+
if (depth === 0)
|
|
278
|
+
return i;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return stmt.length;
|
|
282
|
+
}
|
|
283
|
+
/** Statements whose dollar-quoted body is executable SQL rather than data. */
|
|
284
|
+
const PROCEDURAL_STATEMENT = /^(DO\b|CREATE\s+(OR\s+REPLACE\s+)?(FUNCTION|PROCEDURE)\b)/i;
|
|
285
|
+
/**
|
|
286
|
+
* Candidate fragments inside a procedural body (a `DO $$ ... $$` block or a
|
|
287
|
+
* function source). The body's own string literals are NOT stripped here: the
|
|
288
|
+
* whole point is dynamic SQL, whose payload lives in a literal
|
|
289
|
+
* (`EXECUTE 'DROP TABLE users'`). Rules are anchored, so every keyword-leading
|
|
290
|
+
* position in the body is offered as its own candidate. This deliberately
|
|
291
|
+
* over-reports (a body that merely mentions "drop table" in a message string is
|
|
292
|
+
* flagged) in keeping with the module's false-positives-only asymmetry.
|
|
293
|
+
*/
|
|
294
|
+
function proceduralCandidates(body) {
|
|
295
|
+
const withoutComments = body.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/--[^\n]*/g, ' ');
|
|
296
|
+
const out = [];
|
|
297
|
+
const re = /\b(?:DROP|TRUNCATE|DELETE|ALTER|UPDATE|MERGE)\s/gi;
|
|
298
|
+
let m = re.exec(withoutComments);
|
|
299
|
+
while (m !== null) {
|
|
300
|
+
out.push(withoutComments.slice(m.index));
|
|
301
|
+
m = re.exec(withoutComments);
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
113
305
|
/**
|
|
114
306
|
* Scan SQL (one file's worth; may contain many `;`-separated statements) and
|
|
115
307
|
* return every statement that can destroy data.
|
|
@@ -117,17 +309,40 @@ const RULES = [
|
|
|
117
309
|
export function scanDestructiveSql(sql) {
|
|
118
310
|
const found = [];
|
|
119
311
|
const cleaned = stripCommentsAndStrings(sql);
|
|
120
|
-
|
|
312
|
+
let offset = 0;
|
|
313
|
+
for (const rawStmt of cleaned.text.split(';')) {
|
|
314
|
+
const start = offset;
|
|
315
|
+
const end = offset + rawStmt.length;
|
|
316
|
+
offset = end + 1; // the ';' consumed by split
|
|
121
317
|
const stmt = rawStmt.trim();
|
|
122
318
|
if (!stmt)
|
|
123
319
|
continue;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
320
|
+
// Top level, then data-modifying CTEs, then any procedural body this
|
|
321
|
+
// statement blanked. First match per statement wins, as before.
|
|
322
|
+
const display = stmt.replace(/\s+/g, ' ');
|
|
323
|
+
const candidates = [
|
|
324
|
+
stripLeadingCtes(stmt),
|
|
325
|
+
...cteSubstatements(stmt),
|
|
326
|
+
].map((text) => ({
|
|
327
|
+
text,
|
|
328
|
+
display,
|
|
329
|
+
}));
|
|
330
|
+
// Only a DO block / routine body is procedural SQL. A dollar-quoted literal
|
|
331
|
+
// used as DATA (`INSERT ... VALUES ($$DELETE FROM x$$)`) stays a literal.
|
|
332
|
+
const procedural = PROCEDURAL_STATEMENT.test(stmt);
|
|
333
|
+
for (const block of procedural ? cleaned.blocks : []) {
|
|
334
|
+
if (block.at < start || block.at >= end)
|
|
127
335
|
continue;
|
|
128
|
-
|
|
336
|
+
for (const text of proceduralCandidates(block.body)) {
|
|
337
|
+
// The body was blanked in `display`, so name the fragment that matched.
|
|
338
|
+
candidates.push({ text, display: `${display} [in block: ${text.replace(/\s+/g, ' ').slice(0, 60)}]` });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
for (const candidate of candidates) {
|
|
342
|
+
const hit = matchRules(candidate.text);
|
|
343
|
+
if (!hit)
|
|
129
344
|
continue;
|
|
130
|
-
found.push({ statement:
|
|
345
|
+
found.push({ statement: candidate.display, kind: hit.kind, target: hit.target });
|
|
131
346
|
break;
|
|
132
347
|
}
|
|
133
348
|
}
|