@intentius/chant-lexicon-sql 0.112.1 → 0.114.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 (70) hide show
  1. package/dist/clickhouse/apply/apply.d.ts +6 -1
  2. package/dist/clickhouse/apply/apply.d.ts.map +1 -1
  3. package/dist/clickhouse/apply/statements.d.ts +85 -1
  4. package/dist/clickhouse/apply/statements.d.ts.map +1 -1
  5. package/dist/clickhouse/live/bind.d.ts +11 -1
  6. package/dist/clickhouse/live/bind.d.ts.map +1 -1
  7. package/dist/clickhouse/plan/commands.d.ts +12 -1
  8. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  9. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  10. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  11. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  12. package/dist/clickhouse/plan/schema.d.ts +4 -3
  13. package/dist/clickhouse/plan/schema.d.ts.map +1 -1
  14. package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
  15. package/dist/clickhouse/rebuild/receipts.d.ts +5 -0
  16. package/dist/clickhouse/rebuild/receipts.d.ts.map +1 -1
  17. package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
  18. package/dist/clickhouse/topology.d.ts +112 -0
  19. package/dist/clickhouse/topology.d.ts.map +1 -0
  20. package/dist/clickhouse.d.ts +1 -0
  21. package/dist/clickhouse.d.ts.map +1 -1
  22. package/dist/codegen/classifier-page.d.ts.map +1 -1
  23. package/dist/codegen/postgres-classifier-page.d.ts.map +1 -1
  24. package/dist/config.d.ts +46 -0
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/core/statements.d.ts +106 -0
  27. package/dist/core/statements.d.ts.map +1 -0
  28. package/dist/index.d.ts +1 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/integrity.json +2 -2
  31. package/dist/manifest.json +1 -1
  32. package/dist/migration-statements.d.ts +63 -0
  33. package/dist/migration-statements.d.ts.map +1 -0
  34. package/dist/postgres/apply/apply.d.ts.map +1 -1
  35. package/dist/postgres/apply/statements.d.ts +91 -1
  36. package/dist/postgres/apply/statements.d.ts.map +1 -1
  37. package/package.json +2 -2
  38. package/src/clickhouse/apply/apply.ts +35 -56
  39. package/src/clickhouse/apply/statements.ts +168 -31
  40. package/src/clickhouse/live/bind.ts +13 -1
  41. package/src/clickhouse/live/live.test.ts +10 -0
  42. package/src/clickhouse/plan/commands.ts +63 -9
  43. package/src/clickhouse/plan/deep.ts +2 -1
  44. package/src/clickhouse/plan/diff.ts +3 -1
  45. package/src/clickhouse/plan/normalize.ts +11 -2
  46. package/src/clickhouse/plan/schema.ts +6 -5
  47. package/src/clickhouse/rebuild/backfill.ts +1 -0
  48. package/src/clickhouse/rebuild/receipts.ts +14 -4
  49. package/src/clickhouse/rebuild/steps.ts +8 -5
  50. package/src/clickhouse/topology-replicated.e2e.test.ts +125 -0
  51. package/src/clickhouse/topology.e2e.test.ts +183 -0
  52. package/src/clickhouse/topology.test.ts +309 -0
  53. package/src/clickhouse/topology.ts +477 -0
  54. package/src/clickhouse.ts +16 -0
  55. package/src/codegen/classifier-page.ts +19 -0
  56. package/src/codegen/postgres-classifier-page.ts +17 -0
  57. package/src/config.ts +33 -0
  58. package/src/core/statements.ts +129 -0
  59. package/src/index.ts +15 -0
  60. package/src/migration-statements.test.ts +384 -0
  61. package/src/migration-statements.ts +262 -0
  62. package/src/op/__snapshots__/watch-pipeline.test.ts.snap +93 -0
  63. package/src/op/activities/clickhouse-apply.ts +1 -1
  64. package/src/op/activities/clickhouse-rebuild.ts +1 -1
  65. package/src/op/apply-gate-create.e2e.test.ts +280 -0
  66. package/src/op/watch-drift.e2e.test.ts +302 -0
  67. package/src/op/watch-pipeline.test.ts +113 -0
  68. package/src/plugin.test.ts +9 -0
  69. package/src/postgres/apply/apply.ts +38 -96
  70. package/src/postgres/apply/statements.ts +145 -2
@@ -0,0 +1,477 @@
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
+ }
452
+
453
+ /**
454
+ * A statement for a topology that may not be configured: with none, the
455
+ * statement as the source wrote it. The applier, the plan and the rebuild
456
+ * migration render only when an environment names its topology
457
+ * (`sql.profiles.<env>.topology`, `CLICKHOUSE_TOPOLOGY`), so a project that
458
+ * names none sends what it declared, as before.
459
+ */
460
+ export const renderFor = (sql: string, topology: Topology | undefined): string => (topology ? renderStatement(sql, topology) : sql);
461
+
462
+ /** A topology from its string form or its object form (`sql.profiles.<env>.topology`). */
463
+ export function toTopology(value: string | Topology): Topology {
464
+ return typeof value === "string" ? parseTopology(value) : value;
465
+ }
466
+
467
+ /** The topology's string form, as `parseTopology` reads it. Replica path options are not part of it. */
468
+ export function topologyLabel(topology: Topology): string {
469
+ switch (topology.kind) {
470
+ case "cluster":
471
+ return `cluster:${topology.cluster}`;
472
+ case "replicated":
473
+ return topology.cluster ? `replicated:${topology.cluster}` : "replicated";
474
+ default:
475
+ return topology.kind;
476
+ }
477
+ }
package/src/clickhouse.ts CHANGED
@@ -61,3 +61,19 @@ export {
61
61
  } from "./clickhouse/rebuild/op";
