@intentius/chant-lexicon-sql 0.118.0 → 0.120.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/clickhouse/apply/apply.d.ts +1 -1
  2. package/dist/clickhouse/apply/apply.d.ts.map +1 -1
  3. package/dist/clickhouse/apply/statements.d.ts +8 -0
  4. package/dist/clickhouse/apply/statements.d.ts.map +1 -1
  5. package/dist/clickhouse/import/generator.d.ts +1 -1
  6. package/dist/clickhouse/import/generator.d.ts.map +1 -1
  7. package/dist/clickhouse/import/live-export.d.ts +19 -0
  8. package/dist/clickhouse/import/live-export.d.ts.map +1 -1
  9. package/dist/clickhouse/live/bind.d.ts +8 -0
  10. package/dist/clickhouse/live/bind.d.ts.map +1 -1
  11. package/dist/clickhouse/live/catalog.d.ts +15 -0
  12. package/dist/clickhouse/live/catalog.d.ts.map +1 -1
  13. package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
  14. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  15. package/dist/clickhouse/plan/deep.d.ts +19 -2
  16. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  17. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  18. package/dist/clickhouse/plan/normalize.d.ts +3 -0
  19. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  20. package/dist/clickhouse/plan/rebuild-handoff.d.ts +2 -0
  21. package/dist/clickhouse/plan/rebuild-handoff.d.ts.map +1 -1
  22. package/dist/clickhouse/rebuild/engines.d.ts +44 -0
  23. package/dist/clickhouse/rebuild/engines.d.ts.map +1 -0
  24. package/dist/clickhouse/rebuild/verify.d.ts +25 -1
  25. package/dist/clickhouse/rebuild/verify.d.ts.map +1 -1
  26. package/dist/clickhouse/testing/fake-server.d.ts +5 -0
  27. package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
  28. package/dist/config.d.ts +32 -8
  29. package/dist/config.d.ts.map +1 -1
  30. package/dist/core/foreign-tables.d.ts +13 -0
  31. package/dist/core/foreign-tables.d.ts.map +1 -0
  32. package/dist/files/build-root.d.ts +9 -1
  33. package/dist/files/build-root.d.ts.map +1 -1
  34. package/dist/files/clickhouse.d.ts +7 -3
  35. package/dist/files/clickhouse.d.ts.map +1 -1
  36. package/dist/files/postgres.d.ts +3 -0
  37. package/dist/files/postgres.d.ts.map +1 -1
  38. package/dist/import-parser.d.ts.map +1 -1
  39. package/dist/integrity.json +2 -2
  40. package/dist/manifest.json +1 -1
  41. package/dist/op/activities/clickhouse-emulator.d.ts +7 -1
  42. package/dist/op/activities/clickhouse-emulator.d.ts.map +1 -1
  43. package/dist/op/activities/postgres-emulator.d.ts +8 -1
  44. package/dist/op/activities/postgres-emulator.d.ts.map +1 -1
  45. package/dist/postgres/access/changes.d.ts +23 -0
  46. package/dist/postgres/access/changes.d.ts.map +1 -0
  47. package/dist/postgres/apply/apply.d.ts.map +1 -1
  48. package/dist/postgres/entities.d.ts.map +1 -1
  49. package/dist/postgres/live/bind.d.ts +8 -1
  50. package/dist/postgres/live/bind.d.ts.map +1 -1
  51. package/dist/postgres/live/catalog.d.ts +3 -3
  52. package/dist/postgres/live/catalog.d.ts.map +1 -1
  53. package/dist/postgres/live/client.d.ts +31 -0
  54. package/dist/postgres/live/client.d.ts.map +1 -1
  55. package/dist/postgres/plan/commands.d.ts.map +1 -1
  56. package/dist/postgres/plan/deep.d.ts.map +1 -1
  57. package/dist/sql-files.d.ts +2 -1
  58. package/dist/sql-files.d.ts.map +1 -1
  59. package/package.json +2 -2
  60. package/src/clickhouse/apply/apply.ts +8 -2
  61. package/src/clickhouse/apply/statements.ts +32 -5
  62. package/src/clickhouse/import/generator.ts +8 -3
  63. package/src/clickhouse/import/import.test.ts +64 -0
  64. package/src/clickhouse/import/live-export.ts +70 -2
  65. package/src/clickhouse/live/access.e2e.test.ts +71 -18
  66. package/src/clickhouse/live/bind.ts +10 -0
  67. package/src/clickhouse/live/catalog.ts +25 -2
  68. package/src/clickhouse/live/describe-resources.ts +5 -2
  69. package/src/clickhouse/live/live.test.ts +8 -0
  70. package/src/clickhouse/plan/commands.ts +6 -3
  71. package/src/clickhouse/plan/deep-grants.test.ts +67 -0
  72. package/src/clickhouse/plan/deep.ts +70 -6
  73. package/src/clickhouse/plan/diff.ts +4 -1
  74. package/src/clickhouse/plan/normalize.ts +5 -2
  75. package/src/clickhouse/plan/plan.test.ts +9 -1
  76. package/src/clickhouse/plan/rebuild-handoff.ts +11 -1
  77. package/src/clickhouse/rebuild/engines.ts +163 -0
  78. package/src/clickhouse/rebuild/rebuild-collapsing.e2e.test.ts +120 -0
  79. package/src/clickhouse/rebuild/rebuild-into-collapsing.e2e.test.ts +198 -0
  80. package/src/clickhouse/rebuild/rebuild.test.ts +78 -0
  81. package/src/clickhouse/rebuild/verify.ts +153 -24
  82. package/src/clickhouse/testing/fake-server.ts +3 -2
  83. package/src/codegen/classifier-page.ts +1 -1
  84. package/src/config.ts +16 -4
  85. package/src/core/foreign-tables.ts +28 -0
  86. package/src/files/build-root.ts +15 -3
  87. package/src/files/clickhouse.ts +71 -5
  88. package/src/files/postgres.ts +2 -1
  89. package/src/import-parser.ts +2 -0
  90. package/src/migration-statements.test.ts +33 -0
  91. package/src/migration-statements.ts +2 -0
  92. package/src/op/__snapshots__/watch-pipeline.test.ts.snap +1 -1
  93. package/src/op/activities/clickhouse-emulator.ts +22 -1
  94. package/src/op/activities/postgres-apply-lock-timeout.e2e.test.ts +87 -0
  95. package/src/op/activities/postgres-apply.test.ts +32 -0
  96. package/src/op/activities/postgres-emulator.test.ts +20 -0
  97. package/src/op/activities/postgres-emulator.ts +41 -1
  98. package/src/op/apply-gate-access-clickhouse.e2e.test.ts +195 -0
  99. package/src/op/apply-gate-access.e2e.test.ts +207 -0
  100. package/src/postgres/access/access.test.ts +26 -1
  101. package/src/postgres/access/changes.ts +39 -0
  102. package/src/postgres/apply/apply.ts +10 -7
  103. package/src/postgres/entities.ts +20 -1
  104. package/src/postgres/live/bind.ts +16 -4
  105. package/src/postgres/live/catalog.ts +3 -15
  106. package/src/postgres/live/client.ts +85 -0
  107. package/src/postgres/live/live.test.ts +54 -0
  108. package/src/postgres/live/lock-timeout.e2e.test.ts +70 -0
  109. package/src/postgres/plan/commands.ts +2 -14
  110. package/src/postgres/plan/deep.ts +44 -2
  111. package/src/serializer.test.ts +4 -0
  112. package/src/skills/chant-sql-postgres-plan.md +1 -1
  113. package/src/skills/chant-sql-rebuild.md +2 -0
  114. package/src/skills/chant-sql.md +1 -1
  115. package/src/sql-files.test.ts +110 -1
  116. package/src/sql-files.ts +2 -1
