@intentius/chant-lexicon-sql 0.114.1 → 0.116.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/dist/clickhouse/apply/mutations.d.ts +7 -1
- package/dist/clickhouse/apply/mutations.d.ts.map +1 -1
- package/dist/clickhouse/container.d.ts +22 -7
- package/dist/clickhouse/container.d.ts.map +1 -1
- package/dist/clickhouse/plan/deep.d.ts +5 -0
- package/dist/clickhouse/plan/deep.d.ts.map +1 -1
- package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/backfill.d.ts +11 -5
- package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/op.d.ts +29 -0
- package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/partitions.d.ts +8 -0
- package/dist/clickhouse/rebuild/partitions.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/receipts.d.ts +4 -6
- package/dist/clickhouse/rebuild/receipts.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/shards.d.ts +59 -0
- package/dist/clickhouse/rebuild/shards.d.ts.map +1 -0
- package/dist/clickhouse/rebuild/steps.d.ts +3 -0
- package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/verify.d.ts +4 -0
- package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
- package/dist/codegen/registry.d.ts.map +1 -1
- package/dist/config.d.ts +14 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/core/receipts.d.ts +5 -3
- package/dist/core/receipts.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +5 -5
- package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
- package/dist/lsp/postgres-template.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/meta.json +15 -0
- package/dist/okf/index.md +3 -0
- package/dist/okf/types/PostgresFunction.md +9 -0
- package/dist/okf/types/PostgresProcedure.md +9 -0
- package/dist/okf/types/PostgresTrigger.md +9 -0
- package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +13 -1
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/sql-exec.d.ts +41 -0
- package/dist/op/activities/sql-exec.d.ts.map +1 -0
- package/dist/op/activity-contracts.d.ts +20 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/postgres/apply/apply.d.ts +6 -1
- package/dist/postgres/apply/apply.d.ts.map +1 -1
- package/dist/postgres/apply/statements.d.ts +9 -2
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/dist/postgres/entities.d.ts +110 -3
- package/dist/postgres/entities.d.ts.map +1 -1
- package/dist/postgres/entity-types.d.ts +3 -0
- package/dist/postgres/entity-types.d.ts.map +1 -1
- package/dist/postgres/import/generator.d.ts.map +1 -1
- package/dist/postgres/import/live-export.d.ts.map +1 -1
- package/dist/postgres/live/catalog.d.ts +17 -5
- package/dist/postgres/live/catalog.d.ts.map +1 -1
- package/dist/postgres/live/describe-resources.d.ts +6 -1
- package/dist/postgres/live/describe-resources.d.ts.map +1 -1
- package/dist/postgres/migrate/receipts.d.ts +8 -5
- package/dist/postgres/migrate/receipts.d.ts.map +1 -1
- package/dist/postgres/parser.d.ts +82 -5
- package/dist/postgres/parser.d.ts.map +1 -1
- package/dist/postgres/plan/deep.d.ts.map +1 -1
- package/dist/postgres/plan/diff.d.ts +1 -1
- package/dist/postgres/plan/diff.d.ts.map +1 -1
- package/dist/postgres/plan/normalize.d.ts +21 -2
- package/dist/postgres/plan/normalize.d.ts.map +1 -1
- package/dist/postgres/plan/rules.d.ts +6 -0
- package/dist/postgres/plan/rules.d.ts.map +1 -1
- package/dist/postgres/plan/schema.d.ts +11 -1
- package/dist/postgres/plan/schema.d.ts.map +1 -1
- package/dist/postgres/plan/server-normalize.d.ts.map +1 -1
- package/dist/postgres.d.ts +1 -1
- package/dist/postgres.d.ts.map +1 -1
- package/dist/receipts.d.ts +102 -0
- package/dist/receipts.d.ts.map +1 -0
- package/dist/rules/postgres-templates.ts +2 -2
- package/dist/rules/sqlpg001.ts +3 -0
- package/dist/validate.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/apply/mutations.ts +9 -3
- package/src/clickhouse/container.ts +39 -11
- package/src/clickhouse/plan/cluster-drift.e2e.test.ts +117 -0
- package/src/clickhouse/plan/deep.ts +102 -3
- package/src/clickhouse/plan/normalize.ts +10 -0
- package/src/clickhouse/plan/plan.test.ts +10 -0
- package/src/clickhouse/rebuild/backfill.ts +85 -42
- package/src/clickhouse/rebuild/op.ts +80 -35
- package/src/clickhouse/rebuild/partitions.ts +11 -0
- package/src/clickhouse/rebuild/rebuild-sharded.e2e.test.ts +161 -0
- package/src/clickhouse/rebuild/rebuild.e2e.test.ts +110 -4
- package/src/clickhouse/rebuild/rebuild.test.ts +56 -4
- package/src/clickhouse/rebuild/receipts.ts +4 -6
- package/src/clickhouse/rebuild/shards.ts +104 -0
- package/src/clickhouse/rebuild/steps.ts +4 -0
- package/src/clickhouse/rebuild/verify.ts +14 -5
- package/src/codegen/postgres-classifier-page.ts +1 -1
- package/src/codegen/registry.ts +3 -0
- package/src/config.ts +7 -0
- package/src/core/receipts.ts +5 -3
- package/src/generated/lexicon-sql.json +15 -0
- package/src/index.ts +1 -1
- package/src/lint/rules/postgres-templates.ts +2 -2
- package/src/lint/rules/sqlpg001.ts +3 -0
- package/src/lsp/postgres-template.ts +6 -0
- package/src/mcp/postgres.ts +1 -1
- package/src/op/activities/clickhouse-rebuild.ts +32 -7
- package/src/op/activities/index.ts +21 -4
- package/src/op/activities/sql-exec.ts +84 -0
- package/src/op/activity-contracts.ts +15 -0
- package/src/op/effect-receipts.e2e.test.ts +277 -0
- package/src/plugin.ts +3 -0
- package/src/postgres/apply/apply.ts +21 -5
- package/src/postgres/apply/statements.ts +53 -11
- package/src/postgres/entities.ts +332 -9
- package/src/postgres/entity-types.ts +3 -0
- package/src/postgres/import/generator.ts +5 -2
- package/src/postgres/import/live-export.ts +4 -0
- package/src/postgres/live/catalog.ts +81 -5
- package/src/postgres/live/describe-resources.ts +17 -7
- package/src/postgres/live/routines.e2e.test.ts +173 -0
- package/src/postgres/migrate/batches.e2e.test.ts +2 -1
- package/src/postgres/migrate/migrate.e2e.test.ts +2 -1
- package/src/postgres/migrate/migrate.test.ts +3 -2
- package/src/postgres/migrate/receipts.ts +18 -10
- package/src/postgres/parser.ts +314 -8
- package/src/postgres/plan/commands.ts +2 -2
- package/src/postgres/plan/deep.ts +57 -17
- package/src/postgres/plan/diff.ts +46 -6
- package/src/postgres/plan/normalize.ts +158 -4
- package/src/postgres/plan/rules.ts +36 -0
- package/src/postgres/plan/schema.ts +25 -6
- package/src/postgres/plan/server-normalize.ts +24 -0
- package/src/postgres/routines.test.ts +150 -0
- package/src/postgres.ts +11 -0
- package/src/receipts.test.ts +91 -0
- package/src/receipts.ts +259 -0
- package/src/skill-defs.ts +1 -1
- package/src/skills/chant-sql-postgres.md +18 -2
- package/src/skills/chant-sql-rebuild.md +5 -1
- package/src/validate.ts +3 -0
|
@@ -27,7 +27,15 @@
|
|
|
27
27
|
* column's columns are `NOT NULL` whether or not that is written;
|
|
28
28
|
* - an identity column's or a sequence's options at their defaults are left
|
|
29
29
|
* out; storage parameters compare as a map;
|
|
30
|
-
* - an index's access method defaults to `btree
|
|
30
|
+
* - an index's access method defaults to `btree`;
|
|
31
|
+
* - a function's or procedure's parameter types drop their modifiers (the
|
|
32
|
+
* catalog keeps none: `varchar(20)` is `character varying`), a parameter's
|
|
33
|
+
* mode is `in` unless written, its attributes at their defaults (VOLATILE,
|
|
34
|
+
* CALLED ON NULL INPUT, SECURITY INVOKER, PARALLEL UNSAFE, COST 100 or 1,
|
|
35
|
+
* ROWS 1000 or 0) are left out, and its body is compared as written, since
|
|
36
|
+
* the server keeps it verbatim;
|
|
37
|
+
* - a trigger's events compare as a set, `UPDATE OF` columns sorted, and its
|
|
38
|
+
* arguments as the strings the catalog stores.
|
|
31
39
|
*
|
|
32
40
|
* What the rules cannot see (a view's `SELECT *`, which the server expands, or
|
|
33
41
|
* an expression it parenthesizes) is asked of the live server itself when a
|
|
@@ -52,10 +60,13 @@ import type {
|
|
|
52
60
|
IndexProps,
|
|
53
61
|
KeyDef,
|
|
54
62
|
SchemaProps,
|
|
63
|
+
RoutineProps,
|
|
55
64
|
SequenceProps,
|
|
56
65
|
TableProps,
|
|
66
|
+
TriggerProps,
|
|
57
67
|
ViewProps,
|
|
58
68
|
} from "../entities";
|
|
69
|
+
import { stringValue } from "../entities";
|
|
59
70
|
|
|
60
71
|
// ── Types ──────────────────────────────────────────────────────────────
|
|
61
72
|
|
|
@@ -178,7 +189,7 @@ function castEnd(sig: readonly Token[], i: number): { end: number; type: string
|
|
|
178
189
|
return { end: j, type: words.join("").toLowerCase() };
|
|
179
190
|
}
|
|
180
191
|
|
|
181
|
-
const isLiteral = (t: Token | undefined): boolean => t !== undefined && (t.kind === "string" || t.kind === "number");
|
|
192
|
+
const isLiteral = (t: Token | undefined): boolean => t !== undefined && (t.kind === "string" || t.kind === "number" || (t.kind === "ident" && t.text.toUpperCase() === "NULL"));
|
|
182
193
|
|
|
183
194
|
/** One token's canonical text. */
|
|
184
195
|
function tokenText(t: Token): string {
|
|
@@ -308,12 +319,18 @@ export interface CanonicalConstraint {
|
|
|
308
319
|
comment?: string;
|
|
309
320
|
}
|
|
310
321
|
|
|
311
|
-
export type CanonicalKind = "schema" | "table" | "index" | "view" | "materializedView" | "sequence" | "enum" | "domain" | "extension";
|
|
322
|
+
export type CanonicalKind = "schema" | "table" | "index" | "view" | "materializedView" | "sequence" | "enum" | "domain" | "extension" | "function" | "procedure" | "trigger";
|
|
312
323
|
|
|
313
324
|
export interface CanonicalPgObject {
|
|
314
325
|
kind: CanonicalKind;
|
|
315
326
|
schema?: string;
|
|
316
327
|
name: string;
|
|
328
|
+
/**
|
|
329
|
+
* What tells the object from another of the same name: a routine's input
|
|
330
|
+
* parameter types, `(integer,text)`; a trigger's table, ` ON app.users`.
|
|
331
|
+
* Written as SQL names the object after its name.
|
|
332
|
+
*/
|
|
333
|
+
signature?: string;
|
|
317
334
|
/** Everything else that is compared, by field, each field canonical. */
|
|
318
335
|
fields: Record<string, unknown>;
|
|
319
336
|
columns: CanonicalColumn[];
|
|
@@ -330,6 +347,9 @@ const kindOf: Record<string, CanonicalKind> = {
|
|
|
330
347
|
"Postgres::Enum": "enum",
|
|
331
348
|
"Postgres::Domain": "domain",
|
|
332
349
|
"Postgres::Extension": "extension",
|
|
350
|
+
"Postgres::Function": "function",
|
|
351
|
+
"Postgres::Procedure": "procedure",
|
|
352
|
+
"Postgres::Trigger": "trigger",
|
|
333
353
|
};
|
|
334
354
|
|
|
335
355
|
const comment = (c: string | undefined): string | undefined => {
|
|
@@ -390,6 +410,127 @@ const keyBody = (kind: string, k: KeyDef) =>
|
|
|
390
410
|
.filter(Boolean)
|
|
391
411
|
.join(" ");
|
|
392
412
|
|
|
413
|
+
/** The pseudo-types (`pg_type.typtype = 'p'`), in `pg_catalog`: a routine's parameter or result, never a column's. */
|
|
414
|
+
const PSEUDO_TYPES = new Set([
|
|
415
|
+
"any", "anyarray", "anycompatible", "anycompatiblearray", "anycompatiblemultirange", "anycompatiblenonarray", "anycompatiblerange",
|
|
416
|
+
"anyelement", "anyenum", "anymultirange", "anynonarray", "anyrange", "cstring", "event_trigger", "fdw_handler", "index_am_handler",
|
|
417
|
+
"internal", "language_handler", "pg_ddl_command", "record", "table_am_handler", "trigger", "tsm_handler", "unknown", "void",
|
|
418
|
+
]);
|
|
419
|
+
|
|
420
|
+
/** A parameter's or result's type as the catalog keeps it: canonical, its modifiers dropped (`varchar(20)` is `character varying`). */
|
|
421
|
+
export function canonicalArgType(text: string, defaultSchema: string): string {
|
|
422
|
+
const m = /^\s*(?:pg_catalog\s*\.\s*)?([A-Za-z_]+)\s*((?:\[\s*\]\s*)*)$/.exec(text);
|
|
423
|
+
if (m && PSEUDO_TYPES.has(m[1]!.toLowerCase())) return `${m[1]!.toLowerCase()}${"[]".repeat((m[2]!.match(/\[/g) ?? []).length)}`;
|
|
424
|
+
return canonicalType(text, defaultSchema)!.replace(/\([^()]*\)/g, "");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** A routine's input parameters: those whose mode is `in`, `inout` or `variadic`. */
|
|
428
|
+
const isInput = (a: { mode: string }) => a.mode !== "out";
|
|
429
|
+
|
|
430
|
+
/** A routine's identity among its overloads: its input parameter types, `(integer,text)`. */
|
|
431
|
+
export function routineSignature(p: Pick<RoutineProps, "args">, defaultSchema: string): string {
|
|
432
|
+
return `(${p.args.filter(isInput).map((a) => canonicalArgType(a.type, defaultSchema)).join(",")})`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** The result a function declares, or the one its OUT parameters give it: one OUT is its type, several are `record`. */
|
|
436
|
+
function routineResult(p: RoutineProps, defaultSchema: string): string | undefined {
|
|
437
|
+
if (p.returnsTable) return `table(${p.returnsTable.map((c) => `${quoteIdent(c.name)} ${canonicalArgType(c.type, defaultSchema)}`).join(",")})`;
|
|
438
|
+
if (p.returns !== undefined) {
|
|
439
|
+
const m = /^setof\s+(.*)$/is.exec(p.returns.trim());
|
|
440
|
+
return m ? `setof ${canonicalArgType(m[1]!, defaultSchema)}` : canonicalArgType(p.returns, defaultSchema);
|
|
441
|
+
}
|
|
442
|
+
const outs = p.args.filter((a) => a.mode === "out" || a.mode === "inout");
|
|
443
|
+
if (outs.length === 1) return canonicalArgType(outs[0]!.type, defaultSchema);
|
|
444
|
+
if (outs.length > 1) return "record";
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** A `SET` value as compared: each item unquoted, a bare word folded, `TO` and `=` alike. */
|
|
449
|
+
function canonicalSetting(value: string): string {
|
|
450
|
+
const items: string[] = [];
|
|
451
|
+
let tokens: Token[];
|
|
452
|
+
try {
|
|
453
|
+
tokens = tokenizeText(value, 0).filter((t) => !isTrivia(t));
|
|
454
|
+
} catch {
|
|
455
|
+
return value.trim();
|
|
456
|
+
}
|
|
457
|
+
let sign = "";
|
|
458
|
+
for (const t of tokens) {
|
|
459
|
+
if (t.kind === "punct" && t.text === ",") continue;
|
|
460
|
+
if (t.kind === "op" && (t.text === "-" || t.text === "+")) {
|
|
461
|
+
sign = t.text === "-" ? "-" : "";
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (t.kind === "string") items.push(stringValue(t.text) ?? t.text);
|
|
465
|
+
else if (t.kind === "qident") items.push(identValue(t));
|
|
466
|
+
else items.push(`${sign}${t.kind === "ident" ? t.text.toLowerCase() : t.text}`);
|
|
467
|
+
sign = "";
|
|
468
|
+
}
|
|
469
|
+
return items.join(", ");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** The canonical fields of a function or procedure. */
|
|
473
|
+
function routineFields(kind: "function" | "procedure", p: RoutineProps, defaultSchema: string, fields: Record<string, unknown>): void {
|
|
474
|
+
const lang = (p.language ?? "sql").toLowerCase();
|
|
475
|
+
fields.args = p.args.map((a) =>
|
|
476
|
+
[a.mode, a.name ? quoteIdent(a.name) : "", canonicalArgType(a.type, defaultSchema), a.default !== undefined ? `default ${canonicalExpr(a.default)}` : ""].filter(Boolean).join(" "),
|
|
477
|
+
);
|
|
478
|
+
fields.language = lang;
|
|
479
|
+
// Line endings aside, the body is what the server keeps and prints back.
|
|
480
|
+
fields.body = p.body.replace(/\r\n/g, "\n");
|
|
481
|
+
fields.link = p.link;
|
|
482
|
+
fields.securityDefiner = p.securityDefiner === true;
|
|
483
|
+
fields.set = p.set && Object.keys(p.set).length > 0
|
|
484
|
+
? Object.fromEntries(Object.entries(p.set).map(([k, v]) => [k.toLowerCase(), canonicalSetting(v)]).sort(([a], [b]) => (a! < b! ? -1 : 1)))
|
|
485
|
+
: undefined;
|
|
486
|
+
fields.transform = p.transform ? canonicalExpr(p.transform) : undefined;
|
|
487
|
+
if (kind === "procedure") return;
|
|
488
|
+
const result = routineResult(p, defaultSchema);
|
|
489
|
+
const setReturning = result !== undefined && /^(setof |table\()/.test(result);
|
|
490
|
+
fields.returns = result;
|
|
491
|
+
fields.volatility = p.volatility && p.volatility !== "volatile" ? p.volatility : undefined;
|
|
492
|
+
fields.strict = p.strict === true;
|
|
493
|
+
fields.leakproof = p.leakproof === true;
|
|
494
|
+
fields.parallel = p.parallel && p.parallel !== "unsafe" ? p.parallel : undefined;
|
|
495
|
+
const defaultCost = lang === "c" || lang === "internal" ? 1 : 100;
|
|
496
|
+
fields.cost = p.cost !== undefined && Number(p.cost) !== defaultCost ? String(Number(p.cost)) : undefined;
|
|
497
|
+
const defaultRows = setReturning ? 1000 : 0;
|
|
498
|
+
fields.rows = p.rows !== undefined && Number(p.rows) !== defaultRows ? String(Number(p.rows)) : undefined;
|
|
499
|
+
fields.support = p.support ? canonicalName(p.support, "pg_catalog").replace(/^pg_catalog\./, "") : undefined;
|
|
500
|
+
fields.window = p.window === true;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** The order the catalog prints a trigger's events in. */
|
|
504
|
+
const EVENT_ORDER = ["insert", "delete", "update", "truncate"];
|
|
505
|
+
|
|
506
|
+
/** A trigger argument as the catalog stores it: a string. */
|
|
507
|
+
function triggerArg(a: string): string {
|
|
508
|
+
const t = a.trim();
|
|
509
|
+
if (/^'/.test(t) || /^[Ee]'/.test(t) || /^\$/.test(t)) return stringValue(t) ?? t;
|
|
510
|
+
if (/^"/.test(t)) return identValue(t);
|
|
511
|
+
if (/^-?[0-9.]/.test(t)) return t.replace(/\s+/g, "");
|
|
512
|
+
return t.toLowerCase();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function triggerFields(p: TriggerProps, defaultSchema: string, fields: Record<string, unknown>): void {
|
|
516
|
+
fields.table = canonicalName(p.tableName, defaultSchema);
|
|
517
|
+
fields.constraint = p.constraint === true;
|
|
518
|
+
fields.timing = p.timing;
|
|
519
|
+
fields.events = [...p.events]
|
|
520
|
+
.sort((a, b) => EVENT_ORDER.indexOf(a.event) - EVENT_ORDER.indexOf(b.event))
|
|
521
|
+
.map((e) => (e.columns && e.columns.length > 0 ? `${e.event} of ${[...e.columns].sort().map(quoteIdent).join(",")}` : e.event));
|
|
522
|
+
fields.forEach = p.forEach;
|
|
523
|
+
fields.when = canonicalExpr(p.when);
|
|
524
|
+
fields.function = canonicalName(p.functionName, defaultSchema);
|
|
525
|
+
fields.args = p.args.length > 0 ? p.args.map(triggerArg) : undefined;
|
|
526
|
+
fields.from = p.from ? canonicalName(p.from, defaultSchema) : undefined;
|
|
527
|
+
fields.deferrable = p.deferrable === true;
|
|
528
|
+
fields.initiallyDeferred = p.initiallyDeferred === true;
|
|
529
|
+
fields.referencing = p.referencing && (p.referencing.old || p.referencing.new)
|
|
530
|
+
? [p.referencing.old ? `old table as ${quoteIdent(p.referencing.old)}` : "", p.referencing.new ? `new table as ${quoteIdent(p.referencing.new)}` : ""].filter(Boolean).join(" ")
|
|
531
|
+
: undefined;
|
|
532
|
+
}
|
|
533
|
+
|
|
393
534
|
/**
|
|
394
535
|
* A definition in canonical form. `props` is an entity's props, declared or
|
|
395
536
|
* parsed from what the server printed; `defaultSchema` qualifies a bare name.
|
|
@@ -398,7 +539,7 @@ export function canonicalPgObject(entityType: string, props: Record<string, unkn
|
|
|
398
539
|
const kind = kindOf[entityType];
|
|
399
540
|
if (!kind) throw new Error(`not a Postgres entity type: ${entityType}`);
|
|
400
541
|
const schemaOf = (p: { schema?: string }) => (kind === "schema" || kind === "extension" ? undefined : (p.schema ?? defaultSchema));
|
|
401
|
-
const base = { kind, schema: schemaOf(props as { schema?: string }), name: String(props.name) };
|
|
542
|
+
const base: Pick<CanonicalPgObject, "kind" | "schema" | "name" | "signature"> = { kind, schema: schemaOf(props as { schema?: string }), name: String(props.name) };
|
|
402
543
|
const fields: Record<string, unknown> = { comment: comment(props.comment as string | undefined) };
|
|
403
544
|
const columns: CanonicalColumn[] = [];
|
|
404
545
|
const constraints: CanonicalConstraint[] = [];
|
|
@@ -418,6 +559,19 @@ export function canonicalPgObject(entityType: string, props: Record<string, unkn
|
|
|
418
559
|
case "enum":
|
|
419
560
|
fields.labels = (props as unknown as EnumProps).labels;
|
|
420
561
|
break;
|
|
562
|
+
case "function":
|
|
563
|
+
case "procedure": {
|
|
564
|
+
const p = props as unknown as RoutineProps;
|
|
565
|
+
base.signature = routineSignature(p, defaultSchema);
|
|
566
|
+
routineFields(kind, p, defaultSchema, fields);
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
case "trigger": {
|
|
570
|
+
const p = props as unknown as TriggerProps;
|
|
571
|
+
triggerFields(p, defaultSchema, fields);
|
|
572
|
+
base.signature = ` ON ${String(fields.table)}`;
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
421
575
|
case "domain": {
|
|
422
576
|
const p = props as unknown as DomainProps;
|
|
423
577
|
fields.dataType = canonicalType(p.dataType, defaultSchema);
|
|
@@ -270,6 +270,42 @@ export const PG_CLASSIFIER_RULES = {
|
|
|
270
270
|
),
|
|
271
271
|
SQLPG267: rule("SQLPG267", "metadata", "Change a schema's owner", "ALTER SCHEMA ... OWNER TO changes only the catalog.", `${DOCS}/sql-alterschema.html`),
|
|
272
272
|
SQLPG268: rule("SQLPG268", "expand", "Change an object's kind", "A view and a materialized view, or a table and either, are different kinds of object: the old one is dropped and the new one created.", `${DOCS}/sql-createview.html`),
|
|
273
|
+
SQLPG280: rule(
|
|
274
|
+
"SQLPG280",
|
|
275
|
+
"metadata",
|
|
276
|
+
"Replace a function's or procedure's definition",
|
|
277
|
+
"CREATE OR REPLACE FUNCTION (or PROCEDURE) replaces the body and attributes in the catalog; it locks no table, and a call already running finishes with the old definition. The parameter types, the result and the input parameters' names have to stay the same.",
|
|
278
|
+
`${DOCS}/sql-createfunction.html`,
|
|
279
|
+
),
|
|
280
|
+
SQLPG281: rule(
|
|
281
|
+
"SQLPG281",
|
|
282
|
+
"metadata",
|
|
283
|
+
"Drop and create a function or procedure",
|
|
284
|
+
"CREATE OR REPLACE refuses a change of the result type, the output parameters, an input parameter's name, a removed parameter default or the routine's kind: the routine is dropped and created in one transaction. Nothing on the server depends on it (a view, a trigger, a column default would make the DROP fail).",
|
|
285
|
+
`${DOCS}/sql-createfunction.html`,
|
|
286
|
+
),
|
|
287
|
+
SQLPG282: rule(
|
|
288
|
+
"SQLPG282",
|
|
289
|
+
"expand",
|
|
290
|
+
"Change a function's result or parameters while other objects depend on it",
|
|
291
|
+
"DROP FUNCTION refuses while a view, a trigger, a column default or another routine depends on the function, and CREATE OR REPLACE cannot make this change. Create the new definition under a new name (or signature), move what depends on it, then drop the old.",
|
|
292
|
+
`${DOCS}/sql-dropfunction.html`,
|
|
293
|
+
),
|
|
294
|
+
SQLPG283: rule(
|
|
295
|
+
"SQLPG283",
|
|
296
|
+
"metadata",
|
|
297
|
+
"Create a trigger on an existing table",
|
|
298
|
+
"CREATE TRIGGER takes SHARE ROW EXCLUSIVE on its table: writes and other schema changes to the table wait until the transaction commits, but no row is read. The applier's lock_timeout bounds the wait behind long-running transactions.",
|
|
299
|
+
`${DOCS}/sql-createtrigger.html`,
|
|
300
|
+
),
|
|
301
|
+
SQLPG284: rule(
|
|
302
|
+
"SQLPG284",
|
|
303
|
+
"metadata",
|
|
304
|
+
"Change a trigger",
|
|
305
|
+
"CREATE OR REPLACE TRIGGER (14 and later) replaces the trigger under SHARE ROW EXCLUSIVE on its table. A constraint trigger has no OR REPLACE, and a trigger moved to another table is another trigger: each is dropped (ACCESS EXCLUSIVE, briefly) and created in one transaction.",
|
|
306
|
+
`${DOCS}/sql-createtrigger.html`,
|
|
307
|
+
),
|
|
308
|
+
SQLPG285: rule("SQLPG285", "drop", "Drop a trigger", "DROP TRIGGER takes ACCESS EXCLUSIVE on its table, briefly; no row is read or lost, and the table's writes no longer fire it.", `${DOCS}/sql-droptrigger.html`),
|
|
273
309
|
SQLPG270: rule("SQLPG270", "drop", "Drop an object", "The object and, for a table, a materialized view or a sequence, its data are gone.", `${DOCS}/sql-droptable.html`),
|
|
274
310
|
} as const satisfies Record<string, PgClassifierRule>;
|
|
275
311
|
|
|
@@ -28,6 +28,8 @@ export interface PgDiffObject extends CanonicalPgObject {
|
|
|
28
28
|
foreign?: string;
|
|
29
29
|
/** The export name, against a server. */
|
|
30
30
|
exportName?: string;
|
|
31
|
+
/** What depends on a routine on the server (a view, a trigger, a default), which a DROP FUNCTION would refuse. */
|
|
32
|
+
dependents?: string[];
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export type PgSchemaObject = Keyed<PgDiffObject>;
|
|
@@ -94,10 +96,22 @@ export function pgSchemaFromBuildFile(path: string, defaultSchema = "public"): P
|
|
|
94
96
|
return pgSchemaFromBuildOutput(readFileSync(path, "utf-8"), defaultSchema);
|
|
95
97
|
}
|
|
96
98
|
|
|
97
|
-
/**
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
/**
|
|
100
|
+
* The namespace an object's name is unique in: relations (tables, views,
|
|
101
|
+
* sequences, indexes) share one per schema, types and domains another,
|
|
102
|
+
* functions and procedures a third (told apart by their parameter types),
|
|
103
|
+
* and a trigger's name is unique on its table.
|
|
104
|
+
*/
|
|
105
|
+
export function pgNamespace(kind: string): string {
|
|
106
|
+
if (kind === "schema" || kind === "extension" || kind === "trigger") return kind;
|
|
107
|
+
if (kind === "enum" || kind === "domain") return "type";
|
|
108
|
+
if (kind === "function" || kind === "procedure") return "routine";
|
|
109
|
+
return "relation";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The identity an object has on a server: its kind's namespace, its qualified name, and its signature where it has one. */
|
|
113
|
+
export function qualifiedKey(o: { kind: string; schema?: string; name: string; signature?: string }): string {
|
|
114
|
+
return `${pgNamespace(o.kind)} ${o.schema ? `${o.schema}.` : ""}${o.name}${o.signature ?? ""}`;
|
|
101
115
|
}
|
|
102
116
|
|
|
103
117
|
/** The declared objects re-keyed by qualified name, the only identity a server has. */
|
|
@@ -107,8 +121,13 @@ export function keyedByQualifiedName(objects: readonly PgSchemaObject[]): PgSche
|
|
|
107
121
|
|
|
108
122
|
/** What a server holds, in the diff's form, keyed by qualified name. */
|
|
109
123
|
export function pgSchemaFromLive(live: readonly LivePgObject[], defaultSchema: string): PgSchemaObject[] {
|
|
110
|
-
|
|
111
|
-
|
|
124
|
+
// A routine with a SQL-standard body is not one a declaration can hold (`../parser.ts`): never chant's, never compared.
|
|
125
|
+
return live.filter((o) => !o.unsupported).map((o) => {
|
|
126
|
+
const canonical = {
|
|
127
|
+
...diffObject(o.type, o.statement, defaultSchema),
|
|
128
|
+
...(o.foreign ? { foreign: o.foreign } : {}),
|
|
129
|
+
...(o.dependents && o.dependents.length > 0 ? { dependents: o.dependents } : {}),
|
|
130
|
+
};
|
|
112
131
|
return { key: qualifiedKey(canonical), canonical };
|
|
113
132
|
});
|
|
114
133
|
}
|
|
@@ -88,6 +88,26 @@ async function tableExpressions(client: PostgresClient, o: CanonicalPgObject, de
|
|
|
88
88
|
});
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/** A trigger's WHEN condition as the server prints it, canonical. */
|
|
92
|
+
async function triggerWhen(client: PostgresClient, o: CanonicalPgObject, defaultSchema: string): Promise<string | undefined> {
|
|
93
|
+
const when = o.fields.when;
|
|
94
|
+
// An INSTEAD OF trigger, on a view, takes no WHEN.
|
|
95
|
+
if (typeof when !== "string" || o.fields.timing === "instead of") return undefined;
|
|
96
|
+
const events = ((o.fields.events as string[] | undefined) ?? []).map((e) => e.replace(/^(\w+)( of )?/, (_m, ev: string, of?: string) => `${ev.toUpperCase()}${of ? " OF " : ""}`));
|
|
97
|
+
return rolledBack(client, defaultSchema, async () => {
|
|
98
|
+
await client.query(`CREATE TEMP TABLE chant_normalize (LIKE ${String(o.fields.table)})`);
|
|
99
|
+
await client.query(`CREATE FUNCTION pg_temp.chant_normalize() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NULL; END'`);
|
|
100
|
+
await client.query(
|
|
101
|
+
`CREATE TRIGGER chant_normalize ${String(o.fields.timing).toUpperCase()} ${events.join(" OR ")} ON pg_temp.chant_normalize ` +
|
|
102
|
+
`FOR EACH ${o.fields.forEach === "row" ? "ROW" : "STATEMENT"} WHEN (${when}) EXECUTE FUNCTION pg_temp.chant_normalize()`,
|
|
103
|
+
);
|
|
104
|
+
await emptyPath(client);
|
|
105
|
+
const [row] = await client.query<Row>(`SELECT pg_catalog.pg_get_triggerdef(t.oid, true) AS def FROM pg_catalog.pg_trigger t WHERE t.tgrelid = 'pg_temp.chant_normalize'::pg_catalog.regclass`);
|
|
106
|
+
const m = / WHEN \((.*)\) EXECUTE (?:FUNCTION|PROCEDURE) /s.exec(String(row?.def ?? ""));
|
|
107
|
+
return m ? canonicalExpr(m[1]!) : undefined;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
91
111
|
/**
|
|
92
112
|
* The declared object with its expressions in the server's own printing,
|
|
93
113
|
* when the server answers; the object unchanged otherwise. Only the
|
|
@@ -99,6 +119,10 @@ export async function serverNormalized<T extends CanonicalPgObject & { outputs?:
|
|
|
99
119
|
// A declared column list names the outputs; otherwise the server's names do (`SELECT *` expanded).
|
|
100
120
|
return q === undefined ? o : { ...o, fields: { ...o.fields, query: q.query }, ...(o.fields.columns === undefined ? { outputs: q.outputs } : {}) };
|
|
101
121
|
}
|
|
122
|
+
if (o.kind === "trigger") {
|
|
123
|
+
const when = await triggerWhen(client, o, defaultSchema);
|
|
124
|
+
return when === undefined ? o : { ...o, fields: { ...o.fields, when } };
|
|
125
|
+
}
|
|
102
126
|
if (o.kind === "table") {
|
|
103
127
|
const e = await tableExpressions(client, o, defaultSchema);
|
|
104
128
|
if (!e) return o;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Functions, procedures and triggers (#3680): what the tags parse and refuse,
|
|
3
|
+
* how a change to each is classified and made, and how an import writes them.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "vitest";
|
|
7
|
+
import { func, procedure, schema, SqlTemplateError, table, trigger } from "./entities";
|
|
8
|
+
import { diffObject, keyedByQualifiedName, pgSchemaFromLive, type PgSchemaObject } from "./plan/schema";
|
|
9
|
+
import { diffPgSchemas, matchPgObjects } from "./plan/diff";
|
|
10
|
+
import { alterSteps, dropStatement } from "./apply/statements";
|
|
11
|
+
import { declaredObjects } from "./apply/apply";
|
|
12
|
+
import type { LivePgObject } from "./live/catalog";
|
|
13
|
+
import { objectsToIR } from "./import/ir";
|
|
14
|
+
import { PostgresGenerator } from "./import/generator";
|
|
15
|
+
|
|
16
|
+
const app = schema`CREATE SCHEMA app`;
|
|
17
|
+
const users = table`CREATE TABLE ${app}.users (id bigint PRIMARY KEY, email text, updated_at timestamptz)`;
|
|
18
|
+
|
|
19
|
+
describe("the tags", () => {
|
|
20
|
+
test("a function: its signature, attributes and body as written; an interpolation in the body is a reference", () => {
|
|
21
|
+
const count = func`
|
|
22
|
+
CREATE FUNCTION ${app}.user_count(min_id int = 0, OUT n bigint)
|
|
23
|
+
LANGUAGE sql STABLE STRICT PARALLEL SAFE SET search_path = ${app}
|
|
24
|
+
AS $$ SELECT count(*) FROM ${users} WHERE ${users.columns.id} >= min_id $$;
|
|
25
|
+
COMMENT ON FUNCTION ${app}.user_count(int) IS 'Users'`;
|
|
26
|
+
expect(count.entityType).toBe("Postgres::Function");
|
|
27
|
+
expect(count.sqlName).toBe("app.user_count");
|
|
28
|
+
expect(count.props).toMatchObject({
|
|
29
|
+
schema: "app",
|
|
30
|
+
name: "user_count",
|
|
31
|
+
args: [
|
|
32
|
+
{ mode: "in", name: "min_id", type: "int", default: "0" },
|
|
33
|
+
{ mode: "out", name: "n", type: "bigint" },
|
|
34
|
+
],
|
|
35
|
+
language: "sql",
|
|
36
|
+
volatility: "stable",
|
|
37
|
+
strict: true,
|
|
38
|
+
parallel: "safe",
|
|
39
|
+
set: { search_path: "app" },
|
|
40
|
+
body: " SELECT count(*) FROM app.users WHERE id >= min_id ",
|
|
41
|
+
comment: "Users",
|
|
42
|
+
});
|
|
43
|
+
expect(count.dependsOn).toContain(users);
|
|
44
|
+
expect(count.props.reads).toEqual([users]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a procedure and a trigger", () => {
|
|
48
|
+
const archive = procedure`CREATE PROCEDURE ${app}.archive(IN before bigint, INOUT n int DEFAULT NULL) LANGUAGE plpgsql AS 'BEGIN n := 0; END'`;
|
|
49
|
+
expect(archive.props).toMatchObject({ args: [{ mode: "in", name: "before" }, { mode: "inout", name: "n", default: "NULL" }], body: "BEGIN n := 0; END" });
|
|
50
|
+
const touch = func`CREATE FUNCTION ${app}.touch() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN NEW.updated_at := now(); RETURN NEW; END $$`;
|
|
51
|
+
const t = trigger`CREATE TRIGGER users_touch BEFORE INSERT OR UPDATE OF email ON ${users} FOR EACH ROW WHEN (NEW.email IS NOT NULL) EXECUTE FUNCTION ${touch}('x', 1)`;
|
|
52
|
+
expect(t.entityType).toBe("Postgres::Trigger");
|
|
53
|
+
expect(t.props).toMatchObject({
|
|
54
|
+
schema: "app",
|
|
55
|
+
name: "users_touch",
|
|
56
|
+
timing: "before",
|
|
57
|
+
events: [{ event: "insert" }, { event: "update", columns: ["email"] }],
|
|
58
|
+
tableName: "app.users",
|
|
59
|
+
forEach: "row",
|
|
60
|
+
when: "NEW.email IS NOT NULL",
|
|
61
|
+
functionName: "app.touch",
|
|
62
|
+
args: ["'x'", "1"],
|
|
63
|
+
});
|
|
64
|
+
expect(t.dependsOn).toEqual([users, touch]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a SQL-standard body, SET FROM CURRENT and a qualified trigger name are refused", () => {
|
|
68
|
+
expect(() => func`CREATE FUNCTION app.f(a int) RETURNS int LANGUAGE sql RETURN a + 1`).toThrow(/SQL-standard body/);
|
|
69
|
+
expect(() => func`CREATE FUNCTION app.f() RETURNS int LANGUAGE sql BEGIN ATOMIC SELECT 1; END`).toThrow(/SQL-standard body/);
|
|
70
|
+
expect(() => func`CREATE FUNCTION app.f() RETURNS int LANGUAGE sql SET search_path FROM CURRENT AS 'select 1'`).toThrow(/FROM CURRENT/);
|
|
71
|
+
expect(() => procedure`CREATE PROCEDURE app.p() LANGUAGE sql STABLE AS 'select 1'`).toThrow(/not an attribute of a procedure/);
|
|
72
|
+
expect(() => trigger`CREATE TRIGGER app.t BEFORE INSERT ON app.users EXECUTE FUNCTION app.f()`).toThrow(/not schema-qualified/);
|
|
73
|
+
expect(() => func`CREATE TABLE app.t (a int)`).toThrow(SqlTemplateError);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ── Classifying and making changes ─────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
const F = "Postgres::Function";
|
|
80
|
+
const TR = "Postgres::Trigger";
|
|
81
|
+
const live = (type: string, statement: string, extra: Partial<LivePgObject> = {}) => pgSchemaFromLive([{ type, statement, name: "", oid: "1", ...extra } as LivePgObject], "public");
|
|
82
|
+
const declared = (type: string, ddl: string): PgSchemaObject[] => keyedByQualifiedName([{ key: "x", canonical: { ...diffObject(type, ddl, "public"), exportName: "x" } }]);
|
|
83
|
+
const rules = (type: string, before: string, after: string, extra: Partial<LivePgObject> = {}) =>
|
|
84
|
+
diffPgSchemas(live(type, before, extra), declared(type, after)).changes.map((c) => [c.field, c.rule]);
|
|
85
|
+
|
|
86
|
+
const FN = (body: string, returns = "integer", args = "a integer") => `CREATE FUNCTION app.f(${args}) RETURNS ${returns} LANGUAGE sql AS $$ ${body} $$`;
|
|
87
|
+
const SERVER_FN = "CREATE OR REPLACE FUNCTION app.f(a integer)\n RETURNS integer\n LANGUAGE sql\nAS $function$ select a $function$\n";
|
|
88
|
+
|
|
89
|
+
describe("the classifier", () => {
|
|
90
|
+
test("the server's printing of a function is the declaration's", () => {
|
|
91
|
+
expect(rules(F, SERVER_FN, FN("select a", "int", "a int"))).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a new body or attribute is CREATE OR REPLACE; a new result is drop and create, or expand and contract when something depends on it", () => {
|
|
95
|
+
expect(rules(F, SERVER_FN, FN("select a + 1"))).toEqual([["body", "SQLPG280"]]);
|
|
96
|
+
expect(rules(F, SERVER_FN, FN("select a", "bigint"))).toEqual([["returns", "SQLPG281"]]);
|
|
97
|
+
expect(rules(F, SERVER_FN, FN("select a", "integer", "b integer"))).toEqual([["args", "SQLPG281"]]);
|
|
98
|
+
expect(rules(F, SERVER_FN, FN("select a", "bigint"), { dependents: ["view app.v"] })).toEqual([["returns", "SQLPG282"]]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a trigger: created on an existing table under SHARE ROW EXCLUSIVE, changed in place, dropped", () => {
|
|
102
|
+
const T1 = "CREATE TRIGGER t BEFORE UPDATE ON app.users FOR EACH ROW EXECUTE FUNCTION app.touch()";
|
|
103
|
+
const T2 = "CREATE TRIGGER t BEFORE INSERT OR UPDATE ON app.users FOR EACH ROW EXECUTE FUNCTION app.touch()";
|
|
104
|
+
expect(diffPgSchemas([], declared(TR, T1)).changes.map((c) => c.rule)).toEqual(["SQLPG283"]);
|
|
105
|
+
expect(rules(TR, T1, T2)).toEqual([["events", "SQLPG284"]]);
|
|
106
|
+
expect(diffPgSchemas(live(TR, T1), []).changes.map((c) => [c.rule, c.class])).toEqual([["SQLPG285", "drop"]]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
function steps(type: string, liveDdl: string, declaredDdl: string): string[] {
|
|
111
|
+
const [obj] = declaredObjects(JSON.stringify({ dialect: "postgres", objects: [{ export: "x", type, ddl: declaredDdl, dependsOn: [] }] }));
|
|
112
|
+
const after = keyedByQualifiedName([{ key: "x", canonical: obj!.canonical }]);
|
|
113
|
+
const before = live(type, liveDdl);
|
|
114
|
+
const m = matchPgObjects(before, after).find((x) => x.kind === "matched")!;
|
|
115
|
+
return alterSteps(obj!, m.before!.canonical, diffPgSchemas(before, after).changes, { major: 18 }).steps.map((s) => s.sql);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe("the statements", () => {
|
|
119
|
+
test("OR REPLACE, or a drop and a create named by the parameter types", () => {
|
|
120
|
+
expect(steps(F, SERVER_FN, FN("select a + 1"))).toEqual(["CREATE OR REPLACE FUNCTION app.f(a integer) RETURNS integer LANGUAGE sql AS $$ select a + 1 $$"]);
|
|
121
|
+
const recreate = steps(F, SERVER_FN, FN("select a", "bigint"));
|
|
122
|
+
expect(recreate[0]).toBe("DROP FUNCTION app.f(integer)");
|
|
123
|
+
expect(recreate[1]).toBe("CREATE FUNCTION app.f(a integer) RETURNS bigint LANGUAGE sql AS $$ select a $$");
|
|
124
|
+
expect(recreate[2]).toMatch(/^COMMENT ON FUNCTION app\.f\(integer\) IS /);
|
|
125
|
+
expect(dropStatement("procedure", "app", "p", "(integer,text)").sql).toBe("DROP PROCEDURE app.p(integer,text)");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("a trigger is replaced in place; a constraint trigger is dropped and created", () => {
|
|
129
|
+
const T1 = "CREATE TRIGGER t BEFORE UPDATE ON app.users FOR EACH ROW EXECUTE FUNCTION app.touch()";
|
|
130
|
+
expect(steps(TR, T1, T1.replace("UPDATE", "INSERT"))).toEqual(["CREATE OR REPLACE TRIGGER t BEFORE INSERT ON app.users FOR EACH ROW EXECUTE FUNCTION app.touch()"]);
|
|
131
|
+
const C1 = "CREATE CONSTRAINT TRIGGER t AFTER UPDATE ON app.users DEFERRABLE FOR EACH ROW EXECUTE FUNCTION app.touch()";
|
|
132
|
+
expect(steps(TR, C1, C1.replace("UPDATE", "INSERT")).slice(0, 2)).toEqual(["DROP TRIGGER t ON app.users", C1.replace("UPDATE", "INSERT")]);
|
|
133
|
+
expect(dropStatement("trigger", "app", "t", " ON app.users").sql).toBe("DROP TRIGGER t ON app.users");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("importing", () => {
|
|
138
|
+
test("functions and triggers are written as func and trigger declarations, their references interpolated", () => {
|
|
139
|
+
const content = new PostgresGenerator().generate(
|
|
140
|
+
objectsToIR([
|
|
141
|
+
{ type: "Postgres::Table", schema: "app", name: "users", ddl: "CREATE TABLE app.users (\n id bigint\n)" },
|
|
142
|
+
{ type: "Postgres::Function", schema: "app", name: "touch", ddl: "CREATE OR REPLACE FUNCTION app.touch()\n RETURNS trigger\n LANGUAGE plpgsql\nAS $function$ BEGIN RETURN NEW; END $function$" },
|
|
143
|
+
{ type: "Postgres::Trigger", schema: "app", name: "users_touch", ddl: "CREATE TRIGGER users_touch BEFORE UPDATE ON app.users FOR EACH ROW EXECUTE FUNCTION app.touch()" },
|
|
144
|
+
]),
|
|
145
|
+
)[0]!.content;
|
|
146
|
+
expect(content).toContain('import { func, table, trigger } from "@intentius/chant-lexicon-sql/postgres";');
|
|
147
|
+
expect(content).toContain("export const touch = func`");
|
|
148
|
+
expect(content).toContain("ON ${users} FOR EACH ROW EXECUTE FUNCTION ${touch}()");
|
|
149
|
+
});
|
|
150
|
+
});
|
package/src/postgres.ts
CHANGED
|
@@ -15,6 +15,9 @@ export {
|
|
|
15
15
|
type,
|
|
16
16
|
domain,
|
|
17
17
|
extension,
|
|
18
|
+
func,
|
|
19
|
+
procedure,
|
|
20
|
+
trigger,
|
|
18
21
|
literal,
|
|
19
22
|
SqlLiteral,
|
|
20
23
|
SqlTemplateError,
|
|
@@ -33,6 +36,9 @@ export {
|
|
|
33
36
|
type PostgresEnum,
|
|
34
37
|
type PostgresDomain,
|
|
35
38
|
type PostgresExtension,
|
|
39
|
+
type PostgresFunction,
|
|
40
|
+
type PostgresProcedure,
|
|
41
|
+
type PostgresTrigger,
|
|
36
42
|
type ColumnDef,
|
|
37
43
|
type KeyDef,
|
|
38
44
|
type CheckDef,
|
|
@@ -46,6 +52,11 @@ export {
|
|
|
46
52
|
type EnumProps,
|
|
47
53
|
type DomainProps,
|
|
48
54
|
type ExtensionProps,
|
|
55
|
+
type RoutineProps,
|
|
56
|
+
type RoutineArgDef,
|
|
57
|
+
type FunctionProps,
|
|
58
|
+
type ProcedureProps,
|
|
59
|
+
type TriggerProps,
|
|
49
60
|
type LineageEdge,
|
|
50
61
|
} from "./postgres/entities";
|
|
51
62
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { environmentOf, receiptDialect, sqlReceiptStore } from "./receipts";
|
|
3
|
+
import type { PostgresClient } from "./postgres/live/client";
|
|
4
|
+
|
|
5
|
+
const ref = (effect: string) => ({ name: effect, effect, flavor: "existence" as const, inputs: {} });
|
|
6
|
+
|
|
7
|
+
/** A Postgres client that records each statement and answers the store's catalog reads. */
|
|
8
|
+
function fakePostgres(state: { schema?: boolean; table?: string | null } = {}) {
|
|
9
|
+
const statements: string[] = [];
|
|
10
|
+
let schema = state.schema ?? false;
|
|
11
|
+
let table: string | null | undefined = state.table;
|
|
12
|
+
const rows = new Map<string, string>();
|
|
13
|
+
const client: PostgresClient = {
|
|
14
|
+
async query<T>(sql: string, params: readonly unknown[] = []): Promise<T[]> {
|
|
15
|
+
statements.push(sql);
|
|
16
|
+
if (sql.includes("pg_namespace WHERE nspname")) return [{ n: schema ? 1 : 0 }] as T[];
|
|
17
|
+
if (sql.startsWith("CREATE SCHEMA")) schema = true;
|
|
18
|
+
if (sql.includes("to_regclass($1) IS NOT NULL AS present")) return [{ present: table !== undefined, comment: table ?? null }] as T[];
|
|
19
|
+
if (sql.startsWith("CREATE TABLE")) table = null;
|
|
20
|
+
if (sql.startsWith("COMMENT ON TABLE")) table = sql.slice(sql.indexOf("IS '") + 4, -1);
|
|
21
|
+
if (sql.startsWith("INSERT INTO")) rows.set(String(params[0]), String(params[2]));
|
|
22
|
+
if (sql.startsWith("SELECT expectation")) {
|
|
23
|
+
const v = rows.get(String(params[0]));
|
|
24
|
+
return (v === undefined ? [] : [{ expectation: v }]) as T[];
|
|
25
|
+
}
|
|
26
|
+
return [] as T[];
|
|
27
|
+
},
|
|
28
|
+
end: async () => undefined,
|
|
29
|
+
};
|
|
30
|
+
return { client, statements, rows };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("receiptDialect (#3657)", () => {
|
|
34
|
+
test("the environment's profile decides, by its URL's scheme", () => {
|
|
35
|
+
const config = { sql: { profiles: { pg: { url: "postgres://db:5432/app" }, ch: { url: "http://ch:8123" } } } };
|
|
36
|
+
expect(receiptDialect(config, "pg", {})).toBe("postgres");
|
|
37
|
+
expect(receiptDialect(config, "ch", {})).toBe("clickhouse");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("with no profile, sql.dialect, then whichever server variable is set", () => {
|
|
41
|
+
expect(receiptDialect({ sql: { dialect: "postgres" } }, "dev", {})).toBe("postgres");
|
|
42
|
+
expect(receiptDialect({ sql: { dialect: ["clickhouse"] } }, undefined, { POSTGRES_URL: "postgres://x/y" })).toBe("clickhouse");
|
|
43
|
+
expect(receiptDialect(undefined, undefined, { POSTGRES_URL: "postgres://x/y" })).toBe("postgres");
|
|
44
|
+
expect(receiptDialect(undefined, undefined, {})).toBe("clickhouse");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("environmentOf (#3657)", () => {
|
|
49
|
+
test("the option, then CHANT_ENV, then a literal ownership.env", () => {
|
|
50
|
+
expect(environmentOf({ environment: "a" }, { ownership: { stack: "s", env: "c" } }, { CHANT_ENV: "b" })).toBe("a");
|
|
51
|
+
expect(environmentOf({}, { ownership: { stack: "s", env: "c" } }, { CHANT_ENV: "b" })).toBe("b");
|
|
52
|
+
expect(environmentOf({}, { ownership: { stack: "s", env: "c" } }, {})).toBe("c");
|
|
53
|
+
expect(environmentOf({}, undefined, {})).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("sqlReceiptStore over Postgres (#3657)", () => {
|
|
58
|
+
test("keeps receipts in chant_receipts.receipts, a schema and table it marks as chant's", async () => {
|
|
59
|
+
const pg = fakePostgres();
|
|
60
|
+
const store = sqlReceiptStore({ environment: "prod", stack: "shop", postgres: pg.client, runId: "r1" });
|
|
61
|
+
expect(await store.read(ref("fill/b1"))).toBeUndefined();
|
|
62
|
+
await store.write(ref("fill/b1"), "present");
|
|
63
|
+
expect(await store.read(ref("fill/b1"))).toBe("present");
|
|
64
|
+
|
|
65
|
+
expect(pg.statements).toContain("CREATE SCHEMA IF NOT EXISTS chant_receipts");
|
|
66
|
+
expect(pg.statements.find((s) => s.startsWith("COMMENT ON SCHEMA chant_receipts"))).toContain("receipts=effects");
|
|
67
|
+
expect(pg.statements.find((s) => s.startsWith("CREATE TABLE"))).toContain("TABLE IF NOT EXISTS chant_receipts.receipts (");
|
|
68
|
+
expect([...pg.rows.keys()]).toEqual(["shop/prod/fill/b1"]);
|
|
69
|
+
expect(await store.location()).toEqual({ dialect: "postgres", table: "chant_receipts.receipts", source: "the caller's connection", address: "shop/prod/<effect>" });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a receiptsSchema keeps them in <schema>.__chant_receipts, and creates no schema", async () => {
|
|
73
|
+
const pg = fakePostgres();
|
|
74
|
+
const store = sqlReceiptStore({
|
|
75
|
+
environment: "prod",
|
|
76
|
+
postgres: pg.client,
|
|
77
|
+
config: { ownership: { stack: "shop" }, sql: { profiles: { prod: { url: "postgres://db/app", receiptsSchema: "app" } } } },
|
|
78
|
+
});
|
|
79
|
+
await store.write(ref("fill/b1"), "present");
|
|
80
|
+
expect(pg.statements.some((s) => s.includes("CREATE SCHEMA"))).toBe(false);
|
|
81
|
+
expect(pg.statements.find((s) => s.startsWith("CREATE TABLE"))).toContain("TABLE IF NOT EXISTS app.__chant_receipts (");
|
|
82
|
+
expect((await store.location()).table).toBe("app.__chant_receipts");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a table of the user's under the receipts name is refused, not written into", async () => {
|
|
86
|
+
const pg = fakePostgres({ schema: true, table: "the user's own receipts" });
|
|
87
|
+
const store = sqlReceiptStore({ environment: "prod", stack: "shop", postgres: pg.client });
|
|
88
|
+
await expect(store.write(ref("fill/b1"), "present")).rejects.toThrow(/chant_receipts\.receipts exists and is not chant's receipts table/);
|
|
89
|
+
expect(pg.rows.size).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
});
|