@intentius/chant-lexicon-sql 0.112.1 → 0.113.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/clickhouse/apply/apply.d.ts.map +1 -1
- package/dist/clickhouse/apply/statements.d.ts +77 -1
- package/dist/clickhouse/apply/statements.d.ts.map +1 -1
- package/dist/clickhouse/plan/commands.d.ts +6 -1
- package/dist/clickhouse/plan/commands.d.ts.map +1 -1
- package/dist/clickhouse/topology.d.ts +100 -0
- package/dist/clickhouse/topology.d.ts.map +1 -0
- package/dist/codegen/classifier-page.d.ts.map +1 -1
- package/dist/codegen/postgres-classifier-page.d.ts.map +1 -1
- package/dist/core/statements.d.ts +104 -0
- package/dist/core/statements.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/migration-statements.d.ts +55 -0
- package/dist/migration-statements.d.ts.map +1 -0
- package/dist/postgres/apply/apply.d.ts.map +1 -1
- package/dist/postgres/apply/statements.d.ts +91 -1
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/apply/apply.ts +25 -53
- package/src/clickhouse/apply/statements.ts +149 -31
- package/src/clickhouse/plan/commands.ts +30 -6
- package/src/clickhouse/topology.e2e.test.ts +100 -0
- package/src/clickhouse/topology.test.ts +276 -0
- package/src/clickhouse/topology.ts +451 -0
- package/src/codegen/classifier-page.ts +17 -0
- package/src/codegen/postgres-classifier-page.ts +17 -0
- package/src/core/statements.ts +127 -0
- package/src/index.ts +15 -0
- package/src/migration-statements.test.ts +265 -0
- package/src/migration-statements.ts +252 -0
- package/src/op/__snapshots__/watch-pipeline.test.ts.snap +93 -0
- package/src/op/watch-drift.e2e.test.ts +302 -0
- package/src/op/watch-pipeline.test.ts +113 -0
- package/src/postgres/apply/apply.ts +38 -96
- package/src/postgres/apply/statements.ts +145 -2
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterAll, describe, expect, test, vi } from "vitest";
|
|
5
|
+
import { diffStatements, renderStatements, type SchemaStatements, type StatementStep } from "./migration-statements";
|
|
6
|
+
import { diffStatements as fromRoot } from "./index";
|
|
7
|
+
import { clickhouseApply } from "./op/activities/clickhouse-apply";
|
|
8
|
+
import { postgresApply } from "./op/activities/postgres-apply";
|
|
9
|
+
import { runDiff } from "./clickhouse/plan/commands";
|
|
10
|
+
import { writableClickHouse, type WritableServer } from "./clickhouse/testing/writable-server";
|
|
11
|
+
import { writablePostgres } from "./postgres/testing/writable-server";
|
|
12
|
+
|
|
13
|
+
interface Obj {
|
|
14
|
+
export: string;
|
|
15
|
+
type: string;
|
|
16
|
+
ddl: string;
|
|
17
|
+
dependsOn?: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const dirs: string[] = [];
|
|
21
|
+
const servers: WritableServer[] = [];
|
|
22
|
+
afterAll(async () => {
|
|
23
|
+
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
|
24
|
+
for (const s of servers) await s.close();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const build = (dialect: string, objects: Obj[], extra: Record<string, unknown> = {}) => ({
|
|
28
|
+
dialect,
|
|
29
|
+
...extra,
|
|
30
|
+
applyOrder: objects.map((o) => o.export),
|
|
31
|
+
objects: objects.map((o) => ({ dependsOn: [], ...o })),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function buildFile(doc: object): string {
|
|
35
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-sql-statements-"));
|
|
36
|
+
dirs.push(dir);
|
|
37
|
+
const path = join(dir, "schema.json");
|
|
38
|
+
writeFileSync(path, JSON.stringify(doc));
|
|
39
|
+
return path;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const MARKER = { stack: "shop", env: "prod" };
|
|
43
|
+
const config = { ownership: { stack: "shop", env: "prod" } };
|
|
44
|
+
const sqlOf = (doc: SchemaStatements) => doc.steps.filter((s): s is StatementStep => s.kind === "statement").map((s) => s.sql);
|
|
45
|
+
|
|
46
|
+
// ── ClickHouse ─────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const DB: Obj = { export: "analytics", type: "ClickHouse::Database", ddl: "CREATE DATABASE analytics ENGINE = Atomic" };
|
|
49
|
+
const EVENTS: Obj = {
|
|
50
|
+
export: "events",
|
|
51
|
+
type: "ClickHouse::Table",
|
|
52
|
+
dependsOn: ["analytics"],
|
|
53
|
+
ddl: "CREATE TABLE analytics.events (ts DateTime, user_id UInt64, kind String) ENGINE = MergeTree ORDER BY (ts, user_id) COMMENT 'Raw events'",
|
|
54
|
+
};
|
|
55
|
+
const EVENTS_V2: Obj = {
|
|
56
|
+
...EVENTS,
|
|
57
|
+
ddl: "CREATE TABLE analytics.events (ts DateTime, user_id UInt64, region String DEFAULT 'eu', kind LowCardinality(String)) ENGINE = MergeTree ORDER BY (ts, user_id) COMMENT 'Raw events'",
|
|
58
|
+
};
|
|
59
|
+
const SESSIONS: Obj = { export: "sessions", type: "ClickHouse::Table", dependsOn: ["analytics"], ddl: "CREATE TABLE analytics.sessions (id UInt64, ts DateTime) ENGINE = MergeTree ORDER BY id" };
|
|
60
|
+
const SESSIONS_RESORTED: Obj = { ...SESSIONS, ddl: "CREATE TABLE analytics.sessions (id UInt64, ts DateTime) ENGINE = MergeTree ORDER BY (ts, id)" };
|
|
61
|
+
const OLD: Obj = { export: "old", type: "ClickHouse::Table", dependsOn: ["analytics"], ddl: "CREATE TABLE analytics.old (a UInt8) ENGINE = MergeTree ORDER BY a" };
|
|
62
|
+
const BY_KIND: Obj = { export: "byKind", type: "ClickHouse::View", dependsOn: ["events"], ddl: "CREATE VIEW analytics.by_kind AS SELECT kind, count() AS n FROM analytics.events GROUP BY kind" };
|
|
63
|
+
|
|
64
|
+
const CH_BEFORE = build("clickhouse", [DB, EVENTS, SESSIONS, OLD]);
|
|
65
|
+
const CH_AFTER = build("clickhouse", [DB, EVENTS_V2, SESSIONS_RESORTED, BY_KIND]);
|
|
66
|
+
|
|
67
|
+
describe("ClickHouse", () => {
|
|
68
|
+
const doc = diffStatements(CH_BEFORE, CH_AFTER, { marker: MARKER });
|
|
69
|
+
|
|
70
|
+
test("the statements are ordered, each with its change's rule and class", () => {
|
|
71
|
+
expect(doc.dialect).toBe("clickhouse");
|
|
72
|
+
expect(doc.steps.map((s) => [s.kind, s.object, s.kind === "statement" ? s.rule : s.kind === "op" ? s.op : "", s.kind === "statement" ? s.class : ""])).toEqual([
|
|
73
|
+
["statement", "events", "SQLCH201", "metadata"],
|
|
74
|
+
["statement", "events", "SQLCH210", "rewrite"],
|
|
75
|
+
["op", "sessions", "ClickHouseRebuildOp", ""],
|
|
76
|
+
["statement", "byKind", "SQLCH200", "create"],
|
|
77
|
+
["statement", "old", "SQLCH250", "drop"],
|
|
78
|
+
]);
|
|
79
|
+
expect(sqlOf(doc)).toEqual([
|
|
80
|
+
"ALTER TABLE `analytics`.`events` ADD COLUMN region String DEFAULT 'eu' AFTER `user_id`",
|
|
81
|
+
"ALTER TABLE `analytics`.`events` MODIFY COLUMN `kind` LowCardinality(String)",
|
|
82
|
+
"CREATE VIEW analytics.by_kind AS SELECT kind, count() AS n FROM analytics.events GROUP BY kind COMMENT '[chant managed-by=chant stack=shop env=prod]'",
|
|
83
|
+
"DROP TABLE `analytics`.`old` SYNC",
|
|
84
|
+
]);
|
|
85
|
+
const rewrite = doc.steps[1] as StatementStep;
|
|
86
|
+
expect(rewrite.waitsForMutation).toBe(true);
|
|
87
|
+
expect((doc.steps[4] as StatementStep).destructive).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a rebuild is a step naming ClickHouseRebuildOp, never DDL", () => {
|
|
91
|
+
const op = doc.steps.find((s) => s.kind === "op");
|
|
92
|
+
expect(op).toMatchObject({
|
|
93
|
+
kind: "op",
|
|
94
|
+
object: "sessions",
|
|
95
|
+
type: "ClickHouse::Table",
|
|
96
|
+
name: "analytics.sessions",
|
|
97
|
+
op: "ClickHouseRebuildOp",
|
|
98
|
+
importPath: "@intentius/chant-lexicon-sql/clickhouse",
|
|
99
|
+
options: { name: "rebuild-analytics-sessions", env: "<env>", table: "analytics.sessions", dualWrite: { mode: "materialized-view", cutoverColumn: "ts" } },
|
|
100
|
+
changes: [{ rule: "SQLCH220", class: "rebuild", field: "orderBy" }],
|
|
101
|
+
});
|
|
102
|
+
expect(sqlOf(doc).some((s) => s.includes("sessions"))).toBe(false);
|
|
103
|
+
expect(doc.refused).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a view's rebuild has no Op: it is a manual step", () => {
|
|
107
|
+
const mv = { export: "mv", type: "ClickHouse::MaterializedView", ddl: "CREATE MATERIALIZED VIEW analytics.mv ENGINE = MergeTree ORDER BY kind AS SELECT kind FROM analytics.events" };
|
|
108
|
+
const d = diffStatements(build("clickhouse", [DB, EVENTS, mv]), build("clickhouse", [DB, EVENTS, { ...mv, ddl: mv.ddl.replace("ORDER BY kind", "ORDER BY tuple()") }]));
|
|
109
|
+
expect(d.steps).toEqual([expect.objectContaining({ kind: "manual", object: "mv", changes: [expect.objectContaining({ rule: "SQLCH243", class: "rebuild" })] })]);
|
|
110
|
+
expect((d.steps[0] as { detail: string }).detail).toMatch(/drop and create this object instead/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("two identical builds take no steps", () => {
|
|
114
|
+
expect(diffStatements(CH_BEFORE, CH_BEFORE)).toEqual({ dialect: "clickhouse", defaultDatabase: "default", steps: [], refused: false, hints: [] });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("the applier sends the same statements for the same change", async () => {
|
|
118
|
+
const s = await writableClickHouse();
|
|
119
|
+
servers.push(s);
|
|
120
|
+
const deps = { config, env: { CLICKHOUSE_URL: s.url }, log: () => undefined };
|
|
121
|
+
await clickhouseApply({ buildPath: buildFile(CH_BEFORE), environment: "test" }, undefined, deps);
|
|
122
|
+
s.writes.length = 0;
|
|
123
|
+
const outcome = await clickhouseApply({ buildPath: buildFile(CH_AFTER), environment: "test", prune: true }, undefined, deps);
|
|
124
|
+
expect(s.writes).toEqual(sqlOf(doc));
|
|
125
|
+
// The refused rebuild is the same refusal, word for word.
|
|
126
|
+
const refused = outcome.notAttempted.find((n) => n.name === "analytics.sessions");
|
|
127
|
+
expect(refused?.detail).toBe((doc.steps.find((x) => x.kind === "op") as { detail: string }).detail);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ── Postgres ───────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
const APP: Obj = { export: "app", type: "Postgres::Schema", ddl: "CREATE SCHEMA app" };
|
|
134
|
+
const USERS: Obj = {
|
|
135
|
+
export: "users",
|
|
136
|
+
type: "Postgres::Table",
|
|
137
|
+
dependsOn: ["app"],
|
|
138
|
+
ddl: "CREATE TABLE app.users (\n id bigint PRIMARY KEY,\n email text NOT NULL,\n nick text\n);\nCOMMENT ON TABLE app.users IS 'Accounts'",
|
|
139
|
+
};
|
|
140
|
+
const USERS_V2: Obj = { ...USERS, ddl: USERS.ddl.replace("nick text", "nick text,\n name text NOT NULL DEFAULT 'none'") };
|
|
141
|
+
const EMAIL_IDX: Obj = { export: "usersEmail", type: "Postgres::Index", dependsOn: ["users"], ddl: "CREATE INDEX CONCURRENTLY users_email_idx ON app.users (email)" };
|
|
142
|
+
const ORDERS: Obj = { export: "orders", type: "Postgres::Table", dependsOn: ["app"], ddl: "CREATE TABLE app.orders (\n id bigint,\n ref text\n)" };
|
|
143
|
+
const ORDERS_RENAMED: Obj = { ...ORDERS, ddl: "CREATE TABLE app.orders (\n id bigint,\n reference text -- previously: ref\n)" };
|
|
144
|
+
const EMAILS: Obj = { export: "emails", type: "Postgres::View", dependsOn: ["users"], ddl: "CREATE VIEW app.emails AS SELECT email FROM app.users" };
|
|
145
|
+
|
|
146
|
+
const PG_BEFORE = build("postgres", [APP, USERS, ORDERS, EMAILS], { postgresMajor: 18 });
|
|
147
|
+
const PG_AFTER = build("postgres", [APP, USERS_V2, EMAIL_IDX, ORDERS_RENAMED], { postgresMajor: 18 });
|
|
148
|
+
|
|
149
|
+
describe("Postgres", () => {
|
|
150
|
+
const doc = diffStatements(PG_BEFORE, PG_AFTER, { marker: MARKER, env: "prod" });
|
|
151
|
+
|
|
152
|
+
test("the statements are ordered, each with its change's rule and class and whether it may run in a transaction", () => {
|
|
153
|
+
expect(doc).toMatchObject({ dialect: "postgres", defaultSchema: "public", major: 18 });
|
|
154
|
+
expect(doc.steps.filter((s): s is StatementStep => s.kind === "statement").map((s) => [s.object, s.sql, s.rule, s.class, s.transactional])).toEqual([
|
|
155
|
+
["users", "ALTER TABLE app.users ADD COLUMN name text DEFAULT 'none' NOT NULL", "SQLPG201", "metadata", true],
|
|
156
|
+
["usersEmail", "CREATE INDEX CONCURRENTLY users_email_idx ON app.users (email)", "SQLPG240", "concurrently", false],
|
|
157
|
+
["usersEmail", "COMMENT ON INDEX app.users_email_idx IS '[chant managed-by=chant stack=shop env=prod]'", "SQLPG240", "concurrently", true],
|
|
158
|
+
["emails", "DROP VIEW app.emails", "SQLPG270", "drop", true],
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("a column rename is a step naming PostgresMigrationOp, never DDL", () => {
|
|
163
|
+
const op = doc.steps.find((s) => s.kind === "op");
|
|
164
|
+
expect(op).toMatchObject({
|
|
165
|
+
kind: "op",
|
|
166
|
+
object: "orders",
|
|
167
|
+
type: "Postgres::Table",
|
|
168
|
+
name: "app.orders",
|
|
169
|
+
op: "PostgresMigrationOp",
|
|
170
|
+
importPath: "@intentius/chant-lexicon-sql/postgres",
|
|
171
|
+
options: { name: "migrate-app-orders-reference", env: "prod", table: "app.orders", column: "reference" },
|
|
172
|
+
changes: [{ rule: "SQLPG205", class: "expand", field: "columns.reference", before: "ref", after: "reference" }],
|
|
173
|
+
});
|
|
174
|
+
expect((op as { declaration: string }).declaration).toContain('PostgresMigrationOp({ name: "migrate-app-orders-reference", env: "prod", table: "app.orders", column: "reference" })');
|
|
175
|
+
expect(sqlOf(doc).some((s) => s.includes("orders"))).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("a type change across kinds is a PostgresMigrationOp step too", () => {
|
|
179
|
+
const typed = { ...ORDERS, ddl: "CREATE TABLE app.orders (\n id text,\n ref text\n)" };
|
|
180
|
+
const d = diffStatements(build("postgres", [APP, ORDERS]), build("postgres", [APP, typed]));
|
|
181
|
+
expect(d.steps).toEqual([expect.objectContaining({ kind: "op", op: "PostgresMigrationOp", options: expect.objectContaining({ column: "id" }), changes: [expect.objectContaining({ rule: "SQLPG208" })] })]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("an expand-and-contract change no Op makes is a manual step", () => {
|
|
185
|
+
const partitioned = { ...ORDERS, ddl: "CREATE TABLE app.orders (\n id bigint NOT NULL\n)" };
|
|
186
|
+
const d = diffStatements(build("postgres", [APP, { ...ORDERS, ddl: "CREATE TABLE app.orders (\n ref text\n)" }]), build("postgres", [APP, { ...partitioned, ddl: "CREATE TABLE app.orders (\n ref text,\n id bigint NOT NULL\n)" }]));
|
|
187
|
+
expect(d.steps).toEqual([expect.objectContaining({ kind: "manual", object: "orders", changes: [expect.objectContaining({ rule: "SQLPG203", class: "expand" })] })]);
|
|
188
|
+
expect(d.refused).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("the applier sends the same statements for the same change", async () => {
|
|
192
|
+
const s = writablePostgres();
|
|
193
|
+
const deps = {
|
|
194
|
+
config,
|
|
195
|
+
env: { POSTGRES_URL: "postgres://fake:5432/shop" },
|
|
196
|
+
log: () => undefined,
|
|
197
|
+
connect: async () => s.client,
|
|
198
|
+
readLive: () => s.readLive(),
|
|
199
|
+
serverNormalize: async (_client: unknown, o: unknown) => o,
|
|
200
|
+
};
|
|
201
|
+
await postgresApply({ buildPath: buildFile(PG_BEFORE), environment: "test" }, undefined, deps as never);
|
|
202
|
+
const outcome = await postgresApply({ buildPath: buildFile(PG_AFTER), environment: "test", prune: true }, undefined, deps as never);
|
|
203
|
+
const offline = doc.steps.filter((x): x is StatementStep => x.kind === "statement");
|
|
204
|
+
expect(outcome.statements.map((r) => [r.sql, r.class])).toEqual(offline.map((x) => [x.sql, x.class]));
|
|
205
|
+
// What may not run in a transaction block ran outside one. (A prune's DROP may, and the applier runs each alone.)
|
|
206
|
+
expect(outcome.statements.filter((r, i) => !offline[i]!.transactional).map((r) => r.transaction)).toEqual([undefined]);
|
|
207
|
+
expect(outcome.statements.filter((r, i) => offline[i]!.transactional && offline[i]!.class !== "drop").every((r) => r.transaction !== undefined)).toBe(true);
|
|
208
|
+
const refused = outcome.notAttempted.find((n) => n.name === "app.orders");
|
|
209
|
+
expect(refused?.detail).toBe((doc.steps.find((x) => x.kind === "op") as { detail: string }).detail);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("the document", () => {
|
|
214
|
+
test("is plain JSON: it reads back as written", () => {
|
|
215
|
+
for (const doc of [diffStatements(CH_BEFORE, CH_AFTER), diffStatements(PG_BEFORE, PG_AFTER)]) {
|
|
216
|
+
expect(JSON.parse(JSON.stringify(doc))).toEqual(doc);
|
|
217
|
+
expect(JSON.stringify(doc)).not.toContain("undefined");
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("takes build outputs as JSON text or parsed, and is exported from the package root", () => {
|
|
222
|
+
expect(fromRoot(JSON.stringify(CH_BEFORE), JSON.stringify(CH_AFTER))).toEqual(diffStatements(CH_BEFORE, CH_AFTER));
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("takes the sql output of a multi-lexicon build", () => {
|
|
226
|
+
expect(diffStatements({ sql: PG_BEFORE }, { sql: PG_AFTER })).toEqual(diffStatements(PG_BEFORE, PG_AFTER));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("refuses builds of two dialects", () => {
|
|
230
|
+
expect(() => diffStatements(CH_BEFORE, PG_AFTER)).toThrow(/different dialects/);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("renders as a migration file's SQL, each statement after its rule and class, an Op as a comment", () => {
|
|
234
|
+
const sql = renderStatements(diffStatements(PG_BEFORE, PG_AFTER, { env: "prod" }));
|
|
235
|
+
expect(sql).toContain("-- usersEmail (app.users_email_idx): SQLPG240 concurrently, outside a transaction\nCREATE INDEX CONCURRENTLY users_email_idx ON app.users (email);");
|
|
236
|
+
expect(sql).toContain("-- orders (app.orders): SQLPG205 made by PostgresMigrationOp, not a statement:");
|
|
237
|
+
expect(sql).not.toMatch(/^ALTER TABLE app\.orders/m);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("chant sql diff --statements", () => {
|
|
242
|
+
const capture = async (args: string[]) => {
|
|
243
|
+
const lines: string[] = [];
|
|
244
|
+
const log = vi.spyOn(console, "log").mockImplementation((l: unknown) => void lines.push(String(l)));
|
|
245
|
+
try {
|
|
246
|
+
return { code: await runDiff({ verb: "diff", rawArgs: args }), out: lines.join("\n") };
|
|
247
|
+
} finally {
|
|
248
|
+
log.mockRestore();
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
test("--json prints the document diffStatements returns, and exits 2 for a step that is an Op", async () => {
|
|
253
|
+
const r = await capture([buildFile(CH_BEFORE), buildFile(CH_AFTER), "--statements", "--json"]);
|
|
254
|
+
expect(r.code).toBe(2);
|
|
255
|
+
expect(JSON.parse(r.out)).toEqual(diffStatements(CH_BEFORE, CH_AFTER));
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("without --json prints the SQL, and exits 0 when every step is a statement", async () => {
|
|
259
|
+
const after = build("postgres", [APP, USERS_V2, ORDERS, EMAILS], { postgresMajor: 18 });
|
|
260
|
+
const r = await capture([buildFile(PG_BEFORE), buildFile(after), "--statements"]);
|
|
261
|
+
expect(r.code).toBe(0);
|
|
262
|
+
expect(r.out).toBe(renderStatements(diffStatements(PG_BEFORE, after)));
|
|
263
|
+
expect(r.out).toContain("ALTER TABLE app.users ADD COLUMN name text DEFAULT 'none' NOT NULL;");
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A schema change's statements, rendered offline (#3644): for two `chant
|
|
3
|
+
* build` outputs, the ordered statements that take the first to the second,
|
|
4
|
+
* each with its change's class and rule, for a migration file. No server is
|
|
5
|
+
* read.
|
|
6
|
+
*
|
|
7
|
+
* The statements are the applier's: each dialect's `planStatements` /
|
|
8
|
+
* `planPgStatements` (`./clickhouse/apply/statements.ts`,
|
|
9
|
+
* `./postgres/apply/statements.ts`) turns classified changes into steps, and
|
|
10
|
+
* both the applier and this renderer call it. What differs is only where the
|
|
11
|
+
* current schema comes from: here the older build, keyed by export name as
|
|
12
|
+
* `chant sql diff` keys it; in the applier the server's catalog.
|
|
13
|
+
*
|
|
14
|
+
* A change the applier refuses in place is a step naming the Op that makes
|
|
15
|
+
* it, never DDL: a ClickHouse rebuild is `ClickHouseRebuildOp`, a Postgres
|
|
16
|
+
* column rename or type change across kinds is `PostgresMigrationOp`. One no
|
|
17
|
+
* Op makes is a manual step. Column and object drops are statements, marked
|
|
18
|
+
* destructive.
|
|
19
|
+
*
|
|
20
|
+
* Where the applier asks the server something, the renderer cannot:
|
|
21
|
+
*
|
|
22
|
+
* - ClickHouse: the applier also asks the server's formatter whether a
|
|
23
|
+
* difference the rules leave is only formatting; the offline diff is the
|
|
24
|
+
* rules' alone, as `chant sql diff` is. Before dropping a database it
|
|
25
|
+
* checks the database is empty.
|
|
26
|
+
* - Postgres: the applier asks the server to normalize expressions the rules
|
|
27
|
+
* leave different, and keeps an extension's own comment (from its control
|
|
28
|
+
* file) under the trailer when the declaration sets none.
|
|
29
|
+
* - Both: the applier restamps an object whose comment lost chant's marker;
|
|
30
|
+
* offline, the older build's objects carry it.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { OwnershipMarker } from "@intentius/chant/ownership";
|
|
34
|
+
import { statementChange, type ManualStep, type MigrationStep, type SchemaStatements, type StepObject } from "./core/statements";
|
|
35
|
+
import { declaredObjects as clickhouseDeclared } from "./clickhouse/apply/apply";
|
|
36
|
+
import { planStatements, type DeclaredObject } from "./clickhouse/apply/statements";
|
|
37
|
+
import { diffSchemas, type Change } from "./clickhouse/plan/diff";
|
|
38
|
+
import { rebuildOpSuggestions } from "./clickhouse/plan/rebuild-handoff";
|
|
39
|
+
import { declaredObjects as postgresDeclared } from "./postgres/apply/apply";
|
|
40
|
+
import { planPgStatements, type DeclaredPgObject } from "./postgres/apply/statements";
|
|
41
|
+
import { diffPgSchemas, type PgChange } from "./postgres/plan/diff";
|
|
42
|
+
import { pgBuildMajor } from "./postgres/plan/schema";
|
|
43
|
+
import { migrationOpSuggestions, migrationTarget } from "./postgres/migrate/handoff";
|
|
44
|
+
import { POSTGRES_ENTITY_TYPES } from "./postgres/entity-types";
|
|
45
|
+
import { POSTGRES_LATEST_MAJOR } from "./spec/postgres-pin";
|
|
46
|
+
|
|
47
|
+
export type { ManualStep, MigrationStep, OpStep, SchemaStatements, StatementChange, StatementStep, StepObject } from "./core/statements";
|
|
48
|
+
export { renderStatements } from "./core/statements";
|
|
49
|
+
|
|
50
|
+
export interface DiffStatementsOptions {
|
|
51
|
+
/** The ownership marker every `CREATE` and restamped comment carries, as the applier stamps it. Default: `managed-by=chant` alone. */
|
|
52
|
+
marker?: OwnershipMarker;
|
|
53
|
+
/** The environment an Op step's suggested declaration names. Default: `<env>`. */
|
|
54
|
+
env?: string;
|
|
55
|
+
/** ClickHouse: the database a bare name means. Default: `default`. */
|
|
56
|
+
defaultDatabase?: string;
|
|
57
|
+
/** Postgres: the schema a bare name means. Default: `public`. */
|
|
58
|
+
defaultSchema?: string;
|
|
59
|
+
/** Postgres: the major to classify for. Default: the newer build's `postgresMajor`, else the older's, else the newest pinned. */
|
|
60
|
+
major?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type BuildInput = string | object;
|
|
64
|
+
|
|
65
|
+
const text = (b: BuildInput): string => (typeof b === "string" ? b : JSON.stringify(b));
|
|
66
|
+
|
|
67
|
+
/** The dialect a build output names, at its root or under `sql`. */
|
|
68
|
+
function dialectOf(json: string): string | undefined {
|
|
69
|
+
const raw = JSON.parse(json) as { dialect?: unknown; sql?: { dialect?: unknown } };
|
|
70
|
+
const d = raw?.sql && typeof raw.sql === "object" ? raw.sql.dialect : raw?.dialect;
|
|
71
|
+
return typeof d === "string" ? d : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The statements that take the schema `before` builds to the one `after`
|
|
76
|
+
* builds. Each is a `chant build` output of the sql lexicon, as JSON text or
|
|
77
|
+
* parsed. Both must be the same dialect.
|
|
78
|
+
*/
|
|
79
|
+
export function diffStatements(before: BuildInput, after: BuildInput, options: DiffStatementsOptions = {}): SchemaStatements {
|
|
80
|
+
const b = text(before);
|
|
81
|
+
const a = text(after);
|
|
82
|
+
const dialect = dialectOf(a);
|
|
83
|
+
if (dialectOf(b) !== dialect) throw new Error(`diffStatements: the builds are different dialects (${dialectOf(b) ?? "none"} and ${dialect ?? "none"})`);
|
|
84
|
+
if (dialect === "postgres") return postgresStatements(b, a, options);
|
|
85
|
+
if (dialect === "clickhouse") return clickhouseStatements(b, a, options);
|
|
86
|
+
throw new Error(`diffStatements: not a sql lexicon build output (dialect ${dialect ?? "missing"})`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const at = (object: string, type: string, name: string): StepObject => ({ object, type, name });
|
|
90
|
+
|
|
91
|
+
// ── ClickHouse ─────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
function clickhouseStatements(beforeJson: string, afterJson: string, options: DiffStatementsOptions): SchemaStatements {
|
|
94
|
+
const defaultDatabase = options.defaultDatabase ?? "default";
|
|
95
|
+
const env = options.env ?? "<env>";
|
|
96
|
+
const before = clickhouseDeclared(beforeJson, defaultDatabase);
|
|
97
|
+
const after = clickhouseDeclared(afterJson, defaultDatabase);
|
|
98
|
+
const keyed = (objs: readonly DeclaredObject[]) => objs.map((o) => ({ key: o.exportName, canonical: o.canonical }));
|
|
99
|
+
const diff = diffSchemas(keyed(before), keyed(after));
|
|
100
|
+
const plan = planStatements({
|
|
101
|
+
declared: after,
|
|
102
|
+
changes: diff.changes,
|
|
103
|
+
current: new Map(before.map((o) => [o.exportName, o.canonical])),
|
|
104
|
+
keyOf: (o) => o.exportName,
|
|
105
|
+
allowDestructive: true,
|
|
106
|
+
...(options.marker ? { marker: options.marker } : {}),
|
|
107
|
+
});
|
|
108
|
+
const declaredByExport = new Map(after.map((o) => [o.exportName, o.canonical]));
|
|
109
|
+
const isDestructive = (changes: readonly Change[], rule: string) => changes.some((c) => c.rule === rule && c.destructive);
|
|
110
|
+
|
|
111
|
+
const steps: MigrationStep[] = [];
|
|
112
|
+
for (const entry of plan.objects) {
|
|
113
|
+
const where = at(entry.obj.exportName, entry.obj.type, entry.obj.key);
|
|
114
|
+
if (entry.verdict === "rebuild") {
|
|
115
|
+
const [op] = rebuildOpSuggestions({ ...diff, rebuilds: entry.refused }, declaredByExport, env);
|
|
116
|
+
if (op) {
|
|
117
|
+
steps.push({
|
|
118
|
+
kind: "op",
|
|
119
|
+
...where,
|
|
120
|
+
op: "ClickHouseRebuildOp",
|
|
121
|
+
importPath: "@intentius/chant-lexicon-sql/clickhouse",
|
|
122
|
+
options: { name: op.name, env: op.env, table: op.table, dualWrite: op.dualWrite },
|
|
123
|
+
declaration: op.declaration,
|
|
124
|
+
// The rebuild creates the new table from the declaration, so it makes every change to it.
|
|
125
|
+
changes: entry.changes.map(statementChange),
|
|
126
|
+
detail: entry.detail,
|
|
127
|
+
});
|
|
128
|
+
} else steps.push({ kind: "manual", ...where, changes: entry.refused.map(statementChange), detail: entry.detail });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (entry.verdict === "withheld") continue; // not reached: destructive changes are planned
|
|
132
|
+
for (const s of entry.steps) {
|
|
133
|
+
steps.push({
|
|
134
|
+
kind: "statement",
|
|
135
|
+
...where,
|
|
136
|
+
sql: s.sql,
|
|
137
|
+
rule: s.rule,
|
|
138
|
+
class: s.class,
|
|
139
|
+
transactional: false,
|
|
140
|
+
...(s.rewrite ? { waitsForMutation: true } : {}),
|
|
141
|
+
...(s.rule === "SQLCH202" || isDestructive(entry.changes, s.rule) ? { destructive: true } : {}),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const beforeByExport = new Map(before.map((o) => [o.exportName, o]));
|
|
146
|
+
for (const d of plan.drops) {
|
|
147
|
+
const o = beforeByExport.get(d.key);
|
|
148
|
+
const change = diff.changes.find((c) => c.object === d.key && c.rule === "SQLCH250");
|
|
149
|
+
steps.push({
|
|
150
|
+
kind: "statement",
|
|
151
|
+
...at(d.key, d.type, o?.key ?? d.name),
|
|
152
|
+
sql: d.step.sql,
|
|
153
|
+
rule: d.step.rule,
|
|
154
|
+
class: d.step.class,
|
|
155
|
+
transactional: false,
|
|
156
|
+
...(change?.destructive ? { destructive: true } : {}),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return { dialect: "clickhouse", defaultDatabase, steps, refused: steps.some((s) => s.kind !== "statement"), hints: diff.hints };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Postgres ───────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
function postgresStatements(beforeJson: string, afterJson: string, options: DiffStatementsOptions): SchemaStatements {
|
|
165
|
+
const defaultSchema = options.defaultSchema ?? "public";
|
|
166
|
+
const env = options.env ?? "<env>";
|
|
167
|
+
const major = options.major ?? buildMajor(afterJson) ?? buildMajor(beforeJson) ?? POSTGRES_LATEST_MAJOR;
|
|
168
|
+
const before = postgresDeclared(beforeJson, defaultSchema);
|
|
169
|
+
const after = postgresDeclared(afterJson, defaultSchema);
|
|
170
|
+
const keyed = (objs: readonly DeclaredPgObject[]) => objs.map((o) => ({ key: o.exportName, canonical: o.canonical }));
|
|
171
|
+
const current = keyed(before);
|
|
172
|
+
const diff = diffPgSchemas(current, keyed(after), { major });
|
|
173
|
+
const plan = planPgStatements({
|
|
174
|
+
declared: after,
|
|
175
|
+
changes: diff.changes,
|
|
176
|
+
current,
|
|
177
|
+
keyOf: (o) => o.exportName,
|
|
178
|
+
major,
|
|
179
|
+
allowDestructive: true,
|
|
180
|
+
...(options.marker ? { marker: options.marker } : {}),
|
|
181
|
+
});
|
|
182
|
+
const isDestructive = (changes: readonly PgChange[], rule: string | undefined) => changes.some((c) => c.rule === rule && c.destructive);
|
|
183
|
+
|
|
184
|
+
const steps: MigrationStep[] = [];
|
|
185
|
+
for (const entry of plan.objects) {
|
|
186
|
+
const where = at(entry.obj.exportName, entry.obj.type, entry.obj.name);
|
|
187
|
+
switch (entry.verdict) {
|
|
188
|
+
case "refused": {
|
|
189
|
+
const handled = new Set<PgChange>();
|
|
190
|
+
for (const op of migrationOpSuggestions(entry.refused, new Map([[entry.obj.exportName, entry.obj.canonical]]), env, defaultSchema)) {
|
|
191
|
+
const made = entry.refused.filter((c) => {
|
|
192
|
+
const t = migrationTarget(c, entry.obj.canonical, defaultSchema);
|
|
193
|
+
return t !== undefined && t.table === op.table && t.column === op.column;
|
|
194
|
+
});
|
|
195
|
+
for (const c of made) handled.add(c);
|
|
196
|
+
steps.push({
|
|
197
|
+
kind: "op",
|
|
198
|
+
...where,
|
|
199
|
+
op: "PostgresMigrationOp",
|
|
200
|
+
importPath: "@intentius/chant-lexicon-sql/postgres",
|
|
201
|
+
options: { name: op.name, env: op.env, table: op.table, column: op.column },
|
|
202
|
+
declaration: op.declaration,
|
|
203
|
+
changes: made.map(statementChange),
|
|
204
|
+
detail: entry.detail,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
const rest = entry.changes.filter((c) => !handled.has(c));
|
|
208
|
+
if (rest.length > 0) steps.push(manual(where, rest, handled.size > 0 ? `the applier sends nothing for ${entry.obj.name} while a change to it is refused; these changes are not made by the Op: ${entry.detail}` : entry.detail));
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
case "unsupported":
|
|
212
|
+
steps.push(manual(where, entry.changes, entry.detail));
|
|
213
|
+
continue;
|
|
214
|
+
case "foreign":
|
|
215
|
+
case "withheld":
|
|
216
|
+
continue; // not reached offline: a build declares no foreign object, and destructive changes are planned
|
|
217
|
+
}
|
|
218
|
+
for (const s of entry.steps) {
|
|
219
|
+
steps.push({
|
|
220
|
+
kind: "statement",
|
|
221
|
+
...where,
|
|
222
|
+
sql: s.sql,
|
|
223
|
+
rule: s.rule ?? (s.class === "create" ? "SQLPG200" : (entry.changes[0]?.rule ?? "SQLPG200")),
|
|
224
|
+
class: s.class,
|
|
225
|
+
transactional: s.transactional,
|
|
226
|
+
...(s.rule === "SQLPG204" || isDestructive(entry.changes, s.rule) ? { destructive: true } : {}),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const beforeByExport = new Map(before.map((o) => [o.exportName, o]));
|
|
231
|
+
for (const d of plan.drops) {
|
|
232
|
+
const o = beforeByExport.get(d.key);
|
|
233
|
+
const change = diff.changes.find((c) => c.object === d.key && (c.rule === "SQLPG270" || c.rule === "SQLPG242"));
|
|
234
|
+
steps.push({
|
|
235
|
+
kind: "statement",
|
|
236
|
+
...at(d.key, o?.type ?? POSTGRES_ENTITY_TYPES[d.kind], o?.name ?? d.name),
|
|
237
|
+
sql: d.step.sql,
|
|
238
|
+
rule: d.step.rule ?? "SQLPG270",
|
|
239
|
+
class: d.step.class,
|
|
240
|
+
transactional: d.step.transactional,
|
|
241
|
+
...(change?.destructive ? { destructive: true } : {}),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return { dialect: "postgres", defaultSchema, major, steps, refused: steps.some((s) => s.kind !== "statement"), hints: diff.hints };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const manual = (where: StepObject, changes: readonly PgChange[], detail: string): ManualStep => ({ kind: "manual", ...where, changes: changes.map(statementChange), detail });
|
|
248
|
+
|
|
249
|
+
function buildMajor(json: string): number | undefined {
|
|
250
|
+
const raw = JSON.parse(json) as { sql?: unknown };
|
|
251
|
+
return pgBuildMajor(raw.sql && typeof raw.sql === "object" ? JSON.stringify(raw.sql) : json);
|
|
252
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`generateOpsPipeline renders the sql drift watch (#3642) > Forgejo: the Op's own cron and the reader's credentials; the runner's token takes no permissions block 1`] = `
|
|
4
|
+
"name: schema-watch
|
|
5
|
+
|
|
6
|
+
on:
|
|
7
|
+
schedule:
|
|
8
|
+
- cron: '17 * * * *'
|
|
9
|
+
workflow_dispatch: {}
|
|
10
|
+
|
|
11
|
+
concurrency:
|
|
12
|
+
group: schema-watch
|
|
13
|
+
cancel-in-progress: false
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
schema-watch:
|
|
17
|
+
runs-on: docker
|
|
18
|
+
container: node:22-slim
|
|
19
|
+
env:
|
|
20
|
+
CLICKHOUSE_READER_USER: chant_reader
|
|
21
|
+
CLICKHOUSE_READER_PASSWORD: '\${{ secrets.CLICKHOUSE_READER_PASSWORD }}'
|
|
22
|
+
steps:
|
|
23
|
+
- uses: https://code.forgejo.org/actions/checkout@v7
|
|
24
|
+
- run: npm ci
|
|
25
|
+
- run: echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH"
|
|
26
|
+
- run: chant run schema-watch
|
|
27
|
+
env:
|
|
28
|
+
GITHUB_TOKEN: '\${{ github.token }}'
|
|
29
|
+
"
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
exports[`generateOpsPipeline renders the sql drift watch (#3642) > GitHub: the Op's own cron, a read-only token and the reader's credentials 1`] = `
|
|
33
|
+
"name: schema-watch
|
|
34
|
+
|
|
35
|
+
on:
|
|
36
|
+
schedule:
|
|
37
|
+
- cron: '17 * * * *'
|
|
38
|
+
workflow_dispatch: {}
|
|
39
|
+
|
|
40
|
+
concurrency:
|
|
41
|
+
group: schema-watch
|
|
42
|
+
cancel-in-progress: false
|
|
43
|
+
|
|
44
|
+
permissions:
|
|
45
|
+
contents: read
|
|
46
|
+
|
|
47
|
+
jobs:
|
|
48
|
+
schema-watch:
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
container: node:22-slim
|
|
51
|
+
env:
|
|
52
|
+
CLICKHOUSE_READER_USER: chant_reader
|
|
53
|
+
CLICKHOUSE_READER_PASSWORD: '\${{ secrets.CLICKHOUSE_READER_PASSWORD }}'
|
|
54
|
+
steps:
|
|
55
|
+
- uses: actions/checkout@v7
|
|
56
|
+
- run: npm ci
|
|
57
|
+
- run: echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH"
|
|
58
|
+
- run: chant run schema-watch
|
|
59
|
+
env:
|
|
60
|
+
GITHUB_TOKEN: '\${{ github.token }}'
|
|
61
|
+
"
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
exports[`generateOpsPipeline renders the sql drift watch (#3642) > GitLab: a schedule-only job with the reader's credentials and no token of its own 1`] = `
|
|
65
|
+
"# chant Ops (#927, #2084, #2293) — one job per Op under stage "ops", each
|
|
66
|
+
# selected by its own rules:. A merge_request_event or push job needs no
|
|
67
|
+
# setup; its rule fires on the event itself.
|
|
68
|
+
#
|
|
69
|
+
# GitLab has no in-file cron. Create one Pipeline Schedule per cron Op
|
|
70
|
+
# below (Settings > CI/CD > Schedules): set its cron to the value noted
|
|
71
|
+
# here and its CHANT_SCHEDULED_OP CI/CD variable to the Op's name, so only
|
|
72
|
+
# that job runs on that schedule.
|
|
73
|
+
#
|
|
74
|
+
# schema-watch: cron "17 * * * *", CHANT_SCHEDULED_OP="schema-watch", finding-mode report
|
|
75
|
+
|
|
76
|
+
stages:
|
|
77
|
+
- ops
|
|
78
|
+
|
|
79
|
+
schema-watch:
|
|
80
|
+
stage: ops
|
|
81
|
+
image: node:22-slim
|
|
82
|
+
resource_group: schema-watch
|
|
83
|
+
variables:
|
|
84
|
+
CLICKHOUSE_READER_USER: chant_reader
|
|
85
|
+
CLICKHOUSE_READER_PASSWORD: '$CLICKHOUSE_READER_PASSWORD'
|
|
86
|
+
rules:
|
|
87
|
+
- if: '$CI_PIPELINE_SOURCE == "schedule" && $CHANT_SCHEDULED_OP == "schema-watch"'
|
|
88
|
+
script:
|
|
89
|
+
- npm ci
|
|
90
|
+
- export PATH="$PWD/node_modules/.bin:$PATH"
|
|
91
|
+
- chant run schema-watch
|
|
92
|
+
"
|
|
93
|
+
`;
|