@intentius/chant-lexicon-sql 0.118.1 → 0.121.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 +1 -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/import/generator.d.ts +1 -1
- package/dist/clickhouse/import/generator.d.ts.map +1 -1
- package/dist/clickhouse/import/inlined.d.ts +49 -0
- package/dist/clickhouse/import/inlined.d.ts.map +1 -0
- package/dist/clickhouse/import/live-export.d.ts +22 -0
- package/dist/clickhouse/import/live-export.d.ts.map +1 -1
- package/dist/clickhouse/live/bind.d.ts +8 -0
- package/dist/clickhouse/live/bind.d.ts.map +1 -1
- package/dist/clickhouse/live/catalog.d.ts +15 -0
- package/dist/clickhouse/live/catalog.d.ts.map +1 -1
- package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
- package/dist/clickhouse/plan/commands.d.ts.map +1 -1
- package/dist/clickhouse/plan/deep.d.ts +19 -2
- 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 +3 -0
- package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
- package/dist/clickhouse/plan/rebuild-handoff.d.ts +2 -0
- package/dist/clickhouse/plan/rebuild-handoff.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/engines.d.ts +44 -0
- package/dist/clickhouse/rebuild/engines.d.ts.map +1 -0
- package/dist/clickhouse/rebuild/verify.d.ts +25 -1
- package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
- package/dist/clickhouse/testing/fake-server.d.ts +5 -0
- package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
- package/dist/config.d.ts +36 -8
- package/dist/config.d.ts.map +1 -1
- package/dist/core/foreign-tables.d.ts +13 -0
- package/dist/core/foreign-tables.d.ts.map +1 -0
- package/dist/files/clickhouse.d.ts +7 -3
- package/dist/files/clickhouse.d.ts.map +1 -1
- package/dist/files/postgres.d.ts +3 -0
- package/dist/files/postgres.d.ts.map +1 -1
- package/dist/import-parser.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/clickhouse-emulator.d.ts +7 -1
- package/dist/op/activities/clickhouse-emulator.d.ts.map +1 -1
- package/dist/op/activities/postgres-emulator.d.ts +8 -1
- package/dist/op/activities/postgres-emulator.d.ts.map +1 -1
- package/dist/postgres/access/changes.d.ts +23 -0
- package/dist/postgres/access/changes.d.ts.map +1 -0
- package/dist/postgres/apply/apply.d.ts.map +1 -1
- package/dist/postgres/entities.d.ts.map +1 -1
- package/dist/postgres/live/bind.d.ts +8 -1
- package/dist/postgres/live/bind.d.ts.map +1 -1
- package/dist/postgres/live/catalog.d.ts +3 -3
- package/dist/postgres/live/catalog.d.ts.map +1 -1
- package/dist/postgres/live/client.d.ts +31 -0
- package/dist/postgres/live/client.d.ts.map +1 -1
- package/dist/postgres/plan/commands.d.ts.map +1 -1
- package/dist/postgres/plan/deep.d.ts.map +1 -1
- package/dist/sql-files.d.ts +2 -1
- package/dist/sql-files.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/apply/apply.ts +8 -2
- package/src/clickhouse/apply/statements.ts +32 -5
- package/src/clickhouse/import/generator.ts +8 -3
- package/src/clickhouse/import/import.test.ts +72 -0
- package/src/clickhouse/import/inlined-functions.e2e.test.ts +87 -0
- package/src/clickhouse/import/inlined.test.ts +48 -0
- package/src/clickhouse/import/inlined.ts +200 -0
- package/src/clickhouse/import/live-export.ts +75 -2
- package/src/clickhouse/live/access.e2e.test.ts +71 -18
- package/src/clickhouse/live/bind.ts +10 -0
- package/src/clickhouse/live/catalog.ts +25 -2
- package/src/clickhouse/live/describe-resources.ts +5 -2
- package/src/clickhouse/live/live.test.ts +8 -0
- package/src/clickhouse/plan/commands.ts +6 -3
- package/src/clickhouse/plan/deep-grants.test.ts +67 -0
- package/src/clickhouse/plan/deep.ts +70 -6
- package/src/clickhouse/plan/diff.ts +4 -1
- package/src/clickhouse/plan/normalize.ts +5 -2
- package/src/clickhouse/plan/plan.test.ts +9 -1
- package/src/clickhouse/plan/rebuild-handoff.ts +11 -1
- package/src/clickhouse/rebuild/engines.ts +163 -0
- package/src/clickhouse/rebuild/rebuild-collapsing.e2e.test.ts +120 -0
- package/src/clickhouse/rebuild/rebuild-into-collapsing.e2e.test.ts +198 -0
- package/src/clickhouse/rebuild/rebuild.test.ts +78 -0
- package/src/clickhouse/rebuild/verify.ts +153 -24
- package/src/clickhouse/testing/fake-server.ts +3 -2
- package/src/codegen/classifier-page.ts +1 -1
- package/src/config.ts +18 -4
- package/src/core/foreign-tables.ts +28 -0
- package/src/files/clickhouse.ts +71 -5
- package/src/files/postgres.ts +2 -1
- package/src/import-parser.ts +2 -0
- package/src/migration-statements.test.ts +33 -0
- package/src/migration-statements.ts +2 -0
- package/src/op/__snapshots__/watch-pipeline.test.ts.snap +1 -1
- package/src/op/activities/clickhouse-emulator.ts +22 -1
- package/src/op/activities/postgres-apply-lock-timeout.e2e.test.ts +87 -0
- package/src/op/activities/postgres-apply.test.ts +32 -0
- package/src/op/activities/postgres-emulator.test.ts +20 -0
- package/src/op/activities/postgres-emulator.ts +41 -1
- package/src/op/apply-gate-access-clickhouse.e2e.test.ts +195 -0
- package/src/op/apply-gate-access.e2e.test.ts +207 -0
- package/src/postgres/access/access.test.ts +26 -1
- package/src/postgres/access/changes.ts +39 -0
- package/src/postgres/apply/apply.ts +10 -7
- package/src/postgres/entities.ts +20 -1
- package/src/postgres/live/bind.ts +16 -4
- package/src/postgres/live/catalog.ts +3 -15
- package/src/postgres/live/client.ts +85 -0
- package/src/postgres/live/live.test.ts +54 -0
- package/src/postgres/live/lock-timeout.e2e.test.ts +70 -0
- package/src/postgres/plan/commands.ts +2 -14
- package/src/postgres/plan/deep.ts +44 -2
- package/src/serializer.test.ts +4 -0
- package/src/skills/chant-sql-postgres-plan.md +1 -1
- package/src/skills/chant-sql-rebuild.md +2 -0
- package/src/skills/chant-sql.md +1 -1
- package/src/sql-files.test.ts +78 -1
- package/src/sql-files.ts +2 -1
|
@@ -24,6 +24,14 @@
|
|
|
24
24
|
* later than the cut-over delay, with a time before the cut-over) stop the
|
|
25
25
|
* swap too, rather than stay behind in the old table.
|
|
26
26
|
*
|
|
27
|
+
* A table whose engine collapses rows that share a sorting key when parts
|
|
28
|
+
* merge (Summing, Replacing, Aggregating, Collapsing, VersionedCollapsing,
|
|
29
|
+
* Coalescing and Graphite, replicated or not), rebuilt into such an engine,
|
|
30
|
+
* is read under `FINAL` on both sides (#3674). The old table can hold unmerged parts with several rows for
|
|
31
|
+
* one key, which the copy inserts in one block and the new table collapses
|
|
32
|
+
* as it writes them, so the raw counts differ although the data is the same.
|
|
33
|
+
* Under `FINAL` both sides are compared as merged.
|
|
34
|
+
*
|
|
27
35
|
* On a cluster of more than one shard (#3663) every shard is read, through
|
|
28
36
|
* the profile's server, and each partition is compared per shard
|
|
29
37
|
* (`shard<n>/<partition>`), so a shard the copy missed is a difference.
|
|
@@ -44,9 +52,11 @@ import { rebuildPlanSubject, RebuildRefusal, type RebuildObservation } from "./o
|
|
|
44
52
|
import { sourcePartitionExpression } from "./partitions";
|
|
45
53
|
import { cutoverOf, observe, syncReplica, utcLiteral, waitOn, type RebuildRun } from "./steps";
|
|
46
54
|
import { shardTable } from "./shards";
|
|
55
|
+
import { collapseSemantics, collapsesRows, splitTopLevel, type CollapseSemantics, type EngineColumn } from "./engines";
|
|
47
56
|
|
|
48
57
|
export interface PartitionCheck {
|
|
49
58
|
partition: string;
|
|
59
|
+
/** Rows; keys of the new sorting key when the tables were compared by key (#3727). */
|
|
50
60
|
rows: number;
|
|
51
61
|
checksum: string;
|
|
52
62
|
}
|
|
@@ -67,23 +77,42 @@ export interface VerifyResult {
|
|
|
67
77
|
|
|
68
78
|
/** A verification that found the tables different. */
|
|
69
79
|
export class RebuildVerificationError extends Error {
|
|
80
|
+
/** The engine, when it collapses rows and the tables were compared under FINAL (#3674) or by sorting key (#3727). */
|
|
81
|
+
readonly collapsingEngine?: string;
|
|
70
82
|
constructor(
|
|
71
83
|
readonly table: string,
|
|
72
84
|
readonly mismatches: Mismatch[],
|
|
85
|
+
/** How the tables were compared when an engine collapses rows; an engine's name alone means under FINAL. */
|
|
86
|
+
collapsed?: string | Collapsed,
|
|
73
87
|
) {
|
|
88
|
+
const how: Collapsed | undefined = typeof collapsed === "string" ? { engine: collapsed, how: "final" } : collapsed;
|
|
74
89
|
const shown = mismatches
|
|
75
90
|
.slice(0, 10)
|
|
76
|
-
.map((m) => `${m.partition}: old ${m.old ? `${m.old.rows}
|
|
91
|
+
.map((m) => `${m.partition}: old ${m.old ? `${m.old.rows} ${unit(how)}, checksum ${m.old.checksum}` : "none"}, new ${m.new ? `${m.new.rows} ${unit(how)}, checksum ${m.new.checksum}` : "none"}`)
|
|
77
92
|
.join("; ");
|
|
93
|
+
const lateWrite =
|
|
94
|
+
`in materialized-view mode a row written to the old table later than the cut-over delay, with a time before the cut-over, ` +
|
|
95
|
+
`and in app mode a write the application did not stop`;
|
|
78
96
|
super(
|
|
79
|
-
`${table}: the new table does not match the old one in ${mismatches.length} partition(s): ${shown}${mismatches.length > 10 ? "; ..." : ""}. ` +
|
|
80
|
-
|
|
81
|
-
|
|
97
|
+
`${table}: the new table does not match the old one in ${mismatches.length} partition(s): ${shown}${mismatches.length > 10 ? "; ..." : ""}. Nothing was swapped. ` +
|
|
98
|
+
(how?.how === "final"
|
|
99
|
+
? `${table} is a ${how.engine}, which collapses rows that share a sorting key when parts merge, and both tables were compared under FINAL. ` +
|
|
100
|
+
`They still differ when the new table collapses the copied rows by its sorting key differently from how the old one merges them: ` +
|
|
101
|
+
`run OPTIMIZE TABLE ${table} FINAL, drop the new table if the Op kept it, and run the rebuild again, so the copy reads merged rows. ` +
|
|
102
|
+
`The other causes are ${lateWrite}.`
|
|
103
|
+
: how?.how === "keys"
|
|
104
|
+
? `The new table is a ${how.engine}, which collapses rows that share a sorting key, so both tables were grouped by its sorting key and partition ` +
|
|
105
|
+
`and compared per key on ${how.describe ?? "the keys"}, which its merges keep. A key missing from the new table, or one whose values differ, is rows the copy lost or changed. ` +
|
|
106
|
+
`The usual cause is ${lateWrite}.`
|
|
107
|
+
: `The usual cause is ${lateWrite}.`),
|
|
82
108
|
);
|
|
83
109
|
this.name = "RebuildVerificationError";
|
|
110
|
+
if (how) this.collapsingEngine = how.engine;
|
|
84
111
|
}
|
|
85
112
|
}
|
|
86
113
|
|
|
114
|
+
const unit = (how: Collapsed | undefined) => (how?.how === "keys" ? "keys" : "rows");
|
|
115
|
+
|
|
87
116
|
/** How often a difference is read again before it counts, and how long apart. */
|
|
88
117
|
const COMPARE_ATTEMPTS = 5;
|
|
89
118
|
const COMPARE_INTERVAL_MS = 1000;
|
|
@@ -92,6 +121,81 @@ const COMPARE_INTERVAL_MS = 1000;
|
|
|
92
121
|
interface SideCounts {
|
|
93
122
|
all: Map<string, PartitionCheck>;
|
|
94
123
|
before: Map<string, PartitionCheck>;
|
|
124
|
+
/** Grouped by sorting key (#3727): the rows read, of which `all` counts the keys. */
|
|
125
|
+
rows?: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* How the tables were compared when an engine collapses rows: both under
|
|
130
|
+
* FINAL (#3674), or grouped by the new table's sorting key on what its
|
|
131
|
+
* engine's merges keep (#3727).
|
|
132
|
+
*/
|
|
133
|
+
export interface Collapsed {
|
|
134
|
+
engine: string;
|
|
135
|
+
how: "final" | "keys";
|
|
136
|
+
/** With `keys`: what is compared per key. */
|
|
137
|
+
describe?: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Both tables grouped by the new table's sorting key and its partition, each
|
|
142
|
+
* key compared on what the new engine's merges keep (#3727): its sums, its
|
|
143
|
+
* highest version, the sum of its signs. Each side is read raw, so the
|
|
144
|
+
* comparison is the same whichever parts have merged. The old side's columns
|
|
145
|
+
* are cast to the new table's types and named as there, so the sorting key,
|
|
146
|
+
* the partition key and the aggregates read the same on both sides. A
|
|
147
|
+
* partition is the new table's (`shard<n>/` before it on a cluster), and its
|
|
148
|
+
* rows are its keys.
|
|
149
|
+
*/
|
|
150
|
+
async function keyComparison(run: RebuildRun, o: RebuildObservation, columns: readonly EngineColumn[]): Promise<{ semantics: CollapseSemantics; read: (side: "old" | "new") => Promise<SideCounts> } | undefined> {
|
|
151
|
+
const n = o.names;
|
|
152
|
+
const [t] = await clickhouseQuery<{ engine_full: string; sorting_key: string; partition_key: string }>(
|
|
153
|
+
run.target.endpoint,
|
|
154
|
+
`SELECT engine_full, sorting_key, partition_key FROM system.tables WHERE database = ${sqlString(n.database)} AND name = ${sqlString(n.newName)}`,
|
|
155
|
+
);
|
|
156
|
+
if (!t || !o.newTable) return undefined;
|
|
157
|
+
const semantics = collapseSemantics(o.newTable.engine, t.engine_full, columns);
|
|
158
|
+
if (!semantics) return undefined;
|
|
159
|
+
// Every column of the sorting key must come from the old table, or the old rows have no key to group by.
|
|
160
|
+
const copied = new Set(o.copied.map((c) => c.name));
|
|
161
|
+
if (columns.some((c) => c.inSortingKey && !copied.has(c.name))) return undefined;
|
|
162
|
+
|
|
163
|
+
const types = new Map(columns.map((c) => [c.name, c.type]));
|
|
164
|
+
const sharding = run.sharding;
|
|
165
|
+
const cutover = cutoverOf(run, o);
|
|
166
|
+
const cut = run.dualWrite.mode === "materialized-view" ? (run.dualWrite as { cutoverColumn: string }).cutoverColumn : undefined;
|
|
167
|
+
const before = cut && copied.has(cut) && cutover !== undefined ? `${ident(cut)} < ${utcLiteral(cutover)}` : "1";
|
|
168
|
+
const key = splitTopLevel(t.sorting_key).filter(Boolean);
|
|
169
|
+
const parts = splitTopLevel(t.partition_key).filter(Boolean);
|
|
170
|
+
const partition = parts.length === 0 ? "'all'" : `toString(${parts.length === 1 ? parts[0] : `tuple(${parts.join(", ")})`})`;
|
|
171
|
+
const label = sharding ? `concat('shard', toString(__shard), '/', ${partition})` : partition;
|
|
172
|
+
const hash = `cityHash64(toString(tuple(${[...key, ...semantics.aggregates].join(", ")})))`;
|
|
173
|
+
|
|
174
|
+
const read = async (side: "old" | "new"): Promise<SideCounts> => {
|
|
175
|
+
const name = side === "old" ? n.name : n.newName;
|
|
176
|
+
const from = sharding ? shardTable(sharding, n.database, name) : side === "old" ? n.table : n.newTable;
|
|
177
|
+
const projected = o.copied.map((c) => (side === "old" ? `CAST(${ident(c.source)}, ${sqlString(types.get(c.name) ?? "String")}) AS ${ident(c.name)}` : ident(c.name)));
|
|
178
|
+
const rowsOf = `SELECT ${sharding ? "_shard_num AS __shard, " : ""}${projected.join(", ")} FROM ${from}`;
|
|
179
|
+
const perKey =
|
|
180
|
+
`SELECT ${label} AS __p, ${hash} AS __h, max(${before}) AS __b, count() AS __n, ${semantics.having ?? "1"} AS __k FROM (${rowsOf}) ` +
|
|
181
|
+
`GROUP BY __p${sharding ? ", __shard" : ""}${key.length > 0 ? `, ${key.join(", ")}` : ""}`;
|
|
182
|
+
// A key the engine removes (its sums all zero, its signs cancelled) is not compared, but its rows are counted as read.
|
|
183
|
+
const rows = await clickhouseQuery<{ p: string; rows: string | number; checksum: string; brows: string | number; bchecksum: string; raw: string | number }>(
|
|
184
|
+
run.target.endpoint,
|
|
185
|
+
`SELECT __p AS p, countIf(__k) AS rows, toString(sumIf(__h, __k)) AS checksum, countIf(__b AND __k) AS brows, toString(sumIf(__h, __b AND __k)) AS bchecksum, sum(__n) AS raw ` +
|
|
186
|
+
`FROM (${perKey}) GROUP BY p`,
|
|
187
|
+
);
|
|
188
|
+
const out: SideCounts = { all: new Map(), before: new Map(), rows: 0 };
|
|
189
|
+
for (const r of rows) {
|
|
190
|
+
out.rows! += Number(r.raw);
|
|
191
|
+
// A partition whose keys the engine all removes holds nothing in the new table, as if it had no rows.
|
|
192
|
+
if (Number(r.rows) === 0) continue;
|
|
193
|
+
out.all.set(r.p, { partition: r.p, rows: Number(r.rows), checksum: r.checksum });
|
|
194
|
+
if (Number(r.brows) > 0) out.before.set(r.p, { partition: r.p, rows: Number(r.brows), checksum: r.bchecksum });
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
};
|
|
198
|
+
return { semantics, read };
|
|
95
199
|
}
|
|
96
200
|
|
|
97
201
|
/**
|
|
@@ -103,21 +207,21 @@ async function compareTables(
|
|
|
103
207
|
run: RebuildRun,
|
|
104
208
|
o: RebuildObservation,
|
|
105
209
|
opts: { attempts?: number; intervalMs?: number } = {},
|
|
106
|
-
): Promise<{ old: SideCounts; new: SideCounts; mismatches: Mismatch[] }> {
|
|
210
|
+
): Promise<{ old: SideCounts; new: SideCounts; mismatches: Mismatch[]; collapsed?: Collapsed }> {
|
|
107
211
|
if (!o.newTable) throw new RebuildRefusal(`${o.names.key}: the new table is not there yet; the Create phase makes it`);
|
|
108
212
|
const n = o.names;
|
|
109
213
|
const cutover = cutoverOf(run, o);
|
|
110
214
|
await waitOn(run, n.database, n.name);
|
|
111
215
|
await waitOn(run, n.database, n.newName);
|
|
112
216
|
|
|
113
|
-
const
|
|
114
|
-
(
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
`
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
);
|
|
217
|
+
const newColumns = (
|
|
218
|
+
await clickhouseQuery<{ name: string; type: string; key: number | string; partition: number | string }>(
|
|
219
|
+
run.target.endpoint,
|
|
220
|
+
`SELECT name, type, is_in_sorting_key AS key, is_in_partition_key AS partition FROM system.columns ` +
|
|
221
|
+
`WHERE database = ${sqlString(n.database)} AND table = ${sqlString(n.newName)} ORDER BY position`,
|
|
222
|
+
)
|
|
223
|
+
).map((c): EngineColumn => ({ name: c.name, type: c.type, inSortingKey: Number(c.key) === 1, inPartitionKey: Number(c.partition) === 1 }));
|
|
224
|
+
const types = new Map(newColumns.map((c) => [c.name, c.type]));
|
|
121
225
|
const oldHash = rowHash(o.copied.map((c) => `CAST(${ident(c.source)}, ${sqlString(types.get(c.name) ?? "String")})`));
|
|
122
226
|
const newHash = rowHash(o.copied.map((c) => ident(c.name)));
|
|
123
227
|
const cut = run.dualWrite.mode === "materialized-view" ? o.copied.find((c) => c.name === (run.dualWrite as { cutoverColumn: string }).cutoverColumn) : undefined;
|
|
@@ -127,8 +231,15 @@ async function compareTables(
|
|
|
127
231
|
|
|
128
232
|
// Across the shards of a cluster (#3663), every shard's rows, each partition counted per shard as `shard<n>/<partition>`.
|
|
129
233
|
const sharding = run.sharding;
|
|
130
|
-
|
|
131
|
-
|
|
234
|
+
// Tables whose engines collapse rows by sorting key are read as merged (#3674). Only when both do: a collapsing
|
|
235
|
+
// table rebuilt into a plain MergeTree keeps every row it copies, and is compared row for row.
|
|
236
|
+
const final = collapsesRows(o.live.engine) && collapsesRows(o.newTable.engine);
|
|
237
|
+
// A table that does not collapse rows rebuilt into one that does (#3727): the new table keeps one row per sorting
|
|
238
|
+
// key where the old one keeps them all, by design. Both are grouped by the new sorting key and compared on what
|
|
239
|
+
// the new engine's merges keep per key.
|
|
240
|
+
const keys = !final && collapsesRows(o.newTable.engine) ? await keyComparison(run, o, newColumns) : undefined;
|
|
241
|
+
const read = async (table: string, name: string, partition: string, hash: string, before: string, final: boolean): Promise<SideCounts> => {
|
|
242
|
+
const from = `${sharding ? shardTable(sharding, n.database, name) : table}${final ? " FINAL" : ""}`;
|
|
132
243
|
const key = sharding ? `concat('shard', toString(_shard_num), '/', ${partition})` : partition;
|
|
133
244
|
const rows = await clickhouseQuery<{ p: string; rows: string | number; checksum: string; brows: string | number; bchecksum: string }>(
|
|
134
245
|
run.target.endpoint,
|
|
@@ -150,10 +261,18 @@ async function compareTables(
|
|
|
150
261
|
await syncReplica(run, o, n.database, n.name);
|
|
151
262
|
await syncReplica(run, o, n.database, n.newName);
|
|
152
263
|
// The old table first: a row the view has yet to commit in the new one is read as missing there, never the other way round.
|
|
153
|
-
const before = await read(n.table, n.name, "_partition_id", oldHash, oldBefore);
|
|
154
|
-
const after = await read(n.newTable, n.newName, fromOld, newHash, newBefore);
|
|
264
|
+
const before = keys ? await keys.read("old") : await read(n.table, n.name, "_partition_id", oldHash, oldBefore, final);
|
|
265
|
+
const after = keys ? await keys.read("new") : await read(n.newTable, n.newName, fromOld, newHash, newBefore, final);
|
|
155
266
|
const mismatches = differences(before.all, after.all);
|
|
156
|
-
if (mismatches.length === 0 || attempt >= attempts)
|
|
267
|
+
if (mismatches.length === 0 || attempt >= attempts) {
|
|
268
|
+
return {
|
|
269
|
+
old: before,
|
|
270
|
+
new: after,
|
|
271
|
+
mismatches,
|
|
272
|
+
...(final ? { collapsed: { engine: o.live.engine, how: "final" as const } } : {}),
|
|
273
|
+
...(keys ? { collapsed: { engine: o.newTable.engine, how: "keys" as const, describe: keys.semantics.describe } } : {}),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
157
276
|
run.log(`-- ${n.key}: ${mismatches.length} partition(s) differ (${mismatches.map((m) => m.partition).slice(0, 5).join(", ")}); reading again (${attempt}/${attempts})`);
|
|
158
277
|
await sleep(opts.intervalMs ?? COMPARE_INTERVAL_MS, run.signal);
|
|
159
278
|
}
|
|
@@ -179,8 +298,8 @@ export const rowHash = (columns: readonly string[]): string => `cityHash64(toStr
|
|
|
179
298
|
* {@link RebuildVerificationError} when it does not, so nothing is swapped.
|
|
180
299
|
*/
|
|
181
300
|
export async function assertSameRows(run: RebuildRun, o: RebuildObservation): Promise<void> {
|
|
182
|
-
const { mismatches } = await compareTables(run, o);
|
|
183
|
-
if (mismatches.length > 0) throw new RebuildVerificationError(o.names.key, mismatches);
|
|
301
|
+
const { mismatches, collapsed } = await compareTables(run, o);
|
|
302
|
+
if (mismatches.length > 0) throw new RebuildVerificationError(o.names.key, mismatches, collapsed);
|
|
184
303
|
}
|
|
185
304
|
|
|
186
305
|
export async function verifyRebuild(run: RebuildRun): Promise<VerifyResult> {
|
|
@@ -188,15 +307,25 @@ export async function verifyRebuild(run: RebuildRun): Promise<VerifyResult> {
|
|
|
188
307
|
if (o.state !== "rebuild") return { state: o.state, partitions: 0, rows: 0, verification: [], summary: `nothing to verify: ${o.state}` };
|
|
189
308
|
const cutover = cutoverOf(run, o);
|
|
190
309
|
const compared = await compareTables(run, o);
|
|
191
|
-
if (compared.mismatches.length > 0) throw new RebuildVerificationError(o.names.key, compared.mismatches);
|
|
310
|
+
if (compared.mismatches.length > 0) throw new RebuildVerificationError(o.names.key, compared.mismatches, compared.collapsed);
|
|
192
311
|
|
|
193
312
|
const all = [...compared.old.all.values()];
|
|
194
|
-
const
|
|
313
|
+
const counted = all.reduce((s, v) => s + v.rows, 0);
|
|
314
|
+
const c = compared.collapsed;
|
|
315
|
+
// Grouped by sorting key (#3727), each partition counts keys; the old table's rows are still every row read.
|
|
316
|
+
const rows = c?.how === "keys" ? (compared.old.rows ?? counted) : counted;
|
|
195
317
|
// What the approval binds: the rows before the cut-over (every row in app mode), which later writes do not change.
|
|
196
318
|
const verification = [...compared.old.before.values()].sort((x, y) => x.partition.localeCompare(y.partition));
|
|
197
319
|
const bound = verification.reduce((s, v) => s + v.rows, 0);
|
|
198
|
-
const range = cutover !== undefined ? ` (${bound} of them before the cut-over at ${new Date(cutover).toISOString()})` : "";
|
|
199
|
-
const
|
|
320
|
+
const range = cutover !== undefined ? ` (${bound} ${c?.how === "keys" ? "key(s)" : "of them"} before the cut-over at ${new Date(cutover).toISOString()})` : "";
|
|
321
|
+
const counts = c?.how === "keys" ? `${rows} row(s) of the old table as ${counted} key(s)` : `${rows} row(s)`;
|
|
322
|
+
const merged =
|
|
323
|
+
c?.how === "final"
|
|
324
|
+
? `, read under FINAL (${c.engine})`
|
|
325
|
+
: c?.how === "keys"
|
|
326
|
+
? `, grouped by the sorting key of the new ${c.engine} and compared on ${c.describe}`
|
|
327
|
+
: "";
|
|
328
|
+
const summary = `${o.names.key}: ${all.length} partition(s), ${counts}${range}, counts and checksums equal in the old and new tables${merged}`;
|
|
200
329
|
run.log(`-- ${summary}`);
|
|
201
330
|
return {
|
|
202
331
|
state: o.state,
|
|
@@ -24,6 +24,8 @@ export interface FakeOptions {
|
|
|
24
24
|
/** Answer every query with this status and body (an auth failure, say). */
|
|
25
25
|
fail?: { status: number; body: string };
|
|
26
26
|
version?: string;
|
|
27
|
+
/** The SQL user-defined functions `system.functions` lists. Default: none. */
|
|
28
|
+
functions?: Array<{ name: string; statement: string }>;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
const row = (o: Record<string, unknown>) => `${JSON.stringify(o)}\n`;
|
|
@@ -52,8 +54,7 @@ export async function fakeClickHouse(objects: FakeObject[], options: FakeOptions
|
|
|
52
54
|
.join(""),
|
|
53
55
|
);
|
|
54
56
|
}
|
|
55
|
-
|
|
56
|
-
if (/FROM system\.functions/.test(body)) return ok("");
|
|
57
|
+
if (/FROM system\.functions/.test(body)) return ok((options.functions ?? []).map((f) => row(f)).join(""));
|
|
57
58
|
const show = /^SHOW CREATE (DATABASE|TABLE) (`(?:[^`]|``)*`)(?:\.(`(?:[^`]|``)*`))?$/.exec(body.trim());
|
|
58
59
|
if (show) {
|
|
59
60
|
const [db, name] = show[3] ? [unquote(show[2]!), unquote(show[3])] : [undefined, unquote(show[2]!)];
|
|
@@ -70,7 +70,7 @@ ALTER TABLE \`analytics\`.\`events\` MODIFY COLUMN \`kind\` LowCardinality(Strin
|
|
|
70
70
|
-- export const { op } = ClickHouseRebuildOp({ name: "rebuild-analytics-sessions", env: "<env>", table: "analytics.sessions", dualWrite: { mode: "materialized-view", cutoverColumn: "ts" } });
|
|
71
71
|
\`\`\`
|
|
72
72
|
|
|
73
|
-
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.
|
|
73
|
+
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. A migration runner's history table (\`schema_migrations\`, \`goose_db_version\`; see [Importing a Live Server](../importing/)) is never dropped: the report names it in a hint. 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.
|
|
74
74
|
|
|
75
75
|
\`--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).
|
|
76
76
|
|
package/src/config.ts
CHANGED
|
@@ -104,6 +104,16 @@ export const sqlProfileSchema = z.strictObject({
|
|
|
104
104
|
databases: z.array(z.string()).optional(),
|
|
105
105
|
/** The database an unqualified declaration is created in. `default` when omitted. */
|
|
106
106
|
defaultDatabase: z.string().optional(),
|
|
107
|
+
/**
|
|
108
|
+
* ClickHouse import (#3718): the SQL user-defined functions `chant import
|
|
109
|
+
* --from <env>` adopts besides those the imported objects use, by name, or
|
|
110
|
+
* by prefix with a trailing `*` (`shop_*`). A function belongs to no
|
|
111
|
+
* database, so without this only the functions the imported tables, views
|
|
112
|
+
* and dictionaries use (and the functions those call) are imported. A use
|
|
113
|
+
* is found by name or, since ClickHouse stores a call as the function's
|
|
114
|
+
* body, by that body (#3745).
|
|
115
|
+
*/
|
|
116
|
+
importFunctions: z.array(z.string()).optional(),
|
|
107
117
|
/**
|
|
108
118
|
* ClickHouse: the topology this environment runs, which the applier, the
|
|
109
119
|
* plan and the rebuild migration render every statement for: `single`,
|
|
@@ -140,8 +150,11 @@ export const sqlProfileSchema = z.strictObject({
|
|
|
140
150
|
/**
|
|
141
151
|
* Postgres apply: how long one statement waits for its lock before it
|
|
142
152
|
* fails (`lock_timeout`), in milliseconds, so a change blocked behind a
|
|
143
|
-
* long query fails fast instead of queueing every query behind it.
|
|
144
|
-
*
|
|
153
|
+
* long query fails fast instead of queueing every query behind it. The
|
|
154
|
+
* catalog reads (`chant lifecycle diff --live`, `chant sql plan`, the
|
|
155
|
+
* import, and an apply's read before its first statement) wait under it
|
|
156
|
+
* too, and name the session holding the lock when it passes. 5000 when
|
|
157
|
+
* omitted.
|
|
145
158
|
*/
|
|
146
159
|
lockTimeoutMs: z.number().int().nonnegative().optional(),
|
|
147
160
|
/**
|
|
@@ -156,11 +169,12 @@ export const sqlProfileSchema = z.strictObject({
|
|
|
156
169
|
*/
|
|
157
170
|
scanTimeoutMs: z.number().int().nonnegative().optional(),
|
|
158
171
|
/**
|
|
159
|
-
*
|
|
172
|
+
* Whether chant manages access in this environment. Postgres (#3681): the
|
|
160
173
|
* declared row-level security policies and each table's row-level security,
|
|
161
174
|
* the declared roles (created when missing, never dropped), and the
|
|
162
175
|
* privileges on the declared objects and the default privileges, which a
|
|
163
|
-
* plan compares and an apply grants and revokes.
|
|
176
|
+
* plan compares and an apply grants and revokes. ClickHouse (#3716): the
|
|
177
|
+
* declared users, roles, row policies and grants. Off when omitted: the
|
|
164
178
|
* access declarations are not planned or applied, and the server's access
|
|
165
179
|
* is not read, so a project that manages access elsewhere is not affected.
|
|
166
180
|
*/
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tables another tool keeps its migration history in, by name, with the tool.
|
|
3
|
+
*
|
|
4
|
+
* Both dialects' catalog readers mark a table by one of these names as
|
|
5
|
+
* foreign: import leaves it out with a warning, and a plan names it in a hint
|
|
6
|
+
* instead of proposing a drop, since chant changing it would change what that
|
|
7
|
+
* tool owns (#3047 question 10, #3676). golang-migrate, dbmate, goose and
|
|
8
|
+
* Flyway keep the same tables on ClickHouse as on Postgres.
|
|
9
|
+
*/
|
|
10
|
+
export const FOREIGN_TABLES: Readonly<Record<string, string>> = {
|
|
11
|
+
_prisma_migrations: "Prisma Migrate",
|
|
12
|
+
schema_migrations: "a migration runner (Rails, golang-migrate, dbmate)",
|
|
13
|
+
ar_internal_metadata: "Rails",
|
|
14
|
+
django_migrations: "Django",
|
|
15
|
+
alembic_version: "Alembic",
|
|
16
|
+
__drizzle_migrations: "drizzle-kit",
|
|
17
|
+
flyway_schema_history: "Flyway",
|
|
18
|
+
goose_db_version: "goose",
|
|
19
|
+
knex_migrations: "Knex",
|
|
20
|
+
knex_migrations_lock: "Knex",
|
|
21
|
+
SequelizeMeta: "Sequelize",
|
|
22
|
+
pgmigrations: "node-pg-migrate",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** The tool that keeps a table by this name, or undefined when none does. */
|
|
26
|
+
export function foreignTool(name: string): string | undefined {
|
|
27
|
+
return Object.prototype.hasOwnProperty.call(FOREIGN_TABLES, name) ? FOREIGN_TABLES[name] : undefined;
|
|
28
|
+
}
|
package/src/files/clickhouse.ts
CHANGED
|
@@ -1,23 +1,79 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A file of ClickHouse DDL as the objects it declares: one per `CREATE
|
|
3
3
|
* DATABASE`, `TABLE`, `VIEW`, `MATERIALIZED VIEW`, `DICTIONARY` or `FUNCTION`, as `chant import
|
|
4
|
-
* <file>.sql` reads them
|
|
5
|
-
*
|
|
4
|
+
* <file>.sql` reads them, and one per `CREATE USER`, `CREATE ROLE`, `CREATE
|
|
5
|
+
* ROW POLICY` and `GRANT` (#3711), each read by its tag, so what the tag
|
|
6
|
+
* refuses (`IDENTIFIED BY`, a `REVOKE`) is refused with the tag's message.
|
|
7
|
+
* Any other statement, and any statement the parser refuses, is a problem
|
|
8
|
+
* naming it.
|
|
6
9
|
*
|
|
7
10
|
* With `schema` (a database), an object's unqualified name is qualified with
|
|
8
|
-
* it
|
|
11
|
+
* it, and so is the table a row policy or a grant is on; a function, a user
|
|
12
|
+
* and a role belong to no database and keep their names.
|
|
9
13
|
*/
|
|
10
14
|
|
|
11
15
|
import type { Declarable } from "@intentius/chant/declarable";
|
|
12
16
|
import { isTrivia, tokenizeText } from "../clickhouse/tokens";
|
|
13
17
|
import { parseCreate } from "../clickhouse/parser";
|
|
14
|
-
import { database, dictionary, func, table, view } from "../clickhouse/entities";
|
|
18
|
+
import { CLICKHOUSE_ENTITY_TYPES, database, dictionary, func, grant, policy, role, table, user, view } from "../clickhouse/entities";
|
|
19
|
+
import { isAccessStatement, parseAccess, type AccessNode } from "../clickhouse/access";
|
|
20
|
+
import type { Token } from "../clickhouse/tokens";
|
|
15
21
|
import { CLICKHOUSE_TAG_OF } from "../clickhouse/import/generator";
|
|
16
22
|
import { describeStatement, type ImportedObject } from "../clickhouse/import/ir";
|
|
17
23
|
import { firstLine, quoteBare, splitTokens, tokensText, type ReadOptions } from "./common";
|
|
24
|
+
import { templateSafe, templateStrings } from "./postgres";
|
|
18
25
|
|
|
19
26
|
type Tag = (strings: TemplateStringsArray, ...values: unknown[]) => Declarable;
|
|
20
|
-
const TAGS: Record<string, Tag> = { database, table, view, dictionary, func };
|
|
27
|
+
const TAGS: Record<string, Tag> = { database, table, view, dictionary, func, user, role, policy, grant };
|
|
28
|
+
|
|
29
|
+
/** Tokens with `[db.]name`'s bare name qualified with `schema`. A `*` (all tables) is left alone. */
|
|
30
|
+
function qualifyBare(tokens: Token[], span: { from: number; to: number } | undefined, schema: string): string | undefined {
|
|
31
|
+
if (!span) return undefined;
|
|
32
|
+
const name = tokens.slice(span.from, span.to).filter((t) => !isTrivia(t));
|
|
33
|
+
const [only] = name;
|
|
34
|
+
if (!only || name.length !== 1 || (only.kind !== "ident" && only.kind !== "qident")) return undefined;
|
|
35
|
+
const at = tokens.indexOf(only);
|
|
36
|
+
return tokensText(tokens.slice(0, at)) + `${quoteBare(schema)}.` + tokensText(tokens.slice(at));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** An access statement as the object its tag declares (#3711), or the tag's refusal. */
|
|
40
|
+
function readAccess(sql: string, options: ReadOptions): ImportedObject | string {
|
|
41
|
+
let tokens = tokenizeText(sql, 0);
|
|
42
|
+
let node: AccessNode;
|
|
43
|
+
try {
|
|
44
|
+
node = parseAccess(tokens);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
// What the tag refuses (a password) is refused in the tag's words.
|
|
47
|
+
const words = sql.replace(/--[^\n]*\n/g, " ").trim().split(/\s+/, 2).map((w) => w.toUpperCase());
|
|
48
|
+
const tag = words[0] === "CREATE" ? (words[1] === "USER" ? user : words[1] === "ROLE" ? role : policy) : grant;
|
|
49
|
+
try {
|
|
50
|
+
tag(templateStrings([templateSafe(sql)]));
|
|
51
|
+
} catch (refused) {
|
|
52
|
+
return refused instanceof Error ? refused.message : String(refused);
|
|
53
|
+
}
|
|
54
|
+
throw e;
|
|
55
|
+
}
|
|
56
|
+
if (options.schema) {
|
|
57
|
+
const qualified = qualifyBare(tokens, node.statement === "rowPolicy" ? node.table : node.statement === "grant" ? node.target : undefined, options.schema);
|
|
58
|
+
if (qualified !== undefined) {
|
|
59
|
+
sql = qualified;
|
|
60
|
+
tokens = tokenizeText(sql, 0);
|
|
61
|
+
node = parseAccess(tokens);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const tag = node.statement === "user" ? user : node.statement === "role" ? role : node.statement === "rowPolicy" ? policy : grant;
|
|
65
|
+
let props: { name: string; database?: string; table?: string };
|
|
66
|
+
try {
|
|
67
|
+
props = (tag(templateStrings([templateSafe(sql)])) as unknown as { props: typeof props }).props;
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return e instanceof Error ? e.message : String(e);
|
|
70
|
+
}
|
|
71
|
+
if (node.statement === "user") return { type: CLICKHOUSE_ENTITY_TYPES.user, name: props.name, ddl: sql };
|
|
72
|
+
if (node.statement === "role") return { type: CLICKHOUSE_ENTITY_TYPES.role, name: props.name, ddl: sql };
|
|
73
|
+
if (node.statement === "rowPolicy") return { type: CLICKHOUSE_ENTITY_TYPES.rowPolicy, ...(props.database ? { database: props.database } : {}), name: props.name, ddl: sql };
|
|
74
|
+
// A grant has no name of its own: what it grants, on what, to whom.
|
|
75
|
+
return { type: CLICKHOUSE_ENTITY_TYPES.grant, name: `grant ${tokensText(tokens.slice(tokens.findIndex((t) => !isTrivia(t)) + 1)).replace(/\s+/g, " ").trim().toLowerCase()}`, ddl: sql };
|
|
76
|
+
}
|
|
21
77
|
|
|
22
78
|
/** The tag that declares an entity type. */
|
|
23
79
|
export function clickhouseTag(type: string): Tag | undefined {
|
|
@@ -30,6 +86,16 @@ export function readClickHouse(ddl: string, options: ReadOptions, problems: stri
|
|
|
30
86
|
const objects: ImportedObject[] = [];
|
|
31
87
|
for (const stmt of splitTokens(tokenizeText(ddl, 0), isTrivia)) {
|
|
32
88
|
let sql = tokensText(stmt);
|
|
89
|
+
if (isAccessStatement(sql)) {
|
|
90
|
+
try {
|
|
91
|
+
const read = readAccess(sql, options);
|
|
92
|
+
if (typeof read === "string") problems.push(`${read}: ${firstLine(sql)}`);
|
|
93
|
+
else objects.push(read);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
problems.push(`does not parse (${e instanceof Error ? e.message : String(e)}): ${firstLine(sql)}`);
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
33
99
|
if (!/^CREATE\b/i.test(sql)) {
|
|
34
100
|
problems.push(`not a CREATE statement: ${firstLine(sql)}`);
|
|
35
101
|
continue;
|
package/src/files/postgres.ts
CHANGED
|
@@ -45,7 +45,8 @@ const isName = (t: Token | undefined): boolean => t !== undefined && (t.kind ===
|
|
|
45
45
|
const word = (t: Token | undefined, ...words: string[]): boolean => t !== undefined && t.kind === "ident" && words.includes(t.text.toUpperCase());
|
|
46
46
|
|
|
47
47
|
/** Text that sits inside a template literal: a backquote and `${` escaped. */
|
|
48
|
-
|
|
48
|
+
/** Text that sits inside a template literal: a backquote and `${` escaped. */
|
|
49
|
+
export const templateSafe = (s: string) => s.replace(/\\(?=`|\$\{)/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
|
|
49
50
|
|
|
50
51
|
interface PgFileObject {
|
|
51
52
|
node: StatementNode;
|
package/src/import-parser.ts
CHANGED
|
@@ -25,6 +25,8 @@ const CLICKHOUSE_ONLY = [
|
|
|
25
25
|
/^\s*CREATE\s+(?:DATABASE|(?:OR\s+REPLACE\s+)?DICTIONARY)\b/im,
|
|
26
26
|
// A SQL function: CREATE FUNCTION f AS (x) -> ...
|
|
27
27
|
/^\s*CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+[^\s(]+(?:\s+ON\s+CLUSTER\s+\S+)?\s+AS\s*\(/im,
|
|
28
|
+
// A row policy (#3711): Postgres says CREATE POLICY.
|
|
29
|
+
/^\s*CREATE\s+(?:OR\s+REPLACE\s+)?ROW\s+POLICY\b/im,
|
|
28
30
|
];
|
|
29
31
|
const POSTGRES_ONLY = [
|
|
30
32
|
/^\s*CREATE\s+(?:OR\s+REPLACE\s+)?(?:SCHEMA|EXTENSION|TYPE|DOMAIN|SEQUENCE|(?:UNIQUE\s+)?INDEX|(?:CONSTRAINT\s+)?TRIGGER|POLICY|ROLE|FUNCTION|PROCEDURE|(?:UNLOGGED|TEMP|TEMPORARY)\s+TABLE)\b/im,
|
|
@@ -111,6 +111,39 @@ describe("ClickHouse", () => {
|
|
|
111
111
|
expect((d.steps[0] as { detail: string }).detail).toMatch(/drop and create this object instead/);
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
test("a user the environment creates gets its declared clauses with ALTER USER, its password left alone (#3717)", () => {
|
|
115
|
+
const reader: Obj = { export: "reader", type: "ClickHouse::Role", ddl: "CREATE ROLE reader" };
|
|
116
|
+
const app: Obj = {
|
|
117
|
+
export: "app",
|
|
118
|
+
type: "ClickHouse::User",
|
|
119
|
+
dependsOn: ["reader", "analytics"],
|
|
120
|
+
ddl: "CREATE USER app HOST LOCAL DEFAULT ROLE reader DEFAULT DATABASE analytics SETTINGS max_memory_usage = 1000000000",
|
|
121
|
+
};
|
|
122
|
+
const readerToApp: Obj = { export: "readerToApp", type: "ClickHouse::Grant", dependsOn: ["reader", "app"], ddl: "GRANT reader TO app" };
|
|
123
|
+
const d = diffStatements(build("clickhouse", [DB]), build("clickhouse", [DB, reader, app, readerToApp]));
|
|
124
|
+
const sql = sqlOf(d);
|
|
125
|
+
expect(sql).toEqual([
|
|
126
|
+
"CREATE ROLE reader",
|
|
127
|
+
"ALTER USER `app` HOST LOCAL",
|
|
128
|
+
"ALTER USER `app` DEFAULT DATABASE analytics",
|
|
129
|
+
"ALTER USER `app` SETTINGS max_memory_usage = 1000000000",
|
|
130
|
+
"GRANT `reader` TO `app`",
|
|
131
|
+
// A default role must be held first.
|
|
132
|
+
"ALTER USER `app` DEFAULT ROLE reader",
|
|
133
|
+
]);
|
|
134
|
+
expect(sql.join("\n")).not.toMatch(/CREATE USER|IDENTIFIED/);
|
|
135
|
+
expect(d.steps.filter((s) => s.object === "app").map((s) => (s as StatementStep).rule)).toEqual(["SQLCH271", "SQLCH271", "SQLCH271"]);
|
|
136
|
+
|
|
137
|
+
// Without grants to it, the default role is set with the rest.
|
|
138
|
+
const alone = sqlOf(diffStatements(build("clickhouse", [DB, reader]), build("clickhouse", [DB, reader, app])));
|
|
139
|
+
expect(alone).toEqual([
|
|
140
|
+
"ALTER USER `app` HOST LOCAL",
|
|
141
|
+
"ALTER USER `app` DEFAULT DATABASE analytics",
|
|
142
|
+
"ALTER USER `app` SETTINGS max_memory_usage = 1000000000",
|
|
143
|
+
"ALTER USER `app` DEFAULT ROLE reader",
|
|
144
|
+
]);
|
|
145
|
+
});
|
|
146
|
+
|
|
114
147
|
test("two identical builds take no steps", () => {
|
|
115
148
|
expect(diffStatements(CH_BEFORE, CH_BEFORE)).toEqual({ dialect: "clickhouse", defaultDatabase: "default", topology: "single", steps: [], refused: false, hints: [] });
|
|
116
149
|
});
|
|
@@ -112,6 +112,8 @@ function clickhouseStatements(beforeJson: string, afterJson: string, options: Di
|
|
|
112
112
|
current: new Map(before.map((o) => [o.exportName, o.canonical])),
|
|
113
113
|
keyOf: (o) => o.exportName,
|
|
114
114
|
allowDestructive: true,
|
|
115
|
+
// A user whose password is the environment's exists by the time a migration runs: the GRANTs to it need it too (#3717).
|
|
116
|
+
environmentCreatesUsers: true,
|
|
115
117
|
topology,
|
|
116
118
|
...(options.marker ? { marker: options.marker } : {}),
|
|
117
119
|
});
|
|
@@ -62,7 +62,7 @@ jobs:
|
|
|
62
62
|
`;
|
|
63
63
|
|
|
64
64
|
exports[`generateOpsPipeline renders the sql drift watch (#3642) > GitLab: a schedule-only job with the reader's credentials and no token of its own 1`] = `
|
|
65
|
-
"# chant Ops
|
|
65
|
+
"# chant Ops — one job per Op under stage "ops", each
|
|
66
66
|
# selected by its own rules:. A merge_request_event or push job needs no
|
|
67
67
|
# setup; its rule fires on the event itself.
|
|
68
68
|
#
|
|
@@ -10,9 +10,28 @@
|
|
|
10
10
|
* `upstreamPin` already tracks the LTS tags (`-lts`).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { emulatorLifecycle, type EmulatorCapability, type EmulatorSpec } from "@intentius/chant/op";
|
|
13
|
+
import { emulatorLifecycle, type EmulatorCapability, type EmulatorIdentity, type EmulatorSpec } from "@intentius/chant/op";
|
|
14
14
|
import { clickhouseImage } from "../../spec/pin";
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The server's UUID (#3673), read in the container and through the endpoint
|
|
18
|
+
* `status` prints, so a server of another origin on the same port is told
|
|
19
|
+
* apart from the emulator.
|
|
20
|
+
*/
|
|
21
|
+
export const CLICKHOUSE_EMULATOR_IDENTITY: EmulatorIdentity = {
|
|
22
|
+
server: "ClickHouse",
|
|
23
|
+
command: ["clickhouse-client", "-q", "SELECT serverUUID()"],
|
|
24
|
+
async probe(endpoint) {
|
|
25
|
+
const url = new URL(endpoint);
|
|
26
|
+
url.searchParams.set("query", "SELECT serverUUID(), version() FORMAT TSV");
|
|
27
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(5_000) });
|
|
28
|
+
const body = (await res.text()).trim();
|
|
29
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${body.split("\n")[0]}`);
|
|
30
|
+
const [id = "", version = "(unknown version)"] = body.split("\t");
|
|
31
|
+
return { id, label: `ClickHouse ${version}` };
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
16
35
|
export const CLICKHOUSE_EMULATOR_SPEC: EmulatorSpec = {
|
|
17
36
|
name: "chant-clickhouse",
|
|
18
37
|
image: clickhouseImage(),
|
|
@@ -21,6 +40,8 @@ export const CLICKHOUSE_EMULATOR_SPEC: EmulatorSpec = {
|
|
|
21
40
|
// The image disables network access for the passwordless default user
|
|
22
41
|
// unless this is set; a local server is reached as `default` with no password.
|
|
23
42
|
runArgs: ["-e", "CLICKHOUSE_SKIP_USER_SETUP=1"],
|
|
43
|
+
identity: CLICKHOUSE_EMULATOR_IDENTITY,
|
|
44
|
+
credentials: "user default, no password",
|
|
24
45
|
};
|
|
25
46
|
|
|
26
47
|
/** `CLICKHOUSE_URL` is the variable binding reads when no `sql.profiles.<env>` names a server. */
|