turbine-orm 0.77.1 → 0.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -9
- package/dist/cjs/cli/config.d.ts +7 -1
- package/dist/cjs/cli/config.js +11 -2
- package/dist/cjs/cli/destructive.d.ts +1 -1
- package/dist/cjs/cli/destructive.js +307 -9
- package/dist/cjs/cli/index.js +252 -42
- package/dist/cjs/cli/mcp.d.ts +23 -0
- package/dist/cjs/cli/mcp.js +190 -152
- package/dist/cjs/cli/migrate.d.ts +243 -3
- package/dist/cjs/cli/migrate.js +432 -43
- package/dist/cjs/cli/sql-statements.js +27 -0
- package/dist/cjs/cli/studio.d.ts +0 -1
- package/dist/cjs/cli/studio.js +9 -7
- package/dist/cjs/client.d.ts +8 -1
- package/dist/cjs/client.js +7 -0
- package/dist/cjs/connection-url.d.ts +82 -0
- package/dist/cjs/connection-url.js +187 -1
- package/dist/cjs/errors.d.ts +112 -12
- package/dist/cjs/errors.js +558 -114
- package/dist/cjs/generate.js +47 -15
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/introspect.d.ts +33 -0
- package/dist/cjs/introspect.js +54 -1
- package/dist/cjs/mssql.js +21 -1
- package/dist/cjs/nested-write.js +85 -14
- package/dist/cjs/pipeline-submittable.d.ts +2 -0
- package/dist/cjs/pipeline-submittable.js +88 -3
- package/dist/cjs/pipeline.js +13 -1
- package/dist/cjs/powdb-introspect.d.ts +5 -1
- package/dist/cjs/powdb-introspect.js +5 -1
- package/dist/cjs/powql.d.ts +14 -0
- package/dist/cjs/powql.js +44 -4
- package/dist/cjs/prisma-compat.js +95 -8
- package/dist/cjs/query/aggregates.js +56 -6
- package/dist/cjs/query/builder.d.ts +76 -13
- package/dist/cjs/query/builder.js +188 -58
- package/dist/cjs/query/compound-unique.d.ts +76 -0
- package/dist/cjs/query/compound-unique.js +129 -0
- package/dist/cjs/query/index.d.ts +1 -1
- package/dist/cjs/query/types.d.ts +65 -11
- package/dist/cjs/query/where.d.ts +85 -19
- package/dist/cjs/query/where.js +262 -47
- package/dist/cjs/query/writes.d.ts +11 -2
- package/dist/cjs/query/writes.js +116 -21
- package/dist/cjs/seed.d.ts +16 -0
- package/dist/cjs/seed.js +16 -0
- package/dist/cli/config.d.ts +7 -1
- package/dist/cli/config.js +11 -2
- package/dist/cli/destructive.d.ts +1 -1
- package/dist/cli/destructive.js +307 -9
- package/dist/cli/index.js +254 -44
- package/dist/cli/mcp.d.ts +23 -0
- package/dist/cli/mcp.js +187 -150
- package/dist/cli/migrate.d.ts +243 -3
- package/dist/cli/migrate.js +423 -45
- package/dist/cli/sql-statements.js +27 -0
- package/dist/cli/studio.d.ts +0 -1
- package/dist/cli/studio.js +10 -7
- package/dist/client.d.ts +8 -1
- package/dist/client.js +7 -0
- package/dist/connection-url.d.ts +82 -0
- package/dist/connection-url.js +183 -0
- package/dist/errors.d.ts +112 -12
- package/dist/errors.js +558 -114
- package/dist/generate.js +47 -15
- package/dist/index.d.ts +1 -1
- package/dist/introspect.d.ts +33 -0
- package/dist/introspect.js +53 -1
- package/dist/mssql.js +21 -1
- package/dist/nested-write.js +85 -14
- package/dist/pipeline-submittable.d.ts +2 -0
- package/dist/pipeline-submittable.js +87 -3
- package/dist/pipeline.js +14 -2
- package/dist/powdb-introspect.d.ts +5 -1
- package/dist/powdb-introspect.js +5 -1
- package/dist/powql.d.ts +14 -0
- package/dist/powql.js +45 -5
- package/dist/prisma-compat.js +96 -9
- package/dist/query/aggregates.js +56 -6
- package/dist/query/builder.d.ts +76 -13
- package/dist/query/builder.js +188 -58
- package/dist/query/compound-unique.d.ts +76 -0
- package/dist/query/compound-unique.js +126 -1
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +65 -11
- package/dist/query/where.d.ts +85 -19
- package/dist/query/where.js +260 -47
- package/dist/query/writes.d.ts +11 -2
- package/dist/query/writes.js +117 -22
- package/dist/seed.d.ts +16 -0
- package/dist/seed.js +16 -0
- package/package.json +3 -3
- package/skills/turbine-orm/SKILL.md +37 -10
package/README.md
CHANGED
|
@@ -8,6 +8,8 @@ Turbine compiles typed queries straight to SQL. There is no query engine, no WAS
|
|
|
8
8
|
npm install turbine-orm
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
Turbine is **pre-1.0** (`0.x`). Which surfaces hold steady across minors, which are Experimental, and what has to be true before 1.0: [STABILITY.md](STABILITY.md).
|
|
12
|
+
|
|
11
13
|
**Docs: [turbineorm.dev](https://turbineorm.dev)** · [Quick Start](https://turbineorm.dev/quickstart) · [Why Turbine](https://turbineorm.dev/why-turbine) · [API Reference](https://turbineorm.dev/queries) · [Relations](https://turbineorm.dev/relations) · [AI Agents](https://turbineorm.dev/ai-agents) · [Benchmarks](https://turbineorm.dev/benchmarks) · [Errors](https://turbineorm.dev/errors)
|
|
12
14
|
|
|
13
15
|
**Contents:** [Why Turbine](#why-turbine) · [Benchmarks](#benchmarks) · [Quick Start](#quick-start) · [Queries](#queries) · [Built for agents](#built-for-agents) · [Safety tooling](#safety-tooling) · [Postgres features](#postgres-features) · [Serverless and edge](#serverless-and-edge) · [Database engines](#database-engines) · [From scratch, and forkable](#from-scratch-and-forkable) · [Comparison](#comparison) · [Limitations](#limitations) · [Requirements](#requirements) · [Contributing](#contributing)
|
|
@@ -16,7 +18,7 @@ npm install turbine-orm
|
|
|
16
18
|
|
|
17
19
|
Six reasons, each with the mechanism that makes it true:
|
|
18
20
|
|
|
19
|
-
1. **One dependency.** `dependencies` is `{ "pg": "^8.13.1" }`. No engine binary, no WASM compiler, no adapter packages in lockstep. The optional engines (SQLite, MySQL, SQL Server, PowDB) are peer dependencies or Node builtins you install only if you use them.
|
|
21
|
+
1. **One dependency.** `dependencies` is `{ "pg": "^8.13.1" }`. No engine binary, no WASM compiler, no adapter packages in lockstep. The optional engines (SQLite, MySQL, SQL Server, PowDB, all tiered Experimental in [STABILITY.md](STABILITY.md)) are peer dependencies or Node builtins you install only if you use them.
|
|
20
22
|
2. **Written from scratch.** Turbine is not a layer over Knex or a query-builder library. Query compilation is plain string building with an FNV-1a shape fingerprint into a bounded LRU of SQL templates, so there is no plan cache to size and no compiler running on your event loop.
|
|
21
23
|
3. **Nested relations in one statement.** A `with` clause compiles to correlated `json_agg` subqueries, so users with posts with comments is one round trip, typed end to end: `users[0].posts[0].comments[0].author.name` autocompletes with no annotation.
|
|
22
24
|
4. **Close to raw SQL.** In the last published run, Turbine's overhead over a hand-written `pg` control was 1.08x by geometric mean. The table is below; the losses are stated with the wins.
|
|
@@ -58,6 +60,7 @@ Reproduce it: `cd benchmarks && npm install && npx prisma generate && DATABASE_U
|
|
|
58
60
|
```bash
|
|
59
61
|
npm install turbine-orm
|
|
60
62
|
npm install --save-dev tsx # the CLI loads .ts config/schema files via tsx
|
|
63
|
+
npm pkg set type=module # the snippet below uses top-level await: ESM, or save it as a .mts file
|
|
61
64
|
|
|
62
65
|
npx turbine init --url postgres://user:pass@localhost:5432/mydb
|
|
63
66
|
npx turbine generate # introspect the DB, emit a typed client
|
|
@@ -103,9 +106,9 @@ const users = await db.users.findMany({
|
|
|
103
106
|
// users[0].posts[0].comments is typed, and this was one SQL statement
|
|
104
107
|
```
|
|
105
108
|
|
|
106
|
-
Per-relation `where`, `orderBy`, `limit`, `select`, and `omit` work at every depth. Many-to-many junction tables are auto-detected during `generate`, and self-referencing
|
|
109
|
+
Per-relation `where`, `orderBy`, `limit`, `select`, and `omit` work at every depth. Many-to-many junction tables are auto-detected during `generate`, and a self-referencing FK (`users.manager_id` referencing `users.id`) gives you both directions, named `user` (to-one) and `users` (to-many) by default and renameable in `turbine.config.ts`. Relation filters (`some` / `every` / `none`) filter parents by their children.
|
|
107
110
|
|
|
108
|
-
Four load strategies produce identical rows: `join` (one statement), `batched` (one flat follow-up per relation), `flatten` (LEFT JOIN for eligible to-one relations), and `auto` (the default: the join plan, falling back to batched per relation when the correlation column has no covering index). A differential fuzz suite holds
|
|
111
|
+
Four load strategies produce identical rows: `join` (one statement), `batched` (one flat follow-up per relation), `flatten` (LEFT JOIN for eligible to-one relations), and `auto` (the default: the join plan, falling back to batched per relation when the correlation column has no covering index). A differential fuzz suite holds `join`, `batched` and `auto` to byte-identical output on every release and nightly; `flatten` is held to the same parity by its own deterministic suite (`src/test/flatten-parity.integration.test.ts`). Details: [turbineorm.dev/relations](https://turbineorm.dev/relations).
|
|
109
112
|
|
|
110
113
|
### Writes, including atomic operators
|
|
111
114
|
|
|
@@ -214,7 +217,7 @@ try {
|
|
|
214
217
|
}
|
|
215
218
|
```
|
|
216
219
|
|
|
217
|
-
Every error extends `TurbineError` with a stable code (`TURBINE_E001` through `E018`) and a `docsUrl`. Error messages carry keys, never values: a `NotFoundError` says `where: { id, email }` without printing the email, so errors are safe to forward to a tracker without a scrubbing rule. Retryable failures (`DeadlockError`, `SerializationFailureError`) expose `isRetryable: true` as a typed const. Full table: [turbineorm.dev/errors](https://turbineorm.dev/errors).
|
|
220
|
+
Every error Turbine raises extends `TurbineError` with a stable code (`TURBINE_E001` through `E018`) and a `docsUrl`; a driver error whose SQLSTATE Turbine does not map stays the driver's own error, carrying that SQLSTATE on `.code`. Error messages carry keys, never values: a `NotFoundError` says `where: { id, email }` without printing the email, so errors are safe to forward to a tracker without a scrubbing rule. Retryable failures (`DeadlockError`, `SerializationFailureError`) expose `isRetryable: true` as a typed const. `err.code` is the check that survives a mixed ESM/CJS module graph, where two copies of the package hold two copies of every class; since 0.78 `instanceof` works across copies too, through a cross-copy brand on every error. Full table: [turbineorm.dev/errors](https://turbineorm.dev/errors).
|
|
218
221
|
|
|
219
222
|
## Built for agents
|
|
220
223
|
|
|
@@ -269,7 +272,7 @@ Going deep on one database means the parts other ORMs push to raw SQL are typed
|
|
|
269
272
|
|
|
270
273
|
## Serverless and edge
|
|
271
274
|
|
|
272
|
-
The core is driver-agnostic: hand any pg-compatible pool to `turbineHttp()` and Turbine runs on Vercel Edge, Cloudflare Workers, Deno Deploy, or anywhere else without TCP. The main entry's import graph is held under **
|
|
275
|
+
The core is driver-agnostic: hand any pg-compatible pool to `turbineHttp()` and Turbine runs on Vercel Edge, Cloudflare Workers, Deno Deploy, or anywhere else without TCP. The main entry's import graph is held under **91 kB brotli** (edge entry under **72 kB**) with `pg` external, enforced by `size-limit` in CI at those exact numbers; run `npm run size` for the current figure.
|
|
273
276
|
|
|
274
277
|
```typescript
|
|
275
278
|
import { Pool } from '@neondatabase/serverless';
|
|
@@ -284,7 +287,7 @@ HTTP drivers cannot hold a cursor or a LISTEN connection, so `findManyStream` an
|
|
|
284
287
|
|
|
285
288
|
## Database engines
|
|
286
289
|
|
|
287
|
-
Postgres is the default and primary target. The same typed API also runs on **SQLite** (Node's built-in `node:sqlite`, zero extra installs, Node ≥ 22.5), **MySQL 8** (`mysql2`), **SQL Server 2016+** (`mssql`), and **PowDB** (embedded or networked), each behind a subpath export with its driver as an optional peer:
|
|
290
|
+
Postgres is the default and primary target. The same typed API also runs on **SQLite** (Node's built-in `node:sqlite`, zero extra installs, Node ≥ 22.5), **MySQL 8** (`mysql2`), **SQL Server 2016+** (`mssql`), and **PowDB** (embedded or networked), each behind a subpath export with its driver as an optional peer. All four are **Experimental** in the [stability policy](STABILITY.md): the query API they expose is the shared one, but each engine's capability boundaries and factory options may still change in a minor release.
|
|
288
291
|
|
|
289
292
|
```bash
|
|
290
293
|
npm install turbine-orm # SQLite needs nothing else
|
|
@@ -318,7 +321,7 @@ const schema = defineSchema({
|
|
|
318
321
|
},
|
|
319
322
|
posts: {
|
|
320
323
|
id: { type: 'serial', primaryKey: true },
|
|
321
|
-
// "table.column"
|
|
324
|
+
// "table.column": this is what makes `with: { posts: true }` work below.
|
|
322
325
|
userId: { type: 'integer', notNull: true, references: 'users.id' },
|
|
323
326
|
title: { type: 'text', notNull: true },
|
|
324
327
|
},
|
|
@@ -356,11 +359,11 @@ It is also built to be extended rather than wrapped. All SQL generation routes t
|
|
|
356
359
|
|---|---|---|---|---|
|
|
357
360
|
| **Engine / runtime** | No engine binary (`pg` only) | Client + TS/WASM query compiler | No engine | No engine |
|
|
358
361
|
| **Runtime deps** | 1 (`pg`) | `@prisma/client` + required driver adapter | 0 | 0 |
|
|
359
|
-
| **Main bundle (brotli)** | under
|
|
362
|
+
| **Main bundle (brotli)** | under 91 kB import graph (CI-enforced), `pg` external | ~1.6 MB client (TS/WASM compiler) | ~7 KB core | small |
|
|
360
363
|
| **Studio** | Read-only by default | Full CRUD, cloud-hosted | Full CRUD; [Gateway](https://gateway.drizzle.team/) self-hosted, free | None |
|
|
361
364
|
| **Error PII safety** | Keys only by default | Values in messages | Raw pg errors | Raw pg errors |
|
|
362
365
|
| **Migrations** | SQL-first, SHA-256 checksums | DSL-generated, shadow DB | SQL or Drizzle Kit | None |
|
|
363
|
-
| **Edge runtime** | One import swap, under
|
|
366
|
+
| **Edge runtime** | One import swap, under 72 kB brotli (CI-enforced) | Driver adapter + WASM compiler | Native | Native |
|
|
364
367
|
| **Pipeline batching** | Parse/Bind/Execute protocol | Sequential in txn | Sequential | Manual |
|
|
365
368
|
| **Typed errors** | `isRetryable` discriminant | Error codes only | None | None |
|
|
366
369
|
| **Nested relations** | 1 query, deep type inference | 1 query per relation by default; single-query `relationJoins` is Preview | 1 query, `relations()` re-declaration | Manual (`jsonArrayFrom`) |
|
|
@@ -406,6 +409,7 @@ Stated so you do not find out three weeks in:
|
|
|
406
409
|
- Node.js ≥ 20 (the SQLite engine needs ≥ 22.5 for `node:sqlite`)
|
|
407
410
|
- PostgreSQL ≥ 14 tested; CI runs the integration suite against PostgreSQL 14, 15, 16, and 17
|
|
408
411
|
- ESM and CommonJS both supported
|
|
412
|
+
- `@types/node` in `devDependencies` when you typecheck with `skipLibCheck: false`: the `turbine-orm/sqlite` engine and the `turbine-orm/cli` declarations reference Node's own types (`node:sqlite`, `NodeJS`)
|
|
409
413
|
|
|
410
414
|
## Contributing
|
|
411
415
|
|
package/dist/cjs/cli/config.d.ts
CHANGED
|
@@ -230,5 +230,11 @@ export declare function connectionStringHasPassword(connectionString: string): b
|
|
|
230
230
|
* `.env` that holds the real value. The refusal lives here rather than at the
|
|
231
231
|
* call site so no future caller can reintroduce the leak by passing the raw
|
|
232
232
|
* `--url` through.
|
|
233
|
+
*
|
|
234
|
+
* `schema` is the RESOLVED Postgres schema (`--schema` or the default), written
|
|
235
|
+
* as the config's `schema` value. `init --schema app` probes `app` and then
|
|
236
|
+
* used to write `schema: 'public'` regardless, so the next documented step,
|
|
237
|
+
* `push`, diffed the starter schema against the wrong namespace and proposed
|
|
238
|
+
* dropping every column it found there.
|
|
233
239
|
*/
|
|
234
|
-
export declare function configTemplate(connectionString?: string): string;
|
|
240
|
+
export declare function configTemplate(connectionString?: string, schema?: string): string;
|
package/dist/cjs/cli/config.js
CHANGED
|
@@ -301,9 +301,18 @@ function connectionStringHasPassword(connectionString) {
|
|
|
301
301
|
* `.env` that holds the real value. The refusal lives here rather than at the
|
|
302
302
|
* call site so no future caller can reintroduce the leak by passing the raw
|
|
303
303
|
* `--url` through.
|
|
304
|
+
*
|
|
305
|
+
* `schema` is the RESOLVED Postgres schema (`--schema` or the default), written
|
|
306
|
+
* as the config's `schema` value. `init --schema app` probes `app` and then
|
|
307
|
+
* used to write `schema: 'public'` regardless, so the next documented step,
|
|
308
|
+
* `push`, diffed the starter schema against the wrong namespace and proposed
|
|
309
|
+
* dropping every column it found there.
|
|
304
310
|
*/
|
|
305
|
-
function configTemplate(connectionString) {
|
|
311
|
+
function configTemplate(connectionString, schema = 'public') {
|
|
306
312
|
const inlineUrl = connectionString && !connectionStringHasPassword(connectionString) ? connectionString : undefined;
|
|
313
|
+
// Same delimiter rule as the URL below: a schema name is an arbitrary
|
|
314
|
+
// identifier, and a quote in it must not end the TS string early.
|
|
315
|
+
const schemaLiteral = schema.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
307
316
|
// Single quotes are the string delimiter in the emitted TS, so a connection
|
|
308
317
|
// string containing one would otherwise produce a config file that does not
|
|
309
318
|
// parse (a password-free URL can still carry a quote in a query parameter).
|
|
@@ -324,7 +333,7 @@ ${urlLine}
|
|
|
324
333
|
out: './generated/turbine',
|
|
325
334
|
|
|
326
335
|
/** Postgres schema to introspect (default: public) */
|
|
327
|
-
schema: '
|
|
336
|
+
schema: '${schemaLiteral}',
|
|
328
337
|
|
|
329
338
|
/** Tables to exclude from generation */
|
|
330
339
|
// exclude: ['_migrations', '_sessions'],
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* migration runner. Two lexers is how this guard came to disagree with the
|
|
29
29
|
* executor about what a file contained (see that module's header).
|
|
30
30
|
*/
|
|
31
|
-
export type DestructiveKind = 'drop-table' | 'drop-schema' | 'drop-database' | 'drop-owned' | 'drop-matview' | 'drop-column' | 'drop-cascade' | 'detach-partition' | 'truncate' | 'delete' | 'update-without-where' | 'alter-column-type' | 'merge-delete' | 'rename' | 'dynamic-destructive';
|
|
31
|
+
export type DestructiveKind = 'drop-table' | 'drop-schema' | 'drop-database' | 'drop-owned' | 'drop-matview' | 'drop-column' | 'drop-cascade' | 'detach-partition' | 'truncate' | 'delete' | 'update-without-where' | 'alter-column-type' | 'merge-delete' | 'rename' | 'rewrite-rule' | 'dynamic-destructive' | 'dynamic-unclassified';
|
|
32
32
|
export interface DestructiveStatement {
|
|
33
33
|
/** The offending SQL statement (trimmed, possibly long, display truncated) */
|
|
34
34
|
statement: string;
|
|
@@ -49,7 +49,9 @@ exports.DESTRUCTIVE_KIND_LABEL = {
|
|
|
49
49
|
'alter-column-type': 'rewrites a column type (cast may truncate or fail)',
|
|
50
50
|
'merge-delete': 'deletes matched rows (MERGE ... THEN DELETE)',
|
|
51
51
|
rename: 'renames a table or column, every query and view referencing the old name breaks',
|
|
52
|
+
'rewrite-rule': 'installs a rewrite rule that runs a destructive statement every time a later ordinary statement matches it',
|
|
52
53
|
'dynamic-destructive': 'runs destructive SQL assembled at run time, what it destroys cannot be known without running it',
|
|
54
|
+
'dynamic-unclassified': 'runs SQL assembled at run time whose statement the scanner cannot read; confirm what it does yourself',
|
|
53
55
|
};
|
|
54
56
|
/** Unquote a "quoted" identifier for display. */
|
|
55
57
|
const ident = (raw) => (raw ?? '?').replace(/^"|"$/g, '');
|
|
@@ -154,6 +156,25 @@ const RULES = [
|
|
|
154
156
|
target: (m) => (m[4] ? `${ident(m[2])}.${ident(m[4])}` : ident(m[2])),
|
|
155
157
|
also: (stmt) => !hasTopLevelWhere(stmt),
|
|
156
158
|
},
|
|
159
|
+
{
|
|
160
|
+
// `CREATE RULE r AS ON INSERT TO t DO INSTEAD DELETE FROM u`. The rule
|
|
161
|
+
// destroys nothing when it is created, which is why it passed: the DELETE
|
|
162
|
+
// runs later, on every ordinary INSERT into `t`, and the first one emptied
|
|
163
|
+
// the table. Deferred destruction is still destruction the migration
|
|
164
|
+
// installs. `DO ALSO` counts too (the action runs in addition), and the
|
|
165
|
+
// action may be a parenthesized list, whose FIRST statement decides.
|
|
166
|
+
kind: 'rewrite-rule',
|
|
167
|
+
regex: new RegExp(String.raw `^CREATE\s+(?:OR\s+REPLACE\s+)?RULE\s+${IDENT}\s+AS\s+ON\s+(?:SELECT|INSERT|UPDATE|DELETE)\s+TO\s+${IDENT}${OUTSIDE_QUOTES}\bDO\s+(?:ALSO\s+|INSTEAD\s+)?\(?\s*(DELETE|UPDATE|TRUNCATE|DROP)\b`, 'i'),
|
|
168
|
+
target: (m) => `${qualified(m, 4)} (${(m[7] ?? '').toUpperCase()} on every matching statement)`,
|
|
169
|
+
// An UPDATE action is judged the way a top-level UPDATE is: destructive
|
|
170
|
+
// without a WHERE, ordinary with one. `CREATE RULE v_upd AS ON UPDATE TO v
|
|
171
|
+
// DO INSTEAD UPDATE base SET n = 1 WHERE id = 2` is the standard updatable
|
|
172
|
+
// -view idiom and was being reported, which is a false positive on a shape
|
|
173
|
+
// that appears in ordinary migrations. DELETE, TRUNCATE and DROP stay
|
|
174
|
+
// unconditional: a DELETE inside a rule destroys on every matching
|
|
175
|
+
// statement whether or not it is narrowed, and the other two take no WHERE.
|
|
176
|
+
also: (stmt) => !/\bDO\s+(?:ALSO\s+|INSTEAD\s+)?\(?\s*UPDATE\b/i.test(stmt) || !hasTopLevelWhere(stmt),
|
|
177
|
+
},
|
|
157
178
|
// Renames come LAST: they destroy no data, so any statement that is BOTH a
|
|
158
179
|
// rename and a data-loss operation should report the data loss instead.
|
|
159
180
|
{
|
|
@@ -226,6 +247,28 @@ function matchRules(candidate) {
|
|
|
226
247
|
}
|
|
227
248
|
return null;
|
|
228
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* The DML inside `COPY (DELETE FROM t RETURNING *) TO ...`.
|
|
252
|
+
*
|
|
253
|
+
* The same trick as a data-modifying CTE, through a different wrapper: the
|
|
254
|
+
* statement reads as a COPY, which nothing here treats as destructive, and the
|
|
255
|
+
* DELETE inside it runs and empties the table. Verified live on PostgreSQL 17
|
|
256
|
+
* through node-postgres, `TO STDOUT` included, so it needs no server-side file
|
|
257
|
+
* permission and no `TO PROGRAM`. `cteSubstatements` does not reach it because
|
|
258
|
+
* that one only fires on a leading `WITH`.
|
|
259
|
+
*/
|
|
260
|
+
function copySubstatements(stmt) {
|
|
261
|
+
if (!/^COPY\s*\(/i.test(stmt))
|
|
262
|
+
return [];
|
|
263
|
+
const open = stmt.indexOf('(');
|
|
264
|
+
if (open === -1)
|
|
265
|
+
return [];
|
|
266
|
+
const inner = stmt.slice(open + 1, closingParenIndex(stmt, open)).trim();
|
|
267
|
+
// A `COPY (SELECT ...) TO` is the ordinary export form and destroys nothing.
|
|
268
|
+
// A leading WITH is handed on so its own CTE bodies are read by the rule
|
|
269
|
+
// above rather than being judged as one blob here.
|
|
270
|
+
return /^(?:DELETE|UPDATE|INSERT|MERGE|WITH)\b/i.test(inner) ? [inner] : [];
|
|
271
|
+
}
|
|
229
272
|
/**
|
|
230
273
|
* Data-modifying CTE bodies: `WITH d AS (DELETE FROM t ...) SELECT ...` runs a
|
|
231
274
|
* real DELETE even though the statement reads as a SELECT. Each candidate is cut
|
|
@@ -298,7 +341,7 @@ function proceduralCandidates(body) {
|
|
|
298
341
|
const re = /\b(?:DROP|TRUNCATE|DELETE|ALTER|UPDATE|MERGE)\s/gi;
|
|
299
342
|
let m = re.exec(text);
|
|
300
343
|
while (m !== null) {
|
|
301
|
-
out.push({ text: text.slice(m.index), before: text.slice(0, m.index) });
|
|
344
|
+
out.push({ text: text.slice(m.index), before: text.slice(0, m.index), statementCode: text });
|
|
302
345
|
m = re.exec(text);
|
|
303
346
|
}
|
|
304
347
|
}
|
|
@@ -326,6 +369,7 @@ function scanDestructiveSql(sql) {
|
|
|
326
369
|
const candidates = [
|
|
327
370
|
stripLeadingCtes(body),
|
|
328
371
|
...cteSubstatements(body),
|
|
372
|
+
...copySubstatements(body).flatMap((inner) => [inner, ...cteSubstatements(inner)]),
|
|
329
373
|
].map((text) => ({
|
|
330
374
|
text,
|
|
331
375
|
display,
|
|
@@ -343,10 +387,20 @@ function scanDestructiveSql(sql) {
|
|
|
343
387
|
candidates.push({
|
|
344
388
|
text: part.text,
|
|
345
389
|
display: `${display} [in block: ${part.text.replace(/\s+/g, ' ').slice(0, 60)}]`,
|
|
390
|
+
statementCode: part.statementCode,
|
|
346
391
|
});
|
|
347
392
|
proceduralParts.push(part);
|
|
348
393
|
}
|
|
349
394
|
}
|
|
395
|
+
// Sub-statements of a procedural body that some pass has already named.
|
|
396
|
+
// ONE body holds several statements and each is judged on its own, so a
|
|
397
|
+
// pass that reports one must not silence the others: `DO $$ BEGIN EXECUTE
|
|
398
|
+
// 'ALTER TABLE t DROP COLUMN x'; EXECUTE 'D'||'ROP TABLE victim'; END $$`
|
|
399
|
+
// reported only the DROP COLUMN, and the DROP TABLE then ran under a
|
|
400
|
+
// confirmation the operator gave for something else. Equally, the
|
|
401
|
+
// unclassified backstop must not re-report a statement an earlier pass
|
|
402
|
+
// already named, which is what this set is for.
|
|
403
|
+
const spokenFor = new Set();
|
|
350
404
|
let matched = false;
|
|
351
405
|
for (const candidate of candidates) {
|
|
352
406
|
const hit = matchRules(candidate.text);
|
|
@@ -354,9 +408,15 @@ function scanDestructiveSql(sql) {
|
|
|
354
408
|
continue;
|
|
355
409
|
found.push({ statement: candidate.display, kind: hit.kind, target: hit.target });
|
|
356
410
|
matched = true;
|
|
357
|
-
|
|
411
|
+
if (candidate.statementCode !== undefined)
|
|
412
|
+
spokenFor.add(candidate.statementCode);
|
|
413
|
+
// A statement outside a procedural body has exactly one verdict, as
|
|
414
|
+
// before. Inside one, keep going: the remaining sub-statements have not
|
|
415
|
+
// been judged yet.
|
|
416
|
+
if (!procedural)
|
|
417
|
+
break;
|
|
358
418
|
}
|
|
359
|
-
if (matched)
|
|
419
|
+
if (matched && !procedural)
|
|
360
420
|
continue;
|
|
361
421
|
// Nothing matched a rule. Inside a PROCEDURAL body that is not the end of
|
|
362
422
|
// the question, because the rules all need a parseable object name and
|
|
@@ -379,7 +439,10 @@ function scanDestructiveSql(sql) {
|
|
|
379
439
|
// `RAISE NOTICE 'DROP the mic'` would prompt, and a guard that fires on
|
|
380
440
|
// prose teaches operators to confirm without reading, which costs more than
|
|
381
441
|
// it saves.
|
|
442
|
+
let dynamicHit = false;
|
|
382
443
|
for (const part of proceduralParts) {
|
|
444
|
+
if (spokenFor.has(part.statementCode))
|
|
445
|
+
continue;
|
|
383
446
|
const kind = dynamicDestructiveKind(part);
|
|
384
447
|
if (!kind)
|
|
385
448
|
continue;
|
|
@@ -388,11 +451,210 @@ function scanDestructiveSql(sql) {
|
|
|
388
451
|
kind,
|
|
389
452
|
target: exports.DYNAMIC_TARGET,
|
|
390
453
|
});
|
|
391
|
-
|
|
454
|
+
dynamicHit = true;
|
|
455
|
+
spokenFor.add(part.statementCode);
|
|
456
|
+
}
|
|
457
|
+
// Still nothing, in a procedural body. Every pass above needs to SEE a verb,
|
|
458
|
+
// and an EXECUTE whose text is assembled so that no verb is visible
|
|
459
|
+
// (`'D' || 'ROP TABLE t'`, `chr(68) || ...`, `reverse(...)`, an escape-
|
|
460
|
+
// encoded literal, a variable built across statements) walked past all of
|
|
461
|
+
// them and dropped the table live. The scanner cannot classify such a
|
|
462
|
+
// statement, and "cannot classify" must not be reported as "clean": that is
|
|
463
|
+
// the consent gate deciding in the author's favour on no evidence. So it
|
|
464
|
+
// asks, with a kind whose label says exactly that.
|
|
465
|
+
for (const block of procedural ? statement.blocks : []) {
|
|
466
|
+
for (const unreadable of unclassifiableExecutes(block, matched || dynamicHit)) {
|
|
467
|
+
if (spokenFor.has(unreadable.code))
|
|
468
|
+
continue;
|
|
469
|
+
spokenFor.add(unreadable.code);
|
|
470
|
+
found.push({
|
|
471
|
+
statement: `${display} [in block: ${`EXECUTE ${unreadable.expr}`.replace(/\s+/g, ' ').slice(0, 60)}]`,
|
|
472
|
+
kind: 'dynamic-unclassified',
|
|
473
|
+
target: exports.DYNAMIC_TARGET,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
392
476
|
}
|
|
393
477
|
}
|
|
394
478
|
return found;
|
|
395
479
|
}
|
|
480
|
+
/**
|
|
481
|
+
* Statement openers that destroy nothing, so an assembled `EXECUTE` whose text
|
|
482
|
+
* visibly begins with one of them is left alone. `UPDATE` is here because its
|
|
483
|
+
* destructive form is the ABSENCE of a `WHERE`, which the tail of an assembled
|
|
484
|
+
* fragment cannot answer; flagging every dynamic `UPDATE ... WHERE` is the
|
|
485
|
+
* false-positive cost this module refuses to pay.
|
|
486
|
+
*/
|
|
487
|
+
const HARMLESS_OPENER = /^(?:SELECT|INSERT|UPDATE|CREATE|COMMENT|GRANT|REVOKE|ANALYZE|ANALYSE|REFRESH|VACUUM|REINDEX|CLUSTER|SET|RESET|SHOW|NOTIFY|LOCK|CALL|PERFORM|EXPLAIN)\b/i;
|
|
488
|
+
/**
|
|
489
|
+
* Openers whose rules deliberately stay SILENT on a benign form, so silence
|
|
490
|
+
* from them is a decision rather than a failure to parse. `ALTER TABLE ... ADD
|
|
491
|
+
* COLUMN` loses nothing and is not reported on purpose; a `MERGE` without a
|
|
492
|
+
* `THEN DELETE` likewise; a `WITH` whose CTE bodies are all reads likewise.
|
|
493
|
+
* Reporting these again here would contradict that silence.
|
|
494
|
+
*/
|
|
495
|
+
const VERB_SILENT_WHEN_BENIGN = /^(?:ALTER|MERGE|WITH)\b/i;
|
|
496
|
+
/**
|
|
497
|
+
* Openers whose rules ALWAYS report when they can read the statement. Silence
|
|
498
|
+
* from one of these is not a verdict, it is the rules failing to parse, so the
|
|
499
|
+
* skip is conditional on an earlier pass having actually reported.
|
|
500
|
+
*
|
|
501
|
+
* The shape that made the distinction necessary: two adjacent string literals
|
|
502
|
+
* are ONE string in PostgreSQL, so `EXECUTE 'DROP TABLE '\n'victims'` reads as
|
|
503
|
+
* `DROP` to the opener test while every earlier pass declines, the literal
|
|
504
|
+
* rules because a quote is not an identifier and the assembly test because
|
|
505
|
+
* continuation is lexical and carries no `||`, `concat` or `format`. The
|
|
506
|
+
* opener was trusted, the statement was skipped, and the table went.
|
|
507
|
+
*/
|
|
508
|
+
const VERB_ALWAYS_REPORTS = /^(?:DROP|TRUNCATE|DELETE)\b/i;
|
|
509
|
+
/**
|
|
510
|
+
* `EXECUTE` in a position where plpgsql can begin a statement.
|
|
511
|
+
*
|
|
512
|
+
* Matched against a statement's `stripped` view, so the word can never be one
|
|
513
|
+
* that lived inside a string literal. `GRANT`/`REVOKE EXECUTE ON FUNCTION` are
|
|
514
|
+
* excluded by construction: their preceding token is a verb, not one of the
|
|
515
|
+
* introducers here.
|
|
516
|
+
*/
|
|
517
|
+
const EXECUTE_AT_STATEMENT_START = /(?:^|;|\bTHEN\b|\bELSE\b|\bLOOP\b|\bBEGIN\b|\bDECLARE\b)\s*EXECUTE\s/i;
|
|
518
|
+
/**
|
|
519
|
+
* Functions whose FIRST literal argument is the statement text (or its
|
|
520
|
+
* template), so the opener can be read through the call.
|
|
521
|
+
*/
|
|
522
|
+
const TEMPLATE_FN = /^(?:format|replace|regexp_replace|concat|concat_ws|array_to_string)\s*\(/i;
|
|
523
|
+
/** The leading string literal of `text`: its raw content and whether it can hide a verb. */
|
|
524
|
+
function leadingLiteral(text) {
|
|
525
|
+
const m = /^(U&'|[EeBbXx]'|'|\$([A-Za-z_][\w]*)?\$)/.exec(text);
|
|
526
|
+
if (!m)
|
|
527
|
+
return null;
|
|
528
|
+
const open = m[1];
|
|
529
|
+
if (open.startsWith('$')) {
|
|
530
|
+
const close = text.indexOf(open, open.length);
|
|
531
|
+
if (close === -1)
|
|
532
|
+
return null;
|
|
533
|
+
return { content: text.slice(open.length, close), rest: text.slice(close + open.length), escaped: false };
|
|
534
|
+
}
|
|
535
|
+
// Single-quoted: `''` is an escaped quote inside the literal.
|
|
536
|
+
let i = open.length;
|
|
537
|
+
let content = '';
|
|
538
|
+
while (i < text.length) {
|
|
539
|
+
const ch = text[i];
|
|
540
|
+
if (ch === "'") {
|
|
541
|
+
if (text[i + 1] === "'") {
|
|
542
|
+
content += "'";
|
|
543
|
+
i += 2;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
content += ch;
|
|
549
|
+
i++;
|
|
550
|
+
}
|
|
551
|
+
if (i >= text.length)
|
|
552
|
+
return null;
|
|
553
|
+
// `E'\x44ROP'` and `U&'\0044ROP'` spell a verb the scanner cannot see; an
|
|
554
|
+
// escape string with no backslash in it hides nothing.
|
|
555
|
+
const escaped = open !== "'" && content.includes('\\');
|
|
556
|
+
return { content, rest: text.slice(i + 1), escaped };
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* The first word of the statement an `EXECUTE` argument would run, read as far
|
|
560
|
+
* as the text allows, plus whether the argument is one PLAIN literal (in which
|
|
561
|
+
* case the literal passes already had their chance and this pass stays out).
|
|
562
|
+
* `null` opener = the scanner cannot see a verb at all.
|
|
563
|
+
*/
|
|
564
|
+
function executeOpener(expr) {
|
|
565
|
+
const text = expr.trim();
|
|
566
|
+
const lit = leadingLiteral(text);
|
|
567
|
+
if (lit) {
|
|
568
|
+
const word = /^\s*([A-Za-z_][\w]*)/.exec(lit.content)?.[1] ?? null;
|
|
569
|
+
// Plain: the literal IS the whole argument (bar an INTO / USING clause),
|
|
570
|
+
// spelled without escapes.
|
|
571
|
+
const plain = !lit.escaped && /^\s*(?:;|INTO\b|USING\b|$)/i.test(lit.rest);
|
|
572
|
+
return { opener: lit.escaped ? null : word, plain };
|
|
573
|
+
}
|
|
574
|
+
if (TEMPLATE_FN.test(text)) {
|
|
575
|
+
// Read the opener from the first literal argument that carries a word: a
|
|
576
|
+
// `concat_ws(' ', ...)` separator or a `format('%s', ...)` placeholder is
|
|
577
|
+
// not a verb, and neither is a `concat('DR', 'OP', ...)` fragment.
|
|
578
|
+
const inner = text.slice(text.indexOf('(') + 1);
|
|
579
|
+
let cursor = inner;
|
|
580
|
+
for (let guard = 0; guard < 16; guard++) {
|
|
581
|
+
const lit2 = leadingLiteral(cursor.trimStart());
|
|
582
|
+
if (!lit2)
|
|
583
|
+
break;
|
|
584
|
+
const word = /^\s*([A-Za-z_][\w]*)/.exec(lit2.content)?.[1];
|
|
585
|
+
if (word)
|
|
586
|
+
return { opener: lit2.escaped ? null : word, plain: false };
|
|
587
|
+
const comma = lit2.rest.indexOf(',');
|
|
588
|
+
if (comma === -1)
|
|
589
|
+
break;
|
|
590
|
+
cursor = lit2.rest.slice(comma + 1);
|
|
591
|
+
}
|
|
592
|
+
return { opener: null, plain: false };
|
|
593
|
+
}
|
|
594
|
+
// Any other function call, a parenthesised expression, or a bare variable
|
|
595
|
+
// (`EXECUTE s`, built across earlier statements): nothing readable.
|
|
596
|
+
return { opener: null, plain: false };
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* The EXECUTE argument in `body`, when it is assembled or encoded such that no
|
|
600
|
+
* statement verb is visible to the passes above. Returns the offending
|
|
601
|
+
* fragment for display, or `null` when every EXECUTE in the body either is a
|
|
602
|
+
* plain literal (the literal rules own it) or visibly begins with a verb some
|
|
603
|
+
* other rule has already judged.
|
|
604
|
+
*/
|
|
605
|
+
function unclassifiableExecutes(body, verbPassesReported) {
|
|
606
|
+
const out = [];
|
|
607
|
+
for (const statement of (0, sql_statements_js_1.tokenizeSql)(body)) {
|
|
608
|
+
// Decide PRESENCE against `stripped`, whose literals are emptied, and read
|
|
609
|
+
// the ARGUMENT out of `code`, which keeps them. Reading both out of `code`
|
|
610
|
+
// fired on the word rather than on the statement: `RAISE NOTICE 'EXECUTE
|
|
611
|
+
// the plan'` and `INSERT INTO log(msg) VALUES ('EXECUTE me later')` both
|
|
612
|
+
// prompted, and a guard that fires on prose teaches operators to confirm
|
|
613
|
+
// without reading, which is the cost this module's own doctrine refuses to
|
|
614
|
+
// pay. `stripped` cannot contain a word that only ever appeared inside a
|
|
615
|
+
// literal, so it settles the question the offsets cannot.
|
|
616
|
+
//
|
|
617
|
+
// The keyword must also sit where a plpgsql STATEMENT can start, not
|
|
618
|
+
// merely somewhere in the text: `GRANT EXECUTE ON FUNCTION f() TO app` is
|
|
619
|
+
// an ordinary permission grant and was being reported as unreadable
|
|
620
|
+
// dynamic SQL, which fails an ordinary migration under `migrate deploy`
|
|
621
|
+
// where there is no terminal to confirm at. The introducers below are the
|
|
622
|
+
// positions plpgsql actually allows one at, since `tokenizeSql` splits on
|
|
623
|
+
// top-level semicolons and a body's `IF ... THEN EXECUTE ...` therefore
|
|
624
|
+
// arrives as one statement.
|
|
625
|
+
if (!EXECUTE_AT_STATEMENT_START.test(statement.stripped))
|
|
626
|
+
continue;
|
|
627
|
+
const m = /\bEXECUTE\s+([\s\S]+)$/i.exec(statement.code);
|
|
628
|
+
if (!m)
|
|
629
|
+
continue;
|
|
630
|
+
const expr = m[1];
|
|
631
|
+
const { opener, plain } = executeOpener(expr);
|
|
632
|
+
if (plain)
|
|
633
|
+
continue;
|
|
634
|
+
// `EXECUTE s` where `s` is a bare variable: the statement it runs was
|
|
635
|
+
// assembled by some EARLIER sub-statement of the same block, so when a pass
|
|
636
|
+
// has already named one, the operator has been told what this body does and
|
|
637
|
+
// a second "cannot classify" entry for the same thing is noise that
|
|
638
|
+
// contradicts the first. When nothing was named, this is the whole finding
|
|
639
|
+
// and it stands: an assignment the rules could not read leaves
|
|
640
|
+
// `verbPassesReported` false.
|
|
641
|
+
if (opener === null && verbPassesReported && /^[A-Za-z_]\w*\s*;?$/.test(expr.trim()))
|
|
642
|
+
continue;
|
|
643
|
+
if (opener !== null) {
|
|
644
|
+
if (HARMLESS_OPENER.test(opener))
|
|
645
|
+
continue;
|
|
646
|
+
if (VERB_SILENT_WHEN_BENIGN.test(opener))
|
|
647
|
+
continue;
|
|
648
|
+
// The remaining skip is a claim that an earlier pass already reported
|
|
649
|
+
// this statement, so it holds only when one actually did. See
|
|
650
|
+
// VERB_ALWAYS_REPORTS for the shape where none does.
|
|
651
|
+
if (verbPassesReported && VERB_ALWAYS_REPORTS.test(opener))
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
out.push({ code: statement.code, expr });
|
|
655
|
+
}
|
|
656
|
+
return out;
|
|
657
|
+
}
|
|
396
658
|
/** Shown in place of an object name that does not exist until the block runs. */
|
|
397
659
|
exports.DYNAMIC_TARGET = '<name assembled at run time>';
|
|
398
660
|
/**
|
|
@@ -457,16 +719,41 @@ const ASSEMBLY_FN = /\b(?:format|concat_ws|concat|quote_ident|quote_literal|quot
|
|
|
457
719
|
const WRAPPING_ASSEMBLY_FN = /\b(?:format|concat_ws|concat|quote_ident|quote_literal|quote_nullable|array_to_string|regexp_replace|replace)\s*\([^)]*$/i;
|
|
458
720
|
/** Dynamic SQL only runs if something runs it. Scoped to the candidate's own statement. */
|
|
459
721
|
const RUNS_DYNAMIC_SQL = /\bEXECUTE\b/i;
|
|
722
|
+
/**
|
|
723
|
+
* `ALTER TABLE <assembled name> DROP [COLUMN] ...`: the sub-action that loses
|
|
724
|
+
* rows, with the same exclusions as the static `drop-column` rule (`DROP
|
|
725
|
+
* CONSTRAINT` / `DEFAULT` / `NOT NULL` / `IDENTITY` / `EXPRESSION` lose none).
|
|
726
|
+
* Matches when the keyword `COLUMN`, an `IF EXISTS`, or a literal column name
|
|
727
|
+
* follows the `DROP`; a `DROP` followed by nothing literal is handled below.
|
|
728
|
+
* {@link OUTSIDE_QUOTES} keeps `ADD COLUMN "drop me"` out, as it does statically.
|
|
729
|
+
*/
|
|
730
|
+
const DYNAMIC_ALTER_DROP_COLUMN = new RegExp(String.raw `^ALTER\s+TABLE\b${OUTSIDE_QUOTES}\bDROP\s+(?!CONSTRAINT\b|DEFAULT\b|NOT\b|IDENTITY\b|EXPRESSION\b)(?:COLUMN\b|IF\s+EXISTS\b|${IDENT})`, 'i');
|
|
731
|
+
/** `ALTER TABLE <assembled name> ALTER [COLUMN] <col> [SET DATA] TYPE ...`. */
|
|
732
|
+
const DYNAMIC_ALTER_COLUMN_TYPE = new RegExp(String.raw `^ALTER\s+TABLE\b${OUTSIDE_QUOTES}\bALTER\s+(?:COLUMN\s+)?${IDENT}\s+(?:SET\s+DATA\s+)?TYPE\b`, 'i');
|
|
733
|
+
/** The `DROP <thing>` sub-actions of ALTER TABLE that lose no rows. */
|
|
734
|
+
const DYNAMIC_ALTER_DROP_HARMLESS = new RegExp(String.raw `^ALTER\s+TABLE\b${OUTSIDE_QUOTES}\bDROP\s+(?:CONSTRAINT|DEFAULT|NOT\s+NULL|IDENTITY|EXPRESSION)\b`, 'i');
|
|
460
735
|
/**
|
|
461
736
|
* The kind a runtime-assembled procedural fragment should be reported as, or
|
|
462
737
|
* `null` when it is not dynamic (so a rule already had its chance) or its verb
|
|
463
738
|
* is not one that destroys data on its own.
|
|
464
739
|
*
|
|
465
|
-
* `ALTER`
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
740
|
+
* `ALTER TABLE` IS decided here, by the sub-action that follows the assembled
|
|
741
|
+
* table name, because in the shape that matters that sub-action is literal
|
|
742
|
+
* text: a multi-tenant loop assembles the TABLE (`'ALTER TABLE ' ||
|
|
743
|
+
* quote_ident(t) || ' DROP COLUMN legacy_phone'`) and writes out what it does
|
|
744
|
+
* to it. That statement dropped the column from every tenant table while the
|
|
745
|
+
* scan reported a clean inventory, and its `DROP TABLE` twin was already being
|
|
746
|
+
* flagged, so an operator who had seen the guard fire once would assume this
|
|
747
|
+
* was covered. Only the forms that lose rows are reported (`DROP [COLUMN]`,
|
|
748
|
+
* `ALTER COLUMN ... TYPE`), with the same exclusions as their static rules; an
|
|
749
|
+
* assembled `ADD COLUMN` stays silent. A `DROP` whose object is itself in the
|
|
750
|
+
* runtime expression (`' DROP ' || what`) is `dynamic-destructive`, the bare
|
|
751
|
+
* `DROP` precedent below: alarming is fine, wrong is not.
|
|
752
|
+
*
|
|
753
|
+
* `UPDATE` is still deliberately absent even though {@link proceduralCandidates}
|
|
754
|
+
* collects it: its destructive form is the ABSENCE of a `WHERE`, and absence
|
|
755
|
+
* is not decidable from a fragment whose tail is a runtime expression, so
|
|
756
|
+
* including it would flag every dynamic `UPDATE ... WHERE` in the file.
|
|
470
757
|
*/
|
|
471
758
|
function dynamicDestructiveKind({ text, before }) {
|
|
472
759
|
const assembled = DYNAMIC_ASSEMBLY.test(text) ||
|
|
@@ -474,6 +761,17 @@ function dynamicDestructiveKind({ text, before }) {
|
|
|
474
761
|
(RUNS_DYNAMIC_SQL.test(before) && WRAPPING_ASSEMBLY_FN.test(before));
|
|
475
762
|
if (!assembled)
|
|
476
763
|
return null;
|
|
764
|
+
if (/^ALTER\s+TABLE\b/i.test(text)) {
|
|
765
|
+
if (DYNAMIC_ALTER_DROP_COLUMN.test(text))
|
|
766
|
+
return 'drop-column';
|
|
767
|
+
if (DYNAMIC_ALTER_COLUMN_TYPE.test(text))
|
|
768
|
+
return 'alter-column-type';
|
|
769
|
+
if (DYNAMIC_ALTER_DROP_HARMLESS.test(text))
|
|
770
|
+
return null;
|
|
771
|
+
if (new RegExp(String.raw `^ALTER\s+TABLE\b${OUTSIDE_QUOTES}\bDROP\b`, 'i').test(text))
|
|
772
|
+
return 'dynamic-destructive';
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
477
775
|
if (/^DROP\s+TABLE\b/i.test(text))
|
|
478
776
|
return 'drop-table';
|
|
479
777
|
if (/^DROP\s+SCHEMA\b/i.test(text))
|