@intentius/chant-lexicon-sql 0.115.0 → 0.117.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 (116) hide show
  1. package/dist/codegen/registry.d.ts.map +1 -1
  2. package/dist/config.d.ts +20 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/integrity.json +5 -5
  7. package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
  8. package/dist/lsp/postgres-template.d.ts.map +1 -1
  9. package/dist/manifest.json +1 -1
  10. package/dist/meta.json +35 -0
  11. package/dist/okf/index.md +7 -0
  12. package/dist/okf/types/PostgresDefaultPrivileges.md +9 -0
  13. package/dist/okf/types/PostgresFunction.md +9 -0
  14. package/dist/okf/types/PostgresGrant.md +9 -0
  15. package/dist/okf/types/PostgresPolicy.md +9 -0
  16. package/dist/okf/types/PostgresProcedure.md +9 -0
  17. package/dist/okf/types/PostgresRole.md +9 -0
  18. package/dist/okf/types/PostgresTrigger.md +9 -0
  19. package/dist/op/activities/postgres-migration.d.ts.map +1 -1
  20. package/dist/op/activity-contracts.d.ts +10 -0
  21. package/dist/op/activity-contracts.d.ts.map +1 -1
  22. package/dist/plugin.d.ts.map +1 -1
  23. package/dist/postgres/access/acl.d.ts +119 -0
  24. package/dist/postgres/access/acl.d.ts.map +1 -0
  25. package/dist/postgres/access/import.d.ts +13 -0
  26. package/dist/postgres/access/import.d.ts.map +1 -0
  27. package/dist/postgres/access/live.d.ts +24 -0
  28. package/dist/postgres/access/live.d.ts.map +1 -0
  29. package/dist/postgres/apply/apply.d.ts +6 -1
  30. package/dist/postgres/apply/apply.d.ts.map +1 -1
  31. package/dist/postgres/apply/statements.d.ts +12 -2
  32. package/dist/postgres/apply/statements.d.ts.map +1 -1
  33. package/dist/postgres/entities.d.ts +212 -3
  34. package/dist/postgres/entities.d.ts.map +1 -1
  35. package/dist/postgres/entity-types.d.ts +7 -0
  36. package/dist/postgres/entity-types.d.ts.map +1 -1
  37. package/dist/postgres/import/generator.d.ts +4 -1
  38. package/dist/postgres/import/generator.d.ts.map +1 -1
  39. package/dist/postgres/import/live-export.d.ts +6 -0
  40. package/dist/postgres/import/live-export.d.ts.map +1 -1
  41. package/dist/postgres/live/bind.d.ts +2 -0
  42. package/dist/postgres/live/bind.d.ts.map +1 -1
  43. package/dist/postgres/live/catalog.d.ts +25 -4
  44. package/dist/postgres/live/catalog.d.ts.map +1 -1
  45. package/dist/postgres/live/describe-resources.d.ts +15 -1
  46. package/dist/postgres/live/describe-resources.d.ts.map +1 -1
  47. package/dist/postgres/migrate/op.d.ts +29 -0
  48. package/dist/postgres/migrate/op.d.ts.map +1 -1
  49. package/dist/postgres/parser.d.ts +155 -5
  50. package/dist/postgres/parser.d.ts.map +1 -1
  51. package/dist/postgres/plan/commands.d.ts +12 -0
  52. package/dist/postgres/plan/commands.d.ts.map +1 -1
  53. package/dist/postgres/plan/deep.d.ts.map +1 -1
  54. package/dist/postgres/plan/diff.d.ts +11 -2
  55. package/dist/postgres/plan/diff.d.ts.map +1 -1
  56. package/dist/postgres/plan/normalize.d.ts +28 -2
  57. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  58. package/dist/postgres/plan/rules.d.ts +14 -0
  59. package/dist/postgres/plan/rules.d.ts.map +1 -1
  60. package/dist/postgres/plan/schema.d.ts +12 -1
  61. package/dist/postgres/plan/schema.d.ts.map +1 -1
  62. package/dist/postgres/plan/server-normalize.d.ts +4 -1
  63. package/dist/postgres/plan/server-normalize.d.ts.map +1 -1
  64. package/dist/postgres.d.ts +1 -1
  65. package/dist/postgres.d.ts.map +1 -1
  66. package/dist/rules/postgres-templates.ts +2 -2
  67. package/dist/rules/sqlpg001.ts +10 -4
  68. package/dist/validate.d.ts.map +1 -1
  69. package/package.json +2 -2
  70. package/src/codegen/postgres-classifier-page.ts +1 -1
  71. package/src/codegen/registry.ts +7 -0
  72. package/src/config.ts +10 -0
  73. package/src/generated/lexicon-sql.json +35 -0
  74. package/src/index.ts +1 -1
  75. package/src/lint/rules/postgres-templates.ts +2 -2
  76. package/src/lint/rules/sqlpg001.ts +10 -4
  77. package/src/lsp/postgres-template.ts +8 -0
  78. package/src/mcp/postgres.ts +1 -1
  79. package/src/migration-statements.ts +7 -0
  80. package/src/op/activities/postgres-migration.ts +27 -2
  81. package/src/op/activity-contracts.ts +1 -0
  82. package/src/plugin.ts +6 -0
  83. package/src/postgres/access/access.e2e.test.ts +191 -0
  84. package/src/postgres/access/access.test.ts +147 -0
  85. package/src/postgres/access/acl.ts +400 -0
  86. package/src/postgres/access/import.ts +64 -0
  87. package/src/postgres/access/live.ts +123 -0
  88. package/src/postgres/apply/apply.ts +135 -6
  89. package/src/postgres/apply/statements.ts +104 -16
  90. package/src/postgres/entities.ts +613 -14
  91. package/src/postgres/entity-types.ts +7 -0
  92. package/src/postgres/import/generator.ts +18 -5
  93. package/src/postgres/import/live-export.ts +16 -2
  94. package/src/postgres/live/bind.ts +3 -0
  95. package/src/postgres/live/catalog.ts +155 -5
  96. package/src/postgres/live/describe-resources.ts +43 -9
  97. package/src/postgres/live/routines.e2e.test.ts +173 -0
  98. package/src/postgres/migrate/batches.e2e.test.ts +2 -1
  99. package/src/postgres/migrate/migrate.e2e.test.ts +2 -1
  100. package/src/postgres/migrate/migrate.test.ts +24 -1
  101. package/src/postgres/migrate/op.ts +80 -35
  102. package/src/postgres/migrate/outer.e2e.test.ts +163 -0
  103. package/src/postgres/parser.ts +554 -9
  104. package/src/postgres/plan/commands.ts +61 -8
  105. package/src/postgres/plan/deep.ts +79 -18
  106. package/src/postgres/plan/diff.ts +114 -9
  107. package/src/postgres/plan/normalize.ts +272 -5
  108. package/src/postgres/plan/rules.ts +80 -0
  109. package/src/postgres/plan/schema.ts +27 -6
  110. package/src/postgres/plan/server-normalize.ts +53 -1
  111. package/src/postgres/routines.test.ts +150 -0
  112. package/src/postgres.ts +22 -0
  113. package/src/skill-defs.ts +1 -1
  114. package/src/skills/chant-sql-postgres-migration.md +3 -1
  115. package/src/skills/chant-sql-postgres.md +25 -2
  116. package/src/validate.ts +7 -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,19 @@ import type {
52
60
  IndexProps,
53
61
  KeyDef,
54
62
  SchemaProps,
63
+ DefaultPrivilegesProps,
64
+ GrantProps,
65
+ PolicyProps,
66
+ RoleProps,
67
+ RoutineProps,
55
68
  SequenceProps,
56
69
  TableProps,
70
+ TriggerProps,
57
71
  ViewProps,
58
72
  } from "../entities";