@@ -121,6 +121,12 @@ describe("live export", () => {
121
121
  "CREATE TABLE analytics.events\n(\n `id` UInt64\n)\nENGINE = MergeTree\nORDER BY id\nSETTINGS index_granularity = 8192\nCOMMENT 'Raw events [chant managed-by=chant stack=shop]'",
122
122
  comment: "Raw events [chant managed-by=chant stack=shop]",
123
123
  },
124
+ {
125
+ database: "analytics",
126
+ name: "schema_migrations",
127
+ engine: "MergeTree",
128
+ statement: "CREATE TABLE analytics.schema_migrations\n(\n `version` Int64,\n `dirty` UInt8,\n `sequence` UInt64\n)\nENGINE = MergeTree\nORDER BY sequence",
129
+ },
124
130
  ]);
125
131
  });
126
132
  afterAll(() => server.close());
@@ -134,6 +140,14 @@ describe("live export", () => {
134
140
  expect(String(ir.resources[1]!.properties.ddl)).not.toContain("index_granularity");
135
141
  });
136
142
 
143
+ test("a migration runner's history table is left out with a warning (#3676)", async () => {
144
+ const ir = await run();
145
+ expect(ir.resources.map((r) => r.properties.name)).not.toContain("schema_migrations");
146
+ expect(ir.warnings).toEqual([
147
+ "analytics.schema_migrations is kept by a migration runner (Rails, golang-migrate, dbmate); left out, since declaring it would have chant change what that tool owns",
148
+ ]);
149
+ });
150
+
137
151
  test("verbatim keeps the statement as the server printed it", async () => {
138
152
  const ir = await run({ verbatim: true });
139
153
  expect(String(ir.resources[1]!.properties.ddl)).toContain("index_granularity = 8192");
@@ -161,3 +175,53 @@ describe("live export", () => {
161
175
  expect(new ClickHouseGenerator().generate(ir)[0]!.content).toContain("CREATE TABLE ${analyticsDb}.events");
162
176
  });
163
177
  });
178
+
179
+ describe("live export of SQL functions (#3718)", () => {
180
+ let server: FakeServer;
181
+ beforeAll(async () => {
182
+ server = await fakeClickHouse(
183
+ [
184
+ { name: "shop", engine: "Atomic", statement: "CREATE DATABASE shop\nENGINE = Atomic" },
185
+ {
186
+ database: "shop",
187
+ name: "orders",
188
+ engine: "MergeTree",
189
+ statement: "CREATE TABLE shop.orders\n(\n `id` UInt64,\n `net` Float64 DEFAULT shop_net(id)\n)\nENGINE = MergeTree\nORDER BY id",
190
+ },
191
+ ],
192
+ {
193
+ functions: [
194
+ { name: "other_score", statement: "CREATE FUNCTION other_score AS x -> (x * 2)" },
195
+ { name: "shop_net", statement: "CREATE FUNCTION shop_net AS x -> shop_tax(x)" },
196
+ { name: "shop_tax", statement: "CREATE FUNCTION shop_tax AS x -> (x * 0.2)" },
197
+ { name: "shop_unused", statement: "CREATE FUNCTION shop_unused AS x -> x" },
198
+ ],
199
+ },
200
+ );
201
+ });
202
+ afterAll(() => server.close());
203
+
204
+ const run = (profile: Record<string, unknown> = {}, extra: Record<string, unknown> = {}) =>
205
+ exportResources({ environment: "x", config: { sql: { profiles: { x: { url: server.url, ...profile } } } } as never, env: {}, ...extra });
206
+
207
+ test("adopts the functions the imported objects call, and the ones those call; a warning names the rest", async () => {
208
+ const ir = await run();
209
+ expect(ir.resources.map((r) => r.properties.name)).toEqual(["shop", "orders", "shop_net", "shop_tax"]);
210
+ expect(ir.warnings).toEqual([
211
+ "2 SQL functions on the server are not imported, since no imported object calls them: other_score, shop_unused. Name them in sql.profiles.x.importFunctions to import them.",
212
+ ]);
213
+ });
214
+
215
+ test("importFunctions names more, by name or by prefix", async () => {
216
+ expect((await run({ importFunctions: ["shop_*"] })).resources.map((r) => r.properties.name)).toEqual(["shop", "orders", "shop_net", "shop_tax", "shop_unused"]);
217
+ const ir = await run({ importFunctions: ["other_score"] });
218
+ expect(ir.resources.map((r) => r.properties.name)).toContain("other_score");
219
+ expect(ir.warnings).toEqual([expect.stringContaining(": shop_unused.")]);
220
+ });
221
+
222
+ test("a selector picks a function it names", async () => {
223
+ const ir = await run({}, { selector: { name: "other_score" } });
224
+ expect(ir.resources.map((r) => r.properties.name)).toEqual(["other_score"]);
225
+ expect(ir.warnings).toBeUndefined();
226
+ });
227
+ });
@@ -13,6 +13,14 @@
13
13
  * - `owned` keeps the objects whose comment carries chant's ownership marker
