@intentius/chant-lexicon-sql 0.124.48 → 0.124.50

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 (138) hide show
  1. package/dist/clickhouse/plan/statements.d.ts +29 -0
  2. package/dist/clickhouse/plan/statements.d.ts.map +1 -0
  3. package/dist/clickhouse/plan/statements.js +334 -0
  4. package/dist/codegen/classifier-page.d.ts.map +1 -1
  5. package/dist/codegen/classifier-page.js +23 -0
  6. package/dist/codegen/postgres-classifier-page.js +2 -2
  7. package/dist/codegen/registry.d.ts.map +1 -1
  8. package/dist/codegen/registry.js +5 -0
  9. package/dist/core/statement-class.d.ts +55 -0
  10. package/dist/core/statement-class.d.ts.map +1 -0
  11. package/dist/core/statement-class.js +43 -0
  12. package/dist/coverage/postgres.json +6 -6
  13. package/dist/drizzle.d.ts.map +1 -1
  14. package/dist/drizzle.js +5 -0
  15. package/dist/files/postgres.d.ts.map +1 -1
  16. package/dist/files/postgres.js +23 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/integrity.json +5 -5
  21. package/dist/lint/post-synth/postgres-names.d.ts.map +1 -1
  22. package/dist/lint/post-synth/postgres-names.js +3 -0
  23. package/dist/lint/rules/postgres-templates.js +2 -2
  24. package/dist/lsp/postgres-template.d.ts.map +1 -1
  25. package/dist/lsp/postgres-template.js +10 -0
  26. package/dist/manifest.json +1 -1
  27. package/dist/mcp/postgres.js +1 -1
  28. package/dist/meta.json +25 -0
  29. package/dist/migration-classify.d.ts +23 -0
  30. package/dist/migration-classify.d.ts.map +1 -0
  31. package/dist/migration-classify.js +44 -0
  32. package/dist/okf/index.md +5 -0
  33. package/dist/okf/types/PostgresForeignTable.md +9 -0
  34. package/dist/okf/types/PostgresOperatorClass.md +9 -0
  35. package/dist/okf/types/PostgresOperatorFamily.md +9 -0
  36. package/dist/okf/types/PostgresTextSearchConfiguration.md +9 -0
  37. package/dist/okf/types/PostgresTextSearchDictionary.md +9 -0
  38. package/dist/plugin.d.ts.map +1 -1
  39. package/dist/plugin.js +13 -0
  40. package/dist/postgres/access/acl.js +1 -1
  41. package/dist/postgres/access/import.js +1 -1
  42. package/dist/postgres/apply/apply.js +5 -0
  43. package/dist/postgres/apply/statements.d.ts.map +1 -1
  44. package/dist/postgres/apply/statements.js +156 -2
  45. package/dist/postgres/entities.d.ts +120 -2
  46. package/dist/postgres/entities.d.ts.map +1 -1
  47. package/dist/postgres/entities.js +180 -5
  48. package/dist/postgres/entity-types.d.ts +5 -0
  49. package/dist/postgres/entity-types.d.ts.map +1 -1
  50. package/dist/postgres/entity-types.js +5 -0
  51. package/dist/postgres/import/generator.d.ts.map +1 -1
  52. package/dist/postgres/import/generator.js +16 -1
  53. package/dist/postgres/live/catalog-kinds.d.ts +6 -0
  54. package/dist/postgres/live/catalog-kinds.d.ts.map +1 -1
  55. package/dist/postgres/live/catalog-kinds.js +251 -3
  56. package/dist/postgres/live/describe-resources.d.ts.map +1 -1
  57. package/dist/postgres/live/describe-resources.js +2 -0
  58. package/dist/postgres/migrate/observe.d.ts.map +1 -1
  59. package/dist/postgres/migrate/observe.js +4 -1
  60. package/dist/postgres/migrate/replication.d.ts +42 -0
  61. package/dist/postgres/migrate/replication.d.ts.map +1 -1
  62. package/dist/postgres/migrate/replication.js +53 -0
  63. package/dist/postgres/parser.d.ts +73 -1
  64. package/dist/postgres/parser.d.ts.map +1 -1
  65. package/dist/postgres/parser.js +181 -7
  66. package/dist/postgres/plan/deep.d.ts.map +1 -1
  67. package/dist/postgres/plan/deep.js +15 -4
  68. package/dist/postgres/plan/diff.d.ts.map +1 -1
  69. package/dist/postgres/plan/diff.js +120 -5
  70. package/dist/postgres/plan/normalize.d.ts +12 -1
  71. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  72. package/dist/postgres/plan/normalize.js +151 -0
  73. package/dist/postgres/plan/rules.d.ts +3 -0
  74. package/dist/postgres/plan/rules.d.ts.map +1 -1
  75. package/dist/postgres/plan/rules.js +5 -2
  76. package/dist/postgres/plan/schema.js +1 -1
  77. package/dist/postgres/plan/statements.d.ts +26 -0
  78. package/dist/postgres/plan/statements.d.ts.map +1 -0
  79. package/dist/postgres/plan/statements.js +364 -0
  80. package/dist/postgres/testing/roundtrip-schema.d.ts +3 -2
  81. package/dist/postgres/testing/roundtrip-schema.d.ts.map +1 -1
  82. package/dist/postgres/testing/roundtrip-schema.js +12 -1
  83. package/dist/postgres.d.ts +1 -1
  84. package/dist/postgres.d.ts.map +1 -1
  85. package/dist/postgres.js +1 -1
  86. package/dist/rules/postgres-names.ts +2 -0
  87. package/dist/rules/postgres-templates.ts +2 -2
  88. package/dist/validate.d.ts.map +1 -1
  89. package/dist/validate.js +5 -0
  90. package/package.json +2 -2
  91. package/src/clickhouse/plan/statements.test.ts +61 -0
  92. package/src/clickhouse/plan/statements.ts +306 -0
  93. package/src/codegen/classifier-page.ts +23 -0
  94. package/src/codegen/postgres-classifier-page.ts +2 -2
  95. package/src/codegen/registry.ts +5 -0
  96. package/src/core/statement-class.ts +88 -0
  97. package/src/coverage/postgres.json +6 -6
  98. package/src/drizzle.ts +5 -0
  99. package/src/files/postgres.ts +22 -1
  100. package/src/generated/lexicon-sql.json +25 -0
  101. package/src/index.ts +1 -1
  102. package/src/lint/post-synth/postgres-checks.test.ts +8 -1
  103. package/src/lint/post-synth/postgres-names.ts +2 -0
  104. package/src/lint/rules/postgres-templates.ts +2 -2
  105. package/src/lsp/postgres-template.ts +9 -0
  106. package/src/mcp/postgres.ts +1 -1
  107. package/src/migration-classify.test.ts +150 -0
  108. package/src/migration-classify.ts +45 -0
  109. package/src/op/watch-pipeline.test.ts +8 -1
  110. package/src/plugin.ts +14 -0
  111. package/src/postgres/access/acl.ts +1 -1
  112. package/src/postgres/access/import.ts +1 -1
  113. package/src/postgres/apply/apply.ts +5 -0
  114. package/src/postgres/apply/statements.ts +151 -3
  115. package/src/postgres/database-kinds.test.ts +498 -17
  116. package/src/postgres/entities.ts +276 -5
  117. package/src/postgres/entity-types.ts +5 -0
  118. package/src/postgres/import/generator.ts +13 -1
  119. package/src/postgres/live/catalog-kinds.ts +275 -3
  120. package/src/postgres/live/describe-resources.ts +2 -0
  121. package/src/postgres/migrate/observe.ts +3 -1
  122. package/src/postgres/migrate/replication.ts +66 -0
  123. package/src/postgres/migrate/subscriber.e2e.test.ts +141 -0
  124. package/src/postgres/migrate/subscriber.test.ts +59 -0
  125. package/src/postgres/parser.ts +225 -6
  126. package/src/postgres/plan/deep.ts +15 -4
  127. package/src/postgres/plan/diff.ts +107 -6
  128. package/src/postgres/plan/normalize.ts +155 -0
  129. package/src/postgres/plan/rules.ts +25 -4
  130. package/src/postgres/plan/schema.ts +1 -1
  131. package/src/postgres/plan/statements.test.ts +78 -0
  132. package/src/postgres/plan/statements.ts +299 -0
  133. package/src/postgres/testing/roundtrip-schema.ts +12 -1
  134. package/src/postgres.ts +17 -0
  135. package/src/skills/chant-sql-postgres-migration.md +1 -0
  136. package/src/skills/chant-sql-postgres.md +5 -0
  137. package/src/sql-files.test.ts +33 -0
  138. package/src/validate.ts +5 -0