62
62
  export type { DualWrite } from "./clickhouse/rebuild/observe";
63
63
  export { RECEIPTS_DATABASE, RECEIPTS_TABLE } from "./clickhouse/rebuild/receipts";
64
+ export {
65
+ DEFAULT_REPLICA_NAME,
66
+ DEFAULT_REPLICA_PATH,
67
+ onClusterClause,
68
+ parseTopology,
69
+ renderEngine,
70
+ renderFor,
71
+ renderStatement,
72
+ renderSteps,
73
+ replicatedDatabasePath,
74
+ topologyLabel,
75
+ toTopology,
76
+ type EngineClause,
77
+ type Topology,
78
+ type TopologyKind,
79
+ } from "./clickhouse/topology";
@@ -53,6 +53,25 @@ 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
+
73
+ \`--topology\` renders the statements for where they will run: \`single\` (the default), \`cluster:<name>\`, \`replicated\` or \`cloud\`. On a cluster every statement carries \`ON CLUSTER\` and a MergeTree-family table becomes \`Replicated*MergeTree\` with its Keeper path; in a \`Replicated\` database the tables are \`Replicated*MergeTree\` with no path and no \`ON CLUSTER\`. \`diffStatements(before, after, { topology })\` takes the same choice; see [Applying to a Server](../applying/#topology).
74
+
56
75
  ## Identity and renames
57
76
 
58
77
  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.
package/src/config.ts CHANGED
@@ -45,6 +45,7 @@ import type { ChantConfig } from "@intentius/chant/config";
45
45
  import { PLANNED_SQL_DIALECTS, SQL_DIALECTS } from "./dialects";
46
46
  import { POSTGRES_PROVIDERS } from "./postgres/providers/types";
47
47
  import { POSTGRES_MAJORS } from "./spec/postgres-pin";
48
+ import { parseTopology } from "./clickhouse/topology";
48
49
 
49
50
  const envRef = z.strictObject({ env: z.string() });
50
51
 
@@ -53,6 +54,27 @@ const providerName = z.enum(POSTGRES_PROVIDERS, {
53
54
  error: () => `expected a Postgres provider (${POSTGRES_PROVIDERS.join(", ")})`,
54
55
  });
55
56
 
57
+ /** A ClickHouse topology, in its string form or its object form (`./clickhouse/topology.ts`). */
58
+ const topologySchema = z.union([
59
+ z.string().refine(
60
+ (v) => {
61
+ try {
62
+ parseTopology(v);
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ },
68
+ { error: "expected single, cluster:<name>, replicated, replicated:<cluster> or cloud" },
69
+ ),
70
+ z.discriminatedUnion("kind", [
71
+ z.strictObject({ kind: z.literal("single") }),
72
+ z.strictObject({ kind: z.literal("cluster"), cluster: z.string().min(1), replicaPath: z.string().optional(), replicaName: z.string().optional() }),
73
+ z.strictObject({ kind: z.literal("replicated"), cluster: z.string().min(1).optional() }),
74
+ z.strictObject({ kind: z.literal("cloud") }),
75
+ ]),
76
+ ]);
77
+
56
78
  export const sqlProfileSchema = z.strictObject({
57
79
  /**
58
80
  * Where the server is. ClickHouse: the base URL of its HTTP interface
@@ -73,6 +95,17 @@ export const sqlProfileSchema = z.strictObject({
73
95
  databases: z.array(z.string()).optional(),
74
96
  /** The database an unqualified declaration is created in. `default` when omitted. */
75
97
  defaultDatabase: z.string().optional(),
98
+ /**
99
+ * ClickHouse: the topology this environment runs, which the applier, the
100
+ * plan and the rebuild migration render every statement for: `single`,
101
+ * `cluster:<name>` (`ON CLUSTER`, `Replicated*MergeTree` with a Keeper
102
+ * path), `replicated` (a `Replicated` database; `replicated:<cluster>`
103
+ * creates the databases on that cluster) or `cloud`. The object form sets
104
+ * a cluster's Keeper path and replica name:
105
+ * `{ kind: "cluster", cluster: "main", replicaPath, replicaName }`. When
106
+ * omitted, statements are sent as declared.
107
+ */
108
+ topology: topologySchema.optional(),
76
109
  /**
77
110
  * Postgres: the schemas this environment's schema lives in. Import and
78
111
  * observation read these and nothing else; when omitted, every schema except