@intentius/chant-lexicon-sql 0.124.18 → 0.124.21

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 (198) 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/import/live-export.d.ts +8 -0
  9. package/dist/clickhouse/import/live-export.d.ts.map +1 -1
  10. package/dist/clickhouse/live/catalog.d.ts +6 -5
  11. package/dist/clickhouse/live/catalog.d.ts.map +1 -1
  12. package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
  13. package/dist/clickhouse/live/undeclarable.d.ts +32 -0
  14. package/dist/clickhouse/live/undeclarable.d.ts.map +1 -0
  15. package/dist/clickhouse/parser.d.ts +16 -0
  16. package/dist/clickhouse/parser.d.ts.map +1 -1
  17. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  18. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  19. package/dist/clickhouse/plan/normalize.d.ts +6 -3
  20. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  21. package/dist/clickhouse/plan/rules.d.ts +3 -0
  22. package/dist/clickhouse/plan/rules.d.ts.map +1 -1
  23. package/dist/clickhouse/testing/fake-server.d.ts +6 -0
  24. package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
  25. package/dist/clickhouse/topology.d.ts.map +1 -1
  26. package/dist/clickhouse.d.ts +1 -1
  27. package/dist/clickhouse.d.ts.map +1 -1
  28. package/dist/codegen/coverage-page.d.ts +7 -0
  29. package/dist/codegen/coverage-page.d.ts.map +1 -0
  30. package/dist/codegen/docs.d.ts.map +1 -1
  31. package/dist/codegen/registry.d.ts.map +1 -1
  32. package/dist/core/undeclarable.d.ts +38 -0
  33. package/dist/core/undeclarable.d.ts.map +1 -0
  34. package/dist/coverage/clickhouse-commands.json +24 -0
  35. package/dist/coverage/clickhouse.json +25 -0
  36. package/dist/coverage/postgres-commands.json +245 -0
  37. package/dist/coverage/postgres.json +51 -0
  38. package/dist/coverage.d.ts +68 -0
  39. package/dist/coverage.d.ts.map +1 -1
  40. package/dist/files/clickhouse.d.ts +2 -1
  41. package/dist/files/clickhouse.d.ts.map +1 -1
  42. package/dist/files/common.d.ts +12 -0
  43. package/dist/files/common.d.ts.map +1 -1
  44. package/dist/files/postgres.d.ts +1 -1
  45. package/dist/files/postgres.d.ts.map +1 -1
  46. package/dist/import-parser.d.ts +7 -2
  47. package/dist/import-parser.d.ts.map +1 -1
  48. package/dist/index.d.ts +2 -2
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/integrity.json +10 -10
  51. package/dist/lint/post-synth/postgres-names.d.ts.map +1 -1
  52. package/dist/lint/post-synth/sql101.d.ts.map +1 -1
  53. package/dist/lint/rules/sqlch001.d.ts +1 -1
  54. package/dist/lint/rules/sqlch001.d.ts.map +1 -1
  55. package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
  56. package/dist/lint/rules/templates.d.ts +1 -1
  57. package/dist/lint/rules/templates.d.ts.map +1 -1
  58. package/dist/lsp/postgres-template.d.ts.map +1 -1
  59. package/dist/manifest.json +1 -1
  60. package/dist/meta.json +40 -0
  61. package/dist/okf/index.md +9 -1
  62. package/dist/okf/rules/SQL101.md +1 -0
  63. package/dist/okf/rules/SQLCH001.md +2 -2
  64. package/dist/okf/rules/SQLPG113.md +2 -0
  65. package/dist/okf/types/PostgresAggregate.md +13 -0
  66. package/dist/okf/types/PostgresCast.md +9 -0
  67. package/dist/okf/types/PostgresCollation.md +9 -0
  68. package/dist/okf/types/PostgresCompositeType.md +13 -0
  69. package/dist/okf/types/PostgresEventTrigger.md +9 -0
  70. package/dist/okf/types/PostgresRangeType.md +13 -0
  71. package/dist/okf/types/Quota.md +9 -0
  72. package/dist/okf/types/SettingsProfile.md +9 -0
  73. package/dist/plugin.d.ts.map +1 -1
  74. package/dist/postgres/apply/apply.d.ts.map +1 -1
  75. package/dist/postgres/apply/statements.d.ts +4 -3
  76. package/dist/postgres/apply/statements.d.ts.map +1 -1
  77. package/dist/postgres/entities.d.ts +129 -6
  78. package/dist/postgres/entities.d.ts.map +1 -1
  79. package/dist/postgres/entity-types.d.ts +6 -0
  80. package/dist/postgres/entity-types.d.ts.map +1 -1
  81. package/dist/postgres/import/generator.d.ts +3 -2
  82. package/dist/postgres/import/generator.d.ts.map +1 -1
  83. package/dist/postgres/import/live-export.d.ts +8 -0
  84. package/dist/postgres/import/live-export.d.ts.map +1 -1
  85. package/dist/postgres/live/catalog-kinds.d.ts +28 -0
  86. package/dist/postgres/live/catalog-kinds.d.ts.map +1 -0
  87. package/dist/postgres/live/catalog.d.ts +23 -8
  88. package/dist/postgres/live/catalog.d.ts.map +1 -1
  89. package/dist/postgres/live/describe-resources.d.ts.map +1 -1
  90. package/dist/postgres/live/undeclarable.d.ts +42 -0
  91. package/dist/postgres/live/undeclarable.d.ts.map +1 -0
  92. package/dist/postgres/parser.d.ts +88 -5
  93. package/dist/postgres/parser.d.ts.map +1 -1
  94. package/dist/postgres/plan/deep.d.ts.map +1 -1
  95. package/dist/postgres/plan/diff.d.ts.map +1 -1
  96. package/dist/postgres/plan/normalize.d.ts +17 -2
  97. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  98. package/dist/postgres/plan/rules.d.ts +3 -0
  99. package/dist/postgres/plan/rules.d.ts.map +1 -1
  100. package/dist/postgres/plan/schema.d.ts +3 -2
  101. package/dist/postgres/plan/schema.d.ts.map +1 -1
  102. package/dist/postgres/testing/fake-client.d.ts +6 -0
  103. package/dist/postgres/testing/fake-client.d.ts.map +1 -1
  104. package/dist/postgres/testing/roundtrip-schema.d.ts +4 -2
  105. package/dist/postgres/testing/roundtrip-schema.d.ts.map +1 -1
  106. package/dist/postgres.d.ts +1 -1
  107. package/dist/postgres.d.ts.map +1 -1
  108. package/dist/rules/postgres-names.ts +15 -2
  109. package/dist/rules/postgres-templates.ts +2 -2
  110. package/dist/rules/sql101.ts +1 -0
  111. package/dist/rules/sqlch001.ts +10 -3
  112. package/dist/rules/sqlpg001.ts +6 -21
  113. package/dist/rules/sqlpg113.ts +1 -1
  114. package/dist/rules/templates.ts +2 -2
  115. package/dist/validate.d.ts.map +1 -1
  116. package/package.json +2 -2
  117. package/src/clickhouse/access.ts +197 -22
  118. package/src/clickhouse/apply/statements.ts +17 -5
  119. package/src/clickhouse/entities.ts +78 -2
  120. package/src/clickhouse/import/generator.ts +16 -3
  121. package/src/clickhouse/import/live-export.ts +17 -2
  122. package/src/clickhouse/live/catalog.ts +57 -10
  123. package/src/clickhouse/live/describe-resources.ts +3 -1
  124. package/src/clickhouse/live/kinds.e2e.test.ts +179 -0
  125. package/src/clickhouse/live/undeclarable.e2e.test.ts +65 -0
  126. package/src/clickhouse/live/undeclarable.ts +66 -0
  127. package/src/clickhouse/parser.test.ts +45 -2
  128. package/src/clickhouse/parser.ts +111 -3
  129. package/src/clickhouse/plan/deep.ts +25 -6
  130. package/src/clickhouse/plan/diff.ts +41 -1
  131. package/src/clickhouse/plan/normalize.ts +12 -4
  132. package/src/clickhouse/plan/rules.ts +22 -1
  133. package/src/clickhouse/quota-profile.test.ts +186 -0
  134. package/src/clickhouse/table-function.test.ts +54 -0
  135. package/src/clickhouse/testdata/server-ddl-baseline.json +17 -14
  136. package/src/clickhouse/testing/fake-server.ts +5 -0
  137. package/src/clickhouse/topology.ts +4 -2
  138. package/src/clickhouse.ts +6 -0
  139. package/src/codegen/classifier-page.test.ts +6 -0
  140. package/src/codegen/coverage-page.ts +77 -0
  141. package/src/codegen/docs.ts +5 -0
  142. package/src/codegen/postgres-classifier-page.ts +1 -1
  143. package/src/codegen/registry.ts +8 -0
  144. package/src/core/undeclarable.test.ts +141 -0
  145. package/src/core/undeclarable.ts +66 -0
  146. package/src/coverage/clickhouse-commands.json +24 -0
  147. package/src/coverage/clickhouse.json +25 -0
  148. package/src/coverage/postgres-commands.json +245 -0
  149. package/src/coverage/postgres.json +51 -0
  150. package/src/coverage.test.ts +78 -0
  151. package/src/coverage.ts +142 -0
  152. package/src/files/clickhouse.ts +20 -7
  153. package/src/files/common.ts +18 -0
  154. package/src/files/postgres.ts +25 -6
  155. package/src/generated/lexicon-sql.json +40 -0
  156. package/src/import-parser.ts +12 -4
  157. package/src/index.ts +2 -2
  158. package/src/lint/post-synth/postgres-names.ts +15 -2
  159. package/src/lint/post-synth/sql101.ts +1 -0
  160. package/src/lint/post-synth/sqlpg113.ts +1 -1
  161. package/src/lint/rules/postgres-templates.ts +2 -2
  162. package/src/lint/rules/sqlch001.ts +10 -3
  163. package/src/lint/rules/sqlpg001.ts +6 -21
  164. package/src/lint/rules/templates.ts +2 -2
  165. package/src/lsp/postgres-template.ts +14 -0
  166. package/src/mcp/index.ts +5 -5
  167. package/src/mcp/postgres.ts +1 -1
  168. package/src/plugin.ts +13 -2
  169. package/src/postgres/apply/apply.ts +8 -2
  170. package/src/postgres/apply/statements.ts +29 -6
  171. package/src/postgres/entities.test.ts +2 -2
  172. package/src/postgres/entities.ts +316 -28
  173. package/src/postgres/entity-types.ts +6 -0
  174. package/src/postgres/import/generator.ts +31 -4
  175. package/src/postgres/import/live-export.ts +25 -2
  176. package/src/postgres/kinds.test.ts +140 -0
  177. package/src/postgres/live/catalog-kinds.ts +312 -0
  178. package/src/postgres/live/catalog.ts +33 -11
  179. package/src/postgres/live/describe-resources.ts +14 -5
  180. package/src/postgres/live/kinds.e2e.test.ts +182 -0
  181. package/src/postgres/live/roundtrip.e2e.test.ts +9 -3
  182. package/src/postgres/live/undeclarable.e2e.test.ts +83 -0
  183. package/src/postgres/live/undeclarable.ts +141 -0
  184. package/src/postgres/parser.test.ts +2 -1
  185. package/src/postgres/parser.ts +307 -17
  186. package/src/postgres/plan/deep.ts +33 -0
  187. package/src/postgres/plan/diff.ts +45 -0
  188. package/src/postgres/plan/normalize.ts +153 -2
  189. package/src/postgres/plan/rules.ts +21 -0
  190. package/src/postgres/plan/schema.ts +6 -4
  191. package/src/postgres/testing/fake-client.ts +5 -0
  192. package/src/postgres/testing/roundtrip-schema.ts +19 -1
  193. package/src/postgres.ts +19 -0
  194. package/src/skill-defs.ts +1 -1
  195. package/src/skills/chant-sql-postgres.md +7 -3
  196. package/src/skills/chant-sql.md +3 -1
  197. package/src/unknown-clauses.test.ts +2 -1
  198. package/src/validate.ts +8 -0
