@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.
- package/dist/clickhouse/plan/statements.d.ts +29 -0
- package/dist/clickhouse/plan/statements.d.ts.map +1 -0
- package/dist/clickhouse/plan/statements.js +334 -0
- package/dist/codegen/classifier-page.d.ts.map +1 -1
- package/dist/codegen/classifier-page.js +23 -0
- package/dist/codegen/postgres-classifier-page.js +2 -2
- package/dist/codegen/registry.d.ts.map +1 -1
- package/dist/codegen/registry.js +5 -0
- package/dist/core/statement-class.d.ts +55 -0
- package/dist/core/statement-class.d.ts.map +1 -0
- package/dist/core/statement-class.js +43 -0
- package/dist/coverage/postgres.json +6 -6
- package/dist/drizzle.d.ts.map +1 -1
- package/dist/drizzle.js +5 -0
- package/dist/files/postgres.d.ts.map +1 -1
- package/dist/files/postgres.js +23 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/integrity.json +5 -5
- package/dist/lint/post-synth/postgres-names.d.ts.map +1 -1
- package/dist/lint/post-synth/postgres-names.js +3 -0
- package/dist/lint/rules/postgres-templates.js +2 -2
- package/dist/lsp/postgres-template.d.ts.map +1 -1
- package/dist/lsp/postgres-template.js +10 -0
- package/dist/manifest.json +1 -1
- package/dist/mcp/postgres.js +1 -1
- package/dist/meta.json +25 -0
- package/dist/migration-classify.d.ts +23 -0
- package/dist/migration-classify.d.ts.map +1 -0
- package/dist/migration-classify.js +44 -0
- package/dist/okf/index.md +5 -0
- package/dist/okf/types/PostgresForeignTable.md +9 -0
- package/dist/okf/types/PostgresOperatorClass.md +9 -0
- package/dist/okf/types/PostgresOperatorFamily.md +9 -0
- package/dist/okf/types/PostgresTextSearchConfiguration.md +9 -0
- package/dist/okf/types/PostgresTextSearchDictionary.md +9 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +13 -0
- package/dist/postgres/access/acl.js +1 -1
- package/dist/postgres/access/import.js +1 -1
- package/dist/postgres/apply/apply.js +5 -0
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/dist/postgres/apply/statements.js +156 -2
- package/dist/postgres/entities.d.ts +120 -2
- package/dist/postgres/entities.d.ts.map +1 -1
- package/dist/postgres/entities.js +180 -5
- package/dist/postgres/entity-types.d.ts +5 -0
- package/dist/postgres/entity-types.d.ts.map +1 -1
- package/dist/postgres/entity-types.js +5 -0
- package/dist/postgres/import/generator.d.ts.map +1 -1
- package/dist/postgres/import/generator.js +16 -1
- package/dist/postgres/live/catalog-kinds.d.ts +6 -0
- package/dist/postgres/live/catalog-kinds.d.ts.map +1 -1
- package/dist/postgres/live/catalog-kinds.js +246 -0
- package/dist/postgres/live/describe-resources.d.ts.map +1 -1
- package/dist/postgres/live/describe-resources.js +2 -0
- package/dist/postgres/migrate/observe.d.ts.map +1 -1
- package/dist/postgres/migrate/observe.js +4 -1
- package/dist/postgres/migrate/replication.d.ts +42 -0
- package/dist/postgres/migrate/replication.d.ts.map +1 -1
- package/dist/postgres/migrate/replication.js +53 -0
- package/dist/postgres/parser.d.ts +73 -1
- package/dist/postgres/parser.d.ts.map +1 -1
- package/dist/postgres/parser.js +181 -7
- package/dist/postgres/plan/deep.d.ts.map +1 -1
- package/dist/postgres/plan/deep.js +15 -4
- package/dist/postgres/plan/diff.d.ts.map +1 -1
- package/dist/postgres/plan/diff.js +120 -5
- package/dist/postgres/plan/normalize.d.ts +12 -1
- package/dist/postgres/plan/normalize.d.ts.map +1 -1
- package/dist/postgres/plan/normalize.js +151 -0
- package/dist/postgres/plan/rules.d.ts +3 -0
- package/dist/postgres/plan/rules.d.ts.map +1 -1
- package/dist/postgres/plan/rules.js +5 -2
- package/dist/postgres/plan/schema.js +1 -1
- package/dist/postgres/plan/statements.d.ts +26 -0
- package/dist/postgres/plan/statements.d.ts.map +1 -0
- package/dist/postgres/plan/statements.js +364 -0
- package/dist/postgres/testing/roundtrip-schema.d.ts +3 -2
- package/dist/postgres/testing/roundtrip-schema.d.ts.map +1 -1
- package/dist/postgres/testing/roundtrip-schema.js +12 -1
- package/dist/postgres.d.ts +1 -1
- package/dist/postgres.d.ts.map +1 -1
- package/dist/postgres.js +1 -1
- package/dist/rules/postgres-names.ts +2 -0
- package/dist/rules/postgres-templates.ts +2 -2
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +5 -0
- package/package.json +2 -2
- package/src/clickhouse/plan/statements.test.ts +61 -0
- package/src/clickhouse/plan/statements.ts +306 -0
- package/src/codegen/classifier-page.ts +23 -0
- package/src/codegen/postgres-classifier-page.ts +2 -2
- package/src/codegen/registry.ts +5 -0
- package/src/core/statement-class.ts +88 -0
- package/src/coverage/postgres.json +6 -6
- package/src/drizzle.ts +5 -0
- package/src/files/postgres.ts +22 -1
- package/src/generated/lexicon-sql.json +25 -0
- package/src/index.ts +1 -1
- package/src/lint/post-synth/postgres-checks.test.ts +8 -1
- package/src/lint/post-synth/postgres-names.ts +2 -0
- package/src/lint/rules/postgres-templates.ts +2 -2
- package/src/lsp/postgres-template.ts +9 -0
- package/src/mcp/postgres.ts +1 -1
- package/src/migration-classify.test.ts +150 -0
- package/src/migration-classify.ts +45 -0
- package/src/op/watch-pipeline.test.ts +8 -1
- package/src/plugin.ts +14 -0
- package/src/postgres/access/acl.ts +1 -1
- package/src/postgres/access/import.ts +1 -1
- package/src/postgres/apply/apply.ts +5 -0
- package/src/postgres/apply/statements.ts +151 -3
- package/src/postgres/database-kinds.test.ts +498 -17
- package/src/postgres/entities.ts +276 -5
- package/src/postgres/entity-types.ts +5 -0
- package/src/postgres/import/generator.ts +13 -1
- package/src/postgres/live/catalog-kinds.ts +271 -0
- package/src/postgres/live/describe-resources.ts +2 -0
- package/src/postgres/migrate/observe.ts +3 -1
- package/src/postgres/migrate/replication.ts +66 -0
- package/src/postgres/migrate/subscriber.e2e.test.ts +141 -0
- package/src/postgres/migrate/subscriber.test.ts +59 -0
- package/src/postgres/parser.ts +225 -6
- package/src/postgres/plan/deep.ts +15 -4
- package/src/postgres/plan/diff.ts +107 -6
- package/src/postgres/plan/normalize.ts +155 -0
- package/src/postgres/plan/rules.ts +25 -4
- package/src/postgres/plan/schema.ts +1 -1
- package/src/postgres/plan/statements.test.ts +78 -0
- package/src/postgres/plan/statements.ts +299 -0
- package/src/postgres/testing/roundtrip-schema.ts +12 -1
- package/src/postgres.ts +17 -0
- package/src/skills/chant-sql-postgres-migration.md +1 -0
- package/src/skills/chant-sql-postgres.md +5 -0
- package/src/sql-files.test.ts +33 -0
- 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":
|
package/src/mcp/postgres.ts
CHANGED
|
@@ -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,
|