@intentius/chant-lexicon-sql 0.117.0 → 0.118.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 (183) hide show
  1. package/dist/clickhouse/access.d.ts +132 -0
  2. package/dist/clickhouse/access.d.ts.map +1 -0
  3. package/dist/clickhouse/apply/apply.d.ts.map +1 -1
  4. package/dist/clickhouse/apply/statements.d.ts +2 -0
  5. package/dist/clickhouse/apply/statements.d.ts.map +1 -1
  6. package/dist/clickhouse/entities.d.ts +171 -2
  7. package/dist/clickhouse/entities.d.ts.map +1 -1
  8. package/dist/clickhouse/http.d.ts +3 -0
  9. package/dist/clickhouse/http.d.ts.map +1 -1
  10. package/dist/clickhouse/import/generator.d.ts +28 -0
  11. package/dist/clickhouse/import/generator.d.ts.map +1 -1
  12. package/dist/clickhouse/import/ir.d.ts.map +1 -1
  13. package/dist/clickhouse/import/parser.d.ts +3 -3
  14. package/dist/clickhouse/import/parser.d.ts.map +1 -1
  15. package/dist/clickhouse/live/bind.d.ts.map +1 -1
  16. package/dist/clickhouse/live/catalog.d.ts +41 -6
  17. package/dist/clickhouse/live/catalog.d.ts.map +1 -1
  18. package/dist/clickhouse/live/describe-resources.d.ts +3 -2
  19. package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
  20. package/dist/clickhouse/parser.d.ts +54 -3
  21. package/dist/clickhouse/parser.d.ts.map +1 -1
  22. package/dist/clickhouse/plan/commands.d.ts +3 -1
  23. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  24. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  25. package/dist/clickhouse/plan/diff.d.ts +12 -0
  26. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  27. package/dist/clickhouse/plan/normalize.d.ts +61 -1
  28. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  29. package/dist/clickhouse/plan/report.d.ts.map +1 -1
  30. package/dist/clickhouse/plan/rules.d.ts +7 -0
  31. package/dist/clickhouse/plan/rules.d.ts.map +1 -1
  32. package/dist/clickhouse/plan/schema.d.ts +8 -4
  33. package/dist/clickhouse/plan/schema.d.ts.map +1 -1
  34. package/dist/clickhouse/rebuild/backfill.d.ts +3 -1
  35. package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
  36. package/dist/clickhouse/rebuild/observe.d.ts +7 -3
  37. package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
  38. package/dist/clickhouse/rebuild/op.d.ts +8 -0
  39. package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
  40. package/dist/clickhouse/rebuild/steps.d.ts +48 -0
  41. package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
  42. package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
  43. package/dist/clickhouse/testing/writable-server.d.ts.map +1 -1
  44. package/dist/clickhouse/topology.d.ts.map +1 -1
  45. package/dist/clickhouse.d.ts +1 -1
  46. package/dist/clickhouse.d.ts.map +1 -1
  47. package/dist/codegen/classifier-page.d.ts.map +1 -1
  48. package/dist/codegen/registry.d.ts.map +1 -1
  49. package/dist/config.d.ts +50 -7
  50. package/dist/config.d.ts.map +1 -1
  51. package/dist/core/references.d.ts +4 -2
  52. package/dist/core/references.d.ts.map +1 -1
  53. package/dist/core/statements.d.ts +11 -0
  54. package/dist/core/statements.d.ts.map +1 -1
  55. package/dist/files/build-root.d.ts +26 -0
  56. package/dist/files/build-root.d.ts.map +1 -0
  57. package/dist/files/clickhouse.d.ts +19 -0
  58. package/dist/files/clickhouse.d.ts.map +1 -0
  59. package/dist/files/common.d.ts +25 -0
  60. package/dist/files/common.d.ts.map +1 -0
  61. package/dist/files/postgres.d.ts +36 -0
  62. package/dist/files/postgres.d.ts.map +1 -0
  63. package/dist/import-parser.d.ts +25 -0
  64. package/dist/import-parser.d.ts.map +1 -0
  65. package/dist/index.d.ts +3 -2
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/integrity.json +6 -6
  68. package/dist/lint/rules/sqlch001.d.ts +1 -1
  69. package/dist/lint/rules/sqlch001.d.ts.map +1 -1
  70. package/dist/lint/rules/sqlch002.d.ts.map +1 -1
  71. package/dist/lint/rules/templates.d.ts +1 -1
  72. package/dist/lint/rules/templates.d.ts.map +1 -1
  73. package/dist/manifest.json +1 -1
  74. package/dist/meta.json +30 -0
  75. package/dist/okf/index.md +7 -1
  76. package/dist/okf/rules/SQLCH001.md +2 -2
  77. package/dist/okf/types/Dictionary.md +9 -0
  78. package/dist/okf/types/Function.md +9 -0
  79. package/dist/okf/types/Grant.md +9 -0
  80. package/dist/okf/types/Role.md +9 -0
  81. package/dist/okf/types/RowPolicy.md +9 -0
  82. package/dist/okf/types/User.md +9 -0
  83. package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
  84. package/dist/op/activity-contracts.d.ts +9 -0
  85. package/dist/op/activity-contracts.d.ts.map +1 -1
  86. package/dist/plugin.d.ts.map +1 -1
  87. package/dist/postgres/apply/statements.d.ts +68 -0
  88. package/dist/postgres/apply/statements.d.ts.map +1 -1
  89. package/dist/postgres/import/generator.d.ts +33 -0
  90. package/dist/postgres/import/generator.d.ts.map +1 -1
  91. package/dist/postgres/live/bind.d.ts.map +1 -1
  92. package/dist/postgres/live/client.d.ts +3 -0
  93. package/dist/postgres/live/client.d.ts.map +1 -1
  94. package/dist/rules/sqlch001.ts +26 -4
  95. package/dist/rules/sqlch002.ts +3 -2
  96. package/dist/rules/templates.ts +2 -2
  97. package/dist/serializer.d.ts +2 -1
  98. package/dist/serializer.d.ts.map +1 -1
  99. package/dist/sql-files.d.ts +79 -0
  100. package/dist/sql-files.d.ts.map +1 -0
  101. package/dist/token-source.d.ts +88 -0
  102. package/dist/token-source.d.ts.map +1 -0
  103. package/dist/validate.d.ts.map +1 -1
  104. package/package.json +2 -2
  105. package/src/clickhouse/access.test.ts +203 -0
  106. package/src/clickhouse/access.ts +595 -0
  107. package/src/clickhouse/apply/apply.ts +29 -20
  108. package/src/clickhouse/apply/statements.ts +105 -8
  109. package/src/clickhouse/dictionary.test.ts +177 -0
  110. package/src/clickhouse/entities.ts +356 -6
  111. package/src/clickhouse/function.test.ts +91 -0
  112. package/src/clickhouse/http.ts +11 -2
  113. package/src/clickhouse/import/generator.ts +81 -46
  114. package/src/clickhouse/import/import.test.ts +9 -3
  115. package/src/clickhouse/import/ir.ts +6 -2
  116. package/src/clickhouse/import/parser.ts +10 -18
  117. package/src/clickhouse/live/access.e2e.test.ts +218 -0
  118. package/src/clickhouse/live/bind.ts +17 -6
  119. package/src/clickhouse/live/catalog.ts +106 -10
  120. package/src/clickhouse/live/describe-resources.ts +23 -10
  121. package/src/clickhouse/live/dictionaries.e2e.test.ts +186 -0
  122. package/src/clickhouse/live/functions.e2e.test.ts +155 -0
  123. package/src/clickhouse/live/live.test.ts +2 -2
  124. package/src/clickhouse/parser.ts +136 -4
  125. package/src/clickhouse/plan/commands.ts +18 -7
  126. package/src/clickhouse/plan/deep.ts +71 -11
  127. package/src/clickhouse/plan/diff.ts +65 -2
  128. package/src/clickhouse/plan/normalize.ts +200 -6
  129. package/src/clickhouse/plan/report.ts +14 -3
  130. package/src/clickhouse/plan/rules.ts +44 -1
  131. package/src/clickhouse/plan/schema.ts +40 -17
  132. package/src/clickhouse/plan/server-format.ts +1 -1
  133. package/src/clickhouse/rebuild/backfill.ts +5 -25
  134. package/src/clickhouse/rebuild/observe.ts +7 -3
  135. package/src/clickhouse/rebuild/op.ts +9 -0
  136. package/src/clickhouse/rebuild/rebuild.e2e.test.ts +62 -0
  137. package/src/clickhouse/rebuild/rebuild.test.ts +54 -0
  138. package/src/clickhouse/rebuild/steps.ts +136 -1
  139. package/src/clickhouse/testing/fake-server.ts +2 -0
  140. package/src/clickhouse/testing/writable-server.ts +2 -0
  141. package/src/clickhouse/topology.ts +17 -2
  142. package/src/clickhouse.ts +19 -0
  143. package/src/codegen/classifier-page.ts +2 -0
  144. package/src/codegen/registry.ts +6 -0
  145. package/src/config.ts +12 -3
  146. package/src/core/references.ts +5 -3
  147. package/src/core/statements.ts +11 -1
  148. package/src/files/build-root.ts +62 -0
  149. package/src/files/clickhouse.ts +52 -0
  150. package/src/files/common.ts +54 -0
  151. package/src/files/postgres.ts +371 -0
  152. package/src/generated/lexicon-sql.json +30 -0
  153. package/src/import-parser.ts +61 -0
  154. package/src/index.ts +15 -2
  155. package/src/lint/rules/sqlch001.ts +26 -4
  156. package/src/lint/rules/sqlch002.ts +3 -2
  157. package/src/lint/rules/templates.ts +2 -2
  158. package/src/lsp/template.ts +3 -3
  159. package/src/mcp/index.ts +6 -6
  160. package/src/migration-statements.test.ts +25 -0
  161. package/src/migration-statements.ts +1 -0
  162. package/src/op/activities/clickhouse-rebuild.ts +1 -0
  163. package/src/op/activity-contracts.ts +1 -0
  164. package/src/plugin.ts +29 -8
  165. package/src/postgres/apply/constraint-sequences.e2e.test.ts +179 -0
  166. package/src/postgres/apply/safe-sequences.e2e.test.ts +176 -0
  167. package/src/postgres/apply/statements.test.ts +138 -6
  168. package/src/postgres/apply/statements.ts +156 -10
  169. package/src/postgres/import/generator.ts +118 -45
  170. package/src/postgres/live/bind.ts +17 -6
  171. package/src/postgres/live/client.ts +10 -2
  172. package/src/postgres/plan/plan.test.ts +2 -2
  173. package/src/postgres/plan/rules.ts +5 -5
  174. package/src/serializer.ts +14 -9
  175. package/src/skills/chant-sql-postgres-plan.md +3 -3
  176. package/src/skills/chant-sql-rebuild.md +3 -2
  177. package/src/skills/chant-sql.md +7 -1
  178. package/src/sql-files.test.ts +188 -0
  179. package/src/sql-files.ts +218 -0
  180. package/src/token-source.e2e.test.ts +142 -0
  181. package/src/token-source.test.ts +119 -0
  182. package/src/token-source.ts +189 -0
  183. package/src/validate.ts +6 -0
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Reading a live server's schema: the databases and the tables, views and
3
- * materialized views in them, from `system.databases`, `system.tables` and
4
- * `SHOW CREATE`.
2
+ * Reading a live server's schema: the databases and the tables, views,
3
+ * materialized views and dictionaries in them, from `system.databases`,
4
+ * `system.tables` and `SHOW CREATE`, and the SQL user-defined functions,
5
+ * from `system.functions`.
5
6
  */