@@ -95,6 +95,12 @@ export interface TableNode extends StorageNode {
95
95
  indexes: IndexNode[];
96
96
  projections: ProjectionNode[];
97
97
  constraints: ConstraintNode[];
98
+ /**
99
+ * A table over a table function (#3911): `AS numbers(10)`, `AS remote(...)`,
100
+ * the call after `AS`. Such a table has no engine and no data of its own;
101
+ * its column list is optional, and the server prints the one it inferred.
102
+ */
103
+ asFunction?: Span;
98
104
  comment?: Span;
99
105
  }
100
106
 
@@ -200,6 +206,42 @@ const EXPRESSION_WORDS = new Set(
200
206
  ).split(" "),
201
207
  );
202
208
 
209
+ /** The units an interval is counted in, singular and plural: what ends a refresh schedule before a column list. */
210
+ const INTERVAL_UNIT_WORDS = new Set(
211
+ ["NANOSECOND", "MICROSECOND", "MILLISECOND", "SECOND", "MINUTE", "HOUR", "DAY", "WEEK", "MONTH", "QUARTER", "YEAR"].flatMap((u) => [u, `${u}S`]),
212
+ );
213
+
214
+ /**
215
+ * The `CREATE` kinds the pinned server has (26.8: `system.*` and its parser's
216
+ * own list) that chant does not declare, each with why (#3911). A statement
217
+ * of one is refused with that reason, by name, never as a parse error.
218
+ */
219
+ export const REFUSED_CREATE_KINDS: ReadonlyArray<{ words: readonly string[]; kind: string; reason: string }> = [
220
+ { words: ["LIVE", "VIEW"], kind: "LIVE VIEW", reason: "the pinned server no longer has live views (removed in 25.x); a refreshable materialized view (REFRESH EVERY ...) is the replacement" },
221
+ {
222
+ words: ["WINDOW", "VIEW"],
223
+ kind: "WINDOW VIEW",
224
+ reason: "a window view is experimental and the pinned server refuses it under the default query analyzer (allow_experimental_analyzer), so it cannot be applied as declared",
225
+ },
226
+ { words: ["TEMPORARY"], kind: "TEMPORARY TABLE", reason: "a temporary table lives for one session and is never in the catalog a plan reads" },
227
+ {
228
+ words: ["NAMED", "COLLECTION"],
229
+ kind: "NAMED COLLECTION",
230
+ reason: "a named collection holds credentials (a password, a secret key), which are the environment's and never in a build; the server hides its values, so a plan could not compare them either. Create it from the server's configuration or by hand",
231
+ },
232
+ { words: ["MASKING", "POLICY"], kind: "MASKING POLICY", reason: "masking policies are available only in ClickHouse Cloud, not on the pinned server" },
233
+ { words: ["WORKLOAD"], kind: "WORKLOAD", reason: "a workload is server-level scheduling, configured with the server, not a schema object" },
234
+ { words: ["RESOURCE"], kind: "RESOURCE", reason: "a resource is a server-level scheduling resource (a disk's read or write), configured with the server, not a schema object" },
235
+ { words: ["HANDLER"], kind: "HANDLER", reason: "an HTTP handler is server-level configuration, not a schema object" },
236
+ {
237
+ words: ["INDEX"],
238
+ kind: "INDEX",
239
+ reason: "a skip index belongs to its table: declare it in the table's column list (INDEX name expr TYPE type GRANULARITY n), and a plan adds or drops it with ALTER TABLE",
240
+ },
241
+ { words: ["UNIQUE", "INDEX"], kind: "UNIQUE INDEX", reason: "the pinned server has no unique index on its table engines; declare a skip index in the table's column list" },
242
+ { words: ["HYPOTHETICAL", "INDEX"], kind: "HYPOTHETICAL INDEX", reason: "a hypothetical index is a session's what-if for the query planner, never stored" },
243
+ ];
244
+
203
245
  const STORAGE_KEYWORDS = ["ENGINE", "ORDER", "PRIMARY", "PARTITION", "SAMPLE", "TTL", "SETTINGS", "COMMENT"];
