@intentius/chant-lexicon-sql 0.113.0 → 0.114.1
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 +17 -0
- package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/observe.d.ts +4 -3
- package/dist/clickhouse/rebuild/observe.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 +5 -3
- package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/verify.d.ts +41 -18
- package/dist/clickhouse/rebuild/verify.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/dist/postgres/plan/deep.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 +127 -5
- package/src/clickhouse/rebuild/observe.ts +4 -3
- package/src/clickhouse/rebuild/rebuild.e2e.test.ts +106 -1
- package/src/clickhouse/rebuild/receipts.ts +14 -4
- package/src/clickhouse/rebuild/steps.ts +22 -9
- package/src/clickhouse/rebuild/verify.ts +131 -36
- 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
- package/src/postgres/plan/deep.test.ts +76 -0
- package/src/postgres/plan/deep.ts +13 -2
- package/src/postgres/plan/plan.e2e.test.ts +26 -0
|
@@ -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";
|
|
@@ -70,6 +70,8 @@ ALTER TABLE \`analytics\`.\`events\` MODIFY COLUMN \`kind\` LowCardinality(Strin
|
|
|
70
70
|
|
|
71
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
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
|
+
|
|
73
75
|
## Identity and renames
|
|
74
76
|
|
|
75
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.
|
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
|
package/src/core/statements.ts
CHANGED
|
@@ -85,6 +85,8 @@ export interface SchemaStatements {
|
|
|
85
85
|
dialect: "clickhouse" | "postgres";
|
|
86
86
|
/** ClickHouse: the database a bare name means. */
|
|
87
87
|
defaultDatabase?: string;
|
|
88
|
+
/** ClickHouse: the topology the statements are rendered for, in its string form (`single`, `cluster:<name>`, `replicated`, `cloud`). */
|
|
89
|
+
topology?: string;
|
|
88
90
|
/** Postgres: the schema a bare name means; the applier sets `search_path` to it. */
|
|
89
91
|
defaultSchema?: string;
|
|
90
92
|
/** Postgres: the major the changes were classified for. */
|
|
@@ -9,6 +9,7 @@ import { postgresApply } from "./op/activities/postgres-apply";
|
|
|
9
9
|
import { runDiff } from "./clickhouse/plan/commands";
|
|
10
10
|
import { writableClickHouse, type WritableServer } from "./clickhouse/testing/writable-server";
|
|
11
11
|
import { writablePostgres } from "./postgres/testing/writable-server";
|
|
12
|
+
import type { Topology } from "./clickhouse/topology";
|
|
12
13
|
|
|
13
14
|
interface Obj {
|
|
14
15
|
export: string;
|
|
@@ -111,7 +112,7 @@ describe("ClickHouse", () => {
|
|
|
111
112
|
});
|
|
112
113
|
|
|
113
114
|
test("two identical builds take no steps", () => {
|
|
114
|
-
expect(diffStatements(CH_BEFORE, CH_BEFORE)).toEqual({ dialect: "clickhouse", defaultDatabase: "default", steps: [], refused: false, hints: [] });
|
|
115
|
+
expect(diffStatements(CH_BEFORE, CH_BEFORE)).toEqual({ dialect: "clickhouse", defaultDatabase: "default", topology: "single", steps: [], refused: false, hints: [] });
|
|
115
116
|
});
|
|
116
117
|
|
|
117
118
|
test("the applier sends the same statements for the same change", async () => {
|
|
@@ -263,3 +264,121 @@ describe("chant sql diff --statements", () => {
|
|
|
263
264
|
expect(r.out).toContain("ALTER TABLE app.users ADD COLUMN name text DEFAULT 'none' NOT NULL;");
|
|
264
265
|
});
|
|
265
266
|
});
|
|
267
|
+
|
|
268
|
+
// ── ClickHouse topologies (#3645) ──────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
describe("ClickHouse statements per topology", () => {
|
|
271
|
+
const EMPTY = build("clickhouse", []);
|
|
272
|
+
const MV: Obj = {
|
|
273
|
+
export: "counts",
|
|
274
|
+
type: "ClickHouse::MaterializedView",
|
|
275
|
+
dependsOn: ["events"],
|
|
276
|
+
ddl: "CREATE MATERIALIZED VIEW analytics.counts ENGINE = SummingMergeTree ORDER BY kind AS SELECT kind, count() AS n FROM analytics.events GROUP BY kind",
|
|
277
|
+
};
|
|
278
|
+
const FIRST = build("clickhouse", [DB, EVENTS, SESSIONS, OLD, MV]);
|
|
279
|
+
const TOPOLOGIES: Array<[string, Topology]> = [
|
|
280
|
+
["single", { kind: "single" }],
|
|
281
|
+
["cluster:main", { kind: "cluster", cluster: "main" }],
|
|
282
|
+
["replicated", { kind: "replicated" }],
|
|
283
|
+
["cloud", { kind: "cloud" }],
|
|
284
|
+
];
|
|
285
|
+
const created = (t: Topology) => sqlOf(diffStatements(EMPTY, FIRST, { marker: MARKER, topology: t })).map((s) => s.replace(/ COMMENT '[^']*\[chant[^']*'$/, ""));
|
|
286
|
+
const R = "'/clickhouse/tables/{uuid}/{shard}', '{replica}'";
|
|
287
|
+
|
|
288
|
+
test("single is the default, and the declarations as written", () => {
|
|
289
|
+
expect(diffStatements(EMPTY, FIRST, { marker: MARKER })).toEqual(diffStatements(EMPTY, FIRST, { marker: MARKER, topology: { kind: "single" } }));
|
|
290
|
+
expect(created({ kind: "single" })).toEqual([
|
|
291
|
+
"CREATE DATABASE analytics ENGINE = Atomic",
|
|
292
|
+
"CREATE TABLE analytics.events (ts DateTime, user_id UInt64, kind String) ENGINE = MergeTree ORDER BY (ts, user_id)",
|
|
293
|
+
"CREATE TABLE analytics.sessions (id UInt64, ts DateTime) ENGINE = MergeTree ORDER BY id",
|
|
294
|
+
"CREATE TABLE analytics.old (a UInt8) ENGINE = MergeTree ORDER BY a",
|
|
295
|
+
"CREATE MATERIALIZED VIEW analytics.counts ENGINE = SummingMergeTree ORDER BY kind AS SELECT kind, count() AS n FROM analytics.events GROUP BY kind",
|
|
296
|
+
]);
|
|
297
|
+
expect(diffStatements(EMPTY, FIRST).topology).toBe("single");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("a cluster creates everything ON CLUSTER, with Replicated engines and their Keeper path", () => {
|
|
301
|
+
expect(created({ kind: "cluster", cluster: "main" })).toEqual([
|
|
302
|
+
"CREATE DATABASE analytics ON CLUSTER `main` ENGINE = Atomic",
|
|
303
|
+
`CREATE TABLE analytics.events ON CLUSTER \`main\` (ts DateTime, user_id UInt64, kind String) ENGINE = ReplicatedMergeTree(${R}) ORDER BY (ts, user_id)`,
|
|
304
|
+
`CREATE TABLE analytics.sessions ON CLUSTER \`main\` (id UInt64, ts DateTime) ENGINE = ReplicatedMergeTree(${R}) ORDER BY id`,
|
|
305
|
+
`CREATE TABLE analytics.old ON CLUSTER \`main\` (a UInt8) ENGINE = ReplicatedMergeTree(${R}) ORDER BY a`,
|
|
306
|
+
`CREATE MATERIALIZED VIEW analytics.counts ON CLUSTER \`main\` ENGINE = ReplicatedSummingMergeTree(${R}) ORDER BY kind AS SELECT kind, count() AS n FROM analytics.events GROUP BY kind`,
|
|
307
|
+
]);
|
|
308
|
+
expect(diffStatements(EMPTY, FIRST, { topology: { kind: "cluster", cluster: "main" } }).topology).toBe("cluster:main");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("a Replicated database: the database Replicated, its tables Replicated with no path, no ON CLUSTER", () => {
|
|
312
|
+
expect(created({ kind: "replicated" })).toEqual([
|
|
313
|
+
"CREATE DATABASE analytics ENGINE = Replicated('/clickhouse/databases/analytics', '{shard}', '{replica}')",
|
|
314
|
+
"CREATE TABLE analytics.events (ts DateTime, user_id UInt64, kind String) ENGINE = ReplicatedMergeTree ORDER BY (ts, user_id)",
|
|
315
|
+
"CREATE TABLE analytics.sessions (id UInt64, ts DateTime) ENGINE = ReplicatedMergeTree ORDER BY id",
|
|
316
|
+
"CREATE TABLE analytics.old (a UInt8) ENGINE = ReplicatedMergeTree ORDER BY a",
|
|
317
|
+
"CREATE MATERIALIZED VIEW analytics.counts ENGINE = ReplicatedSummingMergeTree ORDER BY kind AS SELECT kind, count() AS n FROM analytics.events GROUP BY kind",
|
|
318
|
+
]);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("Cloud takes the plain family", () => {
|
|
322
|
+
expect(created({ kind: "cloud" })).toEqual(created({ kind: "single" }));
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("a source written for a cluster renders back to plain DDL for a single node", () => {
|
|
326
|
+
const clustered = build("clickhouse", [
|
|
327
|
+
{ ...EVENTS, ddl: `CREATE TABLE analytics.events ON CLUSTER prod (ts DateTime, user_id UInt64, kind String) ENGINE = ReplicatedMergeTree('/t/{shard}/events', '{replica}') ORDER BY (ts, user_id)` },
|
|
328
|
+
]);
|
|
329
|
+
expect(sqlOf(diffStatements(EMPTY, clustered))[0]).toMatch(/^CREATE TABLE analytics\.events \(ts DateTime, user_id UInt64, kind String\) ENGINE = MergeTree\(\) ORDER BY \(ts, user_id\) COMMENT /);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("the changes between two builds: ON CLUSTER on every ALTER and DROP on a cluster, as written elsewhere", () => {
|
|
333
|
+
const base = sqlOf(diffStatements(CH_BEFORE, CH_AFTER, { marker: MARKER }));
|
|
334
|
+
for (const t of [{ kind: "replicated" }, { kind: "cloud" }] as Topology[]) expect(sqlOf(diffStatements(CH_BEFORE, CH_AFTER, { marker: MARKER, topology: t }))).toEqual(base);
|
|
335
|
+
expect(sqlOf(diffStatements(CH_BEFORE, CH_AFTER, { marker: MARKER, topology: { kind: "cluster", cluster: "main" } }))).toEqual([
|
|
336
|
+
"ALTER TABLE `analytics`.`events` ON CLUSTER `main` ADD COLUMN region String DEFAULT 'eu' AFTER `user_id`",
|
|
337
|
+
"ALTER TABLE `analytics`.`events` ON CLUSTER `main` MODIFY COLUMN `kind` LowCardinality(String)",
|
|
338
|
+
"CREATE VIEW analytics.by_kind ON CLUSTER `main` AS SELECT kind, count() AS n FROM analytics.events GROUP BY kind COMMENT '[chant managed-by=chant stack=shop env=prod]'",
|
|
339
|
+
"DROP TABLE `analytics`.`old` ON CLUSTER `main` SYNC",
|
|
340
|
+
]);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("an engine the topology changes is no change: a cluster's Replicated engine compares equal to the declared MergeTree", () => {
|
|
344
|
+
for (const [, t] of TOPOLOGIES) expect(diffStatements(FIRST, FIRST, { topology: t }).steps).toEqual([]);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test.each(TOPOLOGIES)("the applier sends the same statements as the offline renderer for %s", async (label, t) => {
|
|
348
|
+
const s = await writableClickHouse();
|
|
349
|
+
servers.push(s);
|
|
350
|
+
const deps = { config, env: { CLICKHOUSE_URL: s.url, CLICKHOUSE_TOPOLOGY: label }, log: () => undefined };
|
|
351
|
+
await clickhouseApply({ buildPath: buildFile(FIRST), environment: "test" }, undefined, deps);
|
|
352
|
+
expect(s.writes).toEqual(sqlOf(diffStatements(EMPTY, FIRST, { marker: MARKER, topology: t })));
|
|
353
|
+
// Applied again: nothing to send, the engines it created compare equal to the declarations rendered for it.
|
|
354
|
+
s.writes.length = 0;
|
|
355
|
+
await clickhouseApply({ buildPath: buildFile(FIRST), environment: "test" }, undefined, deps);
|
|
356
|
+
expect(s.writes).toEqual([]);
|
|
357
|
+
const after = build("clickhouse", [DB, EVENTS_V2, SESSIONS_RESORTED, BY_KIND, MV]);
|
|
358
|
+
await clickhouseApply({ buildPath: buildFile(after), environment: "test", prune: true }, undefined, deps);
|
|
359
|
+
expect(s.writes).toEqual(sqlOf(diffStatements(FIRST, after, { marker: MARKER, topology: t })));
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("chant sql diff --statements --topology renders for that topology", async () => {
|
|
363
|
+
const lines: string[] = [];
|
|
364
|
+
const log = vi.spyOn(console, "log").mockImplementation((l: unknown) => void lines.push(String(l)));
|
|
365
|
+
try {
|
|
366
|
+
expect(await runDiff({ verb: "diff", rawArgs: [buildFile(EMPTY), buildFile(FIRST), "--statements", "--json", "--topology", "cluster:main"] })).toBe(0);
|
|
367
|
+
expect(await runDiff({ verb: "diff", rawArgs: [buildFile(EMPTY), buildFile(FIRST), "--statements", "--json", "--topology=replicated"] })).toBe(0);
|
|
368
|
+
} finally {
|
|
369
|
+
log.mockRestore();
|
|
370
|
+
}
|
|
371
|
+
expect(JSON.parse(lines[0]!)).toEqual(diffStatements(EMPTY, FIRST, { topology: { kind: "cluster", cluster: "main" } }));
|
|
372
|
+
expect(JSON.parse(lines[1]!)).toEqual(diffStatements(EMPTY, FIRST, { topology: { kind: "replicated" } }));
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("chant sql diff refuses a topology it does not know", async () => {
|
|
376
|
+
const err = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
377
|
+
try {
|
|
378
|
+
expect(await runDiff({ verb: "diff", rawArgs: [buildFile(EMPTY), buildFile(FIRST), "--statements", "--topology", "sharded"] })).toBe(1);
|
|
379
|
+
expect(err.mock.calls[0]![0]).toMatch(/--topology: unknown topology "sharded"/);
|
|
380
|
+
} finally {
|
|
381
|
+
err.mockRestore();
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
});
|
|
@@ -34,6 +34,7 @@ import type { OwnershipMarker } from "@intentius/chant/ownership";
|
|
|
34
34
|
import { statementChange, type ManualStep, type MigrationStep, type SchemaStatements, type StepObject } from "./core/statements";
|
|
35
35
|
import { declaredObjects as clickhouseDeclared } from "./clickhouse/apply/apply";
|
|
36
36
|
import { planStatements, type DeclaredObject } from "./clickhouse/apply/statements";
|
|
37
|
+
import { topologyLabel, type Topology } from "./clickhouse/topology";
|
|
37
38
|
import { diffSchemas, type Change } from "./clickhouse/plan/diff";
|
|
38
39
|
import { rebuildOpSuggestions } from "./clickhouse/plan/rebuild-handoff";
|
|
39
40
|
import { declaredObjects as postgresDeclared } from "./postgres/apply/apply";
|
|
@@ -54,6 +55,13 @@ export interface DiffStatementsOptions {
|
|
|
54
55
|
env?: string;
|
|
55
56
|
/** ClickHouse: the database a bare name means. Default: `default`. */
|
|
56
57
|
defaultDatabase?: string;
|
|
58
|
+
/**
|
|
59
|
+
* ClickHouse: the topology to render the statements for
|
|
60
|
+
* (`./clickhouse/topology.ts`): `ON CLUSTER` and the engine, on both
|
|
61
|
+
* builds' declarations before they are compared, so the steps are the ones
|
|
62
|
+
* the applier sends to an environment with that topology. Default: `single`.
|
|
63
|
+
*/
|
|
64
|
+
topology?: Topology;
|
|
57
65
|
/** Postgres: the schema a bare name means. Default: `public`. */
|
|
58
66
|
defaultSchema?: string;
|
|
59
67
|
/** Postgres: the major to classify for. Default: the newer build's `postgresMajor`, else the older's, else the newest pinned. */
|
|
@@ -93,8 +101,9 @@ const at = (object: string, type: string, name: string): StepObject => ({ object
|
|
|
93
101
|
function clickhouseStatements(beforeJson: string, afterJson: string, options: DiffStatementsOptions): SchemaStatements {
|
|
94
102
|
const defaultDatabase = options.defaultDatabase ?? "default";
|
|
95
103
|
const env = options.env ?? "<env>";
|
|
96
|
-
const
|
|
97
|
-
const
|
|
104
|
+
const topology: Topology = options.topology ?? { kind: "single" };
|
|
105
|
+
const before = clickhouseDeclared(beforeJson, defaultDatabase, topology);
|
|
106
|
+
const after = clickhouseDeclared(afterJson, defaultDatabase, topology);
|
|
98
107
|
const keyed = (objs: readonly DeclaredObject[]) => objs.map((o) => ({ key: o.exportName, canonical: o.canonical }));
|
|
99
108
|
const diff = diffSchemas(keyed(before), keyed(after));
|
|
100
109
|
const plan = planStatements({
|
|
@@ -103,6 +112,7 @@ function clickhouseStatements(beforeJson: string, afterJson: string, options: Di
|
|
|
103
112
|
current: new Map(before.map((o) => [o.exportName, o.canonical])),
|
|
104
113
|
keyOf: (o) => o.exportName,
|
|
105
114
|
allowDestructive: true,
|
|
115
|
+
topology,
|
|
106
116
|
...(options.marker ? { marker: options.marker } : {}),
|
|
107
117
|
});
|
|
108
118
|
const declaredByExport = new Map(after.map((o) => [o.exportName, o.canonical]));
|
|
@@ -156,7 +166,7 @@ function clickhouseStatements(beforeJson: string, afterJson: string, options: Di
|
|
|
156
166
|
...(change?.destructive ? { destructive: true } : {}),
|
|
157
167
|
});
|
|
158
168
|
}
|
|
159
|
-
return { dialect: "clickhouse", defaultDatabase, steps, refused: steps.some((s) => s.kind !== "statement"), hints: diff.hints };
|
|
169
|
+
return { dialect: "clickhouse", defaultDatabase, topology: topologyLabel(topology), steps, refused: steps.some((s) => s.kind !== "statement"), hints: diff.hints };
|
|
160
170
|
}
|
|
161
171
|
|
|
162
172
|
// ── Postgres ───────────────────────────────────────────────────────────
|
|
@@ -84,7 +84,7 @@ export async function clickhouseApply(args: ClickHouseApplyArgs, signal?: AbortS
|
|
|
84
84
|
if (!(err instanceof ClickHouseBindingError)) throw err;
|
|
85
85
|
return refused(declaredObjects(json), err.unresolved.reason, err.unresolved.detail);
|
|
86
86
|
}
|
|
87
|
-
const declared = declaredObjects(json, target.defaultDatabase);
|
|
87
|
+
const declared = declaredObjects(json, target.defaultDatabase, target.topology);
|
|
88
88
|
let outcome: ClickHouseApplyOutcome;
|
|
89
89
|
try {
|
|
90
90
|
outcome = await applyClickHouse(target, declared, {
|
|
@@ -59,7 +59,7 @@ export async function rebuildRun(args: ClickHouseRebuildArgs, signal: AbortSigna
|
|
|
59
59
|
const config = deps.config ?? (await loadConfig(cwd));
|
|
60
60
|
const marker = resolveMarker(args, config);
|
|
61
61
|
const target = await bindClickHouse({ ...(args.environment !== undefined ? { environment: args.environment } : {}), config: config ?? {}, ...(deps.env ? { env: deps.env } : {}) });
|
|
62
|
-
const declared = declaredTable(declaredObjects(readFileSync(resolve(cwd, args.buildPath), "utf8"), target.defaultDatabase), args.table);
|
|
62
|
+
const declared = declaredTable(declaredObjects(readFileSync(resolve(cwd, args.buildPath), "utf8"), target.defaultDatabase, target.topology), args.table);
|
|
63
63
|
const runId = currentOpRun()?.runId;
|
|
64
64
|
return {
|
|
65
65
|
target,
|