@intentius/chant-lexicon-sql 0.118.1 → 0.120.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/clickhouse/apply/apply.d.ts +1 -1
  2. package/dist/clickhouse/apply/apply.d.ts.map +1 -1
  3. package/dist/clickhouse/apply/statements.d.ts +8 -0
  4. package/dist/clickhouse/apply/statements.d.ts.map +1 -1
  5. package/dist/clickhouse/import/generator.d.ts +1 -1
  6. package/dist/clickhouse/import/generator.d.ts.map +1 -1
  7. package/dist/clickhouse/import/live-export.d.ts +19 -0
  8. package/dist/clickhouse/import/live-export.d.ts.map +1 -1
  9. package/dist/clickhouse/live/bind.d.ts +8 -0
  10. package/dist/clickhouse/live/bind.d.ts.map +1 -1
  11. package/dist/clickhouse/live/catalog.d.ts +15 -0
  12. package/dist/clickhouse/live/catalog.d.ts.map +1 -1
  13. package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
  14. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  15. package/dist/clickhouse/plan/deep.d.ts +19 -2
  16. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  17. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  18. package/dist/clickhouse/plan/normalize.d.ts +3 -0
  19. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  20. package/dist/clickhouse/plan/rebuild-handoff.d.ts +2 -0
  21. package/dist/clickhouse/plan/rebuild-handoff.d.ts.map +1 -1
  22. package/dist/clickhouse/rebuild/engines.d.ts +44 -0
  23. package/dist/clickhouse/rebuild/engines.d.ts.map +1 -0
  24. package/dist/clickhouse/rebuild/verify.d.ts +25 -1
  25. package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
  26. package/dist/clickhouse/testing/fake-server.d.ts +5 -0
  27. package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
  28. package/dist/config.d.ts +32 -8
  29. package/dist/config.d.ts.map +1 -1
  30. package/dist/core/foreign-tables.d.ts +13 -0
  31. package/dist/core/foreign-tables.d.ts.map +1 -0
  32. package/dist/files/clickhouse.d.ts +7 -3
  33. package/dist/files/clickhouse.d.ts.map +1 -1
  34. package/dist/files/postgres.d.ts +3 -0
  35. package/dist/files/postgres.d.ts.map +1 -1
  36. package/dist/import-parser.d.ts.map +1 -1
  37. package/dist/integrity.json +2 -2
  38. package/dist/manifest.json +1 -1
  39. package/dist/op/activities/clickhouse-emulator.d.ts +7 -1
  40. package/dist/op/activities/clickhouse-emulator.d.ts.map +1 -1
  41. package/dist/op/activities/postgres-emulator.d.ts +8 -1
  42. package/dist/op/activities/postgres-emulator.d.ts.map +1 -1
  43. package/dist/postgres/access/changes.d.ts +23 -0
  44. package/dist/postgres/access/changes.d.ts.map +1 -0
  45. package/dist/postgres/apply/apply.d.ts.map +1 -1
  46. package/dist/postgres/entities.d.ts.map +1 -1
  47. package/dist/postgres/live/bind.d.ts +8 -1
  48. package/dist/postgres/live/bind.d.ts.map +1 -1
  49. package/dist/postgres/live/catalog.d.ts +3 -3
  50. package/dist/postgres/live/catalog.d.ts.map +1 -1
  51. package/dist/postgres/live/client.d.ts +31 -0
  52. package/dist/postgres/live/client.d.ts.map +1 -1
  53. package/dist/postgres/plan/commands.d.ts.map +1 -1
  54. package/dist/postgres/plan/deep.d.ts.map +1 -1
  55. package/dist/sql-files.d.ts +2 -1
  56. package/dist/sql-files.d.ts.map +1 -1
  57. package/package.json +2 -2
  58. package/src/clickhouse/apply/apply.ts +8 -2
  59. package/src/clickhouse/apply/statements.ts +32 -5
  60. package/src/clickhouse/import/generator.ts +8 -3
  61. package/src/clickhouse/import/import.test.ts +64 -0
  62. package/src/clickhouse/import/live-export.ts +70 -2
  63. package/src/clickhouse/live/access.e2e.test.ts +71 -18
  64. package/src/clickhouse/live/bind.ts +10 -0
  65. package/src/clickhouse/live/catalog.ts +25 -2
  66. package/src/clickhouse/live/describe-resources.ts +5 -2
  67. package/src/clickhouse/live/live.test.ts +8 -0
  68. package/src/clickhouse/plan/commands.ts +6 -3
  69. package/src/clickhouse/plan/deep-grants.test.ts +67 -0
  70. package/src/clickhouse/plan/deep.ts +70 -6
  71. package/src/clickhouse/plan/diff.ts +4 -1
  72. package/src/clickhouse/plan/normalize.ts +5 -2
  73. package/src/clickhouse/plan/plan.test.ts +9 -1
  74. package/src/clickhouse/plan/rebuild-handoff.ts +11 -1
  75. package/src/clickhouse/rebuild/engines.ts +163 -0
  76. package/src/clickhouse/rebuild/rebuild-collapsing.e2e.test.ts +120 -0
  77. package/src/clickhouse/rebuild/rebuild-into-collapsing.e2e.test.ts +198 -0
  78. package/src/clickhouse/rebuild/rebuild.test.ts +78 -0
  79. package/src/clickhouse/rebuild/verify.ts +153 -24
  80. package/src/clickhouse/testing/fake-server.ts +3 -2
  81. package/src/codegen/classifier-page.ts +1 -1
  82. package/src/config.ts +16 -4
  83. package/src/core/foreign-tables.ts +28 -0
  84. package/src/files/clickhouse.ts +71 -5
  85. package/src/files/postgres.ts +2 -1
  86. package/src/import-parser.ts +2 -0
  87. package/src/migration-statements.test.ts +33 -0
  88. package/src/migration-statements.ts +2 -0
  89. package/src/op/__snapshots__/watch-pipeline.test.ts.snap +1 -1
  90. package/src/op/activities/clickhouse-emulator.ts +22 -1
  91. package/src/op/activities/postgres-apply-lock-timeout.e2e.test.ts +87 -0
  92. package/src/op/activities/postgres-apply.test.ts +32 -0
  93. package/src/op/activities/postgres-emulator.test.ts +20 -0
  94. package/src/op/activities/postgres-emulator.ts +41 -1
  95. package/src/op/apply-gate-access-clickhouse.e2e.test.ts +195 -0
  96. package/src/op/apply-gate-access.e2e.test.ts +207 -0
  97. package/src/postgres/access/access.test.ts +26 -1
  98. package/src/postgres/access/changes.ts +39 -0
  99. package/src/postgres/apply/apply.ts +10 -7
  100. package/src/postgres/entities.ts +20 -1
  101. package/src/postgres/live/bind.ts +16 -4
  102. package/src/postgres/live/catalog.ts +3 -15
  103. package/src/postgres/live/client.ts +85 -0
  104. package/src/postgres/live/live.test.ts +54 -0
  105. package/src/postgres/live/lock-timeout.e2e.test.ts +70 -0
  106. package/src/postgres/plan/commands.ts +2 -14
  107. package/src/postgres/plan/deep.ts +44 -2
  108. package/src/serializer.test.ts +4 -0
  109. package/src/skills/chant-sql-postgres-plan.md +1 -1
  110. package/src/skills/chant-sql-rebuild.md +2 -0
  111. package/src/skills/chant-sql.md +1 -1
  112. package/src/sql-files.test.ts +78 -1
  113. package/src/sql-files.ts +2 -1
