@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.
- package/dist/clickhouse/access.d.ts +132 -0
- package/dist/clickhouse/access.d.ts.map +1 -0
- package/dist/clickhouse/apply/apply.d.ts.map +1 -1
- package/dist/clickhouse/apply/statements.d.ts +2 -0
- package/dist/clickhouse/apply/statements.d.ts.map +1 -1
- package/dist/clickhouse/entities.d.ts +171 -2
- package/dist/clickhouse/entities.d.ts.map +1 -1
- package/dist/clickhouse/http.d.ts +3 -0
- package/dist/clickhouse/http.d.ts.map +1 -1
- package/dist/clickhouse/import/generator.d.ts +28 -0
- package/dist/clickhouse/import/generator.d.ts.map +1 -1
- package/dist/clickhouse/import/ir.d.ts.map +1 -1
- package/dist/clickhouse/import/parser.d.ts +3 -3
- package/dist/clickhouse/import/parser.d.ts.map +1 -1
- package/dist/clickhouse/live/bind.d.ts.map +1 -1
- package/dist/clickhouse/live/catalog.d.ts +41 -6
- package/dist/clickhouse/live/catalog.d.ts.map +1 -1
- package/dist/clickhouse/live/describe-resources.d.ts +3 -2
- package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
- package/dist/clickhouse/parser.d.ts +54 -3
- package/dist/clickhouse/parser.d.ts.map +1 -1
- package/dist/clickhouse/plan/commands.d.ts +3 -1
- package/dist/clickhouse/plan/commands.d.ts.map +1 -1
- package/dist/clickhouse/plan/deep.d.ts.map +1 -1
- package/dist/clickhouse/plan/diff.d.ts +12 -0
- package/dist/clickhouse/plan/diff.d.ts.map +1 -1
- package/dist/clickhouse/plan/normalize.d.ts +61 -1
- package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
- package/dist/clickhouse/plan/report.d.ts.map +1 -1
- package/dist/clickhouse/plan/rules.d.ts +7 -0
- package/dist/clickhouse/plan/rules.d.ts.map +1 -1
- package/dist/clickhouse/plan/schema.d.ts +8 -4
- package/dist/clickhouse/plan/schema.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/backfill.d.ts +3 -1
- package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/observe.d.ts +7 -3
- package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/op.d.ts +8 -0
- package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/steps.d.ts +48 -0
- package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
- package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
- package/dist/clickhouse/testing/writable-server.d.ts.map +1 -1
- package/dist/clickhouse/topology.d.ts.map +1 -1
- package/dist/clickhouse.d.ts +1 -1
- package/dist/clickhouse.d.ts.map +1 -1
- package/dist/codegen/classifier-page.d.ts.map +1 -1
- package/dist/codegen/registry.d.ts.map +1 -1
- package/dist/config.d.ts +50 -7
- package/dist/config.d.ts.map +1 -1
- package/dist/core/references.d.ts +4 -2
- package/dist/core/references.d.ts.map +1 -1
- package/dist/core/statements.d.ts +11 -0
- package/dist/core/statements.d.ts.map +1 -1
- package/dist/files/build-root.d.ts +26 -0
- package/dist/files/build-root.d.ts.map +1 -0
- package/dist/files/clickhouse.d.ts +19 -0
- package/dist/files/clickhouse.d.ts.map +1 -0
- package/dist/files/common.d.ts +25 -0
- package/dist/files/common.d.ts.map +1 -0
- package/dist/files/postgres.d.ts +36 -0
- package/dist/files/postgres.d.ts.map +1 -0
- package/dist/import-parser.d.ts +25 -0
- package/dist/import-parser.d.ts.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +6 -6
- package/dist/lint/rules/sqlch001.d.ts +1 -1
- package/dist/lint/rules/sqlch001.d.ts.map +1 -1
- package/dist/lint/rules/sqlch002.d.ts.map +1 -1
- package/dist/lint/rules/templates.d.ts +1 -1
- package/dist/lint/rules/templates.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/meta.json +30 -0
- package/dist/okf/index.md +7 -1
- package/dist/okf/rules/SQLCH001.md +2 -2
- package/dist/okf/types/Dictionary.md +9 -0
- package/dist/okf/types/Function.md +9 -0
- package/dist/okf/types/Grant.md +9 -0
- package/dist/okf/types/Role.md +9 -0
- package/dist/okf/types/RowPolicy.md +9 -0
- package/dist/okf/types/User.md +9 -0
- package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +9 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/postgres/apply/statements.d.ts +68 -0
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/dist/postgres/import/generator.d.ts +33 -0
- package/dist/postgres/import/generator.d.ts.map +1 -1
- package/dist/postgres/live/bind.d.ts.map +1 -1
- package/dist/postgres/live/client.d.ts +3 -0
- package/dist/postgres/live/client.d.ts.map +1 -1
- package/dist/rules/sqlch001.ts +26 -4
- package/dist/rules/sqlch002.ts +3 -2
- package/dist/rules/templates.ts +2 -2
- package/dist/serializer.d.ts +2 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/sql-files.d.ts +79 -0
- package/dist/sql-files.d.ts.map +1 -0
- package/dist/token-source.d.ts +88 -0
- package/dist/token-source.d.ts.map +1 -0
- package/dist/validate.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/access.test.ts +203 -0
- package/src/clickhouse/access.ts +595 -0
- package/src/clickhouse/apply/apply.ts +29 -20
- package/src/clickhouse/apply/statements.ts +105 -8
- package/src/clickhouse/dictionary.test.ts +177 -0
- package/src/clickhouse/entities.ts +356 -6
- package/src/clickhouse/function.test.ts +91 -0
- package/src/clickhouse/http.ts +11 -2
- package/src/clickhouse/import/generator.ts +81 -46
- package/src/clickhouse/import/import.test.ts +9 -3
- package/src/clickhouse/import/ir.ts +6 -2
- package/src/clickhouse/import/parser.ts +10 -18
- package/src/clickhouse/live/access.e2e.test.ts +218 -0
- package/src/clickhouse/live/bind.ts +17 -6
- package/src/clickhouse/live/catalog.ts +106 -10
- package/src/clickhouse/live/describe-resources.ts +23 -10
- package/src/clickhouse/live/dictionaries.e2e.test.ts +186 -0
- package/src/clickhouse/live/functions.e2e.test.ts +155 -0
- package/src/clickhouse/live/live.test.ts +2 -2
- package/src/clickhouse/parser.ts +136 -4
- package/src/clickhouse/plan/commands.ts +18 -7
- package/src/clickhouse/plan/deep.ts +71 -11
- package/src/clickhouse/plan/diff.ts +65 -2
- package/src/clickhouse/plan/normalize.ts +200 -6
- package/src/clickhouse/plan/report.ts +14 -3
- package/src/clickhouse/plan/rules.ts +44 -1
- package/src/clickhouse/plan/schema.ts +40 -17
- package/src/clickhouse/plan/server-format.ts +1 -1
- package/src/clickhouse/rebuild/backfill.ts +5 -25
- package/src/clickhouse/rebuild/observe.ts +7 -3
- package/src/clickhouse/rebuild/op.ts +9 -0
- package/src/clickhouse/rebuild/rebuild.e2e.test.ts +62 -0
- package/src/clickhouse/rebuild/rebuild.test.ts +54 -0
- package/src/clickhouse/rebuild/steps.ts +136 -1
- package/src/clickhouse/testing/fake-server.ts +2 -0
- package/src/clickhouse/testing/writable-server.ts +2 -0
- package/src/clickhouse/topology.ts +17 -2
- package/src/clickhouse.ts +19 -0
- package/src/codegen/classifier-page.ts +2 -0
- package/src/codegen/registry.ts +6 -0
- package/src/config.ts +12 -3
- package/src/core/references.ts +5 -3
- package/src/core/statements.ts +11 -1
- package/src/files/build-root.ts +62 -0
- package/src/files/clickhouse.ts +52 -0
- package/src/files/common.ts +54 -0
- package/src/files/postgres.ts +371 -0
- package/src/generated/lexicon-sql.json +30 -0
- package/src/import-parser.ts +61 -0
- package/src/index.ts +15 -2
- package/src/lint/rules/sqlch001.ts +26 -4
- package/src/lint/rules/sqlch002.ts +3 -2
- package/src/lint/rules/templates.ts +2 -2
- package/src/lsp/template.ts +3 -3
- package/src/mcp/index.ts +6 -6
- package/src/migration-statements.test.ts +25 -0
- package/src/migration-statements.ts +1 -0
- package/src/op/activities/clickhouse-rebuild.ts +1 -0
- package/src/op/activity-contracts.ts +1 -0
- package/src/plugin.ts +29 -8
- package/src/postgres/apply/constraint-sequences.e2e.test.ts +179 -0
- package/src/postgres/apply/safe-sequences.e2e.test.ts +176 -0
- package/src/postgres/apply/statements.test.ts +138 -6
- package/src/postgres/apply/statements.ts +156 -10
- package/src/postgres/import/generator.ts +118 -45
- package/src/postgres/live/bind.ts +17 -6
- package/src/postgres/live/client.ts +10 -2
- package/src/postgres/plan/plan.test.ts +2 -2
- package/src/postgres/plan/rules.ts +5 -5
- package/src/serializer.ts +14 -9
- package/src/skills/chant-sql-postgres-plan.md +3 -3
- package/src/skills/chant-sql-rebuild.md +3 -2
- package/src/skills/chant-sql.md +7 -1
- package/src/sql-files.test.ts +188 -0
- package/src/sql-files.ts +218 -0
- package/src/token-source.e2e.test.ts +142 -0
- package/src/token-source.test.ts +119 -0
- package/src/token-source.ts +189 -0
- 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
|
|
3
|
-
* materialized views in them, from `system.databases`,
|
|
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
|
|
42
|
-
*
|
|
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%'
|
|
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.
|
|
6
|
-
* per declared entity, so N entities are
|
|
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 (
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
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
|
|
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(
|
|
115
|
+
expect(ok.queries.length - before).toBe(3);
|
|
116
116
|
});
|
|
117
117
|
});
|
|
118
118
|
|