@intentius/chant-lexicon-sql 0.112.0 → 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.
Files changed (38) hide show
  1. package/dist/clickhouse/apply/apply.d.ts.map +1 -1
  2. package/dist/clickhouse/apply/statements.d.ts +77 -1
  3. package/dist/clickhouse/apply/statements.d.ts.map +1 -1
  4. package/dist/clickhouse/plan/commands.d.ts +6 -1
  5. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  6. package/dist/clickhouse/topology.d.ts +100 -0
  7. package/dist/clickhouse/topology.d.ts.map +1 -0
  8. package/dist/codegen/classifier-page.d.ts.map +1 -1
  9. package/dist/codegen/postgres-classifier-page.d.ts.map +1 -1
  10. package/dist/core/statements.d.ts +104 -0
  11. package/dist/core/statements.d.ts.map +1 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/integrity.json +2 -2
  15. package/dist/manifest.json +1 -1
  16. package/dist/migration-statements.d.ts +55 -0
  17. package/dist/migration-statements.d.ts.map +1 -0
  18. package/dist/postgres/apply/apply.d.ts.map +1 -1
  19. package/dist/postgres/apply/statements.d.ts +91 -1
  20. package/dist/postgres/apply/statements.d.ts.map +1 -1
  21. package/package.json +2 -2
  22. package/src/clickhouse/apply/apply.ts +25 -53
  23. package/src/clickhouse/apply/statements.ts +149 -31
  24. package/src/clickhouse/plan/commands.ts +30 -6
  25. package/src/clickhouse/topology.e2e.test.ts +100 -0
  26. package/src/clickhouse/topology.test.ts +276 -0
  27. package/src/clickhouse/topology.ts +451 -0
  28. package/src/codegen/classifier-page.ts +17 -0
  29. package/src/codegen/postgres-classifier-page.ts +17 -0
  30. package/src/core/statements.ts +127 -0
  31. package/src/index.ts +15 -0
  32. package/src/migration-statements.test.ts +265 -0
  33. package/src/migration-statements.ts +252 -0
  34. package/src/op/__snapshots__/watch-pipeline.test.ts.snap +93 -0
  35. package/src/op/watch-drift.e2e.test.ts +302 -0
  36. package/src/op/watch-pipeline.test.ts +113 -0
  37. package/src/postgres/apply/apply.ts +38 -96
  38. package/src/postgres/apply/statements.ts +145 -2