14
14
  * (`../ownership.ts`), the trailer the applier stamps. The trailer itself is
15
15
  * never written into a declaration: `readLiveSchema` takes it off.
16
+ * - a migration runner's history table (`schema_migrations`,
17
+ * `goose_db_version`) is left out with a warning (#3676), as on Postgres.
18
+ * - A SQL user-defined function belongs to no database, and every project's
19
+ * functions sit side by side on a server (#3718). Without a selector, an
20
+ * import adopts only the functions the imported objects call (and those
21
+ * functions call), and the ones `sql.profiles.<env>.importFunctions` names;
22
+ * a warning names the rest. A function carries no comment, so `owned` does
23
+ * not apply to it.
16
24
  */
17
25
 
18
26
  import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
@@ -20,6 +28,48 @@ import { bindClickHouse, type BindOptions } from "../live/bind";
20
28
  import { readLiveSchema } from "../live/catalog";
21
29
  import { objectsToIR, stripServerDefaults, type ImportedObject } from "./ir";
22
30
  import { isChantManaged } from "../ownership";
31
+ import { CLICKHOUSE_ENTITY_TYPES } from "../entities";
32
+ import { isTrivia, tokenizeText } from "../tokens";
33
+ import { unquote } from "../parser";
34
+ import type { LiveObject } from "../live/catalog";
35
+
36
+ /** The functions among `names` a statement calls: a name followed by `(`. */
37
+ export function calledFunctions(ddl: string, names: ReadonlySet<string>): string[] {
38
+ const tokens = tokenizeText(ddl, 0).filter((t) => !isTrivia(t));
39
+ const out = new Set<string>();
40
+ tokens.forEach((t, i) => {
41
+ if (t.kind !== "ident" && t.kind !== "qident") return;
42
+ const next = tokens[i + 1];
43
+ if (next?.kind !== "punct" || next.text !== "(") return;
44
+ const name = t.kind === "qident" ? unquote(t.text) : t.text;
45
+ if (names.has(name)) out.add(name);
46
+ });
47
+ return [...out];
48
+ }
49
+
50
+ /** Whether `patterns` (names, or prefixes ending in `*`) name a function. */
51
+ const named = (patterns: readonly string[], name: string) => patterns.some((p) => (p.endsWith("*") ? name.startsWith(p.slice(0, -1)) : p === name));
52
+
53
+ /**
54
+ * The functions an import adopts (#3718): those the objects call, those
55
+ * `patterns` name, and the functions those call in turn; and the rest.
56
+ */
57
+ export function scopedFunctions(objects: readonly LiveObject[], functions: readonly LiveObject[], patterns: readonly string[] = []): { adopted: LiveObject[]; skipped: string[] } {
58
+ const byName = new Map(functions.map((f) => [f.name, f]));
59
+ const names = new Set(byName.keys());
60
+ const adopted = new Set<string>(functions.filter((f) => named(patterns, f.name)).map((f) => f.name));
61
+ for (const o of objects) for (const n of calledFunctions(o.statement, names)) adopted.add(n);
62
+ const queue = [...adopted];
63
+ while (queue.length > 0) {
64
+ for (const n of calledFunctions(byName.get(queue.pop()!)!.statement, names)) {
65
+ if (!adopted.has(n)) {
66
+ adopted.add(n);
67
+ queue.push(n);
68
+ }
69
+ }
70
+ }
71
+ return { adopted: functions.filter((f) => adopted.has(f.name)), skipped: functions.filter((f) => !adopted.has(f.name)).map((f) => f.name) };
72
+ }
23
73
 
24
74
  export interface ExportOptions extends Omit<BindOptions, "environment"> {
25
75
  environment: string;
@@ -32,7 +82,10 @@ export interface ExportOptions extends Omit<BindOptions, "environment"> {
32
82
 
33
83
  export async function exportResources(options: ExportOptions): Promise<ExportedTemplate> {
34
84
  const target = await bindClickHouse(options);
35
- const live = await readLiveSchema(target);
85
+ const all = await readLiveSchema(target);
86
+ const isFunction = (o: LiveObject) => o.type === CLICKHOUSE_ENTITY_TYPES.function;
87
+ // A selector picks what it names, functions included; without one, functions go with what calls them (#3718).
88
+ const live = options.selector ? all : all.filter((o) => !isFunction(o));
36
89
  const selected = live.filter((o) => {
37
90
  if (options.owned && !isChantManaged(o.comment)) return false;
38
91
  if (options.selector?.type && o.type !== options.selector.type) return false;
@@ -40,14 +93,29 @@ export async function exportResources(options: ExportOptions): Promise<ExportedT
40
93
  if (name !== undefined && name !== o.name && name !== `${o.database}.${o.name}`) return false;
41
94
  return true;
42
95
  });
96
+ const warnings: string[] = [];
97
+ if (!options.selector) {
98
+ const { adopted, skipped } = scopedFunctions(selected.filter((o) => !o.foreign), all.filter(isFunction), target.importFunctions);
99
+ selected.push(...adopted);
100
+ if (skipped.length > 0) {
101
+ warnings.push(
102
+ `${skipped.length} SQL function${skipped.length === 1 ? "" : "s"} on the server ${skipped.length === 1 ? "is" : "are"} not imported, since no imported object calls ${skipped.length === 1 ? "it" : "them"}: ${skipped.join(", ")}. Name ${skipped.length === 1 ? "it" : "them"} in sql.profiles.${options.environment}.importFunctions to import ${skipped.length === 1 ? "it" : "them"}.`,
103
+ );
104
+ }
105
+ }
43
106
  const objects: ImportedObject[] = selected
44
107
  // The `default` database exists on every server; declaring it would make apply create what is there.
45
108
  .filter((o) => !(o.type === "ClickHouse::Database" && o.name === "default"))
109
+ .filter((o) => {
110
+ if (!o.foreign) return true;
111
+ warnings.push(`${o.database ? `${o.database}.` : ""}${o.name} is kept by ${o.foreign}; left out, since declaring it would have chant change what that tool owns`);
112
+ return false;
113
+ })
46
114
  .map((o) => ({
47
115
  type: o.type,
48
116
  ...(o.database ? { database: o.database } : {}),
49
117
  name: o.name,
50
118
  ddl: options.verbatim ? o.statement : stripServerDefaults(o.statement),
51
119
  }));
52
- return objectsToIR(objects) as ExportedTemplate;
120
+ return objectsToIR(objects, warnings) as ExportedTemplate;
53
121
  }
@@ -13,7 +13,11 @@
13
13
  * - a privilege granted by hand to a declared grantee is SQLCH274 and is
14
14
  * revoked, a declared one revoked by hand is SQLCH273 and is granted
15
15
  * again, a changed role setting is SQLCH270, a changed policy SQLCH272;
16
- * - the row policy filters rows for the user.
16
+ * the deep read lists both as pending, with the statements (#3733);
17
+ * - the row policy filters rows for the user;
18
+ * - with a profile that does not manage access (no `access: true`, #3716),
19
+ * none of it is applied, planned or read: each access declaration is
20
+ * reported filtered, and a hand change to a grant is not undone.
17
21
  *
18
22
  * Beside a Replicated database (two replicas of the pinned server in
19
23
  * Docker): access control is in no database, so the database's log does not
@@ -23,7 +27,7 @@
23
27
  * `CLICKHOUSE_PASSWORD`) names a running one, such as the one
24
28
  * `chant emulator up --lexicon sql` starts. Without it, a throwaway server
25
29
  * 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*`
30
+ * are global to the server, so the test names everything `chant_e2e_3716_access*`
27
31
  * and drops it afterwards.
28
32
  */
29
33
 
@@ -37,9 +41,10 @@ import { clickhouseImage } from "../../spec/pin";
37
41
  import { database, grant, policy, role, table, user } from "../entities";
38
42
  import { planAgainstServer } from "../plan/commands";
39
43
  import { observeResourcesDeep, sqlDeepNormalizationHooks } from "../plan/deep";
44
+ import { describeResources } from "./describe-resources";
40
45
  import { clickhouseApply } from "../../op/activities/clickhouse-apply";
41
46
 
42
- const DB = "chant_e2e_3682_access";
47
+ const DB = "chant_e2e_3716_access";
43
48
  const READER = `${DB}_reader`;
44
49
  const APP = `${DB}_app`;
45
50
  const fromEnv = process.env.CLICKHOUSE_URL;
@@ -76,13 +81,27 @@ afterAll(async () => {
76
81
  rmSync(dir, { recursive: true, force: true });
77
82
  });
78
83
 
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
+ const envOf = (at: ClickHouseEndpoint) => ({
85
+ ...(at.user ? { CH_USER: at.user } : {}),
86
+ ...(at.password ? { CH_PASSWORD: at.password } : {}),
84
87
  });
85
88
 
89
+ /** The `test` profile: the server, and whether it manages access (#3716). */
90
+ const configOf = (at: ClickHouseEndpoint, opts: { topology?: string; access?: boolean } = {}) => ({
91
+ ownership: { stack: "e2e3682", env: "test" },
92
+ sql: {
93
+ profiles: {
94
+ test: {
95
+ url: at.url,
96
+ ...(at.user ? { user: { env: "CH_USER" } } : {}),
97
+ ...(at.password ? { password: { env: "CH_PASSWORD" } } : {}),
98
+ ...(opts.topology ? { topology: opts.topology } : {}),
99
+ ...(opts.access !== false ? { access: true } : {}),
100
+ },
101
+ },
102
+ },
103
+ }) as never;
104
+
86
105
  type Entity = { entityType: string; props: { ddl: string }; dependsOn?: unknown };
87
106
  function writeBuild(file: string, declared: Record<string, Entity>): string {
88
107
  const path = join(dir, file);
@@ -91,11 +110,11 @@ function writeBuild(file: string, declared: Record<string, Entity>): string {
91
110
  return path;
92
111
  }
93
112
 
94
- async function apply(at: ClickHouseEndpoint, buildPath: string, topology?: string) {
113
+ async function apply(at: ClickHouseEndpoint, buildPath: string, opts: { topology?: string; access?: boolean } = {}) {
95
114
  const sent: string[] = [];
96
115
  const outcome = await clickhouseApply({ buildPath, environment: "test" }, undefined, {
97
- config: { ownership: { stack: "e2e3682", env: "test" } },
98
- env: envOf(at, topology),
116
+ config: configOf(at, opts),
117
+ env: envOf(at),
99
118
  log: (l) => void (/^[A-Z]+ /.test(l) && sent.push(l)),
100
119
  });
101
120
  return { outcome, sent };
@@ -146,11 +165,12 @@ describe.skipIf(!enabled)("access control on a single node (#3682)", () => {
146
165
  expect((await q<{ s: string }>(`SELECT auth_type[1] AS s FROM system.users WHERE name = '${APP}'`))[0]!.s).toBe("sha256_password");
147
166
 
148
167
  // 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) });
168
+ const diff = await planAgainstServer("test", build, { config: configOf(endpoint), env: envOf(endpoint) });
150
169
  expect(diff.changes).toEqual([]);
151
170
  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) });
171
+ const deep = await observeResourcesDeep({ environment: "test", entityNames: [...entities.keys()], entities, config: configOf(endpoint), env: envOf(endpoint) });
153
172
  expect(Object.keys(deep.unobserved ?? {}).sort()).toEqual(["readEvents", "readerToApp"]);
173
+ expect(deep.pending).toBeUndefined();
154
174
  for (const k of ["reader", "app", "tenant"] as const) expect(compared(deep.resources[k]!.properties), k).toEqual(compared(v1[k].props));
155
175
 
156
176
  // The policy filters rows for the user.
@@ -161,8 +181,14 @@ describe.skipIf(!enabled)("access control on a single node (#3682)", () => {
161
181
  // Changed by hand: a privilege granted, a declared one revoked. Changed in the declaration: a setting and the condition.
162
182
  await q(`GRANT INSERT ON ${DB}.events TO ${READER}`);
163
183
  await q(`REVOKE ${READER} FROM ${APP}`);
184
+ // The deep read lists what an apply would grant and revoke (#3733), so an approval's plan digest moves.
185
+ const handMade = await observeResourcesDeep({ environment: "test", entityNames: [...entities.keys()], entities, config: configOf(endpoint), env: envOf(endpoint) });
186
+ expect(handMade.pending).toEqual([
187
+ { subject: `grants ${READER}`, change: `REVOKE INSERT ON \`${DB}\`.\`events\` FROM \`${READER}\``, entities: ["readEvents"] },
188
+ { subject: `grants ${APP}`, change: `GRANT \`${READER}\` TO \`${APP}\``, entities: ["readerToApp"] },
189
+ ]);
164
190
  const v2 = declarations({ memory: "2000000000", tenant: "b" });
165
- const planned = await planAgainstServer("test", writeBuild("v2.json", v2), { config: {}, env: envOf(endpoint) });
191
+ const planned = await planAgainstServer("test", writeBuild("v2.json", v2), { config: configOf(endpoint), env: envOf(endpoint) });
166
192
  expect(planned.changes.map((c) => [c.object, c.field, c.rule])).toEqual([
167
193
  [`reader (role ${READER})`, "settings", "SQLCH270"],
168
194
  // Revoking the role took it off the user's default roles too.
@@ -180,8 +206,35 @@ describe.skipIf(!enabled)("access control on a single node (#3682)", () => {
180
206
  `GRANT \`${READER}\` TO \`${APP}\``,
181
207
  `ALTER USER \`${APP}\` DEFAULT ROLE ${READER}`,
182
208
  ]);
183
- expect((await planAgainstServer("test", writeBuild("v2.json", v2), { config: {}, env: envOf(endpoint) })).changes).toEqual([]);
209
+ expect((await planAgainstServer("test", writeBuild("v2.json", v2), { config: configOf(endpoint), env: envOf(endpoint) })).changes).toEqual([]);
184
210
  expect(Number((await q<{ n: string }>(`SELECT count() AS n FROM ${DB}.events`, asApp))[0]!.n)).toBe(1);
211
+
212
+ // A profile that does not manage access (#3716): nothing access-related is read, planned or applied.
213
+ await q(`GRANT INSERT ON ${DB}.events TO ${READER}`);
214
+ const off = { access: false };
215
+ const v3 = declarations({ memory: "3000000000", tenant: "a" });
216
+ const offBuild = writeBuild("v3.json", v3);
217
+ const offPlan = await planAgainstServer("test", offBuild, { config: configOf(endpoint, off), env: envOf(endpoint) });
218
+ expect(offPlan.changes).toEqual([]);
219
+ expect(offPlan.hints).toContain("5 access declarations are not planned: test's profile does not manage access (sql.profiles.<env>.access)");
220
+ const offApply = await apply(endpoint, offBuild, off);
221
+ expect(offApply.outcome.failed).toEqual([]);
222
+ expect(offApply.sent).toEqual([]);
223
+ expect(offApply.outcome.notAttempted.map((n) => [n.name, n.reason])).toEqual([
224
+ [`role ${READER}`, "filtered"],
225
+ [`user ${APP}`, "filtered"],
226
+ [`row policy ${DB}_tenant ON ${DB}.events`, "filtered"],
227
+ [`grants ${READER}`, "filtered"],
228
+ [`grants ${APP}`, "filtered"],
229
+ ]);
230
+ expect((await q<{ s: string }>(`SHOW GRANTS FOR ${READER}`)).map((r) => Object.values(r)[0]).join("\n")).toMatch(/GRANT INSERT\b.* ON chant_e2e_3716_access\.events TO/);
231
+ const v3entities = new Map(Object.entries(v3).map(([k, e]) => [k, { entityType: e.entityType, props: e.props as unknown as Record<string, unknown> }]));
232
+ const offDeep = await observeResourcesDeep({ environment: "test", entityNames: [...v3entities.keys()], entities: v3entities, config: configOf(endpoint, off), env: envOf(endpoint) });
233
+ expect(Object.entries(offDeep.unobserved ?? {}).map(([k, u]) => [k, u.reason]).sort()).toEqual(
234
+ ["app", "readEvents", "reader", "readerToApp", "tenant"].map((k) => [k, "filtered"]),
235
+ );
236
+ const offThin = await describeResources({ environment: "test", entityNames: [...v3entities.keys()], entities: v3entities, config: configOf(endpoint, off), env: envOf(endpoint) });
237
+ expect(Object.keys(offThin.unobserved ?? {}).sort()).toEqual(["app", "readEvents", "reader", "readerToApp", "tenant"]);
185
238
  }, 300_000);
186
239
  });
