@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.
- 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 +85 -1
- 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 +12 -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 +112 -0
- package/dist/clickhouse/topology.d.ts.map +1 -0
- 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/codegen/postgres-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 +106 -0
- package/dist/core/statements.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/migration-statements.d.ts +63 -0
- package/dist/migration-statements.d.ts.map +1 -0
- package/dist/postgres/apply/apply.d.ts.map +1 -1
- package/dist/postgres/apply/statements.d.ts +91 -1
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/apply/apply.ts +35 -56
- package/src/clickhouse/apply/statements.ts +168 -31
- package/src/clickhouse/live/bind.ts +13 -1
- package/src/clickhouse/live/live.test.ts +10 -0
- package/src/clickhouse/plan/commands.ts +63 -9
- 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 +183 -0
- package/src/clickhouse/topology.test.ts +309 -0
- package/src/clickhouse/topology.ts +477 -0
- package/src/clickhouse.ts +16 -0
- package/src/codegen/classifier-page.ts +19 -0
- package/src/codegen/postgres-classifier-page.ts +17 -0
- package/src/config.ts +33 -0
- package/src/core/statements.ts +129 -0
- package/src/index.ts +15 -0
- package/src/migration-statements.test.ts +384 -0
- package/src/migration-statements.ts +262 -0
- package/src/op/__snapshots__/watch-pipeline.test.ts.snap +93 -0
- 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/op/watch-drift.e2e.test.ts +302 -0
- package/src/op/watch-pipeline.test.ts +113 -0
- package/src/plugin.test.ts +9 -0
- package/src/postgres/apply/apply.ts +38 -96
- package/src/postgres/apply/statements.ts +145 -2
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single-node rendering against a server (#3645): one migration source
|
|
3
|
+
* written for a cluster (`ON CLUSTER`, `Replicated*MergeTree` with Keeper
|
|
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: as
|
|
6
|
+
* statements, and through the applier with `CLICKHOUSE_TOPOLOGY=single`,
|
|
7
|
+
* which then finds nothing left to change.
|
|
8
|
+
*
|
|
9
|
+
* Runs against `CLICKHOUSE_URL` when it is set (the emulator, `chant
|
|
10
|
+
* emulator up --lexicon sql`), else a scratch server of the pinned image.
|
|
11
|
+
* Everything it writes is in the database `yodel_3645`. Needs one or the
|
|
12
|
+
* other; skips cleanly without Docker.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
19
|
+
import { clickhouseApply } from "../op/activities/clickhouse-apply";
|
|
20
|
+
import { dockerAvailable, startScratchServer, type ScratchServer } from "./container";
|
|
21
|
+
import { clickhouseQuery, type ClickHouseEndpoint } from "./http";
|
|
22
|
+
import { clickhouseImage } from "../spec/pin";
|
|
23
|
+
import { renderStatement, type Topology } from "./topology";
|
|
24
|
+
|
|
25
|
+
const DB = "yodel_3645";
|
|
26
|
+
const fromEnv = process.env.CLICKHOUSE_URL;
|
|
27
|
+
const enabled = fromEnv !== undefined || (await dockerAvailable());
|
|
28
|
+
let server: ScratchServer | undefined;
|
|
29
|
+
let endpoint: ClickHouseEndpoint;
|
|
30
|
+
|
|
31
|
+
const q = <T = Record<string, unknown>>(sql: string) => clickhouseQuery<T>(endpoint, sql);
|
|
32
|
+
|
|
33
|
+
beforeAll(async () => {
|
|
34
|
+
if (!enabled) return;
|
|
35
|
+
if (fromEnv !== undefined) {
|
|
36
|
+
endpoint = {
|
|
37
|
+
url: fromEnv,
|
|
38
|
+
...(process.env.CLICKHOUSE_USER ? { user: process.env.CLICKHOUSE_USER } : {}),
|
|
39
|
+
...(process.env.CLICKHOUSE_PASSWORD ? { password: process.env.CLICKHOUSE_PASSWORD } : {}),
|
|
40
|
+
};
|
|
41
|
+
} else {
|
|
42
|
+
server = await startScratchServer(clickhouseImage(), { namePrefix: "chant-sql-topology" });
|
|
43
|
+
endpoint = server.endpoint;
|
|
44
|
+
}
|
|
45
|
+
await q(`DROP DATABASE IF EXISTS ${DB} SYNC`);
|
|
46
|
+
}, 600_000);
|
|
47
|
+
|
|
48
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-sql-topology-"));
|
|
49
|
+
|
|
50
|
+
afterAll(async () => {
|
|
51
|
+
if (enabled) await q(`DROP DATABASE IF EXISTS ${DB} SYNC`).catch(() => undefined);
|
|
52
|
+
await server?.stop();
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const SINGLE: Topology = { kind: "single" };
|
|
57
|
+
|
|
58
|
+
/** One migration, written for a cluster named `main`. */
|
|
59
|
+
const SOURCE = [
|
|
60
|
+
`CREATE DATABASE ${DB} ON CLUSTER main ENGINE = Replicated('/clickhouse/databases/${DB}', '{shard}', '{replica}') COMMENT 'topology e2e'`,
|
|
61
|
+
`CREATE TABLE ${DB}.events ON CLUSTER main (
|
|
62
|
+
id UInt64,
|
|
63
|
+
ts DateTime,
|
|
64
|
+
kind LowCardinality(String)
|
|
65
|
+
) ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/${DB}/events', '{replica}', ts)
|
|
66
|
+
ORDER BY id`,
|
|
67
|
+
`CREATE MATERIALIZED VIEW ${DB}.by_kind ON CLUSTER main
|
|
68
|
+
ENGINE = ReplicatedSummingMergeTree('/clickhouse/tables/{shard}/${DB}/by_kind', '{replica}')
|
|
69
|
+
ORDER BY kind
|
|
70
|
+
AS SELECT kind, count() AS n FROM ${DB}.events GROUP BY kind`,
|
|
71
|
+
`CREATE TABLE ${DB}.staging ON CLUSTER main (id UInt64, ts DateTime, kind LowCardinality(String)) ENGINE = ReplicatedReplacingMergeTree(ts) ORDER BY id`,
|
|
72
|
+
`ALTER TABLE ${DB}.events ON CLUSTER main ADD COLUMN source String DEFAULT 'web' AFTER kind`,
|
|
73
|
+
`ALTER TABLE ${DB}.staging ON CLUSTER main ADD COLUMN source String DEFAULT 'web' AFTER kind`,
|
|
74
|
+
`ALTER TABLE ${DB}.events ON CLUSTER main MODIFY COMMENT 'events'`,
|
|
75
|
+
`EXCHANGE TABLES ${DB}.events AND ${DB}.staging ON CLUSTER main`,
|
|
76
|
+
`RENAME TABLE ${DB}.staging TO ${DB}.events_old ON CLUSTER main`,
|
|
77
|
+
`DROP TABLE ${DB}.events_old ON CLUSTER main SYNC`,
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
describe.skipIf(!enabled)("the single-node rendering against a server (#3645)", () => {
|
|
81
|
+
test("the source as written for a cluster is refused by a single node", async () => {
|
|
82
|
+
await expect(q(SOURCE[0]!)).rejects.toThrow();
|
|
83
|
+
await expect(q(renderStatement(SOURCE[0]!, SINGLE))).resolves.toEqual([]);
|
|
84
|
+
// The database is there now; the table as written still fails, on its ON CLUSTER or its Keeper path.
|
|
85
|
+
await expect(q(SOURCE[1]!)).rejects.toThrow();
|
|
86
|
+
await q(`DROP DATABASE ${DB} SYNC`);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("every statement rendered for a single node applies, with the plain MergeTree family", async () => {
|
|
90
|
+
for (const sql of SOURCE) {
|
|
91
|
+
const rendered = renderStatement(sql, SINGLE);
|
|
92
|
+
expect(rendered).not.toMatch(/ON CLUSTER|Replicated/);
|
|
93
|
+
await q(rendered);
|
|
94
|
+
}
|
|
95
|
+
const db = await q<{ engine: string }>(`SELECT engine FROM system.databases WHERE name = '${DB}'`);
|
|
96
|
+
expect(db).toEqual([{ engine: "Atomic" }]);
|
|
97
|
+
const tables = await q<{ name: string; engine: string }>(
|
|
98
|
+
`SELECT name, engine FROM system.tables WHERE database = '${DB}' AND NOT startsWith(name, '.inner') ORDER BY name`,
|
|
99
|
+
);
|
|
100
|
+
expect(tables).toEqual([
|
|
101
|
+
{ name: "by_kind", engine: "MaterializedView" },
|
|
102
|
+
{ name: "events", engine: "ReplacingMergeTree" },
|
|
103
|
+
]);
|
|
104
|
+
const inner = await q<{ engine: string }>(`SELECT engine FROM system.tables WHERE database = '${DB}' AND startsWith(name, '.inner')`);
|
|
105
|
+
expect(inner).toEqual([{ engine: "SummingMergeTree" }]);
|
|
106
|
+
const cols = await q<{ name: string }>(`SELECT name FROM system.columns WHERE database = '${DB}' AND table = 'events' ORDER BY position`);
|
|
107
|
+
expect(cols.map((c) => c.name)).toEqual(["id", "ts", "kind", "source"]);
|
|
108
|
+
});
|
|
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
|
+
});
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { canonicalObject } from "./plan/normalize";
|
|
3
|
+
import { diffSchemas } from "./plan/diff";
|
|
4
|
+
import type { Change } from "./plan/diff";
|
|
5
|
+
import { alterSteps, createStatement, dropStatement, type DeclaredObject } from "./apply/statements";
|
|
6
|
+
import { CLICKHOUSE_ENTITY_TYPES, type ClickHouseEntityType } from "./entities";
|
|
7
|
+
import { parseTopology, renderEngine, renderStatement, renderSteps, type Topology } from "./topology";
|
|
8
|
+
|
|
9
|
+
const SINGLE: Topology = { kind: "single" };
|
|
10
|
+
const CLUSTER: Topology = { kind: "cluster", cluster: "main" };
|
|
11
|
+
const REPLICATED: Topology = { kind: "replicated" };
|
|
12
|
+
const CLOUD: Topology = { kind: "cloud" };
|
|
13
|
+
|
|
14
|
+
const EVENTS = `CREATE TABLE analytics.events (
|
|
15
|
+
id UInt64,
|
|
16
|
+
ts DateTime,
|
|
17
|
+
kind LowCardinality(String)
|
|
18
|
+
)
|
|
19
|
+
ENGINE = ReplacingMergeTree(ts)
|
|
20
|
+
ORDER BY id
|
|
21
|
+
COMMENT 'events'`;
|
|
22
|
+
|
|
23
|
+
describe("CREATE TABLE per topology", () => {
|
|
24
|
+
test("a single node takes the declaration as written", () => {
|
|
25
|
+
expect(renderStatement(EVENTS, SINGLE)).toBe(EVENTS);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a cluster adds ON CLUSTER after the name and replicates the engine with its Keeper path and replica", () => {
|
|
29
|
+
expect(renderStatement(EVENTS, CLUSTER)).toBe(`CREATE TABLE analytics.events ON CLUSTER \`main\` (
|
|
30
|
+
id UInt64,
|
|
31
|
+
ts DateTime,
|
|
32
|
+
kind LowCardinality(String)
|
|
33
|
+
)
|
|
34
|
+
ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}', ts)
|
|
35
|
+
ORDER BY id
|
|
36
|
+
COMMENT 'events'`);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a cluster's Keeper path and replica name can be set, and a macro cluster name is a string literal", () => {
|
|
40
|
+
const t: Topology = { kind: "cluster", cluster: "{cluster}", replicaPath: "/ch/{shard}/{database}/{table}", replicaName: "{replica}" };
|
|
41
|
+
expect(renderStatement("CREATE TABLE db.t (id UInt64) ENGINE = MergeTree ORDER BY id", t)).toBe(
|
|
42
|
+
"CREATE TABLE db.t ON CLUSTER '{cluster}' (id UInt64) ENGINE = ReplicatedMergeTree('/ch/{shard}/{database}/{table}', '{replica}') ORDER BY id",
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("a Replicated database gets the Replicated family with no path arguments and no ON CLUSTER", () => {
|
|
47
|
+
expect(renderStatement(EVENTS, REPLICATED)).toBe(EVENTS.replace("ReplacingMergeTree(ts)", "ReplicatedReplacingMergeTree(ts)"));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("Cloud takes the plain family, which it turns into SharedMergeTree itself", () => {
|
|
51
|
+
expect(renderStatement(EVENTS, CLOUD)).toBe(EVENTS);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("a declaration written for a cluster", () => {
|
|
55
|
+
const declared =
|
|
56
|
+
"CREATE TABLE IF NOT EXISTS db.t ON CLUSTER prod (id UInt64, v UInt32) ENGINE = ReplicatedSummingMergeTree('/p/{shard}/t', '{replica}', (v)) ORDER BY id";
|
|
57
|
+
|
|
58
|
+
test("keeps its own path on a cluster, under the topology's cluster name", () => {
|
|
59
|
+
expect(renderStatement(declared, CLUSTER)).toBe(
|
|
60
|
+
"CREATE TABLE IF NOT EXISTS db.t ON CLUSTER `main` (id UInt64, v UInt32) ENGINE = ReplicatedSummingMergeTree('/p/{shard}/t', '{replica}', (v)) ORDER BY id",
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("loses ON CLUSTER and replication on a single node", () => {
|
|
65
|
+
expect(renderStatement(declared, SINGLE)).toBe("CREATE TABLE IF NOT EXISTS db.t (id UInt64, v UInt32) ENGINE = SummingMergeTree((v)) ORDER BY id");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("loses ON CLUSTER and the path arguments in a Replicated database", () => {
|
|
69
|
+
expect(renderStatement(declared, REPLICATED)).toBe(
|
|
70
|
+
"CREATE TABLE IF NOT EXISTS db.t (id UInt64, v UInt32) ENGINE = ReplicatedSummingMergeTree((v)) ORDER BY id",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("loses ON CLUSTER and the path arguments on Cloud", () => {
|
|
75
|
+
expect(renderStatement(declared, CLOUD)).toBe("CREATE TABLE IF NOT EXISTS db.t (id UInt64, v UInt32) ENGINE = SummingMergeTree((v)) ORDER BY id");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("ON CLUSTER goes after a UUID clause", () => {
|
|
80
|
+
expect(renderStatement("CREATE TABLE db.t UUID '0e9a7e46-0a7d-4c3a-8d47-3c1f2f9b0a11' (id UInt64) ENGINE = MergeTree ORDER BY id", CLUSTER)).toBe(
|
|
81
|
+
"CREATE TABLE db.t UUID '0e9a7e46-0a7d-4c3a-8d47-3c1f2f9b0a11' ON CLUSTER `main` (id UInt64) ENGINE = ReplicatedMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}') ORDER BY id",
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("an engine outside the MergeTree family is left alone; only ON CLUSTER changes", () => {
|
|
86
|
+
const log = "CREATE TABLE db.l (id UInt64) ENGINE = Log";
|
|
87
|
+
expect(renderStatement(log, REPLICATED)).toBe(log);
|
|
88
|
+
expect(renderStatement(log, CLUSTER)).toBe("CREATE TABLE db.l ON CLUSTER `main` (id UInt64) ENGINE = Log");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("comments and spacing in the declaration survive", () => {
|
|
92
|
+
const sql = "CREATE TABLE db.t ( -- the key\n id UInt64 /* id */\n)\nENGINE = MergeTree() -- engine\nORDER BY id";
|
|
93
|
+
expect(renderStatement(sql, REPLICATED)).toBe(sql.replace("MergeTree()", "ReplicatedMergeTree()"));
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("views per topology", () => {
|
|
98
|
+
const mv = "CREATE MATERIALIZED VIEW db.counts ENGINE = SummingMergeTree ORDER BY kind AS SELECT kind, count() AS n FROM db.events GROUP BY kind";
|
|
99
|
+
const to = "CREATE MATERIALIZED VIEW db.feed TO db.events AS SELECT * FROM db.raw";
|
|
100
|
+
|
|
101
|
+
test("a materialized view's inner engine is replicated like a table's", () => {
|
|
102
|
+
expect(renderStatement(mv, CLUSTER)).toBe(
|
|
103
|
+
"CREATE MATERIALIZED VIEW db.counts ON CLUSTER `main` ENGINE = ReplicatedSummingMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}') ORDER BY kind AS SELECT kind, count() AS n FROM db.events GROUP BY kind",
|
|
104
|
+
);
|
|
105
|
+
expect(renderStatement(mv, REPLICATED)).toBe(mv.replace("SummingMergeTree", "ReplicatedSummingMergeTree"));
|
|
106
|
+
expect(renderStatement(mv, SINGLE)).toBe(mv);
|
|
107
|
+
expect(renderStatement(mv, CLOUD)).toBe(mv);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a view writing TO a table only gains ON CLUSTER", () => {
|
|
111
|
+
expect(renderStatement(to, CLUSTER)).toBe("CREATE MATERIALIZED VIEW db.feed ON CLUSTER `main` TO db.events AS SELECT * FROM db.raw");
|
|
112
|
+
for (const t of [SINGLE, REPLICATED, CLOUD]) expect(renderStatement(to, t)).toBe(to);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a SELECT inside a view is not mistaken for the view's own clauses", () => {
|
|
116
|
+
const v = "CREATE OR REPLACE VIEW db.v AS SELECT * FROM db.t ON CLUSTER_X JOIN db.u USING id";
|
|
117
|
+
expect(renderStatement(v, SINGLE)).toBe(v);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("CREATE DATABASE per topology", () => {
|
|
122
|
+
const db = "CREATE DATABASE IF NOT EXISTS analytics COMMENT 'the warehouse'";
|
|
123
|
+
|
|
124
|
+
test("single node and Cloud take it as written", () => {
|
|
125
|
+
expect(renderStatement(db, SINGLE)).toBe(db);
|
|
126
|
+
expect(renderStatement(db, CLOUD)).toBe(db);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a cluster adds ON CLUSTER", () => {
|
|
130
|
+
expect(renderStatement(db, CLUSTER)).toBe("CREATE DATABASE IF NOT EXISTS analytics ON CLUSTER `main` COMMENT 'the warehouse'");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("the replicated topology makes it a Replicated database, on its cluster when it names one", () => {
|
|
134
|
+
expect(renderStatement(db, REPLICATED)).toBe(
|
|
135
|
+
"CREATE DATABASE IF NOT EXISTS analytics ENGINE = Replicated('/clickhouse/databases/analytics', '{shard}', '{replica}') COMMENT 'the warehouse'",
|
|
136
|
+
);
|
|
137
|
+
expect(renderStatement(db, { kind: "replicated", cluster: "all" })).toBe(
|
|
138
|
+
"CREATE DATABASE IF NOT EXISTS analytics ON CLUSTER `all` ENGINE = Replicated('/clickhouse/databases/analytics', '{shard}', '{replica}') COMMENT 'the warehouse'",
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a declared Atomic engine becomes Replicated in the replicated topology, and a declared Replicated one keeps its arguments", () => {
|
|
143
|
+
expect(renderStatement("CREATE DATABASE `a` ENGINE = Atomic", REPLICATED)).toBe("CREATE DATABASE `a` ENGINE = Replicated('/clickhouse/databases/a', '{shard}', '{replica}')");
|
|
144
|
+
const r = "CREATE DATABASE a ENGINE = Replicated('/x/a', 's', 'r')";
|
|
145
|
+
expect(renderStatement(r, REPLICATED)).toBe(r);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("a declared Replicated engine is dropped everywhere else", () => {
|
|
149
|
+
const r = "CREATE DATABASE a ENGINE = Replicated('/x/a', '{shard}', '{replica}') COMMENT 'c'";
|
|
150
|
+
expect(renderStatement(r, SINGLE)).toBe("CREATE DATABASE a COMMENT 'c'");
|
|
151
|
+
expect(renderStatement(r, CLOUD)).toBe("CREATE DATABASE a COMMENT 'c'");
|
|
152
|
+
expect(renderStatement(r, CLUSTER)).toBe("CREATE DATABASE a ON CLUSTER `main` COMMENT 'c'");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("the other DDL per topology", () => {
|
|
157
|
+
const cases: Array<[string, string]> = [
|
|
158
|
+
["ALTER TABLE `db`.`t` ADD COLUMN `x` UInt8 AFTER `id`", "ALTER TABLE `db`.`t` ON CLUSTER `main` ADD COLUMN `x` UInt8 AFTER `id`"],
|
|
159
|
+
["ALTER TABLE `db`.`t` MODIFY COMMENT 'c'", "ALTER TABLE `db`.`t` ON CLUSTER `main` MODIFY COMMENT 'c'"],
|
|
160
|
+
["DROP TABLE `db`.`t` SYNC", "DROP TABLE `db`.`t` ON CLUSTER `main` SYNC"],
|
|
161
|
+
["DROP VIEW IF EXISTS `db`.`v` SYNC", "DROP VIEW IF EXISTS `db`.`v` ON CLUSTER `main` SYNC"],
|
|
162
|
+
["TRUNCATE TABLE db.t", "TRUNCATE TABLE db.t ON CLUSTER `main`"],
|
|
163
|
+
["DETACH VIEW db.v", "DETACH VIEW db.v ON CLUSTER `main`"],
|
|
164
|
+
["ATTACH TABLE db.v", "ATTACH TABLE db.v ON CLUSTER `main`"],
|
|
165
|
+
["OPTIMIZE TABLE db.t FINAL", "OPTIMIZE TABLE db.t ON CLUSTER `main` FINAL"],
|
|
166
|
+
["RENAME TABLE `db`.`a` TO `db`.`b`", "RENAME TABLE `db`.`a` TO `db`.`b` ON CLUSTER `main`"],
|
|
167
|
+
["RENAME TABLE db.a TO db.b, db.c TO db.d;", "RENAME TABLE db.a TO db.b, db.c TO db.d ON CLUSTER `main`;"],
|
|
168
|
+
["EXCHANGE TABLES db.a AND db.b", "EXCHANGE TABLES db.a AND db.b ON CLUSTER `main`"],
|
|
169
|
+
["EXCHANGE TABLES db.a AND db.b SETTINGS x = 1", "EXCHANGE TABLES db.a AND db.b ON CLUSTER `main` SETTINGS x = 1"],
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
test.each(cases)("%s gains ON CLUSTER on a cluster", (sql, onCluster) => {
|
|
173
|
+
expect(renderStatement(sql, CLUSTER)).toBe(onCluster);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test.each(cases)("%s is written without ON CLUSTER on every other topology", (sql, onCluster) => {
|
|
177
|
+
for (const t of [SINGLE, REPLICATED, CLOUD]) {
|
|
178
|
+
expect(renderStatement(sql, t)).toBe(sql);
|
|
179
|
+
expect(renderStatement(onCluster, t)).toBe(sql);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("ALTER, RENAME and DROP DATABASE take ON CLUSTER on a cluster", () => {
|
|
184
|
+
expect(renderStatement("ALTER DATABASE `db` MODIFY COMMENT 'c'", CLUSTER)).toBe("ALTER DATABASE `db` ON CLUSTER `main` MODIFY COMMENT 'c'");
|
|
185
|
+
expect(renderStatement("RENAME DATABASE `a` TO `b`", CLUSTER)).toBe("RENAME DATABASE `a` TO `b` ON CLUSTER `main`");
|
|
186
|
+
expect(renderStatement("DROP DATABASE `db` SYNC", CLUSTER)).toBe("DROP DATABASE `db` ON CLUSTER `main` SYNC");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("in the replicated topology only dropping a database carries its cluster", () => {
|
|
190
|
+
const t: Topology = { kind: "replicated", cluster: "all" };
|
|
191
|
+
expect(renderStatement("DROP DATABASE `db` SYNC", t)).toBe("DROP DATABASE `db` ON CLUSTER `all` SYNC");
|
|
192
|
+
expect(renderStatement("ALTER DATABASE `db` MODIFY COMMENT 'c'", t)).toBe("ALTER DATABASE `db` MODIFY COMMENT 'c'");
|
|
193
|
+
expect(renderStatement("ALTER TABLE db.t DROP COLUMN x", t)).toBe("ALTER TABLE db.t DROP COLUMN x");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("a declared ON CLUSTER is replaced with the topology's cluster", () => {
|
|
197
|
+
expect(renderStatement("ALTER TABLE db.t ON CLUSTER old DROP COLUMN x", CLUSTER)).toBe("ALTER TABLE db.t ON CLUSTER `main` DROP COLUMN x");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("KILL gains ON CLUSTER on a cluster, and is kept as written elsewhere", () => {
|
|
201
|
+
expect(renderStatement("KILL QUERY WHERE query_id = 'q' SYNC", CLUSTER)).toBe("KILL QUERY ON CLUSTER `main` WHERE query_id = 'q' SYNC");
|
|
202
|
+
const replicatedKill = "KILL QUERY ON CLUSTER 'db' WHERE query_id = 'q' SYNC";
|
|
203
|
+
expect(renderStatement(replicatedKill, REPLICATED)).toBe(replicatedKill);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("statements that are not DDL come back unchanged", () => {
|
|
207
|
+
for (const sql of ["INSERT INTO db.t SELECT * FROM db.s", "SELECT 1", "SYSTEM SYNC REPLICA db.t LIGHTWEIGHT"]) {
|
|
208
|
+
for (const t of [SINGLE, CLUSTER, REPLICATED, CLOUD]) expect(renderStatement(sql, t)).toBe(sql);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("a CREATE the parser does not take still gains ON CLUSTER", () => {
|
|
213
|
+
expect(renderStatement("CREATE TABLE db.t2 AS db.t", CLUSTER)).toBe("CREATE TABLE db.t2 ON CLUSTER `main` AS db.t");
|
|
214
|
+
expect(renderStatement("CREATE TABLE db.t2 ON CLUSTER x AS db.t", SINGLE)).toBe("CREATE TABLE db.t2 AS db.t");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("renderEngine", () => {
|
|
219
|
+
test("splits a Replicated engine's path and replica from the family's own arguments", () => {
|
|
220
|
+
expect(renderEngine({ name: "ReplicatedReplacingMergeTree", args: ["'/p'", "'{replica}'", "ver"] }, SINGLE)).toEqual({ name: "ReplacingMergeTree", args: ["ver"] });
|
|
221
|
+
expect(renderEngine({ name: "ReplicatedReplacingMergeTree", args: ["ver"] }, SINGLE)).toEqual({ name: "ReplacingMergeTree", args: ["ver"] });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("leaves a Shared engine and the non-MergeTree engines alone", () => {
|
|
225
|
+
for (const name of ["SharedMergeTree", "Distributed", "Memory"]) {
|
|
226
|
+
const e = { name, args: ["a"] };
|
|
227
|
+
for (const t of [SINGLE, CLUSTER, REPLICATED, CLOUD]) expect(renderEngine(e, t)).toBe(e);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("keeps an engine written without parentheses without them where it can", () => {
|
|
232
|
+
expect(renderEngine({ name: "MergeTree" }, REPLICATED)).toEqual({ name: "ReplicatedMergeTree" });
|
|
233
|
+
expect(renderEngine({ name: "ReplicatedMergeTree" }, CLOUD)).toEqual({ name: "MergeTree" });
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("parseTopology", () => {
|
|
238
|
+
test("reads each topology's string form", () => {
|
|
239
|
+
expect(parseTopology("single")).toEqual(SINGLE);
|
|
240
|
+
expect(parseTopology("cluster:main")).toEqual(CLUSTER);
|
|
241
|
+
expect(parseTopology("cluster:{cluster}")).toEqual({ kind: "cluster", cluster: "{cluster}" });
|
|
242
|
+
expect(parseTopology("replicated")).toEqual(REPLICATED);
|
|
243
|
+
expect(parseTopology("replicated:all")).toEqual({ kind: "replicated", cluster: "all" });
|
|
244
|
+
expect(parseTopology("cloud")).toEqual(CLOUD);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("refuses a cluster without a name, and anything unknown", () => {
|
|
248
|
+
expect(() => parseTopology("cluster")).toThrow(/needs the cluster's name/);
|
|
249
|
+
expect(() => parseTopology("single:x")).toThrow(/takes no argument/);
|
|
250
|
+
expect(() => parseTopology("sharded")).toThrow(/unknown topology/);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe("the applier's own statements", () => {
|
|
255
|
+
const declared = (type: ClickHouseEntityType, exportName: string, ddl: string): DeclaredObject => {
|
|
256
|
+
const canonical = canonicalObject(ddl);
|
|
257
|
+
return { exportName, type, key: canonical.database ? `${canonical.database}.${canonical.name}` : canonical.name, ddl, canonical, dependsOn: [] };
|
|
258
|
+
};
|
|
259
|
+
const table = declared(CLICKHOUSE_ENTITY_TYPES.table, "events", "CREATE TABLE db.events (id UInt64, x UInt8) ENGINE = MergeTree ORDER BY id");
|
|
260
|
+
const marker = { stack: "s", env: "e" };
|
|
261
|
+
const added: Change = { object: "events", field: "columns.x", after: "UInt8", rule: "SQLCH201", class: "metadata" };
|
|
262
|
+
|
|
263
|
+
test("render for a cluster, with the marker comment and steps kept", () => {
|
|
264
|
+
const create = renderStatement(createStatement(table, marker), CLUSTER);
|
|
265
|
+
expect(create).toMatch(/^CREATE TABLE db\.events ON CLUSTER `main` \(id UInt64, x UInt8\) ENGINE = ReplicatedMergeTree\('\/clickhouse\/tables\/\{uuid\}\/\{shard\}', '\{replica\}'\) ORDER BY id COMMENT '/);
|
|
266
|
+
const steps = renderSteps(alterSteps(table, [added]), CLUSTER);
|
|
267
|
+
expect(steps).toEqual([{ sql: "ALTER TABLE `db`.`events` ON CLUSTER `main` ADD COLUMN x UInt8 AFTER `id`", rewrite: false, rule: "SQLCH201", class: "metadata" }]);
|
|
268
|
+
expect(renderStatement(dropStatement(CLICKHOUSE_ENTITY_TYPES.table, "db", "events"), CLUSTER)).toBe("DROP TABLE `db`.`events` ON CLUSTER `main` SYNC");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("come back unchanged for a single node", () => {
|
|
272
|
+
const create = createStatement(table, marker);
|
|
273
|
+
expect(renderStatement(create, SINGLE)).toBe(create);
|
|
274
|
+
const steps = alterSteps(table, [added]);
|
|
275
|
+
expect(renderSteps(steps, SINGLE)).toEqual(steps);
|
|
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
|
+
});
|