@intentius/chant-lexicon-sql 0.115.0 → 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.
Files changed (78) hide show
  1. package/dist/codegen/registry.d.ts.map +1 -1
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/integrity.json +5 -5
  5. package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
  6. package/dist/lsp/postgres-template.d.ts.map +1 -1
  7. package/dist/manifest.json +1 -1
  8. package/dist/meta.json +15 -0
  9. package/dist/okf/index.md +3 -0
  10. package/dist/okf/types/PostgresFunction.md +9 -0
  11. package/dist/okf/types/PostgresProcedure.md +9 -0
  12. package/dist/okf/types/PostgresTrigger.md +9 -0
  13. package/dist/plugin.d.ts.map +1 -1
  14. package/dist/postgres/apply/apply.d.ts +6 -1
  15. package/dist/postgres/apply/apply.d.ts.map +1 -1
  16. package/dist/postgres/apply/statements.d.ts +9 -2
  17. package/dist/postgres/apply/statements.d.ts.map +1 -1
  18. package/dist/postgres/entities.d.ts +110 -3
  19. package/dist/postgres/entities.d.ts.map +1 -1
  20. package/dist/postgres/entity-types.d.ts +3 -0
  21. package/dist/postgres/entity-types.d.ts.map +1 -1
  22. package/dist/postgres/import/generator.d.ts.map +1 -1
  23. package/dist/postgres/import/live-export.d.ts.map +1 -1
  24. package/dist/postgres/live/catalog.d.ts +16 -4
  25. package/dist/postgres/live/catalog.d.ts.map +1 -1
  26. package/dist/postgres/live/describe-resources.d.ts +6 -1
  27. package/dist/postgres/live/describe-resources.d.ts.map +1 -1
  28. package/dist/postgres/parser.d.ts +82 -5
  29. package/dist/postgres/parser.d.ts.map +1 -1
  30. package/dist/postgres/plan/deep.d.ts.map +1 -1
  31. package/dist/postgres/plan/diff.d.ts +1 -1
  32. package/dist/postgres/plan/diff.d.ts.map +1 -1
  33. package/dist/postgres/plan/normalize.d.ts +21 -2
  34. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  35. package/dist/postgres/plan/rules.d.ts +6 -0
  36. package/dist/postgres/plan/rules.d.ts.map +1 -1
  37. package/dist/postgres/plan/schema.d.ts +11 -1
  38. package/dist/postgres/plan/schema.d.ts.map +1 -1
  39. package/dist/postgres/plan/server-normalize.d.ts.map +1 -1
  40. package/dist/postgres.d.ts +1 -1
  41. package/dist/postgres.d.ts.map +1 -1
  42. package/dist/rules/postgres-templates.ts +2 -2
  43. package/dist/rules/sqlpg001.ts +3 -0
  44. package/dist/validate.d.ts.map +1 -1
  45. package/package.json +2 -2
  46. package/src/codegen/postgres-classifier-page.ts +1 -1
  47. package/src/codegen/registry.ts +3 -0
  48. package/src/generated/lexicon-sql.json +15 -0
  49. package/src/index.ts +1 -1
  50. package/src/lint/rules/postgres-templates.ts +2 -2
  51. package/src/lint/rules/sqlpg001.ts +3 -0
  52. package/src/lsp/postgres-template.ts +6 -0
  53. package/src/mcp/postgres.ts +1 -1
  54. package/src/plugin.ts +3 -0
  55. package/src/postgres/apply/apply.ts +21 -5
  56. package/src/postgres/apply/statements.ts +53 -11
  57. package/src/postgres/entities.ts +332 -9
  58. package/src/postgres/entity-types.ts +3 -0
  59. package/src/postgres/import/generator.ts +5 -2
  60. package/src/postgres/import/live-export.ts +4 -0
  61. package/src/postgres/live/catalog.ts +78 -4
  62. package/src/postgres/live/describe-resources.ts +17 -7
  63. package/src/postgres/live/routines.e2e.test.ts +173 -0
  64. package/src/postgres/migrate/batches.e2e.test.ts +2 -1
  65. package/src/postgres/migrate/migrate.e2e.test.ts +2 -1
  66. package/src/postgres/parser.ts +314 -8
  67. package/src/postgres/plan/commands.ts +2 -2
  68. package/src/postgres/plan/deep.ts +57 -17
  69. package/src/postgres/plan/diff.ts +46 -6
  70. package/src/postgres/plan/normalize.ts +158 -4
  71. package/src/postgres/plan/rules.ts +36 -0
  72. package/src/postgres/plan/schema.ts +25 -6
  73. package/src/postgres/plan/server-normalize.ts +24 -0
  74. package/src/postgres/routines.test.ts +150 -0
  75. package/src/postgres.ts +11 -0
  76. package/src/skill-defs.ts +1 -1
  77. package/src/skills/chant-sql-postgres.md +18 -2
  78. 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