187
240
 
@@ -198,12 +251,12 @@ describe.skipIf(!docker)("access control beside a Replicated database (#3682)",
198
251
  const [r1, r2] = cluster!.replicas as [ClickHouseEndpoint, ClickHouseEndpoint];
199
252
  const { shop, events, reader, tenant, readEvents } = declarations();
200
253
  const build = writeBuild("repl.json", { shop, events, reader, tenant, readEvents });
201
- const first = await apply(r1, build, "replicated");
254
+ const first = await apply(r1, build, { topology: "replicated" });
202
255
  expect(first.outcome.failed).toEqual([]);
203
256
  expect(first.sent.join("\n")).not.toMatch(/ON CLUSTER/);
204
257
  await q(first.sent[0]!, r2);
205
258
  await q(`SYSTEM SYNC DATABASE REPLICA ${DB}`, r2);
206
- const second = await apply(r2, build, "replicated");
259
+ const second = await apply(r2, build, { topology: "replicated" });
207
260
  expect(second.outcome.failed).toEqual([]);
208
261
  expect(second.outcome.applied.filter((a) => a.action === "created").map((a) => a.name)).toEqual([
209
262
  `role ${READER}`,
@@ -212,7 +265,7 @@ describe.skipIf(!docker)("access control beside a Replicated database (#3682)",
212
265
  ]);
213
266
  for (const at of [r1, r2]) {
214
267
  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([]);
268
+ expect((await apply(at, build, { topology: "replicated" })).sent).toEqual([]);
216
269
  }
217
270
  }, 300_000);
