@intentius/chant-lexicon-sql 0.124.48 → 0.124.49

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 +246 -0
  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 +271 -0
  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
@@ -1,24 +1,27 @@
1
1
  /**
2
2
  * Extended statistics, foreign data wrappers, foreign servers and
3
- * publications (#3911): what the tags parse and refuse, how a change to each
4
- * is classified and made, how the catalog reader prints each, and the round
5
- * trip: declare, build, import, plan with no change.
3
+ * publications (#3911), and foreign tables, operator classes and families, and
4
+ * text search configurations and dictionaries (#4406): what the tags parse and
5
+ * refuse, how a change to each is classified and made, how the catalog reader
6
+ * prints each, and the round trip: declare, build, import, plan with no change.
6
7
  */
7
8
 
8
9
  import { describe, expect, test } from "vitest";
9
- import { foreignDataWrapper, foreignServer, func, operator, publication, schema, SqlTemplateError, statistics, table } from "./entities";
10
+ import { foreignDataWrapper, foreignServer, foreignTable, func, operator, operatorClass, operatorFamily, publication, schema, SqlTemplateError, statistics, table, textSearchConfiguration, textSearchDictionary } from "./entities";
10
11
  import { diffObject, keyedByQualifiedName, pgSchemaFromLive, type PgSchemaObject } from "./plan/schema";
11
12
  import { diffPgSchemas, matchPgObjects } from "./plan/diff";
12
13
  import { alterSteps, dropStatement } from "./apply/statements";
13
14
  import { declaredObjects } from "./apply/apply";
14
15
  import { readLiveSchema, type LivePgObject } from "./live/catalog";
15
- import { readDatabaseKinds, readOperators } from "./live/catalog-kinds";
16
+ import { parseDictionaryOptions, readDatabaseKinds, readOperators } from "./live/catalog-kinds";
16
17
  import type { PostgresClient } from "./live/client";
17
18
  import { objectsToIR, type ImportedPgObject } from "./import/ir";
18
19
  import { PostgresGenerator, postgresTemplates } from "./import/generator";
19
20
  import { canonicalPgObject } from "./plan/normalize";
20
21
  import { liveProps } from "./plan/deep";
21
22
  import { fakeClient } from "./testing/fake-client";
23
+ import { sqlSerializer } from "../serializer";
24
+ import type { SerializerResult } from "@intentius/chant/serializer";
22
25
  import { kindRow } from "../coverage";
23
26
 
24
27
  const app = schema`CREATE SCHEMA app`;
@@ -90,10 +93,83 @@ describe("the tags", () => {
90
93
  expect(canonicalPgObject(empty.entityType, props(empty)).fields).not.toHaveProperty("tables");
91
94
  });
92
95
 