204
246
 
205
247
  /** A column's own clause words: an unknown one stops at the next of these (#3872). */
@@ -208,6 +250,8 @@ const COLUMN_WORDS = ["DEFAULT", "MATERIALIZED", "ALIAS", "EPHEMERAL", "NULL", "
208
250
  class Parser extends SqlCursor {
209
251
  /** Where the clause-aware expression being read started (#3872); undefined outside one. */
210
252
  private clauseFrom: number | undefined;
253
+ /** Reading a refreshable materialized view's schedule, which a column list ends. */
254
+ private refreshing = false;
211
255
 
212
256
  /**
213
257
  * `ORDER`, `PARTITION`, `SAMPLE` and `GROUP` stop only before `BY`, `PRIMARY`
@@ -215,6 +259,8 @@ class Parser extends SqlCursor {
215
259
  * not know stops it too.
216
260
  */
217
261
  protected override stopsAt(stop: readonly string[]): boolean {
262
+ // `REFRESH EVERY 1 HOUR (a UInt64) ENGINE ...`, as `SHOW CREATE` prints it: the column list follows the schedule.
263
+ if (this.refreshing && this.isPunct("(") && INTERVAL_UNIT_WORDS.has(this.peek(-1)?.text.toUpperCase() ?? "")) return true;
218
264
  if (!super.stopsAt(stop)) return this.startsUnknownClause();
219
265
  const word = this.peek()!.text.toUpperCase();
220
266
  const two = ["ORDER", "PARTITION", "SAMPLE", "GROUP"].includes(word) ? kw(this.peek(1), "BY") : true;
@@ -478,6 +524,22 @@ class Parser extends SqlCursor {
478
524
  if (this.accept("FUNCTION")) return this.function(orReplace);
479
525
  const materialized = this.accept("MATERIALIZED");
480
526
  if (this.accept("VIEW")) return this.view(orReplace, materialized);
527
+ if (!materialized) {
528
+ const refused = REFUSED_CREATE_KINDS.find((k) => k.words.every((w, i) => kw(this.peek(i), w)));
529
+ if (refused) this.fail(`CREATE ${refused.kind} is not declared: ${refused.reason}`);
530
+ // Access control is read by its own tags (`./access.ts`).
531
+ const access: Array<[string[], string, string]> = [
532
+ [["USER"], "USER", "user"],
533
+ [["ROLE"], "ROLE", "role"],
534
+ [["ROW", "POLICY"], "ROW POLICY", "policy"],
535
+ [["POLICY"], "ROW POLICY", "policy"],
536
+ [["QUOTA"], "QUOTA", "quota"],
537
+ [["SETTINGS", "PROFILE"], "SETTINGS PROFILE", "settingsProfile"],
538
+ [["PROFILE"], "SETTINGS PROFILE", "settingsProfile"],
539
+ ];
540
+ const hit = access.find(([words]) => words.every((w, i) => kw(this.peek(i), w)));
541
+ if (hit) this.fail(`CREATE ${hit[1]} is access control: declare it with the ${hit[2]} tag`);
542
+ }
481
543
  return this.fail(materialized ? "expected VIEW" : "expected DATABASE, TABLE, VIEW, MATERIALIZED VIEW, DICTIONARY or FUNCTION");
482
544
  }
483
545
 
@@ -540,14 +602,53 @@ class Parser extends SqlCursor {
540
602
  projections: [],
541
603
  constraints: [],
542
604
  };
543
- if (!this.isPunct("(")) this.fail("expected the column list (CREATE TABLE ... AS is not declared here)");
544
- this.tableElements(node, true);
605
+ if (this.isPunct("(")) this.tableElements(node, true);
606
+ else if (!kw(this.peek(), "AS")) this.refuseCopy();
607
+ const asWord = this.peek();
608
+ if (this.accept("AS")) {
609
+ // `AS numbers(10)`: a table over a table function. `AS other` and `AS SELECT` copy, and are refused.
610
+ if (kw(this.peek(), "SELECT", "WITH") || this.isPunct("(")) this.refuseCopy("select", asWord);
611
+ const from = this.idx();
612
+ this.qualifiedName();
613
+ if (!this.isPunct("(")) this.refuseCopy("table", asWord);
614
+ this.parenthesized();
615
+ node.asFunction = this.span(from, this.tokens.slice(from, this.idx(-1) + 1).flatMap((t) => (t.kind === "ref" ? [t.part] : [])));
616
+ if (this.accept("COMMENT")) node.comment = this.clauseExpr();
617
+ if (kw(this.peek(), ...STORAGE_KEYWORDS)) this.fail("a table over a table function has no engine or storage clauses: the function is its storage");
618
+ this.finish("table definition", ["COMMENT"]);
619
+ return node;
620
+ }
545
621
  this.storage(node, []);
546
622
  if (this.accept("COMMENT")) node.comment = this.clauseExpr();
623
+ if (kw(this.peek(), "AS") && (kw(this.peek(1), "SELECT", "WITH") || this.peek(1)?.text === "(")) this.refuseCopy("select");
547
624
  this.finish("table definition", STORAGE_KEYWORDS);
548
625
  return node;
549
626
  }
550
627
 
628
+ /**
629
+ * The `CREATE TABLE` forms that copy another table or a query, refused
630
+ * with why (#3911). Each is a one-off the server keeps no trace of: it
631
+ * prints the copied columns and engine, or the query's result's, as if
632
+ * they were written out.
633
+ */
634
+ private refuseCopy(what?: "select" | "table", at: Token | undefined = this.peek()): never {
635
+ if (what === "select") {
636
+ return this.fail(
637
+ "CREATE TABLE ... AS SELECT is not declared: it creates the table and fills it once, and the server keeps the columns, not the query. Declare the table's columns and fill it with INSERT ... SELECT, or declare a materialized view",
638
+ at,
639
+ );
640
+ }
641
+ if (what === "table") {
642
+ return this.fail(
643
+ "CREATE TABLE ... AS <table> is not declared: the server copies the other table's columns and engine and keeps no link to it. Declare them (a composite can share them), or write AS <table function>(...) for a table over a table function",
644
+ at,
645
+ );
646
+ }
647
+ if (kw(this.peek(), "CLONE")) return this.fail("CREATE TABLE ... CLONE AS is not declared: it copies another table's columns, engine and data once. Declare the columns and engine");
648
+ if (kw(this.peek(), "EMPTY")) return this.fail("CREATE TABLE ... EMPTY AS SELECT is not declared: the server keeps the query's columns, not the query. Declare the columns");
649
+ return this.fail("expected the column list (a table declared without one, CREATE TABLE ... ENGINE = ... AS SELECT, is not declared: declare the columns)");
650
+ }
651
+
551
652
  private dictionaryAttribute(): DictionaryAttributeNode {
552
653
  const { text, span } = this.name("an attribute name");
553
654
  const STOP = ["DEFAULT", "EXPRESSION", "HIERARCHICAL", "BIDIRECTIONAL", "INJECTIVE", "IS_OBJECT_ID"];
@@ -644,7 +745,14 @@ class Parser extends SqlCursor {
644
745
  empty: false,
645
746
  select: { from: 0, to: 0, refs: [] },
646
747
  };
647
- if (this.accept("REFRESH")) node.refresh = this.expr(["TO", "ENGINE", "AS", "EMPTY", "APPEND", "DEPENDS", "SETTINGS"]);
748
+ if (this.accept("REFRESH")) {
749
+ this.refreshing = true;
750
+ try {
751
+ node.refresh = this.expr(["TO", "ENGINE", "AS", "EMPTY", "APPEND", "DEPENDS", "SETTINGS"]);
752
+ } finally {
753
+ this.refreshing = false;
754
+ }
755
+ }
648
756
  if (this.accept("APPEND")) node.append = true;
649
757
  if (this.accept("TO")) node.to = this.qualifiedName();
650
758
  if (this.isPunct("(")) {
@@ -37,7 +37,7 @@ import { ACCESS_ENTITY_TYPES, ACCESS_UNMANAGED_DETAIL, accessOf, readLiveAccess,
37
37
  import { clickhouseQuery } from "../http";
38
38
  import { sqlString } from "../apply/statements";
39
39
  import { stripMarkerFromStatement } from "../ownership";
40
- import { database, dictionary, func, policy, role, table, user, view, CLICKHOUSE_ENTITY_TYPES, type ColumnDef } from "../entities";
40
+ import { database, dictionary, func, policy, quota, role, settingsProfile, table, user, view, CLICKHOUSE_ENTITY_TYPES, type ColumnDef } from "../entities";
41
41
  import { canonicalObject, grantsObject, objectKey, type CanonicalColumn, type CanonicalObject } from "./normalize";
42
42
  import { grantStatement, grantsByGrantee, revokeStatement, type GrantAtom } from "../access";
43
43
  import { renderFor } from "../topology";
@@ -67,7 +67,11 @@ function liveProps(o: LiveObject): Props {
67
67
  ? role
68
68
  : o.type === CLICKHOUSE_ENTITY_TYPES.rowPolicy
69
69
  ? policy
70
- : view;
70
+ : o.type === CLICKHOUSE_ENTITY_TYPES.quota
71
+ ? quota
72
+ : o.type === CLICKHOUSE_ENTITY_TYPES.settingsProfile
73
+ ? settingsProfile
74
+ : view;
71
75
  const strings = Object.assign([o.statement], { raw: [o.statement] }) as unknown as TemplateStringsArray;
72
76
  return { ...(tag(strings).props as unknown as Props) };
73
77
  }
@@ -89,8 +93,10 @@ export function inDeclaredVocabulary(declared: Props, live: Props, d: CanonicalO
89
93
  adopt("name", d.name === l.name);
90
94
  adopt("database", d.database === l.database);
91
95
  adopt("engine", same("engine"));
92
- for (const k of ["orderBy", "primaryKey", "partitionBy", "sampleBy", "ttl", "comment", "select", "refresh", "dataSource", "layout", "lifetime", "range"] as const) adopt(k, same(k));
96
+ for (const k of ["orderBy", "primaryKey", "partitionBy", "sampleBy", "ttl", "comment", "select", "refresh", "dataSource", "layout", "lifetime", "range", "asFunction"] as const) adopt(k, same(k));
93
97
  adopt("settings", same("settings"));
98
+ // A view's DEFINER and SQL SECURITY are not compared by a plan; the server adds its own to a refreshable materialized view (#3911).
99
+ adopt("security", d.kind === "view" || d.kind === "materializedView");
94
100
  // A user's, role's or row policy's fields, compared as `../access.ts` reads them.
95
101
  if (d.kind === "user" || d.kind === "role" || d.kind === "rowPolicy") {
96
102
  for (const k of ["identified", "host", "validUntil", "defaultRole", "defaultDatabase", "grantees", "settings", "as", "for", "using", "to"]) {
@@ -100,6 +106,12 @@ export function inDeclaredVocabulary(declared: Props, live: Props, d: CanonicalO
100
106
  // A user's authentication is the environment's unless the declaration says it.
101
107
  if (d.kind === "user" && declared.identified === undefined) delete out.identified;
102
108
  }
109
+ // A quota's or settings profile's fields; a quota's intervals are one prop, the same when every interval is.
110
+ if (d.kind === "quota" || d.kind === "settingsProfile") {
111
+ const fields = (o: CanonicalObject, pick: (k: string) => boolean) => JSON.stringify(Object.entries(o.access ?? {}).filter(([k]) => pick(k)));
112
+ for (const k of ["keyed", "settings", "to"]) adopt(k, d.access?.[k] === l.access?.[k]);
113
+ adopt("intervals", fields(d, (k) => k.startsWith("interval.")) === fields(l, (k) => k.startsWith("interval.")));
114
+ }
103
115
  // A function's parameters and expression are one lambda: the same lambda is the declaration's words.
104
116
  adopt("params", d.kind === "function" && same("lambda"));
105
117
  adopt("body", d.kind === "function" && same("lambda"));
@@ -108,8 +120,8 @@ export function inDeclaredVocabulary(declared: Props, live: Props, d: CanonicalO
108
120
  adopt("constraints", same("constraints"));
109
121
 
110
122
  const declaredColumns = (declared.columns as ColumnDef[] | undefined) ?? [];
111
- if (d.kind === "view" || d.kind === "materializedView") {
112
- // The server infers a view's columns; a declaration that lists none is not drift.
123
+ if (d.kind === "view" || d.kind === "materializedView" || (d.kind === "table" && d.asFunction !== undefined && declaredColumns.length === 0)) {
124
+ // The server infers a view's columns, and a table function's (#3911); a declaration that lists none is not drift.
113
125
  if (declaredColumns.length === 0) out.columns = [];
114
126
  } else if (d.kind === "table" || d.kind === "dictionary") {
115
127
  const liveColumns = (live.columns as ColumnDef[] | undefined) ?? [];
@@ -124,7 +136,14 @@ export function inDeclaredVocabulary(declared: Props, live: Props, d: CanonicalO
124
136
  }
125
137
 
126
138
  /** The entity types that belong to no database. */
127
- const GLOBAL_TYPES: ReadonlySet<string> = new Set([CLICKHOUSE_ENTITY_TYPES.database, CLICKHOUSE_ENTITY_TYPES.function, CLICKHOUSE_ENTITY_TYPES.user, CLICKHOUSE_ENTITY_TYPES.role]);
139
+ const GLOBAL_TYPES: ReadonlySet<string> = new Set([
140
+ CLICKHOUSE_ENTITY_TYPES.database,
141
+ CLICKHOUSE_ENTITY_TYPES.function,
142
+ CLICKHOUSE_ENTITY_TYPES.user,
143
+ CLICKHOUSE_ENTITY_TYPES.role,
144
+ CLICKHOUSE_ENTITY_TYPES.quota,
145
+ CLICKHOUSE_ENTITY_TYPES.settingsProfile,
146
+ ]);
128
147
 
129
148
  /** Whether the server's formatter prints two expressions the same. A formatter that cannot parse them says no. */
130
149
  async function sameExpression(target: ClickHouseTarget, a: string, b: string): Promise<boolean> {
@@ -179,6 +179,17 @@ function diffObject(key: string, before: CanonicalObject, after: CanonicalObject
179
179
  return;
180
180
  }
181
181
 
182
+ if (before.kind === "quota" || before.kind === "settingsProfile") {
183
+ const rule = before.kind === "quota" ? "SQLCH275" : "SQLCH276";
184
+ const b = before.access ?? {};
185
+ const a = after.access ?? {};
186
+ // In the order the server prints them: the key, each interval by length, the settings, whom it applies to.
187
+ const rank = (f: string) => (f === "keyed" ? 0 : f.startsWith("interval.") ? 1 + Number(f.slice("interval.".length)) / 1e9 : f === "settings" ? 2 : 3);
188
+ const fields = [...new Set([...Object.keys(b), ...Object.keys(a)])].sort((x, y) => rank(x) - rank(y));
189
+ for (const field of fields) if (b[field] !== a[field]) out.push(change(key, field, rule, b[field], a[field]));
190
+ return;
191
+ }
192
+
182
193
  if (before.kind === "grants") {
183
194
  const b = before.access ?? {};
184
195
  const a = after.access ?? {};
@@ -212,6 +223,11 @@ function diffObject(key: string, before: CanonicalObject, after: CanonicalObject
212
223
  return;
213
224
  }
214
225
 
226
+ if (before.asFunction !== undefined || after.asFunction !== undefined) {
227
+ diffTableFunction(key, before, after, out);
228
+ return;
229
+ }
230
+
215
231
  // A table.
216
232
  if (before.engine !== after.engine) out.push(change(key, "engine", "SQLCH223", before.engine, after.engine));
217
233
  const added = diffColumns(key, before, after, out, hints);
@@ -244,6 +260,30 @@ function diffObject(key: string, before: CanonicalObject, after: CanonicalObject
244
260
  diffMap(key, "constraints", before.constraints, after.constraints, "SQLCH215", out);
245
261
  }
246
262
 
263
+ /**
264
+ * A table over a table function (#3911), on either side. It stores nothing,
265
+ * so every change but its comment replaces it (SQLCH219); a table with data
266
+ * of its own becoming one would lose that data, which is a rebuild
267
+ * (SQLCH223). A declaration with no column list takes the columns the server
268
+ * infers, so they are compared only when it lists them.
269
+ */
270
+ function diffTableFunction(key: string, before: CanonicalObject, after: CanonicalObject, out: Change[]): void {
271
+ if (before.asFunction === undefined) {
272
+ out.push(change(key, "asFunction", "SQLCH223", before.engine, `AS ${after.asFunction}`));
273
+ return;
274
+ }
275
+ if (before.asFunction !== after.asFunction) out.push(change(key, "asFunction", "SQLCH219", before.asFunction, after.asFunction));
276
+ if (after.asFunction !== undefined && after.columns.length === 0) return;
277
+ const column = (c: CanonicalColumn) => [c.type, c.nullable ? "NULL" : "", c.defaultKind ?? "", c.defaultExpr ?? "", c.codec ? `CODEC(${c.codec})` : "", c.comment ? `COMMENT ${c.comment}` : ""].filter(Boolean).join(" ");
278
+ const b = new Map(before.columns.map((c) => [c.name, column(c)]));
279
+ const a = new Map(after.columns.map((c) => [c.name, column(c)]));
280
+ for (const name of new Set([...b.keys(), ...a.keys()])) {
281
+ if (b.get(name) !== a.get(name)) out.push(change(key, `columns.${name}`, "SQLCH219", b.get(name), a.get(name)));
282
+ }
283
+ const order = (o: CanonicalObject) => o.columns.map((c) => c.name).join(", ");
284
+ if (order(before) !== order(after) && a.size === b.size && [...a.keys()].every((n) => b.has(n))) out.push(change(key, "columns.order", "SQLCH219", order(before), order(after)));
285
+ }
286
+
247
287
  /** A dictionary: every change but its comment replaces it (SQLCH245). */
248
288
  function diffDictionary(key: string, before: CanonicalObject, after: CanonicalObject, out: Change[]): void {
249
289
  const attribute = (c: CanonicalColumn) =>
@@ -272,7 +312,7 @@ export function diffSchemas(before: readonly SchemaObject[], after: readonly Sch
272
312
  const matches = matchByIdentity(before, after, {
273
313
  qualified,
274
314
  // A function, a user, a role, a row policy and a grantee's grants are not renamed: one under a new name is a create.
275
- previously: (o) => (o.kind === "function" || o.kind === "user" || o.kind === "role" || o.kind === "rowPolicy" || o.kind === "grants" ? undefined : o.previously),
315
+ previously: (o) => (o.kind === "function" || o.kind === "user" || o.kind === "role" || o.kind === "rowPolicy" || o.kind === "quota" || o.kind === "settingsProfile" || o.kind === "grants" ? undefined : o.previously),
276
316
  previousNames: (o, prev) => [prev, `${o.database}.${prev}`],
277
317
  });
278
318
  for (const m of matches) {
@@ -41,7 +41,7 @@ import { accessCanonical, grantsCanonical, isAccessStatement } from "../access";
41
41
 
42
42
  export { previouslyIn } from "../../core/normalize";
43
43
 
44
- export type ObjectKind = "database" | "table" | "view" | "materializedView" | "dictionary" | "function" | "user" | "role" | "rowPolicy" | "grants";
44
+ export type ObjectKind = "database" | "table" | "view" | "materializedView" | "dictionary" | "function" | "user" | "role" | "rowPolicy" | "quota" | "settingsProfile" | "grants";
45
45
 
46
46
  export interface CanonicalColumn {
47
47
  name: string;
@@ -84,6 +84,8 @@ export interface CanonicalObject {
84
84
  select?: string;
85
85
  to?: string;
86
86
  refresh?: string;
87
+ /** A table over a table function (#3911): the call after `AS`. */
88
+ asFunction?: string;
87
89
  /** A dictionary's `SOURCE`, `LAYOUT`, `LIFETIME` and `RANGE`, each what is inside the parentheses. */
88
90
  dataSource?: string;
89
91
  layout?: string;
@@ -94,8 +96,9 @@ export interface CanonicalObject {
94
96
  /** A row policy's table. */
95
97
  table?: string;
96
98
  /**
97
- * A user's, role's or row policy's fields (`../access.ts`), each absent at
98
- * the server's default; a grantee's grants, by atom.
99
+ * A user's, role's, row policy's, quota's or settings profile's fields
100
+ * (`../access.ts`), each absent at the server's default; a grantee's
101
+ * grants, by atom.
99
102
  */
100
103
  access?: Record<string, string>;
101
104
  /** `-- previously: <name>` before the statement: the object's previous name. */
@@ -374,6 +377,7 @@ export function canonicalObject(ddl: string, defaultDatabase = "default"): Canon
374
377
  }
375
378
 
376
379
  if (node.statement === "table") {
380
+ if (node.asFunction) obj.asFunction = canonicalExpression(spanText(tokens, node.asFunction) ?? "", database);
377
381
  node.columns.forEach((c, i) => obj.columns.push(column(tokens, c, i, database)));
378
382
  for (const ix of node.indexes) {
379
383
  obj.indexes[ix.name] = [expr(ix.expr), "TYPE", expr(ix.type), ix.granularity ? `GRANULARITY ${expr(ix.granularity)}` : ""]
@@ -556,6 +560,8 @@ export function objectKey(o: { kind?: ObjectKind; type?: string; database?: stri
556
560
  if (kind === "function") return `function ${o.name}`;
557
561
  if (kind === "user") return `user ${o.name}`;
558
562
  if (kind === "role") return `role ${o.name}`;
563
+ if (kind === "quota") return `quota ${o.name}`;
564
+ if (kind === "settingsProfile") return `settings profile ${o.name}`;
559
565
  if (kind === "grants") return `grants ${o.name}`;
560
566
  if (kind === "rowPolicy") return `row policy ${o.name} ON ${o.database}.${o.table}`;
561
567
  return o.database !== undefined ? `${o.database}.${o.name}` : o.name;
@@ -566,6 +572,8 @@ const KIND_OF_TYPE: Record<string, ObjectKind> = {
566
572
  "ClickHouse::User": "user",
567
573
  "ClickHouse::Role": "role",
568
574
  "ClickHouse::RowPolicy": "rowPolicy",
575
+ "ClickHouse::Quota": "quota",
576
+ "ClickHouse::SettingsProfile": "settingsProfile",
569
577
  "ClickHouse::Grant": "grants",
570
578
  };
571
579
 
@@ -582,7 +590,7 @@ export function grantsObject(ddl: string, grantee: string, defaultDatabase = "de
582
590
  */
583
591
  export function scopeOf(o: { kind?: ObjectKind; type?: string; database?: string; name: string; table?: string }): string {
584
592
  const kind = o.kind ?? KIND_OF_TYPE[o.type ?? ""];
585
- if (kind === "function" || kind === "user" || kind === "role" || kind === "grants") return objectKey(o);
593
+ if (kind === "function" || kind === "user" || kind === "role" || kind === "quota" || kind === "settingsProfile" || kind === "grants") return objectKey(o);
586
594
  return o.database ?? o.name;
587
595
  }
588
596
 
@@ -35,7 +35,7 @@ const DOCS = "https://clickhouse.com/docs/sql-reference/statements";
35
35
  const rule = (id: string, cls: ChangeClass, title: string, restriction: string, cite: string): ClassifierRule => classifierRule(id, cls, title, restriction, cite);
36
36
 
37
37
  export const CLASSIFIER_RULES = {
38
- SQLCH200: rule("SQLCH200", "create", "Create an object", "A new database, table, view, dictionary, function, user, role or row policy is created, or a grantee gets its first grants; nothing existing changes.", `${DOCS}/create`),
38
+ SQLCH200: rule("SQLCH200", "create", "Create an object", "A new database, table, view, dictionary, function, user, role, row policy, quota or settings profile is created, or a grantee gets its first grants; nothing existing changes.", `${DOCS}/create`),
39
39
  SQLCH201: rule(
40
40
  "SQLCH201",
41
41
  "metadata",
@@ -150,6 +150,13 @@ export const CLASSIFIER_RULES = {
150
150
  "A table setting the server marks read-only (such as index_granularity) is fixed when the table is created and needs a new table to change.",
151
151
  `${DOCS}/alter/setting`,
152
152
  ),
153
+ SQLCH219: rule(
154
+ "SQLCH219",
155
+ "metadata",
156
+ "Replace a table over a table function",
157
+ "A table declared AS a table function (numbers, remote, s3, url) stores no data of its own and has no ALTER for its function; CREATE OR REPLACE TABLE replaces its call and columns, and queries read through the new one.",
158
+ `${DOCS}/create/table#from-a-table-function`,
159
+ ),
153
160
  SQLCH220: rule(
154
161
  "SQLCH220",
155
162
  "rebuild",
@@ -278,6 +285,20 @@ export const CLASSIFIER_RULES = {
278
285
  "REVOKE takes a privilege or a role away, including one granted by hand to a grantee the build declares grants for; queries that relied on it fail from then on.",
279
286
  `${DOCS}/revoke`,
280
287
  ),
288
+ SQLCH275: rule(
289
+ "SQLCH275",
290
+ "metadata",
291
+ "Change a quota",
292
+ "ALTER QUOTA sets the quota's key, one interval's limits (NO LIMITS takes the interval away) or whom it applies to, on the quota that is there, so what names it still does.",
293
+ `${DOCS}/alter/quota`,
294
+ ),
295
+ SQLCH276: rule(
296
+ "SQLCH276",
297
+ "metadata",
298
+ "Change a settings profile",
299
+ "ALTER SETTINGS PROFILE replaces the profile's settings, constraints and inherited profiles, or whom it applies to; the users, roles and profiles that name it keep it. Sessions already open keep the old settings.",
300
+ `${DOCS}/alter/settings-profile`,
301
+ ),
281
302
  SQLCH290: rule(
282
303
  "SQLCH290",
283
304
  "rebuild",
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Quotas and settings profiles (#3911): the `quota` and `settingsProfile`
3
+ * tags, each compared with what the pinned server prints for it, the
4
+ * `ALTER` each change takes, `ON CLUSTER` per topology, and reading them
5
+ * from a `.sql` file. `live/quota-profile.e2e.test.ts` runs them against a
6
+ * server.
7
+ */
8
+
9
+ import { describe, expect, test } from "vitest";
10
+ import { quota, role, settingsProfile, CLICKHOUSE_ENTITY_TYPES } from "./entities";
11
+ import { canonicalObject, objectKey } from "./plan/normalize";
12
+ import { diffSchemas } from "./plan/diff";
13
+ import { planStatements, type DeclaredObject } from "./apply/statements";
14
+ import { renderStatement } from "./topology";
15
+ import { readClickHouse } from "../files/clickhouse";
16
+ import { clickhouseTemplates } from "./import/generator";
17
+
18
+ const t = (s: string) => [s] as unknown as TemplateStringsArray;
19
+
20
+ describe("the tags", () => {
21
+ test("a quota: its key, its intervals as written, whom it applies to", () => {
22
+ const reader = role`CREATE ROLE reader`;
23
+ const q = quota`CREATE QUOTA IF NOT EXISTS per_user KEYED BY user_name FOR INTERVAL 1 hour MAX queries = 100, errors = 5, FOR RANDOMIZED INTERVAL 1 day TRACKING ONLY TO ${reader}`;
24
+ expect(q.entityType).toBe("ClickHouse::Quota");
25
+ expect(q.props).toMatchObject({
26
+ name: "per_user",
27
+ ifNotExists: true,
28
+ keyed: "user_name",
29
+ intervals: "FOR INTERVAL 1 hour MAX queries = 100, errors = 5, FOR RANDOMIZED INTERVAL 1 day TRACKING ONLY",
30
+ to: "reader",
31
+ });
32
+ expect(q.props.ddl).toBe("CREATE QUOTA IF NOT EXISTS per_user KEYED BY user_name FOR INTERVAL 1 hour MAX queries = 100, errors = 5, FOR RANDOMIZED INTERVAL 1 day TRACKING ONLY TO reader");
33
+ });
34
+
35
+ test("a settings profile, written either way, inheriting another", () => {
36
+ const base = settingsProfile`CREATE SETTINGS PROFILE base SETTINGS max_threads = 8`;
37
+ const p = settingsProfile`CREATE PROFILE analysts SETTINGS PROFILE ${base}, max_memory_usage = 10000000000 MIN 1000000 MAX 20000000000, readonly = 1 CONST TO ALL EXCEPT admin`;
38
+ expect(p.entityType).toBe("ClickHouse::SettingsProfile");
39
+ expect(p.props).toMatchObject({ name: "analysts", settings: "PROFILE base, max_memory_usage = 10000000000 MIN 1000000 MAX 20000000000, readonly = 1 CONST", to: "ALL EXCEPT admin" });
40
+ expect(p.dependsOn).toContain(base);
41
+ });
42
+
43
+ test("one per template, the wrong tag, and malformed intervals are refused", () => {
44
+ expect(() => quota(t("CREATE QUOTA a, b"))).toThrow(/one quota per template/);
45
+ expect(() => settingsProfile(t("CREATE SETTINGS PROFILE a, b"))).toThrow(/one settings profile per template/);
46
+ expect(() => quota(t("CREATE SETTINGS PROFILE p"))).toThrow(/holds a CREATE SETTINGS PROFILE; use the settingsProfile tag/);
47
+ expect(() => settingsProfile(t("CREATE QUOTA q"))).toThrow(/holds a CREATE QUOTA; use the quota tag/);
48
+ expect(() => role(t("CREATE QUOTA q"))).toThrow(/use the quota tag/);
49
+ expect(() => quota(t("CREATE QUOTA q KEYED BY user_name NOT KEYED"))).toThrow(/both written/);
50
+ expect(() => quota(t("CREATE QUOTA q FOR INTERVAL 1 hour MAX queries = 1 TO r FOR INTERVAL 1 day MAX queries = 2"))).toThrow(/FOR is written twice/);
51
+ expect(() => canonicalObject("CREATE QUOTA q FOR INTERVAL 1 fortnight MAX queries = 1")).toThrow(/whole number and a unit/);
52
+ });
53
+ });
54
+
55
+ describe("compared with what the server prints", () => {
56
+ const same = (declared: string, shown: string) => diffSchemas([{ key: "k", canonical: canonicalObject(shown) }], [{ key: "k", canonical: canonicalObject(declared) }]).changes;
57
+
58
+ test("a quota: units in seconds, limits sorted and lower case, numbers as the server keeps them, NO LIMITS as no interval", () => {
59
+ expect(
60
+ same(
61
+ "CREATE QUOTA q KEYED BY 'user_name' FOR INTERVAL 60 MINUTE MAX QUERIES 100, errors = 5, FOR RANDOMIZED INTERVAL 1 DAY MAX execution_time = 1000.0, read_bytes = 1e9, FOR INTERVAL 1 week NO LIMITS TO r1",
62
+ "CREATE QUOTA q KEYED BY user_name FOR INTERVAL 1 hour MAX queries = 100, errors = 5, FOR RANDOMIZED INTERVAL 1 day MAX read_bytes = 1000000000, execution_time = 1000 TO r1",
63
+ ),
64
+ ).toEqual([]);
65
+ // Three months are a quarter, as the server counts them.
66
+ expect(same("CREATE QUOTA q FOR INTERVAL 3 month MAX queries = 1", "CREATE QUOTA q FOR INTERVAL 1 quarter MAX queries = 1")).toEqual([]);
67
+ expect(same("CREATE QUOTA q NOT KEYED TO NONE", "CREATE QUOTA q")).toEqual([]);
68
+ // `KEY BY` is `KEYED BY`; an IP key's prefix bits are part of the key.
69
+ expect(same("CREATE QUOTA q KEY BY ip_address ipv6_prefix_bits 64", "CREATE QUOTA q KEYED BY ip_address IPV6_PREFIX_BITS 64")).toEqual([]);
70
+ expect(canonicalObject("CREATE QUOTA q KEYED BY ip_address ipv4_prefix_bits 16 ipv6_prefix_bits 48").access).toEqual({ keyed: "ip_address ipv4_prefix_bits 16 ipv6_prefix_bits 48" });
71
+ // A name may start with a digit, as the server's own tests write them.
72
+ expect(canonicalObject("CREATE SETTINGS PROFILE 02294_profile1 SETTINGS max_execution_time = 0.5 TO default").name).toBe("02294_profile1");
73
+ expect(canonicalObject("CREATE QUOTA `q x` KEYED BY client_key, user_name FOR INTERVAL 2 week MAX queries = 1 TO ALL").access).toEqual({
74
+ keyed: "client_key , user_name",
75
+ "interval.1209600": "MAX queries = 1",
76
+ to: "ALL",
77
+ });
78
+ });
79
+
80
+ test("a changed quota is SQLCH275 per field and per interval", () => {
81
+ expect(
82
+ same(
83
+ "CREATE QUOTA q KEYED BY ip_address FOR INTERVAL 1 hour MAX queries = 7, FOR INTERVAL 1 day TRACKING ONLY",
84
+ "CREATE QUOTA q KEYED BY user_name FOR INTERVAL 1 hour MAX queries = 5, errors = 2, FOR INTERVAL 1 week MAX queries = 1 TO r",
85
+ ).map((c) => [c.field, c.rule, c.before, c.after]),
86
+ ).toEqual([
87
+ ["keyed", "SQLCH275", "user_name", "ip_address"],
88
+ ["interval.3600", "SQLCH275", "MAX errors = 2 , queries = 5", "MAX queries = 7"],
89
+ ["interval.86400", "SQLCH275", undefined, "TRACKING ONLY"],
90
+ ["interval.604800", "SQLCH275", "MAX queries = 1", undefined],
91
+ ["to", "SQLCH275", "r", undefined],
92
+ ]);
93
+ });
94
+
95
+ test("a settings profile: READONLY as CONST, PROFILE 'x' as INHERIT x, the name quoted or not", () => {
96
+ expect(
97
+ same(
98
+ "CREATE PROFILE p2 SETTINGS PROFILE 'p1', readonly = 1, max_threads READONLY, max_memory_usage = 100 MIN 90 MAX 110 TO b, a",
99
+ "CREATE SETTINGS PROFILE `p2` SETTINGS INHERIT `p1`, readonly = 1, max_memory_usage = 100 MIN 90 MAX 110, max_threads CONST TO a, b",
100
+ ),
101
+ ).toEqual([]);
102
+ expect(same("CREATE SETTINGS PROFILE p SETTINGS max_threads = 4", "CREATE SETTINGS PROFILE `p` SETTINGS max_threads = 5 TO r").map((c) => [c.field, c.rule])).toEqual([
103
+ ["settings", "SQLCH276"],
104
+ ["to", "SQLCH276"],
105
+ ]);
106
+ });
107
+ });
108
+
109
+ describe("the statements", () => {
110
+ const decl = (type: string, ddl: string): DeclaredObject => {
111
+ const canonical = canonicalObject(ddl);
112
+ return { exportName: "x", type: type as DeclaredObject["type"], key: objectKey(canonical), ddl, canonical, dependsOn: [] };
113
+ };
114
+ const steps = (obj: DeclaredObject, shown?: string) => {
115
+ const live = shown === undefined ? undefined : canonicalObject(shown);
116
+ const changes = diffSchemas(live ? [{ key: obj.key, canonical: live }] : [], [{ key: obj.key, canonical: obj.canonical }]).changes;
117
+ const entry = planStatements({ declared: [obj], changes, current: new Map(live ? [[obj.key, live]] : []) }).objects[0]!;
118
+ return entry.verdict === "create" || entry.verdict === "alter" ? entry.steps.map((s) => s.sql) : [entry.verdict];
119
+ };
120
+
121
+ test("a quota is created as declared, then changed with ALTER QUOTA, never replaced", () => {
122
+ const q = decl(CLICKHOUSE_ENTITY_TYPES.quota, "CREATE QUOTA q KEYED BY ip_address FOR RANDOMIZED INTERVAL 1 hour MAX queries = 7, errors = 1 TO r");
123
+ expect(q.key).toBe("quota q");
124
+ expect(steps(q)).toEqual(["CREATE QUOTA q KEYED BY ip_address FOR RANDOMIZED INTERVAL 1 hour MAX queries = 7, errors = 1 TO r"]);
125
+ expect(steps(q, "CREATE QUOTA q FOR INTERVAL 1 hour MAX queries = 5, FOR INTERVAL 1 day MAX queries = 100")).toEqual([
126
+ "ALTER QUOTA `q` KEYED BY ip_address",
127
+ "ALTER QUOTA `q` FOR RANDOMIZED INTERVAL 3600 second MAX errors = 1, queries = 7",
128
+ "ALTER QUOTA `q` FOR INTERVAL 86400 second NO LIMITS",
129
+ "ALTER QUOTA `q` TO r",
130
+ ]);
131
+ expect(steps(decl(CLICKHOUSE_ENTITY_TYPES.quota, "CREATE QUOTA q"), "CREATE QUOTA q KEYED BY user_name TO r")).toEqual(["ALTER QUOTA `q` NOT KEYED", "ALTER QUOTA `q` TO NONE"]);
132
+ });
133
+
134
+ test("a settings profile is changed with ALTER SETTINGS PROFILE, so what inherits it keeps it", () => {
135
+ const p = decl(CLICKHOUSE_ENTITY_TYPES.settingsProfile, "CREATE SETTINGS PROFILE p SETTINGS max_threads = 4 TO r");
136
+ expect(p.key).toBe("settings profile p");
137
+ expect(steps(p, "CREATE SETTINGS PROFILE `p` SETTINGS max_threads = 8")).toEqual(["ALTER SETTINGS PROFILE `p` SETTINGS max_threads = 4", "ALTER SETTINGS PROFILE `p` TO r"]);
138
+ expect(steps(decl(CLICKHOUSE_ENTITY_TYPES.settingsProfile, "CREATE PROFILE p"), "CREATE SETTINGS PROFILE `p` SETTINGS max_threads = 8 TO r")).toEqual([
139
+ "ALTER SETTINGS PROFILE `p` SETTINGS NONE",
140
+ "ALTER SETTINGS PROFILE `p` TO NONE",
141
+ ]);
142
+ });
143
+
144
+ test("neither is ever dropped", () => {
145
+ const live = [
146
+ { key: "quota q", canonical: canonicalObject("CREATE QUOTA q") },
147
+ { key: "settings profile p", canonical: canonicalObject("CREATE SETTINGS PROFILE p") },
148
+ ];
149
+ const gone = diffSchemas(live, []).changes;
150
+ expect(planStatements({ declared: [], changes: gone, current: new Map(live.map((l) => [l.key, l.canonical])), allowDestructive: true }).drops).toEqual([]);
151
+ });
152
+
153
+ test("ON CLUSTER on a cluster, since neither is in a database", () => {
154
+ const cluster = { kind: "cluster" as const, cluster: "main" };
155
+ expect(renderStatement("CREATE QUOTA q KEYED BY user_name", cluster)).toBe("CREATE QUOTA q ON CLUSTER `main` KEYED BY user_name");
156
+ expect(renderStatement("CREATE SETTINGS PROFILE p SETTINGS max_threads = 1", cluster)).toBe("CREATE SETTINGS PROFILE p ON CLUSTER `main` SETTINGS max_threads = 1");
157
+ expect(renderStatement("CREATE PROFILE p", cluster)).toBe("CREATE PROFILE p ON CLUSTER `main`");
158
+ expect(renderStatement("ALTER QUOTA `q` TO r", cluster)).toBe("ALTER QUOTA `q` ON CLUSTER `main` TO r");
159
+ expect(renderStatement("ALTER SETTINGS PROFILE `p` SETTINGS NONE", cluster)).toBe("ALTER SETTINGS PROFILE `p` ON CLUSTER `main` SETTINGS NONE");
160
+ });
161
+ });
162
+
163
+ describe("import", () => {
164
+ test("a .sql file's quotas and settings profiles are read by their tags", () => {
165
+ const problems: string[] = [];
166
+ const objects = readClickHouse(
167
+ "CREATE SETTINGS PROFILE `p` SETTINGS max_threads = 4;\nCREATE QUOTA q KEYED BY user_name FOR INTERVAL 1 hour MAX queries = 5 TO r;\nCREATE NAMED COLLECTION c AS password = 'x';\n",
168
+ { origin: "access.sql" },
169
+ problems,
170
+ );
171
+ expect(objects.map((o) => [o.type, o.name])).toEqual([
172
+ ["ClickHouse::SettingsProfile", "p"],
173
+ ["ClickHouse::Quota", "q"],
174
+ ]);
175
+ expect(problems).toEqual([expect.stringMatching(/^named collection: chant cannot declare it \(a named collection holds credentials/)]);
176
+ });
177
+
178
+ test("the generator writes them with their tags, never as a relation another object names", () => {
179
+ const items = [
180
+ { exportName: "q", type: CLICKHOUSE_ENTITY_TYPES.quota, name: "q", ddl: "CREATE QUOTA q TO events" },
181
+ { exportName: "events", type: CLICKHOUSE_ENTITY_TYPES.table, database: "default", name: "events", ddl: "CREATE TABLE default.events (a UInt8) ENGINE = Log" },
182
+ ];
183
+ const { bodies } = clickhouseTemplates(items);
184
+ expect(bodies.get("q")!.refs).toEqual([]);
185
+ });
186
+ });