@intentius/chant-lexicon-sql 0.124.19 → 0.124.22

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 (156) hide show
  1. package/dist/clickhouse/access.d.ts +36 -7
  2. package/dist/clickhouse/access.d.ts.map +1 -1
  3. package/dist/clickhouse/apply/statements.d.ts.map +1 -1
  4. package/dist/clickhouse/entities.d.ts +60 -0
  5. package/dist/clickhouse/entities.d.ts.map +1 -1
  6. package/dist/clickhouse/import/generator.d.ts +1 -7
  7. package/dist/clickhouse/import/generator.d.ts.map +1 -1
  8. package/dist/clickhouse/live/catalog.d.ts +6 -5
  9. package/dist/clickhouse/live/catalog.d.ts.map +1 -1
  10. package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
  11. package/dist/clickhouse/parser.d.ts +16 -0
  12. package/dist/clickhouse/parser.d.ts.map +1 -1
  13. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  14. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  15. package/dist/clickhouse/plan/normalize.d.ts +6 -3
  16. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  17. package/dist/clickhouse/plan/rules.d.ts +3 -0
  18. package/dist/clickhouse/plan/rules.d.ts.map +1 -1
  19. package/dist/clickhouse/topology.d.ts.map +1 -1
  20. package/dist/clickhouse.d.ts +1 -1
  21. package/dist/clickhouse.d.ts.map +1 -1
  22. package/dist/codegen/registry.d.ts.map +1 -1
  23. package/dist/coverage/clickhouse.json +8 -8
  24. package/dist/coverage/postgres.json +6 -6
  25. package/dist/files/clickhouse.d.ts +2 -1
  26. package/dist/files/clickhouse.d.ts.map +1 -1
  27. package/dist/files/postgres.d.ts +1 -1
  28. package/dist/index.d.ts +2 -2
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/integrity.json +10 -10
  31. package/dist/lint/post-synth/postgres-names.d.ts.map +1 -1
  32. package/dist/lint/post-synth/sql101.d.ts.map +1 -1
  33. package/dist/lint/rules/sqlch001.d.ts +1 -1
  34. package/dist/lint/rules/sqlch001.d.ts.map +1 -1
  35. package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
  36. package/dist/lint/rules/templates.d.ts +1 -1
  37. package/dist/lint/rules/templates.d.ts.map +1 -1
  38. package/dist/lsp/postgres-template.d.ts.map +1 -1
  39. package/dist/manifest.json +1 -1
  40. package/dist/meta.json +40 -0
  41. package/dist/okf/index.md +9 -1
  42. package/dist/okf/rules/SQL101.md +1 -0
  43. package/dist/okf/rules/SQLCH001.md +2 -2
  44. package/dist/okf/rules/SQLPG113.md +2 -0
  45. package/dist/okf/types/PostgresAggregate.md +13 -0
  46. package/dist/okf/types/PostgresCast.md +9 -0
  47. package/dist/okf/types/PostgresCollation.md +9 -0
  48. package/dist/okf/types/PostgresCompositeType.md +13 -0
  49. package/dist/okf/types/PostgresEventTrigger.md +9 -0
  50. package/dist/okf/types/PostgresRangeType.md +13 -0
  51. package/dist/okf/types/Quota.md +9 -0
  52. package/dist/okf/types/SettingsProfile.md +9 -0
  53. package/dist/plugin.d.ts.map +1 -1
  54. package/dist/postgres/apply/apply.d.ts.map +1 -1
  55. package/dist/postgres/apply/statements.d.ts +4 -3
  56. package/dist/postgres/apply/statements.d.ts.map +1 -1
  57. package/dist/postgres/entities.d.ts +129 -6
  58. package/dist/postgres/entities.d.ts.map +1 -1
  59. package/dist/postgres/entity-types.d.ts +6 -0
  60. package/dist/postgres/entity-types.d.ts.map +1 -1
  61. package/dist/postgres/import/generator.d.ts +3 -2
  62. package/dist/postgres/import/generator.d.ts.map +1 -1
  63. package/dist/postgres/live/catalog-kinds.d.ts +28 -0
  64. package/dist/postgres/live/catalog-kinds.d.ts.map +1 -0
  65. package/dist/postgres/live/catalog.d.ts +15 -11
  66. package/dist/postgres/live/catalog.d.ts.map +1 -1
  67. package/dist/postgres/live/describe-resources.d.ts.map +1 -1
  68. package/dist/postgres/parser.d.ts +88 -5
  69. package/dist/postgres/parser.d.ts.map +1 -1
  70. package/dist/postgres/plan/deep.d.ts.map +1 -1
  71. package/dist/postgres/plan/diff.d.ts.map +1 -1
  72. package/dist/postgres/plan/normalize.d.ts +17 -2
  73. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  74. package/dist/postgres/plan/rules.d.ts +3 -0
  75. package/dist/postgres/plan/rules.d.ts.map +1 -1
  76. package/dist/postgres/plan/schema.d.ts +3 -2
  77. package/dist/postgres/plan/schema.d.ts.map +1 -1
  78. package/dist/postgres/testing/roundtrip-schema.d.ts +4 -2
  79. package/dist/postgres/testing/roundtrip-schema.d.ts.map +1 -1
  80. package/dist/postgres.d.ts +1 -1
  81. package/dist/postgres.d.ts.map +1 -1
  82. package/dist/rules/postgres-names.ts +15 -2
  83. package/dist/rules/postgres-templates.ts +2 -2
  84. package/dist/rules/sql101.ts +1 -0
  85. package/dist/rules/sqlch001.ts +10 -3
  86. package/dist/rules/sqlpg001.ts +6 -21
  87. package/dist/rules/sqlpg113.ts +1 -1
  88. package/dist/rules/templates.ts +2 -2
  89. package/dist/validate.d.ts.map +1 -1
  90. package/package.json +2 -2
  91. package/src/clickhouse/access.ts +197 -22
  92. package/src/clickhouse/apply/statements.ts +17 -5
  93. package/src/clickhouse/entities.ts +78 -2
  94. package/src/clickhouse/import/generator.ts +16 -3
  95. package/src/clickhouse/live/catalog.ts +57 -10
  96. package/src/clickhouse/live/describe-resources.ts +3 -1
  97. package/src/clickhouse/live/kinds.e2e.test.ts +179 -0
  98. package/src/clickhouse/live/undeclarable.e2e.test.ts +5 -4
  99. package/src/clickhouse/parser.test.ts +45 -2
  100. package/src/clickhouse/parser.ts +111 -3
  101. package/src/clickhouse/plan/deep.ts +25 -6
  102. package/src/clickhouse/plan/diff.ts +41 -1
  103. package/src/clickhouse/plan/normalize.ts +12 -4
  104. package/src/clickhouse/plan/rules.ts +22 -1
  105. package/src/clickhouse/quota-profile.test.ts +186 -0
  106. package/src/clickhouse/table-function.test.ts +54 -0
  107. package/src/clickhouse/testdata/server-ddl-baseline.json +17 -14
  108. package/src/clickhouse/topology.ts +4 -2
  109. package/src/clickhouse.ts +6 -0
  110. package/src/codegen/postgres-classifier-page.ts +1 -1
  111. package/src/codegen/registry.ts +8 -0
  112. package/src/core/undeclarable.test.ts +22 -19
  113. package/src/coverage/clickhouse.json +8 -8
  114. package/src/coverage/postgres.json +6 -6
  115. package/src/files/clickhouse.ts +14 -6
  116. package/src/files/postgres.ts +19 -5
  117. package/src/generated/lexicon-sql.json +40 -0
  118. package/src/index.ts +2 -2
  119. package/src/lint/post-synth/postgres-names.ts +15 -2
  120. package/src/lint/post-synth/sql101.ts +1 -0
  121. package/src/lint/post-synth/sqlpg113.ts +1 -1
  122. package/src/lint/rules/postgres-templates.ts +2 -2
  123. package/src/lint/rules/sqlch001.ts +10 -3
  124. package/src/lint/rules/sqlpg001.ts +6 -21
  125. package/src/lint/rules/templates.ts +2 -2
  126. package/src/lsp/postgres-template.ts +14 -0
  127. package/src/mcp/index.ts +5 -5
  128. package/src/mcp/postgres.ts +1 -1
  129. package/src/plugin.ts +7 -1
  130. package/src/postgres/apply/apply.ts +8 -2
  131. package/src/postgres/apply/statements.ts +29 -6
  132. package/src/postgres/entities.test.ts +2 -2
  133. package/src/postgres/entities.ts +316 -28
  134. package/src/postgres/entity-types.ts +6 -0
  135. package/src/postgres/import/generator.ts +31 -4
  136. package/src/postgres/kinds.test.ts +140 -0
  137. package/src/postgres/live/catalog-kinds.ts +312 -0
  138. package/src/postgres/live/catalog.ts +30 -12
  139. package/src/postgres/live/describe-resources.ts +14 -5
  140. package/src/postgres/live/kinds.e2e.test.ts +182 -0
  141. package/src/postgres/live/roundtrip.e2e.test.ts +9 -3
  142. package/src/postgres/live/undeclarable.e2e.test.ts +15 -13
  143. package/src/postgres/parser.test.ts +2 -1
  144. package/src/postgres/parser.ts +307 -17
  145. package/src/postgres/plan/deep.ts +33 -0
  146. package/src/postgres/plan/diff.ts +45 -0
  147. package/src/postgres/plan/normalize.ts +153 -2
  148. package/src/postgres/plan/rules.ts +21 -0
  149. package/src/postgres/plan/schema.ts +6 -4
  150. package/src/postgres/testing/roundtrip-schema.ts +19 -1
  151. package/src/postgres.ts +19 -0
  152. package/src/skill-defs.ts +1 -1
  153. package/src/skills/chant-sql-postgres.md +7 -3
  154. package/src/skills/chant-sql.md +3 -1
  155. package/src/unknown-clauses.test.ts +2 -1
  156. package/src/validate.ts +8 -0
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * ClickHouse access control as declarations (#3682): users, roles, row
3
- * policies and grants.
3
+ * policies and grants, and quotas and settings profiles (#3911).
4
4
  *
5
5
  * Each is a statement the server keeps outside any table: `CREATE USER`,
6
- * `CREATE ROLE`, `CREATE ROW POLICY` and `GRANT`. This module parses them
6
+ * `CREATE ROLE`, `CREATE ROW POLICY`, `CREATE QUOTA`,
7
+ * `CREATE SETTINGS PROFILE` and `GRANT`. This module parses them
7
8
  * (the tags in `./entities.ts`, the lint rule, the plan), reduces each to
8
9
  * the fields a plan compares, and writes the statements that make a change.
9
10
  *
@@ -30,19 +31,24 @@ import { isTrivia, tokenizeText, type Token } from "./tokens";
30
31
  import { kw, SqlCursor, type Span } from "../core/cursor";
31
32
  import { unquote } from "./parser";
32
33
 
33
- export type AccessStatement = "user" | "role" | "rowPolicy" | "grant";
34
+ export type AccessStatement = "user" | "role" | "rowPolicy" | "quota" | "settingsProfile" | "grant";
34
35
 
35
36
  /** One parsed access statement. Clause spans are token spans, keywords left out. */
36
37
  export interface AccessNode {
37
38
  statement: AccessStatement;
38
39
  orReplace: boolean;
39
40
  ifNotExists: boolean;
40
- /** A user's, role's or row policy's name; none for a grant. */
41
+ /** A user's, role's, row policy's, quota's or settings profile's name; none for a grant. */
41
42
  name?: Span;
42
43
  onCluster?: Span;
43
44
  /** A row policy's table, `[db.]table`. */
44
45
  table?: Span;
45
- /** Each clause after its keywords, by key: `identified`, `host`, `defaultRole`, `defaultDatabase`, `grantees`, `settings`, `validUntil`, `as`, `for`, `using`, `to`. */
46
+ /**
47
+ * Each clause after its keywords, by key: `identified`, `host`, `defaultRole`,
48
+ * `defaultDatabase`, `grantees`, `settings`, `validUntil`, `as`, `for`,
49
+ * `using`, `to`; a quota's `keyed`, `notKeyed` and `for` (every interval,
50
+ * `FOR` to `FOR`, as one span).
51
+ */
46
52
  clauses: Partial<Record<string, Span>>;
47
53
  /** A grant's privileges or roles, one span each. */
48
54
  items: Span[];
@@ -56,7 +62,9 @@ export interface AccessNode {
56
62
  revoke?: boolean;
57
63
  }
58
64
 
59
- const isName = (t: Token | undefined): boolean => t !== undefined && (t.kind === "ident" || t.kind === "qident" || t.kind === "ref");
65
+ /** A token that can stand where a name goes: a name may start with a digit (`02294_profile`). */
66
+ const isName = (t: Token | undefined): boolean =>
67
+ t !== undefined && (t.kind === "ident" || t.kind === "qident" || t.kind === "ref" || (t.kind === "number" && /[A-Za-z_]/.test(t.text)));
60
68
 
61
69
  class AccessParser extends SqlCursor {
62
70
  private name(what: string): Span {
@@ -108,18 +116,29 @@ class AccessParser extends SqlCursor {
108
116
  return undefined;
109
117
  }
110
118
 
111
- /** Clauses in any order: each starts with one of `starts` (a keyword sequence) and runs to the next clause. */
112
- private clauses(starts: Record<string, string[]>, node: AccessNode): void {
119
+ /**
120
+ * Clauses in any order: each starts with one of `starts` (a keyword
121
+ * sequence) and runs to the next clause. A key of `repeats` may follow
122
+ * itself (a quota's `FOR INTERVAL ..., FOR INTERVAL ...`): its span runs
123
+ * from the first to the last.
124
+ */
125
+ private clauses(starts: Record<string, string[]>, node: AccessNode, repeats: readonly string[] = []): void {
113
126
  const stopWords = [...new Set(Object.values(starts).map((w) => w[0]!))];
127
+ let last: string | undefined;
114
128
  for (;;) {
115
129
  if (this.atEnd()) return;
116
130
  const hit = Object.entries(starts).find(([, words]) => words.every((w, i) => kw(this.peek(i), w)));
117
131
  if (!hit) this.fail(`unexpected ${this.peek()?.text}: expected ${Object.values(starts).map((w) => w.join(" ")).join(", ")}`);
118
132
  const [key, words] = hit;
133
+ const again = node.clauses[key];
134
+ if (again && !(repeats.includes(key) && last === key)) this.fail(`${words.join(" ")} is written twice`);
135
+ // A repeated clause keeps its keywords inside the span: `FOR INTERVAL 1 hour ..., FOR INTERVAL 1 day ...`.
136
+ const from = this.idx();
119
137
  this.p += words.length;
120
- if (node.clauses[key]) this.fail(`${words.join(" ")} is written twice`);
121
- // `NOT IDENTIFIED` has no value; every other clause has one.
122
- node.clauses[key] = key === "notIdentified" ? { from: this.idx(-1), to: this.idx(-1) + 1, refs: [] } : this.expr(stopWords, false);
138
+ // `NOT IDENTIFIED` and `NOT KEYED` have no value; every other clause has one.
139
+ const value = key === "notIdentified" || key === "notKeyed" ? { from: this.idx(-1), to: this.idx(-1) + 1, refs: [] } : this.expr(stopWords, false);
140
+ node.clauses[key] = repeats.includes(key) ? { from: again?.from ?? from, to: value.to, refs: [...(again?.refs ?? []), ...value.refs] } : value;
141
+ last = key;
123
142
  }
124
143
  }
125
144
 
@@ -160,8 +179,33 @@ class AccessParser extends SqlCursor {
160
179
  this.clauses({ in: ["IN"], settings: ["SETTINGS"] }, n);
161
180
  return n;
162
181
  }
182
+ if (this.accept("QUOTA")) {
183
+ this.head();
184
+ const n = node("quota");
185
+ n.name = this.name("a quota name");
186
+ if (this.isPunct(",")) this.fail("declare one quota per template");
187
+ n.onCluster = this.onClusterClause();
188
+ this.clauses({ in: ["IN"], keyed: ["KEYED", "BY"], keyBy: ["KEY", "BY"], notKeyed: ["NOT", "KEYED"], for: ["FOR"], to: ["TO"] }, n, ["for"]);
189
+ // `KEY BY` is the server's other spelling of `KEYED BY`.
190
+ if (n.clauses.keyBy) {
191
+ if (n.clauses.keyed) this.fail("KEYED BY is written twice");
192
+ n.clauses.keyed = n.clauses.keyBy;
193
+ delete n.clauses.keyBy;
194
+ }
195
+ if (n.clauses.keyed && n.clauses.notKeyed) this.fail("KEYED BY and NOT KEYED are both written");
196
+ return n;
197
+ }
198
+ if (this.acceptSeq("SETTINGS", "PROFILE") || this.accept("PROFILE")) {
199
+ this.head();
200
+ const n = node("settingsProfile");
201
+ n.name = this.name("a settings profile name");
202
+ if (this.isPunct(",")) this.fail("declare one settings profile per template");
203
+ n.onCluster = this.onClusterClause();
204
+ this.clauses({ in: ["IN"], settings: ["SETTINGS"], to: ["TO"] }, n);
205
+ return n;
206
+ }
163
207
  if (this.accept("ROW")) this.expect("POLICY");
164
- else if (!this.accept("POLICY")) this.fail("expected USER, ROLE or ROW POLICY");
208
+ else if (!this.accept("POLICY")) this.fail("expected USER, ROLE, ROW POLICY, QUOTA or SETTINGS PROFILE");
165
209
  this.head();
166
210
  const n = node("rowPolicy");
167
211
  n.name = this.name("a policy name");
@@ -240,7 +284,7 @@ export function isAccessStatement(ddl: string): boolean {
240
284
  .map((w) => w.toUpperCase());
241
285
  if (words[0] === "GRANT" || words[0] === "REVOKE") return true;
242
286
  if (words[0] !== "CREATE") return false;
243
- return words[1] === "USER" || words[1] === "ROLE" || words[1] === "ROW" || words[1] === "POLICY";
287
+ return ["USER", "ROLE", "ROW", "POLICY", "QUOTA", "PROFILE"].includes(words[1]!) || (words[1] === "SETTINGS" && words[2] === "PROFILE");
244
288
  }
245
289
 
246
290
  // ── canonical forms ───────────────────────────────────────────────────
@@ -284,9 +328,8 @@ function nameList(tokens: Token[], span: Span, keywords: string[]): string {
284
328
  return [...keys, ...names].join(" , ");
285
329
  }
286
330
 
287
- /** `SETTINGS a = 1 READONLY, b MIN 0 MAX 2`: each setting, sorted by name, `READONLY` as the server prints it (`CONST`). */
288
- function settingsList(tokens: Token[], span: Span): string {
289
- const sig = sigOf(tokens, span);
331
+ /** A span's significant tokens split at its top-level commas. */
332
+ function topLevelParts(sig: Token[]): Token[][] {
290
333
  const parts: Token[][] = [[]];
291
334
  let depth = 0;
292
335
  for (const t of sig) {
@@ -295,14 +338,104 @@ function settingsList(tokens: Token[], span: Span): string {
295
338
  if (depth === 0 && t.kind === "punct" && t.text === ",") parts.push([]);
296
339
  else parts[parts.length - 1]!.push(t);
297
340
  }
298
- const words = new Set(["MIN", "MAX", "CONST", "READONLY", "WRITABLE", "CHANGEABLE_IN_READONLY", "PROFILE", "NONE"]);
299
- return parts
300
- .filter((p) => p.length > 0)
301
- .map((p) => join(p, (w) => (words.has(w.toUpperCase()) ? w.toUpperCase() : w)).replace(/\bREADONLY$/, "CONST"))
341
+ return parts.filter((p) => p.length > 0);
342
+ }
343
+
344
+ /**
345
+ * `SETTINGS a = 1 READONLY, b MIN 0 MAX 2, PROFILE 'base'`: each setting,
346
+ * sorted by name, `READONLY` as the server prints it (`CONST`), and a
347
+ * profile inherited as the server prints it (`INHERIT base`).
348
+ */
349
+ function settingsList(tokens: Token[], span: Span): string {
350
+ const words = new Set(["MIN", "MAX", "CONST", "READONLY", "WRITABLE", "CHANGEABLE_IN_READONLY", "PROFILE", "INHERIT", "NONE"]);
351
+ return topLevelParts(sigOf(tokens, span))
352
+ .map((p) => {
353
+ const head = p[0]!.text.toUpperCase();
354
+ if ((head === "PROFILE" || head === "INHERIT") && p.length === 2 && p[0]!.kind === "ident") return `INHERIT ${p[1]!.kind === "string" ? stringText(p[1]!.text) : unquote(p[1]!.text)}`;
355
+ // The first word is the setting's name (`readonly = 1`), never a key word.
356
+ return [join(p.slice(0, 1), (w) => w), join(p.slice(1), (w) => (words.has(w.toUpperCase()) ? w.toUpperCase() : w))].filter(Boolean).join(" ").replace(/\bREADONLY$/, "CONST");
357
+ })
302
358
  .sort()
303
359
  .join(" , ");
304
360
  }
305
361
 
362
+ /** A `'quoted'` string's value. */
363
+ const stringText = (s: string): string => s.slice(1, -1).replace(/''/g, "'").replace(/\\(.)/g, "$1");
364
+
365
+ /** The length of each interval unit a quota takes, in seconds, as the server counts it (a month is a twelfth of a year). */
366
+ export const QUOTA_INTERVAL_SECONDS: Readonly<Record<string, number>> = {
367
+ SECOND: 1,
368
+ MINUTE: 60,
369
+ HOUR: 3600,
370
+ DAY: 86400,
371
+ WEEK: 604800,
372
+ MONTH: 2629746,
373
+ QUARTER: 7889238,
374
+ YEAR: 31556952,
375
+ };
376
+
377
+ /** A limit's number as the server prints it: `1e9` is `1000000000`, `1000.0` is `1000`. */
378
+ function quotaNumber(text: string): string {
379
+ if (/^\d+$/.test(text)) return text.replace(/^0+(?=\d)/, "");
380
+ const n = Number(text);
381
+ return Number.isFinite(n) ? (Number.isSafeInteger(n) || !Number.isInteger(n) ? String(n) : BigInt(Math.round(n)).toString()) : text;
382
+ }
383
+
384
+ /**
385
+ * A quota's intervals (`FOR [RANDOMIZED] INTERVAL n unit {MAX k = v, ... |
386
+ * NO LIMITS | TRACKING ONLY}`, each `FOR` to the next), keyed by the
387
+ * interval's length in seconds, each as the server keeps it: limits sorted
388
+ * by name, lower case, `MAX queries 5` as `queries = 5`. An interval with
389
+ * `NO LIMITS` is no interval; a second one of the same length replaces the
390
+ * first, as on the server.
391
+ */
392
+ export function quotaIntervals(tokens: Token[], span: Span): Record<string, string> {
393
+ const sig = sigOf(tokens, span);
394
+ const groups: Token[][] = [];
395
+ for (const t of sig) {
396
+ if (t.kind === "ident" && t.text.toUpperCase() === "FOR") groups.push([]);
397
+ else if (groups.length > 0) groups[groups.length - 1]!.push(t);
398
+ }
399
+ const out: Record<string, string> = {};
400
+ for (const g of groups) {
401
+ const words = g.filter((t) => !(t.kind === "punct" && t.text === ","));
402
+ let i = 0;
403
+ const randomized = words[i]?.text.toUpperCase() === "RANDOMIZED";
404
+ if (randomized) i++;
405
+ if (words[i]?.text.toUpperCase() !== "INTERVAL") throw new Error(`a quota's FOR takes INTERVAL n unit: FOR ${g.map((t) => t.text).join(" ")}`);
406
+ const count = Number(words[i + 1]?.text);
407
+ const unit = QUOTA_INTERVAL_SECONDS[(words[i + 2]?.text ?? "").toUpperCase().replace(/S$/, "")];
408
+ if (!Number.isInteger(count) || unit === undefined) throw new Error(`a quota's interval is a whole number and a unit (second to year): FOR ${g.map((t) => t.text).join(" ")}`);
409
+ const rest = words.slice(i + 3);
410
+ const head = rest.map((t) => t.text.toUpperCase()).slice(0, 2).join(" ");
411
+ const seconds = String(count * unit);
412
+ if (head === "NO LIMITS") {
413
+ delete out[seconds];
414
+ continue;
415
+ }
416
+ let limits: string;
417
+ if (head === "TRACKING ONLY") limits = "TRACKING ONLY";
418
+ else {
419
+ if (rest[0]?.text.toUpperCase() !== "MAX") throw new Error(`a quota's interval takes MAX, NO LIMITS or TRACKING ONLY: FOR ${g.map((t) => t.text).join(" ")}`);
420
+ // After MAX: `name = value` or `name value`, separated by commas (already left out).
421
+ const items: string[] = [];
422
+ const after = rest.slice(1);
423
+ for (let k = 0; k < after.length; ) {
424
+ const name = after[k]!.text.toLowerCase();
425
+ k++;
426
+ if (after[k]?.text === "=" && after[k]!.kind !== "string") k++;
427
+ const value = after[k];
428
+ if (!value) throw new Error(`a quota's limit ${name} has no value`);
429
+ items.push(`${name} = ${quotaNumber(value.text)}`);
430
+ k++;
431
+ }
432
+ limits = `MAX ${items.sort().join(" , ")}`;
433
+ }
434
+ out[seconds] = `${randomized ? "RANDOMIZED " : ""}${limits}`;
435
+ }
436
+ return out;
437
+ }
438
+
306
439
  /** `HOST LOCAL, IP '10.0.0.0/8'`: each host, sorted, the kind of each upper case. */
307
440
  function hostList(tokens: Token[], span: Span): string {
308
441
  const words = new Set(["LOCAL", "ANY", "NONE", "IP", "NAME", "REGEXP", "LIKE"]);
@@ -326,9 +459,9 @@ function qualifiedName(tokens: Token[], span: Span, defaultDatabase: string): {
326
459
  return parts.length >= 2 ? { database: parts[parts.length - 2]!, name: parts[parts.length - 1]! } : { database: defaultDatabase, name: parts[0] ?? "" };
327
460
  }
328
461
 
329
- /** What a plan compares of a user, role or row policy: one string per field, absent at its default. */
462
+ /** What a plan compares of a user, role, row policy, quota or settings profile: one string per field, absent at its default. */
330
463
  export interface AccessCanonical {
331
- kind: "user" | "role" | "rowPolicy";
464
+ kind: "user" | "role" | "rowPolicy" | "quota" | "settingsProfile";
332
465
  name: string;
333
466
  /** A row policy's table's database. */
334
467
  database?: string;
@@ -373,6 +506,21 @@ export function accessCanonical(ddl: string, defaultDatabase = "default"): Acces
373
506
  put("settings", c.settings && settingsList(tokens, c.settings), "NONE");
374
507
  return { kind: "role", name, fields };
375
508
  }
509
+ if (node.statement === "settingsProfile") {
510
+ put("settings", c.settings && settingsList(tokens, c.settings), "NONE");
511
+ put("to", c.to && nameList(tokens, c.to, ["ALL", "NONE", "EXCEPT"]), "NONE");
512
+ return { kind: "settingsProfile", name, fields };
513
+ }
514
+ if (node.statement === "quota") {
515
+ // `KEYED BY 'user_name'`, `KEYED BY client_key, user_name`, `KEYED BY ip_address IPV6_PREFIX_BITS 64`: unquoted, lower case.
516
+ if (c.keyed) {
517
+ const keys = topLevelParts(sigOf(tokens, c.keyed)).map((p) => p.map((t) => (t.kind === "string" ? stringText(t.text) : unquote(t.text)).toLowerCase()).join(" "));
518
+ put("keyed", keys.join(" , "));
519
+ }
520
+ if (c.for) for (const [seconds, limits] of Object.entries(quotaIntervals(tokens, c.for))) fields[`interval.${seconds}`] = limits;
521
+ put("to", c.to && nameList(tokens, c.to, ["ALL", "NONE", "EXCEPT"]), "NONE");
522
+ return { kind: "quota", name, fields };
523
+ }
376
524
  const table = qualifiedName(tokens, node.table!, defaultDatabase);
377
525
  put("as", c.as && text(tokens, c.as)!.toUpperCase(), "PERMISSIVE");
378
526
  put("for", c.for && join(sigOf(tokens, c.for), (w) => w.toUpperCase()), "SELECT");
@@ -556,6 +704,33 @@ export function alterAccessField(kind: "user" | "role", name: string, field: str
556
704
  }
557
705
  }
558
706
 
707
+ /**
708
+ * The `ALTER` that sets one field of a quota or settings profile to its
709
+ * declared value, or back to the server's default when the declaration
710
+ * leaves it out. Never `CREATE ... OR REPLACE`: that makes a new object
711
+ * under the name, and a profile that inherits the old one, or a quota's
712
+ * usage, is cut off from it.
713
+ *
714
+ * A quota's interval is set whole (`ALTER QUOTA q FOR INTERVAL 3600 second
715
+ * MAX queries = 5` replaces that interval's limits), and taken away with
716
+ * `NO LIMITS`.
717
+ */
718
+ export function alterQuotaOrProfileField(kind: "quota" | "settingsProfile", name: string, field: string, declared: AccessCanonical["fields"], declaredText: Partial<Record<string, string>>): string {
719
+ const head = `ALTER ${kind === "quota" ? "QUOTA" : "SETTINGS PROFILE"} ${ident(name)}`;
720
+ const interval = /^interval\.(\d+)$/.exec(field);
721
+ if (kind === "quota" && interval) {
722
+ const value = declared[field];
723
+ if (value === undefined) return `${head} FOR INTERVAL ${interval[1]} second NO LIMITS`;
724
+ const randomized = value.startsWith("RANDOMIZED ");
725
+ const limits = (randomized ? value.slice("RANDOMIZED ".length) : value).replace(/ , /g, ", ");
726
+ return `${head} FOR ${randomized ? "RANDOMIZED " : ""}INTERVAL ${interval[1]} second ${limits}`;
727
+ }
728
+ if (kind === "quota" && field === "keyed") return declaredText.keyed === undefined ? `${head} NOT KEYED` : `${head} KEYED BY ${declaredText.keyed}`;
729
+ if (field === "to") return `${head} TO ${declaredText.to ?? "NONE"}`;
730
+ if (kind === "settingsProfile" && field === "settings") return `${head} SETTINGS ${declaredText.settings ?? "NONE"}`;
731
+ throw new Error(`no ALTER for ${kind} field ${field}`);
732
+ }
733
+
559
734
  /** A user's or role's clauses as written, by field, for the `ALTER` that sets one. */
560
735
  export function accessClauseText(ddl: string): Partial<Record<string, string>> {
561
736
  const tokens = tokenizeText(ddl, 0);
@@ -24,7 +24,7 @@ import { CLASSIFIER_RULES, type ChangeClass, type ClassifierRuleId } from "../pl
24
24
  import { stampedComment } from "../ownership";
25
25
  import { CLICKHOUSE_ENTITY_TYPES, type ClickHouseEntityType } from "../entities";
26
26
  import { renderFor, type Topology } from "../topology";
27
- import { accessClauseText, alterAccessField, grantStatement, revokeStatement, SECRETLESS, type GrantAtom } from "../access";
27
+ import { accessClauseText, alterAccessField, alterQuotaOrProfileField, grantStatement, revokeStatement, SECRETLESS, type GrantAtom } from "../access";
28
28
 
29
29
  /** A backquoted identifier. */
30
30
  export const ident = (name: string): string => `\`${name.replace(/`/g, "``")}\``;
@@ -143,7 +143,7 @@ export function createStatement(
143
143
  } else {
144
144
  sql = `${statementBody(tokens)} COMMENT ${literal}`;
145
145
  }
146
- if (opts.orReplace && (node.statement === "view" || node.statement === "dictionary") && !node.orReplace) {
146
+ if (opts.orReplace && (node.statement === "view" || node.statement === "dictionary" || node.statement === "table") && !node.orReplace) {
147
147
  sql = sql.replace(/^(\s*(?:--[^\n]*\n\s*)*)CREATE\s+/i, "$1CREATE OR REPLACE ");
148
148
  }
149
149
  return sql;
@@ -407,7 +407,9 @@ export function alterSteps(obj: DeclaredObject, changes: readonly Change[], opts
407
407
  if (obj.type === CLICKHOUSE_ENTITY_TYPES.dictionary && changes.some((c) => c.rule === "SQLCH245")) {
408
408
  steps.push(stepFor(createStatement(obj, opts.marker, { orReplace: true }), "SQLCH245"));
409
409
  }
410
- if (obj.type === CLICKHOUSE_ENTITY_TYPES.table) steps.push(...tableSteps(obj, changes, opts.live));
410
+ // A table over a table function stores nothing: it is replaced whole (#3911), its comment and marker with it.
411
+ if (obj.type === CLICKHOUSE_ENTITY_TYPES.table && changes.some((c) => c.rule === "SQLCH219")) steps.push(stepFor(createStatement(obj, opts.marker, { orReplace: true }), "SQLCH219"));
412
+ else if (obj.type === CLICKHOUSE_ENTITY_TYPES.table) steps.push(...tableSteps(obj, changes, opts.live));
411
413
  else if (obj.type === CLICKHOUSE_ENTITY_TYPES.view || obj.type === CLICKHOUSE_ENTITY_TYPES.materializedView) steps.push(...viewSteps(obj, changes, opts.marker));
412
414
  return steps;
413
415
  }
@@ -416,6 +418,7 @@ export function alterSteps(obj: DeclaredObject, changes: readonly Change[], opts
416
418
  export function stepsSetComment(obj: DeclaredObject, changes: readonly Change[]): boolean {
417
419
  return (
418
420
  (obj.type === CLICKHOUSE_ENTITY_TYPES.view && changes.some((c) => c.rule === "SQLCH240")) ||
421
+ (obj.type === CLICKHOUSE_ENTITY_TYPES.table && changes.some((c) => c.rule === "SQLCH219")) ||
419
422
  (obj.type === CLICKHOUSE_ENTITY_TYPES.dictionary && changes.some((c) => c.rule === "SQLCH245"))
420
423
  );
421
424
  }
@@ -509,17 +512,19 @@ export function planStatements(input: StatementPlanInput): StatementPlan {
509
512
  };
510
513
  }
511
514
 
512
- const NEVER_DROPPED: ReadonlySet<string> = new Set(["function", "user", "role", "rowPolicy", "grants"]);
515
+ const NEVER_DROPPED: ReadonlySet<string> = new Set(["function", "user", "role", "rowPolicy", "quota", "settingsProfile", "grants"]);
513
516
 
514
517
  /** The entity types of access control (`../access.ts`): no comment, no marker, never dropped. */
515
518
  export const ACCESS_TYPES: ReadonlySet<string> = new Set([
516
519
  CLICKHOUSE_ENTITY_TYPES.user,
517
520
  CLICKHOUSE_ENTITY_TYPES.role,
518
521
  CLICKHOUSE_ENTITY_TYPES.rowPolicy,
522
+ CLICKHOUSE_ENTITY_TYPES.quota,
523
+ CLICKHOUSE_ENTITY_TYPES.settingsProfile,
519
524
  CLICKHOUSE_ENTITY_TYPES.grant,
520
525
  ]);
521
526
 
522
- /** What a user, role, row policy or grantee's grants take (`../access.ts`). */
527
+ /** What a user, role, row policy, quota, settings profile or grantee's grants take (`../access.ts`). */
523
528
  function accessStatements(obj: DeclaredObject, mine: Change[], live: CanonicalObject | undefined, deferred: DeferredDefaultRoles, environmentCreatesUsers: boolean): ObjectStatements {
524
529
  const kind = obj.canonical.kind;
525
530
  const name = obj.canonical.name;
@@ -567,6 +572,13 @@ function accessStatements(obj: DeclaredObject, mine: Change[], live: CanonicalOb
567
572
  : body.replace(/^(\s*(?:--[^\n]*\n\s*)*CREATE\s+(?:ROW\s+)?POLICY)\s+(?:IF\s+NOT\s+EXISTS\s+)?/i, "$1 OR REPLACE ");
568
573
  return { verdict: "alter", obj, changes: mine, steps: mine.some((c) => c.rule === "SQLCH272") ? [stepFor(replace, "SQLCH272")] : [] };
569
574
  }
575
+ if (kind === "quota" || kind === "settingsProfile") {
576
+ // ALTER, never OR REPLACE: a replaced profile is a new one, and what inherited the old one loses it.
577
+ const clauses = accessClauseText(obj.ddl);
578
+ const rule = kind === "quota" ? "SQLCH275" : "SQLCH276";
579
+ const steps = mine.filter((c) => c.rule === rule).map((c) => stepFor(alterQuotaOrProfileField(kind, name, c.field, obj.canonical.access ?? {}, clauses), rule));
580
+ return { verdict: "alter", obj, changes: mine, steps };
581
+ }
570
582
  const clauses = accessClauseText(obj.ddl);
571
583
  const rule = kind === "user" ? "SQLCH271" : "SQLCH270";
572
584
  const steps = mine
@@ -65,6 +65,8 @@ export const CLICKHOUSE_ENTITY_TYPES = {
65
65
  role: "ClickHouse::Role",
66
66
  rowPolicy: "ClickHouse::RowPolicy",
67
67
  grant: "ClickHouse::Grant",
68
+ quota: "ClickHouse::Quota",
69
+ settingsProfile: "ClickHouse::SettingsProfile",
68
70
  } as const;
69
71
 
70
72
  export type ClickHouseEntityType = (typeof CLICKHOUSE_ENTITY_TYPES)[keyof typeof CLICKHOUSE_ENTITY_TYPES];
@@ -128,6 +130,12 @@ export interface TableProps extends CommonProps, StorageProps {
128
130
  indexes: Array<{ name: string; expr: string; type: string; granularity?: string }>;
129
131
  projections: Array<{ name: string; definition: string }>;
130
132
  constraints: Array<{ name: string; kind: "CHECK" | "ASSUME"; expr: string }>;
133
+ /**
134
+ * For a table over a table function (#3911), the call after `AS`:
135
+ * `numbers(10)`, `remote('host', db, t)`. Such a table has no engine and
136
+ * stores nothing; its columns may be left to the server to infer.
137
+ */
138
+ asFunction?: string;
131
139
  orReplace?: boolean;
132
140
  ifNotExists?: boolean;
133
141
  }
@@ -315,6 +323,45 @@ export interface GrantProps {
315
323
  source: { strings: string[] };
316
324
  }
317
325
 
326
+ /** A quota (#3911): limits on what a user's queries use, per interval. */
327
+ export interface QuotaProps {
328
+ name: string;
329
+ onCluster?: string;
330
+ /** `KEYED BY ...` after the keywords: `user_name`, `client_key, user_name`. Absent when not keyed. */
331
+ keyed?: string;
332
+ /** Every `FOR [RANDOMIZED] INTERVAL ...` clause, as written. */
333
+ intervals?: string;
334
+ /** `TO ...` after the keyword: the users and roles it applies to. */
335
+ to?: string;
336
+ orReplace?: boolean;
337
+ ifNotExists?: boolean;
338
+ ddl: string;
339
+ source: { strings: string[] };
340
+ }
341
+
342
+ /** A settings profile (#3911): settings and their constraints, for the users and roles it is given to. */
343
+ export interface SettingsProfileProps {
344
+ name: string;
345
+ onCluster?: string;
346
+ /** `SETTINGS ...` after the keyword, as written. */
347
+ settings?: string;
348
+ /** `TO ...` after the keyword. */
349
+ to?: string;
350
+ orReplace?: boolean;
351
+ ifNotExists?: boolean;
352
+ ddl: string;
353
+ source: { strings: string[] };
354
+ }
355
+
356
+ export interface ClickHouseQuota extends ClickHouseObject {
357
+ readonly entityType: "ClickHouse::Quota";
358
+ readonly props: QuotaProps;
359
+ }
360
+ export interface ClickHouseSettingsProfile extends ClickHouseObject {
361
+ readonly entityType: "ClickHouse::SettingsProfile";
362
+ readonly props: SettingsProfileProps;
363
+ }
364
+
318
365
  export interface ClickHouseUser extends ClickHouseObject {
319
366
  readonly entityType: "ClickHouse::User";
320
367
  readonly props: UserProps;
@@ -585,6 +632,7 @@ function build(tag: Expect, strings: TemplateStringsArray | readonly string[], v
585
632
  ),
586
633
  projections: node.projections.map((p) => ({ name: p.name, definition: req(text(ctx, p.body, "projections")) })),
587
634
  constraints: node.constraints.map((c) => ({ name: c.name, kind: c.kind, expr: req(text(ctx, c.expr, "constraints")) })),
635
+ asFunction: text(ctx, node.asFunction, "asFunction"),
588
636
  ...storageProps(ctx, node),
589
637
  ddl,
590
638
  source,
@@ -729,12 +777,14 @@ export function func(strings: TemplateStringsArray, ...values: unknown[]): Click
729
777
  return build("func", strings, values) as ClickHouseFunction;
730
778
  }
731
779
 
732
- type AccessTag = "user" | "role" | "policy" | "grant";
733
- const ACCESS_STATEMENT: Record<AccessTag, AccessNode["statement"]> = { user: "user", role: "role", policy: "rowPolicy", grant: "grant" };
780
+ type AccessTag = "user" | "role" | "policy" | "quota" | "settingsProfile" | "grant";
781
+ const ACCESS_STATEMENT: Record<AccessTag, AccessNode["statement"]> = { user: "user", role: "role", policy: "rowPolicy", quota: "quota", settingsProfile: "settingsProfile", grant: "grant" };
734
782
  const ACCESS_HOLDS: Record<AccessNode["statement"], [string, AccessTag]> = {
735
783
  user: ["CREATE USER", "user"],
736
784
  role: ["CREATE ROLE", "role"],
737
785
  rowPolicy: ["CREATE ROW POLICY", "policy"],
786
+ quota: ["CREATE QUOTA", "quota"],
787
+ settingsProfile: ["CREATE SETTINGS PROFILE", "settingsProfile"],
738
788
  grant: ["GRANT", "grant"],
739
789
  };
740
790
 
@@ -786,6 +836,14 @@ function buildAccess(tag: AccessTag, strings: TemplateStringsArray | readonly st
786
836
  const props: RoleProps = strip({ ...common, settings: rendered(c.settings), ddl, source });
787
837
  return done(makeEntity(CLICKHOUSE_ENTITY_TYPES.role, quoteIdentifier(name), props, undefined, dependsOn));
788
838
  }
839
+ if (node.statement === "quota") {
840
+ const props: QuotaProps = strip({ ...common, keyed: rendered(c.keyed), intervals: rendered(c.for), to: rendered(c.to), ddl, source });
841
+ return done(makeEntity(CLICKHOUSE_ENTITY_TYPES.quota, quoteIdentifier(name), props, undefined, dependsOn));
842
+ }
843
+ if (node.statement === "settingsProfile") {
844
+ const props: SettingsProfileProps = strip({ ...common, settings: rendered(c.settings), to: rendered(c.to), ddl, source });
845
+ return done(makeEntity(CLICKHOUSE_ENTITY_TYPES.settingsProfile, quoteIdentifier(name), props, undefined, dependsOn));
846
+ }
789
847
  if (node.statement === "rowPolicy") {
790
848
  const { database, name: tableName } = qualified(ctx, node.table!);
791
849
  const props: RowPolicyProps = strip({
@@ -837,6 +895,24 @@ export function policy(strings: TemplateStringsArray, ...values: unknown[]): Cli
837
895
  return buildAccess("policy", strings, values) as ClickHouseRowPolicy;
838
896
  }
839
897
 
898
+ /**
899
+ * `` quota`CREATE QUOTA ...` ``: one quota (#3911), its key, its intervals'
900
+ * limits and whom it applies to. A plan changes it with `ALTER QUOTA`, one
901
+ * clause or interval at a time, and never drops it.
902
+ */
903
+ export function quota(strings: TemplateStringsArray, ...values: unknown[]): ClickHouseQuota {
904
+ return buildAccess("quota", strings, values) as ClickHouseQuota;
905
+ }
906
+
907
+ /**
908
+ * `` settingsProfile`CREATE SETTINGS PROFILE ...` ``: one settings profile
909
+ * (#3911), its settings, their constraints and the profiles it inherits.
910
+ * Interpolate it where a user, a role or another profile names it.
911
+ */
912
+ export function settingsProfile(strings: TemplateStringsArray, ...values: unknown[]): ClickHouseSettingsProfile {
913
+ return buildAccess("settingsProfile", strings, values) as ClickHouseSettingsProfile;
914
+ }
915
+
840
916
  /**
841
917
  * `` grant`GRANT ...` ``: privileges or roles granted to users or roles
842
918
  * (#3682). Every grant declaration naming a grantee adds up to its complete
@@ -29,7 +29,7 @@ export interface TemplateBody {
29
29
  refs: string[];
30
30
  }
31
31
 
32
- export const CLICKHOUSE_TAG_OF: Readonly<Record<string, "database" | "table" | "view" | "dictionary" | "func" | "user" | "role" | "policy" | "grant">> = {
32
+ export const CLICKHOUSE_TAG_OF: Readonly<Record<string, "database" | "table" | "view" | "dictionary" | "func" | "user" | "role" | "policy" | "quota" | "settingsProfile" | "grant">> = {
33
33
  [CLICKHOUSE_ENTITY_TYPES.database]: "database",
34
34
  [CLICKHOUSE_ENTITY_TYPES.table]: "table",
35
35
  [CLICKHOUSE_ENTITY_TYPES.view]: "view",
@@ -40,6 +40,8 @@ export const CLICKHOUSE_TAG_OF: Readonly<Record<string, "database" | "table" | "
40
40
  [CLICKHOUSE_ENTITY_TYPES.user]: "user",
41
41
  [CLICKHOUSE_ENTITY_TYPES.role]: "role",
42
42
  [CLICKHOUSE_ENTITY_TYPES.rowPolicy]: "policy",
43
+ [CLICKHOUSE_ENTITY_TYPES.quota]: "quota",
44
+ [CLICKHOUSE_ENTITY_TYPES.settingsProfile]: "settingsProfile",
43
45
  [CLICKHOUSE_ENTITY_TYPES.grant]: "grant",
44
46
  };
45
47
 
@@ -129,13 +131,24 @@ export const bodyText = (body: TemplateBody): string => body.parts.map((p, i) =>
129
131
  * name) interpolated, and the items in the order a file declares them: each
130
132
  * after what it references.
131
133
  */
134
+ /** The kinds a `FROM`, `JOIN` or `TO` never names. */
135
+ const NOT_RELATIONS = new Set<string>([
136
+ CLICKHOUSE_ENTITY_TYPES.function,
137
+ CLICKHOUSE_ENTITY_TYPES.user,
138
+ CLICKHOUSE_ENTITY_TYPES.role,
139
+ CLICKHOUSE_ENTITY_TYPES.rowPolicy,
140
+ CLICKHOUSE_ENTITY_TYPES.quota,
141
+ CLICKHOUSE_ENTITY_TYPES.settingsProfile,
142
+ CLICKHOUSE_ENTITY_TYPES.grant,
143
+ ]);
144
+
132
145
  export function clickhouseTemplates(items: readonly Item[], targets: readonly Item[] = items): { order: Item[]; bodies: Map<string, TemplateBody> } {
133
146
  const byQualified = new Map<string, Item>();
134
147
  const dbExport = new Map<string, string>();
135
148
  for (const it of targets) {
136
149
  if (it.type === CLICKHOUSE_ENTITY_TYPES.database) dbExport.set(it.name, it.exportName);
137
- // A function is called, never named after FROM, JOIN or TO; a row policy and a grant are never named at all.
138
- else if (it.type !== CLICKHOUSE_ENTITY_TYPES.function && it.type !== CLICKHOUSE_ENTITY_TYPES.rowPolicy && it.type !== CLICKHOUSE_ENTITY_TYPES.grant) byQualified.set(it.database ? `${it.database}.${it.name}` : it.name, it);
150
+ // A function is called, never named after FROM, JOIN or TO; access objects are never named so at all.
151
+ else if (!NOT_RELATIONS.has(it.type)) byQualified.set(it.database ? `${it.database}.${it.name}` : it.name, it);
139
152
  }
140
153
  const bodies = new Map(items.map((it) => [it.exportName, templateBody(it, byQualified, dbExport)]));
141
154
  // Declarations reference each other in one file, so each comes after what it references.