96
+ test("a foreign table: its columns with options, CHECK constraints, server and options", () => {
97
+ const replica = foreignServer`CREATE SERVER app_replica FOREIGN DATA WRAPPER app_fdw`;
98
+ const ft = foreignTable`CREATE FOREIGN TABLE ${app}.remote_orders (id int OPTIONS (column_name 'order_id') NOT NULL, status text DEFAULT 'placed', CONSTRAINT status_known CHECK (status IS NOT NULL)) SERVER ${replica} OPTIONS (table_name 'orders'); COMMENT ON FOREIGN TABLE ${app}.remote_orders IS 'Orders'; COMMENT ON COLUMN ${app}.remote_orders.id IS 'The key'`;
99
+ expect(ft.entityType).toBe("Postgres::ForeignTable");
100
+ expect(ft.sqlName).toBe("app.remote_orders");
101
+ expect(ft.dependsOn).toEqual(expect.arrayContaining([app, replica]));
102
+ expect(ft.props).toMatchObject({ schema: "app", name: "remote_orders", serverName: "app_replica", options: { table_name: "orders" }, comment: "Orders" });
103
+ expect(ft.props.columns[0]).toMatchObject({ name: "id", type: "int", notNull: true, options: { column_name: "order_id" }, comment: "The key" });
104
+ expect((ft as unknown as { columns: Record<string, unknown> }).columns).toHaveProperty("status");
105
+ const c = canonicalPgObject(ft.entityType, props(ft));
106
+ expect(c.kind).toBe("foreignTable");
107
+ expect(c.schema).toBe("app");
108
+ expect(c.fields).toMatchObject({ server: "app_replica", options: { table_name: "orders" } });
109
+ expect(c.columns.map((x) => [x.name, x.type, x.notNull, x.default])).toEqual([["id", "integer", true, undefined], ["status", "text", false, "'placed'"]]);
110
+ expect(c.columns[0]!.options).toEqual({ column_name: "order_id" });
111
+ expect(c.constraints).toEqual([expect.objectContaining({ kind: "CHECK", name: "status_known", body: "check status is not null" })]);
112
+ });
113
+
114
+ test("operator classes and families: identified by their index method", () => {
115
+ const fam = operatorFamily`CREATE OPERATOR FAMILY ${app}.ints USING btree; COMMENT ON OPERATOR FAMILY ${app}.ints USING btree IS 'Integers'`;
116
+ expect(fam.entityType).toBe("Postgres::OperatorFamily");
117
+ expect(fam.props).toMatchObject({ schema: "app", name: "ints", method: "btree", comment: "Integers" });
118
+ expect(canonicalPgObject(fam.entityType, props(fam))).toMatchObject({ kind: "operatorFamily", schema: "app", name: "ints", signature: " USING btree" });
119
+ const cmp = func`CREATE FUNCTION ${app}.int_cmp(a int, b int) RETURNS int LANGUAGE sql AS 'SELECT 0'`;
120
+ const oc = operatorClass`CREATE OPERATOR CLASS ${app}.int_ops DEFAULT FOR TYPE int USING btree FAMILY ${fam} AS OPERATOR 1 <, OPERATOR 3 pg_catalog.= (int, int), FUNCTION 1 ${cmp}(int, int), OPERATOR 5 > FOR SEARCH`;
121
+ expect(oc.entityType).toBe("Postgres::OperatorClass");
122
+ expect(oc.sqlName).toBe("app.int_ops");
123
+ expect(oc.dependsOn).toEqual(expect.arrayContaining([fam, cmp]));
124
+ expect(oc.props).toMatchObject({ schema: "app", name: "int_ops", default: true, dataType: "int", method: "btree", family: "app.ints" });
125
+ const c = canonicalPgObject(oc.entityType, props(oc));
126
+ expect(c.signature).toBe(" USING btree");
127
+ // Operand types default to the class's type; a bare operator is pg_catalog's or the default schema's; the family is the class's own unless another is named.
128
+ expect(c.fields).toMatchObject({
129
+ default: true,
130
+ dataType: "integer",
131
+ family: "app.ints",
132
+ items: ["operator 1 < (integer,integer)", "operator 3 = (integer,integer)", "operator 5 > (integer,integer)", "function 1 (integer,integer) app.int_cmp(integer,integer)"],
133
+ });
134
+ // The server's printing of a class in a family of its own name is the declaration with no FAMILY.
135
+ const own = operatorClass`CREATE OPERATOR CLASS app.hash_ops FOR TYPE int USING hash AS OPERATOR 1 =, FUNCTION 1 hashint4(int)`;
136
+ const named = operatorClass`CREATE OPERATOR CLASS app.hash_ops FOR TYPE integer USING hash FAMILY app.hash_ops AS OPERATOR 1 pg_catalog.= (integer, integer), FUNCTION 1 (integer, integer) hashint4(integer)`;
137
+ expect(canonicalPgObject(own.entityType, props(own)).fields).toEqual(canonicalPgObject(named.entityType, props(named)).fields);
138
+ expect(canonicalPgObject(own.entityType, props(own)).fields).not.toHaveProperty("family");
139
+ // An ordering operator names the btree family it sorts by; a prefix operator's missing side is NONE; STORAGE is the stored type.
140
+ const gist = operatorClass`CREATE OPERATOR CLASS app.g FOR TYPE point USING gist AS OPERATOR 1 <-> (point, point) FOR ORDER BY pg_catalog.float_ops, OPERATOR 2 @@ (NONE, point), STORAGE box`;
141
+ expect(canonicalPgObject(gist.entityType, props(gist)).fields.items).toEqual(["operator 1 <-> (point,point) for order by float_ops", "operator 2 @@ (NONE,point)", "storage box"]);
142
+ });
143
+
144
+ test("a text search dictionary and configuration: options, a parser and the mappings after the CREATE", () => {
145
+ const dict = textSearchDictionary`CREATE TEXT SEARCH DICTIONARY ${app}.shop_stop (TEMPLATE = simple, StopWords = english, Accept = false); COMMENT ON TEXT SEARCH DICTIONARY ${app}.shop_stop IS 'Stop words'`;
146
+ expect(dict.entityType).toBe("Postgres::TextSearchDictionary");
147
+ expect(dict.props).toMatchObject({ schema: "app", name: "shop_stop", template: "simple", options: { stopwords: "english", accept: "false" }, comment: "Stop words" });
148
+ expect(canonicalPgObject(dict.entityType, props(dict)).fields).toMatchObject({ template: "simple", options: { accept: "false", stopwords: "english" } });
149
+ // A quoted value is the same option: the server keeps every value as a string.
150
+ const quoted = textSearchDictionary`CREATE TEXT SEARCH DICTIONARY app.shop_stop (template = pg_catalog.simple, accept = 'false', stopwords = 'english')`;
151
+ expect(canonicalPgObject(quoted.entityType, props(quoted)).fields).toEqual({ template: "simple", options: { accept: "false", stopwords: "english" } });
152
+
153
+ const cfg = textSearchConfiguration`CREATE TEXT SEARCH CONFIGURATION ${app}.shop (PARSER = pg_catalog."default");
154
+ ALTER TEXT SEARCH CONFIGURATION ${app}.shop ADD MAPPING FOR asciiword, word WITH ${dict}, english_stem;
155
+ ALTER TEXT SEARCH CONFIGURATION ${app}.shop ADD MAPPING FOR int WITH simple;
156
+ COMMENT ON TEXT SEARCH CONFIGURATION ${app}.shop IS 'Shop search'`;
157
+ expect(cfg.entityType).toBe("Postgres::TextSearchConfiguration");
158
+ expect(cfg.dependsOn).toEqual(expect.arrayContaining([app, dict]));
159
+ expect(cfg.props).toMatchObject({ schema: "app", name: "shop", parser: 'pg_catalog."default"', comment: "Shop search" });
160
+ expect(cfg.props.mappings).toEqual([
161
+ { tokens: ["asciiword", "word"], dictionaries: ["app.shop_stop", "english_stem"] },
162
+ { tokens: ["int"], dictionaries: ["simple"] },
163
+ ]);
164
+ expect(canonicalPgObject(cfg.entityType, props(cfg)).fields).toMatchObject({ parser: '"default"', mappings: { asciiword: ["shop_stop", "english_stem"].map((n, i) => (i === 0 ? "app.shop_stop" : n)), int: ["simple"], word: ["app.shop_stop", "english_stem"] } });
165
+ });
166
+
93
167
  test("what is refused", () => {
94
168
  expect(() => operator`CREATE OPERATOR app.=== (LEFTARG = int, RIGHTARG = int)`).toThrow(/FUNCTION/);
95
169
  expect(() => operator`CREATE OPERATOR app.=== (FUNCTION = f, LEFTARG = int, MERGE = true)`).toThrow(/an option of an operator/);
96
- expect(() => operator`CREATE OPERATOR CLASS c FOR TYPE int USING btree AS OPERATOR 1 <`).toThrow(/CREATE OPERATOR CLASS and CREATE OPERATOR FAMILY/);
170
+ expect(() => operator`CREATE OPERATOR CLASS c FOR TYPE int USING btree AS OPERATOR 1 <`).toThrow(/use the operatorClass tag/);
171
+ expect(() => operator`CREATE OPERATOR FAMILY f USING btree`).toThrow(/use the operatorFamily tag/);
172
+ expect(() => table`CREATE FOREIGN TABLE app.t (id int) SERVER s`).toThrow(/use the foreignTable tag/);
97
173
  expect(() => operator`CREATE OPERATOR app.eq (FUNCTION = f)`).toThrow(/operator's symbol/);
98
174
  expect(() => foreignServer`CREATE SERVER s FOREIGN DATA WRAPPER w OPTIONS (host 'x', password 'hunter2')`).toThrow(/holds a credential/);
99
175
  expect(() => foreignDataWrapper`CREATE FOREIGN DATA WRAPPER w OPTIONS (api_key 'x')`).toThrow(SqlTemplateError);
@@ -106,6 +182,33 @@ describe("the tags", () => {
106
182
  expect(() => statistics`CREATE STATISTICS app.s ON a, b`).toThrow(/FROM/);
107
183
  expect(() => statistics`CREATE USER MAPPING FOR u SERVER s`).toThrow(SqlTemplateError);
108
184
  expect(() => publication`CREATE STATISTICS app.s ON a, b FROM app.t`).toThrow(/use the statistics tag/);
185
+ // #4406: what a foreign table, an operator class and a text search object refuse.
186
+ expect(() => foreignTable`CREATE FOREIGN TABLE app.t (id int PRIMARY KEY) SERVER s`).toThrow(/CHECK or NOT NULL, not PRIMARY KEY/);
187
+ expect(() => foreignTable`CREATE FOREIGN TABLE app.t (id int, UNIQUE (id)) SERVER s`).toThrow(/CHECK or NOT NULL, not UNIQUE/);
188
+ expect(() => foreignTable`CREATE FOREIGN TABLE app.t (id int, total int GENERATED ALWAYS AS (id * 2) STORED) SERVER s`).toThrow(/generated column/);
189
+ expect(() => foreignTable`CREATE FOREIGN TABLE app.t (id int, CHECK (id > 0) NOT ENFORCED) SERVER s`).toThrow(/NOT ENFORCED/);
190
+ expect(() => foreignTable`CREATE FOREIGN TABLE app.t (id int) INHERITS (app.p) SERVER s`).toThrow(/INHERITS/);
191
+ expect(() => foreignTable`CREATE FOREIGN TABLE app.t PARTITION OF app.p FOR VALUES IN (1) SERVER s`).toThrow(/PARTITION OF/);
192
+ expect(() => foreignTable`CREATE FOREIGN TABLE app.t (LIKE app.p) SERVER s`).toThrow(/LIKE/);
193
+ expect(() => foreignTable`CREATE FOREIGN TABLE app.t (id int)`).toThrow(/SERVER/);
194
+ expect(() => foreignTable`CREATE FOREIGN TABLE app.t (id int) SERVER s OPTIONS (password 'x')`).toThrow(/holds a credential/);
195
+ expect(() => foreignTable`CREATE FOREIGN TABLE app.t (id int OPTIONS (api_key 'x')) SERVER s`).toThrow(/holds a credential/);
196
+ expect(() => operatorClass`CREATE OPERATOR CLASS app.c FOR TYPE int USING btree AS`).toThrow();
197
+ expect(() => operatorClass`CREATE OPERATOR CLASS app.c FOR TYPE int USING btree AS OPERATOR x <`).toThrow(/strategy or support number/);
198
+ expect(() => operatorClass`CREATE OPERATOR CLASS app.c FOR TYPE int USING btree AS OPERATOR 1 < FOR EVERYTHING`).toThrow(/SEARCH or ORDER BY/);
199
+ expect(() => operatorClass`CREATE OPERATOR CLASS app.c FOR TYPE int USING btree AS FUNCTION 1 f`).toThrow(/argument types/);
200
+ expect(() => operatorClass`CREATE OPERATOR CLASS app.c FOR TYPE int USING btree AS OPERATOR 1 <; COMMENT ON OPERATOR CLASS app.c USING hash IS 'x'`).toThrow(/another index method/);
201
+ expect(() => operatorFamily`CREATE OPERATOR FAMILY app.f`).toThrow(/USING/);
202
+ expect(() => textSearchConfiguration`CREATE TEXT SEARCH CONFIGURATION app.c (COPY = english)`).toThrow(/COPY copies mappings/);
203
+ expect(() => textSearchConfiguration`CREATE TEXT SEARCH CONFIGURATION app.c ()`).toThrow(/PARSER/);
204
+ expect(() => textSearchConfiguration`CREATE TEXT SEARCH CONFIGURATION app.c (PARSER = p); ALTER TEXT SEARCH CONFIGURATION app.other ADD MAPPING FOR word WITH simple`).toThrow(/another configuration/);
205
+ expect(() => textSearchConfiguration`CREATE TEXT SEARCH CONFIGURATION app.c (PARSER = p); ALTER TEXT SEARCH CONFIGURATION app.c ADD MAPPING FOR word WITH simple; ALTER TEXT SEARCH CONFIGURATION app.c ADD MAPPING FOR word WITH english_stem`).toThrow(/mapped twice/);
206
+ expect(() => textSearchConfiguration`CREATE TEXT SEARCH CONFIGURATION app.c (PARSER = p); ALTER TEXT SEARCH CONFIGURATION app.c DROP MAPPING FOR word`).toThrow(/ADD MAPPING/);
207
+ expect(() => textSearchConfiguration`ALTER TEXT SEARCH CONFIGURATION app.c ADD MAPPING FOR word WITH simple`).toThrow(/before any CREATE/);
208
+ expect(() => textSearchDictionary`CREATE TEXT SEARCH DICTIONARY app.d (StopWords = english)`).toThrow(/TEMPLATE/);
209
+ expect(() => textSearchDictionary`CREATE TEXT SEARCH DICTIONARY app.d (TEMPLATE = simple, TEMPLATE = snowball)`).toThrow(/given twice/);
210
+ expect(() => textSearchDictionary`CREATE TEXT SEARCH PARSER app.p (START = f)`).toThrow(/DICTIONARY or CONFIGURATION/);
211
+ expect(() => textSearchDictionary`CREATE TEXT SEARCH CONFIGURATION app.c (PARSER = p)`).toThrow(/use the textSearchConfiguration tag/);
109
212
  });
110
213
  });
111
214
 
@@ -113,15 +216,15 @@ describe("the tags", () => {
113
216
 
114
217
  const live = (type: string, statement: string, extra: Partial<LivePgObject> = {}) => pgSchemaFromLive([{ type, statement, name: "", oid: "1", ...extra } as LivePgObject], "public");
115
218
  const declared = (type: string, ddl: string): PgSchemaObject[] => keyedByQualifiedName([{ key: "x", canonical: { ...diffObject(type, ddl, "public"), exportName: "x" } }]);
116
- const rules = (type: string, before: string, after: string, extra: Partial<LivePgObject> = {}) =>
117
- diffPgSchemas(live(type, before, extra), declared(type, after)).changes.map((c) => [c.field, c.rule]);
219
+ const rules = (type: string, before: string, after: string, extra: Partial<LivePgObject> = {}, major?: number) =>
220
+ diffPgSchemas(live(type, before, extra), declared(type, after), major ? { major } : {}).changes.map((c) => [c.field, c.rule]);
118
221
 
119
- function steps(type: string, liveDdl: string, declaredDdl: string, extra: Partial<LivePgObject> = {}): string[] {
222
+ function steps(type: string, liveDdl: string, declaredDdl: string, extra: Partial<LivePgObject> = {}, major = 18): string[] {
120
223
  const [obj] = declaredObjects(JSON.stringify({ dialect: "postgres", objects: [{ export: "x", type, ddl: declaredDdl, dependsOn: [] }] }));
121
224
  const after = keyedByQualifiedName([{ key: "x", canonical: obj!.canonical }]);
122
225
  const before = live(type, liveDdl, extra);
123
226
  const m = matchPgObjects(before, after).find((x) => x.kind === "matched")!;
124
- return alterSteps(obj!, m.before!.canonical, diffPgSchemas(before, after).changes, { major: 18 }).steps.map((s) => s.sql);
227
+ return alterSteps(obj!, m.before!.canonical, diffPgSchemas(before, after, { major }).changes, { major }).steps.map((s) => s.sql);
125
228
  }
126
229
 
127
230
  const OPR = "Postgres::Operator";
@@ -129,6 +232,11 @@ const STAT = "Postgres::Statistics";
129
232
  const FDW = "Postgres::ForeignDataWrapper";
130
233
  const SRV = "Postgres::ForeignServer";
131
234
  const PUB = "Postgres::Publication";
235
+ const OPC = "Postgres::OperatorClass";
236
+ const OPF = "Postgres::OperatorFamily";
237
+ const FTB = "Postgres::ForeignTable";
238
+ const TSD = "Postgres::TextSearchDictionary";
239
+ const TSC = "Postgres::TextSearchConfiguration";
132
240
 
133
241
  describe("the classifier and the statements", () => {
134
242
  test("the server's printing is the declaration's", () => {
@@ -146,11 +254,13 @@ describe("the classifier and the statements", () => {
146
254
  test("an operator is dropped and created: it has no ALTER for its function or operands", () => {
147
255
  const O1 = "CREATE OPERATOR app.=== (FUNCTION = app.pair_eq, LEFTARG = integer, RIGHTARG = integer, COMMUTATOR = OPERATOR(app.===), RESTRICT = eqsel)";
148
256
  expect(rules(OPR, O1, "CREATE OPERATOR app.=== (LEFTARG = int, RIGHTARG = int, RESTRICT = eqsel, COMMUTATOR = ===, FUNCTION = app.pair_eq)")).toEqual([]);
149
- expect(rules(OPR, O1, O1.replace("eqsel", "scalarltsel"))).toEqual([["definition", "SQLPG300"]]);
150
- expect(rules(OPR, O1, O1.replace("eqsel", "scalarltsel"), { dependents: ["operator class app.c for access method btree"] })).toEqual([["definition", "SQLPG301"]]);
151
- const recreate = steps(OPR, O1, O1.replace("eqsel", "scalarltsel"));
257
+ // The function is not an attribute ALTER OPERATOR reaches, so a change of it is a drop and a create.
258
+ const F2 = O1.replace("app.pair_eq", "app.pair_eq2");
259
+ expect(rules(OPR, O1, F2)).toEqual([["definition", "SQLPG300"]]);
260
+ expect(rules(OPR, O1, F2, { dependents: ["operator class app.c for access method btree"] })).toEqual([["definition", "SQLPG301"]]);
261
+ const recreate = steps(OPR, O1, F2);
152
262
  expect(recreate[0]).toBe("DROP OPERATOR app.===(integer,integer)");
153
- expect(recreate[1]).toContain("scalarltsel");
263
+ expect(recreate[1]).toContain("pair_eq2");
154
264
  expect(recreate[2]).toMatch(/^COMMENT ON OPERATOR app\.===\(integer,integer\) IS /);
155
265
  // Another operand type is another operator, not a change.
156
266
  expect(dropStatement("operator", "app", "===", "(NONE,integer)").sql).toBe("DROP OPERATOR app.===(NONE,integer)");
@@ -217,6 +327,150 @@ describe("the classifier and the statements", () => {
217
327
  expect(steps(PUB, "CREATE PUBLICATION p FOR ALL TABLES", "CREATE PUBLICATION p FOR TABLE app.a")[0]).toBe("DROP PUBLICATION p");
218
328
  });
219
329
 
330
+ test("an operator's estimators, and from 17 its commutator, negator and flags, are altered in place", () => {
331
+ const O1 = "CREATE OPERATOR app.=== (FUNCTION = app.pair_eq, LEFTARG = integer, RIGHTARG = integer, COMMUTATOR = OPERATOR(app.===), RESTRICT = eqsel)";
332
+ const alter = "ALTER OPERATOR app.===(integer,integer) SET";
333
+ // RESTRICT and JOIN, set, changed or removed, on every major.
334
+ for (const major of [14, 16, 18]) {
335
+ expect(rules(OPR, O1, O1.replace("eqsel", "scalarltsel"), {}, major)).toEqual([["definition.restrict", "SQLPG304"]]);
336
+ expect(rules(OPR, O1, `${O1.slice(0, -1)}, JOIN = eqjoinsel)`, {}, major)).toEqual([["definition.join", "SQLPG304"]]);
337
+ }
338
+ expect(steps(OPR, O1, O1.replace("eqsel", "scalarltsel"))).toEqual([`${alter} (RESTRICT = scalarltsel)`]);
339
+ expect(steps(OPR, O1, O1.replace(", RESTRICT = eqsel", ""))).toEqual([`${alter} (RESTRICT = NONE)`]);
340
+ // Every attribute that changed is one statement; the object's dependents do not matter to it.
341
+ const both = `${O1.replace("eqsel", "scalarltsel").slice(0, -1)}, JOIN = scalarltjoinsel)`;
342
+ expect(rules(OPR, O1, both, { dependents: ["operator class app.c for access method btree"] })).toEqual([["definition.restrict", "SQLPG304"], ["definition.join", "SQLPG304"]]);
343
+ expect(steps(OPR, O1, both)).toEqual([`${alter} (RESTRICT = scalarltsel, JOIN = scalarltjoinsel)`]);
344
+ // 17 added COMMUTATOR, NEGATOR, HASHES and MERGES to ALTER OPERATOR, each only to turn it on or fill it in.
345
+ const negated = `${O1.slice(0, -1)}, NEGATOR = OPERATOR(app.!==), HASHES, MERGES)`;
346
+ for (const major of [17, 18]) {
347
+ expect(rules(OPR, O1, negated, {}, major)).toEqual([["definition.negator", "SQLPG304"], ["definition.hashes", "SQLPG304"], ["definition.merges", "SQLPG304"]]);
348
+ }
349
+ expect(steps(OPR, O1, negated)).toEqual([`${alter} (NEGATOR = OPERATOR(app.!==), HASHES, MERGES)`]);
350
+ expect(steps(OPR, "CREATE OPERATOR app.=== (FUNCTION = app.pair_eq, LEFTARG = integer, RIGHTARG = integer)", O1, {}, 17)).toEqual([`${alter} (RESTRICT = eqsel, COMMUTATOR = OPERATOR(app.===))`]);
351
+ // Before 17 only RESTRICT and JOIN are alterable, so the rest is a drop and a create.
352
+ for (const major of [14, 15, 16]) expect(rules(OPR, O1, negated, {}, major)).toEqual([["definition", "SQLPG300"]]);
353
+ // Never in place: a commutator that is already set, a flag turned off, the function or an operand.
354
+ const other = O1.replace("COMMUTATOR = OPERATOR(app.===)", "COMMUTATOR = OPERATOR(app.====)");
355
+ expect(rules(OPR, O1, other)).toEqual([["definition", "SQLPG300"]]);
356
+ expect(rules(OPR, O1, O1.replace(", COMMUTATOR = OPERATOR(app.===)", ""))).toEqual([["definition", "SQLPG300"]]);
357
+ expect(rules(OPR, `${O1.slice(0, -1)}, HASHES)`, O1)).toEqual([["definition", "SQLPG300"]]);
358
+ expect(rules(OPR, O1, `${O1.replace("eqsel", "scalarltsel").slice(0, -1)}, HASHES)`.replace("app.pair_eq", "app.other_eq"))).toEqual([["definition", "SQLPG300"]]);
359
+ // A comment still changes in place beside them.
360
+ expect(rules(OPR, O1, `${O1}; COMMENT ON OPERATOR app.===(integer, integer) IS 'x'`)).toEqual([["comment", "SQLPG216"]]);
361
+ });
362
+
363
+ test("a text search dictionary's options change in place; its template does not", () => {
364
+ const D1 = "CREATE TEXT SEARCH DICTIONARY app.d (TEMPLATE = simple, StopWords = english)";
365
+ expect(rules(TSD, D1.replace("simple", "pg_catalog.simple").replace("StopWords", "stopwords").replace("english", "'english'"), D1)).toEqual([]);
366
+ const D2 = "CREATE TEXT SEARCH DICTIONARY app.d (TEMPLATE = simple, Accept = false)";
367
+ expect(rules(TSD, D1, D2)).toEqual([["options", "SQLPG305"]]);
368
+ // An option named with no value is removed.
369
+ expect(steps(TSD, D1, D2)).toEqual(["ALTER TEXT SEARCH DICTIONARY app.d (accept = 'false', stopwords)"]);
370
+ expect(steps(TSD, D1, D1.replace("english", "russian"))).toEqual(["ALTER TEXT SEARCH DICTIONARY app.d (stopwords = 'russian')"]);
371
+ const T2 = D1.replace("simple", "snowball");
372
+ expect(rules(TSD, D1, T2)).toEqual([["template", "SQLPG300"]]);
373
+ expect(rules(TSD, D1, T2, { dependents: ["text search configuration app.c"] })).toEqual([["template", "SQLPG301"]]);
374
+ const recreate = steps(TSD, D1, T2);
375
+ expect(recreate[0]).toBe("DROP TEXT SEARCH DICTIONARY app.d");
376
+ expect(recreate[1]).toBe(T2);
377
+ expect(recreate[2]).toMatch(/^COMMENT ON TEXT SEARCH DICTIONARY app\.d IS /);
378
+ expect(dropStatement("textSearchDictionary", "app", "d").sql).toBe("DROP TEXT SEARCH DICTIONARY app.d");
379
+ });
380
+
381
+ test("a text search configuration's mappings change in place; its parser does not", () => {
382
+ const add = (verb: string, tokens: string, dicts: string) => `ALTER TEXT SEARCH CONFIGURATION app.c ${verb} MAPPING FOR ${tokens} WITH ${dicts}`;
383
+ const C1 = `CREATE TEXT SEARCH CONFIGURATION app.c (PARSER = pg_catalog."default"); ${add("ADD", "asciiword, word", "english_stem")}; ${add("ADD", "int", "simple")}`;
384
+ // What the server prints: the parser without pg_catalog, the dictionaries as the catalog names them.
385
+ expect(rules(TSC, `CREATE TEXT SEARCH CONFIGURATION app.c (PARSER = "default");\n${add("ADD", "word, asciiword", "pg_catalog.english_stem")};\n${add("ADD", "int", "public.simple")}`, C1)).toEqual([]);
386
+ const C2 = `CREATE TEXT SEARCH CONFIGURATION app.c (PARSER = pg_catalog."default"); ${add("ADD", "asciiword", "english_stem")}; ${add("ADD", "word", "app.d, english_stem")}; ${add("ADD", "uint", "simple")}`;
387
+ expect(rules(TSC, C1, C2)).toEqual([["mappings", "SQLPG305"]]);
388
+ expect(steps(TSC, C1, C2)).toEqual([
389
+ 'ALTER TEXT SEARCH CONFIGURATION app.c DROP MAPPING FOR "int"',
390
+ "ALTER TEXT SEARCH CONFIGURATION app.c ALTER MAPPING FOR word WITH app.d, english_stem",
391
+ "ALTER TEXT SEARCH CONFIGURATION app.c ADD MAPPING FOR uint WITH simple",
392
+ ]);
393
+ // Token types that share their dictionaries share a statement.
394
+ expect(steps(TSC, "CREATE TEXT SEARCH CONFIGURATION app.c (PARSER = \"default\")", C1)).toEqual([
395
+ "ALTER TEXT SEARCH CONFIGURATION app.c ADD MAPPING FOR asciiword, word WITH english_stem",
396
+ 'ALTER TEXT SEARCH CONFIGURATION app.c ADD MAPPING FOR "int" WITH simple',
397
+ ]);
398
+ const P2 = C1.replace('pg_catalog."default"', "app.my_parser");
399
+ expect(rules(TSC, C1, P2)).toEqual([["parser", "SQLPG300"]]);
400
+ expect(rules(TSC, C1, P2, { dependents: ["index app.docs_idx"] })).toEqual([["parser", "SQLPG301"]]);
401
+ expect(steps(TSC, C1, P2).slice(0, 2)).toEqual(["DROP TEXT SEARCH CONFIGURATION app.c", `CREATE TEXT SEARCH CONFIGURATION app.c (PARSER = app.my_parser)`]);
402
+ expect(dropStatement("textSearchConfiguration", "app", "c").sql).toBe("DROP TEXT SEARCH CONFIGURATION app.c");
403
+ });
404
+
405
+ test("an operator class is dropped and created: ALTER OPERATOR CLASS renames and moves it only", () => {
406
+ const K1 = "CREATE OPERATOR CLASS app.c FOR TYPE integer USING hash AS OPERATOR 1 =, FUNCTION 1 hashint4(integer)";
407
+ // The server prints every operand type, the operator's schema and the family when it is not the class's own.
408
+ const printed = "CREATE OPERATOR CLASS app.c FOR TYPE integer USING hash AS\n OPERATOR 1 pg_catalog.= (integer, integer),\n FUNCTION 1 (integer, integer) hashint4(integer)";
409
+ expect(rules(OPC, printed, K1)).toEqual([]);
410
+ expect(rules(OPC, printed, K1.replace("CLASS app.c", "CLASS app.c DEFAULT"))).toEqual([["default", "SQLPG300"]]);
411
+ expect(rules(OPC, printed, K1.replace("AS OPERATOR 1 =", "FAMILY app.f AS OPERATOR 1 ="))).toEqual([["family", "SQLPG300"]]);
412
+ expect(rules(OPC, printed, K1.replace("hashint4(integer)", "hashint4extended(integer, bigint)"))).toEqual([["items", "SQLPG300"]]);
413
+ expect(rules(OPC, printed, K1.replace("hashint4(integer)", "hashint4extended(integer, bigint)"), { dependents: ["index app.docs_idx"] })).toEqual([["items", "SQLPG301"]]);
414
+ const recreate = steps(OPC, printed, K1.replace("CLASS app.c", "CLASS app.c DEFAULT"));
415
+ expect(recreate[0]).toBe("DROP OPERATOR CLASS app.c USING hash");
416
+ expect(recreate[1]).toContain("CREATE OPERATOR CLASS app.c DEFAULT FOR TYPE");
417
+ expect(recreate[2]).toMatch(/^COMMENT ON OPERATOR CLASS app\.c USING hash IS /);
418
+ expect(dropStatement("operatorClass", "app", "c", " USING hash").sql).toBe("DROP OPERATOR CLASS app.c USING hash");
419
+ });
420
+
421
+ test("an operator family has its name, method and comment", () => {
422
+ expect(rules(OPF, "CREATE OPERATOR FAMILY app.f USING btree", "CREATE OPERATOR FAMILY app.f USING btree")).toEqual([]);
423
+ expect(steps(OPF, "CREATE OPERATOR FAMILY app.f USING btree", "CREATE OPERATOR FAMILY app.f USING btree; COMMENT ON OPERATOR FAMILY app.f USING btree IS 'x'")).toEqual([expect.stringMatching(/^COMMENT ON OPERATOR FAMILY app\.f USING btree IS 'x/)]);
424
+ expect(dropStatement("operatorFamily", "app", "f", " USING btree").sql).toBe("DROP OPERATOR FAMILY app.f USING btree");
425
+ // The same name for another method is another family, not a change.
426
+ const [a] = declared(OPF, "CREATE OPERATOR FAMILY app.f USING btree");
427
+ const [b] = declared(OPF, "CREATE OPERATOR FAMILY app.f USING hash");
428
+ expect(a!.key).not.toBe(b!.key);
429
+ });
430
+
431
+ test("a foreign table is altered in place, except for its server", () => {
432
+ const F1 = "CREATE FOREIGN TABLE app.r (id integer NOT NULL, status text, old text) SERVER s OPTIONS (table_name 'orders')";
433
+ // The server prints the default cast and the doubled parentheses; a column is its type, options and NOT NULL.
434
+ expect(rules(FTB, "CREATE FOREIGN TABLE app.r (\n id integer NOT NULL,\n status text DEFAULT 'new'::text,\n CONSTRAINT k CHECK ((id > 0))\n) SERVER s", "CREATE FOREIGN TABLE app.r (id int NOT NULL, status text DEFAULT 'new', CHECK (id > 0)) SERVER s")).toEqual([]);
435
+ const F2 =
436
+ "CREATE FOREIGN TABLE app.r (id integer, status character varying(10) DEFAULT 'new' CHECK (status <> ''), total numeric OPTIONS (column_name 't')) SERVER s OPTIONS (table_name 'orders2', schema_name 'x')";
437
+ expect(rules(FTB, F1, F2)).toEqual([
438
+ ["columns.old", "SQLPG306"],
439
+ ["columns.id.notNull", "SQLPG306"],
440
+ ["columns.status.type", "SQLPG306"],
441
+ ["columns.status.default", "SQLPG306"],
442
+ ["columns.total", "SQLPG306"],
443
+ ["constraints.check", "SQLPG306"],
444
+ ["options", "SQLPG306"],
445
+ ]);
446
+ expect(steps(FTB, F1, F2)).toEqual([
447
+ "ALTER FOREIGN TABLE app.r DROP COLUMN old",
448
+ "ALTER FOREIGN TABLE app.r ALTER COLUMN id DROP NOT NULL",
449
+ "ALTER FOREIGN TABLE app.r ALTER COLUMN status TYPE character varying(10)",
450
+ "ALTER FOREIGN TABLE app.r ALTER COLUMN status SET DEFAULT 'new'",
451
+ "ALTER FOREIGN TABLE app.r ADD COLUMN total numeric",
452
+ "ALTER FOREIGN TABLE app.r ALTER COLUMN total OPTIONS (ADD column_name 't')",
453
+ "ALTER FOREIGN TABLE app.r ADD CHECK (status <> '')",
454
+ "ALTER FOREIGN TABLE app.r OPTIONS (ADD schema_name 'x', SET table_name 'orders2')",
455
+ ]);
456
+ // A column option, a dropped constraint and a dropped default.
457
+ const G1 = "CREATE FOREIGN TABLE app.r (id integer OPTIONS (column_name 'a') DEFAULT 1, CONSTRAINT pos CHECK (id > 0)) SERVER s";
458
+ const G2 = "CREATE FOREIGN TABLE app.r (id integer OPTIONS (column_name 'b')) SERVER s";
459
+ expect(steps(FTB, G1, G2)).toEqual([
460
+ "ALTER FOREIGN TABLE app.r ALTER COLUMN id SET DEFAULT 1".replace("SET DEFAULT 1", "DROP DEFAULT"),
461
+ "ALTER FOREIGN TABLE app.r ALTER COLUMN id OPTIONS (SET column_name 'b')",
462
+ "ALTER FOREIGN TABLE app.r DROP CONSTRAINT pos",
463
+ ]);
464
+ // ALTER FOREIGN TABLE has no SERVER action.
465
+ const S2 = F1.replace(" SERVER s ", " SERVER s2 ");
466
+ expect(rules(FTB, F1, S2)).toEqual([["server", "SQLPG300"]]);
467
+ expect(rules(FTB, F1, S2, { dependents: ["view app.v"] })).toEqual([["server", "SQLPG301"]]);
468
+ expect(steps(FTB, F1, S2).slice(0, 2)).toEqual(["DROP FOREIGN TABLE app.r", S2]);
469
+ expect(dropStatement("foreignTable", "app", "r").sql).toBe("DROP FOREIGN TABLE app.r");
470
+ // A column's comment and the table's go through COMMENT ON.
471
+ expect(steps(FTB, F1, `${F1}; COMMENT ON COLUMN app.r.status IS 'State'`)).toEqual(["COMMENT ON COLUMN app.r.status IS 'State'"]);
472
+ });
473
+
220
474
  test("a comment changes in place", () => {
221
475
  expect(steps(SRV, "CREATE SERVER s FOREIGN DATA WRAPPER w", "CREATE SERVER s FOREIGN DATA WRAPPER w; COMMENT ON SERVER s IS 'x'")).toEqual([expect.stringMatching(/^COMMENT ON SERVER s IS 'x/)]);
222
476
  expect(steps(PUB, "CREATE PUBLICATION p", "CREATE PUBLICATION p; COMMENT ON PUBLICATION p IS 'x'")).toEqual([expect.stringMatching(/^COMMENT ON PUBLICATION p IS 'x/)]);
@@ -226,7 +480,22 @@ describe("the classifier and the statements", () => {
226
480
  // ── Reading the catalog ────────────────────────────────────────────────
227
481
 
228
482
  /** Answers the database-kind reader's queries from rows, by the catalog each reads. */
229
- function catalogClient(rows: { stats?: unknown[]; wrappers?: unknown[]; servers?: unknown[]; pubs?: unknown[]; pubTables?: unknown[]; pubSchemas?: unknown[]; version15?: boolean }): PostgresClient {
483
+ function catalogClient(rows: {
484
+ stats?: unknown[];
485
+ wrappers?: unknown[];
486
+ servers?: unknown[];
487
+ pubs?: unknown[];
488
+ pubTables?: unknown[];
489
+ pubSchemas?: unknown[];
490
+ version15?: boolean;
491
+ classes?: unknown[];
492
+ families?: unknown[];
493
+ dictionaries?: unknown[];
494
+ configurations?: unknown[];
495
+ foreignTables?: unknown[];
496
+ foreignColumns?: unknown[];
497
+ foreignChecks?: unknown[];
498
+ }): PostgresClient {
230
499
  return {
231
500
  async query<T>(sql: string): Promise<T[]> {
232
501
  const r = (x: unknown[] | undefined) => (x ?? []) as T[];
@@ -236,6 +505,13 @@ function catalogClient(rows: { stats?: unknown[]; wrappers?: unknown[]; servers?
236
505
  if (sql.includes("FROM pg_catalog.pg_publication p")) return r(rows.pubs);
237
506
  if (sql.includes("FROM pg_catalog.pg_publication_rel r")) return r(rows.pubTables);
238
507
  if (sql.includes("FROM pg_catalog.pg_publication_namespace pn")) return r(rows.pubSchemas);
508
+ if (sql.includes("FROM pg_catalog.pg_opfamily f JOIN")) return r(rows.families);
509
+ if (sql.includes("FROM pg_catalog.pg_opclass c JOIN pg_catalog.pg_namespace n ON n.oid = c.opcnamespace")) return r(rows.classes);
510
+ if (sql.includes("FROM pg_catalog.pg_ts_config c JOIN pg_catalog.pg_namespace n ON n.oid = c.cfgnamespace")) return r(rows.configurations);
511
+ if (sql.includes("FROM pg_catalog.pg_ts_dict d JOIN pg_catalog.pg_namespace n ON n.oid = d.dictnamespace")) return r(rows.dictionaries);
512
+ if (sql.includes("JOIN pg_catalog.pg_foreign_table ft ON ft.ftrelid = c.oid")) return r(rows.foreignTables);
513
+ if (sql.includes("FROM pg_catalog.pg_attribute AS a JOIN pg_catalog.pg_type t ON t.oid = a.atttypid\n LEFT JOIN")) return r(rows.foreignColumns);
514
+ if (sql.includes("FROM pg_catalog.pg_constraint k")) return r(rows.foreignChecks);
239
515
  // The 15 features: the column list, and schema members.
240
516
  if (sql.includes("attname = 'prattrs'") || sql.includes("to_regclass('pg_catalog.pg_publication_namespace')")) return rows.version15 ? r([{ x: 1 }]) : [];
241
517
  return [];
@@ -296,6 +572,127 @@ describe("reading the catalog", () => {
296
572
  for (const o of ops) expect(() => liveProps(o)).not.toThrow();
297
573
  });
298
574
 
575
+ test("operator classes and families: a class carries its members, its own family is its own", async () => {
576
+ const objects = await readDatabaseKinds(
577
+ catalogClient({
578
+ classes: [
579
+ {
580
+ oid: "10", schema: "app", name: "int_hash_ops", method: "hash", isdefault: false, type: "integer", storage: null, family: null,
581
+ operators: ["OPERATOR 1 pg_catalog.= (integer, integer)"], functions: ["FUNCTION 1 (integer, integer) hashint4(integer)"], comment: "Hash by value", dependents: ["index app.users_idx"],
582
+ },
583
+ {
584
+ oid: "11", schema: "app", name: "box_ops", method: "gist", isdefault: true, type: "box", storage: "box", family: 'app."My Family"',
585
+ operators: ["OPERATOR 1 pg_catalog.<<(box, box)".replace("<<(box", "<< (box")], functions: [], comment: null, dependents: [],
586
+ },
587
+ { oid: "12", schema: "app", name: "empty_ops", method: "hash", isdefault: false, type: "integer", storage: null, family: null, operators: [], functions: [], comment: null, dependents: [] },
588
+ ],
589
+ families: [
590
+ { oid: "20", schema: "app", name: "spare_ops", method: "btree", loose: 2, comment: "Spare", dependents: [] },
591
+ { oid: "21", schema: "app", name: "plain_ops", method: "btree", loose: 0, comment: null, dependents: [] },
592
+ ],
593
+ }),
594
+ {},
595
+ );
596
+ const [hash, box, empty, spare, plain] = objects;
597
+ expect(objects.map((o) => [o.type, o.name, o.signature])).toEqual([
598
+ [OPC, "int_hash_ops", " USING hash"],
599
+ [OPC, "box_ops", " USING gist"],
600
+ [OPC, "empty_ops", " USING hash"],
601
+ [OPF, "spare_ops", " USING btree"],
602
+ [OPF, "plain_ops", " USING btree"],
603
+ ]);
604
+ expect(hash!.statement).toBe(
605
+ "CREATE OPERATOR CLASS app.int_hash_ops FOR TYPE integer USING hash AS\n OPERATOR 1 pg_catalog.= (integer, integer),\n FUNCTION 1 (integer, integer) hashint4(integer);\nCOMMENT ON OPERATOR CLASS app.int_hash_ops USING hash IS 'Hash by value'",
606
+ );
607
+ expect(hash!.dependents).toEqual(["index app.users_idx"]);
608
+ expect(box!.statement).toBe('CREATE OPERATOR CLASS app.box_ops DEFAULT FOR TYPE box USING gist FAMILY app."My Family" AS\n OPERATOR 1 pg_catalog.<< (box, box),\n STORAGE box');
609
+ // A class whose members could not be read is not declared half.
610
+ expect(empty!.unsupported).toMatch(/at least one/);
611
+ expect(spare!.statement).toBe("CREATE OPERATOR FAMILY app.spare_ops USING btree;\nCOMMENT ON OPERATOR FAMILY app.spare_ops USING btree IS 'Spare'");
612
+ expect(spare!.warnings).toEqual(["2 loose members added with ALTER OPERATOR FAMILY ... ADD are left out; a declaration holds the family and its classes' members"]);
613
+ expect(plain!.warnings).toBeUndefined();
614
+ for (const o of [hash!, box!, spare!, plain!]) expect(() => liveProps(o)).not.toThrow();
615
+ expect(liveProps(box!)).toMatchObject({ default: true, family: 'app."My Family"', items: [{ kind: "operator" }, { kind: "storage", type: "box" }] });
616
+ });
617
+
618
+ test("text search: a dictionary's options as the server writes them, a configuration's mappings grouped by dictionaries", async () => {
619
+ const objects = await readDatabaseKinds(
620
+ catalogClient({
621
+ dictionaries: [
622
+ { oid: "30", schema: "app", name: "shop_stop", template: "simple", init: "stopwords = 'english', accept = 'false'", comment: "It's stop words", dependents: ["text search configuration app.shop"] },
623
+ { oid: "31", schema: "app", name: "bare", template: "app.my_template", init: null, comment: null, dependents: [] },
624
+ { oid: "32", schema: "app", name: "odd", template: "simple", init: "this is not a list", comment: null, dependents: [] },
625
+ ],
626
+ configurations: [
627
+ {
628
+ oid: "40", schema: "app", name: "shop", parser: '"default"', comment: "Shop search", dependents: ["index app.docs_idx"],
629
+ mappings: ["asciiword\tapp.shop_stop, english_stem", "word\tapp.shop_stop, english_stem", "int\tsimple"],
630
+ },
631
+ { oid: "41", schema: "app", name: "nothing", parser: '"default"', comment: null, mappings: [], dependents: [] },
632
+ ],
633
+ }),
634
+ {},
635
+ );
636
+ const [stop, bare, odd, shop, nothing] = objects;
637
+ expect(objects.map((o) => [o.type, o.name])).toEqual([[TSD, "shop_stop"], [TSD, "bare"], [TSD, "odd"], [TSC, "shop"], [TSC, "nothing"]]);
638
+ expect(stop!.statement).toBe("CREATE TEXT SEARCH DICTIONARY app.shop_stop (TEMPLATE = simple, stopwords = 'english', accept = 'false');\nCOMMENT ON TEXT SEARCH DICTIONARY app.shop_stop IS 'It''s stop words'");
639
+ expect(stop!.dependents).toEqual(["text search configuration app.shop"]);
640
+ expect(bare!.statement).toBe("CREATE TEXT SEARCH DICTIONARY app.bare (TEMPLATE = app.my_template)");
641
+ expect(odd!.unsupported).toMatch(/not in the form the server writes/);
642
+ expect(shop!.statement).toBe(
643
+ [
644
+ 'CREATE TEXT SEARCH CONFIGURATION app.shop (PARSER = "default")',
645
+ "ALTER TEXT SEARCH CONFIGURATION app.shop ADD MAPPING FOR asciiword, word WITH app.shop_stop, english_stem",
646
+ 'ALTER TEXT SEARCH CONFIGURATION app.shop ADD MAPPING FOR "int" WITH simple',
647
+ "COMMENT ON TEXT SEARCH CONFIGURATION app.shop IS 'Shop search'",
648
+ ].join(";\n"),
649
+ );
650
+ expect(nothing!.statement).toBe('CREATE TEXT SEARCH CONFIGURATION app.nothing (PARSER = "default")');
651
+ for (const o of [stop!, bare!, shop!, nothing!]) expect(() => liveProps(o)).not.toThrow();
652
+ expect(liveProps(shop!).mappings).toEqual([{ tokens: ["asciiword", "word"], dictionaries: ["app.shop_stop", "english_stem"] }, { tokens: ["int"], dictionaries: ["simple"] }]);
653
+ expect(parseDictionaryOptions("Language = 'it''s', \"Odd Name\" = 'a\\\\b'")).toEqual([["Language", "it's"], ["Odd Name", "a\\b"]]);
654
+ expect(parseDictionaryOptions(undefined)).toEqual([]);
655
+ expect(parseDictionaryOptions("language")).toBeUndefined();
656
+ });
657
+
658
+ test("foreign tables: columns with their options, checks, server and options; a credential is left out; a partition, an inheriting table and a generated column are not declarable", async () => {
659
+ const objects = await readDatabaseKinds(
660
+ catalogClient({
661
+ foreignTables: [
662
+ { oid: "50", schema: "app", name: "remote_orders", ispartition: false, inherits: false, server: "app_replica", options: ["table_name=orders", "password=hunter2"], comment: "Orders", dependents: ["view app.order_view"] },
663
+ { oid: "51", schema: "app", name: "remote_part", ispartition: true, inherits: false, server: "app_replica", options: null, comment: null, dependents: [] },
664
+ { oid: "52", schema: "app", name: "remote_child", ispartition: false, inherits: true, server: "app_replica", options: null, comment: null, dependents: [] },
665
+ { oid: "53", schema: "app", name: "remote_gen", ispartition: false, inherits: false, server: "app_replica", options: null, comment: null, dependents: [] },
666
+ ],
667
+ foreignColumns: [
668
+ { rel: "50", name: "id", type: "integer", notnull: true, generated: "", dflt: null, collation: null, options: ["column_name=order_id", "sslpassword=x"], comment: "The key" },
669
+ { rel: "50", name: "status", type: "text", notnull: false, generated: "", dflt: "'placed'::text", collation: '"C"', options: null, comment: null },
670
+ { rel: "51", name: "id", type: "integer", notnull: false, generated: "", dflt: null, collation: null, options: null, comment: null },
671
+ { rel: "52", name: "id", type: "integer", notnull: false, generated: "", dflt: null, collation: null, options: null, comment: null },
672
+ { rel: "53", name: "id", type: "integer", notnull: false, generated: "s", dflt: "1", collation: null, options: null, comment: null },
673
+ ],
674
+ foreignChecks: [{ rel: "50", name: "status_known", def: "CHECK ((status IS NOT NULL))", comment: "Always set" }],
675
+ }),
676
+ {},
677
+ );
678
+ expect(objects.map((o) => [o.type, o.name])).toEqual([[FTB, "remote_orders"], [FTB, "remote_part"], [FTB, "remote_child"], [FTB, "remote_gen"]]);
679
+ const [orders, part, child, gen] = objects;
680
+ expect(orders!.statement).toBe(
681
+ [
682
+ "CREATE FOREIGN TABLE app.remote_orders (\n id integer OPTIONS (column_name 'order_id') NOT NULL,\n status text COLLATE \"C\" DEFAULT 'placed'::text,\n CONSTRAINT status_known CHECK ((status IS NOT NULL))\n) SERVER app_replica OPTIONS (table_name 'orders')",
683
+ "COMMENT ON FOREIGN TABLE app.remote_orders IS 'Orders'",
684
+ "COMMENT ON COLUMN app.remote_orders.id IS 'The key'",
685
+ "COMMENT ON CONSTRAINT status_known ON app.remote_orders IS 'Always set'",
686
+ ].join(";\n"),
687
+ );
688
+ expect(orders!.warnings).toEqual(["the options sslpassword, password hold a credential and are left out; the environment supplies it"]);
689
+ expect(orders!.dependents).toEqual(["view app.order_view"]);
690
+ expect(part!.unsupported).toMatch(/partition/);
691
+ expect(child!.unsupported).toMatch(/inherits/);
692
+ expect(gen!.unsupported).toMatch(/generated column/);
693
+ expect(liveProps(orders!)).toMatchObject({ serverName: "app_replica", options: { table_name: "orders" }, columns: [{ name: "id", options: { column_name: "order_id" } }, { name: "status", collate: '"C"' }], checks: [{ name: "status_known" }] });
694
+ });
695
+
299
696
  test("a publication of every table, on a server before 15", async () => {
300
697
  const [p] = await readDatabaseKinds(catalogClient({ pubs: [{ oid: "4", name: "all_of_it", alltables: true, pubinsert: true, pubupdate: true, pubdelete: true, pubtruncate: true, def: { pubviaroot: false }, comment: null }] }), {});
301
698
  expect(p!.statement).toBe("CREATE PUBLICATION all_of_it FOR ALL TABLES");
@@ -323,6 +720,30 @@ const SERVER_PRINTS: Array<{ type: string; schema?: string; name: string; signat
323
720
  { type: FDW, name: "app_fdw", statement: "CREATE FOREIGN DATA WRAPPER app_fdw OPTIONS (debug 'true');\nCOMMENT ON FOREIGN DATA WRAPPER app_fdw IS 'Remote'" },
324
721
  { type: SRV, name: "app_remote", statement: "CREATE SERVER app_remote TYPE 'pg' VERSION '16' FOREIGN DATA WRAPPER app_fdw OPTIONS (host 'db.internal', port '5432')" },
325
722
  { type: PUB, name: "app_pub", statement: "CREATE PUBLICATION app_pub FOR TABLE app.orders (id, status) WHERE (id > 0), TABLES IN SCHEMA audit WITH (publish = 'insert, update')" },
723
+ {
724
+ type: FTB,
725
+ schema: "app",
726
+ name: "remote_orders",
727
+ statement:
728
+ "CREATE FOREIGN TABLE app.remote_orders (\n id integer OPTIONS (column_name 'order_id') NOT NULL,\n status text DEFAULT 'placed'::text,\n CONSTRAINT status_known CHECK ((status IS NOT NULL))\n) SERVER app_remote OPTIONS (table_name 'orders');\nCOMMENT ON FOREIGN TABLE app.remote_orders IS 'Orders on the replica'",
729
+ },
730
+ {
731
+ type: OPC,
732
+ schema: "app",
733
+ name: "int_hash_ops",
734
+ signature: " USING hash",
735
+ statement:
736
+ "CREATE OPERATOR CLASS app.int_hash_ops FOR TYPE integer USING hash AS\n OPERATOR 1 pg_catalog.= (integer, integer),\n FUNCTION 1 (integer, integer) hashint4(integer);\nCOMMENT ON OPERATOR CLASS app.int_hash_ops USING hash IS 'Hash by value'",
737
+ },
738
+ { type: OPF, schema: "app", name: "spare_ops", signature: " USING btree", statement: "CREATE OPERATOR FAMILY app.spare_ops USING btree" },
739
+ { type: TSD, schema: "app", name: "shop_stop", statement: "CREATE TEXT SEARCH DICTIONARY app.shop_stop (TEMPLATE = simple, stopwords = 'english')" },
740
+ {
741
+ type: TSC,
742
+ schema: "app",
743
+ name: "shop",
744
+ statement:
745
+ 'CREATE TEXT SEARCH CONFIGURATION app.shop (PARSER = "default");\nALTER TEXT SEARCH CONFIGURATION app.shop ADD MAPPING FOR asciiword, word WITH app.shop_stop, english_stem;\nALTER TEXT SEARCH CONFIGURATION app.shop ADD MAPPING FOR "int" WITH simple;\nCOMMENT ON TEXT SEARCH CONFIGURATION app.shop IS \'Shop search\'',
746
+ },
326
747
  ];
327
748
 
328
749
  describe("declare, build, import, plan with no change", () => {
@@ -333,6 +754,8 @@ describe("declare, build, import, plan with no change", () => {
333
754
  const audit = schema`CREATE SCHEMA audit`;
334
755
  const fdw = foreignDataWrapper`CREATE FOREIGN DATA WRAPPER app_fdw OPTIONS (debug 'true'); COMMENT ON FOREIGN DATA WRAPPER app_fdw IS 'Remote'`;
335
756
  const pairEq = func`CREATE FUNCTION ${app}.pair_eq(a integer, b integer) RETURNS boolean LANGUAGE sql AS 'SELECT a = b'`;
757
+ const remote = foreignServer`CREATE SERVER app_remote TYPE 'pg' VERSION '16' FOREIGN DATA WRAPPER ${fdw} OPTIONS (port '5432', host 'db.internal')`;
758
+ const shopStop = textSearchDictionary`CREATE TEXT SEARCH DICTIONARY ${app}.shop_stop (TEMPLATE = simple, StopWords = english)`;
336
759
  const objects = [
337
760
  app,
338
761
  audit,
@@ -341,8 +764,16 @@ describe("declare, build, import, plan with no change", () => {
341
764
  operator`CREATE OPERATOR ${app}.=== (LEFTARG = int, RIGHTARG = int, FUNCTION = ${pairEq}, HASHES, COMMUTATOR = ===); COMMENT ON OPERATOR ${app}.===(int, int) IS 'Same pair'`,
342
765
  statistics`CREATE STATISTICS ${app}.orders_stats (dependencies) ON status, id FROM ${orders}; COMMENT ON STATISTICS ${app}.orders_stats IS 'Correlated'`,
343
766
  fdw,
344
- foreignServer`CREATE SERVER app_remote TYPE 'pg' VERSION '16' FOREIGN DATA WRAPPER ${fdw} OPTIONS (port '5432', host 'db.internal')`,
767
+ remote,
345
768
  publication`CREATE PUBLICATION app_pub FOR TABLE ${orders} (status, id) WHERE (id > 0), TABLES IN SCHEMA ${audit} WITH (publish = 'update, insert')`,
769
+ foreignTable`CREATE FOREIGN TABLE ${app}.remote_orders (id int OPTIONS (column_name 'order_id') NOT NULL, status text DEFAULT 'placed', CONSTRAINT status_known CHECK (status IS NOT NULL)) SERVER ${remote} OPTIONS (table_name 'orders'); COMMENT ON FOREIGN TABLE ${app}.remote_orders IS 'Orders on the replica'`,
770
+ operatorClass`CREATE OPERATOR CLASS ${app}.int_hash_ops FOR TYPE int USING hash AS OPERATOR 1 =, FUNCTION 1 hashint4(int); COMMENT ON OPERATOR CLASS ${app}.int_hash_ops USING hash IS 'Hash by value'`,
771
+ operatorFamily`CREATE OPERATOR FAMILY ${app}.spare_ops USING btree`,
772
+ shopStop,
773
+ textSearchConfiguration`CREATE TEXT SEARCH CONFIGURATION ${app}.shop (PARSER = pg_catalog.default);
774
+ ALTER TEXT SEARCH CONFIGURATION ${app}.shop ADD MAPPING FOR word, asciiword WITH ${shopStop}, english_stem;
775
+ ALTER TEXT SEARCH CONFIGURATION ${app}.shop ADD MAPPING FOR int WITH simple;
776
+ COMMENT ON TEXT SEARCH CONFIGURATION ${app}.shop IS 'Shop search'`,
346
777
  ];
347
778
  // The build: each entity's DDL, rendered, as the build output holds it.
348
779
  const build = JSON.stringify({ dialect: "postgres", objects: objects.map((o, i) => ({ export: `o${i}`, type: o.entityType, ddl: (o.props as { ddl: string }).ddl, dependsOn: [] })) });
@@ -354,7 +785,9 @@ describe("declare, build, import, plan with no change", () => {
354
785
  const ir = objectsToIR(SERVER_PRINTS.map((o) => ({ type: o.type, ...(o.schema ? { schema: o.schema } : {}), name: o.name, ddl: o.statement }) as ImportedPgObject));
355
786
  const [file] = new PostgresGenerator().generate(ir);
356
787
  const content = file!.content;
357
- expect(content).toContain('import { foreignDataWrapper, foreignServer, func, operator, publication, schema, statistics, table } from "@intentius/chant-lexicon-sql/postgres";');
788
+ expect(content).toContain(
789
+ 'import { foreignDataWrapper, foreignServer, foreignTable, func, operator, operatorClass, operatorFamily, publication, schema, statistics, table, textSearchConfiguration, textSearchDictionary } from "@intentius/chant-lexicon-sql/postgres";',
790
+ );
358
791
  expect(content).toContain("export const ordersStats = statistics`");
359
792
  expect(content).toContain("export const pairEq = func`");
360
793
  expect(content).toContain("CREATE OPERATOR ${appSchema}.=== (FUNCTION = ${pairEq}, LEFTARG = integer");
@@ -364,18 +797,66 @@ describe("declare, build, import, plan with no change", () => {
364
797
  expect(content).toContain("export const appPub = publication`");
365
798
  expect(content).toContain("TABLES IN SCHEMA ${auditSchema} WITH (publish = 'insert, update')");
366
799
  expect(content).toContain("FOR TABLE ${orders} (id, status) WHERE (id > 0)");
800
+ // #4406: a foreign table names its server, a configuration its dictionary, by export.
801
+ expect(content).toContain("export const remoteOrders = foreignTable`");
802
+ expect(content).toContain(") SERVER ${appRemote} OPTIONS (table_name 'orders')");
803
+ expect(content).toContain("export const intHashOps = operatorClass`");
804
+ expect(content).toContain("export const spareOps = operatorFamily`");
805
+ expect(content).toContain("export const shopStop = textSearchDictionary`");
806
+ expect(content).toContain("ADD MAPPING FOR asciiword, word WITH ${shopStop}, english_stem");
807
+ expect(content).toContain("CREATE TEXT SEARCH CONFIGURATION ${appSchema}.shop (PARSER");
367
808
 
368
809
  // The imported statements, in the generator's order, as a build: no change against the objects they came from.
369
810
  const items = ir.resources.map((r) => ({ exportName: r.logicalId, type: r.type, ...(r.properties.schema ? { schema: String(r.properties.schema) } : {}), name: String(r.properties.name), ddl: String(r.properties.ddl) }));
370
811
  const { order } = postgresTemplates(items);
371
812
  expect(order.map((o) => o.exportName).indexOf("appFdw")).toBeLessThan(order.map((o) => o.exportName).indexOf("appRemote"));
372
813
  expect(order.map((o) => o.exportName).indexOf("auditSchema")).toBeLessThan(order.map((o) => o.exportName).indexOf("appPub"));
814
+ expect(order.map((o) => o.exportName).indexOf("appRemote")).toBeLessThan(order.map((o) => o.exportName).indexOf("remoteOrders"));
815
+ expect(order.map((o) => o.exportName).indexOf("shopStop")).toBeLessThan(order.map((o) => o.exportName).indexOf("shop"));
373
816
  const build = JSON.stringify({ dialect: "postgres", objects: order.map((it) => ({ export: it.exportName, type: it.type, ddl: it.ddl, dependsOn: [] })) });
374
817
  const declaredSchema = keyedByQualifiedName(declaredObjects(build).map((d) => ({ key: d.exportName, canonical: d.canonical })));
375
818
  expect(diffPgSchemas(pgSchemaFromLive(liveObjects(), "public"), declaredSchema).changes).toEqual([]);
376
819
  });
377
820
  });
378
821
 
822
+ describe("the build output", () => {
823
+ test("each new kind is written under its entity type, with its own props beside it", () => {
824
+ const orders = foreignTable`CREATE FOREIGN TABLE app.remote_orders (id int) SERVER replica`;
825
+ const ops = operatorClass`CREATE OPERATOR CLASS app.int_hash_ops FOR TYPE integer USING hash AS OPERATOR 1 =, FUNCTION 1 hashint4(integer)`;
826
+ const family = operatorFamily`CREATE OPERATOR FAMILY app.spare_ops USING btree`;
827
+ const dict = textSearchDictionary`CREATE TEXT SEARCH DICTIONARY app.d (TEMPLATE = simple)`;
828
+ const cfg = textSearchConfiguration`CREATE TEXT SEARCH CONFIGURATION app.c (PARSER = pg_catalog.default); ALTER TEXT SEARCH CONFIGURATION app.c ADD MAPPING FOR word WITH ${dict}`;
829
+ const out = sqlSerializer.serialize(new Map(Object.entries({ orders, ops, family, dict, cfg })) as never, undefined, {}) as SerializerResult;
830
+ const doc = JSON.parse(out.primary) as { objects: Array<Record<string, unknown>> };
831
+ const byExport = Object.fromEntries(doc.objects.map((o) => [o.export, o]));
832
+ expect(byExport.orders).toMatchObject({ type: FTB, sqlName: "app.remote_orders", serverName: "replica" });
833
+ expect(byExport.ops).toMatchObject({ type: OPC, method: "hash", dataType: "integer" });
834
+ expect(byExport.family).toMatchObject({ type: OPF, method: "btree" });
835
+ expect(byExport.dict).toMatchObject({ type: TSD, template: "simple" });
836
+ expect(byExport.cfg).toMatchObject({ type: TSC, parser: "pg_catalog.default", dependsOn: ["dict"] });
837
+ // And the applier reads the objects back from that output.
838
+ expect(declaredObjects(out.primary).map((d) => [d.exportName, d.canonical.kind, d.canonical.signature]).sort((a, b) => String(a[0]).localeCompare(String(b[0])))).toEqual([
839
+ ["cfg", "textSearchConfiguration", undefined],
840
+ ["dict", "textSearchDictionary", undefined],
841
+ ["family", "operatorFamily", " USING btree"],
842
+ ["ops", "operatorClass", " USING hash"],
843
+ ["orders", "foreignTable", undefined],
844
+ ]);
845
+ });
846
+ });
847
+
848
+ describe("the coverage table", () => {
849
+ test("a foreign table, an operator class and family and the two text search kinds are declared; the parser and template stay refused", () => {
850
+ expect(kindRow("postgres", "foreign table")).toMatchObject({ status: "declared", entity: FTB });
851
+ expect(kindRow("postgres", "operator class")).toMatchObject({ status: "declared", entity: OPC });
852
+ expect(kindRow("postgres", "operator family")).toMatchObject({ status: "declared", entity: OPF });
853
+ expect(kindRow("postgres", "text search configuration")).toMatchObject({ status: "declared", entity: TSC });
854
+ expect(kindRow("postgres", "text search dictionary")).toMatchObject({ status: "declared", entity: TSD });
855
+ expect(kindRow("postgres", "text search parser")).toMatchObject({ status: "refused", reason: expect.stringContaining("C code") });
856
+ expect(kindRow("postgres", "text search template")).toMatchObject({ status: "refused", reason: expect.stringContaining("C code") });
857
+ });
858
+ });
859
+
379
860
  describe("the kinds that stay refused", () => {
380
861
  test("a user mapping and a subscription say why: they carry credentials", () => {
381
862
  expect(kindRow("postgres", "user mapping")).toMatchObject({ status: "refused", reason: expect.stringContaining("password") });