@@ -4,7 +4,7 @@ import type { PostSynthCheck } from "@intentius/chant/lint/post-synth";
4
4
  import type { SerializerResult } from "@intentius/chant/serializer";
5
5
  import { sqlSerializer } from "../../serializer";
6
6
  import { table as chTable } from "../../clickhouse/entities";
7
- import { domain, extension, func, grant, index, role, schema, sequence, table, type as pgType, view } from "../../postgres/entities";
7
+ import { domain, extension, func, grant, index, operatorClass, role, schema, sequence, table, type as pgType, view } from "../../postgres/entities";
8
8
  import { sqlAuditCatalog } from "../audit-catalog";
9
9
  import { postSynthChecks } from "./index";
10
10
 
@@ -444,6 +444,13 @@ describe("SQLPG125: access methods and operator classes", () => {
444
444
  clean("SQLPG125", { t, i, e: extension`CREATE EXTENSION pg_trgm` });
445
445
  });
446
446
  test("an operator class in an undeclared schema is left alone", () => clean("SQLPG125", { t, i: index`CREATE INDEX t_x ON ${t} (email ext.my_ops)` }));
447
+ test("an operator class the project declares passes for its own method only (#4406)", () => {
448
+ const app = schema`CREATE SCHEMA app`;
449
+ const ops = operatorClass`CREATE OPERATOR CLASS ${app}.shop_ops FOR TYPE text USING btree AS OPERATOR 1 <, OPERATOR 2 <=, OPERATOR 3 =, OPERATOR 4 >=, OPERATOR 5 >, FUNCTION 1 bttextcmp(text, text)`;
450
+ clean("SQLPG125", { app, t, ops, i: index`CREATE INDEX t_x ON ${t} (email shop_ops)` });
451
+ clean("SQLPG125", { app, t, ops, i: index`CREATE INDEX t_x ON ${t} (email app.shop_ops)` });
452
+ flagged("SQLPG125", { app, t, ops, i: index`CREATE INDEX t_x ON ${t} USING hash (email shop_ops)` }, /shop_ops, which Postgres 18 does not have for hash/);
453
+ });
447
454
  });
448
455
 