@@ -24,6 +24,8 @@ import { partitionEffect } from "./backfill";
24
24
  import { insertTarget, waitForCutover, type CutoverProbe } from "./steps";
25
25
  import { intoShard, onShard, onShardServers, shardTable } from "./shards";
26
26
  import { renameColumns, sourcePartitionExpression } from "./partitions";
27
+ import { collapseSemantics, collapsesRows, engineArguments, splitTopLevel, type EngineColumn } from "./engines";
28
+ import { RebuildVerificationError } from "./verify";
27
29
  import { canonicalObject } from "../plan/normalize";
28
30
  import { diffSchemas } from "../plan/diff";
29
31
  import { renderDiff } from "../plan/report";
@@ -226,6 +228,42 @@ describe("the plan hands a rebuild to the Op", () => {
226
228
  });
227
229
  });
228
230
 
231
+ describe("a table whose engine collapses rows by sorting key (#3674)", () => {
232
+ test("the collapsing MergeTree engines are read under FINAL, replicated or shared too; the others are not", () => {
233
+ for (const e of ["SummingMergeTree", "ReplacingMergeTree", "AggregatingMergeTree", "CollapsingMergeTree", "VersionedCollapsingMergeTree", "ReplicatedSummingMergeTree", "SharedReplacingMergeTree"]) {
234
+ expect(collapsesRows(e), e).toBe(true);
235
+ }
236
+ for (const e of ["MergeTree", "ReplicatedMergeTree", "Log", "Distributed", undefined]) expect(collapsesRows(e), String(e)).toBe(false);
237
+ });
238
+
239
+ test("a difference on such a table names the engine and the OPTIMIZE that merges it, not only a late write", () => {
240
+ const mismatch = [{ partition: "all", old: { partition: "all", rows: 3, checksum: "1" }, new: { partition: "all", rows: 1, checksum: "2" } }];
241
+ const collapsing = new RebuildVerificationError("db.daily", mismatch, "SummingMergeTree").message;
242
+ expect(collapsing).toMatch(/Nothing was swapped\. db\.daily is a SummingMergeTree, .*compared under FINAL/);
243
+ expect(collapsing).toContain("run OPTIMIZE TABLE db.daily FINAL");
244
+ const plain = new RebuildVerificationError("db.events", mismatch).message;
245
+ expect(plain).not.toMatch(/FINAL/);
246
+ expect(plain).toMatch(/The usual cause is in materialized-view mode a row written to the old table later than the cut-over delay/);
247
+ });
248
+
249
+ test("from a plain MergeTree, a difference names what was compared per key", () => {
250
+ const mismatch = [{ partition: "202610", old: { partition: "202610", rows: 2, checksum: "1" }, new: { partition: "202610", rows: 1, checksum: "2" } }];
251
+ const message = new RebuildVerificationError("db.daily", mismatch, { engine: "SummingMergeTree", how: "keys", describe: "the sums of total" }).message;
252
+ expect(message).toContain("202610: old 2 keys, checksum 1, new 1 keys, checksum 2");
253
+ expect(message).toMatch(/The new table is a SummingMergeTree, .*compared per key on the sums of total/);
254
+ expect(message).not.toMatch(/OPTIMIZE/);
255
+ });
256
+
257
+ test("the plan's hand-off says so for such a table", () => {
258
+ const ddl = (o: string) => `CREATE TABLE db.daily (day Date, total Decimal(18, 2), n UInt64) ENGINE = SummingMergeTree ORDER BY ${o}`;
259
+ const after = [{ key: "daily", canonical: canonicalObject(ddl("(day, n)")) }];
260
+ const diff = diffSchemas([{ key: "daily", canonical: canonicalObject(ddl("day")) }], after);
261
+ const ops = rebuildOpSuggestions(diff, new Map(after.map((o) => [o.key, o.canonical])), "prod");
262
+ expect(ops[0]!.note).toMatch(/^db\.daily is a SummingMergeTree: when the old table's engine collapses rows too, the Verify phase reads both tables under FINAL/);
263
+ expect(renderDiff({ ...diff, rebuildOps: ops })).toContain(` Note: ${ops[0]!.note}`);
264
+ });
265
+ });
266
+
229
267
  describe("a rebuild across the shards of a cluster (#3663)", () => {
230
268
  const sharding = { cluster: "main", shards: [{ num: 1, macro: "s1", slot: 0 }, { num: 2, macro: "s2", slot: 1 }] };
231
269
  const shard2 = sharding.shards[1]!;
@@ -294,3 +332,43 @@ describe("the wait for the cut-over (#3700)", () => {
294
332
  expect(insertTarget("SELECT * FROM shop.events", "shop")).toBeUndefined();
295
333
  });
296
334
  });
335
+
336
+ describe("what a collapsing engine keeps per sorting key (#3727)", () => {
337
+ const col = (name: string, type: string, inSortingKey = false): EngineColumn => ({ name, type, inSortingKey });
338
+ const columns = [col("day", "Date", true), col("shop", "UInt32", true), col("total", "Decimal(18, 2)"), col("n", "UInt64"), col("ratio", "Float64"), col("note", "String"), col("sign", "Int8"), col("ver", "UInt32")];
339
+
340
+ test("splitTopLevel and engineArguments read engine_full, without a Replicated engine's Keeper path", () => {
341
+ expect(splitTopLevel("toStartOfInterval(ts, toIntervalDay(1)), id")).toEqual(["toStartOfInterval(ts, toIntervalDay(1))", "id"]);
342
+ expect(splitTopLevel("")).toEqual([]);
343
+ expect(engineArguments("SummingMergeTree((total, n)) PARTITION BY toYYYYMM(day) ORDER BY (day, shop) SETTINGS index_granularity = 8192")).toEqual(["(total, n)"]);
344
+ expect(engineArguments("ReplicatedCollapsingMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}', sign) ORDER BY id")).toEqual(["sign"]);
345
+ expect(engineArguments("SummingMergeTree ORDER BY id")).toEqual([]);
346
+ });
347
+
348
+ test("SummingMergeTree: the sums of every numeric column outside the key, or of those listed, in their own types; a key whose sums are zero is removed", () => {
349
+ const all = collapseSemantics("SummingMergeTree", "SummingMergeTree ORDER BY (day, shop)", columns)!;
350
+ expect(all.aggregates).toEqual(["CAST(sum(`total`), 'Decimal(18, 2)')", "CAST(sum(`n`), 'UInt64')", "toFloat32(sum(`ratio`))", "CAST(sum(`sign`), 'Int8')", "CAST(sum(`ver`), 'UInt32')"]);
351
+ expect(all.having).toBe(`NOT (${all.aggregates.map((a) => `${a} = 0`).join(" AND ")})`);
352
+ const listed = collapseSemantics("ReplicatedSummingMergeTree", "ReplicatedSummingMergeTree('/p', '{replica}', (total, n)) ORDER BY (day, shop)", columns)!;
353
+ expect(listed).toMatchObject({ engine: "SummingMergeTree", aggregates: ["CAST(sum(`total`), 'Decimal(18, 2)')", "CAST(sum(`n`), 'UInt64')"], describe: "the sums of total, n" });
354
+ const partitioned = collapseSemantics("SummingMergeTree", "SummingMergeTree PARTITION BY g ORDER BY k", [col("k", "UInt32", true), { ...col("g", "UInt8"), inPartitionKey: true }, col("a", "UInt32")])!;
355
+ expect(partitioned.aggregates).toEqual(["CAST(sum(`a`), 'UInt32')"]);
356
+ });
357
+
358
+ test("Replacing keeps the highest version, Collapsing the sum of its signs, Aggregating its order-free simple functions, Coalescing which columns hold a value", () => {
359
+ expect(collapseSemantics("ReplacingMergeTree", "ReplacingMergeTree(ver) ORDER BY id", columns)).toMatchObject({ aggregates: ["max(`ver`)"], describe: "the highest ver" });
360
+ expect(collapseSemantics("ReplacingMergeTree", "ReplacingMergeTree ORDER BY id", columns)).toMatchObject({ aggregates: [], describe: "the keys" });
361
+ expect(collapseSemantics("CollapsingMergeTree", "CollapsingMergeTree(sign) ORDER BY id", columns)).toMatchObject({ aggregates: ["sum(`sign`)"], having: "sum(`sign`) != 0" });
362
+ expect(collapseSemantics("VersionedCollapsingMergeTree", "VersionedCollapsingMergeTree(sign, ver) ORDER BY id", columns)).toMatchObject({ aggregates: ["sum(`sign`)"], having: "sum(`sign`) != 0" });
363
+ const agg = [col("k", "UInt32", true), col("hi", "SimpleAggregateFunction(max, UInt64)"), col("any", "SimpleAggregateFunction(anyLast, String)"), col("u", "AggregateFunction(uniq, UInt64)")];
364
+ expect(collapseSemantics("AggregatingMergeTree", "AggregatingMergeTree ORDER BY k", agg)).toMatchObject({ aggregates: ["max(`hi`)"], describe: "max(hi)" });
365
+ const coalescing = [col("k", "UInt32", true), col("v", "Nullable(String)"), col("w", "UInt8")];
366
+ expect(collapseSemantics("CoalescingMergeTree", "CoalescingMergeTree ORDER BY k", coalescing)).toMatchObject({ aggregates: ["count(`v`) > 0"] });
367
+ });
368
+
369
+ test("an engine that keeps every row, or whose rows depend on the order of merges, has none", () => {
370
+ expect(collapseSemantics("MergeTree", "MergeTree ORDER BY id", columns)).toBeUndefined();
371
+ expect(collapseSemantics("GraphiteMergeTree", "GraphiteMergeTree('graphite_rollup') ORDER BY id", columns)).toBeUndefined();
372
+ expect(collapseSemantics("CollapsingMergeTree", "CollapsingMergeTree(missing) ORDER BY id", columns)).toBeUndefined();
373
+ });
374
+ });
@@ -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} rows, checksum ${m.old.checksum}` : "none"}, new ${m.new ? `${m.new.rows} rows, checksum ${m.new.checksum}` : "none"}`)
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
- `Nothing was swapped. In materialized-view mode a row written to the old table later than the cut-over delay, with a time before the cut-over, is the usual cause; ` +
81
- `in app mode, a write the application did not stop.`,
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 types = new Map(
114
- (
115
- await clickhouseQuery<{ name: string; type: string }>(
116
- run.target.endpoint,
117
- `SELECT name, type FROM system.columns WHERE database = ${sqlString(n.database)} AND table = ${sqlString(n.newName)}`,
118
- )
119
- ).map((c) => [c.name, c.type]),
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
- const read = async (table: string, name: string, partition: string, hash: string, before: string): Promise<SideCounts> => {
131
- const from = sharding ? shardTable(sharding, n.database, name) : table;
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) return { old: before, new: after, mismatches };
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 rows = all.reduce((s, v) => s + v.rows, 0);
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 summary = `${o.names.key}: ${all.length} partition(s), ${rows} row(s)${range}, counts and checksums equal in the old and new tables`;
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
- // No SQL user-defined functions.
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,14 @@ 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 call, 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 call (and the functions those call) are imported.
113
+ */
114
+ importFunctions: z.array(z.string()).optional(),
107
115
  /**
108
116
  * ClickHouse: the topology this environment runs, which the applier, the
109
117
  * plan and the rebuild migration render every statement for: `single`,
@@ -140,8 +148,11 @@ export const sqlProfileSchema = z.strictObject({
140
148
  /**
141
149
  * Postgres apply: how long one statement waits for its lock before it
142
150
  * fails (`lock_timeout`), in milliseconds, so a change blocked behind a
143
- * long query fails fast instead of queueing every query behind it. 5000
144
- * when omitted.
151
+ * long query fails fast instead of queueing every query behind it. The
152
+ * catalog reads (`chant lifecycle diff --live`, `chant sql plan`, the
153
+ * import, and an apply's read before its first statement) wait under it
154
+ * too, and name the session holding the lock when it passes. 5000 when
155
+ * omitted.
145
156
  */
146
157
  lockTimeoutMs: z.number().int().nonnegative().optional(),
147
158
  /**
@@ -156,11 +167,12 @@ export const sqlProfileSchema = z.strictObject({
156
167
  */
157
168
  scanTimeoutMs: z.number().int().nonnegative().optional(),
158
169
  /**
159
- * Postgres: whether chant manages access in this environment (#3681): the
170
+ * Whether chant manages access in this environment. Postgres (#3681): the
160
171
  * declared row-level security policies and each table's row-level security,
161
172
  * the declared roles (created when missing, never dropped), and the
162
173
  * privileges on the declared objects and the default privileges, which a
163
- * plan compares and an apply grants and revokes. Off when omitted: the
174
+ * plan compares and an apply grants and revokes. ClickHouse (#3716): the
175
+ * declared users, roles, row policies and grants. Off when omitted: the
164
176
  * access declarations are not planned or applied, and the server's access
165
177
  * is not read, so a project that manages access elsewhere is not affected.
166
178
  */
@@ -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
+ }
@@ -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. Any other statement, and any statement the parser
5
- * refuses, is a problem naming it.
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; a function belongs to no database and keeps its name.
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;
@@ -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
- const templateSafe = (s: string) => s.replace(/\\(?=`|\$\{)/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
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;
@@ -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
  });