73
+ import { stringValue } from "../entities";
74
+ import { parseStatements } from "../parser";
75
+ import type { RoutineNode } from "../parser";
59
76
 
60
77
  // ── Types ──────────────────────────────────────────────────────────────
61
78
 
@@ -178,7 +195,7 @@ function castEnd(sig: readonly Token[], i: number): { end: number; type: string
178
195
  return { end: j, type: words.join("").toLowerCase() };
179
196
  }
180
197
 
181
- const isLiteral = (t: Token | undefined): boolean => t !== undefined && (t.kind === "string" || t.kind === "number");
198
+ const isLiteral = (t: Token | undefined): boolean => t !== undefined && (t.kind === "string" || t.kind === "number" || (t.kind === "ident" && t.text.toUpperCase() === "NULL"));
182
199
 
183
200
  /** One token's canonical text. */
184
201
  function tokenText(t: Token): string {
@@ -308,12 +325,40 @@ export interface CanonicalConstraint {
308
325
  comment?: string;
309
326
  }
310
327
 
311
- export type CanonicalKind = "schema" | "table" | "index" | "view" | "materializedView" | "sequence" | "enum" | "domain" | "extension";
328
+ export type CanonicalKind =
329
+ | "schema"
330
+ | "table"
331
+ | "index"
332
+ | "view"
333
+ | "materializedView"
334
+ | "sequence"
335
+ | "enum"
336
+ | "domain"
337
+ | "extension"
338
+ | "function"
339
+ | "procedure"
340
+ | "trigger"
341
+ | "policy"
342
+ | "role"
343
+ | "grant"
344
+ | "defaultPrivileges";
345
+
346
+ /** The kinds whose declarations are privileges, compared as the access they add up to (`../access/`), not one by one. */
347
+ export const ACCESS_KINDS: ReadonlySet<CanonicalKind> = new Set(["grant", "defaultPrivileges"]);
348
+
349
+ /** The kinds a profile manages only when it says so (`sql.profiles.<env>.access`). */
350
+ export const ACCESS_MANAGED_KINDS: ReadonlySet<CanonicalKind> = new Set(["policy", "role", "grant", "defaultPrivileges"]);
312
351
 
313
352
  export interface CanonicalPgObject {
314
353
  kind: CanonicalKind;
315
354
  schema?: string;
316
355
  name: string;
356
+ /**
357
+ * What tells the object from another of the same name: a routine's input
358
+ * parameter types, `(integer,text)`; a trigger's table, ` ON app.users`.
359
+ * Written as SQL names the object after its name.
360
+ */
361
+ signature?: string;
317
362
  /** Everything else that is compared, by field, each field canonical. */
318
363
  fields: Record<string, unknown>;
319
364
  columns: CanonicalColumn[];
@@ -330,6 +375,13 @@ const kindOf: Record<string, CanonicalKind> = {
330
375
  "Postgres::Enum": "enum",
331
376
  "Postgres::Domain": "domain",
332
377
  "Postgres::Extension": "extension",
378
+ "Postgres::Function": "function",
379
+ "Postgres::Procedure": "procedure",
380
+ "Postgres::Trigger": "trigger",
381
+ "Postgres::Policy": "policy",
382
+ "Postgres::Role": "role",
383
+ "Postgres::Grant": "grant",
384
+ "Postgres::DefaultPrivileges": "defaultPrivileges",
333
385
  };
334
386
 
335
387
  const comment = (c: string | undefined): string | undefined => {
@@ -390,6 +442,159 @@ const keyBody = (kind: string, k: KeyDef) =>
390
442
  .filter(Boolean)
391
443
  .join(" ");
392
444
 
445
+ /** The pseudo-types (`pg_type.typtype = 'p'`), in `pg_catalog`: a routine's parameter or result, never a column's. */
446
+ const PSEUDO_TYPES = new Set([
447
+ "any", "anyarray", "anycompatible", "anycompatiblearray", "anycompatiblemultirange", "anycompatiblenonarray", "anycompatiblerange",
448
+ "anyelement", "anyenum", "anymultirange", "anynonarray", "anyrange", "cstring", "event_trigger", "fdw_handler", "index_am_handler",
449
+ "internal", "language_handler", "pg_ddl_command", "record", "table_am_handler", "trigger", "tsm_handler", "unknown", "void",
450
+ ]);
451
+
452
+ /** A parameter's or result's type as the catalog keeps it: canonical, its modifiers dropped (`varchar(20)` is `character varying`). */
453
+ export function canonicalArgType(text: string, defaultSchema: string): string {
454
+ const m = /^\s*(?:pg_catalog\s*\.\s*)?([A-Za-z_]+)\s*((?:\[\s*\]\s*)*)$/.exec(text);
455
+ if (m && PSEUDO_TYPES.has(m[1]!.toLowerCase())) return `${m[1]!.toLowerCase()}${"[]".repeat((m[2]!.match(/\[/g) ?? []).length)}`;
456
+ return canonicalType(text, defaultSchema)!.replace(/\([^()]*\)/g, "");
457
+ }
458
+
459
+ /** A routine's input parameters: those whose mode is `in`, `inout` or `variadic`. */
460
+ const isInput = (a: { mode: string }) => a.mode !== "out";
461
+
462
+ /** A routine's identity among its overloads: its input parameter types, `(integer,text)`. */
463
+ export function routineSignature(p: Pick<RoutineProps, "args">, defaultSchema: string): string {
464
+ return `(${p.args.filter(isInput).map((a) => canonicalArgType(a.type, defaultSchema)).join(",")})`;
465
+ }
466
+
467
+ /** The result a function declares, or the one its OUT parameters give it: one OUT is its type, several are `record`. */
468
+ function routineResult(p: RoutineProps, defaultSchema: string): string | undefined {
469
+ if (p.returnsTable) return `table(${p.returnsTable.map((c) => `${quoteIdent(c.name)} ${canonicalArgType(c.type, defaultSchema)}`).join(",")})`;
470
+ if (p.returns !== undefined) {
471
+ const m = /^setof\s+(.*)$/is.exec(p.returns.trim());
472
+ return m ? `setof ${canonicalArgType(m[1]!, defaultSchema)}` : canonicalArgType(p.returns, defaultSchema);
473
+ }
474
+ const outs = p.args.filter((a) => a.mode === "out" || a.mode === "inout");
475
+ if (outs.length === 1) return canonicalArgType(outs[0]!.type, defaultSchema);
476
+ if (outs.length > 1) return "record";
477
+ return undefined;
478
+ }
479
+
480
+ /** A `SET` value as compared: each item unquoted, a bare word folded, `TO` and `=` alike. */
481
+ function canonicalSetting(value: string): string {
482
+ const items: string[] = [];
483
+ let tokens: Token[];
484
+ try {
485
+ tokens = tokenizeText(value, 0).filter((t) => !isTrivia(t));
486
+ } catch {
487
+ return value.trim();
488
+ }
489
+ let sign = "";
490
+ for (const t of tokens) {
491
+ if (t.kind === "punct" && t.text === ",") continue;
492
+ if (t.kind === "op" && (t.text === "-" || t.text === "+")) {
493
+ sign = t.text === "-" ? "-" : "";
494
+ continue;
495
+ }
496
+ if (t.kind === "string") items.push(stringValue(t.text) ?? t.text);
497
+ else if (t.kind === "qident") items.push(identValue(t));
498
+ else items.push(`${sign}${t.kind === "ident" ? t.text.toLowerCase() : t.text}`);
499
+ sign = "";
500
+ }
501
+ return items.join(", ");
502
+ }
503
+
504
+ /** The canonical fields of a function or procedure. */
505
+ function routineFields(kind: "function" | "procedure", p: RoutineProps, defaultSchema: string, fields: Record<string, unknown>): void {
506
+ const lang = (p.language ?? "sql").toLowerCase();
507
+ fields.args = p.args.map((a) =>
508
+ [a.mode, a.name ? quoteIdent(a.name) : "", canonicalArgType(a.type, defaultSchema), a.default !== undefined ? `default ${canonicalExpr(a.default)}` : ""].filter(Boolean).join(" "),
509
+ );
510
+ fields.language = lang;
511
+ // Line endings aside, the body is what the server keeps and prints back.
512
+ fields.body = p.body.replace(/\r\n/g, "\n");
513
+ fields.link = p.link;
514
+ fields.securityDefiner = p.securityDefiner === true;
515
+ fields.set = p.set && Object.keys(p.set).length > 0
516
+ ? Object.fromEntries(Object.entries(p.set).map(([k, v]) => [k.toLowerCase(), canonicalSetting(v)]).sort(([a], [b]) => (a! < b! ? -1 : 1)))
517
+ : undefined;
518
+ fields.transform = p.transform ? canonicalExpr(p.transform) : undefined;
519
+ if (kind === "procedure") return;
520
+ const result = routineResult(p, defaultSchema);
521
+ const setReturning = result !== undefined && /^(setof |table\()/.test(result);
522
+ fields.returns = result;
523
+ fields.volatility = p.volatility && p.volatility !== "volatile" ? p.volatility : undefined;
524
+ fields.strict = p.strict === true;
525
+ fields.leakproof = p.leakproof === true;
526
+ fields.parallel = p.parallel && p.parallel !== "unsafe" ? p.parallel : undefined;
527
+ const defaultCost = lang === "c" || lang === "internal" ? 1 : 100;
528
+ fields.cost = p.cost !== undefined && Number(p.cost) !== defaultCost ? String(Number(p.cost)) : undefined;
529
+ const defaultRows = setReturning ? 1000 : 0;
530
+ fields.rows = p.rows !== undefined && Number(p.rows) !== defaultRows ? String(Number(p.rows)) : undefined;
531
+ fields.support = p.support ? canonicalName(p.support, "pg_catalog").replace(/^pg_catalog\./, "") : undefined;
532
+ fields.window = p.window === true;
533
+ }
534
+
535
+ /** A routine named with its parameters as a GRANT writes it (`app.f(int, text)`), as the target the catalog names: `app.f(integer,text)`. */
536
+ export function canonicalRoutineTarget(text: string, defaultSchema: string): string {
537
+ const open = text.indexOf("(");
538
+ const name = canonicalName(open < 0 ? text : text.slice(0, open), defaultSchema);
539
+ if (open < 0) return name;
540
+ try {
541
+ // The parameter list read the way a CREATE FUNCTION reads it: modes, names and types.
542
+ const tokens = tokenizeText(`CREATE FUNCTION x${text.slice(open)} RETURNS void LANGUAGE sql AS ''`, 0);
543
+ const [node] = parseStatements(tokens);
544
+ const args = (node as RoutineNode).args.map((a) => ({ mode: (a.mode?.toLowerCase() ?? "in") as RoutineProps["args"][number]["mode"], type: tokens.slice(a.type.from, a.type.to).map((t) => t.text).join("") }));
545
+ return `${name}${routineSignature({ args }, defaultSchema)}`;
546
+ } catch {
547
+ return `${name}${text.slice(open).replace(/\s+/g, "")}`;
548
+ }
549
+ }
550
+
551
+ /** A role's attributes that differ from `CREATE ROLE`'s defaults (NOLOGIN, INHERIT and every other NO...), sorted. */
552
+ const ROLE_DEFAULTS: Readonly<Record<string, boolean>> = { superuser: false, createdb: false, createrole: false, inherit: true, login: false, replication: false, bypassrls: false };
553
+
554
+ export function canonicalRoleAttributes(options: readonly string[]): string[] {
555
+ const set: Record<string, boolean> = { ...ROLE_DEFAULTS };
556
+ for (const o of options) {
557
+ const word = o.toLowerCase();
558
+ if (word.startsWith("no") && word.slice(2) in ROLE_DEFAULTS) set[word.slice(2)] = false;
559
+ else if (word in ROLE_DEFAULTS) set[word] = true;
560
+ }
561
+ return Object.entries(set)
562
+ .filter(([k, v]) => ROLE_DEFAULTS[k] !== v)
563
+ .map(([k, v]) => (v ? k : `no${k}`))
564
+ .sort();
565
+ }
566
+
567
+ /** The order the catalog prints a trigger's events in. */
568
+ const EVENT_ORDER = ["insert", "delete", "update", "truncate"];
569
+
570
+ /** A trigger argument as the catalog stores it: a string. */
571
+ function triggerArg(a: string): string {
572
+ const t = a.trim();
573
+ if (/^'/.test(t) || /^[Ee]'/.test(t) || /^\$/.test(t)) return stringValue(t) ?? t;
574
+ if (/^"/.test(t)) return identValue(t);
575
+ if (/^-?[0-9.]/.test(t)) return t.replace(/\s+/g, "");
576
+ return t.toLowerCase();
577
+ }
578
+
579
+ function triggerFields(p: TriggerProps, defaultSchema: string, fields: Record<string, unknown>): void {
580
+ fields.table = canonicalName(p.tableName, defaultSchema);
581
+ fields.constraint = p.constraint === true;
582
+ fields.timing = p.timing;
583
+ fields.events = [...p.events]
584
+ .sort((a, b) => EVENT_ORDER.indexOf(a.event) - EVENT_ORDER.indexOf(b.event))
585
+ .map((e) => (e.columns && e.columns.length > 0 ? `${e.event} of ${[...e.columns].sort().map(quoteIdent).join(",")}` : e.event));
586
+ fields.forEach = p.forEach;
587
+ fields.when = canonicalExpr(p.when);
588
+ fields.function = canonicalName(p.functionName, defaultSchema);
589
+ fields.args = p.args.length > 0 ? p.args.map(triggerArg) : undefined;
590
+ fields.from = p.from ? canonicalName(p.from, defaultSchema) : undefined;
591
+ fields.deferrable = p.deferrable === true;
592
+ fields.initiallyDeferred = p.initiallyDeferred === true;
593
+ fields.referencing = p.referencing && (p.referencing.old || p.referencing.new)
594
+ ? [p.referencing.old ? `old table as ${quoteIdent(p.referencing.old)}` : "", p.referencing.new ? `new table as ${quoteIdent(p.referencing.new)}` : ""].filter(Boolean).join(" ")
595
+ : undefined;
596
+ }
597
+
393
598
  /**
394
599
  * A definition in canonical form. `props` is an entity's props, declared or
395
600
  * parsed from what the server printed; `defaultSchema` qualifies a bare name.
@@ -397,8 +602,8 @@ const keyBody = (kind: string, k: KeyDef) =>
397
602
  export function canonicalPgObject(entityType: string, props: Record<string, unknown>, defaultSchema = "public"): CanonicalPgObject {
398
603
  const kind = kindOf[entityType];
399
604
  if (!kind) throw new Error(`not a Postgres entity type: ${entityType}`);
400
- 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) };
605
+ const schemaOf = (p: { schema?: string }) => (kind === "schema" || kind === "extension" || kind === "role" || ACCESS_KINDS.has(kind) ? undefined : (p.schema ?? defaultSchema));
606
+ const base: Pick<CanonicalPgObject, "kind" | "schema" | "name" | "signature"> = { kind, schema: schemaOf(props as { schema?: string }), name: String(props.name) };
402
607
  const fields: Record<string, unknown> = { comment: comment(props.comment as string | undefined) };
403
608
  const columns: CanonicalColumn[] = [];
404
609
  const constraints: CanonicalConstraint[] = [];
@@ -418,6 +623,66 @@ export function canonicalPgObject(entityType: string, props: Record<string, unkn
418
623
  case "enum":
419
624
  fields.labels = (props as unknown as EnumProps).labels;
420
625
  break;
626
+ case "function":
627
+ case "procedure": {
628
+ const p = props as unknown as RoutineProps;
629
+ base.signature = routineSignature(p, defaultSchema);
630
+ routineFields(kind, p, defaultSchema, fields);
631
+ break;
632
+ }
633
+ case "trigger": {
634
+ const p = props as unknown as TriggerProps;
635
+ triggerFields(p, defaultSchema, fields);
636
+ base.signature = ` ON ${String(fields.table)}`;
637
+ break;
638
+ }
639
+ case "policy": {
640
+ const p = props as unknown as PolicyProps;
641
+ fields.table = canonicalName(p.tableName, defaultSchema);
642
+ base.signature = ` ON ${String(fields.table)}`;
643
+ fields.restrictive = p.permissive === false;
644
+ fields.command = p.command && p.command !== "all" ? p.command : undefined;
645
+ const roles = [...new Set(p.roles.map((r) => r.toLowerCase() === "public" ? "public" : quoteIdent(r)))].sort();
646
+ fields.roles = roles.length === 1 && roles[0] === "public" ? undefined : roles;
647
+ fields.using = canonicalExpr(p.using);
648
+ fields.check = canonicalExpr(p.check);
649
+ break;
650
+ }
651
+ case "role": {
652
+ const p = props as unknown as RoleProps;
653
+ const attributes = canonicalRoleAttributes(p.options);
654
+ fields.attributes = attributes.length > 0 ? attributes : undefined;
655
+ fields.connectionLimit = p.connectionLimit !== undefined && Number(p.connectionLimit) !== -1 ? String(Number(p.connectionLimit)) : undefined;
656
+ break;
657
+ }
658
+ case "grant": {
659
+ const p = props as unknown as GrantProps;
660
+ const routine = p.on === "function" || p.on === "procedure" || p.on === "routine";
661
+ fields.grant = {
662
+ action: p.action,
663
+ on: p.on,
664
+ objects: p.objectNames.map((n) => (routine ? canonicalRoutineTarget(n, defaultSchema) : p.on === "schema" ? canonicalName(n) : canonicalName(n, defaultSchema))),
665
+ privileges: p.privileges.map((x) => ({ privilege: x.privilege.toLowerCase(), ...(x.columns ? { columns: x.columns } : {}) })),
666
+ grantees: p.grantees,
667
+ ...(p.withGrantOption ? { withGrantOption: true } : {}),
668
+ ...(p.grantOptionFor ? { grantOptionFor: true } : {}),
669
+ };
670
+ break;
671
+ }
672
+ case "defaultPrivileges": {
673
+ const p = props as unknown as DefaultPrivilegesProps;
674
+ fields.grant = {
675
+ action: p.action,
676
+ on: p.on,
677
+ privileges: p.privileges.map((x) => ({ privilege: x.toLowerCase() })),
678
+ grantees: p.grantees,
679
+ ...(p.forRoles ? { forRoles: p.forRoles } : {}),
680
+ ...(p.inSchemas ? { inSchemas: p.inSchemas } : {}),
681
+ ...(p.withGrantOption ? { withGrantOption: true } : {}),
682
+ ...(p.grantOptionFor ? { grantOptionFor: true } : {}),
683
+ };
684
+ break;
685
+ }
421
686
  case "domain": {
422
687
  const p = props as unknown as DomainProps;
423
688
  fields.dataType = canonicalType(p.dataType, defaultSchema);
@@ -527,6 +792,8 @@ export function canonicalPgObject(entityType: string, props: Record<string, unkn
527
792
  fields.using = p.using && p.using.toLowerCase() !== "heap" ? p.using.toLowerCase() : undefined;
528
793
  fields.with = canonicalOptions(p.with);
529
794
  fields.tablespace = p.tablespace;
795
+ fields.rowSecurity = p.rowSecurity === true;
796
+ fields.forceRowSecurity = p.forceRowSecurity === true;
530
797
  break;
531
798
  }
532
799
  }
@@ -270,6 +270,86 @@ 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`),
309
+ SQLPG290: rule(
310
+ "SQLPG290",
311
+ "metadata",
312
+ "Create a policy on an existing table",
313
+ "CREATE POLICY takes ACCESS EXCLUSIVE on its table, briefly; no row is read. Where row-level security is on, the rows each role sees change when the transaction commits.",
314
+ `${DOCS}/sql-createpolicy.html`,
315
+ ),
316
+ SQLPG291: rule(
317
+ "SQLPG291",
318
+ "metadata",
319
+ "Change a policy",
320
+ "ALTER POLICY changes its roles, USING and WITH CHECK under ACCESS EXCLUSIVE on the table, briefly. Its command, AS RESTRICTIVE and table cannot be altered, nor an expression taken away: the policy is dropped and created in one transaction, so no session sees the table without it.",
321
+ `${DOCS}/sql-alterpolicy.html`,
322
+ ),
323
+ SQLPG292: rule("SQLPG292", "drop", "Drop a policy", "DROP POLICY takes ACCESS EXCLUSIVE on its table, briefly; the rows it let a role see are hidden from that role at once, or, if it was the last policy, every non-owner sees none.", `${DOCS}/sql-droppolicy.html`),
324
+ SQLPG293: rule(
325
+ "SQLPG293",
326
+ "metadata",
327
+ "Turn row-level security on or off, or force it on the owner",
328
+ "ALTER TABLE ... ENABLE, DISABLE, FORCE or NO FORCE ROW LEVEL SECURITY changes only the catalog, under ACCESS EXCLUSIVE, briefly. With it on and no policy that lets a role in, that role sees no rows; FORCE subjects the table's owner too.",
329
+ `${DOCS}/ddl-rowsecurity.html`,
330
+ ),
331
+ SQLPG294: rule("SQLPG294", "metadata", "Change a role's attributes", "ALTER ROLE changes the role in the cluster's catalog, for every database on the server, at once; sessions already open keep the attributes they started with until they reconnect.", `${DOCS}/sql-alterrole.html`),
332
+ SQLPG296: rule(
333
+ "SQLPG296",
334
+ "metadata",
335
+ "Grant a privilege",
336
+ "GRANT adds to the object's privileges in the catalog; no row is read and the role can use it from its next statement.",
337
+ `${DOCS}/sql-grant.html`,
338
+ ),
339
+ SQLPG297: rule(
340
+ "SQLPG297",
341
+ "metadata",
342
+ "Revoke a privilege",
343
+ "REVOKE removes from the object's privileges in the catalog. The role loses it at once: its next statement that needs it fails, so move every reader off it first.",
344
+ `${DOCS}/sql-revoke.html`,
345
+ ),
346
+ SQLPG298: rule(
347
+ "SQLPG298",
348
+ "metadata",
349
+ "Change default privileges",
350
+ "ALTER DEFAULT PRIVILEGES changes what objects created afterwards are given; objects that exist keep their privileges.",
351
+ `${DOCS}/sql-alterdefaultprivileges.html`,
352
+ ),
273
353
  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
354
  } as const satisfies Record<string, PgClassifierRule>;
275
355
 
@@ -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,24 @@ 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
+ * a trigger's or a policy's name is unique on its table, and a role's in the
104
+ * cluster.
105
+ */
106
+ export function pgNamespace(kind: string): string {
107
+ if (kind === "schema" || kind === "extension" || kind === "trigger" || kind === "policy" || kind === "role") return kind;
108
+ if (kind === "grant" || kind === "defaultPrivileges") return "grant";
109
+ if (kind === "enum" || kind === "domain") return "type";
110
+ if (kind === "function" || kind === "procedure") return "routine";
111
+ return "relation";
112
+ }
113
+
114
+ /** The identity an object has on a server: its kind's namespace, its qualified name, and its signature where it has one. */
115
+ export function qualifiedKey(o: { kind: string; schema?: string; name: string; signature?: string }): string {
116
+ return `${pgNamespace(o.kind)} ${o.schema ? `${o.schema}.` : ""}${o.name}${o.signature ?? ""}`;
101
117
  }
102
118
 
103
119
  /** The declared objects re-keyed by qualified name, the only identity a server has. */
@@ -107,8 +123,13 @@ export function keyedByQualifiedName(objects: readonly PgSchemaObject[]): PgSche
107
123
 
108
124
  /** What a server holds, in the diff's form, keyed by qualified name. */
109
125
  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 } : {}) };
126
+ // A routine with a SQL-standard body is not one a declaration can hold (`../parser.ts`): never chant's, never compared.
127
+ return live.filter((o) => !o.unsupported).map((o) => {
128
+ const canonical = {
129
+ ...diffObject(o.type, o.statement, defaultSchema),
130
+ ...(o.foreign ? { foreign: o.foreign } : {}),
131
+ ...(o.dependents && o.dependents.length > 0 ? { dependents: o.dependents } : {}),
132
+ };
112
133
  return { key: qualifiedKey(canonical), canonical };
113
134
  });
114
135
  }
@@ -8,7 +8,10 @@
8
8
  * (`CREATE TEMP VIEW`, or a `CREATE TEMP TABLE` holding the columns' types,
9
9
  * defaults and generated expressions and the checks) inside a transaction
10
10
  * that is always rolled back, with a short `lock_timeout`, and read back with
11
- * the same printers the catalog read uses. Nothing is left behind: Postgres
11
+ * the same printers the catalog read uses. A trigger's `WHEN` and a policy's
12
+ * `USING` and `WITH CHECK`, which the server prints with the casts it
13
+ * resolved, are read back from a trigger or policy on a temporary copy of the
14
+ * table (`CREATE TEMP TABLE ... (LIKE table)`). Nothing is left behind: Postgres
12
15
  * DDL is transactional and a temporary object lives in the session's own
13
16
  * schema. A server that refuses (no TEMP privilege, a lock not granted in
14
17
  * time, an expression it rejects) leaves the rules' answer standing.
@@ -88,6 +91,47 @@ async function tableExpressions(client: PostgresClient, o: CanonicalPgObject, de
88
91
  });