449
456
  describe("SQLPG126: functions", () => {
@@ -474,6 +474,8 @@ export type OpClassResolution = { ok: true } | { ok: false; miss: Miss; otherMet
474
474
  /** An operator class for `method` at the target major. */
475
475
  export function resolveOpClass(p: PgProject, method: string, opclass: { schema?: string; name: string }): OpClassResolution {
476
476
  if (opclass.schema !== undefined && outsideProject(p, opclass.schema)) return { ok: true };
477
+ // A class the project declares (an `operatorClass` tag), for this method.
478
+ if (p.objects.some((o) => o.type === "Postgres::OperatorClass" && o.name === opclass.name && o.method === method && (opclass.schema === undefined || o.schema === opclass.schema))) return { ok: true };
477
479
  const classes = OPERATOR_CLASSES as Readonly<Record<string, Readonly<Record<string, Presence>>>>;
478
480
  if (presentAt(classes[method]?.[opclass.name], p.major)) return { ok: true };
479
481
  const ext = extensionFor(p, (e) => e.opclasses?.[method]?.includes(opclass.name) ?? false);
@@ -17,12 +17,12 @@ export const POSTGRES_TEMPLATE_SOURCES: readonly TemplateSource<PostgresTag>[] =
17
17
  {
18
18
  dialect: "postgres",
19
19
  modules: ["@intentius/chant-lexicon-sql/postgres"],
20
- tags: ["schema", "table", "index", "view", "sequence", "type", "domain", "extension", "func", "procedure", "aggregate", "trigger", "eventTrigger", "operator", "statistics", "foreignDataWrapper", "foreignServer", "publication", "collation", "cast", "policy", "role", "grant"],
20
+ tags: ["schema", "table", "index", "view", "sequence", "type", "domain", "extension", "func", "procedure", "aggregate", "trigger", "eventTrigger", "operator", "operatorClass", "operatorFamily", "foreignTable", "textSearchConfiguration", "textSearchDictionary", "statistics", "foreignDataWrapper", "foreignServer", "publication", "collation", "cast", "policy", "role", "grant"],
21
21
  },
22
22
  {
23
23
  dialect: "postgres",
24
24
  modules: ["@intentius/chant-lexicon-sql"],
25
- tags: ["schema", "index", "sequence", "type", "domain", "extension", "func", "procedure", "aggregate", "trigger", "eventTrigger", "operator", "statistics", "foreignDataWrapper", "foreignServer", "publication", "collation", "cast", "policy", "role", "grant"],
25
+ tags: ["schema", "index", "sequence", "type", "domain", "extension", "func", "procedure", "aggregate", "trigger", "eventTrigger", "operator", "operatorClass", "operatorFamily", "foreignTable", "textSearchConfiguration", "textSearchDictionary", "statistics", "foreignDataWrapper", "foreignServer", "publication", "collation", "cast", "policy", "role", "grant"],
26
26
  },
27
27
  ];
28
28
 
@@ -156,6 +156,15 @@ export function postgresDeclarations(content: string, fileName = "file.ts"): Pos
156
156
  sqlName = text(node.name.span);
157
157
  kind = "operator";
158
158
  break;
159
+ case "operatorClass":
160
+ case "operatorFamily":
161
+ case "foreignTable":
162
+ case "textSearchConfiguration":
163
+ case "textSearchDictionary":
164
+ sqlName = text(node.name.span);
165
+ kind = { operatorClass: "operator class", operatorFamily: "operator family", foreignTable: "foreign table", textSearchConfiguration: "text search configuration", textSearchDictionary: "text search dictionary" }[node.statement];
166
+ if (node.statement === "foreignTable") columns = node.columns.map((c) => ({ name: c.name || text(c.nameSpan) || "", ...(text(c.type) ? { type: text(c.type)! } : {}) }));
167
+ break;
159
168
  case "statistics":
160
169
  case "foreignDataWrapper":
161
170
  case "foreignServer":
@@ -81,7 +81,7 @@ export function postgresSearch(params: Record<string, unknown>): unknown {
81
81
  };
82
82
  }
83
83
 
84
- const TAGS = { schema: pg.schema, table: pg.table, index: pg.index, view: pg.view, sequence: pg.sequence, type: pg.type, domain: pg.domain, extension: pg.extension, func: pg.func, procedure: pg.procedure, aggregate: pg.aggregate, trigger: pg.trigger, eventTrigger: pg.eventTrigger, operator: pg.operator, statistics: pg.statistics, foreignDataWrapper: pg.foreignDataWrapper, foreignServer: pg.foreignServer, publication: pg.publication, collation: pg.collation, cast: pg.cast, policy: pg.policy, role: pg.role, grant: pg.grant } as const;
84
+ const TAGS = { schema: pg.schema, table: pg.table, index: pg.index, view: pg.view, sequence: pg.sequence, type: pg.type, domain: pg.domain, extension: pg.extension, func: pg.func, procedure: pg.procedure, aggregate: pg.aggregate, trigger: pg.trigger, eventTrigger: pg.eventTrigger, operator: pg.operator, operatorClass: pg.operatorClass, operatorFamily: pg.operatorFamily, foreignTable: pg.foreignTable, textSearchConfiguration: pg.textSearchConfiguration, textSearchDictionary: pg.textSearchDictionary, statistics: pg.statistics, foreignDataWrapper: pg.foreignDataWrapper, foreignServer: pg.foreignServer, publication: pg.publication, collation: pg.collation, cast: pg.cast, policy: pg.policy, role: pg.role, grant: pg.grant } as const;
85
85
  export const POSTGRES_TAGS = Object.keys(TAGS);
86
86
 
87
87
  /** Parse one statement with the Postgres tag; an error comes back as SQLPG001 with its line and column. */
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Classifying the migration files a pull request adds (#4407), for goose,
3
+ * golang-migrate, clickhouse-migrations and Flyway. Each fixture directory is
4
+ * an in-memory tree at the base commit and another at the head, with one
5
+ * file added; the answer is what the plan note lists. Offline: nothing here
6
+ * opens a server.
7
+ */
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { describe, expect, test, vi } from "vitest";
12
+ import { LINK_READERS, type KindLinkRead } from "@intentius/chant/workspace/kind-link-readers";
13
+ import { classifyNewMigrations, newMigrationGroups } from "@intentius/chant/workspace/new-migrations";
14
+ import { renderMigrationLines } from "@intentius/chant/pr-migrations";
15
+ import type { WorkspaceTree } from "@intentius/chant/workspace/tree";
16
+ import { classifyMigrationFiles } from "./migration-classify";
17
+
18
+ function memoryTree(files: Record<string, string>): WorkspaceTree {
19
+ const dirs = new Set([""]);
20
+ for (const f of Object.keys(files)) {
21
+ const parts = f.split("/");
22
+ for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join("/"));
23
+ }
24
+ return {
25
+ label: "",
26
+ stat: (p) => (p in files ? "file" : dirs.has(p) ? "dir" : undefined),
27
+ list(p) {
28
+ if (!dirs.has(p)) return undefined;
29
+ const prefix = p === "" ? "" : `${p}/`;
30
+ const seen = new Map<string, "file" | "dir">();
31
+ for (const f of Object.keys(files)) {
32
+ if (!f.startsWith(prefix)) continue;
33
+ const rest = f.slice(prefix.length);
34
+ seen.set(rest.split("/")[0]!, rest.includes("/") ? "dir" : "file");
35
+ }
36
+ return [...seen].map(([name, type]) => ({ name, type }));
37
+ },
38
+ read(p) {
39
+ if (!(p in files)) throw new Error(`no such file ${p}`);
40
+ return files[p]!;
41
+ },
42
+ };
43
+ }
44
+
45
+ const sql = { name: "sql", classifyMigrations: classifyMigrationFiles };
46
+
47
+ /** The files the pull request adds, classified, as the plan note lists them. */
48
+ async function classify(reader: keyof typeof LINK_READERS, fields: Record<string, string>, base: Record<string, string>, added: Record<string, string>) {
49
+ const head = memoryTree({ ...base, ...added });
50
+ const read = new Map<string, KindLinkRead>([["m", LINK_READERS[reader]({ member: { name: "m" } as never, dir: "db", fields: fields as never, tree: head })]]);
51
+ const groups = newMigrationGroups([{ name: "m", dir: "db" }], read, memoryTree(base));
52
+ return classifyNewMigrations(groups, head, [sql]);
53
+ }
54
+
55
+ const rules = (f: Awaited<ReturnType<typeof classify>>[number]) => f.statements.flatMap((s) => s.changes.map((c) => c.rule));
56
+
57
+ describe("a new file in each migration directory kind appears with classes", () => {
58
+ test("goose: the Up section only", async () => {
59
+ const [f, ...rest] = await classify(
60
+ "goose-dir",
61
+ { dialect: "postgres" },
62
+ { "db/00001_init.sql": "-- +goose Up\nCREATE TABLE orders (id bigint PRIMARY KEY, status text);\n-- +goose Down\nDROP TABLE orders;\n" },
63
+ {
64
+ "db/00002_idx.sql":
65
+ "-- +goose Up\nCREATE INDEX orders_status_idx ON orders (status);\nALTER TABLE orders ADD COLUMN note text;\n-- +goose Down\nDROP INDEX orders_status_idx;\n",
66
+ },
67
+ );
68
+ expect(rest).toEqual([]);
69
+ expect(f).toMatchObject({ member: "m", reader: "goose-dir", file: "db/00002_idx.sql", dialect: "postgres" });
70
+ expect(rules(f!)).toEqual(["SQLPG241", "SQLPG201"]);
71
+ expect(f!.statements[0]!.changes[0]).toMatchObject({ class: "concurrently", lock: "SHARE" });
72
+ });
73
+
74
+ test("golang-migrate: the .up file, not the .down file", async () => {
75
+ const out = await classify(
76
+ "golang-migrate-dir",
77
+ { dialect: "postgres" },
78
+ { "db/000001_init.up.sql": "CREATE TABLE t (id bigint);", "db/000001_init.down.sql": "DROP TABLE t;" },
79
+ { "db/000002_fk.up.sql": "ALTER TABLE t ADD CONSTRAINT fk FOREIGN KEY (id) REFERENCES u (id) NOT VALID;", "db/000002_fk.down.sql": "ALTER TABLE t DROP CONSTRAINT fk;" },
80
+ );
81
+ expect(out.map((f) => f.file)).toEqual(["db/000002_fk.up.sql"]);
82
+ expect(rules(out[0]!)).toEqual(["SQLPG217"]);
83
+ });
84
+
85
+ test("clickhouse-migrations: a sort-key change is a rebuild", async () => {
86
+ const [f] = await classify(
87
+ "clickhouse-migrations-dir",
88
+ {},
89
+ { "db/1_events.sql": "CREATE TABLE events (user_id UInt64, ts DateTime) ENGINE = MergeTree ORDER BY ts;" },
90
+ { "db/2_key.sql": "ALTER TABLE events MODIFY ORDER BY (user_id, ts);" },
91
+ );
92
+ expect(f).toMatchObject({ dialect: "clickhouse" });
93
+ expect(f!.statements[0]).toMatchObject({ outcome: "classified", changes: [{ rule: "SQLCH220", class: "rebuild", label: "REBUILD" }] });
94
+ });
95
+
96
+ test("flyway: placeholders are substituted before the statements are read", async () => {
97
+ const [f] = await classify(
98
+ "flyway-dir",
99
+ { dialect: "postgres" },
100
+ { "db/V1__init.sql": "CREATE TABLE ${schema}.t (id bigint);", "db/flyway.conf": "flyway.locations=filesystem:.\nflyway.placeholders.schema=app\n" },
101
+ { "db/V2__idx.sql": "CREATE INDEX CONCURRENTLY i ON ${schema}.t (id);" },
102
+ );
103
+ expect(f).toMatchObject({ file: "db/V2__idx.sql", type: "versioned" });
104
+ expect(f!.statements[0]!.sql).toBe("CREATE INDEX CONCURRENTLY i ON app.t (id)");
105
+ expect(rules(f!)).toEqual(["SQLPG240"]);
106
+ });
107
+
108
+ test("flyway: a placeholder with no value is reported on the file, not thrown", async () => {
109
+ const [f] = await classify("flyway-dir", { dialect: "postgres" }, { "db/V1__init.sql": "SELECT 1;" }, { "db/V2__x.sql": "CREATE TABLE ${missing}.t (id bigint);" });
110
+ expect(f!.statements).toEqual([]);
111
+ expect(f!.error).toContain("missing");
112
+ });
113
+ });
114
+
115
+ describe("the plan note lines", () => {
116
+ test("a Postgres CREATE INDEX without CONCURRENTLY is listed with its lock", async () => {
117
+ const files = await classify("goose-dir", { dialect: "postgres" }, { "db/1_a.sql": "-- +goose Up\nSELECT 1;\n" }, { "db/2_b.sql": "-- +goose Up\nCREATE INDEX i ON orders (status);\n" });
118
+ const lines = renderMigrationLines(files).join("\n");
119
+ expect(lines).toContain("`db/2_b.sql` (member `m`, postgres)");
120
+ expect(lines).toContain("`CREATE INDEX i ON orders (status)`: needs CONCURRENTLY (SQLPG241, SHARE)");
121
+ });
122
+
123
+ test("a ClickHouse sort-key change is listed as a rebuild", async () => {
124
+ const files = await classify("clickhouse-migrations-dir", {}, { "db/1_a.sql": "SELECT 1;" }, { "db/2_b.sql": "ALTER TABLE events MODIFY ORDER BY (a, b);" });
125
+ expect(renderMigrationLines(files).join("\n")).toContain("REBUILD (SQLCH220)");
126
+ });
127
+
128
+ test("an undecided statement names both rules", async () => {
129
+ const files = await classify("clickhouse-migrations-dir", {}, { "db/1_a.sql": "SELECT 1;" }, { "db/2_b.sql": "ALTER TABLE events MODIFY COLUMN user_id UInt32;" });
130
+ expect(renderMigrationLines(files).join("\n")).toContain("undecided between SQLCH210 background rewrite and SQLCH211 REBUILD");
131
+ });
132
+ });
133
+
134
+ describe("offline", () => {
135
+ test("classifying contacts no server and starts no process", async () => {
136
+ const fetched = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network used"));
137
+ await classify("goose-dir", { dialect: "postgres" }, { "db/1_a.sql": "-- +goose Up\nSELECT 1;\n" }, { "db/2_b.sql": "-- +goose Up\nDROP TABLE t;\n" });
138
+ await classify("clickhouse-migrations-dir", {}, { "db/1_a.sql": "SELECT 1;" }, { "db/2_b.sql": "DROP TABLE t;" });
139
+ expect(fetched).not.toHaveBeenCalled();
140
+ fetched.mockRestore();
141
+ });
142
+
143
+ test("the classifier modules import no live binding, client or process API", () => {
144
+ for (const f of ["migration-classify.ts", "postgres/plan/statements.ts", "clickhouse/plan/statements.ts", "core/statement-class.ts"]) {
145
+ const src = readFileSync(join(import.meta.dirname, f), "utf8");
146
+ const imports = [...src.matchAll(/(?:from|import\()\s*["']([^"']+)["']/g)].map((m) => m[1]!);
147
+ for (const spec of imports) expect(spec, `${f} imports ${spec}`).not.toMatch(/\/live\/|\/apply\/|\/http$|child_process|node:net|node:http|node:https|\/bind$/);
148
+ }
149
+ });
150
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Classify the migration files a pull request adds (#4407), the lexicon's
3
+ * answer to core's `classifyMigrations` hook. The "check" adoption level:
4
+ * each new file's up statements are read and given the class the schema-diff
5
+ * classifier gives the same change, and nothing else happens. No server is
6
+ * contacted, no migration tool runs and nothing is applied, so it needs no
7
+ * environment binding (compare `chant sql replay`, which does).
8
+ *
9
+ * The up statements are the ones replay would run: goose's `-- +goose Up`
10
+ * section, the whole file for golang-migrate and clickhouse-migrations, and a
11
+ * Flyway file after placeholder substitution with the configuration's values.
12
+ */
13
+
14
+ import type { MigrationFileClassification } from "@intentius/chant/pr-migrations";
15
+ import { findFlywayConfig, readFlywayConfig, substitute } from "@intentius/chant/workspace/flyway-layout";
16
+ import type { WorkspaceTree } from "@intentius/chant/workspace/tree";
17
+ import { classifyClickHouseStatements } from "./clickhouse/plan/statements";
18
+ import { gooseUpStatements, splitStatements } from "./migration-replay";
19
+ import { classifyPgStatements } from "./postgres/plan/statements";
20
+
21
+ export const MIGRATION_DIALECTS = ["postgres", "clickhouse"] as const;
22
+
23
+ /** The reader names whose files this lexicon reads. */
24
+ const READERS = new Set(["goose-dir", "golang-migrate-dir", "clickhouse-migrations-dir", "flyway-dir"]);
25
+
26
+ export function classifyMigrationFiles(options: { reader: string; dialect: string; tree: WorkspaceTree; dir: string; files: string[] }): MigrationFileClassification[] {
27
+ const { reader, dialect, tree, dir, files } = options;
28
+ if (!READERS.has(reader) || !(MIGRATION_DIALECTS as readonly string[]).includes(dialect)) return [];
29
+ const classify = dialect === "postgres" ? classifyPgStatements : classifyClickHouseStatements;
30
+ let flyway: ReturnType<typeof readFlywayConfig> | undefined;
31
+ return files.map((file) => {
32
+ try {
33
+ const text = tree.read(file);
34
+ let statements: string[];
35
+ if (reader === "goose-dir") statements = gooseUpStatements(text);
36
+ else if (reader === "flyway-dir") {
37
+ flyway ??= readFlywayConfig(tree, findFlywayConfig(tree, dir), dir);
38
+ statements = splitStatements(substitute(text, file, flyway));
39
+ } else statements = splitStatements(text);
40
+ return { file, statements: classify(statements) };
41
+ } catch (err) {
42
+ return { file, statements: [], error: err instanceof Error ? err.message : String(err) };
43
+ }
44
+ });
45
+ }
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { join } from "node:path";
16
- import { describe, expect, test } from "vitest";
16
+ import { beforeAll, describe, expect, test } from "vitest";
17
17
  import { generateOpsPipeline } from "@intentius/chant/op";
18
18
  import { parseYAML } from "@intentius/chant/yaml";
19
19
  import type { ScheduledOpSpec } from "@intentius/chant/lexicon";
@@ -58,6 +58,13 @@ async function render(forge: "github" | "gitlab" | "forgejo", spec: ScheduledOpS
58
58
  const runLine = (steps: Array<Record<string, unknown>>) => steps.find((s) => s.run === "chant run schema-watch");
59
59
 
60
60
  describe("generateOpsPipeline renders the sql drift watch (#3642)", () => {
61
+ // The first render loads the github, gitlab and forgejo plugins and discovers the example's Ops. On a loaded CI shard
62
+ // that cold load took 5.7s and put the first test over the 5s unit-test budget (chant #2817). A file's beforeAll is
63
+ // not counted against the budget, so the load is paid here and each test below times only its own render.
64
+ beforeAll(async () => {
65
+ await generateOpsPipeline([reader(actionsSecret)], "github", { beforeScript: INSTALL.actions }, EXAMPLE);
66
+ }, 60_000);
67
+
61
68
  test("GitHub: the Op's own cron, a read-only token and the reader's credentials", async () => {
62
69
  const { file, doc } = await render("github", reader(actionsSecret));
63
70
  expect(file.name).toBe("schema-watch.yml");
package/src/plugin.ts CHANGED
@@ -115,6 +115,11 @@ export const sqlPlugin: LexiconPlugin = {
115
115
  { name: "trigger", isTag: true, description: "A Postgres CREATE TRIGGER, parsed into a trigger entity on its table" },
116
116
  { name: "eventTrigger", isTag: true, description: "A Postgres CREATE EVENT TRIGGER, parsed into the database's event trigger entity" },
117
117
  { name: "operator", isTag: true, description: "A Postgres CREATE OPERATOR, parsed into an operator entity identified by its name and operand types" },
118
+ { name: "operatorClass", isTag: true, description: "A Postgres CREATE OPERATOR CLASS, parsed into an operator class entity identified by its name and index method" },
119
+ { name: "operatorFamily", isTag: true, description: "A Postgres CREATE OPERATOR FAMILY, parsed into an operator family entity identified by its name and index method" },
120
+ { name: "foreignTable", isTag: true, description: "A Postgres CREATE FOREIGN TABLE, parsed into a foreign table entity with its columns, server and options" },
121
+ { name: "textSearchConfiguration", isTag: true, description: "A Postgres CREATE TEXT SEARCH CONFIGURATION with its ADD MAPPING statements, parsed into a configuration entity" },
122
+ { name: "textSearchDictionary", isTag: true, description: "A Postgres CREATE TEXT SEARCH DICTIONARY, parsed into a dictionary entity with its template and options" },
118
123
  { name: "statistics", isTag: true, description: "A Postgres CREATE STATISTICS, parsed into an extended statistics entity on its table" },
119
124
  { name: "foreignDataWrapper", isTag: true, description: "A Postgres CREATE FOREIGN DATA WRAPPER, parsed into the database's wrapper entity" },
120
125
  { name: "foreignServer", isTag: true, description: "A Postgres CREATE SERVER, parsed into the database's foreign server entity (user mappings are not declared)" },
@@ -295,6 +300,15 @@ export const sqlPlugin: LexiconPlugin = {
295
300
  return { ...classifyDisruption(options), ...classifyPgDisruption(options) };
296
301
  },
297
302
 
303
+ /**
304
+ * The migration files a pull request adds (#4407): each up statement given
305
+ * the class the schema-diff classifier gives the same change. Offline.
306
+ */
307
+ async classifyMigrations(options) {
308
+ const { classifyMigrationFiles } = await import("./migration-classify");
309
+ return classifyMigrationFiles(options);
310
+ },
311
+
298
312
  /** `chant sql diff` and `chant sql plan`: schema changes, classified. */
299
313
  commands() {
300
314
  return sqlCommands;
@@ -188,7 +188,7 @@ export function declaredAccess(objects: ReadonlyArray<{ key: string; canonical:
188
188
  for (const { canonical: o } of objects) {
189
189
  const name = `${o.schema ? `${quoteIdent(o.schema)}.` : ""}${quoteIdent(o.name)}`;
190
190
  if (o.kind === "schema") declaredTargets.push({ kind: "schema", name: quoteIdent(o.name) });
191
- else if (o.kind === "table" || o.kind === "view" || o.kind === "materializedView") {
191
+ else if (o.kind === "table" || o.kind === "foreignTable" || o.kind === "view" || o.kind === "materializedView") {
192
192
  relationKind.set(name, "table");
193
193
  declaredTargets.push({ kind: "table", name });
194
194
  } else if (o.kind === "sequence") {
@@ -21,7 +21,7 @@ function targetsOf(objects: readonly LivePgObject[]): AclTarget[] {
21
21
  for (const o of objects) {
22
22
  const name = `${o.schema ? `${quoteIdent(o.schema)}.` : ""}${quoteIdent(o.name)}`;
23
23
  if (o.type === POSTGRES_ENTITY_TYPES.schema) out.push({ kind: "schema", name: quoteIdent(o.name) });
24
- else if (o.type === POSTGRES_ENTITY_TYPES.table || o.type === POSTGRES_ENTITY_TYPES.view || o.type === POSTGRES_ENTITY_TYPES.materializedView) out.push({ kind: "table", name });
24
+ else if (o.type === POSTGRES_ENTITY_TYPES.table || o.type === POSTGRES_ENTITY_TYPES.foreignTable || o.type === POSTGRES_ENTITY_TYPES.view || o.type === POSTGRES_ENTITY_TYPES.materializedView) out.push({ kind: "table", name });
25
25
  else if (o.type === POSTGRES_ENTITY_TYPES.sequence) out.push({ kind: "sequence", name });
26
26
  else if (o.type === POSTGRES_ENTITY_TYPES.function || o.type === POSTGRES_ENTITY_TYPES.procedure) {
27
27
  out.push({ kind: o.type === POSTGRES_ENTITY_TYPES.function ? "function" : "procedure", name: `${name}${o.signature ?? ""}` });
@@ -575,6 +575,11 @@ function liveCanonicalAddress(o: LivePgObject): { kind: CanonicalKind; schema?:
575
575
  "Postgres::Cast": "cast",
576
576
  "Postgres::EventTrigger": "eventTrigger",
577
577
  "Postgres::Operator": "operator",
578
+ "Postgres::OperatorClass": "operatorClass",
579
+ "Postgres::OperatorFamily": "operatorFamily",
580
+ "Postgres::ForeignTable": "foreignTable",
581
+ "Postgres::TextSearchConfiguration": "textSearchConfiguration",
582
+ "Postgres::TextSearchDictionary": "textSearchDictionary",
578
583
  "Postgres::Statistics": "statistics",
579
584
  "Postgres::ForeignDataWrapper": "foreignDataWrapper",
580
585
  "Postgres::ForeignServer": "foreignServer",
@@ -34,7 +34,7 @@ import { ACCESS_KINDS, canonicalOptions, sameConstraint, type CanonicalConstrain
34
34
  import type { PgDiffObject, PgSchemaObject } from "../plan/schema";
35
35
  import { migrationTarget } from "../migrate/handoff";
36
36
  import { pgName } from "../migrate/names";
37
- import type { CheckDef, ColumnDef, DomainProps, ExclusionDef, ForeignKeyDef, IndexProps, KeyDef, PolicyProps, SequenceProps, TableProps } from "../entities";
37
+ import type { CheckDef, ColumnDef, DomainProps, ForeignTableProps, TextSearchConfigurationProps, ExclusionDef, ForeignKeyDef, IndexProps, KeyDef, PolicyProps, SequenceProps, TableProps } from "../entities";
38
38
 
39
39
  const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
40
40
 
@@ -136,6 +136,11 @@ export const KIND_WORDS: Readonly<Record<CanonicalKind, string>> = {
136
136
  cast: "CAST",
137
137
  eventTrigger: "EVENT TRIGGER",
138
138
  operator: "OPERATOR",
139
+ operatorClass: "OPERATOR CLASS",
140
+ operatorFamily: "OPERATOR FAMILY",
141
+ foreignTable: "FOREIGN TABLE",
142
+ textSearchConfiguration: "TEXT SEARCH CONFIGURATION",
143
+ textSearchDictionary: "TEXT SEARCH DICTIONARY",
139
144
  statistics: "STATISTICS",
140
145
  foreignDataWrapper: "FOREIGN DATA WRAPPER",
141
146
  foreignServer: "SERVER",
@@ -322,7 +327,7 @@ function identitySetClauses(options: string | undefined): string[] {
322
327
  return out;
323
328
  }
324
329
 
325
- const COLUMN_FIELD = /^columns\.(.+?)(?:\.(type|default|notNull|generated|identity|collate|storage|comment))?$/;
330
+ const COLUMN_FIELD = /^columns\.(.+?)(?:\.(type|default|notNull|generated|identity|collate|storage|options|comment))?$/;
326
331
 
327
332
  // ── Constraints ────────────────────────────────────────────────────────
328
333
 
@@ -632,6 +637,125 @@ function alteredSteps(d: PgDiffObject, live: PgDiffObject, target: string): PgSt
632
637
  return out.map((sql) => step(sql, "metadata", "SQLPG303"));
633
638
  }
634
639
 
640
+
641
+ // ── Operators, text search and foreign tables (#4406) ──────────────────
642
+
643
+ /**
644
+ * The clauses of `ALTER OPERATOR ... SET (...)` for the attributes the diff
645
+ * found changeable in place (SQLPG304): RESTRICT and JOIN to the declared
646
+ * estimator or NONE, COMMUTATOR and NEGATOR to the declared operator, and
647
+ * HASHES and MERGES as bare words, since they can only be turned on.
648
+ */
649
+ function operatorSetClauses(d: PgDiffObject, changes: readonly PgChange[]): string[] {
650
+ const definition = (d.fields.definition ?? {}) as Record<string, string>;
651
+ return changes.map((c) => {
652
+ const key = c.field.replace(/^definition\./, "");
653
+ const value = definition[key];
654
+ if (key === "hashes" || key === "merges") return key.toUpperCase();
655
+ if (key === "commutator" || key === "negator") return `${key.toUpperCase()} = OPERATOR(${value})`;
656
+ return `${key.toUpperCase()} = ${value ?? "NONE"}`;
657
+ });
658
+ }
659
+
660
+ /**
661
+ * A text search configuration's mappings, or a dictionary's options, taken to
662
+ * the declaration in place (SQLPG305). For a configuration: DROP MAPPING for
663
+ * the token types no longer mapped, ALTER MAPPING for those whose dictionaries
664
+ * changed, ADD MAPPING for the new ones, one statement for the token types
665
+ * that share their dictionaries; the dictionaries are named as the
666
+ * declaration wrote them. For a dictionary: one ALTER with each option that
667
+ * differs set, and each option the declaration dropped named with no value,
668
+ * which removes it.
669
+ */
670
+ function textSearchSteps(obj: DeclaredPgObject, live: PgDiffObject, target: string): PgStep[] {
671
+ const d = obj.canonical;
672
+ if (d.kind === "textSearchDictionary") {
673
+ const want = (d.fields.options ?? {}) as Record<string, string>;
674
+ const have = (live.fields.options ?? {}) as Record<string, string>;
675
+ const parts = [
676
+ ...Object.keys(want).filter((k) => have[k] !== want[k]).map((k) => `${quoteIdent(k)} = ${pgString(want[k]!)}`),
677
+ ...Object.keys(have).filter((k) => !(k in want)).map((k) => quoteIdent(k)),
678
+ ];
679
+ return parts.length > 0 ? [step(`ALTER TEXT SEARCH DICTIONARY ${target} (${parts.join(", ")})`, "metadata", "SQLPG305")] : [];
680
+ }
681
+ const want = (d.fields.mappings ?? {}) as Record<string, string[]>;
682
+ const have = (live.fields.mappings ?? {}) as Record<string, string[]>;
683
+ const written = new Map<string, string[]>();
684
+ for (const m of (obj.props as unknown as TextSearchConfigurationProps).mappings) for (const t of m.tokens) written.set(t.toLowerCase(), m.dictionaries);
685
+ const group = (tokens: string[]): Map<string, string[]> => {
686
+ const out = new Map<string, string[]>();
687
+ for (const t of tokens) {
688
+ const key = JSON.stringify(want[t]);
689
+ out.set(key, [...(out.get(key) ?? []), t]);
690
+ }
691
+ return out;
692
+ };
693
+ const dropped = Object.keys(have).filter((t) => !(t in want));
694
+ const added = Object.keys(want).filter((t) => !(t in have));
695
+ const changed = Object.keys(want).filter((t) => t in have && JSON.stringify(have[t]) !== JSON.stringify(want[t]));
696
+ const names = (tokens: string[]) => tokens.map(quoteIdent).join(", ");
697
+ const out: PgStep[] = [];
698
+ if (dropped.length > 0) out.push(step(`ALTER TEXT SEARCH CONFIGURATION ${target} DROP MAPPING FOR ${names(dropped)}`, "metadata", "SQLPG305"));
699
+ for (const [verb, tokens] of [["ALTER", changed], ["ADD", added]] as const) {
700
+ for (const members of group(tokens).values()) out.push(step(`ALTER TEXT SEARCH CONFIGURATION ${target} ${verb} MAPPING FOR ${names(members)} WITH ${written.get(members[0]!)!.join(", ")}`, "metadata", "SQLPG305"));
701
+ }
702
+ return out;
703
+ }
704
+
705
+ /**
706
+ * A foreign table's changes in place (SQLPG306), as ALTER FOREIGN TABLE
707
+ * makes them: ADD and DROP COLUMN, a column's type, default, NOT NULL and
708
+ * OPTIONS, CHECK constraints added and dropped, and the table's OPTIONS.
709
+ * A column added with options gets them in an ALTER of its own, since ADD
710
+ * COLUMN has no OPTIONS clause.
711
+ */
712
+ function foreignTableSteps(c: PgChange, obj: DeclaredPgObject, live: PgDiffObject, alterKind: string): PgStep[] {
713
+ const d = obj.canonical;
714
+ const props = obj.props as unknown as ForeignTableProps;
715
+ const make = (sql: string) => step(sql, "metadata", "SQLPG306");
716
+ if (c.field === "options") {
717
+ const clause = optionsClause(d.fields.options, live.fields.options);
718
+ return clause ? [make(`${alterKind} ${clause}`)] : [];
719
+ }
720
+ if (c.field.startsWith("constraints.")) {
721
+ const key = c.field.slice("constraints.".length);
722
+ // A declared CHECK the server lacks is added; one it has and the declaration dropped is dropped by the server's name.
723
+ if (c.before === undefined) {
724
+ const i = d.constraints.findIndex((a) => (a.name ?? a.kind.toLowerCase()) === key && !live.constraints.some((b) => sameConstraint(a, b)));
725
+ return i >= 0 && props.checks[i] ? [make(`${alterKind} ADD ${constraintSql("CHECK", props.checks[i]!)}`)] : [];
726
+ }
727
+ return [make(`${alterKind} DROP CONSTRAINT ${quoteIdent(key)}`)];
728
+ }
729
+ const m = COLUMN_FIELD.exec(c.field);
730
+ if (!m) return [];
731
+ const name = m[1]!;
732
+ const prop = m[2];
733
+ const def = props.columns.find((x) => x.name === name);
734
+ const column = `${alterKind} ALTER COLUMN ${quoteIdent(name)}`;
735
+ const liveColumn = live.columns.find((x) => x.name === name);
736
+ if (prop === undefined) {
737
+ if (!def) return [make(`${alterKind} DROP COLUMN ${quoteIdent(name)}`)];
738
+ const options = optionsClause(def.options, undefined);
739
+ return [make(`${alterKind} ADD COLUMN ${columnDefinition(def)}`), ...(options ? [make(`${column} ${options}`)] : [])];
740
+ }
741
+ if (!def) return [];
742
+ switch (prop) {
743
+ case "type":
744
+ case "collate":
745
+ return def.type ? [make(`${column} TYPE ${def.type}${def.collate ? ` COLLATE ${def.collate}` : prop === "collate" ? ' COLLATE "default"' : ""}`)] : [];
746
+ case "default":
747
+ return [make(def.default !== undefined ? `${column} SET DEFAULT ${def.default}` : `${column} DROP DEFAULT`)];
748
+ case "notNull":
749
+ return [make(`${column} ${def.notNull ? "SET" : "DROP"} NOT NULL`)];
750
+ case "options": {
751
+ const clause = optionsClause(def.options, liveColumn?.options);
752
+ return clause ? [make(`${column} ${clause}`)] : [];
753
+ }
754
+ default:
755
+ return [];
756
+ }
757
+ }
758
+
635
759
  /**
636
760
  * The statements for an object that exists on the server, change by change.
637
761
  * `live` is the server's definition, matched to the declaration (a renamed
@@ -667,13 +791,23 @@ export function alterSteps(
667
791
  steps.push(...createSteps(obj, opts.marker, { cls: c.class, rule: c.rule }));
668
792
  continue;
669
793
  }
794
+ if (c.rule === "SQLPG306") {
795
+ steps.push(...foreignTableSteps(c, obj, live, alterKind));
796
+ continue;
797
+ }
798
+ if (d.kind === "foreignTable" && c.rule === "SQLPG216" && /^constraints\.[^.]+\.comment$/.test(c.field)) {
799
+ const name = c.field.split(".")[1]!;
800
+ const declared = d.constraints.find((a) => (a.name ?? a.kind.toLowerCase()) === name);
801
+ steps.push(step(`COMMENT ON CONSTRAINT ${quoteIdent(name)} ON ${target} IS ${declared?.comment === undefined ? "NULL" : pgString(declared.comment)}`, c.class, c.rule));
802
+ continue;
803
+ }
670
804
  if (c.field.startsWith("constraints.")) {
671
805
  if (!constraintsDone) steps.push(...constraintSteps(obj, live, notNullChecks));
672
806
  constraintsDone = true;
673
807
  continue;
674
808
  }
675
809
  const col = COLUMN_FIELD.exec(c.field);
676
- if (col && (d.kind === "table")) {
810
+ if (col && (d.kind === "table" || d.kind === "foreignTable")) {
677
811
  const name = col[1]!;
678
812
  const prop = col[2];
679
813
  const def = columns.get(name);
@@ -770,6 +904,12 @@ export function alterSteps(
770
904
  case "SQLPG303":
771
905
  if (!steps.some((s) => s.rule === "SQLPG303")) steps.push(...alteredSteps(d, live, target));
772
906
  break;
907
+ case "SQLPG304":
908
+ if (!steps.some((s) => s.rule === "SQLPG304")) steps.push(step(`ALTER OPERATOR ${target} SET (${operatorSetClauses(d, changes.filter((x) => x.rule === "SQLPG304")).join(", ")})`, c.class, c.rule));
909
+ break;
910
+ case "SQLPG305":
911
+ if (!steps.some((s) => s.rule === "SQLPG305")) steps.push(...textSearchSteps(obj, live, target));
912
+ break;
773
913
  case "SQLPG250":
774
914
  case "SQLPG280":
775
915
  case "SQLPG302":
@@ -955,12 +1095,20 @@ const DROP_ORDER: Record<CanonicalKind, number> = {
955
1095
  view: 2,
956
1096
  index: 3,
957
1097
  table: 4,
1098
+ // A foreign table reads a foreign server (below) and a type; a view on it goes before.
1099
+ foreignTable: 4,
958
1100
  sequence: 5,
959
1101
  // An event trigger and a cast before the function they call; an aggregate before its functions.
960
1102
  eventTrigger: 5.5,
961
1103
  cast: 5.5,
962
1104
  aggregate: 5.5,
963
1105
  operator: 5.5,
1106
+ // A class names operators and functions; a family goes after its classes.
1107
+ operatorClass: 5.2,
1108
+ operatorFamily: 5.3,
1109
+ // A configuration maps token types to dictionaries, so it goes first.
1110
+ textSearchConfiguration: 5.7,
1111
+ textSearchDictionary: 5.8,
964
1112
  // A server before its wrapper, a wrapper before the handler function it calls.
965
1113
  foreignServer: 5.5,
966
1114
  foreignDataWrapper: 5.6,