6
7
 
7
8
  import { clickhouseQuery } from "../http";
@@ -16,13 +17,15 @@ export interface LiveObject {
16
17
  type: ClickHouseEntityType;
17
18
  database?: string;
18
19
  name: string;
19
- /** The engine `system.tables` / `system.databases` report: `MergeTree`, `View`, `MaterializedView`, `Atomic`. */
20
+ /** The engine `system.tables` / `system.databases` report: `MergeTree`, `View`, `MaterializedView`, `Atomic`, `Dictionary`. */
20
21
  engine: string;
21
22
  uuid?: string;
22
23
  /** The object's comment as the server holds it, chant's ownership trailer included (`../ownership.ts`). */
23
24
  comment?: string;
24
25
  /** `SHOW CREATE` as the server prints it, less chant's ownership trailer, so it reads as the declaration. */
25
26
  statement: string;
27
+ /** A row policy's table. */
28
+ table?: string;
26
29
  }
27
30
 
28
31
  const quote = (s: string) => `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
@@ -38,8 +41,10 @@ export function entityTypeOfEngine(engine: string): ClickHouseEntityType {
38
41
  /**
39
42
  * Every object in the target's databases, sorted by database then name.
40
43
  * A materialized view's inner table (`.inner_id.<uuid>`, `.inner.<name>`)
41
- * belongs to its view and is left out, as are temporary tables and
42
- * dictionaries (not declared in slice 1).
44
+ * belongs to its view and is left out, as are temporary tables. A
45
+ * dictionary is read (#3682) from `system.tables`, where a reader granted
46
+ * `SELECT` on the database sees it without a grant on `system.dictionaries`;
47
+ * a table with the `Dictionary` engine is a table.
43
48
  *
44
49
  * So are chant's own working objects (`../ownership.ts`
45
50
  * `isChantWorkingObject`): a rebuild migration's new, dual-write and retained
@@ -56,9 +61,9 @@ export async function readLiveSchema(target: ClickHouseTarget, opts: { withState
56
61
  const databases = await q<{ name: string; engine: string; uuid: string; comment: string }>(
57
62
  `SELECT name, engine, toString(uuid) AS uuid, comment FROM system.databases WHERE name ${scope} ORDER BY name`,
58
63
  );
59
- const tables = await q<{ database: string; name: string; engine: string; uuid: string; comment: string }>(
60
- `SELECT database, name, engine, toString(uuid) AS uuid, comment FROM system.tables ` +
61
- `WHERE database ${scope} AND NOT is_temporary AND name NOT LIKE '.inner%' AND engine != 'Dictionary' ` +
64
+ const tables = await q<{ database: string; name: string; engine: string; uuid: string; comment: string; dictionary: number }>(
65
+ `SELECT database, name, engine, toString(uuid) AS uuid, comment, startsWith(create_table_query, 'CREATE DICTIONARY') AS dictionary FROM system.tables ` +
66
+ `WHERE database ${scope} AND NOT is_temporary AND name NOT LIKE '.inner%' ` +
62
67
  `ORDER BY database, name`,
63
68
  );
64
69
 
@@ -83,7 +88,7 @@ export async function readLiveSchema(target: ClickHouseTarget, opts: { withState
83
88
  for (const t of tables) {
84
89
  if (working.has(t.database) || isChantWorkingObject(t.comment)) continue;
85
90
  out.push({
86
- type: entityTypeOfEngine(t.engine),
91
+ type: Number(t.dictionary) === 1 ? CLICKHOUSE_ENTITY_TYPES.dictionary : entityTypeOfEngine(t.engine),
87
92
  database: t.database,
88
93
  name: t.name,
89
94
  engine: t.engine,
@@ -92,5 +97,96 @@ export async function readLiveSchema(target: ClickHouseTarget, opts: { withState
92
97
  statement: await statement("TABLE", `${ident(t.database)}.${ident(t.name)}`),
93
98
  });
94
99
  }
100
+ // SQL user-defined functions belong to no database: every one is read, and a caller keeps those it declares.
101
+ const functions = await q<{ name: string; statement: string }>(
102
+ `SELECT name, create_query AS statement FROM system.functions WHERE origin = 'SQLUserDefined' ORDER BY name`,
103
+ );
104
+ for (const f of functions) {
105
+ out.push({ type: CLICKHOUSE_ENTITY_TYPES.function, name: f.name, engine: "", statement: opts.withStatements === false ? "" : f.statement });
106
+ }
107
+ return out;
108
+ }
109
+
110
+
111
+ /** The access objects a build declares, by kind and name: what `readLiveAccess` reads. */
112
+ export interface DeclaredAccess {
113
+ kind: string;
114
+ name: string;
115
+ database?: string;
116
+ table?: string;
117
+ }
118
+
119
+ /**
120
+ * The users, roles, row policies and grantees' grants the build declares,
121
+ * as the server holds them (#3682): `SHOW CREATE USER`, `SHOW CREATE ROLE`,
122
+ * `SHOW CREATE ROW POLICY`, and for a grantee every line of
123
+ * `SHOW GRANTS FOR`, one per line. Nothing else is read, so a project that
124
+ * declares none needs no access to `system.users` and the like, and an
125
+ * object made by hand is never seen. One that does not exist is left out.
126
+ */
127
+ export async function readLiveAccess(target: ClickHouseTarget, declared: readonly DeclaredAccess[], opts: { withStatements?: boolean } = {}): Promise<LiveObject[]> {
128
+ if (declared.length === 0) return [];
129
+ const q = <T>(sql: string) => clickhouseQuery<T>(target.endpoint, sql);
130
+ const first = async (sql: string) => (opts.withStatements === false ? "" : Object.values((await q<Record<string, string>>(sql))[0] ?? {})[0] ?? "");
131
+ const all = async (sql: string) => (await q<Record<string, string>>(sql)).map((r) => Object.values(r)[0] ?? "");
132
+ const names = (kind: string) => [...new Set(declared.filter((d) => d.kind === kind).map((d) => d.name))];
133
+ const users = names("user");
134
+ const roles = names("role");
135
+ const grantees = names("grants");
136
+ const principals = [...new Set([...users, ...roles, ...grantees])];
137
+ const list = (xs: string[]) => xs.map(quote).join(", ");
138
+ const existingUsers = new Set(principals.length ? (await q<{ name: string }>(`SELECT name FROM system.users WHERE name IN (${list(principals)})`)).map((r) => r.name) : []);
139
+ const existingRoles = new Set(principals.length ? (await q<{ name: string }>(`SELECT name FROM system.roles WHERE name IN (${list(principals)})`)).map((r) => r.name) : []);
140
+ const out: LiveObject[] = [];
141
+ for (const name of users.filter((u) => existingUsers.has(u))) {
142
+ out.push({ type: CLICKHOUSE_ENTITY_TYPES.user, name, engine: "", statement: await first(`SHOW CREATE USER ${ident(name)}`) });
143
+ }
144
+ for (const name of roles.filter((r) => existingRoles.has(r))) {
145
+ out.push({ type: CLICKHOUSE_ENTITY_TYPES.role, name, engine: "", statement: await first(`SHOW CREATE ROLE ${ident(name)}`) });
146
+ }
147
+ const policies = declared.filter((d) => d.kind === "rowPolicy");
148
+ if (policies.length > 0) {
149
+ const live = await q<{ name: string; database: string; table: string }>(
150
+ `SELECT short_name AS name, database, table FROM system.row_policies WHERE short_name IN (${list(policies.map((p) => p.name))})`,
151
+ );
152
+ for (const p of policies) {
153
+ if (!live.some((l) => l.name === p.name && l.database === p.database && l.table === p.table)) continue;
154
+ out.push({
155
+ type: CLICKHOUSE_ENTITY_TYPES.rowPolicy,
156
+ name: p.name,
157
+ database: p.database!,
158
+ table: p.table!,
159
+ engine: "",
160
+ statement: await first(`SHOW CREATE ROW POLICY ${ident(p.name)} ON ${ident(p.database!)}.${ident(p.table!)}`),
161
+ });
162
+ }
163
+ }
164
+ for (const name of grantees.filter((g) => existingUsers.has(g) || existingRoles.has(g))) {
165
+ const lines = opts.withStatements === false ? [] : await all(`SHOW GRANTS FOR ${ident(name)}`);
166
+ out.push({ type: CLICKHOUSE_ENTITY_TYPES.grant, name, engine: "", statement: lines.join("\n") });
167
+ }
168
+ return out;
169
+ }
170
+
171
+ const ACCESS_KINDS = new Set(["user", "role", "rowPolicy", "grants"]);
172
+
173
+ /** The access objects among declared canonical objects, for `readLiveAccess`. */
174
+ export function declaredAccess(objects: ReadonlyArray<{ kind: string; name: string; database?: string; table?: string }>): DeclaredAccess[] {
175
+ return objects
176
+ .filter((o) => ACCESS_KINDS.has(o.kind))
177
+ .map((o) => ({ kind: o.kind, name: o.name, ...(o.database !== undefined ? { database: o.database } : {}), ...(o.table !== undefined ? { table: o.table } : {}) }));
178
+ }
179
+
180
+ /** The users, roles and row policies among declared entities, for `readLiveAccess`. */
181
+ export function accessOf(entities: ReadonlyMap<string, { entityType: string; props: Record<string, unknown> }>, defaultDatabase: string): DeclaredAccess[] {
182
+ const kinds: Record<string, string> = { [CLICKHOUSE_ENTITY_TYPES.user]: "user", [CLICKHOUSE_ENTITY_TYPES.role]: "role", [CLICKHOUSE_ENTITY_TYPES.rowPolicy]: "rowPolicy" };
183
+ const out: DeclaredAccess[] = [];
184
+ for (const e of entities.values()) {
185
+ const kind = kinds[e.entityType];
186
+ if (!kind) continue;
187
+ const name = String(e.props.name ?? "");
188
+ if (kind === "rowPolicy") out.push({ kind, name, database: typeof e.props.database === "string" ? e.props.database : defaultDatabase, table: String(e.props.table ?? "") });
189
+ else out.push({ kind, name });
190
+ }
95
191
  return out;
96
192
  }
@@ -2,8 +2,9 @@
2
2
  * `describeResources()` for the ClickHouse dialect: which declared databases,
3
3
  * tables and views exist on the environment's server.
4
4
  *
5
- * One read of `system.databases` and `system.tables` per run, then a lookup
6
- * per declared entity, so N entities are two queries, not N. An object the
5
+ * One read of `system.databases`, `system.tables` and `system.functions` per
6
+ * run, then a lookup per declared entity, so N entities are three queries,
7
+ * not N. An object the
7
8
  * catalog does not list is absent, and the `queried` address says which
8
9
  * server and name were asked. A server that cannot be reached, or refuses the
9
10
  * credentials, leaves every entity unobserved with that reason: never absent.
@@ -23,8 +24,9 @@ import {
23
24
  type ObserverAdapter,
24
25
  } from "@intentius/chant/observation";
25
26
  import { bindClickHouse, classifyClickHouseFailure, type BindOptions, type ClickHouseTarget } from "./bind";
26
- import { readLiveSchema, type LiveObject } from "./catalog";
27
+ import { accessOf, readLiveAccess, readLiveSchema, type DeclaredAccess, type LiveObject } from "./catalog";
27
28
  import { CLICKHOUSE_ENTITY_TYPES } from "../entities";
29
+ import { objectKey } from "../plan/normalize";
28
30
  import { isChantManaged, readMarker, stripMarker } from "../ownership";
29
31
 
30
32
  interface Bound {
@@ -32,22 +34,29 @@ interface Bound {
32
34
  byKey: Map<string, LiveObject>;
33
35
  }
34
36
 
35
- const keyOf = (database: string | undefined, name: string) => `${database ?? ""}.${name}`;
36
37
 
37
38
  /** The database and name a declared entity is created as. */
38
39
  export function declaredAddress(entity: DeclaredEntity, defaultDatabase: string): { database?: string; name: string } {
39
40
  const name = String(entity.props.name ?? "");
40
- if (entity.type === CLICKHOUSE_ENTITY_TYPES.database) return { name };
41
+ if (
42
+ entity.type === CLICKHOUSE_ENTITY_TYPES.database ||
43
+ entity.type === CLICKHOUSE_ENTITY_TYPES.function ||
44
+ entity.type === CLICKHOUSE_ENTITY_TYPES.user ||
45
+ entity.type === CLICKHOUSE_ENTITY_TYPES.role
46
+ ) {
47
+ return { name };
48
+ }
41
49
  return { database: typeof entity.props.database === "string" ? entity.props.database : defaultDatabase, name };
42
50
  }
43
51
 
44
- function adapter(options: BindOptions & { owned?: boolean }): ObserverAdapter<Bound> {
52
+ function adapter(options: BindOptions & { owned?: boolean; access?: (defaultDatabase: string) => DeclaredAccess[] }): ObserverAdapter<Bound> {
45
53
  return {
46
54
  async bind() {
47
55
  const target = await bindClickHouse(options);
48
56
  const byKey = new Map<string, LiveObject>();
49
- for (const o of await readLiveSchema(target, { withStatements: false })) {
50
- byKey.set(o.type === CLICKHOUSE_ENTITY_TYPES.database ? keyOf(undefined, o.name) : keyOf(o.database, o.name), o);
57
+ const access = await readLiveAccess(target, options.access?.(target.defaultDatabase) ?? [], { withStatements: false });
58
+ for (const o of [...(await readLiveSchema(target, { withStatements: false })), ...access]) {
59
+ byKey.set(objectKey(o), o);
51
60
  }
52
61
  return { target, byKey };
53
62
  },
@@ -56,9 +65,13 @@ function adapter(options: BindOptions & { owned?: boolean }): ObserverAdapter<Bo
56
65
  if (!entity.type.startsWith("ClickHouse::")) {
57
66
  return { unobserved: { reason: "unsupported-kind", detail: entity.type } };
58
67
  }
68
+ if (entity.type === CLICKHOUSE_ENTITY_TYPES.grant) {
69
+ return { unobserved: { reason: "unsupported-kind", detail: "a grant is compared as part of its grantees' grants, which chant sql plan reports" } };
70
+ }
59
71
  const { database, name } = declaredAddress(entity, target.defaultDatabase);
60
72
  const queried = `${target.endpoint.url} ${database ? `${database}.` : ""}${name}`;
61
- const live = byKey.get(keyOf(database, name));
73
+ const table = typeof entity.props.table === "string" ? entity.props.table : undefined;
74
+ const live = byKey.get(objectKey({ type: entity.type, ...(database !== undefined ? { database } : {}), name, ...(table !== undefined ? { table } : {}) }));
62
75
  if (!live) return { absent: true, queried };
63
76
  const owned = isChantManaged(live.comment);
64
77
  if (options.owned && !owned) {
@@ -94,5 +107,5 @@ export async function describeResources(
94
107
  const entity = options.entities.get(name);
95
108
  return { name, type: entity?.entityType ?? "", props: entity?.props ?? {} };
96
109
  });
97
- return observeEntities(declared, adapter(options));
110
+ return observeEntities(declared, adapter({ ...options, access: (db) => accessOf(options.entities, db) }));
98
111
  }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Dictionaries against a server (#3653, #3682).
3
+ *
4
+ * On a single node: a declared dictionary is created with chant's marker,
5
+ * plans with no changes and reads back with no drift; a layout and a
6
+ * lifetime changed in the declaration are SQLCH245 and the apply replaces
7
+ * the dictionary; a dictionary made by hand in a declared database is
8
+ * listed, as a drop, never left out; a prune drops the declared one once
9
+ * the build no longer holds it, and leaves the foreign one. A table with
10
+ * the `Dictionary` engine is read as a table.
11
+ *
12
+ * On a Replicated database (two replicas of the pinned server in Docker):
13
+ * the dictionary is created once, through the database, with no
14
+ * `ON CLUSTER`, and both replicas hold it and answer `dictGet`.
15
+ *
16
+ * The single node: `CLICKHOUSE_URL` (with `CLICKHOUSE_USER` and
17
+ * `CLICKHOUSE_PASSWORD`) names a running one, such as the one
18
+ * `chant emulator up --lexicon sql` starts. Without it, a throwaway server at
19
+ * the pin, skipped cleanly when Docker is not available. The test writes
20
+ * only to the database `chant_e2e_3682`, dropped afterwards.
21
+ */
22
+
23
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { join } from "node:path";
26
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
27
+ import { dockerAvailable, startScratchCluster, startScratchServer, type ScratchCluster, type ScratchServer } from "../container";
28
+ import { clickhouseQuery, type ClickHouseEndpoint } from "../http";
29
+ import { clickhouseImage } from "../../spec/pin";
30
+ import { database, dictionary, table } from "../entities";
31
+ import { planAgainstServer } from "../plan/commands";
32
+ import { observeResourcesDeep, sqlDeepNormalizationHooks } from "../plan/deep";
33
+ import { clickhouseApply } from "../../op/activities/clickhouse-apply";
34
+
35
+ const DB = "chant_e2e_3682";
36
+ const fromEnv = process.env.CLICKHOUSE_URL;
37
+ const docker = await dockerAvailable();
38
+ const enabled = fromEnv !== undefined || docker;
39
+ let server: ScratchServer | undefined;
40
+ let endpoint: ClickHouseEndpoint;
41
+ const dir = mkdtempSync(join(tmpdir(), "chant-sql-dictionaries-"));
42
+ const q = <T = Record<string, unknown>>(sql: string, at: ClickHouseEndpoint = endpoint) => clickhouseQuery<T>(at, sql);
43
+
44
+ beforeAll(async () => {
45
+ if (!enabled) return;
46
+ if (fromEnv !== undefined) {
47
+ endpoint = {
48
+ url: fromEnv,
49
+ ...(process.env.CLICKHOUSE_USER ? { user: process.env.CLICKHOUSE_USER } : {}),
50
+ ...(process.env.CLICKHOUSE_PASSWORD ? { password: process.env.CLICKHOUSE_PASSWORD } : {}),
51
+ };
52
+ } else {
53
+ server = await startScratchServer(clickhouseImage(), { namePrefix: "chant-sql-dictionaries" });
54
+ endpoint = server.endpoint;
55
+ }
56
+ await q(`DROP DATABASE IF EXISTS ${DB} SYNC`);
57
+ }, 600_000);
58
+
59
+ afterAll(async () => {
60
+ if (enabled) await q(`DROP DATABASE IF EXISTS ${DB} SYNC`).catch(() => undefined);
61
+ await server?.stop();
62
+ rmSync(dir, { recursive: true, force: true });
63
+ });
64
+
65
+ const envOf = (at: ClickHouseEndpoint, topology?: string) => ({
66
+ CLICKHOUSE_URL: at.url,
67
+ ...(at.user ? { CLICKHOUSE_USER: at.user } : {}),
68
+ ...(at.password ? { CLICKHOUSE_PASSWORD: at.password } : {}),
69
+ ...(topology ? { CLICKHOUSE_TOPOLOGY: topology } : {}),
70
+ });
71
+ const OWNERSHIP = { stack: "e2e3682", env: "test" };
72
+
73
+ type Entity = { entityType: string; props: { ddl: string } };
74
+ function writeBuild(file: string, declared: Record<string, Entity>): string {
75
+ const path = join(dir, file);
76
+ const objects = Object.entries(declared).map(([k, e]) => ({ export: k, type: e.entityType, ddl: e.props.ddl, dependsOn: [] }));
77
+ writeFileSync(path, JSON.stringify({ dialect: "clickhouse", applyOrder: objects.map((o) => o.export), objects }));
78
+ return path;
79
+ }
80
+
81
+ async function apply(at: ClickHouseEndpoint, buildPath: string, opts: { prune?: boolean; topology?: string } = {}) {
82
+ const sent: string[] = [];
83
+ const outcome = await clickhouseApply({ buildPath, environment: "test", ...(opts.prune ? { prune: true } : {}) }, undefined, {
84
+ config: { ownership: OWNERSHIP },
85
+ env: envOf(at, opts.topology),
86
+ log: (l) => void (/^[A-Z]+ /.test(l) && sent.push(l)),
87
+ });
88
+ return { outcome, sent };
89
+ }
90
+
91
+ const ratesDict = (layout: string, lifetime: string) =>
92
+ dictionary([
93
+ `CREATE DICTIONARY ${DB}.rates_dict (code String, rate Float64 DEFAULT 1) PRIMARY KEY code SOURCE(clickhouse(table 'rates' db '${DB}')) LAYOUT(${layout}) LIFETIME(${lifetime}) COMMENT 'exchange rates'`,
94
+ ] as unknown as TemplateStringsArray);
95
+
96
+ /** The props a deep diff compares: the declaration's, less what the hooks prune. */
97
+ const compared = (props: object): Record<string, unknown> =>
98
+ Object.fromEntries(Object.entries(JSON.parse(JSON.stringify(props)) as Record<string, unknown>).filter(([k]) => !sqlDeepNormalizationHooks.prune!({ pattern: k } as never)));
99
+
100
+ describe.skipIf(!enabled)("a dictionary on a single node (#3682)", () => {
101
+ const shop = database([`CREATE DATABASE ${DB}`] as unknown as TemplateStringsArray);
102
+ const rates = table([`CREATE TABLE ${DB}.rates (code String, rate Float64) ENGINE = MergeTree ORDER BY code`] as unknown as TemplateStringsArray);
103
+
104
+ test("created, planned, read back, changed, listed beside a foreign one, and pruned", async () => {
105
+ const v1 = { shop, rates, ratesDict: ratesDict("complex_key_hashed()", "300") };
106
+ const first = await apply(endpoint, writeBuild("v1.json", v1));
107
+ expect(first.outcome.failed).toEqual([]);
108
+ expect(first.outcome.applied.map((a) => [a.name, a.action])).toEqual([
109
+ [DB, "created"],
110
+ [`${DB}.rates`, "created"],
111
+ [`${DB}.rates_dict`, "created"],
112
+ ]);
113
+ await q(`INSERT INTO ${DB}.rates VALUES ('EUR', 1.1), ('GBP', 1.3)`);
114
+ expect((await q<{ r: number }>(`SELECT dictGet('${DB}.rates_dict', 'rate', 'GBP') AS r`))[0]!.r).toBe(1.3);
115
+ // The server prints the layout upper case, the lifetime as MIN and MAX: still the declaration.
116
+ expect((await q<{ s: string }>(`SELECT create_table_query AS s FROM system.tables WHERE database = '${DB}' AND name = 'rates_dict'`))[0]!.s).toMatch(/LIFETIME\(MIN 0 MAX 300\) LAYOUT\(COMPLEX_KEY_HASHED\(\)\)/);
117
+
118
+ const diff = await planAgainstServer("test", writeBuild("v1.json", v1), { config: {}, env: envOf(endpoint) });
119
+ expect(diff.changes).toEqual([]);
120
+ expect(diff.unreadable).toBeUndefined();
121
+ const entities = new Map(Object.entries(v1).map(([k, e]) => [k, { entityType: e.entityType, props: e.props as unknown as Record<string, unknown> }]));
122
+ const deep = await observeResourcesDeep({ environment: "test", entityNames: [...entities.keys()], entities, config: {}, env: envOf(endpoint) });
123
+ expect(deep.unobserved ?? {}).toEqual({});
124
+ expect(compared(deep.resources.ratesDict!.properties)).toEqual(compared(v1.ratesDict.props));
125
+ expect(deep.resources.ratesDict!.type).toBe("ClickHouse::Dictionary");
126
+
127
+ // A foreign dictionary and a Dictionary-engine table, made by hand: listed, not left out.
128
+ await q(`CREATE DICTIONARY ${DB}.foreign_dict (code String, rate Float64) PRIMARY KEY code SOURCE(CLICKHOUSE(TABLE 'rates' DB '${DB}')) LAYOUT(COMPLEX_KEY_HASHED()) LIFETIME(0)`);
129
+ await q(`CREATE TABLE ${DB}.rates_view (code String, rate Float64) ENGINE = Dictionary(${DB}.rates_dict)`);
130
+
131
+ const v2 = { ...v1, ratesDict: ratesDict("complex_key_sparse_hashed()", "MIN 10 MAX 600") };
132
+ const planned = await planAgainstServer("test", writeBuild("v2.json", v2), { config: {}, env: envOf(endpoint) });
133
+ expect(planned.changes.map((c) => [c.object, c.field, c.rule])).toEqual([
134
+ [`ratesDict (${DB}.rates_dict)`, "layout", "SQLCH245"],
135
+ [`ratesDict (${DB}.rates_dict)`, "lifetime", "SQLCH245"],
136
+ [`${DB}.foreign_dict`, "object", "SQLCH250"],
137
+ [`${DB}.rates_view`, "object", "SQLCH250"],
138
+ ]);
139
+
140
+ const changed = await apply(endpoint, writeBuild("v2.json", v2));
141
+ expect(changed.sent).toEqual([expect.stringMatching(new RegExp(`^CREATE OR REPLACE DICTIONARY ${DB}\\.rates_dict .*LAYOUT\\(complex_key_sparse_hashed\\(\\)\\) LIFETIME\\(MIN 10 MAX 600\\) COMMENT 'exchange rates \\[chant`))]);
142
+ expect((await q<{ r: number }>(`SELECT dictGet('${DB}.rates_dict', 'rate', 'EUR') AS r`))[0]!.r).toBe(1.1);
143
+ expect((await planAgainstServer("test", writeBuild("v2.json", v2), { config: {}, env: envOf(endpoint) })).changes.map((c) => c.object)).toEqual([
144
+ `${DB}.foreign_dict`,
145
+ `${DB}.rates_view`,
146
+ ]);
147
+
148
+ // Pruned once the build no longer declares it; the foreign dictionary carries no marker and stays.
149
+ await q(`DROP TABLE ${DB}.rates_view SYNC`);
150
+ const pruned = await apply(endpoint, writeBuild("v3.json", { shop, rates }), { prune: true });
151
+ expect(pruned.outcome.pruned.map((p) => p.name)).toEqual([`${DB}.rates_dict`]);
152
+ expect(pruned.sent).toEqual([`DROP DICTIONARY \`${DB}\`.\`rates_dict\` SYNC`]);
153
+ const left = await q<{ name: string }>(`SELECT name FROM system.tables WHERE database = '${DB}' ORDER BY name`);
154
+ expect(left.map((t) => t.name)).toEqual(["foreign_dict", "rates"]);
155
+ }, 300_000);
156
+ });
157
+
158
+ describe.skipIf(!docker)("a dictionary in a Replicated database (#3682)", () => {
159
+ let cluster: ScratchCluster | undefined;
160
+ beforeAll(async () => {
161
+ cluster = await startScratchCluster(clickhouseImage(), { replicas: 2, namePrefix: "chant-sql-dictionaries-repl" });
162
+ }, 600_000);
163
+ afterAll(async () => {
164
+ await cluster?.stop();
165
+ });
166
+
167
+ test("created once through the database, held by both replicas, with no ON CLUSTER", async () => {
168
+ const [r1, r2] = cluster!.replicas as [ClickHouseEndpoint, ClickHouseEndpoint];
169
+ const shop = database([`CREATE DATABASE ${DB}`] as unknown as TemplateStringsArray);
170
+ const rates = table([`CREATE TABLE ${DB}.rates (code String, rate Float64) ENGINE = MergeTree ORDER BY code`] as unknown as TemplateStringsArray);
171
+ const build = writeBuild("repl.json", { shop, rates, ratesDict: ratesDict("complex_key_hashed()", "300") });
172
+ const first = await apply(r1, build, { topology: "replicated" });
173
+ expect(first.outcome.failed).toEqual([]);
174
+ expect(first.sent.join("\n")).not.toMatch(/ON CLUSTER/);
175
+ // The second replica joins the database with the statement the first was sent, and replays the rest from its log.
176
+ await q(first.sent[0]!, r2);
177
+ await q(`SYSTEM SYNC DATABASE REPLICA ${DB}`, r2);
178
+ await q(`INSERT INTO ${DB}.rates VALUES ('EUR', 1.1)`, r1);
179
+ await q(`SYSTEM SYNC REPLICA ${DB}.rates`, r2);
180
+ expect((await q<{ r: number }>(`SELECT dictGet('${DB}.rates_dict', 'rate', 'EUR') AS r`, r2))[0]!.r).toBe(1.1);
181
+ // On the other replica the dictionary is as declared: nothing to send.
182
+ const again = await apply(r2, build, { topology: "replicated" });
183
+ expect(again.sent).toEqual([]);
184
+ expect(again.outcome.notAttempted).toEqual([]);
185
+ }, 300_000);
186
+ });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * SQL user-defined functions against a server (#3682).
3
+ *
4
+ * On a single node: a declared function is created as declared, plans with
5
+ * no changes although the server prints its expression its own way, and
6
+ * reads back with no drift; a changed expression is SQLCH260 and the apply
7
+ * replaces it; a function the build does not declare is never read, so
8
+ * never listed or dropped.
9
+ *
10
+ * Beside a Replicated database (two replicas of the pinned server in
11
+ * Docker): a function belongs to no database, so the database's log does
12
+ * not carry it. With no cluster named, each replica's apply creates it
13
+ * there, and a second apply sends nothing.
14
+ *
15
+ * The single node: `CLICKHOUSE_URL` (with `CLICKHOUSE_USER` and
16
+ * `CLICKHOUSE_PASSWORD`) names a running one, such as the one
17
+ * `chant emulator up --lexicon sql` starts. Without it, a throwaway server
18
+ * at the pin, skipped cleanly when Docker is not available. A function is
19
+ * global to the server, so the test names its functions `chant_e2e_3682_*`
20
+ * and drops them afterwards.
21
+ */
22
+
23
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { join } from "node:path";
26
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
27
+ import { dockerAvailable, startScratchCluster, startScratchServer, type ScratchCluster, type ScratchServer } from "../container";
28
+ import { clickhouseQuery, type ClickHouseEndpoint } from "../http";
29
+ import { clickhouseImage } from "../../spec/pin";
30
+ import { database, func, table } from "../entities";
31
+ import { planAgainstServer } from "../plan/commands";
32
+ import { observeResourcesDeep, sqlDeepNormalizationHooks } from "../plan/deep";
33
+ import { clickhouseApply } from "../../op/activities/clickhouse-apply";
34
+
35
+ const PREFIX = "chant_e2e_3682";
36
+ const LINEAR = `${PREFIX}_linear`;
37
+ const FOREIGN = `${PREFIX}_foreign`;
38
+ const DB = `${PREFIX}_fn`;
39
+ const fromEnv = process.env.CLICKHOUSE_URL;
40
+ const docker = await dockerAvailable();
41
+ const enabled = fromEnv !== undefined || docker;
42
+ let server: ScratchServer | undefined;
43
+ let endpoint: ClickHouseEndpoint;
44
+ const dir = mkdtempSync(join(tmpdir(), "chant-sql-functions-"));
45
+ const q = <T = Record<string, unknown>>(sql: string, at: ClickHouseEndpoint = endpoint) => clickhouseQuery<T>(at, sql);
46
+ const cleanup = async (at: ClickHouseEndpoint) => {
47
+ for (const f of [LINEAR, FOREIGN]) await q(`DROP FUNCTION IF EXISTS ${f}`, at).catch(() => undefined);
48
+ await q(`DROP DATABASE IF EXISTS ${DB} SYNC`, at).catch(() => undefined);
49
+ };
50
+
51
+ beforeAll(async () => {
52
+ if (!enabled) return;
53
+ if (fromEnv !== undefined) {
54
+ endpoint = {
55
+ url: fromEnv,
56
+ ...(process.env.CLICKHOUSE_USER ? { user: process.env.CLICKHOUSE_USER } : {}),
57
+ ...(process.env.CLICKHOUSE_PASSWORD ? { password: process.env.CLICKHOUSE_PASSWORD } : {}),
58
+ };
59
+ } else {
60
+ server = await startScratchServer(clickhouseImage(), { namePrefix: "chant-sql-functions" });
61
+ endpoint = server.endpoint;
62
+ }
63
+ await cleanup(endpoint);
64
+ }, 600_000);
65
+
66
+ afterAll(async () => {
67
+ if (enabled) await cleanup(endpoint);
68
+ await server?.stop();
69
+ rmSync(dir, { recursive: true, force: true });
70
+ });
71
+
72
+ const envOf = (at: ClickHouseEndpoint, topology?: string) => ({
73
+ CLICKHOUSE_URL: at.url,
74
+ ...(at.user ? { CLICKHOUSE_USER: at.user } : {}),
75
+ ...(at.password ? { CLICKHOUSE_PASSWORD: at.password } : {}),
76
+ ...(topology ? { CLICKHOUSE_TOPOLOGY: topology } : {}),
77
+ });
78
+
79
+ type Entity = { entityType: string; props: { ddl: string } };
80
+ function writeBuild(file: string, declared: Record<string, Entity>): string {
81
+ const path = join(dir, file);
82
+ const objects = Object.entries(declared).map(([k, e]) => ({ export: k, type: e.entityType, ddl: e.props.ddl, dependsOn: [] }));
83
+ writeFileSync(path, JSON.stringify({ dialect: "clickhouse", applyOrder: objects.map((o) => o.export), objects }));
84
+ return path;
85
+ }
86
+
87
+ async function apply(at: ClickHouseEndpoint, buildPath: string, opts: { prune?: boolean; topology?: string } = {}) {
88
+ const sent: string[] = [];
89
+ const outcome = await clickhouseApply({ buildPath, environment: "test", ...(opts.prune ? { prune: true } : {}) }, undefined, {
90
+ config: { ownership: { stack: "e2e3682", env: "test" } },
91
+ env: envOf(at, opts.topology),
92
+ log: (l) => void (/^[A-Z]+ /.test(l) && sent.push(l)),
93
+ });
94
+ return { outcome, sent };
95
+ }
96
+
97
+ const linear = (expr: string) => func([`CREATE FUNCTION ${LINEAR} AS (x, k, b) -> ${expr}`] as unknown as TemplateStringsArray);
98
+ const compared = (props: object): Record<string, unknown> =>
99
+ Object.fromEntries(Object.entries(JSON.parse(JSON.stringify(props)) as Record<string, unknown>).filter(([k]) => !sqlDeepNormalizationHooks.prune!({ pattern: k } as never)));
100
+
101
+ describe.skipIf(!enabled)("a function on a single node (#3682)", () => {
102
+ test("created, planned and read back as declared, changed, and a foreign one left alone", async () => {
103
+ const v1 = { linear: linear("k*x + b") };
104
+ const first = await apply(endpoint, writeBuild("v1.json", v1));
105
+ expect(first.outcome.failed).toEqual([]);
106
+ expect(first.sent).toEqual([`CREATE FUNCTION ${LINEAR} AS (x, k, b) -> k*x + b`]);
107
+ expect((await q<{ y: number }>(`SELECT ${LINEAR}(2, 3, 1) AS y`))[0]!.y).toBe(7);
108
+
109
+ // The server prints `((k * x) + b)`: its formatter says that is the declaration.
110
+ await q(`CREATE FUNCTION ${FOREIGN} AS (x) -> x`);
111
+ const diff = await planAgainstServer("test", writeBuild("v1.json", v1), { config: {}, env: envOf(endpoint) });
112
+ expect(diff.changes).toEqual([]);
113
+ const entities = new Map(Object.entries(v1).map(([k, e]) => [k, { entityType: e.entityType, props: e.props as unknown as Record<string, unknown> }]));
114
+ const deep = await observeResourcesDeep({ environment: "test", entityNames: [...entities.keys()], entities, config: {}, env: envOf(endpoint) });
115
+ expect(deep.unobserved ?? {}).toEqual({});
116
+ expect(compared(deep.resources.linear!.properties)).toEqual(compared(v1.linear.props));
117
+
118
+ const v2 = { linear: linear("k*x + b + 1") };
119
+ const planned = await planAgainstServer("test", writeBuild("v2.json", v2), { config: {}, env: envOf(endpoint) });
120
+ expect(planned.changes.map((c) => [c.object, c.field, c.rule])).toEqual([[`linear (function ${LINEAR})`, "lambda", "SQLCH260"]]);
121
+ const changed = await apply(endpoint, writeBuild("v2.json", v2), { prune: true });
122
+ expect(changed.sent).toEqual([`CREATE OR REPLACE FUNCTION ${LINEAR} AS (x, k, b) -> k*x + b + 1`]);
123
+ expect(changed.outcome.pruned).toEqual([]);
124
+ expect((await q<{ y: number }>(`SELECT ${LINEAR}(2, 3, 1) AS y`))[0]!.y).toBe(8);
125
+ // The foreign function is never read, so never dropped.
126
+ expect((await q<{ n: number }>(`SELECT count() AS n FROM system.functions WHERE name = '${FOREIGN}'`))[0]!.n).toBe(1);
127
+ }, 300_000);
128
+ });
129
+
130
+ describe.skipIf(!docker)("a function beside a Replicated database (#3682)", () => {
131
+ let cluster: ScratchCluster | undefined;
132
+ beforeAll(async () => {
133
+ cluster = await startScratchCluster(clickhouseImage(), { replicas: 2, namePrefix: "chant-sql-functions-repl" });
134
+ }, 600_000);
135
+ afterAll(async () => {
136
+ await cluster?.stop();
137
+ });
138
+
139
+ test("each replica's apply creates it there, with no ON CLUSTER, and the next apply sends nothing", async () => {
140
+ const [r1, r2] = cluster!.replicas as [ClickHouseEndpoint, ClickHouseEndpoint];
141
+ const shop = database([`CREATE DATABASE ${DB}`] as unknown as TemplateStringsArray);
142
+ const rates = table([`CREATE TABLE ${DB}.rates (code String, rate Float64) ENGINE = MergeTree ORDER BY code`] as unknown as TemplateStringsArray);
143
+ const build = writeBuild("repl.json", { shop, rates, linear: linear("k*x + b") });
144
+ const first = await apply(r1, build, { topology: "replicated" });
145
+ expect(first.outcome.failed).toEqual([]);
146
+ expect(first.sent.join("\n")).not.toMatch(/ON CLUSTER/);
147
+ await q(first.sent[0]!, r2);
148
+ await q(`SYSTEM SYNC DATABASE REPLICA ${DB}`, r2);
149
+ const second = await apply(r2, build, { topology: "replicated" });
150
+ expect(second.sent).toEqual([`CREATE FUNCTION ${LINEAR} AS (x, k, b) -> k*x + b`]);
151
+ expect((await q<{ y: number }>(`SELECT ${LINEAR}(2, 3, 1) AS y`, r2))[0]!.y).toBe(7);
152
+ expect((await apply(r1, build, { topology: "replicated" })).sent).toEqual([]);
153
+ expect((await apply(r2, build, { topology: "replicated" })).sent).toEqual([]);
154
+ }, 300_000);
155
+ });
@@ -109,10 +109,10 @@ describe("describeResources", () => {
109
109
  });
110
110
  });
111
111
 
112
- test("reads the catalog in two queries, whatever the number of entities", async () => {
112
+ test("reads the catalog in three queries, whatever the number of entities", async () => {
113
113
  const before = ok.queries.length;
114
114
  await run(ok.url);
115
- expect(ok.queries.length - before).toBe(2);
115
+ expect(ok.queries.length - before).toBe(3);
116
116
  });
117
117
  });
118
118