- /** The identity an object has on a server: its kind's namespace and its qualified name. */
98
- export function qualifiedKey(o: { kind: string; schema?: string; name: string }): string {
99
- const space = o.kind === "schema" ? "schema" : o.kind === "extension" ? "extension" : o.kind === "enum" || o.kind === "domain" ? "type" : "relation";
100
- return `${space} ${o.schema ? `${o.schema}.` : ""}${o.name}`;
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
- return live.map((o) => {
111
- const canonical = { ...diffObject(o.type, o.statement, defaultSchema), ...(o.foreign ? { foreign: o.foreign } : {}) };
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
 
package/src/skill-defs.ts CHANGED
@@ -54,7 +54,7 @@ export const sqlSkills = createSkillsLoader(import.meta.url, [
54
54
  file: "chant-sql-postgres.md",
55
55
  name: "chant-sql-postgres",
56
56
  description:
57
- "Declare Postgres schemas, tables, constraints, indexes, views, sequences, enum and domain types and extensions as SQL-shaped tagged templates, with references, lineage and the SQLPG lint rules",
57
+ "Declare Postgres schemas, tables, constraints, indexes, views, sequences, enum and domain types, extensions, functions, procedures and triggers as SQL-shaped tagged templates, with references, lineage and the SQLPG lint rules",
58
58
  triggers: [
59
59
  { type: "context" as const, value: "postgres" },
60
60
  { type: "context" as const, value: "postgres schema" },
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  skill: chant-sql-postgres
3
- description: Declare Postgres schemas, tables, constraints, indexes, views, sequences, enum and domain types and extensions as SQL-shaped tagged templates, with references, lineage and the SQLPG lint rules
3
+ description: Declare Postgres schemas, tables, constraints, indexes, views, sequences, enum and domain types, extensions, functions, procedures and triggers as SQL-shaped tagged templates, with references, lineage and the SQLPG lint rules
4
4
  user-invocable: true
5
5
  ---
6
6
  # Declaring a Postgres schema with chant
@@ -37,6 +37,9 @@ The export name (`users`) is the object's identity in chant. The name in the SQL
37
37
  | `type` | `CREATE TYPE ... AS ENUM` | `Postgres::Enum` |
38
38
  | `domain` | `CREATE DOMAIN` | `Postgres::Domain` |
39
39
  | `extension` | `CREATE EXTENSION` | `Postgres::Extension` |
40
+ | `func` | `CREATE FUNCTION` | `Postgres::Function` |
41
+ | `procedure` | `CREATE PROCEDURE` | `Postgres::Procedure` |
42
+ | `trigger` | `CREATE TRIGGER` | `Postgres::Trigger` |
40
43
 
41
44
  `table` and `view` imported from the package root are ClickHouse's. Import every tag from `@intentius/chant-lexicon-sql/postgres`.
42
45
 
@@ -124,6 +127,19 @@ export const citext = extension`CREATE EXTENSION IF NOT EXISTS citext WITH SCHEM
124
127
 
125
128
  Use the entity as a column type: `status ${status} NOT NULL DEFAULT 'placed'`. The table then depends on the type. Enum labels are values of the type, so a label cannot be removed or reordered later without a rebuild; add labels at the end. An extension is an object of its own with no references to it from a column type, so a table using a type it provides does not wait for it; keep extensions in a project the database already has, or apply them first.
126
129
 
130
+ ## Functions, procedures and triggers
131
+
132
+ ```ts
133
+ import { func, trigger } from "@intentius/chant-lexicon-sql/postgres";
134
+
135
+ export const touch = func`CREATE FUNCTION ${app}.touch() RETURNS trigger LANGUAGE plpgsql AS $$
136
+ BEGIN NEW.updated_at := now(); RETURN NEW; END
137
+ $$`;
138
+ export const ordersTouch = trigger`CREATE TRIGGER orders_touch BEFORE UPDATE ON ${orders} FOR EACH ROW EXECUTE FUNCTION ${touch}()`;
139
+ ```
140
+
141
+ The tag is `func` (`function` is a JavaScript key word). Write the body as a string, `AS $$ ... $$`: it is kept and compared verbatim. `${users}` inside the body renders as `app.users` and makes the routine depend on the table. A SQL-standard body (`RETURN ...`, `BEGIN ATOMIC`) and `SET ... FROM CURRENT` are SQLPG001 errors. Overloads are separate objects, identified by their input parameter types. A trigger's name is unqualified; it lives on its table.
142
+
127
143
  ## Comments
128
144
 
129
145
  Postgres has no inline comment clause. Follow the `CREATE` with `COMMENT ON` for the object, its columns and its named constraints, separated by `;`:
@@ -162,4 +178,4 @@ chant build src --lexicon sql -o dist/schema.json
162
178
 
163
179
  ## Not covered
164
180
 
165
- `ALTER`, `CREATE POLICY`, `GRANT`, functions, triggers, rules and the composite and range forms of `CREATE TYPE` are not declared with the tags. Planning a schema change (`chant sql diff`, `chant sql plan`, the lock classes) is the `chant-sql-postgres-plan` skill. Apply and the expand-and-contract migration Op have their own skills, which arrive with the code they describe.
181
+ `ALTER`, `CREATE POLICY`, `GRANT`, rules, aggregates, event triggers and the composite and range forms of `CREATE TYPE` are not declared with the tags. Planning a schema change (`chant sql diff`, `chant sql plan`, the lock classes) is the `chant-sql-postgres-plan` skill. Apply and the expand-and-contract migration Op have their own skills, which arrive with the code they describe.
package/src/validate.ts CHANGED
@@ -34,6 +34,9 @@ const REQUIRED_ENTITIES = [
34
34
  "PostgresEnum",
35
35
  "PostgresDomain",
36
36
  "PostgresExtension",
37
+ "PostgresFunction",
38
+ "PostgresProcedure",
39
+ "PostgresTrigger",
37
40
  ];
38
41
 
39
42
  /** Engines a ClickHouse schema declares most: the MergeTree family and the engines beside it. */