218
271
  });
@@ -24,6 +24,14 @@ export interface ClickHouseTarget {
24
24
  * as declared.
25
25
  */
26
26
  topology?: Topology;
27
+ /**
28
+ * Whether chant manages access here (`sql.profiles.<env>.access`, #3716):
29
+ * the declared users, roles, row policies and grants. Off when omitted, as
30
+ * on Postgres, and off for a server bound by `CLICKHOUSE_URL`.
31
+ */
32
+ access?: boolean;
33
+ /** `sql.profiles.<env>.importFunctions` (#3718): the functions an import adopts besides those the imported objects call. */
34
+ importFunctions?: string[];
27
35
  }
28
36
 
29
37
  export interface UnresolvedTarget {
@@ -75,6 +83,8 @@ export function resolveClickHouseTarget(input: {
75
83
  ...(profile.databases ? { databases: profile.databases } : {}),
76
84
  defaultDatabase: profile.defaultDatabase ?? "default",
77
85
  ...(profile.topology !== undefined ? { topology: toTopology(profile.topology) } : {}),
86
+ ...(profile.access === true ? { access: true } : {}),
87
+ ...(profile.importFunctions ? { importFunctions: profile.importFunctions } : {}),
78
88
  };
79
89
  }
80
90
  const url = env.CLICKHOUSE_URL;
@@ -9,6 +9,7 @@ import { clickhouseQuery } from "../http";
9
9
  import type { ClickHouseTarget } from "./bind";
10
10
  import { CLICKHOUSE_ENTITY_TYPES, type ClickHouseEntityType } from "../entities";
11
11
  import { isChantWorkingObject, stripMarkerFromStatement } from "../ownership";
12
+ import { foreignTool } from "../../core/foreign-tables";
12
13
 
13
14
  /** The server's own databases, never part of a declared schema. */
14
15
  export const SYSTEM_DATABASES = new Set(["system", "information_schema", "INFORMATION_SCHEMA"]);
@@ -26,6 +27,8 @@ export interface LiveObject {
26
27
  statement: string;
27
28
  /** A row policy's table. */
28
29
  table?: string;
30
+ /** The tool that keeps it, for a migration runner's history table (`../../core/foreign-tables.ts`): never chant's to import, change or drop. */
31
+ foreign?: string;
29
32
  }
30
33
 
31
34
  const quote = (s: string) => `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
@@ -46,6 +49,11 @@ export function entityTypeOfEngine(engine: string): ClickHouseEntityType {
46
49
  * `SELECT` on the database sees it without a grant on `system.dictionaries`;
47
50
  * a table with the `Dictionary` engine is a table.
48
51
  *
52
+ * A table another tool keeps its migration history in (`schema_migrations`,
53
+ * `goose_db_version`, `../../core/foreign-tables.ts`) is read and marked with
54
+ * the tool, as the Postgres reader marks it (#3676): import leaves it out
55
+ * with a warning, and a plan names it in a hint instead of dropping it.
56
+ *
49
57
  * So are chant's own working objects (`../ownership.ts`
50
58
  * `isChantWorkingObject`): a rebuild migration's new, dual-write and retained
51
59
  * tables, and the effect receipts database. None of them is a declaration, so
@@ -87,13 +95,16 @@ export async function readLiveSchema(target: ClickHouseTarget, opts: { withState
87
95
  }
88
96
  for (const t of tables) {
89
97
  if (working.has(t.database) || isChantWorkingObject(t.comment)) continue;
98
+ const type = Number(t.dictionary) === 1 ? CLICKHOUSE_ENTITY_TYPES.dictionary : entityTypeOfEngine(t.engine);
99
+ const foreign = type === CLICKHOUSE_ENTITY_TYPES.table ? foreignTool(t.name) : undefined;
90
100
  out.push({
91
- type: Number(t.dictionary) === 1 ? CLICKHOUSE_ENTITY_TYPES.dictionary : entityTypeOfEngine(t.engine),
101
+ type,
92
102
  database: t.database,
93
103
  name: t.name,
94
104
  engine: t.engine,
95
105
  ...(t.uuid && !/^0{8}-/.test(t.uuid) ? { uuid: t.uuid } : {}),
96
106
  ...(t.comment ? { comment: t.comment } : {}),
107
+ ...(foreign ? { foreign } : {}),
97
108
  statement: await statement("TABLE", `${ident(t.database)}.${ident(t.name)}`),
98
109
  });
99
110
  }
@@ -168,7 +179,19 @@ export async function readLiveAccess(target: ClickHouseTarget, declared: readonl
168
179
  return out;
169
180
  }
170
181
 
171
- const ACCESS_KINDS = new Set(["user", "role", "rowPolicy", "grants"]);
182
+ /** The canonical kinds a profile's `access` switch decides (#3716). */
183
+ export const ACCESS_KINDS: ReadonlySet<string> = new Set(["user", "role", "rowPolicy", "grants"]);
184
+
185
+ /** The entity types a profile's `access` switch decides (#3716). */
186
+ export const ACCESS_ENTITY_TYPES: ReadonlySet<string> = new Set([CLICKHOUSE_ENTITY_TYPES.user, CLICKHOUSE_ENTITY_TYPES.role, CLICKHOUSE_ENTITY_TYPES.rowPolicy, CLICKHOUSE_ENTITY_TYPES.grant]);
187
+
188
+ /** Why an access declaration is not read, planned or applied where the profile does not manage access. */
189
+ export const ACCESS_UNMANAGED_DETAIL = "access is not managed in this environment: set sql.profiles.<env>.access to plan and apply users, roles, row policies and grants";
190
+
191
+ /** The hint a plan gives for the access declarations it left out. */
192
+ export function accessUnmanagedHint(n: number, environment: string): string {
193
+ return `${n} access declaration${n === 1 ? " is" : "s are"} not planned: ${environment}'s profile does not manage access (sql.profiles.<env>.access)`;
194
+ }
172
195
 