@@ -0,0 +1,451 @@
1
+ /**
2
+ * One ClickHouse statement rendered for the topology it runs on (#3645):
3
+ * a single node, a cluster (`ON CLUSTER`), a `Replicated` database, or
4
+ * ClickHouse Cloud.
5
+ *
6
+ * A migration source is written once. What differs between topologies is
7
+ * two things, and this module rewrites only those:
8
+ *
9
+ * - `ON CLUSTER`. A cluster's DDL carries it so every host runs the
10
+ * statement. A single node has no cluster to name. A `Replicated`
11
+ * database replicates its own DDL and refuses `ON CLUSTER` naming any
12
+ * other cluster ("ON CLUSTER is not allowed for Replicated database",
13
+ * code 80 on 26.8) unless `ignore_on_cluster_for_replicated_database` is
14
+ * set (default 0). Cloud's databases are Replicated, so the same holds
15
+ * there.
16
+ * - The table engine. A MergeTree-family table replicates its rows only as
17
+ * `Replicated*MergeTree`. On a cluster that engine names its Keeper path
18
+ * and replica; inside a `Replicated` database it takes no path arguments,
19
+ * since `database_replicated_allow_replicated_engine_arguments` defaults to
20
+ * 0 (refuse them) and the database supplies its own. A single node has no
21
+ * Keeper to replicate through, so it gets the plain family. Cloud converts
22
+ * every MergeTree and `Replicated*` engine to its `Shared*` equivalent
23
+ * itself ("You don't add a `Shared` or `Replicated` prefix yourself",
24
+ * https://clickhouse.com/docs/whats-new/cloud-compatibility), so it gets
25
+ * the plain family as well, with no path arguments to clash.
26
+ *
27
+ * Every function here is pure: SQL in, SQL out, nothing sent. Statements
28
+ * this module does not recognize (`INSERT`, `SELECT`, `SYSTEM`) come back
29
+ * unchanged, so a caller can pass every statement it sends.
30
+ */
31
+
32
+ import { isTrivia, tokenizeText, type Token } from "./tokens";
33
+ import { parseCreate, unquote, type CreateNode, type Span } from "./parser";
34
+
35
+ /** Where the statements run. */
36
+ export type Topology =
37
+ | { kind: "single" }
38
+ | {
39
+ kind: "cluster";
40
+ /** The cluster's name in `remote_servers`, or a macro such as `{cluster}`. */
41
+ cluster: string;
42
+ /** Keeper path for a `Replicated*MergeTree` the renderer writes. Default {@link DEFAULT_REPLICA_PATH}. */
43
+ replicaPath?: string;
44
+ /** Replica name for a `Replicated*MergeTree` the renderer writes. Default {@link DEFAULT_REPLICA_NAME}. */
45
+ replicaName?: string;
46
+ }
47
+ | {
48
+ kind: "replicated";
49
+ /**
50
+ * A cluster to create and drop the `Replicated` databases themselves
51
+ * on, as `CREATE DATABASE ... ON CLUSTER` reaches every replica.
52
+ * Statements inside the database never carry it.
53
+ */
54
+ cluster?: string;
55
+ }
56
+ | { kind: "cloud" };
57
+
58
+ export type TopologyKind = Topology["kind"];
59
+
60
+ /**
61
+ * The Keeper path a cluster's `Replicated*MergeTree` gets: the server's own
62
+ * `default_replica_path`. `{uuid}` is the same on every host because the
63
+ * `CREATE` runs `ON CLUSTER`, and it does not change when the table is
64
+ * renamed or exchanged, so a rebuild's new table never shares the old one's
65
+ * path.
66
+ */
67
+ export const DEFAULT_REPLICA_PATH = "/clickhouse/tables/{uuid}/{shard}";
68
+ /** The replica name a cluster's `Replicated*MergeTree` gets: the server's `default_replica_name`. */
69
+ export const DEFAULT_REPLICA_NAME = "{replica}";
70
+
71
+ /** The Keeper path a `Replicated` database is created with in the `replicated` topology. */
72
+ export const replicatedDatabasePath = (database: string): string => `/clickhouse/databases/${database}`;
73
+
74
+ /**
75
+ * Read a topology from its string form, as a command-line flag or an
76
+ * environment variable gives it: `single`, `cluster:<name>`, `replicated`,
77
+ * `replicated:<cluster>`, `cloud`.
78
+ */
79
+ export function parseTopology(value: string): Topology {
80
+ const at = value.indexOf(":");
81
+ const kind = (at < 0 ? value : value.slice(0, at)).trim();
82
+ const arg = at < 0 ? undefined : value.slice(at + 1).trim();
83
+ switch (kind) {
84
+ case "single":
85
+ case "cloud":
86
+ if (arg !== undefined) throw new Error(`topology ${kind} takes no argument: ${JSON.stringify(value)}`);
87
+ return { kind };
88
+ case "cluster":
89
+ if (!arg) throw new Error(`topology cluster needs the cluster's name: cluster:<name>, got ${JSON.stringify(value)}`);
90
+ return { kind, cluster: arg };
91
+ case "replicated":
92
+ return arg ? { kind, cluster: arg } : { kind };
93
+ default:
94
+ throw new Error(`unknown topology ${JSON.stringify(value)}: expected single, cluster:<name>, replicated or cloud`);
95
+ }
96
+ }
97
+
98
+ // ── engines ───────────────────────────────────────────────────────────
99
+
100
+ /** An engine as a statement names it: its name and, if it has a parenthesised list, each argument's text. */
101
+ export interface EngineClause {
102
+ name: string;
103
+ args?: string[];
104
+ }
105
+
106
+ const FAMILY = /^(Replicated)?(\w*MergeTree)$/;
107
+ const isStringLiteral = (arg: string | undefined): boolean => arg !== undefined && /^'(?:[^'\\]|\\.)*'$/s.test(arg.trim());
108
+ const sqlString = (value: string): string => `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
109
+
110
+ /**
111
+ * A MergeTree-family engine split into its family and its own arguments,
112
+ * with the Keeper path and replica name a `Replicated*` engine names taken
113
+ * apart. Undefined for any other engine (`Log`, `Distributed`) and for a
114
+ * `Shared*` one, which only Cloud writes.
115
+ *
116
+ * As ClickHouse reads it, a `Replicated*MergeTree` names a path and replica
117
+ * when its first argument is a string literal; otherwise every argument is
118
+ * the family's own (`ReplicatedReplacingMergeTree(ver)`).
119
+ */
120
+ function mergeTreeFamily(engine: EngineClause): { family: string; replicated: boolean; replica?: [string, string]; args: string[] } | undefined {
121
+ const m = FAMILY.exec(engine.name);
122
+ if (!m || m[2]!.startsWith("Shared")) return undefined;
123
+ const args = (engine.args ?? []).map((a) => a.trim());
124
+ if (m[1] && isStringLiteral(args[0]) && isStringLiteral(args[1])) {
125
+ return { family: m[2]!, replicated: true, replica: [args[0]!, args[1]!], args: args.slice(2) };
126
+ }
127
+ return { family: m[2]!, replicated: m[1] !== undefined, args };
128
+ }
129
+
130
+ /**
131
+ * The engine a table gets on a topology. A MergeTree-family engine becomes
132
+ * `Replicated*` on a cluster (with the path and replica it declared, or the
133
+ * topology's) and in a Replicated database (with none); the plain family on
134
+ * a single node and on Cloud. Any other engine is returned as declared.
135
+ */
136
+ export function renderEngine(engine: EngineClause, topology: Topology): EngineClause {
137
+ const f = mergeTreeFamily(engine);
138
+ if (!f) return engine;
139
+ const parens = engine.args !== undefined;
140
+ const withArgs = (name: string, args: string[]): EngineClause => (args.length > 0 || parens ? { name, args } : { name });
141
+ switch (topology.kind) {
142
+ case "single":
143
+ case "cloud":
144
+ return withArgs(f.family, f.args);
145
+ case "replicated":
146
+ return withArgs(`Replicated${f.family}`, f.args);
147
+ case "cluster": {
148
+ const replica = f.replica ?? [sqlString(topology.replicaPath ?? DEFAULT_REPLICA_PATH), sqlString(topology.replicaName ?? DEFAULT_REPLICA_NAME)];
149
+ return { name: `Replicated${f.family}`, args: [...replica, ...f.args] };
150
+ }
151
+ }
152
+ }
153
+
154
+ const engineText = (e: EngineClause): string => (e.args === undefined ? e.name : `${e.name}(${e.args.join(", ")})`);
155
+
156
+ // ── statements ────────────────────────────────────────────────────────
157
+
158
+ /** `ON CLUSTER` and the name after it: a backquoted identifier, or a string literal for a macro (`'{cluster}'`). */
159
+ export function onClusterClause(cluster: string): string {
160
+ return `ON CLUSTER ${/[{}]/.test(cluster) ? sqlString(cluster) : `\`${cluster.replace(/`/g, "``")}\``}`;
161
+ }
162
+
163
+ interface Edit {
164
+ /** Token index the replaced range starts at. */
165
+ from: number;
166
+ /** Token index past its end; equal to `from` for an insertion. */
167
+ to: number;
168
+ text: string;
169
+ }
170
+
171
+ function applyEdits(tokens: Token[], edits: Edit[]): string {
172
+ const sorted = [...edits].sort((a, b) => a.from - b.from);
173
+ let out = "";
174
+ let at = 0;
175
+ for (const e of sorted) {
176
+ out += tokens.slice(at, e.from).map((t) => t.text).join("") + e.text;
177
+ at = e.to;
178
+ }
179
+ return out + tokens.slice(at).map((t) => t.text).join("");
180
+ }
181
+
182
+ const word = (t: Token | undefined, ...words: string[]): boolean => t !== undefined && t.kind === "ident" && words.includes(t.text.toUpperCase());
183
+ const isName = (t: Token | undefined): boolean => t !== undefined && (t.kind === "ident" || t.kind === "qident" || t.kind === "number" || t.kind === "ref");
184
+
185
+ /** A walker over the significant tokens, which knows each one's index in the full list. */
186
+ class Walk {
187
+ private readonly sig: number[];
188
+ p = 0;
189
+ constructor(readonly tokens: Token[]) {
190
+ this.sig = tokens.flatMap((t, i) => (isTrivia(t) ? [] : [i]));
191
+ }
192
+ peek(n = 0): Token | undefined {
193
+ const i = this.sig[this.p + n];
194
+ return i === undefined ? undefined : this.tokens[i];
195
+ }
196
+ /** Index in `tokens` of the significant token `n` ahead, or the end. */
197
+ index(n = 0): number {
198
+ return this.sig[this.p + n] ?? this.tokens.length;
199
+ }
200
+ /** Index in `tokens` just past the last significant token consumed. */
201
+ end(): number {
202
+ return this.p === 0 ? 0 : this.sig[this.p - 1]! + 1;
203
+ }
204
+ accept(...words: string[]): boolean {
205
+ if (!word(this.peek(), ...words)) return false;
206
+ this.p++;
207
+ return true;
208
+ }
209
+ acceptSeq(...words: string[]): boolean {
210
+ if (!words.every((w, i) => word(this.peek(i), w))) return false;
211
+ this.p += words.length;
212
+ return true;
213
+ }
214
+ /** `name` or `db.name`. False when no name is here. */
215
+ name(): boolean {
216
+ if (!isName(this.peek())) return false;
217
+ this.p++;
218
+ while (this.peek()?.text === "." && isName(this.peek(1))) this.p += 2;
219
+ return true;
220
+ }
221
+ /** The significant token index of the last top-level `SETTINGS`, outside any parentheses, or undefined. */
222
+ topLevelSettings(): number | undefined {
223
+ let depth = 0;
224
+ let found: number | undefined;
225
+ for (let k = this.p; k < this.sig.length; k++) {
226
+ const t = this.tokens[this.sig[k]!]!;
227
+ if (t.text === "(") depth++;
228
+ else if (t.text === ")") depth--;
229
+ else if (depth === 0 && word(t, "SETTINGS")) found = k;
230
+ }
231
+ return found;
232
+ }
233
+ /** Index in `tokens` of the significant token `k` (absolute), or the end of the statement. */
234
+ at(k: number): number {
235
+ return this.sig[k] ?? this.tokens.length;
236
+ }
237
+ /** Index in `tokens` past the last significant token, ignoring a trailing `;`. */
238
+ bodyEnd(): number {
239
+ let k = this.sig.length - 1;
240
+ if (k >= 0 && this.tokens[this.sig[k]!]!.text === ";") k--;
241
+ return k < 0 ? 0 : this.sig[k]! + 1;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * The edit that makes the `ON CLUSTER` at the walker's position what the
247
+ * topology wants: replaced with the topology's cluster, removed, or (when
248
+ * there is none and `cluster` is given) inserted at `insertAt`.
249
+ */
250
+ function onClusterEdit(w: Walk, cluster: string | undefined, insertAt: number): Edit | undefined {
251
+ if (word(w.peek(), "ON") && word(w.peek(1), "CLUSTER")) {
252
+ // Back to the end of the token before ON, so removing it takes its leading space too.
253
+ const from = w.end();
254
+ w.p += 2;
255
+ if (!w.name() && w.peek()?.kind === "string") w.p++;
256
+ const to = w.end();
257
+ return { from, to, text: cluster === undefined ? "" : ` ${onClusterClause(cluster)}` };
258
+ }
259
+ return cluster === undefined ? undefined : { from: insertAt, to: insertAt, text: ` ${onClusterClause(cluster)}` };
260
+ }
261
+
262
+ /** The cluster a statement on a table or view carries on this topology, if any. */
263
+ const objectCluster = (topology: Topology): string | undefined => (topology.kind === "cluster" ? topology.cluster : undefined);
264
+ /**
265
+ * The cluster a statement on a database itself carries on this topology, if
266
+ * any. In the replicated topology only creating and dropping the database
267
+ * reach every replica this way; an `ALTER` or `RENAME` of a Replicated
268
+ * database is replicated by the database.
269
+ */
270
+ const databaseCluster = (topology: Topology, createOrDrop = false): string | undefined =>
271
+ topology.kind === "cluster" ? topology.cluster : topology.kind === "replicated" && createOrDrop ? topology.cluster : undefined;
272
+
273
+ const text = (tokens: Token[], span: Span): string =>
274
+ tokens
275
+ .slice(span.from, span.to)
276
+ .map((t) => t.text)
277
+ .join("")
278
+ .trim();
279
+
280
+ /** The `ON CLUSTER` edit for a parsed `CREATE`: after the name and any `UUID '...'`. */
281
+ function createClusterEdit(tokens: Token[], node: CreateNode, cluster: string | undefined): Edit | undefined {
282
+ if (node.onCluster) {
283
+ let on = node.onCluster.from - 1;
284
+ // Back over the trivia and the CLUSTER and ON keywords, to the end of what came before.
285
+ let seen = 0;
286
+ while (on >= 0 && seen < 2) {
287
+ if (!isTrivia(tokens[on]!)) seen++;
288
+ on--;
289
+ }
290
+ while (on >= 0 && isTrivia(tokens[on]!)) on--;
291
+ return { from: on + 1, to: node.onCluster.to, text: cluster === undefined ? "" : ` ${onClusterClause(cluster)}` };
292
+ }
293
+ if (cluster === undefined) return undefined;
294
+ const at = afterName(tokens, node);
295
+ return { from: at, to: at, text: ` ${onClusterClause(cluster)}` };
296
+ }
297
+
298
+ /** Index in `tokens` past a `CREATE`'s name and any `UUID '...'` after it: where `ON CLUSTER` goes. */
299
+ function afterName(tokens: Token[], node: CreateNode): number {
300
+ const w = new Walk(tokens.slice(node.name.to));
301
+ if (w.accept("UUID") && w.peek()?.kind === "string") {
302
+ w.p++;
303
+ return node.name.to + w.end();
304
+ }
305
+ return node.name.to;
306
+ }
307
+
308
+ function renderCreate(tokens: Token[], node: CreateNode, topology: Topology): string {
309
+ const edits: Edit[] = [];
310
+ if (node.statement === "database") {
311
+ const cluster = createClusterEdit(tokens, node, databaseCluster(topology, true));
312
+ if (cluster) edits.push(cluster);
313
+ const declared = node.engine?.name;
314
+ if (topology.kind === "replicated") {
315
+ if (declared !== "Replicated") {
316
+ const name = unquote(text(tokens, node.name));
317
+ const clause = `ENGINE = Replicated(${sqlString(replicatedDatabasePath(name))}, '{shard}', '{replica}')`;
318
+ if (node.engine) edits.push({ from: node.engine.span.from, to: node.engine.span.to, text: clause });
319
+ else {
320
+ // After ON CLUSTER as declared, or where an inserted one goes; edits at one index apply in the order pushed.
321
+ const at = node.onCluster ? node.onCluster.to : afterName(tokens, node);
322
+ edits.push({ from: at, to: at, text: ` ${clause}` });
323
+ }
324
+ }
325
+ } else if (declared === "Replicated" && node.engine) {
326
+ // Only the replicated topology runs a Replicated database; elsewhere the server's default (Atomic, or Cloud's own).
327
+ let from = node.engine.span.from;
328
+ while (from > 0 && isTrivia(tokens[from - 1]!)) from--;
329
+ edits.push({ from, to: node.engine.span.to, text: "" });
330
+ }
331
+ return applyEdits(tokens, edits);
332
+ }
333
+
334
+ const cluster = createClusterEdit(tokens, node, objectCluster(topology));
335
+ if (cluster) edits.push(cluster);
336
+ if (node.engine) {
337
+ const declared: EngineClause = { name: node.engine.name, ...(node.engine.args ? { args: node.engine.args.map((a) => text(tokens, a)) } : {}) };
338
+ const rendered = renderEngine(declared, topology);
339
+ if (engineText(rendered) !== engineText(declared)) {
340
+ // From the engine's name to the end of its argument list; `ENGINE =` stays as written.
341
+ edits.push({ from: node.engine.nameSpan.from, to: node.engine.span.to, text: engineText(rendered) });
342
+ }
343
+ }
344
+ return applyEdits(tokens, edits);
345
+ }
346
+
347
+ /** A `CREATE` the parser does not take (`CREATE TABLE ... AS`, a dictionary): `ON CLUSTER` after the name, the rest as written. */
348
+ function renderOtherCreate(tokens: Token[], topology: Topology): string {
349
+ const w = new Walk(tokens);
350
+ w.accept("CREATE");
351
+ if (w.accept("OR")) w.accept("REPLACE");
352
+ w.accept("TEMPORARY");
353
+ let database = false;
354
+ if (w.accept("DATABASE")) database = true;
355
+ else if (w.accept("MATERIALIZED", "LIVE", "WINDOW")) w.accept("VIEW");
356
+ else if (!w.accept("TABLE", "VIEW", "DICTIONARY")) return applyEdits(tokens, []);
357
+ w.acceptSeq("IF", "NOT", "EXISTS");
358
+ if (!w.name()) return applyEdits(tokens, []);
359
+ let at = w.end();
360
+ if (w.accept("UUID") && w.peek()?.kind === "string") {
361
+ w.p++;
362
+ at = w.end();
363
+ }
364
+ const edit = onClusterEdit(w, database ? databaseCluster(topology) : objectCluster(topology), at);
365
+ return applyEdits(tokens, edit ? [edit] : []);
366
+ }
367
+
368
+ /**
369
+ * The `ON CLUSTER` for a statement whose clause follows the object's name:
370
+ * `ALTER`, `DROP`, `TRUNCATE`, `OPTIMIZE`, `DETACH`, `ATTACH`.
371
+ */
372
+ function renderAfterName(tokens: Token[], w: Walk, cluster: string | undefined): string {
373
+ w.acceptSeq("IF", "NOT", "EXISTS") || w.acceptSeq("IF", "EXISTS");
374
+ if (!w.name()) return applyEdits(tokens, []);
375
+ const edit = onClusterEdit(w, cluster, w.end());
376
+ return applyEdits(tokens, edit ? [edit] : []);
377
+ }
378
+
379
+ /** `RENAME ... TO ...` and `EXCHANGE ... AND ...`: `ON CLUSTER` at the end, before any `SETTINGS`. */
380
+ function renderAtEnd(tokens: Token[], w: Walk, cluster: string | undefined): string {
381
+ // An `ON CLUSTER` already written, after the renames.
382
+ for (let n = 0; w.peek(n) !== undefined; n++) {
383
+ if (word(w.peek(n), "ON") && word(w.peek(n + 1), "CLUSTER")) {
384
+ w.p += n;
385
+ return applyEdits(tokens, [onClusterEdit(w, cluster, 0)!]);
386
+ }
387
+ }
388
+ if (cluster === undefined) return applyEdits(tokens, []);
389
+ const settings = w.topLevelSettings();
390
+ let at: number;
391
+ if (settings !== undefined) {
392
+ at = w.at(settings);
393
+ while (at > 0 && isTrivia(tokens[at - 1]!)) at--;
394
+ } else at = w.bodyEnd();
395
+ return applyEdits(tokens, [{ from: at, to: at, text: ` ${onClusterClause(cluster)}` }]);
396
+ }
397
+
398
+ /**
399
+ * One statement as it runs on `topology`: `ON CLUSTER` added, replaced or
400
+ * removed, and in a `CREATE`, the engine the topology needs. Comments,
401
+ * spacing and everything else in the statement are kept as written.
402
+ * A statement that is not DDL on a database, table or view is returned
403
+ * unchanged, as is `KILL QUERY`/`KILL MUTATION` everywhere but a cluster,
404
+ * where it gains `ON CLUSTER` (inside a Replicated database
405
+ * `KILL QUERY ON CLUSTER '<db>'` names the database's own cluster and is kept).
406
+ */
407
+ export function renderStatement(sql: string, topology: Topology): string {
408
+ const tokens = tokenizeText(sql, 0);
409
+ const w = new Walk(tokens);
410
+ const head = w.peek();
411
+ if (word(head, "CREATE")) {
412
+ let node: CreateNode | undefined;
413
+ try {
414
+ node = parseCreate(tokens);
415
+ } catch {
416
+ node = undefined;
417
+ }
418
+ return node ? renderCreate(tokens, node, topology) : renderOtherCreate(tokens, topology);
419
+ }
420
+ if (word(head, "ALTER", "DROP", "DETACH", "ATTACH", "TRUNCATE", "OPTIMIZE", "UNDROP")) {
421
+ w.p++;
422
+ if (word(head, "DROP", "DETACH", "ATTACH")) w.accept("TEMPORARY");
423
+ if (w.accept("DATABASE")) return renderAfterName(tokens, w, databaseCluster(topology, word(head, "DROP")));
424
+ // `TRUNCATE t` may leave out TABLE.
425
+ if (w.accept("TABLE", "VIEW", "DICTIONARY") || word(head, "TRUNCATE")) return renderAfterName(tokens, w, objectCluster(topology));
426
+ return sql;
427
+ }
428
+ if (word(head, "RENAME")) {
429
+ w.p++;
430
+ if (w.accept("DATABASE")) return renderAtEnd(tokens, w, databaseCluster(topology));
431
+ if (w.accept("TABLE", "DICTIONARY")) return renderAtEnd(tokens, w, objectCluster(topology));
432
+ return sql;
433
+ }
434
+ if (word(head, "EXCHANGE")) {
435
+ w.p++;
436
+ if (w.accept("TABLES", "DICTIONARIES")) return renderAtEnd(tokens, w, objectCluster(topology));
437
+ return sql;
438
+ }
439
+ if (word(head, "KILL") && topology.kind === "cluster") {
440
+ w.p++;
441
+ if (!w.accept("QUERY", "MUTATION")) return sql;
442
+ const edit = onClusterEdit(w, topology.cluster, w.end());
443
+ return applyEdits(tokens, edit ? [edit] : []);
444
+ }
445
+ return sql;
446
+ }
447
+
448
+ /** Each step's statement rendered for `topology`; every other field of a step is kept. */
449
+ export function renderSteps<S extends { sql: string }>(steps: readonly S[], topology: Topology): S[] {
450
+ return steps.map((s) => ({ ...s, sql: renderStatement(s.sql, topology) }));
451
+ }
@@ -53,6 +53,23 @@ The plan only reports. [\`clickhouseApply\`](../applying/) makes the metadata-on
53
53
 
54
54
  \`chant migrate\` plays no part in any of this. It translates a file from one lexicon's format into another's, such as a GitHub Actions workflow into GitLab CI, and does not run schema migrations.
55
55
 
56
+ ## Statements for a migration file
57
+
58
+ \`chant sql diff base.json head.json --statements\` prints the statements that take the base schema to the head one, offline: each one the applier would send for the same change, after a comment naming its object, rule and class. \`--json\` prints them as one document, which \`diffStatements(before, after)\` from \`@intentius/chant-lexicon-sql\` also returns:
59
+
60
+ \`\`\`sql
61
+ -- events (analytics.events): SQLCH201 metadata
62
+ ALTER TABLE \`analytics\`.\`events\` ADD COLUMN region String DEFAULT 'eu' AFTER \`user_id\`;
63
+
64
+ -- events (analytics.events): SQLCH210 rewrite, waits for its mutation
65
+ ALTER TABLE \`analytics\`.\`events\` MODIFY COLUMN \`kind\` LowCardinality(String);
66
+
67
+ -- sessions (analytics.sessions): SQLCH220 made by ClickHouseRebuildOp, not a statement:
68
+ -- export const { op } = ClickHouseRebuildOp({ name: "rebuild-analytics-sessions", env: "<env>", table: "analytics.sessions", dualWrite: { mode: "materialized-view", cutoverColumn: "ts" } });
69
+ \`\`\`
70
+
71
+ A rebuild is never DDL: it is a step naming \`ClickHouseRebuildOp\`, or for a view or a database, which the Op does not rebuild, a manual step. A column or object drop is a statement marked destructive. Each \`CREATE\` carries the project's ownership marker, as the applier stamps it. The command exits 2 when a step is not a statement. Two things the applier asks the server are left out: whether a difference is formatting only, and whether a database is empty before it is dropped.
72
+
56
73
  ## Identity and renames
57
74
 
58
75
  An object is identified by its export name between two builds, so a changed name in the SQL under the same export is a rename. Against a server, which has no export names, an object is identified by \`database.name\`; a declaration renamed since the server last saw it says so with \`-- previously: <old name>\` before its CREATE.
@@ -44,6 +44,23 @@ chant sql plan prod schema.json # a build output against the prod server
44
44
 
45
45
  Both read the build's \`dialect\` and exit 2 when a change can only be made as expand and contract. \`--json\` prints the changes, the hints, the refused changes and the migration Op suggestions as one document. \`chant sql diff\` compares a pull request's base and head builds with no server. \`chant sql plan\` reads the server \`sql.profiles.<env>\` binds (else \`POSTGRES_URL\`), in the profile's \`schemas\` when it lists them, otherwise in the default schema and every schema the build declares an object in. Where the normalization rules leave an expression different, it asks that server: the declared view, or the table's column types, defaults, generated expressions and checks, are created as temporary objects in a transaction that is always rolled back, and read back with the same printers, so a view's \`SELECT *\` and the server's added parentheses are not reported as changes.
46
46
 
47
+ ## Statements for a migration file
48
+
49
+ \`chant sql diff base.json head.json --statements\` prints the statements that take the base schema to the head one, offline: each one the applier would send for the same change, after a comment naming its object, rule and class, and whether it has to run outside a transaction block. \`--json\` prints them as one document, which \`diffStatements(before, after)\` from \`@intentius/chant-lexicon-sql\` also returns, each statement with \`transactional\` set:
50
+
51
+ \`\`\`sql
52
+ -- users (app.users): SQLPG201 metadata
53
+ ALTER TABLE app.users ADD COLUMN name text DEFAULT 'none' NOT NULL;
54
+
55
+ -- usersEmail (app.users_email_idx): SQLPG240 concurrently, outside a transaction
56
+ CREATE INDEX CONCURRENTLY users_email_idx ON app.users (email);
57
+
58
+ -- orders (app.orders): SQLPG205 made by PostgresMigrationOp, not a statement:
59
+ -- export const { op } = PostgresMigrationOp({ name: "migrate-app-orders-reference", env: "<env>", table: "app.orders", column: "reference" });
60
+ \`\`\`
61
+
62
+ A column rename or a type change across kinds is never DDL: it is a step naming \`PostgresMigrationOp\`, one per column. Any other expand-and-contract change, and the refused table's other changes, which the applier also holds back, are a manual step. A column or object drop is a statement marked destructive. Statements use the default schema for a bare name, as the applier sets \`search_path\`. The command exits 2 when a step is not a statement. What the applier asks the server is left out: its normalization of expressions the rules leave different, and an extension's own comment.
63
+
47
64
  ## Which major
48
65
 
49
66
  Two rules change class across the supported majors: a STORED generated column's expression (SQLPG212) is a rewrite from 17 and expand and contract before it, and a table's access method (SQLPG226) is a rewrite from 15 and expand and contract before it.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The statements a schema change takes, rendered offline (#3644), the same
3
+ * shape in every dialect: what `diffStatements`
4
+ * (`../migration-statements.ts`) returns and `chant sql diff --statements
5
+ * --json` prints, for a migration file to hold.
6
+ *
7
+ * Every value is plain JSON: strings, booleans, arrays and objects, no
8
+ * `undefined` left in, so the document reads back as it was written. Steps
9
+ * are in the order they run.
10
+ */
11
+
12
+ /** A classified change, as a step names the changes it makes. */
13
+ export interface StatementChange {
14
+ /** The classifier rule, e.g. `SQLCH201`, `SQLPG240`. */
15
+ rule: string;
16
+ /** That rule's class, e.g. `metadata`, `rewrite`, `concurrently`. */
17
+ class: string;
18
+ /** What changed, e.g. `columns.region`, `orderBy`. */
19
+ field: string;
20
+ before?: string;
21
+ after?: string;
22
+ /** Data is removed and not recoverable. */
23
+ destructive?: boolean;
24
+ note?: string;
25
+ }
26
+
27
+ /** What every step says about the object it is for. */
28
+ export interface StepObject {
29
+ /** The object's export name: in the newer build, or the older one for an object it drops. */
30
+ object: string;
31
+ /** Its entity type, e.g. `ClickHouse::Table`, `Postgres::Index`. */
32
+ type: string;
33
+ /** Its name on the server: `database.name` or `schema.name`, or a database's, schema's or extension's own name. */
34
+ name: string;
35
+ }
36
+
37
+ /** One statement, as the applier would send it. */
38
+ export interface StatementStep extends StepObject {
39
+ kind: "statement";
40
+ sql: string;
41
+ /** The rule of the change it makes. */
42
+ rule: string;
43
+ /** That rule's class. */
44
+ class: string;
45
+ /** False for a statement that may not run inside a transaction block (Postgres `CONCURRENTLY`, `ALTER TYPE ... ADD VALUE`). Always false for ClickHouse, which has none. */
46
+ transactional: boolean;
47
+ /** ClickHouse: the statement starts a mutation; wait for it in `system.mutations` before the next one. */
48
+ waitsForMutation?: boolean;
49
+ /** The statement removes data that is not recoverable (a column or table drop). */
50
+ destructive?: boolean;
51
+ }
52
+
53
+ /** A change no statement makes in place, made by a migration Op. */
54
+ export interface OpStep extends StepObject {
55
+ kind: "op";
56
+ /** The Op's export name. */
57
+ op: "ClickHouseRebuildOp" | "PostgresMigrationOp";
58
+ /** The module the Op is imported from. */
59
+ importPath: string;
60
+ /** The Op's options as the hand-off suggests them: `name`, `env`, `table`, and `dualWrite` (ClickHouse) or `column` (Postgres). */
61
+ options: Record<string, unknown>;
62
+ /** The Op's declaration, ready for an `*.op.ts` file. */
63
+ declaration: string;
64
+ /** The changes the Op makes. */
65
+ changes: StatementChange[];
66
+ /** The refusal as the applier reports it: each rule, its restriction, and the Op. */
67
+ detail: string;
68
+ }
69
+
70
+ /**
71
+ * A change neither a statement nor an Op makes: a rebuild of a view or a
72
+ * database, an expand-and-contract change no Op covers, a change with no
73
+ * in-place statement, or the object's other changes that the applier holds
74
+ * back while one of its changes is refused.
75
+ */
76
+ export interface ManualStep extends StepObject {
77
+ kind: "manual";
78
+ changes: StatementChange[];
79
+ detail: string;
80
+ }
81
+
82
+ export type MigrationStep = StatementStep | OpStep | ManualStep;
83
+
84
+ export interface SchemaStatements {
85
+ dialect: "clickhouse" | "postgres";
86
+ /** ClickHouse: the database a bare name means. */
87
+ defaultDatabase?: string;
88
+ /** Postgres: the schema a bare name means; the applier sets `search_path` to it. */
89
+ defaultSchema?: string;
90
+ /** Postgres: the major the changes were classified for. */
91
+ major?: number;
92
+ /** In the order they run: each object's in the newer build's creation order, then the drops. */
93
+ steps: MigrationStep[];
94
+ /** True when a step is an Op or manual: the schema change is more than statements. */
95
+ refused: boolean;
96
+ /** What the diff points out besides the changes (a likely rename written as a drop and an add). */
97
+ hints: string[];
98
+ }
99
+
100
+ /** A classified change as a step names it, with no `undefined` fields. */
101
+ export function statementChange(c: { rule: string; class: string; field: string; before?: string; after?: string; destructive?: boolean; note?: string }): StatementChange {
102
+ return {
103
+ rule: c.rule,
104
+ class: c.class,
105
+ field: c.field,
106
+ ...(c.before !== undefined ? { before: c.before } : {}),
107
+ ...(c.after !== undefined ? { after: c.after } : {}),
108
+ ...(c.destructive ? { destructive: true } : {}),
109
+ ...(c.note !== undefined ? { note: c.note } : {}),
110
+ };
111
+ }
112
+
113
+ /** The steps as a migration file's SQL: each statement after a comment naming its object, rule and class; an Op or manual step as a comment. */
114
+ export function renderStatements(doc: SchemaStatements): string {
115
+ const out: string[] = [];
116
+ for (const s of doc.steps) {
117
+ if (s.kind === "statement") {
118
+ const notes = [s.transactional || doc.dialect !== "postgres" ? "" : "outside a transaction", s.waitsForMutation ? "waits for its mutation" : "", s.destructive ? "destructive" : ""].filter(Boolean);
119
+ out.push(`-- ${s.object} (${s.name}): ${s.rule} ${s.class}${notes.length ? `, ${notes.join(", ")}` : ""}`, `${s.sql};`, "");
120
+ } else if (s.kind === "op") {
121
+ out.push(`-- ${s.object} (${s.name}): ${s.changes.map((c) => c.rule).join(", ")} made by ${s.op}, not a statement:`, `-- ${s.declaration}`, "");
122
+ } else {
123
+ out.push(`-- ${s.object} (${s.name}): ${s.changes.map((c) => c.rule).join(", ")} has no statement and no Op: ${s.detail}`, "");
124
+ }
125
+ }
126
+ return out.join("\n");
127
+ }
package/src/index.ts CHANGED
@@ -19,3 +19,18 @@ export { database, table, view, literal } from "./clickhouse/entities";
19
19
  // `@intentius/chant-lexicon-sql/postgres` is Postgres's. Declarations import
20
20
  // them from the dialect subpath.
21
21
  export { schema, index, sequence, type, domain, extension } from "./postgres/entities";
22
+
23
+ // A schema change's statements between two builds, rendered offline for a
24
+ // migration file (#3644); also at `@intentius/chant-lexicon-sql/migration-statements`.
25
+ export {
26
+ diffStatements,
27
+ renderStatements,
28
+ type DiffStatementsOptions,
29
+ type ManualStep,
30
+ type MigrationStep,
31
+ type OpStep,
32
+ type SchemaStatements,
33
+ type StatementChange,
34
+ type StatementStep,
35
+ type StepObject,
36
+ } from "./migration-statements";