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.
Files changed (65) hide show
  1. package/README.md +58 -39
  2. package/dist/cjs/cli/destructive.js +233 -18
  3. package/dist/cjs/cli/index.js +56 -12
  4. package/dist/cjs/cli/mcp.js +23 -2
  5. package/dist/cjs/cli/migrate.js +28 -1
  6. package/dist/cjs/cli/pii-tags.js +111 -0
  7. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  8. package/dist/cjs/cli/studio.js +158 -0
  9. package/dist/cjs/cli/ui.js +8 -3
  10. package/dist/cjs/client.js +21 -1
  11. package/dist/cjs/dialect.js +2 -0
  12. package/dist/cjs/index-advisor.js +0 -0
  13. package/dist/cjs/index-stats.js +118 -6
  14. package/dist/cjs/mssql.js +5 -0
  15. package/dist/cjs/mysql.js +5 -0
  16. package/dist/cjs/nested-write.js +248 -18
  17. package/dist/cjs/observe.js +21 -15
  18. package/dist/cjs/powdb.js +3 -0
  19. package/dist/cjs/powql.js +13 -0
  20. package/dist/cjs/prisma-compat.js +9 -0
  21. package/dist/cjs/query/aggregates.js +41 -1
  22. package/dist/cjs/query/batched-loader.js +70 -6
  23. package/dist/cjs/query/builder.js +3 -3
  24. package/dist/cjs/query/relations.js +12 -2
  25. package/dist/cjs/query/where.js +36 -1
  26. package/dist/cjs/sqlite.js +5 -0
  27. package/dist/cli/destructive.d.ts +9 -3
  28. package/dist/cli/destructive.js +233 -18
  29. package/dist/cli/index.js +57 -13
  30. package/dist/cli/mcp.d.ts +7 -0
  31. package/dist/cli/mcp.js +23 -2
  32. package/dist/cli/migrate.d.ts +2 -1
  33. package/dist/cli/migrate.js +28 -1
  34. package/dist/cli/pii-tags.d.ts +53 -0
  35. package/dist/cli/pii-tags.js +106 -0
  36. package/dist/cli/studio-ui.generated.js +1 -1
  37. package/dist/cli/studio.d.ts +42 -0
  38. package/dist/cli/studio.js +157 -0
  39. package/dist/cli/ui.js +8 -3
  40. package/dist/client.js +21 -1
  41. package/dist/dialect.d.ts +19 -0
  42. package/dist/dialect.js +2 -0
  43. package/dist/index-advisor.d.ts +7 -0
  44. package/dist/index-advisor.js +0 -0
  45. package/dist/index-stats.d.ts +52 -1
  46. package/dist/index-stats.js +117 -5
  47. package/dist/mssql.js +5 -0
  48. package/dist/mysql.js +5 -0
  49. package/dist/nested-write.js +249 -19
  50. package/dist/observe.d.ts +0 -1
  51. package/dist/observe.js +21 -15
  52. package/dist/powdb.js +3 -0
  53. package/dist/powql.js +13 -0
  54. package/dist/prisma-compat.js +9 -0
  55. package/dist/query/aggregates.d.ts +18 -0
  56. package/dist/query/aggregates.js +40 -1
  57. package/dist/query/batched-loader.d.ts +29 -1
  58. package/dist/query/batched-loader.js +69 -6
  59. package/dist/query/builder.js +4 -4
  60. package/dist/query/relations.js +12 -2
  61. package/dist/query/types.d.ts +16 -0
  62. package/dist/query/where.d.ts +18 -1
  63. package/dist/query/where.js +34 -1
  64. package/dist/sqlite.js +5 -0
  65. 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 every column is already present; nothing to strip.
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
- /** Merge the batched correlation predicate (`key IN chunk`) into the relation's own where. */
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
- return { ...(where ?? {}), [keyField]: { in: chunk } };
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
- throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${ownerTable}". ` +
555
- `Available: ${Object.keys(ownerMeta.relations).join(', ')}`);
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.
@@ -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(_qi, filter, params) {
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.`);
@@ -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 or rewrites a
14
- * column's type (a potentially lossy cast) is flagged. `DROP INDEX`,
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;
@@ -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 or rewrites a
14
- * column's type (a potentially lossy cast) is flagged. `DROP INDEX`,
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
- // single-quoted literal ('' escapes a quote)
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 (sql[j] === "'" && sql[j + 1] === "'")
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] === '$' && /^\$[a-zA-Z_]*\$/.test(sql.slice(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(/^\$[a-zA-Z_]*\$/)?.[0] ?? '$$';
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[7])}`,
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) => !/\bWHERE\b/i.test(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
- for (const rawStmt of cleaned.split(';')) {
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
- for (const rule of RULES) {
125
- const m = stmt.match(rule.regex);
126
- if (!m)
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
- if (rule.also && !rule.also(stmt))
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: stmt.replace(/\s+/g, ' '), kind: rule.kind, target: rule.target(m) });
345
+ found.push({ statement: candidate.display, kind: hit.kind, target: hit.target });
131
346
  break;
132
347
  }
133
348
  }