173
196
  /** The access objects among declared canonical objects, for `readLiveAccess`. */
174
197
  export function declaredAccess(objects: ReadonlyArray<{ kind: string; name: string; database?: string; table?: string }>): DeclaredAccess[] {
@@ -24,7 +24,7 @@ import {
24
24
  type ObserverAdapter,
25
25
  } from "@intentius/chant/observation";
26
26
  import { bindClickHouse, classifyClickHouseFailure, type BindOptions, type ClickHouseTarget } from "./bind";
27
- import { accessOf, readLiveAccess, readLiveSchema, type DeclaredAccess, type LiveObject } from "./catalog";
27
+ import { ACCESS_ENTITY_TYPES, ACCESS_UNMANAGED_DETAIL, accessOf, readLiveAccess, readLiveSchema, type DeclaredAccess, type LiveObject } from "./catalog";
28
28
  import { CLICKHOUSE_ENTITY_TYPES } from "../entities";
29
29
  import { objectKey } from "../plan/normalize";
30
30
  import { isChantManaged, readMarker, stripMarker } from "../ownership";
@@ -54,7 +54,7 @@ function adapter(options: BindOptions & { owned?: boolean; access?: (defaultData
54
54
  async bind() {
55
55
  const target = await bindClickHouse(options);
56
56
  const byKey = new Map<string, LiveObject>();
57
- const access = await readLiveAccess(target, options.access?.(target.defaultDatabase) ?? [], { withStatements: false });
57
+ const access = target.access === true ? await readLiveAccess(target, options.access?.(target.defaultDatabase) ?? [], { withStatements: false }) : [];
58
58
  for (const o of [...(await readLiveSchema(target, { withStatements: false })), ...access]) {
59
59
  byKey.set(objectKey(o), o);
60
60
  }
@@ -65,6 +65,9 @@ function adapter(options: BindOptions & { owned?: boolean; access?: (defaultData
65
65
  if (!entity.type.startsWith("ClickHouse::")) {
66
66
  return { unobserved: { reason: "unsupported-kind", detail: entity.type } };
67
67
  }
68
+ if (target.access !== true && ACCESS_ENTITY_TYPES.has(entity.type)) {
69
+ return { unobserved: { reason: "filtered", detail: ACCESS_UNMANAGED_DETAIL } };
70
+ }
68
71
  if (entity.type === CLICKHOUSE_ENTITY_TYPES.grant) {
69
72
  return { unobserved: { reason: "unsupported-kind", detail: "a grant is compared as part of its grantees' grants, which chant sql plan reports" } };
70
73
  }
@@ -60,6 +60,14 @@ describe("binding an environment to a server", () => {
60
60
  });
61
61
  });
62
62
 
63
+ test("access is managed only where the profile says so (#3716)", () => {
64
+ const on = resolveClickHouseTarget({ environment: "prod", config: { sql: { profiles: { prod: { url: "http://ch:8123", access: true } } } } as never, env: {} });
65
+ expect(on).toMatchObject({ access: true });
66
+ const off = resolveClickHouseTarget({ environment: "prod", config: { sql: { profiles: { prod: { url: "http://ch:8123" } } } } as never, env: {} });
67
+ expect(off).not.toHaveProperty("access");
68
+ expect(resolveClickHouseTarget({ env: { CLICKHOUSE_URL: "http://ch:8123" } })).not.toHaveProperty("access");
69
+ });
70
+
63
71
  test("a credential variable the profile names but nobody set is no-credentials", () => {
64
72
  const t = resolveClickHouseTarget({ environment: "prod", config: { sql: { profiles: { prod: { url: "x", password: { env: "P" } } } } }, env: {} });
65
73
  expect(isUnresolvedTarget(t) && t.reason).toBe("no-credentials");
@@ -35,7 +35,7 @@ import { renderDiff } from "./report";
35
35
  import { keyedByQualifiedName, schemaFromBuildFile, schemaFromServer } from "./schema";
36
36
  import { dropFormattingOnly } from "./server-format";
37
37
  import { scopeOf } from "./normalize";
38
- import { declaredAccess } from "../live/catalog";
38
+ import { ACCESS_KINDS, accessUnmanagedHint, declaredAccess } from "../live/catalog";
39
39
  import { bindClickHouse } from "../live/bind";
40
40
  import { rebuildOpSuggestions } from "./rebuild-handoff";
41
41
  import { readFileSync } from "node:fs";
@@ -120,7 +120,10 @@ export async function runDiff(ctx: CommandGroupContext): Promise<number> {
120
120
  /** The declared objects against the server, keys and labels by `database.name`. */
121
121
  export async function planAgainstServer(environment: string, buildFile: string, options: Parameters<typeof bindClickHouse>[0] = {}): Promise<SchemaDiff> {
122
122
  const target = await bindClickHouse({ ...options, environment });
123
- const declared = keyedByQualifiedName(schemaFromBuildFile(buildFile, target.defaultDatabase, target.topology));
123
+ const all = keyedByQualifiedName(schemaFromBuildFile(buildFile, target.defaultDatabase, target.topology));
124
+ // Access declarations a profile does not manage are not planned, and the server's access is not read (#3716).
125
+ const declared = target.access === true ? all : all.filter((o) => !ACCESS_KINDS.has(o.canonical.kind));
126
+ const unmanaged = all.length - declared.length;
124
127
  const databases = new Set(declared.map((o) => scopeOf(o.canonical)));
125
128
  const unreadable: UnreadableEntry[] = [];
126
129
  const live = await schemaFromServer(target, databases, unreadable, declaredAccess(declared.map((o) => o.canonical)));
@@ -136,7 +139,7 @@ export async function planAgainstServer(environment: string, buildFile: string,
136
139
  return {
137
140
  changes: relabel,
138
141
  rebuilds: relabel.filter((c) => c.class === "rebuild"),
139
- hints: diff.hints,
142
+ hints: unmanaged > 0 ? [...diff.hints, accessUnmanagedHint(unmanaged, environment)] : diff.hints,
140
143
  ...(rebuildOps.length > 0 ? { rebuildOps } : {}),
141
144
  ...(unreadable.length > 0 ? { unreadable } : {}),
142
145
  };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The grants the deep read reports as pending (#3733): what an apply would
3
+ * grant or revoke from each declared grantee, so a privilege granted by hand
4
+ * after an approval moves the plan digest. The server is a stub answering the
5
+ * queries `readLiveAccess` sends.
6
+ */
7
+
8
+ import { describe, expect, test, vi } from "vitest";
9
+ import { grant } from "../entities";
10
+ import type { ClickHouseTarget } from "../live/bind";
11
+
12
+ /** What `SHOW GRANTS FOR` prints, by grantee; a grantee missing here does not exist. */
13
+ let held: Record<string, string[]> = {};
14
+
15
+ vi.mock("../http", async (importOriginal) => {
16
+ const actual = await importOriginal<typeof import("../http")>();
17
+ return {
18
+ ...actual,
19
+ clickhouseQuery: async (_endpoint: unknown, sql: string) => {
20
+ if (/FROM system\.users/.test(sql)) return Object.keys(held).filter((n) => sql.includes(`'${n}'`)).map((name) => ({ name }));
21
+ if (/FROM system\.roles/.test(sql)) return [];
22
+ const show = /^SHOW GRANTS FOR `(.+)`$/.exec(sql.trim());
23
+ if (show) return (held[show[1]!] ?? []).map((line) => ({ [`GRANTS FOR ${show[1]}`]: line }));
24
+ throw new Error(`unexpected query: ${sql}`);
25
+ },
26
+ };
27
+ });
28
+
29
+ const { pendingGrants } = await import("./deep");
30
+
31
+ const target: ClickHouseTarget = { endpoint: { url: "http://stub:8123" }, source: "sql.profiles.test", defaultDatabase: "default", access: true };
32
+
33
+ const entities = (all: Record<string, ReturnType<typeof grant>>) =>
34
+ new Map(Object.entries(all).map(([k, e]) => [k, { entityType: e.entityType, props: e.props as unknown as Record<string, unknown> }]));
35
+
36
+ const declared = entities({
37
+ readEvents: grant`GRANT SELECT ON shop.events TO app`,
38
+ readOrders: grant`GRANT SELECT ON shop.orders TO app`,
39
+ });
40
+
41
+ describe("pendingGrants (#3733)", () => {
42
+ test("a grantee holding what is declared has nothing pending", async () => {
43
+ held = { app: ["GRANT SELECT ON shop.events TO app", "GRANT SELECT ON shop.orders TO app"] };
44
+ expect(await pendingGrants(target, declared)).toEqual([]);
45
+ });
46
+
47
+ test("a privilege granted by hand is a REVOKE, one revoked by hand a GRANT, revokes first", async () => {
48
+ held = { app: ["GRANT INSERT, SELECT ON shop.events TO app"] };
49
+ expect(await pendingGrants(target, declared)).toEqual([
50
+ {
51
+ subject: "grants app",
52
+ change: "REVOKE INSERT ON `shop`.`events` FROM `app`; GRANT SELECT ON `shop`.`orders` TO `app`",
53
+ entities: ["readEvents", "readOrders"],
54
+ },
55
+ ]);
56
+ });
57
+
58
+ test("a grantee the server does not hold yet is given every declared grant", async () => {
59
+ held = {};
60
+ expect((await pendingGrants(target, declared)).map((p) => p.change)).toEqual(["GRANT SELECT ON `shop`.`events` TO `app`; GRANT SELECT ON `shop`.`orders` TO `app`"]);
61
+ });
62
+
63
+ test("no grant declarations read nothing", async () => {
64
+ held = {};
65
+ expect(await pendingGrants(target, new Map())).toEqual([]);
66
+ });
67
+ });