89
92
  }
90
93
 
94
+ /** A trigger's WHEN condition as the server prints it, canonical. */
95
+ async function triggerWhen(client: PostgresClient, o: CanonicalPgObject, defaultSchema: string): Promise<string | undefined> {
96
+ const when = o.fields.when;
97
+ // An INSTEAD OF trigger, on a view, takes no WHEN.
98
+ if (typeof when !== "string" || o.fields.timing === "instead of") return undefined;
99
+ const events = ((o.fields.events as string[] | undefined) ?? []).map((e) => e.replace(/^(\w+)( of )?/, (_m, ev: string, of?: string) => `${ev.toUpperCase()}${of ? " OF " : ""}`));
100
+ return rolledBack(client, defaultSchema, async () => {
101
+ await client.query(`CREATE TEMP TABLE chant_normalize (LIKE ${String(o.fields.table)})`);
102
+ await client.query(`CREATE FUNCTION pg_temp.chant_normalize() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NULL; END'`);
103
+ await client.query(
104
+ `CREATE TRIGGER chant_normalize ${String(o.fields.timing).toUpperCase()} ${events.join(" OR ")} ON pg_temp.chant_normalize ` +
105
+ `FOR EACH ${o.fields.forEach === "row" ? "ROW" : "STATEMENT"} WHEN (${when}) EXECUTE FUNCTION pg_temp.chant_normalize()`,
106
+ );
107
+ await emptyPath(client);
108
+ 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`);
109
+ const m = / WHEN \((.*)\) EXECUTE (?:FUNCTION|PROCEDURE) /s.exec(String(row?.def ?? ""));
110
+ return m ? canonicalExpr(m[1]!) : undefined;
111
+ });
112
+ }
113
+
114
+ /** A policy's USING and WITH CHECK as the server prints them, canonical, from a policy on a temporary copy of its table. */
115
+ async function policyExpressions(client: PostgresClient, o: CanonicalPgObject, defaultSchema: string): Promise<{ using?: string; check?: string } | undefined> {
116
+ const using = o.fields.using as string | undefined;
117
+ const check = o.fields.check as string | undefined;
118
+ if (using === undefined && check === undefined) return undefined;
119
+ return rolledBack(client, defaultSchema, async () => {
120
+ await client.query(`CREATE TEMP TABLE chant_normalize (LIKE ${String(o.fields.table)})`);
121
+ await client.query(
122
+ [`CREATE POLICY chant_normalize ON pg_temp.chant_normalize FOR ${String(o.fields.command ?? "all").toUpperCase()}`, using !== undefined ? `USING (${using})` : "", check !== undefined ? `WITH CHECK (${check})` : ""]
123
+ .filter(Boolean)
124
+ .join(" "),
125
+ );
126
+ await emptyPath(client);
127
+ const [row] = await client.query<Row>(
128
+ `SELECT pg_catalog.pg_get_expr(p.polqual, p.polrelid, true) AS qual, pg_catalog.pg_get_expr(p.polwithcheck, p.polrelid, true) AS withcheck
129
+ FROM pg_catalog.pg_policy p WHERE p.polrelid = 'pg_temp.chant_normalize'::pg_catalog.regclass`,
130
+ );
131
+ return { ...(row?.qual ? { using: canonicalExpr(String(row.qual)) } : {}), ...(row?.withcheck ? { check: canonicalExpr(String(row.withcheck)) } : {}) };
132
+ });
133
+ }
134
+
91
135
  /**
92
136
  * The declared object with its expressions in the server's own printing,
93
137
  * when the server answers; the object unchanged otherwise. Only the
@@ -99,6 +143,14 @@ export async function serverNormalized<T extends CanonicalPgObject & { outputs?:
99
143
  // A declared column list names the outputs; otherwise the server's names do (`SELECT *` expanded).
100
144
  return q === undefined ? o : { ...o, fields: { ...o.fields, query: q.query }, ...(o.fields.columns === undefined ? { outputs: q.outputs } : {}) };
101
145
  }
146
+ if (o.kind === "policy") {
147
+ const e = await policyExpressions(client, o, defaultSchema);
148
+ return e === undefined ? o : { ...o, fields: { ...o.fields, ...e } };
149
+ }
150
+ if (o.kind === "trigger") {
151
+ const when = await triggerWhen(client, o, defaultSchema);
152
+ return when === undefined ? o : { ...o, fields: { ...o.fields, when } };
153
+ }
102
154
  if (o.kind === "table") {
103
155
  const e = await tableExpressions(client, o, defaultSchema);
104
156
  if (!e) return o;