@intentius/chant-lexicon-sql 0.113.0 → 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.
- package/dist/clickhouse/apply/apply.d.ts +6 -1
- package/dist/clickhouse/apply/apply.d.ts.map +1 -1
- package/dist/clickhouse/apply/statements.d.ts +8 -0
- package/dist/clickhouse/apply/statements.d.ts.map +1 -1
- package/dist/clickhouse/live/bind.d.ts +11 -1
- package/dist/clickhouse/live/bind.d.ts.map +1 -1
- package/dist/clickhouse/plan/commands.d.ts +7 -1
- package/dist/clickhouse/plan/commands.d.ts.map +1 -1
- package/dist/clickhouse/plan/deep.d.ts.map +1 -1
- package/dist/clickhouse/plan/diff.d.ts.map +1 -1
- package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
- package/dist/clickhouse/plan/schema.d.ts +4 -3
- package/dist/clickhouse/plan/schema.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/receipts.d.ts +5 -0
- package/dist/clickhouse/rebuild/receipts.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
- package/dist/clickhouse/topology.d.ts +12 -0
- package/dist/clickhouse/topology.d.ts.map +1 -1
- package/dist/clickhouse.d.ts +1 -0
- package/dist/clickhouse.d.ts.map +1 -1
- package/dist/codegen/classifier-page.d.ts.map +1 -1
- package/dist/config.d.ts +46 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/core/statements.d.ts +2 -0
- package/dist/core/statements.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/migration-statements.d.ts +8 -0
- package/dist/migration-statements.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/apply/apply.ts +10 -3
- package/src/clickhouse/apply/statements.ts +19 -0
- package/src/clickhouse/live/bind.ts +13 -1
- package/src/clickhouse/live/live.test.ts +10 -0
- package/src/clickhouse/plan/commands.ts +42 -12
- package/src/clickhouse/plan/deep.ts +2 -1
- package/src/clickhouse/plan/diff.ts +3 -1
- package/src/clickhouse/plan/normalize.ts +11 -2
- package/src/clickhouse/plan/schema.ts +6 -5
- package/src/clickhouse/rebuild/backfill.ts +1 -0
- package/src/clickhouse/rebuild/receipts.ts +14 -4
- package/src/clickhouse/rebuild/steps.ts +8 -5
- package/src/clickhouse/topology-replicated.e2e.test.ts +125 -0
- package/src/clickhouse/topology.e2e.test.ts +84 -1
- package/src/clickhouse/topology.test.ts +33 -0
- package/src/clickhouse/topology.ts +26 -0
- package/src/clickhouse.ts +16 -0
- package/src/codegen/classifier-page.ts +2 -0
- package/src/config.ts +33 -0
- package/src/core/statements.ts +2 -0
- package/src/migration-statements.test.ts +120 -1
- package/src/migration-statements.ts +13 -3
- package/src/op/activities/clickhouse-apply.ts +1 -1
- package/src/op/activities/clickhouse-rebuild.ts +1 -1
- package/src/op/apply-gate-create.e2e.test.ts +280 -0
- package/src/plugin.test.ts +9 -0
|
@@ -65,6 +65,16 @@ describe("binding an environment to a server", () => {
|
|
|
65
65
|
expect(isUnresolvedTarget(t) && t.reason).toBe("no-credentials");
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
test("the topology comes from the profile, or CLICKHOUSE_TOPOLOGY, and is undefined when neither names one (#3645)", () => {
|
|
69
|
+
const profile = (topology: unknown) =>
|
|
70
|
+
resolveClickHouseTarget({ environment: "prod", config: { sql: { profiles: { prod: { url: "http://ch:8123", topology } } } } as never, env: {} });
|
|
71
|
+
expect(profile("cluster:main")).toMatchObject({ topology: { kind: "cluster", cluster: "main" } });
|
|
72
|
+
expect(profile({ kind: "replicated", cluster: "all" })).toMatchObject({ topology: { kind: "replicated", cluster: "all" } });
|
|
73
|
+
expect(resolveClickHouseTarget({ env: { CLICKHOUSE_URL: "http://ch:8123", CLICKHOUSE_TOPOLOGY: "cloud" } })).toMatchObject({ topology: { kind: "cloud" } });
|
|
74
|
+
expect("topology" in resolveClickHouseTarget({ env: { CLICKHOUSE_URL: "http://ch:8123" } })).toBe(false);
|
|
75
|
+
expect(() => resolveClickHouseTarget({ env: { CLICKHOUSE_URL: "http://ch:8123", CLICKHOUSE_TOPOLOGY: "sharded" } })).toThrow(/unknown topology/);
|
|
76
|
+
});
|
|
77
|
+
|
|
68
78
|
test("no profile and no CLICKHOUSE_URL is no-binding", () => {
|
|
69
79
|
const t = resolveClickHouseTarget({ environment: "dev", config: {}, env: {} });
|
|
70
80
|
expect(isUnresolvedTarget(t) && t.reason).toBe("no-binding");
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* says `"dialect": "postgres"`) or a Postgres binding goes to
|
|
5
5
|
* `../../postgres/plan/commands.ts`.
|
|
6
6
|
*
|
|
7
|
-
* chant sql diff <before.json> <after.json> [--json] [--statements]
|
|
7
|
+
* chant sql diff <before.json> <after.json> [--json] [--statements] [--topology <topology>]
|
|
8
8
|
* chant sql plan <env> <build.json> [--json]
|
|
9
9
|
*
|
|
10
10
|
* `diff` is offline: it compares two `chant build` outputs, say a pull
|
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
* each one the applier would send, with its change's rule and class, and a
|
|
20
20
|
* refused change as the Op that makes it (`../../migration-statements.ts`).
|
|
21
21
|
* The marker in each `CREATE` is the project's `ownership` config's.
|
|
22
|
+
*
|
|
23
|
+
* `--topology` (`single`, `cluster:<name>`, `replicated`, `replicated:<cluster>`,
|
|
24
|
+
* `cloud`; `../topology.ts`) renders a ClickHouse diff's declarations and
|
|
25
|
+
* statements for that topology: `ON CLUSTER` and the engine. `--statements`
|
|
26
|
+
* renders for `single` without it; the plain diff compares the declarations as
|
|
27
|
+
* written. `plan` takes the topology from `sql.profiles.<env>.topology`.
|
|
22
28
|
*/
|
|
23
29
|
|
|
24
30
|
import type { CommandGroup, CommandGroupContext } from "@intentius/chant/cli/command-group";
|
|
@@ -29,6 +35,7 @@ import { dropFormattingOnly } from "./server-format";
|
|
|
29
35
|
import { bindClickHouse } from "../live/bind";
|
|
30
36
|
import { rebuildOpSuggestions } from "./rebuild-handoff";
|
|
31
37
|
import { readFileSync } from "node:fs";
|
|
38
|
+
import { parseTopology, type Topology } from "../topology";
|
|
32
39
|
|
|
33
40
|
/** The dialect a build output names. */
|
|
34
41
|
function outputDialect(path: string): string | undefined {
|
|
@@ -39,12 +46,28 @@ function outputDialect(path: string): string | undefined {
|
|
|
39
46
|
}
|
|
40
47
|
}
|
|
41
48
|
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
interface DiffArgs {
|
|
50
|
+
positional: string[];
|
|
51
|
+
json: boolean;
|
|
52
|
+
statements: boolean;
|
|
53
|
+
/** `--topology <t>` or `--topology=<t>`, as written. */
|
|
54
|
+
topology?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function split(args: string[]): DiffArgs {
|
|
58
|
+
const positional: string[] = [];
|
|
59
|
+
let topology: string | undefined;
|
|
60
|
+
for (let i = 0; i < args.length; i++) {
|
|
61
|
+
const a = args[i]!;
|
|
62
|
+
if (a === "--topology") topology = args[++i] ?? "";
|
|
63
|
+
else if (a.startsWith("--topology=")) topology = a.slice("--topology=".length);
|
|
64
|
+
else if (!a.startsWith("--")) positional.push(a);
|
|
65
|
+
}
|
|
66
|
+
return { positional, json: args.includes("--json"), statements: args.includes("--statements"), ...(topology !== undefined ? { topology } : {}) };
|
|
44
67
|
}
|
|
45
68
|
|
|
46
69
|
/** `chant sql diff --statements`: the statements between two builds, as SQL with comments or as JSON. Exits 2 when a step is an Op or manual. */
|
|
47
|
-
async function runDiffStatements(before: string, after: string, json: boolean): Promise<number> {
|
|
70
|
+
async function runDiffStatements(before: string, after: string, json: boolean, topology: Topology | undefined): Promise<number> {
|
|
48
71
|
const { diffStatements, renderStatements } = await import("../../migration-statements");
|
|
49
72
|
const { resolveOwnershipMarker } = await import("../../core/apply");
|
|
50
73
|
let marker;
|
|
@@ -56,7 +79,7 @@ async function runDiffStatements(before: string, after: string, json: boolean):
|
|
|
56
79
|
console.error(err instanceof Error ? err.message : String(err));
|
|
57
80
|
return 1;
|
|
58
81
|
}
|
|
59
|
-
const doc = diffStatements(readFileSync(before, "utf-8"), readFileSync(after, "utf-8"), marker ? { marker } : {});
|
|
82
|
+
const doc = diffStatements(readFileSync(before, "utf-8"), readFileSync(after, "utf-8"), { ...(marker ? { marker } : {}), ...(topology ? { topology } : {}) });
|
|
60
83
|
console.log(json ? JSON.stringify(doc, null, 2) : renderStatements(doc));
|
|
61
84
|
return doc.refused ? 2 : 0;
|
|
62
85
|
}
|
|
@@ -67,18 +90,25 @@ function emit(diff: SchemaDiff, json: boolean, title: string): number {
|
|
|
67
90
|
}
|
|
68
91
|
|
|
69
92
|
export async function runDiff(ctx: CommandGroupContext): Promise<number> {
|
|
70
|
-
const { positional, json, statements } = split(ctx.rawArgs);
|
|
93
|
+
const { positional, json, statements, topology: topologyArg } = split(ctx.rawArgs);
|
|
71
94
|
if (positional.length !== 2) {
|
|
72
|
-
console.error("usage: chant sql diff <before.json> <after.json> [--json] [--statements]");
|
|
95
|
+
console.error("usage: chant sql diff <before.json> <after.json> [--json] [--statements] [--topology <topology>]");
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
let topology: Topology | undefined;
|
|
99
|
+
try {
|
|
100
|
+
topology = topologyArg !== undefined ? parseTopology(topologyArg) : undefined;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error(`--topology: ${(err as Error).message}`);
|
|
73
103
|
return 1;
|
|
74
104
|
}
|
|
75
|
-
if (statements) return runDiffStatements(positional[0]!, positional[1]!, json);
|
|
105
|
+
if (statements) return runDiffStatements(positional[0]!, positional[1]!, json, topology);
|
|
76
106
|
if (outputDialect(positional[1]!) === "postgres") {
|
|
77
107
|
const { diffPgBuildFiles, emitPg, projectMajor } = await import("../../postgres/plan/commands");
|
|
78
108
|
return emitPg(diffPgBuildFiles(positional[0]!, positional[1]!, await projectMajor()), json, `${positional[0]} -> ${positional[1]}`);
|
|
79
109
|
}
|
|
80
|
-
const after = schemaFromBuildFile(positional[1]
|
|
81
|
-
const diff = diffSchemas(schemaFromBuildFile(positional[0]
|
|
110
|
+
const after = schemaFromBuildFile(positional[1]!, "default", topology);
|
|
111
|
+
const diff = diffSchemas(schemaFromBuildFile(positional[0]!, "default", topology), after);
|
|
82
112
|
const rebuildOps = rebuildOpSuggestions(diff, new Map(after.map((o) => [o.key, o.canonical])), "<env>");
|
|
83
113
|
return emit(rebuildOps.length > 0 ? { ...diff, rebuildOps } : diff, json, `${positional[0]} -> ${positional[1]}`);
|
|
84
114
|
}
|
|
@@ -86,7 +116,7 @@ export async function runDiff(ctx: CommandGroupContext): Promise<number> {
|
|
|
86
116
|
/** The declared objects against the server, keys and labels by `database.name`. */
|
|
87
117
|
export async function planAgainstServer(environment: string, buildFile: string, options: Parameters<typeof bindClickHouse>[0] = {}): Promise<SchemaDiff> {
|
|
88
118
|
const target = await bindClickHouse({ ...options, environment });
|
|
89
|
-
const declared = keyedByQualifiedName(schemaFromBuildFile(buildFile, target.defaultDatabase));
|
|
119
|
+
const declared = keyedByQualifiedName(schemaFromBuildFile(buildFile, target.defaultDatabase, target.topology));
|
|
90
120
|
const databases = new Set(declared.map((o) => o.canonical.database ?? o.canonical.name));
|
|
91
121
|
const live = await schemaFromServer(target, databases);
|
|
92
122
|
const diff = diffSchemas(live, declared);
|
|
@@ -119,7 +149,7 @@ export const sqlCommands: CommandGroup = {
|
|
|
119
149
|
name: "sql",
|
|
120
150
|
description: "Schema changes, classified: between two builds, or a build and a live server",
|
|
121
151
|
commands: [
|
|
122
|
-
{ name: "diff", description: "Classify the change between two chant build outputs (offline); --statements prints the statements it takes", handler: runDiff },
|
|
152
|
+
{ name: "diff", description: "Classify the change between two chant build outputs (offline); --statements prints the statements it takes, --topology renders them for single, cluster:<name>, replicated or cloud", handler: runDiff },
|
|
123
153
|
{ name: "plan", description: "Classify the change from an environment's server to a chant build output", handler: runPlan },
|
|
124
154
|
],
|
|
125
155
|
};
|
|
@@ -20,6 +20,7 @@ import { bindClickHouse, classifyClickHouseFailure, type BindOptions } from "../
|
|
|
20
20
|
import { readLiveSchema, type LiveObject } from "../live/catalog";
|
|
21
21
|
import { database, table, view, CLICKHOUSE_ENTITY_TYPES, type ColumnDef } from "../entities";
|
|
22
22
|
import { canonicalObject, type CanonicalColumn, type CanonicalObject } from "./normalize";
|
|
23
|
+
import { renderFor } from "../topology";
|
|
23
24
|
|
|
24
25
|
type Props = Record<string, unknown>;
|
|
25
26
|
|
|
@@ -109,7 +110,7 @@ export async function observeResourcesDeep(
|
|
|
109
110
|
const o = byKey.get(`${db ?? ""}.${String(props.name)}`);
|
|
110
111
|
if (!o) continue;
|
|
111
112
|
try {
|
|
112
|
-
const d = canonicalObject(String(props.ddl), target.defaultDatabase);
|
|
113
|
+
const d = canonicalObject(renderFor(String(props.ddl), target.topology), target.defaultDatabase);
|
|
113
114
|
const l = canonicalObject(o.statement, target.defaultDatabase);
|
|
114
115
|
resources[name] = {
|
|
115
116
|
type: o.type,
|
|
@@ -127,7 +127,9 @@ function diffObject(key: string, before: CanonicalObject, after: CanonicalObject
|
|
|
127
127
|
if (before.comment !== after.comment) out.push(change(key, "comment", "SQLCH203", before.comment, after.comment));
|
|
128
128
|
|
|
129
129
|
if (before.kind === "database") {
|
|
130
|
-
|
|
130
|
+
// A declaration with no ENGINE takes the server's default database engine
|
|
131
|
+
// (Atomic; Replicated on Cloud), whatever the server says it is (#3645).
|
|
132
|
+
if (after.engine !== undefined && before.engine !== after.engine) out.push(change(key, "engine", "SQLCH232", before.engine, after.engine));
|
|
131
133
|
return;
|
|
132
134
|
}
|
|
133
135
|
|
|
@@ -275,8 +275,17 @@ export function canonicalObject(ddl: string, defaultDatabase = "default"): Canon
|
|
|
275
275
|
// `ENGINE = ReplicatedMergeTree` with no Keeper path and replica name is
|
|
276
276
|
// printed back with the server's defaults filled in; the two say the same.
|
|
277
277
|
if (/^Replicated.*MergeTree$/.test(engine.name) && args[0] === DEFAULT_REPLICA_PATH && args[1] === DEFAULT_REPLICA_NAME) args = args.slice(2);
|
|
278
|
-
|
|
279
|
-
|
|
278
|
+
// ClickHouse Cloud turns a declared MergeTree-family engine into its
|
|
279
|
+
// `Shared*` one and prints it back with the path and replica it chose
|
|
280
|
+
// (#3645, `../topology.ts`): the declaration said the plain family.
|
|
281
|
+
let name = engine.name;
|
|
282
|
+
const shared = /^Shared(\w*MergeTree)$/.exec(name);
|
|
283
|
+
if (shared) {
|
|
284
|
+
name = shared[1]!;
|
|
285
|
+
if (/^'.*'$/s.test(args[0] ?? "") && /^'.*'$/s.test(args[1] ?? "")) args = args.slice(2);
|
|
286
|
+
}
|
|
287
|
+
obj.engineName = name;
|
|
288
|
+
obj.engine = args.length ? `${name}(${args.join(", ")})` : name;
|
|
280
289
|
}
|
|
281
290
|
const comment = stringValue(spanText(tokens, node.comment));
|
|
282
291
|
if (comment !== undefined && comment !== "") obj.comment = comment;
|
|
@@ -8,23 +8,24 @@ import { canonicalObject } from "./normalize";
|
|
|
8
8
|
import type { SchemaObject } from "./diff";
|
|
9
9
|
import type { ClickHouseTarget } from "../live/bind";
|
|
10
10
|
import { readLiveSchema } from "../live/catalog";
|
|
11
|
+
import { renderFor, type Topology } from "../topology";
|
|
11
12
|
|
|
12
13
|
interface OutputDoc {
|
|
13
14
|
dialect?: string;
|
|
14
15
|
objects?: Array<{ export: string; ddl: string }>;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
/** The objects a `chant build` output holds, keyed by export name. */
|
|
18
|
-
export function schemaFromBuildOutput(json: string, defaultDatabase = "default"): SchemaObject[] {
|
|
18
|
+
/** The objects a `chant build` output holds, keyed by export name; with a `topology`, each declaration as rendered for it (`../topology.ts`). */
|
|
19
|
+
export function schemaFromBuildOutput(json: string, defaultDatabase = "default", topology?: Topology): SchemaObject[] {
|
|
19
20
|
const doc = JSON.parse(json) as OutputDoc;
|
|
20
21
|
if (doc.dialect !== "clickhouse" || !Array.isArray(doc.objects)) {
|
|
21
22
|
throw new Error("not a sql lexicon build output: expected { dialect: \"clickhouse\", objects: [...] }");
|
|
22
23
|
}
|
|
23
|
-
return doc.objects.map((o) => ({ key: o.export, canonical: canonicalObject(o.ddl, defaultDatabase) }));
|
|
24
|
+
return doc.objects.map((o) => ({ key: o.export, canonical: canonicalObject(renderFor(o.ddl, topology), defaultDatabase) }));
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
export function schemaFromBuildFile(path: string, defaultDatabase = "default"): SchemaObject[] {
|
|
27
|
-
return schemaFromBuildOutput(readFileSync(path, "utf-8"), defaultDatabase);
|
|
27
|
+
export function schemaFromBuildFile(path: string, defaultDatabase = "default", topology?: Topology): SchemaObject[] {
|
|
28
|
+
return schemaFromBuildOutput(readFileSync(path, "utf-8"), defaultDatabase, topology);
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
/**
|
|
@@ -120,6 +120,7 @@ export async function backfill(run: RebuildRun, deps: BackfillDeps = {}): Promis
|
|
|
120
120
|
clickhouseReceiptStore(run.target.endpoint, identity, {
|
|
121
121
|
...(run.runId ? { runId: run.runId } : {}),
|
|
122
122
|
...(o.replicated ? { replicatedIn: n.database } : {}),
|
|
123
|
+
...(run.target.topology ? { topology: run.target.topology } : {}),
|
|
123
124
|
...(run.replicaTimeoutMs !== undefined ? { replicaTimeoutMs: run.replicaTimeoutMs } : {}),
|
|
124
125
|
});
|
|
125
126
|
const recorded = await receipts.readAll(receiptAddress(identity, `rebuild/${n.key}/`));
|
|
@@ -50,6 +50,7 @@ import { clickhouseQuery, type ClickHouseEndpoint } from "../http";
|
|
|
50
50
|
import { ident, sqlString } from "../apply/statements";
|
|
51
51
|
import { CLICKHOUSE_COMMENT_OWNERSHIP_KEYS, RECEIPTS_TRAILER_KEY } from "../ownership";
|
|
52
52
|
import { DDL_SETTINGS, syncReplicaWithin } from "./replicas";
|
|
53
|
+
import { renderFor, type Topology } from "../topology";
|
|
53
54
|
|
|
54
55
|
/** The database the receipts live in. Its comment marks it as chant's, so schema reads leave it out. */
|
|
55
56
|
export const RECEIPTS_DATABASE = "chant_receipts";
|
|
@@ -79,22 +80,31 @@ export type ClickHouseReceiptStore = SqlReceiptStore;
|
|
|
79
80
|
* project's ownership stack and env, which every address starts with.
|
|
80
81
|
* `replicatedIn` names a Replicated database to keep them in instead, and
|
|
81
82
|
* `replicaTimeoutMs` how long a read waits for this replica to catch up there.
|
|
83
|
+
* `topology` is the environment's (`../topology.ts`): the database and table
|
|
84
|
+
* are created for it, `ON CLUSTER` and a `Replicated*` engine on a cluster.
|
|
85
|
+
* In a Replicated database they are created for that database whatever it says.
|
|
82
86
|
*/
|
|
83
87
|
export function clickhouseReceiptStore(
|
|
84
88
|
endpoint: ClickHouseEndpoint,
|
|
85
89
|
identity: { stack?: string; env?: string },
|
|
86
|
-
opts: { runId?: string; replicatedIn?: string; replicaTimeoutMs?: number } = {},
|
|
90
|
+
opts: { runId?: string; replicatedIn?: string; replicaTimeoutMs?: number; topology?: Topology } = {},
|
|
87
91
|
): ClickHouseReceiptStore {
|
|
88
92
|
const where = receiptsTable(opts.replicatedIn);
|
|
93
|
+
const topology: Topology | undefined = opts.replicatedIn ? { kind: "replicated" } : opts.topology;
|
|
89
94
|
const TABLE = `${ident(where.database)}.${ident(where.name)}`;
|
|
90
95
|
let ensured = false;
|
|
91
96
|
const ensure = async () => {
|
|
92
97
|
if (ensured) return;
|
|
93
|
-
if (!opts.replicatedIn)
|
|
98
|
+
if (!opts.replicatedIn) {
|
|
99
|
+
await clickhouseQuery(endpoint, renderFor(`CREATE DATABASE IF NOT EXISTS ${ident(RECEIPTS_DATABASE)} COMMENT ${sqlString(COMMENT)}`, topology), { settings: DDL_SETTINGS });
|
|
100
|
+
}
|
|
94
101
|
await clickhouseQuery(
|
|
95
102
|
endpoint,
|
|
96
|
-
|
|
97
|
-
`
|
|
103
|
+
renderFor(
|
|
104
|
+
`CREATE TABLE IF NOT EXISTS ${TABLE} (address String, effect String, expectation String, run_id String, written_at DateTime64(3) DEFAULT now64(3)) ` +
|
|
105
|
+
`ENGINE = ReplacingMergeTree(written_at) ORDER BY address COMMENT ${sqlString(COMMENT)}`,
|
|
106
|
+
topology,
|
|
107
|
+
),
|
|
98
108
|
{ settings: DDL_SETTINGS },
|
|
99
109
|
);
|
|
100
110
|
ensured = true;
|
|
@@ -14,6 +14,7 @@ import type { ClickHouseTarget } from "../live/bind";
|
|
|
14
14
|
import { carriesMarker, readTrailerPairs, REBUILD_TRAILER_KEY, stampedComment } from "../ownership";
|
|
15
15
|
import { createStatement, ident, qualifiedIdent, renamedDeclaration, sqlString, type DeclaredObject } from "../apply/statements";
|
|
16
16
|
import { waitForMutations } from "../apply/mutations";
|
|
17
|
+
import { renderFor } from "../topology";
|
|
17
18
|
import { DDL_SETTINGS, syncReplicaWithin } from "./replicas";
|
|
18
19
|
import {
|
|
19
20
|
changesBetween,
|
|
@@ -60,12 +61,14 @@ export const utcLiteral = (ms: number): string => `toDateTime64(${sqlString(sqlU
|
|
|
60
61
|
export const observe = (run: RebuildRun): Promise<RebuildObservation> => observeRebuild(run.target, run.declared, run.marker);
|
|
61
62
|
|
|
62
63
|
/**
|
|
63
|
-
* One DDL statement
|
|
64
|
-
*
|
|
65
|
-
*
|
|
64
|
+
* One DDL statement, rendered for the environment's topology when it names
|
|
65
|
+
* one (`../topology.ts`: `ON CLUSTER` and the engine). In a Replicated
|
|
66
|
+
* database it goes on without the replicas that are down, which run it from
|
|
67
|
+
* the database's log when they come back (`./replicas.ts`).
|
|
66
68
|
*/
|
|
67
|
-
const q = (run: RebuildRun,
|
|
69
|
+
const q = (run: RebuildRun, statement: string) => {
|
|
68
70
|
run.signal?.throwIfAborted();
|
|
71
|
+
const sql = renderFor(statement, run.target.topology);
|
|
69
72
|
run.log(sql);
|
|
70
73
|
return clickhouseQuery(run.target.endpoint, sql, { settings: DDL_SETTINGS, ...(run.signal ? { signal: run.signal } : {}) });
|
|
71
74
|
};
|
|
@@ -373,7 +376,7 @@ export async function compensate(run: Pick<RebuildRun, "target" | "declared" | "
|
|
|
373
376
|
const row = rows.find((r) => r.name === name);
|
|
374
377
|
const pairs = readTrailerPairs(row?.comment);
|
|
375
378
|
if (!row || pairs?.get(REBUILD_TRAILER_KEY) !== n.key || pairs.get("role") !== role || !carriesMarker(row.comment, run.marker)) continue;
|
|
376
|
-
const sql = `DROP ${what} ${qualifiedIdent(n.database, name)} SYNC
|
|
379
|
+
const sql = renderFor(`DROP ${what} ${qualifiedIdent(n.database, name)} SYNC`, run.target.topology);
|
|
377
380
|
run.log(sql);
|
|
378
381
|
await clickhouseQuery(run.target.endpoint, sql, { settings: DDL_SETTINGS });
|
|
379
382
|
dropped.push(`${n.database}.${name}`);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The replicated topology against a Replicated database (#3645): two
|
|
3
|
+
* replicas of the pinned server, Keeper embedded in the first (the scratch
|
|
4
|
+
* cluster the Replicated rebuild runs on).
|
|
5
|
+
*
|
|
6
|
+
* The source is written for a single node: a plain database, MergeTree
|
|
7
|
+
* tables. Applied with `CLICKHOUSE_TOPOLOGY=replicated`, the database is a
|
|
8
|
+
* `Replicated` one and its tables `Replicated*MergeTree` with no path, so
|
|
9
|
+
* the second replica gets them from the database's log and rows written on
|
|
10
|
+
* one reach the other. Applied again on the other replica, the server's
|
|
11
|
+
* printed engines (with the default Keeper path filled in) compare equal to
|
|
12
|
+
* the declarations, and nothing is sent; a change applies there and reaches
|
|
13
|
+
* the first.
|
|
14
|
+
*
|
|
15
|
+
* The scratch cluster names no cluster in `remote_servers`, so the second
|
|
16
|
+
* replica joins the database with the `CREATE DATABASE` the applier sent to
|
|
17
|
+
* the first, as an operator would (`replicated:<cluster>` sends it `ON
|
|
18
|
+
* CLUSTER` instead). Needs Docker; skips cleanly without it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
25
|
+
import { dockerAvailable, startScratchCluster, type ScratchCluster } from "./container";
|
|
26
|
+
import { clickhouseQuery, type ClickHouseEndpoint } from "./http";
|
|
27
|
+
import { clickhouseImage } from "../spec/pin";
|
|
28
|
+
import { clickhouseApply } from "../op/activities/clickhouse-apply";
|
|
29
|
+
import { renderStatement } from "./topology";
|
|
30
|
+
|
|
31
|
+
const DB = "yodel_3645";
|
|
32
|
+
const enabled = await dockerAvailable();
|
|
33
|
+
let cluster: ScratchCluster | undefined;
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-sql-topology-repl-"));
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
if (!enabled) return;
|
|
38
|
+
cluster = await startScratchCluster(clickhouseImage(), { replicas: 2, namePrefix: "chant-sql-topology-repl" });
|
|
39
|
+
}, 600_000);
|
|
40
|
+
|
|
41
|
+
afterAll(async () => {
|
|
42
|
+
await cluster?.stop();
|
|
43
|
+
rmSync(dir, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
interface Obj {
|
|
47
|
+
export: string;
|
|
48
|
+
type: string;
|
|
49
|
+
ddl: string;
|
|
50
|
+
dependsOn?: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const writeBuild = (file: string, objects: Obj[]): string => {
|
|
54
|
+
const path = join(dir, file);
|
|
55
|
+
writeFileSync(path, JSON.stringify({ dialect: "clickhouse", applyOrder: objects.map((o) => o.export), objects: objects.map((o) => ({ dependsOn: [], ...o })) }));
|
|
56
|
+
return path;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const objects = (extraColumn = ""): Obj[] => [
|
|
60
|
+
{ export: "db", type: "ClickHouse::Database", ddl: `CREATE DATABASE ${DB}` },
|
|
61
|
+
{
|
|
62
|
+
export: "events",
|
|
63
|
+
type: "ClickHouse::Table",
|
|
64
|
+
dependsOn: ["db"],
|
|
65
|
+
ddl: `CREATE TABLE ${DB}.events (id UInt64, ts DateTime, kind String${extraColumn}) ENGINE = ReplacingMergeTree(ts) ORDER BY id`,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
export: "byKind",
|
|
69
|
+
type: "ClickHouse::MaterializedView",
|
|
70
|
+
dependsOn: ["events"],
|
|
71
|
+
ddl: `CREATE MATERIALIZED VIEW ${DB}.by_kind ENGINE = SummingMergeTree ORDER BY kind AS SELECT kind, count() AS n FROM ${DB}.events GROUP BY kind`,
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const q = <T = Record<string, unknown>>(at: ClickHouseEndpoint, sql: string) => clickhouseQuery<T>(at, sql);
|
|
76
|
+
|
|
77
|
+
async function apply(at: ClickHouseEndpoint, buildPath: string) {
|
|
78
|
+
const sent: string[] = [];
|
|
79
|
+
const outcome = await clickhouseApply({ buildPath, environment: "test" }, undefined, {
|
|
80
|
+
config: { ownership: { stack: "yodel", env: "e2e" } },
|
|
81
|
+
env: { CLICKHOUSE_URL: at.url, CLICKHOUSE_TOPOLOGY: "replicated" },
|
|
82
|
+
log: (l) => void (/^[A-Z]+ /.test(l) && sent.push(l)),
|
|
83
|
+
});
|
|
84
|
+
return { outcome, sent };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe.skipIf(!enabled)("the replicated topology on a Replicated database (#3645)", () => {
|
|
88
|
+
test("a single node's source becomes a Replicated database that both replicas hold, and converges on either", async () => {
|
|
89
|
+
const [r1, r2] = cluster!.replicas as [ClickHouseEndpoint, ClickHouseEndpoint];
|
|
90
|
+
expect(renderStatement(`CREATE DATABASE ${DB}`, { kind: "replicated" })).toBe(`CREATE DATABASE ${DB} ENGINE = Replicated('/clickhouse/databases/${DB}', '{shard}', '{replica}')`);
|
|
91
|
+
|
|
92
|
+
const v1 = writeBuild("v1.json", objects());
|
|
93
|
+
const first = await apply(r1, v1);
|
|
94
|
+
expect(first.outcome.failed).toEqual([]);
|
|
95
|
+
// The second replica joins the database with the statement the applier sent to the first, and replays its tables from the log.
|
|
96
|
+
expect(first.sent[0]).toMatch(new RegExp(`^CREATE DATABASE ${DB} ENGINE = Replicated\\('/clickhouse/databases/${DB}', '\\{shard\\}', '\\{replica\\}'\\) COMMENT `));
|
|
97
|
+
await q(r2, first.sent[0]!);
|
|
98
|
+
await q(r2, `SYSTEM SYNC DATABASE REPLICA ${DB}`);
|
|
99
|
+
expect(first.sent.join("\n")).not.toMatch(/ON CLUSTER/);
|
|
100
|
+
expect(first.sent[1]).toMatch(/ENGINE = ReplicatedReplacingMergeTree\(ts\) ORDER BY id/);
|
|
101
|
+
|
|
102
|
+
await q(r1, `INSERT INTO ${DB}.events (id, ts, kind) VALUES (1, now(), 'click'), (2, now(), 'view')`);
|
|
103
|
+
await q(r2, `SYSTEM SYNC REPLICA ${DB}.events`);
|
|
104
|
+
expect(Number((await q<{ n: string | number }>(r2, `SELECT count() AS n FROM ${DB}.events`))[0]!.n)).toBe(2);
|
|
105
|
+
const engines = await q<{ name: string; engine: string }>(r2, `SELECT name, engine FROM system.tables WHERE database = '${DB}' ORDER BY name`);
|
|
106
|
+
expect(engines.map((t) => [t.name.startsWith(".inner") ? ".inner" : t.name, t.engine])).toEqual([
|
|
107
|
+
[".inner", "ReplicatedSummingMergeTree"],
|
|
108
|
+
["by_kind", "MaterializedView"],
|
|
109
|
+
["events", "ReplicatedReplacingMergeTree"],
|
|
110
|
+
]);
|
|
111
|
+
expect((await q<{ engine: string }>(r2, `SELECT engine FROM system.databases WHERE name = '${DB}'`))[0]!.engine).toBe("Replicated");
|
|
112
|
+
|
|
113
|
+
// On the other replica: the printed engines (default Keeper path filled in) are the declarations; nothing to send.
|
|
114
|
+
const again = await apply(r2, v1);
|
|
115
|
+
expect(again.sent).toEqual([]);
|
|
116
|
+
expect(again.outcome.notAttempted).toEqual([]);
|
|
117
|
+
|
|
118
|
+
// A change there reaches the first replica through the database's log.
|
|
119
|
+
const change = await apply(r2, writeBuild("v2.json", objects(", source String DEFAULT 'web'")));
|
|
120
|
+
expect(change.sent).toEqual([`ALTER TABLE \`${DB}\`.\`events\` ADD COLUMN source String DEFAULT 'web' AFTER \`kind\``]);
|
|
121
|
+
await q(r1, `SYSTEM SYNC DATABASE REPLICA ${DB}`);
|
|
122
|
+
const cols = await q<{ name: string }>(r1, `SELECT name FROM system.columns WHERE database = '${DB}' AND table = 'events' ORDER BY position`);
|
|
123
|
+
expect(cols.map((c) => c.name)).toEqual(["id", "ts", "kind", "source"]);
|
|
124
|
+
}, 300_000);
|
|
125
|
+
});
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* The single-node rendering against a server (#3645): one migration source
|
|
3
3
|
* written for a cluster (`ON CLUSTER`, `Replicated*MergeTree` with Keeper
|
|
4
4
|
* paths, a `Replicated` database) is refused by a single node as written
|
|
5
|
-
* and applies once rendered for it, with the plain MergeTree family
|
|
5
|
+
* and applies once rendered for it, with the plain MergeTree family: as
|
|
6
|
+
* statements, and through the applier with `CLICKHOUSE_TOPOLOGY=single`,
|
|
7
|
+
* which then finds nothing left to change.
|
|
6
8
|
*
|
|
7
9
|
* Runs against `CLICKHOUSE_URL` when it is set (the emulator, `chant
|
|
8
10
|
* emulator up --lexicon sql`), else a scratch server of the pinned image.
|
|
@@ -10,7 +12,11 @@
|
|
|
10
12
|
* other; skips cleanly without Docker.
|
|
11
13
|
*/
|
|
12
14
|
|
|
15
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
13
18
|
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
19
|
+
import { clickhouseApply } from "../op/activities/clickhouse-apply";
|
|
14
20
|
import { dockerAvailable, startScratchServer, type ScratchServer } from "./container";
|
|
15
21
|
import { clickhouseQuery, type ClickHouseEndpoint } from "./http";
|
|
16
22
|
import { clickhouseImage } from "../spec/pin";
|
|
@@ -39,9 +45,12 @@ beforeAll(async () => {
|
|
|
39
45
|
await q(`DROP DATABASE IF EXISTS ${DB} SYNC`);
|
|
40
46
|
}, 600_000);
|
|
41
47
|
|
|
48
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-sql-topology-"));
|
|
49
|
+
|
|
42
50
|
afterAll(async () => {
|
|
43
51
|
if (enabled) await q(`DROP DATABASE IF EXISTS ${DB} SYNC`).catch(() => undefined);
|
|
44
52
|
await server?.stop();
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
45
54
|
});
|
|
46
55
|
|
|
47
56
|
const SINGLE: Topology = { kind: "single" };
|
|
@@ -98,3 +107,77 @@ describe.skipIf(!enabled)("the single-node rendering against a server (#3645)",
|
|
|
98
107
|
expect(cols.map((c) => c.name)).toEqual(["id", "ts", "kind", "source"]);
|
|
99
108
|
});
|
|
100
109
|
});
|
|
110
|
+
|
|
111
|
+
describe.skipIf(!enabled)("the applier with a single-node topology (#3645)", () => {
|
|
112
|
+
interface Obj {
|
|
113
|
+
export: string;
|
|
114
|
+
type: string;
|
|
115
|
+
ddl: string;
|
|
116
|
+
dependsOn?: string[];
|
|
117
|
+
}
|
|
118
|
+
const writeBuild = (file: string, objects: Obj[]): string => {
|
|
119
|
+
const path = join(dir, file);
|
|
120
|
+
writeFileSync(path, JSON.stringify({ dialect: "clickhouse", applyOrder: objects.map((o) => o.export), objects: objects.map((o) => ({ dependsOn: [], ...o })) }));
|
|
121
|
+
return path;
|
|
122
|
+
};
|
|
123
|
+
const objects = (extraColumn = ""): Obj[] => [
|
|
124
|
+
{ export: "db", type: "ClickHouse::Database", ddl: `CREATE DATABASE ${DB} ON CLUSTER main ENGINE = Replicated('/clickhouse/databases/${DB}', '{shard}', '{replica}')` },
|
|
125
|
+
{
|
|
126
|
+
export: "events",
|
|
127
|
+
type: "ClickHouse::Table",
|
|
128
|
+
dependsOn: ["db"],
|
|
129
|
+
ddl: `CREATE TABLE ${DB}.events ON CLUSTER main (id UInt64, ts DateTime, kind String${extraColumn}) ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/${DB}/events', '{replica}', ts) ORDER BY id`,
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
export: "byKind",
|
|
133
|
+
type: "ClickHouse::MaterializedView",
|
|
134
|
+
dependsOn: ["events"],
|
|
135
|
+
ddl: `CREATE MATERIALIZED VIEW ${DB}.by_kind ON CLUSTER main ENGINE = ReplicatedSummingMergeTree ORDER BY kind AS SELECT kind, count() AS n FROM ${DB}.events GROUP BY kind`,
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
const apply = async (buildPath: string, topology?: string) => {
|
|
139
|
+
const sent: string[] = [];
|
|
140
|
+
const env: Record<string, string> = { CLICKHOUSE_URL: endpoint.url, ...(topology ? { CLICKHOUSE_TOPOLOGY: topology } : {}) };
|
|
141
|
+
if (endpoint.user) env.CLICKHOUSE_USER = endpoint.user;
|
|
142
|
+
if (endpoint.password) env.CLICKHOUSE_PASSWORD = endpoint.password;
|
|
143
|
+
const outcome = await clickhouseApply({ buildPath, environment: "test" }, undefined, {
|
|
144
|
+
config: { ownership: { stack: "yodel", env: "e2e" } },
|
|
145
|
+
env,
|
|
146
|
+
// The statements it sent; the log's other lines report each object.
|
|
147
|
+
log: (l) => void (/^[A-Z]+ /.test(l) && sent.push(l)),
|
|
148
|
+
}).catch((err: unknown) => ({ error: err as Error }));
|
|
149
|
+
return { outcome, sent };
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
test("as declared, the cluster's source fails on a single node; rendered for it, it applies and converges", async () => {
|
|
153
|
+
await q(`DROP DATABASE IF EXISTS ${DB} SYNC`);
|
|
154
|
+
const v1 = writeBuild("v1.json", objects());
|
|
155
|
+
|
|
156
|
+
const asDeclared = await apply(v1);
|
|
157
|
+
expect("error" in asDeclared.outcome || asDeclared.outcome.failed.length > 0).toBe(true);
|
|
158
|
+
await q(`DROP DATABASE IF EXISTS ${DB} SYNC`);
|
|
159
|
+
|
|
160
|
+
const first = await apply(v1, "single");
|
|
161
|
+
expect("error" in first.outcome).toBe(false);
|
|
162
|
+
expect(first.sent.join("\n")).not.toMatch(/ON CLUSTER|Replicated/);
|
|
163
|
+
expect(first.sent).toHaveLength(3);
|
|
164
|
+
const tables = await q<{ name: string; engine: string }>(`SELECT name, engine FROM system.tables WHERE database = '${DB}' ORDER BY name`);
|
|
165
|
+
expect(tables.map((t) => [t.name.startsWith(".inner") ? ".inner" : t.name, t.engine])).toEqual([
|
|
166
|
+
[".inner", "SummingMergeTree"],
|
|
167
|
+
["by_kind", "MaterializedView"],
|
|
168
|
+
["events", "ReplacingMergeTree"],
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
// Again: the server's plain engines are what the source renders to on a single node, so nothing is sent.
|
|
172
|
+
const again = await apply(v1, "single");
|
|
173
|
+
expect(again.sent).toEqual([]);
|
|
174
|
+
expect("error" in again.outcome ? again.outcome.error : again.outcome.notAttempted).toEqual([]);
|
|
175
|
+
|
|
176
|
+
// A change to the cluster's source applies as a single node's ALTER.
|
|
177
|
+
const v2 = writeBuild("v2.json", objects(", source String DEFAULT 'web'"));
|
|
178
|
+
const change = await apply(v2, "single");
|
|
179
|
+
expect(change.sent).toEqual([`ALTER TABLE \`${DB}\`.\`events\` ADD COLUMN source String DEFAULT 'web' AFTER \`kind\``]);
|
|
180
|
+
const cols = await q<{ name: string }>(`SELECT name FROM system.columns WHERE database = '${DB}' AND table = 'events' ORDER BY position`);
|
|
181
|
+
expect(cols.map((c) => c.name)).toEqual(["id", "ts", "kind", "source"]);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
2
|
import { canonicalObject } from "./plan/normalize";
|
|
3
|
+
import { diffSchemas } from "./plan/diff";
|
|
3
4
|
import type { Change } from "./plan/diff";
|
|
4
5
|
import { alterSteps, createStatement, dropStatement, type DeclaredObject } from "./apply/statements";
|
|
5
6
|
import { CLICKHOUSE_ENTITY_TYPES, type ClickHouseEntityType } from "./entities";
|
|
@@ -274,3 +275,35 @@ describe("the applier's own statements", () => {
|
|
|
274
275
|
expect(renderSteps(steps, SINGLE)).toEqual(steps);
|
|
275
276
|
});
|
|
276
277
|
});
|
|
278
|
+
|
|
279
|
+
describe("the classifier compares the engine a topology runs", () => {
|
|
280
|
+
const declared = "CREATE TABLE db.t (id UInt64, v UInt64) ENGINE = ReplacingMergeTree(v) ORDER BY id";
|
|
281
|
+
const changes = (live: string, topology: Topology) =>
|
|
282
|
+
diffSchemas([{ key: "t", canonical: canonicalObject(live) }], [{ key: "t", canonical: canonicalObject(renderStatement(declared, topology)) }]).changes;
|
|
283
|
+
|
|
284
|
+
test("a cluster's table, as the server prints it, is the rendered declaration", () => {
|
|
285
|
+
expect(changes("CREATE TABLE db.t (`id` UInt64, `v` UInt64) ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}', v) ORDER BY id", CLUSTER)).toEqual([]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("a Replicated database's table, printed with the default path and replica, is the declaration with none", () => {
|
|
289
|
+
expect(changes("CREATE TABLE db.t (`id` UInt64, `v` UInt64) ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}', v) ORDER BY id", REPLICATED)).toEqual([]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("Cloud's Shared engine is the plain family it was declared as", () => {
|
|
293
|
+
expect(changes("CREATE TABLE db.t (`id` UInt64, `v` UInt64) ENGINE = SharedReplacingMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}', v) ORDER BY id", CLOUD)).toEqual([]);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("a database declared with no engine takes whatever the server's default is, so a single node's Atomic is no change", () => {
|
|
297
|
+
const db = (live: string, declared: string) => diffSchemas([{ key: "d", canonical: canonicalObject(live) }], [{ key: "d", canonical: canonicalObject(declared) }]).changes;
|
|
298
|
+
const declaredForCluster = "CREATE DATABASE d ON CLUSTER main ENGINE = Replicated('/clickhouse/databases/d', '{shard}', '{replica}')";
|
|
299
|
+
expect(db("CREATE DATABASE d\nENGINE = Atomic", renderStatement(declaredForCluster, SINGLE))).toEqual([]);
|
|
300
|
+
expect(db("CREATE DATABASE d\nENGINE = Replicated('/clickhouse/databases/d', '{shard}', '{replica}')", renderStatement("CREATE DATABASE d", REPLICATED))).toEqual([]);
|
|
301
|
+
expect(db("CREATE DATABASE d\nENGINE = Atomic", "CREATE DATABASE d ENGINE = Lazy(60)")).toEqual([expect.objectContaining({ rule: "SQLCH232" })]);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("the engine a topology does not run is still a change", () => {
|
|
305
|
+
expect(changes("CREATE TABLE db.t (`id` UInt64, `v` UInt64) ENGINE = ReplacingMergeTree(v) ORDER BY id", CLUSTER)).toEqual([
|
|
306
|
+
expect.objectContaining({ field: "engine", rule: "SQLCH223", class: "rebuild" }),
|
|
307
|
+
]);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
@@ -449,3 +449,29 @@ export function renderStatement(sql: string, topology: Topology): string {
|
|
|
449
449
|
export function renderSteps<S extends { sql: string }>(steps: readonly S[], topology: Topology): S[] {
|
|
450
450
|
return steps.map((s) => ({ ...s, sql: renderStatement(s.sql, topology) }));
|
|
451
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";
|