@intentius/chant-lexicon-sql 0.116.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 +70 -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 +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +8 -8
- 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/sqlpg001.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/lsp/postgres-template.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/meta.json +50 -0
- package/dist/okf/index.md +11 -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/PostgresDefaultPrivileges.md +9 -0
- package/dist/okf/types/PostgresGrant.md +9 -0
- package/dist/okf/types/PostgresPolicy.md +9 -0
- package/dist/okf/types/PostgresRole.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/activities/postgres-migration.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +19 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/postgres/access/acl.d.ts +119 -0
- package/dist/postgres/access/acl.d.ts.map +1 -0
- package/dist/postgres/access/import.d.ts +13 -0
- package/dist/postgres/access/import.d.ts.map +1 -0
- package/dist/postgres/access/live.d.ts +24 -0
- package/dist/postgres/access/live.d.ts.map +1 -0
- package/dist/postgres/apply/apply.d.ts.map +1 -1
- package/dist/postgres/apply/statements.d.ts +71 -0
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/dist/postgres/entities.d.ts +106 -4
- package/dist/postgres/entities.d.ts.map +1 -1
- package/dist/postgres/entity-types.d.ts +4 -0
- package/dist/postgres/entity-types.d.ts.map +1 -1
- package/dist/postgres/import/generator.d.ts +37 -1
- package/dist/postgres/import/generator.d.ts.map +1 -1
- package/dist/postgres/import/live-export.d.ts +6 -0
- package/dist/postgres/import/live-export.d.ts.map +1 -1
- package/dist/postgres/live/bind.d.ts +2 -0
- package/dist/postgres/live/bind.d.ts.map +1 -1
- package/dist/postgres/live/catalog.d.ts +11 -2
- package/dist/postgres/live/catalog.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/postgres/live/describe-resources.d.ts +9 -0
- package/dist/postgres/live/describe-resources.d.ts.map +1 -1
- package/dist/postgres/migrate/op.d.ts +29 -0
- package/dist/postgres/migrate/op.d.ts.map +1 -1
- package/dist/postgres/parser.d.ts +76 -3
- package/dist/postgres/parser.d.ts.map +1 -1
- package/dist/postgres/plan/commands.d.ts +12 -0
- package/dist/postgres/plan/commands.d.ts.map +1 -1
- package/dist/postgres/plan/deep.d.ts.map +1 -1
- package/dist/postgres/plan/diff.d.ts +10 -1
- package/dist/postgres/plan/diff.d.ts.map +1 -1
- package/dist/postgres/plan/normalize.d.ts +8 -1
- package/dist/postgres/plan/normalize.d.ts.map +1 -1
- package/dist/postgres/plan/rules.d.ts +8 -0
- package/dist/postgres/plan/rules.d.ts.map +1 -1
- package/dist/postgres/plan/schema.d.ts +2 -1
- package/dist/postgres/plan/schema.d.ts.map +1 -1
- package/dist/postgres/plan/server-normalize.d.ts +4 -1
- package/dist/postgres/plan/server-normalize.d.ts.map +1 -1
- package/dist/postgres.d.ts +1 -1
- package/dist/postgres.d.ts.map +1 -1
- package/dist/rules/postgres-templates.ts +2 -2
- package/dist/rules/sqlch001.ts +26 -4
- package/dist/rules/sqlch002.ts +3 -2
- package/dist/rules/sqlpg001.ts +7 -4
- 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 +10 -0
- package/src/config.ts +22 -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 +50 -0
- package/src/import-parser.ts +61 -0
- package/src/index.ts +14 -1
- package/src/lint/rules/postgres-templates.ts +2 -2
- package/src/lint/rules/sqlch001.ts +26 -4
- package/src/lint/rules/sqlch002.ts +3 -2
- package/src/lint/rules/sqlpg001.ts +7 -4
- package/src/lint/rules/templates.ts +2 -2
- package/src/lsp/postgres-template.ts +2 -0
- package/src/lsp/template.ts +3 -3
- package/src/mcp/index.ts +6 -6
- package/src/mcp/postgres.ts +1 -1
- package/src/migration-statements.test.ts +25 -0
- package/src/migration-statements.ts +8 -0
- package/src/op/activities/clickhouse-rebuild.ts +1 -0
- package/src/op/activities/postgres-migration.ts +27 -2
- package/src/op/activity-contracts.ts +2 -0
- package/src/plugin.ts +29 -5
- package/src/postgres/access/access.e2e.test.ts +191 -0
- package/src/postgres/access/access.test.ts +147 -0
- package/src/postgres/access/acl.ts +400 -0
- package/src/postgres/access/import.ts +64 -0
- package/src/postgres/access/live.ts +123 -0
- package/src/postgres/apply/apply.ts +117 -4
- 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 +210 -18
- package/src/postgres/entities.ts +287 -11
- package/src/postgres/entity-types.ts +4 -0
- package/src/postgres/import/generator.ts +129 -46
- package/src/postgres/import/live-export.ts +12 -2
- package/src/postgres/live/bind.ts +20 -6
- package/src/postgres/live/catalog.ts +79 -3
- package/src/postgres/live/client.ts +10 -2
- package/src/postgres/live/describe-resources.ts +27 -3
- package/src/postgres/migrate/migrate.test.ts +24 -1
- package/src/postgres/migrate/op.ts +80 -35
- package/src/postgres/migrate/outer.e2e.test.ts +163 -0
- package/src/postgres/parser.ts +247 -8
- package/src/postgres/plan/commands.ts +60 -7
- package/src/postgres/plan/deep.ts +24 -3
- package/src/postgres/plan/diff.ts +71 -6
- package/src/postgres/plan/normalize.ts +115 -2
- package/src/postgres/plan/plan.test.ts +2 -2
- package/src/postgres/plan/rules.ts +49 -5
- package/src/postgres/plan/schema.ts +4 -2
- package/src/postgres/plan/server-normalize.ts +29 -1
- package/src/postgres.ts +11 -0
- package/src/serializer.ts +14 -9
- package/src/skill-defs.ts +1 -1
- package/src/skills/chant-sql-postgres-migration.md +3 -1
- package/src/skills/chant-sql-postgres-plan.md +3 -3
- package/src/skills/chant-sql-postgres.md +9 -2
- 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 +10 -0
|
@@ -12,19 +12,29 @@ import { isTrivia, tokenizeText, type Token } from "../tokens";
|
|
|
12
12
|
import { parseCreate, unquote, type CreateNode } from "../parser";
|
|
13
13
|
import { CLICKHOUSE_ENTITY_TYPES } from "../entities";
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
/** One object a template is written for, or one a template may reference. */
|
|
16
|
+
export interface ChTemplateItem {
|
|
16
17
|
exportName: string;
|
|
17
18
|
type: string;
|
|
18
19
|
database?: string;
|
|
19
20
|
name: string;
|
|
20
21
|
ddl: string;
|
|
21
22
|
}
|
|
23
|
+
type Item = ChTemplateItem;
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
/** A template's text: `parts` (template-literal safe) around one interpolation per entry of `refs`, an export name. */
|
|
26
|
+
export interface TemplateBody {
|
|
27
|
+
parts: string[];
|
|
28
|
+
refs: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const CLICKHOUSE_TAG_OF: Readonly<Record<string, "database" | "table" | "view" | "dictionary" | "func">> = {
|
|
24
32
|
[CLICKHOUSE_ENTITY_TYPES.database]: "database",
|
|
25
33
|
[CLICKHOUSE_ENTITY_TYPES.table]: "table",
|
|
26
34
|
[CLICKHOUSE_ENTITY_TYPES.view]: "view",
|
|
27
35
|
[CLICKHOUSE_ENTITY_TYPES.materializedView]: "view",
|
|
36
|
+
[CLICKHOUSE_ENTITY_TYPES.dictionary]: "dictionary",
|
|
37
|
+
[CLICKHOUSE_ENTITY_TYPES.function]: "func",
|
|
28
38
|
};
|
|
29
39
|
|
|
30
40
|
/** A backquoted or double-quoted identifier, written bare when it can be and double-quoted otherwise. */
|
|
@@ -37,12 +47,16 @@ function identifierText(t: Token): string {
|
|
|
37
47
|
/** Text that sits inside a template literal: a backquote and `${` escaped. */
|
|
38
48
|
const templateSafe = (s: string) => s.replace(/\\(?=`|\$\{)/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
|
|
39
49
|
|
|
50
|
+
/** Words after which a bare name is a relation: `FROM t`, `JOIN t`, a materialized view's `TO t`. */
|
|
51
|
+
const RELATION_WORDS = new Set(["FROM", "JOIN", "TO"]);
|
|
52
|
+
|
|
40
53
|
/**
|
|
41
54
|
* The template body for one statement, and the exports it references. A
|
|
42
|
-
* qualified name of another
|
|
43
|
-
*
|
|
55
|
+
* qualified name of another object becomes `${export}`, and so does a bare
|
|
56
|
+
* name after `FROM`, `JOIN` or `TO` that an unqualified object has; the
|
|
57
|
+
* database part of the statement's own name becomes the database's export.
|
|
44
58
|
*/
|
|
45
|
-
function templateBody(item: Item, byQualified: Map<string, Item>, dbExport: Map<string, string>):
|
|
59
|
+
function templateBody(item: Item, byQualified: Map<string, Item>, dbExport: Map<string, string>): TemplateBody {
|
|
46
60
|
const tokens = tokenizeText(item.ddl, 0);
|
|
47
61
|
let node: CreateNode | undefined;
|
|
48
62
|
try {
|
|
@@ -56,41 +70,86 @@ function templateBody(item: Item, byQualified: Map<string, Item>, dbExport: Map<
|
|
|
56
70
|
if (!isTrivia(t)) sig.push(i);
|
|
57
71
|
});
|
|
58
72
|
const isName = (t: Token | undefined) => t !== undefined && (t.kind === "ident" || t.kind === "qident");
|
|
59
|
-
const
|
|
60
|
-
|
|
73
|
+
const isSelf = (target: Item) => target === item || target.exportName === item.exportName;
|
|
74
|
+
const replace = new Map<number, { to: number; ref: string }>();
|
|
75
|
+
for (let k = 0; k < sig.length; k++) {
|
|
61
76
|
const a = tokens[sig[k]!]!;
|
|
62
|
-
const dot = tokens[sig[k + 1]
|
|
63
|
-
const b = tokens[sig[k + 2]
|
|
64
|
-
if (!isName(a) || dot.kind !== "punct" || dot.text !== "." || !isName(b)) continue;
|
|
77
|
+
const dot = tokens[sig[k + 1] ?? -1];
|
|
78
|
+
const b = tokens[sig[k + 2] ?? -1];
|
|
65
79
|
const prev = tokens[sig[k - 1] ?? -1];
|
|
66
80
|
if (prev?.kind === "punct" && prev.text === ".") continue;
|
|
81
|
+
if (!isName(a) || dot?.kind !== "punct" || dot.text !== "." || !isName(b)) {
|
|
82
|
+
const inOwnName = own !== undefined && sig[k]! >= own.from && sig[k]! < own.to;
|
|
83
|
+
const bare = isName(a) && !inOwnName && prev?.kind === "ident" && RELATION_WORDS.has(prev.text.toUpperCase());
|
|
84
|
+
const target = bare ? byQualified.get(unquote(a.text)) : undefined;
|
|
85
|
+
if (target && !isSelf(target)) replace.set(sig[k]!, { to: sig[k]! + 1, ref: target.exportName });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
67
88
|
const db = unquote(a.text);
|
|
68
|
-
const name = unquote(b
|
|
89
|
+
const name = unquote(b!.text);
|
|
69
90
|
const inOwnName = own !== undefined && sig[k]! >= own.from && sig[k]! < own.to;
|
|
70
91
|
const target = byQualified.get(`${db}.${name}`);
|
|
71
|
-
if (!inOwnName && target && target
|
|
72
|
-
replace.set(sig[k]!, { to: sig[k + 2]! + 1,
|
|
92
|
+
if (!inOwnName && target && !isSelf(target)) {
|
|
93
|
+
replace.set(sig[k]!, { to: sig[k + 2]! + 1, ref: target.exportName });
|
|
73
94
|
k += 2;
|
|
74
95
|
} else if (inOwnName && dbExport.has(db)) {
|
|
75
|
-
replace.set(sig[k]!, { to: sig[k]! + 1,
|
|
96
|
+
replace.set(sig[k]!, { to: sig[k]! + 1, ref: dbExport.get(db)! });
|
|
76
97
|
k += 2;
|
|
77
98
|
}
|
|
78
99
|
}
|
|
79
|
-
|
|
100
|
+
const parts: string[] = [""];
|
|
80
101
|
const refs: string[] = [];
|
|
81
102
|
for (let i = 0; i < tokens.length; ) {
|
|
82
103
|
const r = replace.get(i);
|
|
83
104
|
if (r) {
|
|
84
|
-
body += r.text;
|
|
85
105
|
refs.push(r.ref);
|
|
106
|
+
parts.push("");
|
|
86
107
|
i = r.to;
|
|
87
108
|
continue;
|
|
88
109
|
}
|
|
89
110
|
const t = tokens[i]!;
|
|
90
|
-
|
|
111
|
+
parts[parts.length - 1] += t.kind === "qident" ? templateSafe(identifierText(t)) : templateSafe(t.text);
|
|
91
112
|
i++;
|
|
92
113
|
}
|
|
93
|
-
return {
|
|
114
|
+
return { parts, refs };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A template body as the text between the backquotes. */
|
|
118
|
+
export const bodyText = (body: TemplateBody): string => body.parts.map((p, i) => (i === 0 ? p : `\${${body.refs[i - 1]}}${p}`)).join("");
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The template of each object in `items`, with the references to `targets`
|
|
122
|
+
* (the items themselves, and any object declared elsewhere that they may
|
|
123
|
+
* name) interpolated, and the items in the order a file declares them: each
|
|
124
|
+
* after what it references.
|
|
125
|
+
*/
|
|
126
|
+
export function clickhouseTemplates(items: readonly Item[], targets: readonly Item[] = items): { order: Item[]; bodies: Map<string, TemplateBody> } {
|
|
127
|
+
const byQualified = new Map<string, Item>();
|
|
128
|
+
const dbExport = new Map<string, string>();
|
|
129
|
+
for (const it of targets) {
|
|
130
|
+
if (it.type === CLICKHOUSE_ENTITY_TYPES.database) dbExport.set(it.name, it.exportName);
|
|
131
|
+
// A function is called, never named after FROM, JOIN or TO.
|
|
132
|
+
else if (it.type !== CLICKHOUSE_ENTITY_TYPES.function) byQualified.set(it.database ? `${it.database}.${it.name}` : it.name, it);
|
|
133
|
+
}
|
|
134
|
+
const bodies = new Map(items.map((it) => [it.exportName, templateBody(it, byQualified, dbExport)]));
|
|
135
|
+
// Declarations reference each other in one file, so each comes after what it references.
|
|
136
|
+
const order: Item[] = [];
|
|
137
|
+
const done = new Set<string>();
|
|
138
|
+
const visiting = new Set<string>();
|
|
139
|
+
const byExport = new Map(items.map((it) => [it.exportName, it]));
|
|
140
|
+
const visit = (it: Item) => {
|
|
141
|
+
if (done.has(it.exportName) || visiting.has(it.exportName)) return;
|
|
142
|
+
visiting.add(it.exportName);
|
|
143
|
+
for (const ref of bodies.get(it.exportName)!.refs) {
|
|
144
|
+
const dep = byExport.get(ref);
|
|
145
|
+
if (dep) visit(dep);
|
|
146
|
+
}
|
|
147
|
+
visiting.delete(it.exportName);
|
|
148
|
+
done.add(it.exportName);
|
|
149
|
+
order.push(it);
|
|
150
|
+
};
|
|
151
|
+
for (const it of items) visit(it);
|
|
152
|
+
return { order, bodies };
|
|
94
153
|
}
|
|
95
154
|
|
|
96
155
|
function indent(body: string): string {
|
|
@@ -105,7 +164,7 @@ export class ClickHouseGenerator implements TypeScriptGenerator {
|
|
|
105
164
|
|
|
106
165
|
generate(ir: TemplateIR): GeneratedFile[] {
|
|
107
166
|
const items: Item[] = ir.resources
|
|
108
|
-
.filter((r) =>
|
|
167
|
+
.filter((r) => CLICKHOUSE_TAG_OF[r.type])
|
|
109
168
|
.map((r) => ({
|
|
110
169
|
exportName: r.logicalId,
|
|
111
170
|
type: r.type,
|
|
@@ -115,38 +174,14 @@ export class ClickHouseGenerator implements TypeScriptGenerator {
|
|
|
115
174
|
}));
|
|
116
175
|
if (items.length === 0) return [];
|
|
117
176
|
|
|
118
|
-
const
|
|
119
|
-
const dbExport = new Map<string, string>();
|
|
120
|
-
for (const it of items) {
|
|
121
|
-
if (it.type === CLICKHOUSE_ENTITY_TYPES.database) dbExport.set(it.name, it.exportName);
|
|
122
|
-
else if (it.database) byQualified.set(`${it.database}.${it.name}`, it);
|
|
123
|
-
}
|
|
177
|
+
const { order, bodies } = clickhouseTemplates(items);
|
|
124
178
|
|
|
125
|
-
const
|
|
126
|
-
// Declarations reference each other in one file, so each comes after what it references.
|
|
127
|
-
const order: Item[] = [];
|
|
128
|
-
const done = new Set<string>();
|
|
129
|
-
const visiting = new Set<string>();
|
|
130
|
-
const byExport = new Map(items.map((it) => [it.exportName, it]));
|
|
131
|
-
const visit = (it: Item) => {
|
|
132
|
-
if (done.has(it.exportName) || visiting.has(it.exportName)) return;
|
|
133
|
-
visiting.add(it.exportName);
|
|
134
|
-
for (const ref of bodies.get(it.exportName)!.refs) {
|
|
135
|
-
const dep = byExport.get(ref);
|
|
136
|
-
if (dep) visit(dep);
|
|
137
|
-
}
|
|
138
|
-
visiting.delete(it.exportName);
|
|
139
|
-
done.add(it.exportName);
|
|
140
|
-
order.push(it);
|
|
141
|
-
};
|
|
142
|
-
for (const it of items) visit(it);
|
|
143
|
-
|
|
144
|
-
const tags = [...new Set(order.map((it) => TAG[it.type]!))].sort();
|
|
179
|
+
const tags = [...new Set(order.map((it) => CLICKHOUSE_TAG_OF[it.type]!))].sort();
|
|
145
180
|
const lines: string[] = [];
|
|
146
181
|
if (this.header) lines.push(...this.header.split("\n").map((l) => `// ${l}`), "");
|
|
147
182
|
lines.push(`import { ${tags.join(", ")} } from "@intentius/chant-lexicon-sql/clickhouse";`, "");
|
|
148
183
|
for (const it of order) {
|
|
149
|
-
lines.push(`export const ${it.exportName} = ${
|
|
184
|
+
lines.push(`export const ${it.exportName} = ${CLICKHOUSE_TAG_OF[it.type]}\`\n ${indent(bodyText(bodies.get(it.exportName)!))}\`;`, "");
|
|
150
185
|
}
|
|
151
186
|
return [{ path: "schema.ts", content: `${lines.join("\n").trimEnd()}\n` }];
|
|
152
187
|
}
|
|
@@ -38,15 +38,21 @@ describe("reading a file of statements", () => {
|
|
|
38
38
|
]);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
test("each statement becomes one resource of its type
|
|
42
|
-
const ir = new ClickHouseSqlParser().parse(
|
|
41
|
+
test("each statement becomes one resource of its type", () => {
|
|
42
|
+
const ir = new ClickHouseSqlParser().parse(SCHEMA);
|
|
43
43
|
expect(ir.resources.map((r) => [r.logicalId, r.type])).toEqual([
|
|
44
44
|
["analyticsDb", "ClickHouse::Database"],
|
|
45
45
|
["events", "ClickHouse::Table"],
|
|
46
46
|
["counts", "ClickHouse::MaterializedView"],
|
|
47
47
|
["daily", "ClickHouse::Table"],
|
|
48
48
|
]);
|
|
49
|
-
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a statement that does not parse or is not a CREATE stops the import, each named", () => {
|
|
52
|
+
const parse = () => new ClickHouseSqlParser().parse(`${SCHEMA}\nCREATE TABLE broken (a Strin g) ENGINE = Log;\nINSERT INTO t VALUES (1);`);
|
|
53
|
+
expect(parse).toThrow(/2 statements chant cannot read as declarations/);
|
|
54
|
+
expect(parse).toThrow(/does not parse \(.*\): CREATE TABLE broken/);
|
|
55
|
+
expect(parse).toThrow(/not a CREATE statement: INSERT INTO t VALUES \(1\)/);
|
|
50
56
|
});
|
|
51
57
|
});
|
|
52
58
|
|
|
@@ -96,7 +96,11 @@ const statementType = (node: CreateNode): ClickHouseEntityType =>
|
|
|
96
96
|
? CLICKHOUSE_ENTITY_TYPES.database
|
|
97
97
|
: node.statement === "table"
|
|
98
98
|
? CLICKHOUSE_ENTITY_TYPES.table
|
|
99
|
-
: node.
|
|
99
|
+
: node.statement === "dictionary"
|
|
100
|
+
? CLICKHOUSE_ENTITY_TYPES.dictionary
|
|
101
|
+
: node.statement === "function"
|
|
102
|
+
? CLICKHOUSE_ENTITY_TYPES.function
|
|
103
|
+
: node.materialized
|
|
100
104
|
? CLICKHOUSE_ENTITY_TYPES.materializedView
|
|
101
105
|
: CLICKHOUSE_ENTITY_TYPES.view;
|
|
102
106
|
|
|
@@ -130,7 +134,7 @@ export function stripServerDefaults(ddl: string): string {
|
|
|
130
134
|
return ddl;
|
|
131
135
|
}
|
|
132
136
|
const edits: Array<{ span: Span; text: string }> = [];
|
|
133
|
-
if (node.statement
|
|
137
|
+
if ((node.statement === "table" || node.statement === "view") && node.settings && node.settingsClause) {
|
|
134
138
|
const text = (s: Span) => tokens.slice(s.from, s.to).map((t) => t.text).join("").trim();
|
|
135
139
|
const kept = node.settings.filter((s) => {
|
|
136
140
|
const def = (MERGE_TREE_SETTINGS as Record<string, { default: string } | undefined>)[s.key];
|
|
@@ -1,27 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `chant import schema.sql
|
|
3
|
-
* import IR.
|
|
4
|
-
*
|
|
2
|
+
* `chant import schema.sql` for ClickHouse: a file of CREATE statements into
|
|
3
|
+
* the import IR. A statement that is not a CREATE, or does not parse, is an
|
|
4
|
+
* error naming it (`../../files/clickhouse.ts`); none is left out.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { TemplateIR, TemplateParser } from "@intentius/chant/import/parser";
|
|
8
|
-
import {
|
|
8
|
+
import { objectsToIR } from "./ir";
|
|
9
|
+
import { readClickHouse } from "../../files/clickhouse";
|
|
10
|
+
import { SqlFileError } from "../../files/common";
|
|
9
11
|
|
|
10
12
|
export class ClickHouseSqlParser implements TemplateParser {
|
|
11
13
|
parse(content: string): TemplateIR {
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
warnings.push(`not a CREATE statement, left out: ${stmt.split("\n")[0]!.slice(0, 80)}`);
|
|
17
|
-
continue;
|
|
18
|
-
}
|
|
19
|
-
try {
|
|
20
|
-
objects.push(describeStatement(stmt));
|
|
21
|
-
} catch (err) {
|
|
22
|
-
warnings.push(`does not parse, left out (${(err as Error).message}): ${stmt.split("\n")[0]!.slice(0, 80)}`);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
return objectsToIR(objects, warnings);
|
|
14
|
+
const problems: string[] = [];
|
|
15
|
+
const objects = readClickHouse(content, { origin: "the DDL" }, problems);
|
|
16
|
+
if (problems.length > 0) throw new SqlFileError("the DDL", problems);
|
|
17
|
+
return objectsToIR(objects);
|
|
26
18
|
}
|
|
27
19
|
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access control against a server (#3682): a role, a user, a row policy and
|
|
3
|
+
* grants.
|
|
4
|
+
*
|
|
5
|
+
* On a single node:
|
|
6
|
+
*
|
|
7
|
+
* - a user declared with a password is the environment's to create, and is
|
|
8
|
+
* not attempted until it exists; once it does, the apply makes the rest
|
|
9
|
+
* of it as declared and leaves its password alone;
|
|
10
|
+
* - the role, the row policy and the grants are created; the plan is then
|
|
11
|
+
* clean and the deep read reports no drift, although the server prints
|
|
12
|
+
* the policy's condition and the grants its own way;
|
|
13
|
+
* - a privilege granted by hand to a declared grantee is SQLCH274 and is
|
|
14
|
+
* revoked, a declared one revoked by hand is SQLCH273 and is granted
|
|
15
|
+
* again, a changed role setting is SQLCH270, a changed policy SQLCH272;
|
|
16
|
+
* - the row policy filters rows for the user.
|
|
17
|
+
*
|
|
18
|
+
* Beside a Replicated database (two replicas of the pinned server in
|
|
19
|
+
* Docker): access control is in no database, so the database's log does not
|
|
20
|
+
* carry it; with no cluster named, each replica's apply makes it there.
|
|
21
|
+
*
|
|
22
|
+
* The single node: `CLICKHOUSE_URL` (with `CLICKHOUSE_USER` and
|
|
23
|
+
* `CLICKHOUSE_PASSWORD`) names a running one, such as the one
|
|
24
|
+
* `chant emulator up --lexicon sql` starts. Without it, a throwaway server
|
|
25
|
+
* at the pin, skipped cleanly when Docker is not available. Users and roles
|
|
26
|
+
* are global to the server, so the test names everything `chant_e2e_3682_access*`
|
|
27
|
+
* and drops it afterwards.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { tmpdir } from "node:os";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
34
|
+
import { dockerAvailable, startScratchCluster, startScratchServer, type ScratchCluster, type ScratchServer } from "../container";
|
|
35
|
+
import { clickhouseQuery, type ClickHouseEndpoint } from "../http";
|
|
36
|
+
import { clickhouseImage } from "../../spec/pin";
|
|
37
|
+
import { database, grant, policy, role, table, user } from "../entities";
|
|
38
|
+
import { planAgainstServer } from "../plan/commands";
|
|
39
|
+
import { observeResourcesDeep, sqlDeepNormalizationHooks } from "../plan/deep";
|
|
40
|
+
import { clickhouseApply } from "../../op/activities/clickhouse-apply";
|
|
41
|
+
|
|
42
|
+
const DB = "chant_e2e_3682_access";
|
|
43
|
+
const READER = `${DB}_reader`;
|
|
44
|
+
const APP = `${DB}_app`;
|
|
45
|
+
const fromEnv = process.env.CLICKHOUSE_URL;
|
|
46
|
+
const docker = await dockerAvailable();
|
|
47
|
+
const enabled = fromEnv !== undefined || docker;
|
|
48
|
+
let server: ScratchServer | undefined;
|
|
49
|
+
let endpoint: ClickHouseEndpoint;
|
|
50
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-sql-access-"));
|
|
51
|
+
const q = <T = Record<string, unknown>>(sql: string, at: ClickHouseEndpoint = endpoint) => clickhouseQuery<T>(at, sql);
|
|
52
|
+
const cleanup = async (at: ClickHouseEndpoint) => {
|
|
53
|
+
for (const sql of [`DROP ROW POLICY IF EXISTS ${DB}_tenant ON ${DB}.events`, `DROP USER IF EXISTS ${APP}`, `DROP ROLE IF EXISTS ${READER}`, `DROP DATABASE IF EXISTS ${DB} SYNC`]) {
|
|
54
|
+
await q(sql, at).catch(() => undefined);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
beforeAll(async () => {
|
|
59
|
+
if (!enabled) return;
|
|
60
|
+
if (fromEnv !== undefined) {
|
|
61
|
+
endpoint = {
|
|
62
|
+
url: fromEnv,
|
|
63
|
+
...(process.env.CLICKHOUSE_USER ? { user: process.env.CLICKHOUSE_USER } : {}),
|
|
64
|
+
...(process.env.CLICKHOUSE_PASSWORD ? { password: process.env.CLICKHOUSE_PASSWORD } : {}),
|
|
65
|
+
};
|
|
66
|
+
} else {
|
|
67
|
+
server = await startScratchServer(clickhouseImage(), { namePrefix: "chant-sql-access" });
|
|
68
|
+
endpoint = server.endpoint;
|
|
69
|
+
}
|
|
70
|
+
await cleanup(endpoint);
|
|
71
|
+
}, 600_000);
|
|
72
|
+
|
|
73
|
+
afterAll(async () => {
|
|
74
|
+
if (enabled) await cleanup(endpoint);
|
|
75
|
+
await server?.stop();
|
|
76
|
+
rmSync(dir, { recursive: true, force: true });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const envOf = (at: ClickHouseEndpoint, topology?: string) => ({
|
|
80
|
+
CLICKHOUSE_URL: at.url,
|
|
81
|
+
...(at.user ? { CLICKHOUSE_USER: at.user } : {}),
|
|
82
|
+
...(at.password ? { CLICKHOUSE_PASSWORD: at.password } : {}),
|
|
83
|
+
...(topology ? { CLICKHOUSE_TOPOLOGY: topology } : {}),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
type Entity = { entityType: string; props: { ddl: string }; dependsOn?: unknown };
|
|
87
|
+
function writeBuild(file: string, declared: Record<string, Entity>): string {
|
|
88
|
+
const path = join(dir, file);
|
|
89
|
+
const objects = Object.entries(declared).map(([k, e]) => ({ export: k, type: e.entityType, ddl: e.props.ddl, dependsOn: [] }));
|
|
90
|
+
writeFileSync(path, JSON.stringify({ dialect: "clickhouse", applyOrder: objects.map((o) => o.export), objects }));
|
|
91
|
+
return path;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function apply(at: ClickHouseEndpoint, buildPath: string, topology?: string) {
|
|
95
|
+
const sent: string[] = [];
|
|
96
|
+
const outcome = await clickhouseApply({ buildPath, environment: "test" }, undefined, {
|
|
97
|
+
config: { ownership: { stack: "e2e3682", env: "test" } },
|
|
98
|
+
env: envOf(at, topology),
|
|
99
|
+
log: (l) => void (/^[A-Z]+ /.test(l) && sent.push(l)),
|
|
100
|
+
});
|
|
101
|
+
return { outcome, sent };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const compared = (props: object): Record<string, unknown> =>
|
|
105
|
+
Object.fromEntries(Object.entries(JSON.parse(JSON.stringify(props)) as Record<string, unknown>).filter(([k]) => !sqlDeepNormalizationHooks.prune!({ pattern: k } as never)));
|
|
106
|
+
|
|
107
|
+
function declarations(opts: { memory?: string; tenant?: string } = {}) {
|
|
108
|
+
const shop = database([`CREATE DATABASE ${DB}`] as unknown as TemplateStringsArray);
|
|
109
|
+
const events = table([`CREATE TABLE ${DB}.events (id UInt64, tenant String) ENGINE = MergeTree ORDER BY id`] as unknown as TemplateStringsArray);
|
|
110
|
+
const reader = role([`CREATE ROLE ${READER} SETTINGS max_memory_usage = ${opts.memory ?? "1000000000"}`] as unknown as TemplateStringsArray);
|
|
111
|
+
const app = user([`CREATE USER ${APP} HOST ANY DEFAULT ROLE ${READER} DEFAULT DATABASE ${DB}`] as unknown as TemplateStringsArray);
|
|
112
|
+
const tenant = policy([`CREATE ROW POLICY ${DB}_tenant ON ${DB}.events USING tenant='${opts.tenant ?? "a"}' AND id+0>0 TO ${READER}`] as unknown as TemplateStringsArray);
|
|
113
|
+
const readEvents = grant([`GRANT SELECT(id, tenant) ON ${DB}.events TO ${READER}`] as unknown as TemplateStringsArray);
|
|
114
|
+
const readerToApp = grant([`GRANT ${READER} TO ${APP}`] as unknown as TemplateStringsArray);
|
|
115
|
+
return { shop, events, reader, app, tenant, readEvents, readerToApp };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe.skipIf(!enabled)("access control on a single node (#3682)", () => {
|
|
119
|
+
test("a user with a password waits for the environment; the rest is created, planned clean, kept as declared", async () => {
|
|
120
|
+
const v1 = declarations();
|
|
121
|
+
const build = writeBuild("v1.json", v1);
|
|
122
|
+
const first = await apply(endpoint, build);
|
|
123
|
+
expect(first.outcome.failed).toEqual([]);
|
|
124
|
+
expect(first.outcome.notAttempted).toEqual([
|
|
125
|
+
expect.objectContaining({ name: `user ${APP}`, reason: "filtered", detail: expect.stringMatching(/password is the environment's/) }),
|
|
126
|
+
expect.objectContaining({ name: `grants ${APP}`, reason: "dependency-failed" }),
|
|
127
|
+
]);
|
|
128
|
+
expect(first.outcome.applied.map((a) => [a.name, a.action])).toEqual([
|
|
129
|
+
[DB, "created"],
|
|
130
|
+
[`${DB}.events`, "created"],
|
|
131
|
+
[`role ${READER}`, "created"],
|
|
132
|
+
[`row policy ${DB}_tenant ON ${DB}.events`, "created"],
|
|
133
|
+
[`grants ${READER}`, "created"],
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
// The environment creates the user with its password; the next apply makes the rest of it as declared.
|
|
137
|
+
await q(`CREATE USER ${APP} IDENTIFIED WITH sha256_password BY 'chant-e2e'`);
|
|
138
|
+
const second = await apply(endpoint, build);
|
|
139
|
+
expect(second.outcome.failed).toEqual([]);
|
|
140
|
+
expect(second.sent).toEqual([
|
|
141
|
+
`ALTER USER \`${APP}\` DEFAULT DATABASE ${DB}`,
|
|
142
|
+
`GRANT \`${READER}\` TO \`${APP}\``,
|
|
143
|
+
// A default role must be held first.
|
|
144
|
+
`ALTER USER \`${APP}\` DEFAULT ROLE ${READER}`,
|
|
145
|
+
]);
|
|
146
|
+
expect((await q<{ s: string }>(`SELECT auth_type[1] AS s FROM system.users WHERE name = '${APP}'`))[0]!.s).toBe("sha256_password");
|
|
147
|
+
|
|
148
|
+
// The server prints the policy's condition and the grants its own way: still the declarations.
|
|
149
|
+
const diff = await planAgainstServer("test", build, { config: {}, env: envOf(endpoint) });
|
|
150
|
+
expect(diff.changes).toEqual([]);
|
|
151
|
+
const entities = new Map(Object.entries(v1).map(([k, e]) => [k, { entityType: e.entityType, props: e.props as unknown as Record<string, unknown> }]));
|
|
152
|
+
const deep = await observeResourcesDeep({ environment: "test", entityNames: [...entities.keys()], entities, config: {}, env: envOf(endpoint) });
|
|
153
|
+
expect(Object.keys(deep.unobserved ?? {}).sort()).toEqual(["readEvents", "readerToApp"]);
|
|
154
|
+
for (const k of ["reader", "app", "tenant"] as const) expect(compared(deep.resources[k]!.properties), k).toEqual(compared(v1[k].props));
|
|
155
|
+
|
|
156
|
+
// The policy filters rows for the user.
|
|
157
|
+
await q(`INSERT INTO ${DB}.events VALUES (1, 'a'), (2, 'b'), (3, 'a')`);
|
|
158
|
+
const asApp = { ...endpoint, user: APP, password: "chant-e2e" };
|
|
159
|
+
expect(Number((await q<{ n: string }>(`SELECT count() AS n FROM ${DB}.events`, asApp))[0]!.n)).toBe(2);
|
|
160
|
+
|
|
161
|
+
// Changed by hand: a privilege granted, a declared one revoked. Changed in the declaration: a setting and the condition.
|
|
162
|
+
await q(`GRANT INSERT ON ${DB}.events TO ${READER}`);
|
|
163
|
+
await q(`REVOKE ${READER} FROM ${APP}`);
|
|
164
|
+
const v2 = declarations({ memory: "2000000000", tenant: "b" });
|
|
165
|
+
const planned = await planAgainstServer("test", writeBuild("v2.json", v2), { config: {}, env: envOf(endpoint) });
|
|
166
|
+
expect(planned.changes.map((c) => [c.object, c.field, c.rule])).toEqual([
|
|
167
|
+
[`reader (role ${READER})`, "settings", "SQLCH270"],
|
|
168
|
+
// Revoking the role took it off the user's default roles too.
|
|
169
|
+
[`app (user ${APP})`, "defaultRole", "SQLCH271"],
|
|
170
|
+
[`tenant (row policy ${DB}_tenant ON ${DB}.events)`, "using", "SQLCH272"],
|
|
171
|
+
[`grants ${READER}`, `grants.INSERT ON ${DB}.events`, "SQLCH274"],
|
|
172
|
+
[`grants ${APP}`, `grants.ROLE ${READER}`, "SQLCH273"],
|
|
173
|
+
]);
|
|
174
|
+
const third = await apply(endpoint, writeBuild("v2.json", v2));
|
|
175
|
+
expect(third.outcome.failed).toEqual([]);
|
|
176
|
+
expect(third.sent).toEqual([
|
|
177
|
+
`ALTER ROLE \`${READER}\` SETTINGS max_memory_usage = 2000000000`,
|
|
178
|
+
`CREATE ROW POLICY OR REPLACE ${DB}_tenant ON ${DB}.events USING tenant='b' AND id+0>0 TO ${READER}`,
|
|
179
|
+
`REVOKE INSERT ON \`${DB}\`.\`events\` FROM \`${READER}\``,
|
|
180
|
+
`GRANT \`${READER}\` TO \`${APP}\``,
|
|
181
|
+
`ALTER USER \`${APP}\` DEFAULT ROLE ${READER}`,
|
|
182
|
+
]);
|
|
183
|
+
expect((await planAgainstServer("test", writeBuild("v2.json", v2), { config: {}, env: envOf(endpoint) })).changes).toEqual([]);
|
|
184
|
+
expect(Number((await q<{ n: string }>(`SELECT count() AS n FROM ${DB}.events`, asApp))[0]!.n)).toBe(1);
|
|
185
|
+
}, 300_000);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe.skipIf(!docker)("access control beside a Replicated database (#3682)", () => {
|
|
189
|
+
let cluster: ScratchCluster | undefined;
|
|
190
|
+
beforeAll(async () => {
|
|
191
|
+
cluster = await startScratchCluster(clickhouseImage(), { replicas: 2, namePrefix: "chant-sql-access-repl" });
|
|
192
|
+
}, 600_000);
|
|
193
|
+
afterAll(async () => {
|
|
194
|
+
await cluster?.stop();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("each replica's apply makes it there, with no ON CLUSTER, and the next apply sends nothing", async () => {
|
|
198
|
+
const [r1, r2] = cluster!.replicas as [ClickHouseEndpoint, ClickHouseEndpoint];
|
|
199
|
+
const { shop, events, reader, tenant, readEvents } = declarations();
|
|
200
|
+
const build = writeBuild("repl.json", { shop, events, reader, tenant, readEvents });
|
|
201
|
+
const first = await apply(r1, build, "replicated");
|
|
202
|
+
expect(first.outcome.failed).toEqual([]);
|
|
203
|
+
expect(first.sent.join("\n")).not.toMatch(/ON CLUSTER/);
|
|
204
|
+
await q(first.sent[0]!, r2);
|
|
205
|
+
await q(`SYSTEM SYNC DATABASE REPLICA ${DB}`, r2);
|
|
206
|
+
const second = await apply(r2, build, "replicated");
|
|
207
|
+
expect(second.outcome.failed).toEqual([]);
|
|
208
|
+
expect(second.outcome.applied.filter((a) => a.action === "created").map((a) => a.name)).toEqual([
|
|
209
|
+
`role ${READER}`,
|
|
210
|
+
`row policy ${DB}_tenant ON ${DB}.events`,
|
|
211
|
+
`grants ${READER}`,
|
|
212
|
+
]);
|
|
213
|
+
for (const at of [r1, r2]) {
|
|
214
|
+
expect(Number((await q<{ n: string }>(`SELECT count() AS n FROM system.row_policies WHERE short_name = '${DB}_tenant'`, at))[0]!.n)).toBe(1);
|
|
215
|
+
expect((await apply(at, build, "replicated")).sent).toEqual([]);
|
|
216
|
+
}
|
|
217
|
+
}, 300_000);
|
|
218
|
+
});
|
|
@@ -8,6 +8,7 @@ import type { ChantConfig } from "@intentius/chant/config";
|
|
|
8
8
|
import type { UnobservedReason } from "@intentius/chant/observation";
|
|
9
9
|
import { ClickHouseQueryError, clickhouseQuery, type ClickHouseEndpoint } from "../http";
|
|
10
10
|
import { parseTopology, toTopology, type Topology } from "../topology";
|
|
11
|
+
import { credentialSource, POSTGRES_ONLY_TOKEN_SOURCES, TokenSourceError } from "../../token-source";
|
|
11
12
|
|
|
12
13
|
export interface ClickHouseTarget {
|
|
13
14
|
endpoint: ClickHouseEndpoint;
|
|
@@ -52,12 +53,21 @@ export function resolveClickHouseTarget(input: {
|
|
|
52
53
|
if (profile) {
|
|
53
54
|
const source = `sql.profiles.${input.environment}`;
|
|
54
55
|
const endpoint: ClickHouseEndpoint = { url: profile.url.replace(/\/+$/, "") };
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
if (profile.user) {
|
|
57
|
+
const value = env[profile.user.env];
|
|
58
|
+
if (value === undefined) return { reason: "no-credentials", detail: `${source}.user names ${profile.user.env}, which is not set` };
|
|
59
|
+
endpoint.user = value;
|
|
60
|
+
}
|
|
61
|
+
const password = profile.password;
|
|
62
|
+
if (password && "env" in password) {
|
|
63
|
+
const value = env[password.env];
|
|
64
|
+
if (value === undefined) return { reason: "no-credentials", detail: `${source}.password names ${password.env}, which is not set` };
|
|
65
|
+
endpoint.password = value;
|
|
66
|
+
} else if (password) {
|
|
67
|
+
if (POSTGRES_ONLY_TOKEN_SOURCES.has(password.token)) {
|
|
68
|
+
return { reason: "no-credentials", detail: `${source}.password is a ${password.token} token, which only a Postgres server takes; use a command source for ClickHouse` };
|
|
69
|
+
}
|
|
70
|
+
endpoint.token = credentialSource(password, { url: profile.url, ...(endpoint.user !== undefined ? { user: endpoint.user } : {}), env, source: `${source}.password` });
|
|
61
71
|
}
|
|
62
72
|
return {
|
|
63
73
|
endpoint,
|
|
@@ -121,6 +131,7 @@ export async function bindClickHouse(options: BindOptions = {}): Promise<ClickHo
|
|
|
121
131
|
/** What a failed bind or read means, in the observation vocabulary. */
|
|
122
132
|
export function classifyClickHouseFailure(err: unknown): { reason: UnobservedReason; detail: string } {
|
|
123
133
|
if (err instanceof ClickHouseBindingError) return { reason: err.unresolved.reason, detail: err.unresolved.detail };
|
|
134
|
+
if (err instanceof TokenSourceError) return { reason: "no-credentials", detail: err.message };
|
|
124
135
|
if (err instanceof ClickHouseQueryError) {
|
|
125
136
|
// 516 AUTHENTICATION_FAILED and 497 ACCESS_DENIED arrive as 401/403 or in the message.
|
|
126
137
|
if (err.status === 401 || err.status === 403 || /AUTHENTICATION_FAILED|ACCESS_DENIED|Code: 516|Code: 497/.test(err.serverMessage)) {
|