turbine-orm 0.65.0 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -32
- package/dist/adapters/cockroachdb.js +21 -3
- package/dist/adapters/index.d.ts +15 -0
- package/dist/adapters/yugabytedb.js +20 -3
- package/dist/cjs/adapters/cockroachdb.js +21 -3
- package/dist/cjs/adapters/index.d.ts +15 -0
- package/dist/cjs/adapters/yugabytedb.js +20 -3
- package/dist/cjs/cli/destructive.d.ts +18 -4
- package/dist/cjs/cli/destructive.js +230 -122
- package/dist/cjs/cli/index.d.ts +21 -4
- package/dist/cjs/cli/index.js +119 -22
- package/dist/cjs/cli/mcp.d.ts +28 -8
- package/dist/cjs/cli/mcp.js +170 -127
- package/dist/cjs/cli/migrate.d.ts +134 -13
- package/dist/cjs/cli/migrate.js +349 -241
- package/dist/cjs/cli/pii-predicate-guard.d.ts +112 -0
- package/dist/cjs/cli/pii-predicate-guard.js +390 -0
- package/dist/cjs/cli/prisma-resolve.js +75 -4
- package/dist/cjs/cli/prisma-schema.d.ts +17 -1
- package/dist/cjs/cli/prisma-schema.js +83 -17
- package/dist/cjs/cli/sql-statements.d.ts +125 -0
- package/dist/cjs/cli/sql-statements.js +378 -0
- package/dist/cjs/cli/studio.js +49 -118
- package/dist/cjs/cli/ui.d.ts +1 -1
- package/dist/cjs/client.d.ts +43 -0
- package/dist/cjs/client.js +125 -6
- package/dist/cjs/dialect.d.ts +123 -0
- package/dist/cjs/dialect.js +33 -0
- package/dist/cjs/errors.d.ts +74 -1
- package/dist/cjs/errors.js +239 -25
- package/dist/cjs/index-advisor.d.ts +33 -1
- package/dist/cjs/index-advisor.js +32 -1
- package/dist/cjs/introspect.d.ts +48 -0
- package/dist/cjs/introspect.js +222 -91
- package/dist/cjs/mssql.js +43 -1
- package/dist/cjs/mysql.d.ts +5 -2
- package/dist/cjs/mysql.js +202 -17
- package/dist/cjs/nested-write.js +6 -1
- package/dist/cjs/pipeline-submittable.js +17 -3
- package/dist/cjs/pipeline.js +75 -9
- package/dist/cjs/powdb.d.ts +23 -0
- package/dist/cjs/powdb.js +33 -1
- package/dist/cjs/powql.d.ts +61 -9
- package/dist/cjs/powql.js +186 -49
- package/dist/cjs/prisma-compat.js +160 -41
- package/dist/cjs/query/aggregates.d.ts +1 -1
- package/dist/cjs/query/aggregates.js +80 -18
- package/dist/cjs/query/batched-loader.d.ts +10 -0
- package/dist/cjs/query/batched-loader.js +268 -7
- package/dist/cjs/query/builder.d.ts +73 -0
- package/dist/cjs/query/builder.js +225 -28
- package/dist/cjs/query/filters.d.ts +162 -0
- package/dist/cjs/query/filters.js +250 -1
- package/dist/cjs/query/relations.d.ts +10 -10
- package/dist/cjs/query/relations.js +93 -12
- package/dist/cjs/query/types.d.ts +14 -1
- package/dist/cjs/query/utils.d.ts +146 -2
- package/dist/cjs/query/utils.js +210 -4
- package/dist/cjs/query/warn-registry.d.ts +10 -0
- package/dist/cjs/query/warn-registry.js +10 -0
- package/dist/cjs/query/where-compile.d.ts +30 -0
- package/dist/cjs/query/where-compile.js +41 -0
- package/dist/cjs/query/where.d.ts +128 -13
- package/dist/cjs/query/where.js +215 -77
- package/dist/cjs/query/writes.d.ts +1 -1
- package/dist/cjs/query/writes.js +39 -15
- package/dist/cjs/schema-builder.d.ts +2 -1
- package/dist/cjs/schema-sql.d.ts +94 -4
- package/dist/cjs/schema-sql.js +506 -30
- package/dist/cjs/schema.d.ts +3 -1
- package/dist/cjs/sqlite.d.ts +6 -0
- package/dist/cjs/sqlite.js +151 -10
- package/dist/cjs/typed-sql.d.ts +29 -1
- package/dist/cjs/typed-sql.js +30 -12
- package/dist/cli/destructive.d.ts +18 -4
- package/dist/cli/destructive.js +229 -121
- package/dist/cli/index.d.ts +21 -4
- package/dist/cli/index.js +120 -24
- package/dist/cli/mcp.d.ts +28 -8
- package/dist/cli/mcp.js +172 -129
- package/dist/cli/migrate.d.ts +134 -13
- package/dist/cli/migrate.js +347 -238
- package/dist/cli/pii-predicate-guard.d.ts +112 -0
- package/dist/cli/pii-predicate-guard.js +386 -0
- package/dist/cli/prisma-resolve.js +75 -4
- package/dist/cli/prisma-schema.d.ts +17 -1
- package/dist/cli/prisma-schema.js +83 -17
- package/dist/cli/sql-statements.d.ts +125 -0
- package/dist/cli/sql-statements.js +373 -0
- package/dist/cli/studio.js +49 -118
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +43 -0
- package/dist/client.js +126 -7
- package/dist/dialect.d.ts +123 -0
- package/dist/dialect.js +33 -0
- package/dist/errors.d.ts +74 -1
- package/dist/errors.js +228 -19
- package/dist/index-advisor.d.ts +33 -1
- package/dist/index-advisor.js +31 -1
- package/dist/introspect.d.ts +48 -0
- package/dist/introspect.js +221 -91
- package/dist/mssql.js +44 -2
- package/dist/mysql.d.ts +5 -2
- package/dist/mysql.js +203 -18
- package/dist/nested-write.js +7 -2
- package/dist/pipeline-submittable.js +18 -4
- package/dist/pipeline.js +76 -10
- package/dist/powdb.d.ts +23 -0
- package/dist/powdb.js +33 -2
- package/dist/powql.d.ts +61 -9
- package/dist/powql.js +187 -50
- package/dist/prisma-compat.js +160 -41
- package/dist/query/aggregates.d.ts +1 -1
- package/dist/query/aggregates.js +82 -20
- package/dist/query/batched-loader.d.ts +10 -0
- package/dist/query/batched-loader.js +270 -9
- package/dist/query/builder.d.ts +73 -0
- package/dist/query/builder.js +226 -30
- package/dist/query/filters.d.ts +162 -0
- package/dist/query/filters.js +246 -1
- package/dist/query/relations.d.ts +10 -10
- package/dist/query/relations.js +94 -14
- package/dist/query/types.d.ts +14 -1
- package/dist/query/utils.d.ts +146 -2
- package/dist/query/utils.js +204 -3
- package/dist/query/warn-registry.d.ts +10 -0
- package/dist/query/warn-registry.js +10 -0
- package/dist/query/where-compile.d.ts +30 -0
- package/dist/query/where-compile.js +40 -1
- package/dist/query/where.d.ts +128 -13
- package/dist/query/where.js +216 -80
- package/dist/query/writes.d.ts +1 -1
- package/dist/query/writes.js +40 -16
- package/dist/schema-builder.d.ts +2 -1
- package/dist/schema-sql.d.ts +94 -4
- package/dist/schema-sql.js +505 -30
- package/dist/schema.d.ts +3 -1
- package/dist/sqlite.d.ts +6 -0
- package/dist/sqlite.js +151 -10
- package/dist/typed-sql.d.ts +29 -1
- package/dist/typed-sql.js +30 -12
- package/package.json +6 -4
|
@@ -258,8 +258,13 @@ function parseAttributes(fragment, line) {
|
|
|
258
258
|
else {
|
|
259
259
|
j = k;
|
|
260
260
|
}
|
|
261
|
-
// `@db.VarChar(255)` etc.
|
|
262
|
-
|
|
261
|
+
// `@db.VarChar(255)` etc. The HEAD is the recorded name (so consumers keep
|
|
262
|
+
// matching on `db`), and everything after the first dot is kept as the
|
|
263
|
+
// native type rather than discarded.
|
|
264
|
+
const dot = rawName.indexOf('.');
|
|
265
|
+
const head = dot === -1 ? rawName : rawName.slice(0, dot);
|
|
266
|
+
const nativeType = dot === -1 ? undefined : rawName.slice(dot + 1);
|
|
267
|
+
attrs.push({ name: head, ...(nativeType ? { nativeType } : {}), args, block, line });
|
|
263
268
|
i = j;
|
|
264
269
|
}
|
|
265
270
|
return attrs;
|
|
@@ -292,16 +297,28 @@ function matchBrace(s, open) {
|
|
|
292
297
|
}
|
|
293
298
|
return -1;
|
|
294
299
|
}
|
|
295
|
-
/**
|
|
296
|
-
|
|
300
|
+
/**
|
|
301
|
+
* Scan the top level for `keyword Name { ... }` blocks via brace matching.
|
|
302
|
+
*
|
|
303
|
+
* A header whose keyword is not one we handle is skipped, and now RECORDED:
|
|
304
|
+
* the switch in `parsePrismaSchema` has a `default` branch that pushes an
|
|
305
|
+
* "unsupported block" warning, but nothing could ever reach it because the
|
|
306
|
+
* filter here dropped those blocks first. The body is deliberately still
|
|
307
|
+
* re-scanned (unchanged behaviour) so a recognized block nested under an
|
|
308
|
+
* unrecognized one is not lost.
|
|
309
|
+
*/
|
|
310
|
+
function scanBlocks(src, warnings) {
|
|
297
311
|
const blocks = [];
|
|
298
312
|
const headerRe = /(^|\n)[ \t]*([a-zA-Z]+)[ \t]+([A-Za-z_]\w*)[ \t]*\{/g;
|
|
299
313
|
let m;
|
|
300
314
|
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex exec loop
|
|
301
315
|
while ((m = headerRe.exec(src)) !== null) {
|
|
302
316
|
const keyword = m[2];
|
|
303
|
-
if (!BLOCK_KEYWORDS.has(keyword))
|
|
317
|
+
if (!BLOCK_KEYWORDS.has(keyword)) {
|
|
318
|
+
warnings.push(`Skipped unsupported block "${keyword} ${m[3]}" (line ${lineAt(src, m.index + m[1].length)}): the parser ` +
|
|
319
|
+
`understands model / view / type / enum / datasource / generator blocks only.`);
|
|
304
320
|
continue;
|
|
321
|
+
}
|
|
305
322
|
const braceOpen = src.indexOf('{', m.index);
|
|
306
323
|
const close = matchBrace(src, braceOpen);
|
|
307
324
|
const headerLine = lineAt(src, m.index + m[1].length);
|
|
@@ -360,23 +377,57 @@ function parseCompoundKey(attr, line) {
|
|
|
360
377
|
function truncate(s, n = 60) {
|
|
361
378
|
return s.length > n ? `${s.slice(0, n)}...` : s;
|
|
362
379
|
}
|
|
380
|
+
/**
|
|
381
|
+
* Field attributes that are RECOGNIZED but change what the model means, and
|
|
382
|
+
* that the name mapper does not act on. Each gets a warning naming what is
|
|
383
|
+
* being lost, so it surfaces in the report instead of being dropped in silence.
|
|
384
|
+
*/
|
|
385
|
+
const NOTED_FIELD_ATTRS = {
|
|
386
|
+
ignore: '@ignore excludes the field from the Prisma client. Turbine generates from the DATABASE, so the column IS present on the generated client.',
|
|
387
|
+
};
|
|
388
|
+
/** Same, for block (`@@`) attributes. */
|
|
389
|
+
const NOTED_BLOCK_ATTRS = {
|
|
390
|
+
ignore: '@@ignore excludes the model from the Prisma client. Turbine generates from the DATABASE, so the table IS present on the generated client.',
|
|
391
|
+
schema: '@@schema (multi-schema datasource) is not represented. Turbine introspects ONE Postgres namespace per run, and two models in different schemas can resolve to the same bare table name.',
|
|
392
|
+
fulltext: '@@fulltext indexes are not represented in the generated metadata.',
|
|
393
|
+
};
|
|
363
394
|
/** Parse a single field declaration line. Returns null for a non-field line. */
|
|
364
395
|
function parseFieldLine(text, line, warnings) {
|
|
365
|
-
// First token = field name, second token = type.
|
|
366
|
-
//
|
|
367
|
-
|
|
396
|
+
// First token = field name, second token = type. The type is a simple word or
|
|
397
|
+
// an `Unsupported("...")` call, and may carry a trailing `[]` and/or `?`.
|
|
398
|
+
//
|
|
399
|
+
// `Unsupported(...)` HAS to be matched here rather than left to the bare-word
|
|
400
|
+
// branch: `data Unsupported("tsvector")?` matched only up to `Unsupported`,
|
|
401
|
+
// so the `?` sat behind an unconsumed `(` and the field recorded
|
|
402
|
+
// `optional: false`. A nullable column read as required is a required/optional
|
|
403
|
+
// INVERSION, which is exactly the kind of thing a migration report exists to
|
|
404
|
+
// catch.
|
|
405
|
+
const m = text.match(/^([A-Za-z_]\w*)\s+(Unsupported\(\s*"(?:[^"\\]|\\.)*"\s*\)|[A-Za-z_]\w*)(\[\])?(\?)?/);
|
|
368
406
|
if (!m) {
|
|
369
407
|
// Not a field (e.g. a stray token); skip leniently.
|
|
370
408
|
warnings.push(`Skipped unrecognized line ${line}: "${truncate(text)}"`);
|
|
371
409
|
return null;
|
|
372
410
|
}
|
|
373
411
|
const name = m[1];
|
|
374
|
-
const
|
|
412
|
+
const rawType = m[2];
|
|
375
413
|
const isList = m[3] === '[]';
|
|
376
414
|
const optional = m[4] === '?';
|
|
377
415
|
const rest = text.slice(m[0].length);
|
|
378
416
|
const attrs = parseAttributes(rest, line);
|
|
379
|
-
|
|
417
|
+
let type = rawType;
|
|
418
|
+
let unsupported;
|
|
419
|
+
if (rawType.startsWith('Unsupported')) {
|
|
420
|
+
type = 'Unsupported';
|
|
421
|
+
unsupported = unquote(rawType.slice(rawType.indexOf('(') + 1, rawType.lastIndexOf(')')).trim());
|
|
422
|
+
warnings.push(`Line ${line}: field "${name}" is Unsupported("${unsupported}"). Prisma cannot read or write it; ` +
|
|
423
|
+
`Turbine generates the column from the database, so it IS present on the generated client.`);
|
|
424
|
+
}
|
|
425
|
+
for (const attr of attrs) {
|
|
426
|
+
const note = NOTED_FIELD_ATTRS[attr.name];
|
|
427
|
+
if (note)
|
|
428
|
+
warnings.push(`Line ${line}: field "${name}": ${note}`);
|
|
429
|
+
}
|
|
430
|
+
return { name, type, ...(unsupported !== undefined ? { unsupported } : {}), optional, isList, attrs, line };
|
|
380
431
|
}
|
|
381
432
|
function parseModelBody(block, kind, src, warnings) {
|
|
382
433
|
const model = {
|
|
@@ -402,7 +453,14 @@ function parseModelBody(block, kind, src, warnings) {
|
|
|
402
453
|
else if (attr.name === 'id' || attr.name === 'unique') {
|
|
403
454
|
model.compoundKeys.push(parseCompoundKey(attr, line));
|
|
404
455
|
}
|
|
405
|
-
|
|
456
|
+
else {
|
|
457
|
+
// @@index and anything else: recorded in blockAttrs, unused. The
|
|
458
|
+
// few that change what the model MEANS get a warning rather than
|
|
459
|
+
// silence (see NOTED_BLOCK_ATTRS).
|
|
460
|
+
const note = NOTED_BLOCK_ATTRS[attr.name];
|
|
461
|
+
if (note)
|
|
462
|
+
warnings.push(`Line ${line}: model "${block.name}": ${note}`);
|
|
463
|
+
}
|
|
406
464
|
}
|
|
407
465
|
continue;
|
|
408
466
|
}
|
|
@@ -419,7 +477,10 @@ function parseEnumBody(block, src) {
|
|
|
419
477
|
if (text.startsWith('@@')) {
|
|
420
478
|
for (const attr of parseAttributes(text, line)) {
|
|
421
479
|
if (attr.name === 'map') {
|
|
422
|
-
|
|
480
|
+
// `@@map("x")` and `@@map(name: "x")` are both valid; reading only the
|
|
481
|
+
// positional form silently dropped the mapped enum-type name, matching
|
|
482
|
+
// the field-level `@map` bug in prisma-resolve.ts.
|
|
483
|
+
const arg = attr.args.find((a) => a.key === undefined || a.key === 'name');
|
|
423
484
|
if (arg?.kind === 'string' && arg.value)
|
|
424
485
|
en.map = arg.value;
|
|
425
486
|
}
|
|
@@ -523,13 +584,19 @@ export function resolvePrismaDatasourceUrl(ast, env) {
|
|
|
523
584
|
export function parsePrismaSchema(source) {
|
|
524
585
|
const src = stripComments(source);
|
|
525
586
|
const ast = { models: [], enums: [], datasources: [], warnings: [] };
|
|
526
|
-
for (const block of scanBlocks(src)) {
|
|
587
|
+
for (const block of scanBlocks(src, ast.warnings)) {
|
|
527
588
|
switch (block.keyword) {
|
|
528
589
|
case 'model':
|
|
529
590
|
ast.models.push(parseModelBody(block, 'model', src, ast.warnings));
|
|
530
591
|
break;
|
|
531
592
|
case 'view':
|
|
532
593
|
ast.models.push(parseModelBody(block, 'view', src, ast.warnings));
|
|
594
|
+
// A Prisma `view` is READ-ONLY in the Prisma client. Turbine resolves it
|
|
595
|
+
// against an introspected view and emits an ordinary delegate, so
|
|
596
|
+
// create/update/delete become reachable where Prisma refused them. Said
|
|
597
|
+
// out loud, because `type` blocks already warn and this one did not.
|
|
598
|
+
ast.warnings.push(`Block "view ${block.name}" is read-only in Prisma; the generated Turbine delegate is not. ` +
|
|
599
|
+
`Writes through it will reach the database if the view is updatable.`);
|
|
533
600
|
break;
|
|
534
601
|
case 'type':
|
|
535
602
|
// Composite/embedded types (MongoDB) are not tables. Parse leniently so
|
|
@@ -544,11 +611,10 @@ export function parsePrismaSchema(source) {
|
|
|
544
611
|
// Not a table, but it declares the connection string the CLI can reuse.
|
|
545
612
|
ast.datasources.push(parseDatasourceBody(block, src));
|
|
546
613
|
break;
|
|
547
|
-
case 'generator':
|
|
548
|
-
// Configuration block - irrelevant to name mapping.
|
|
549
|
-
break;
|
|
550
614
|
default:
|
|
551
|
-
|
|
615
|
+
// 'generator': configuration, irrelevant to name mapping. Any other
|
|
616
|
+
// keyword never reaches here (scanBlocks filters and warns), so this
|
|
617
|
+
// stays a silent catch-all rather than a second warning site.
|
|
552
618
|
break;
|
|
553
619
|
}
|
|
554
620
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE SQL statement tokenizer the migration tooling speaks.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this module exists
|
|
5
|
+
*
|
|
6
|
+
* `migrate.ts` executes migration SQL and `destructive.ts` decides whether that
|
|
7
|
+
* SQL is allowed to run. Both need to answer the same two questions, "where does
|
|
8
|
+
* one statement end", and "which characters are code rather than comment or
|
|
9
|
+
* literal", and until v0.66 each answered them with its own hand-written lexer.
|
|
10
|
+
* They disagreed, and the one that was wrong was the GUARD:
|
|
11
|
+
*
|
|
12
|
+
* - `destructive.ts` ended a block comment at the first `*\/`
|
|
13
|
+
* (`sql.indexOf('*\/', i + 2)`). Postgres NESTS block comments, so a
|
|
14
|
+
* commented-out block that itself contains a comment, the ordinary shape of
|
|
15
|
+
* "I disabled this for now", reopened as CODE partway through and the
|
|
16
|
+
* scanner resynchronised in the middle of the file.
|
|
17
|
+
* - `destructive.ts` then split statements with `text.split(';')`, so a
|
|
18
|
+
* semicolon inside a quoted identifier (`DROP TABLE "we;ird"`) cut a
|
|
19
|
+
* statement in half and neither half matched a rule.
|
|
20
|
+
*
|
|
21
|
+
* Both bugs FAIL OPEN. The worst measured case printed a partial inventory: for
|
|
22
|
+
* a file holding a nested commented-out block, a `DROP TABLE`, and a `DELETE`,
|
|
23
|
+
* the guard listed only the DELETE. The operator confirms the inventory they
|
|
24
|
+
* were shown, and the unlisted DROP TABLE runs under that confirmation.
|
|
25
|
+
*
|
|
26
|
+
* The tokenizer that replaced them arrived with the same shape of hole one
|
|
27
|
+
* layer down, and it is worth naming the shape rather than the instances: EVERY
|
|
28
|
+
* disagreement with the server's own lexer fails open, because the guard reads
|
|
29
|
+
* a file the server will not execute and the server executes a file the guard
|
|
30
|
+
* never read. Two more were found and fixed after the rewrite, both verified
|
|
31
|
+
* dropping a table live on PostgreSQL 16: a `$` INSIDE an identifier read as a
|
|
32
|
+
* dollar-quote opener (see {@link IDENT_CONT}), and the inner semicolons of a
|
|
33
|
+
* PG14+ `BEGIN ATOMIC` routine body read as statement terminators (see
|
|
34
|
+
* {@link opensAtomicBody}). The rule the module is written to now is to copy
|
|
35
|
+
* Postgres's lexical rules exactly, and where a case is genuinely ambiguous, to
|
|
36
|
+
* pick the reading that shows the operator MORE, never less.
|
|
37
|
+
*
|
|
38
|
+
* A second lexer that agrees today drifts tomorrow, so there is exactly one
|
|
39
|
+
* here and both callers consume it. {@link tokenizeSql} does the whole job in a
|
|
40
|
+
* single pass and hands back, per statement, BOTH the verbatim source (what
|
|
41
|
+
* `migrate.ts` executes, byte for byte) and the comment/literal-stripped source
|
|
42
|
+
* (what `destructive.ts` matches its rules against). Neither view can describe a
|
|
43
|
+
* different set of statements than the other, because there is only one walk.
|
|
44
|
+
*
|
|
45
|
+
* Pure leaf: no imports at all, and in particular none from `migrate.ts` or
|
|
46
|
+
* `destructive.ts` (`migrate.ts` already imports `destructive.ts`, so the shared
|
|
47
|
+
* code cannot live in either of them).
|
|
48
|
+
*/
|
|
49
|
+
/** One top-level statement, in both of the forms its two consumers need. */
|
|
50
|
+
export interface SqlStatement {
|
|
51
|
+
/**
|
|
52
|
+
* The statement exactly as written, trimmed, with its terminating semicolon
|
|
53
|
+
* removed. This is what gets EXECUTED, so it is sliced straight out of the
|
|
54
|
+
* source rather than reassembled: a tokenizer that rebuilds the text can
|
|
55
|
+
* silently alter it, and a mangled statement run against production is the
|
|
56
|
+
* failure this module exists to prevent.
|
|
57
|
+
*/
|
|
58
|
+
raw: string;
|
|
59
|
+
/**
|
|
60
|
+
* The same statement with comments removed, string literals emptied to `''`,
|
|
61
|
+
* and dollar-quoted bodies emptied to `''`. Quoted identifiers are kept
|
|
62
|
+
* VERBATIM, because the destructive rules match on object names.
|
|
63
|
+
*/
|
|
64
|
+
stripped: string;
|
|
65
|
+
/**
|
|
66
|
+
* The same statement with comments removed and EVERYTHING ELSE verbatim:
|
|
67
|
+
* string literals, quoted identifiers, and dollar-quoted bodies all keep
|
|
68
|
+
* their contents.
|
|
69
|
+
*
|
|
70
|
+
* This is the view dynamic SQL has to be read through. A `DO` block keeps its
|
|
71
|
+
* payload in a LITERAL (`EXECUTE 'DROP TABLE users'`), which is precisely
|
|
72
|
+
* what `stripped` empties, so the destructive scanner cannot use `stripped`
|
|
73
|
+
* for a procedural body and cannot use `raw` either (a comment in the body
|
|
74
|
+
* would hide the statement after it). Removing the comments here means the
|
|
75
|
+
* scanner never has to strip them itself, which is what it used to do, with a
|
|
76
|
+
* pair of regexes that were the exact hand-written lexer this module exists
|
|
77
|
+
* to delete.
|
|
78
|
+
*/
|
|
79
|
+
code: string;
|
|
80
|
+
/**
|
|
81
|
+
* Executable SQL carried INSIDE this statement, in source order, with its
|
|
82
|
+
* delimiters excluded: every dollar-quoted body, plus a PG14+ `BEGIN ATOMIC`
|
|
83
|
+
* routine body. A `DO` block or a routine source is code rather than data, so
|
|
84
|
+
* the destructive scanner re-scans these.
|
|
85
|
+
*/
|
|
86
|
+
blocks: string[];
|
|
87
|
+
/** True when the statement holds nothing but comments and whitespace. */
|
|
88
|
+
commentOnly: boolean;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Split a SQL script into its top-level statements.
|
|
92
|
+
*
|
|
93
|
+
* A semicolon inside a single-quoted string (including a backslash-escaping
|
|
94
|
+
* `E'...'` string), a double-quoted identifier, a dollar-quoted body, a PG14+
|
|
95
|
+
* `BEGIN ATOMIC` routine body, a line comment, or a block comment (which
|
|
96
|
+
* Postgres allows to NEST) does not terminate a statement. Nor does a `$` that
|
|
97
|
+
* merely continues an identifier open a dollar-quoted body, which is the same
|
|
98
|
+
* question asked from the other side (see IDENT_CONT and opensAtomicBody for
|
|
99
|
+
* what each of those two cost when they were wrong).
|
|
100
|
+
*
|
|
101
|
+
* Every returned statement is trimmed and carries no
|
|
102
|
+
* trailing semicolon; nothing is dropped, so a comment-only fragment comes back
|
|
103
|
+
* flagged rather than missing (the executor skips those, the scanner ignores
|
|
104
|
+
* them, and neither has to re-derive the fact).
|
|
105
|
+
*/
|
|
106
|
+
export declare function tokenizeSql(sql: string): SqlStatement[];
|
|
107
|
+
/**
|
|
108
|
+
* Split a SQL script into individual executable statements on top-level
|
|
109
|
+
* semicolons, dropping comment-only fragments.
|
|
110
|
+
*
|
|
111
|
+
* This is the list `-- turbine:no-transaction` migrations issue one statement
|
|
112
|
+
* per `client.query()` call, which is the one production-destroying failure
|
|
113
|
+
* mode of the migration runner (a partial statement executed against
|
|
114
|
+
* production), so the behavior is pinned by exhaustive unit tests.
|
|
115
|
+
*/
|
|
116
|
+
export declare function splitSqlStatements(sql: string): string[];
|
|
117
|
+
/**
|
|
118
|
+
* The whole script with comments removed, string and dollar-quoted literals
|
|
119
|
+
* emptied, and quoted identifiers preserved: the view the destructive rules are
|
|
120
|
+
* written against, joined back into one string.
|
|
121
|
+
*
|
|
122
|
+
* Statement-level consumers should prefer {@link tokenizeSql}, whose per-
|
|
123
|
+
* statement `stripped` is the same text without having to re-split it.
|
|
124
|
+
*/
|
|
125
|
+
export declare function stripCommentsAndStrings(sql: string): string;
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE SQL statement tokenizer the migration tooling speaks.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this module exists
|
|
5
|
+
*
|
|
6
|
+
* `migrate.ts` executes migration SQL and `destructive.ts` decides whether that
|
|
7
|
+
* SQL is allowed to run. Both need to answer the same two questions, "where does
|
|
8
|
+
* one statement end", and "which characters are code rather than comment or
|
|
9
|
+
* literal", and until v0.66 each answered them with its own hand-written lexer.
|
|
10
|
+
* They disagreed, and the one that was wrong was the GUARD:
|
|
11
|
+
*
|
|
12
|
+
* - `destructive.ts` ended a block comment at the first `*\/`
|
|
13
|
+
* (`sql.indexOf('*\/', i + 2)`). Postgres NESTS block comments, so a
|
|
14
|
+
* commented-out block that itself contains a comment, the ordinary shape of
|
|
15
|
+
* "I disabled this for now", reopened as CODE partway through and the
|
|
16
|
+
* scanner resynchronised in the middle of the file.
|
|
17
|
+
* - `destructive.ts` then split statements with `text.split(';')`, so a
|
|
18
|
+
* semicolon inside a quoted identifier (`DROP TABLE "we;ird"`) cut a
|
|
19
|
+
* statement in half and neither half matched a rule.
|
|
20
|
+
*
|
|
21
|
+
* Both bugs FAIL OPEN. The worst measured case printed a partial inventory: for
|
|
22
|
+
* a file holding a nested commented-out block, a `DROP TABLE`, and a `DELETE`,
|
|
23
|
+
* the guard listed only the DELETE. The operator confirms the inventory they
|
|
24
|
+
* were shown, and the unlisted DROP TABLE runs under that confirmation.
|
|
25
|
+
*
|
|
26
|
+
* The tokenizer that replaced them arrived with the same shape of hole one
|
|
27
|
+
* layer down, and it is worth naming the shape rather than the instances: EVERY
|
|
28
|
+
* disagreement with the server's own lexer fails open, because the guard reads
|
|
29
|
+
* a file the server will not execute and the server executes a file the guard
|
|
30
|
+
* never read. Two more were found and fixed after the rewrite, both verified
|
|
31
|
+
* dropping a table live on PostgreSQL 16: a `$` INSIDE an identifier read as a
|
|
32
|
+
* dollar-quote opener (see {@link IDENT_CONT}), and the inner semicolons of a
|
|
33
|
+
* PG14+ `BEGIN ATOMIC` routine body read as statement terminators (see
|
|
34
|
+
* {@link opensAtomicBody}). The rule the module is written to now is to copy
|
|
35
|
+
* Postgres's lexical rules exactly, and where a case is genuinely ambiguous, to
|
|
36
|
+
* pick the reading that shows the operator MORE, never less.
|
|
37
|
+
*
|
|
38
|
+
* A second lexer that agrees today drifts tomorrow, so there is exactly one
|
|
39
|
+
* here and both callers consume it. {@link tokenizeSql} does the whole job in a
|
|
40
|
+
* single pass and hands back, per statement, BOTH the verbatim source (what
|
|
41
|
+
* `migrate.ts` executes, byte for byte) and the comment/literal-stripped source
|
|
42
|
+
* (what `destructive.ts` matches its rules against). Neither view can describe a
|
|
43
|
+
* different set of statements than the other, because there is only one walk.
|
|
44
|
+
*
|
|
45
|
+
* Pure leaf: no imports at all, and in particular none from `migrate.ts` or
|
|
46
|
+
* `destructive.ts` (`migrate.ts` already imports `destructive.ts`, so the shared
|
|
47
|
+
* code cannot live in either of them).
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* A dollar-quote tag. Postgres allows digits after the first character
|
|
51
|
+
* (`$do1$`), so a tag regex that stops at letters reads the body as code and
|
|
52
|
+
* misses everything inside it. The leading character can never be a digit,
|
|
53
|
+
* which is what keeps a `$1` bind placeholder from opening a quoted body.
|
|
54
|
+
*
|
|
55
|
+
* This pattern answers "is this a well-formed tag", NOT "does a body open
|
|
56
|
+
* here". The second question is decided before the pattern is ever run, by
|
|
57
|
+
* whether the walk is standing at the start of a token at all: see
|
|
58
|
+
* {@link IDENT_CONT}.
|
|
59
|
+
*/
|
|
60
|
+
const DOLLAR_TAG = /^\$([A-Za-z_][A-Za-z_0-9]*)?\$/;
|
|
61
|
+
/**
|
|
62
|
+
* Postgres identifier characters, copied from its own lexer rather than from
|
|
63
|
+
* intuition (`scan.l`: `ident_start [A-Za-z\200-\377_]`, `ident_cont
|
|
64
|
+
* [A-Za-z\200-\377_0-9\$]`). Every non-ASCII character is an identifier
|
|
65
|
+
* character, and so is `$` in every position but the first.
|
|
66
|
+
*
|
|
67
|
+
* That `$` is the whole reason the walk consumes an identifier as ONE token.
|
|
68
|
+
* Postgres lexes by LONGEST MATCH, so in `SELECT x$y$ FROM t` the identifier
|
|
69
|
+
* rule claims all four characters of `x$y$` and the dollar-quoting rule never
|
|
70
|
+
* gets a look at that `$`. Reading `$y$` as an opener instead starts a body
|
|
71
|
+
* whose tag never appears again, which swallows the REST OF THE FILE:
|
|
72
|
+
*
|
|
73
|
+
* input: SELECT x$y$ FROM t;
|
|
74
|
+
* DROP TABLE users;
|
|
75
|
+
* guard: destructive(0): (none)
|
|
76
|
+
* split: statements(1) <- the whole file
|
|
77
|
+
*
|
|
78
|
+
* Measured on PostgreSQL 16.14, that file returns a column named `x$y$` and
|
|
79
|
+
* then drops the table, under an inventory that listed nothing. Collapsing a
|
|
80
|
+
* file to one statement also breaks the one-statement-per-round-trip contract
|
|
81
|
+
* `-- turbine:no-transaction` migrations depend on, which is this module's
|
|
82
|
+
* other production-destroying failure mode.
|
|
83
|
+
*
|
|
84
|
+
* Consuming the identifier run first IS Postgres's rule rather than an
|
|
85
|
+
* approximation of it, which is what keeps the converse working: a digit
|
|
86
|
+
* cannot START an identifier, so the `$$` in `SELECT 1$$x$$` still opens a
|
|
87
|
+
* body (PostgreSQL reports its syntax error at the string `$$x$$`, not at the
|
|
88
|
+
* number). Verified against the same server: `SELECT a$$b`, `SELECT$$x$$`, and
|
|
89
|
+
* `AS$$SELECT 1$$` are each ONE identifier token, and `naïve$col$` is a legal
|
|
90
|
+
* column name.
|
|
91
|
+
*/
|
|
92
|
+
const IDENT_START = /[A-Za-z_\u0080-\uFFFF]/;
|
|
93
|
+
const IDENT_CONT = /[A-Za-z0-9_$\u0080-\uFFFF]/;
|
|
94
|
+
/**
|
|
95
|
+
* A statement head that can carry a PG14+ SQL-standard routine body. Requiring
|
|
96
|
+
* it is what stops an ordinary transaction-control `BEGIN` from ever putting
|
|
97
|
+
* the walk into body mode, where semicolons stop terminating statements.
|
|
98
|
+
*/
|
|
99
|
+
const SQL_BODY_HEAD = /^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:FUNCTION|PROCEDURE)\b/i;
|
|
100
|
+
/**
|
|
101
|
+
* `BEGIN` as the immediately preceding token. The leading character class
|
|
102
|
+
* spells out what `\b` gets wrong here: `$` is an identifier character in
|
|
103
|
+
* Postgres, so `\bBEGIN` would also match the tail of `x$BEGIN`.
|
|
104
|
+
*/
|
|
105
|
+
const BEGIN_BEFORE_ATOMIC = /(?:^|[^A-Za-z0-9_$\u0080-\uFFFF])BEGIN\s+$/i;
|
|
106
|
+
/**
|
|
107
|
+
* True when the identifier just scanned opens a PG14+ SQL-standard routine
|
|
108
|
+
* body: `CREATE FUNCTION ... BEGIN ATOMIC <stmt>; <stmt>; END`.
|
|
109
|
+
*
|
|
110
|
+
* Those inner semicolons are not statement terminators, and splitting there
|
|
111
|
+
* cost the guard the entire body. `CREATE FUNCTION purge() RETURNS void
|
|
112
|
+
* LANGUAGE SQL BEGIN ATOMIC DELETE FROM users; END;` became three fragments:
|
|
113
|
+
* the first headed `CREATE FUNCTION`, which matches no destructive rule, and
|
|
114
|
+
* the rest headless. A function whose only job is to empty a table therefore
|
|
115
|
+
* reported a clean inventory (verified live: the function creates, runs, and
|
|
116
|
+
* leaves zero rows). It also handed the no-transaction runner a fragment that
|
|
117
|
+
* cannot execute on its own.
|
|
118
|
+
*
|
|
119
|
+
* The two keywords must be ADJACENT (whitespace and comments aside) because
|
|
120
|
+
* both are unreserved. `CREATE FUNCTION f() RETURNS TABLE (begin int, atomic
|
|
121
|
+
* int)` is a legal header, verified on PostgreSQL 16, and reading that as a
|
|
122
|
+
* body would swallow every following statement up to the next `END`: the
|
|
123
|
+
* fail-OPEN direction, which is the one this guard cannot afford.
|
|
124
|
+
*/
|
|
125
|
+
function opensAtomicBody(word, before) {
|
|
126
|
+
return /^ATOMIC$/i.test(word) && BEGIN_BEFORE_ATOMIC.test(before) && SQL_BODY_HEAD.test(before);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* True when the quote at `quoteAt` opens a Postgres escape string (`E'...'`),
|
|
130
|
+
* whose body treats a backslash as an escape character.
|
|
131
|
+
*
|
|
132
|
+
* The `E` must be a standalone token, so an identifier that merely ends in `e`
|
|
133
|
+
* does not turn the following literal into an E-string. Ordinary literals are
|
|
134
|
+
* left alone on purpose: with the modern `standard_conforming_strings = on`
|
|
135
|
+
* default, `'a\'` IS a complete string.
|
|
136
|
+
*
|
|
137
|
+
* Getting this wrong is not cosmetic. `E'p\'q'` is ONE literal; a tokenizer
|
|
138
|
+
* that ends it at the backslash-quote reads the rest of the file as string
|
|
139
|
+
* content, which merges following statements together (fatal for a
|
|
140
|
+
* `-- turbine:no-transaction` migration, which must issue one statement per
|
|
141
|
+
* round trip) and hides every later statement from the destructive guard.
|
|
142
|
+
*/
|
|
143
|
+
function isEscapeStringPrefix(sql, quoteAt) {
|
|
144
|
+
const prev = sql[quoteAt - 1];
|
|
145
|
+
if (prev !== 'E' && prev !== 'e')
|
|
146
|
+
return false;
|
|
147
|
+
const before = sql[quoteAt - 2];
|
|
148
|
+
return before === undefined || !/[A-Za-z0-9_$"]/.test(before);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Split a SQL script into its top-level statements.
|
|
152
|
+
*
|
|
153
|
+
* A semicolon inside a single-quoted string (including a backslash-escaping
|
|
154
|
+
* `E'...'` string), a double-quoted identifier, a dollar-quoted body, a PG14+
|
|
155
|
+
* `BEGIN ATOMIC` routine body, a line comment, or a block comment (which
|
|
156
|
+
* Postgres allows to NEST) does not terminate a statement. Nor does a `$` that
|
|
157
|
+
* merely continues an identifier open a dollar-quoted body, which is the same
|
|
158
|
+
* question asked from the other side (see IDENT_CONT and opensAtomicBody for
|
|
159
|
+
* what each of those two cost when they were wrong).
|
|
160
|
+
*
|
|
161
|
+
* Every returned statement is trimmed and carries no
|
|
162
|
+
* trailing semicolon; nothing is dropped, so a comment-only fragment comes back
|
|
163
|
+
* flagged rather than missing (the executor skips those, the scanner ignores
|
|
164
|
+
* them, and neither has to re-derive the fact).
|
|
165
|
+
*/
|
|
166
|
+
export function tokenizeSql(sql) {
|
|
167
|
+
const out = [];
|
|
168
|
+
const n = sql.length;
|
|
169
|
+
let stripped = '';
|
|
170
|
+
let code = '';
|
|
171
|
+
let blocks = [];
|
|
172
|
+
let start = 0;
|
|
173
|
+
let i = 0;
|
|
174
|
+
// Inside a `BEGIN ATOMIC` routine body: 0 means no. The count tracks the
|
|
175
|
+
// `CASE ... END` expressions nested in it, which are the only other `END` a
|
|
176
|
+
// SQL-standard body can hold (`BEGIN ATOMIC SELECT CASE WHEN ... END; END`
|
|
177
|
+
// is legal, verified on PostgreSQL 16), so a body cannot end at the first
|
|
178
|
+
// `END` it happens to contain.
|
|
179
|
+
let atomicDepth = 0;
|
|
180
|
+
let atomicStart = 0;
|
|
181
|
+
const flush = (rawEnd) => {
|
|
182
|
+
const raw = sql.slice(start, rawEnd).trim();
|
|
183
|
+
const strippedTrimmed = stripped.trim();
|
|
184
|
+
if (raw.length > 0) {
|
|
185
|
+
out.push({
|
|
186
|
+
raw,
|
|
187
|
+
stripped: strippedTrimmed,
|
|
188
|
+
code: code.trim(),
|
|
189
|
+
blocks,
|
|
190
|
+
commentOnly: strippedTrimmed.length === 0,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
stripped = '';
|
|
194
|
+
code = '';
|
|
195
|
+
blocks = [];
|
|
196
|
+
};
|
|
197
|
+
while (i < n) {
|
|
198
|
+
const ch = sql[i];
|
|
199
|
+
const next = sql[i + 1];
|
|
200
|
+
// Line comment: runs to the end of the line. The newline itself is left for
|
|
201
|
+
// the generic branch below, so line structure survives into `stripped`.
|
|
202
|
+
if (ch === '-' && next === '-') {
|
|
203
|
+
let j = i;
|
|
204
|
+
while (j < n && sql[j] !== '\n')
|
|
205
|
+
j++;
|
|
206
|
+
i = j;
|
|
207
|
+
code += ' ';
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
// Block comment. Postgres nests these, so depth is counted rather than
|
|
211
|
+
// scanning for the first `*\/`: `/* a /* b */ c */` is ONE comment, and a
|
|
212
|
+
// scanner that stops at the inner terminator treats ` c */ ...` as code.
|
|
213
|
+
if (ch === '/' && next === '*') {
|
|
214
|
+
let depth = 1;
|
|
215
|
+
let j = i + 2;
|
|
216
|
+
while (j < n && depth > 0) {
|
|
217
|
+
if (sql[j] === '/' && sql[j + 1] === '*') {
|
|
218
|
+
depth++;
|
|
219
|
+
j += 2;
|
|
220
|
+
}
|
|
221
|
+
else if (sql[j] === '*' && sql[j + 1] === '/') {
|
|
222
|
+
depth--;
|
|
223
|
+
j += 2;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
j++;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
i = j;
|
|
230
|
+
stripped += ' ';
|
|
231
|
+
code += ' ';
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
// Single-quoted literal. `''` always escapes a quote; inside an E-string a
|
|
235
|
+
// backslash escapes the next character too.
|
|
236
|
+
if (ch === "'") {
|
|
237
|
+
const escapes = isEscapeStringPrefix(sql, i);
|
|
238
|
+
let j = i + 1;
|
|
239
|
+
while (j < n) {
|
|
240
|
+
if (escapes && sql[j] === '\\') {
|
|
241
|
+
j += 2;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (sql[j] === "'" && sql[j + 1] === "'") {
|
|
245
|
+
j += 2;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (sql[j] === "'")
|
|
249
|
+
break;
|
|
250
|
+
j++;
|
|
251
|
+
}
|
|
252
|
+
const end = Math.min(j + 1, n);
|
|
253
|
+
stripped += "''";
|
|
254
|
+
code += sql.slice(i, end);
|
|
255
|
+
i = end;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
// Quoted identifier (`""` escapes a quote). Kept VERBATIM in `stripped`,
|
|
259
|
+
// because the destructive rules match on object names, but consumed as ONE
|
|
260
|
+
// token: an apostrophe inside a quoted name (`"customer's_orders"`) would
|
|
261
|
+
// otherwise open a string literal and hide every following statement, and a
|
|
262
|
+
// semicolon inside one (`"we;ird"`) would otherwise split a statement in
|
|
263
|
+
// half so that neither half matched anything.
|
|
264
|
+
if (ch === '"') {
|
|
265
|
+
let j = i + 1;
|
|
266
|
+
while (j < n) {
|
|
267
|
+
if (sql[j] === '"' && sql[j + 1] === '"') {
|
|
268
|
+
j += 2;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (sql[j] === '"')
|
|
272
|
+
break;
|
|
273
|
+
j++;
|
|
274
|
+
}
|
|
275
|
+
const end = Math.min(j + 1, n);
|
|
276
|
+
stripped += sql.slice(i, end);
|
|
277
|
+
code += sql.slice(i, end);
|
|
278
|
+
i = end;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
// Identifier or keyword run, consumed as ONE token because Postgres does.
|
|
282
|
+
// This branch has to sit ABOVE the dollar-quote branch: a `$` that is part
|
|
283
|
+
// of an identifier is not an opener, and reading it as one swallows the
|
|
284
|
+
// rest of the file (see IDENT_CONT). It also gives the `BEGIN ATOMIC`
|
|
285
|
+
// tracking below a token boundary to work from, so `BEGINATOMIC` or a
|
|
286
|
+
// column named `atomic$` can never be mistaken for the keyword pair.
|
|
287
|
+
if (IDENT_START.test(ch)) {
|
|
288
|
+
let j = i + 1;
|
|
289
|
+
while (j < n && IDENT_CONT.test(sql[j]))
|
|
290
|
+
j++;
|
|
291
|
+
const word = sql.slice(i, j);
|
|
292
|
+
if (atomicDepth === 0) {
|
|
293
|
+
if (opensAtomicBody(word, stripped)) {
|
|
294
|
+
atomicDepth = 1;
|
|
295
|
+
atomicStart = j;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
else if (/^CASE$/i.test(word)) {
|
|
299
|
+
atomicDepth++;
|
|
300
|
+
}
|
|
301
|
+
else if (/^END$/i.test(word) && --atomicDepth === 0) {
|
|
302
|
+
blocks.push(sql.slice(atomicStart, i));
|
|
303
|
+
}
|
|
304
|
+
stripped += word;
|
|
305
|
+
code += word;
|
|
306
|
+
i = j;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
// Dollar-quoted body ($tag$ ... $tag$).
|
|
310
|
+
if (ch === '$') {
|
|
311
|
+
const tag = DOLLAR_TAG.exec(sql.slice(i))?.[0];
|
|
312
|
+
if (tag) {
|
|
313
|
+
const close = sql.indexOf(tag, i + tag.length);
|
|
314
|
+
const next = close === -1 ? n : close + tag.length;
|
|
315
|
+
blocks.push(sql.slice(i + tag.length, close === -1 ? n : close));
|
|
316
|
+
stripped += "''";
|
|
317
|
+
code += sql.slice(i, next);
|
|
318
|
+
i = next;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// Top-level statement terminator. The semicolons INSIDE a `BEGIN ATOMIC`
|
|
323
|
+
// routine body are not top level, so while one is open this falls through
|
|
324
|
+
// to the generic branch and the body stays one statement (see
|
|
325
|
+
// opensAtomicBody).
|
|
326
|
+
if (ch === ';' && atomicDepth === 0) {
|
|
327
|
+
flush(i);
|
|
328
|
+
i++;
|
|
329
|
+
start = i;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
stripped += ch;
|
|
333
|
+
code += ch;
|
|
334
|
+
i++;
|
|
335
|
+
}
|
|
336
|
+
// A `BEGIN ATOMIC` body still open at EOF (a file that ends mid-routine, or
|
|
337
|
+
// an `END` this walk failed to find) is handed over anyway rather than
|
|
338
|
+
// dropped. Dropping it is the fail-OPEN direction: the body is executable
|
|
339
|
+
// SQL, and it would reach no scanner at all, whereas handing over a body
|
|
340
|
+
// that the server may reject as unterminated costs at most a confirmation
|
|
341
|
+
// prompt for a file that was never going to run.
|
|
342
|
+
if (atomicDepth > 0)
|
|
343
|
+
blocks.push(sql.slice(atomicStart));
|
|
344
|
+
flush(n);
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Split a SQL script into individual executable statements on top-level
|
|
349
|
+
* semicolons, dropping comment-only fragments.
|
|
350
|
+
*
|
|
351
|
+
* This is the list `-- turbine:no-transaction` migrations issue one statement
|
|
352
|
+
* per `client.query()` call, which is the one production-destroying failure
|
|
353
|
+
* mode of the migration runner (a partial statement executed against
|
|
354
|
+
* production), so the behavior is pinned by exhaustive unit tests.
|
|
355
|
+
*/
|
|
356
|
+
export function splitSqlStatements(sql) {
|
|
357
|
+
return tokenizeSql(sql)
|
|
358
|
+
.filter((s) => !s.commentOnly)
|
|
359
|
+
.map((s) => s.raw);
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* The whole script with comments removed, string and dollar-quoted literals
|
|
363
|
+
* emptied, and quoted identifiers preserved: the view the destructive rules are
|
|
364
|
+
* written against, joined back into one string.
|
|
365
|
+
*
|
|
366
|
+
* Statement-level consumers should prefer {@link tokenizeSql}, whose per-
|
|
367
|
+
* statement `stripped` is the same text without having to re-split it.
|
|
368
|
+
*/
|
|
369
|
+
export function stripCommentsAndStrings(sql) {
|
|
370
|
+
return tokenizeSql(sql)
|
|
371
|
+
.map((s) => s.stripped)
|
|
372
|
+
.join('; ');
|
|
373
|
+
}
|