@intentius/chant-lexicon-sql 0.124.11 → 0.124.13

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 (173) hide show
  1. package/dist/clickhouse/apply/statements.d.ts.map +1 -1
  2. package/dist/clickhouse/entities.d.ts +7 -1
  3. package/dist/clickhouse/entities.d.ts.map +1 -1
  4. package/dist/clickhouse/expressions.d.ts +27 -0
  5. package/dist/clickhouse/expressions.d.ts.map +1 -0
  6. package/dist/clickhouse/parser.d.ts +12 -2
  7. package/dist/clickhouse/parser.d.ts.map +1 -1
  8. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  9. package/dist/clickhouse/plan/normalize.d.ts +11 -1
  10. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  11. package/dist/clickhouse/plan/rebuild-handoff.d.ts.map +1 -1
  12. package/dist/clickhouse/plan/rules.d.ts +1 -0
  13. package/dist/clickhouse/plan/rules.d.ts.map +1 -1
  14. package/dist/clickhouse.d.ts +1 -0
  15. package/dist/clickhouse.d.ts.map +1 -1
  16. package/dist/codegen/classifier-page.d.ts.map +1 -1
  17. package/dist/codegen/postgres-classifier-page.d.ts.map +1 -1
  18. package/dist/core/cursor.d.ts +41 -0
  19. package/dist/core/cursor.d.ts.map +1 -1
  20. package/dist/core/find-templates.d.ts +1 -1
  21. package/dist/core/find-templates.d.ts.map +1 -1
  22. package/dist/core/interpolation.d.ts +18 -1
  23. package/dist/core/interpolation.d.ts.map +1 -1
  24. package/dist/files/clickhouse.d.ts +2 -2
  25. package/dist/files/clickhouse.d.ts.map +1 -1
  26. package/dist/files/common.d.ts +16 -0
  27. package/dist/files/common.d.ts.map +1 -1
  28. package/dist/files/postgres.d.ts +2 -2
  29. package/dist/files/postgres.d.ts.map +1 -1
  30. package/dist/import-parser.d.ts +2 -1
  31. package/dist/import-parser.d.ts.map +1 -1
  32. package/dist/integrity.json +24 -24
  33. package/dist/lint/post-synth/clickhouse-helpers.d.ts +6 -19
  34. package/dist/lint/post-synth/clickhouse-helpers.d.ts.map +1 -1
  35. package/dist/lint/post-synth/postgres-helpers.d.ts +15 -55
  36. package/dist/lint/post-synth/postgres-helpers.d.ts.map +1 -1
  37. package/dist/lint/post-synth/postgres-names.d.ts +2 -2
  38. package/dist/lint/post-synth/postgres-names.d.ts.map +1 -1
  39. package/dist/lint/post-synth/sql-helpers.d.ts +5 -6
  40. package/dist/lint/post-synth/sql-helpers.d.ts.map +1 -1
  41. package/dist/lint/post-synth/sqlch102.d.ts.map +1 -1
  42. package/dist/lint/post-synth/sqlch103.d.ts.map +1 -1
  43. package/dist/lint/post-synth/sqlch104.d.ts.map +1 -1
  44. package/dist/lint/post-synth/sqlch105.d.ts.map +1 -1
  45. package/dist/lint/post-synth/sqlch106.d.ts.map +1 -1
  46. package/dist/lint/post-synth/sqlch107.d.ts.map +1 -1
  47. package/dist/lint/post-synth/sqlch111.d.ts.map +1 -1
  48. package/dist/lint/post-synth/sqlch112.d.ts.map +1 -1
  49. package/dist/lint/post-synth/sqlch113.d.ts.map +1 -1
  50. package/dist/lint/post-synth/sqlch114.d.ts.map +1 -1
  51. package/dist/lint/post-synth/sqlch115.d.ts.map +1 -1
  52. package/dist/lint/post-synth/sqlch120.d.ts.map +1 -1
  53. package/dist/lint/post-synth/sqlch125.d.ts.map +1 -1
  54. package/dist/lint/rules/postgres-templates.d.ts +1 -1
  55. package/dist/lint/rules/postgres-templates.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/lint/rules/typed-names.d.ts +1 -1
  59. package/dist/lint/rules/typed-names.d.ts.map +1 -1
  60. package/dist/manifest.json +1 -1
  61. package/dist/postgres/apply/statements.d.ts.map +1 -1
  62. package/dist/postgres/entities.d.ts +7 -1
  63. package/dist/postgres/entities.d.ts.map +1 -1
  64. package/dist/postgres/parser.d.ts +10 -2
  65. package/dist/postgres/parser.d.ts.map +1 -1
  66. package/dist/postgres/plan/diff.d.ts.map +1 -1
  67. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  68. package/dist/postgres/plan/rules.d.ts +1 -0
  69. package/dist/postgres/plan/rules.d.ts.map +1 -1
  70. package/dist/postgres.d.ts +1 -0
  71. package/dist/postgres.d.ts.map +1 -1
  72. package/dist/read/clickhouse.d.ts +66 -0
  73. package/dist/read/clickhouse.d.ts.map +1 -0
  74. package/dist/read/objects.d.ts +99 -0
  75. package/dist/read/objects.d.ts.map +1 -0
  76. package/dist/read/postgres.d.ts +83 -0
  77. package/dist/read/postgres.d.ts.map +1 -0
  78. package/dist/read.d.ts +49 -0
  79. package/dist/read.d.ts.map +1 -0
  80. package/dist/rules/clickhouse-helpers.ts +20 -97
  81. package/dist/rules/postgres-helpers.ts +18 -53
  82. package/dist/rules/postgres-names.ts +6 -6
  83. package/dist/rules/postgres-templates.ts +1 -1
  84. package/dist/rules/sql-helpers.ts +8 -28
  85. package/dist/rules/sqlch003.ts +1 -1
  86. package/dist/rules/sqlch102.ts +2 -3
  87. package/dist/rules/sqlch103.ts +2 -3
  88. package/dist/rules/sqlch104.ts +2 -3
  89. package/dist/rules/sqlch105.ts +2 -3
  90. package/dist/rules/sqlch106.ts +2 -3
  91. package/dist/rules/sqlch107.ts +2 -3
  92. package/dist/rules/sqlch111.ts +2 -3
  93. package/dist/rules/sqlch112.ts +2 -3
  94. package/dist/rules/sqlch113.ts +2 -3
  95. package/dist/rules/sqlch114.ts +2 -3
  96. package/dist/rules/sqlch115.ts +2 -3
  97. package/dist/rules/sqlch120.ts +2 -3
  98. package/dist/rules/sqlch125.ts +2 -3
  99. package/dist/rules/sqlpg002.ts +1 -1
  100. package/dist/rules/templates.ts +1 -1
  101. package/dist/rules/typed-names.ts +1 -1
  102. package/dist/serializer.d.ts +11 -1
  103. package/dist/serializer.d.ts.map +1 -1
  104. package/dist/sql-files.d.ts +17 -1
  105. package/dist/sql-files.d.ts.map +1 -1
  106. package/dist/sql-types.d.ts +3 -0
  107. package/dist/sql-types.d.ts.map +1 -1
  108. package/package.json +2 -3
  109. package/src/clickhouse/apply/statements.ts +2 -0
  110. package/src/clickhouse/entities.ts +8 -1
  111. package/src/clickhouse/expressions.ts +108 -0
  112. package/src/clickhouse/parser.ts +113 -31
  113. package/src/clickhouse/plan/diff.ts +19 -0
  114. package/src/clickhouse/plan/normalize.ts +27 -2
  115. package/src/clickhouse/plan/rebuild-handoff.ts +2 -1
  116. package/src/clickhouse/plan/rules.ts +7 -0
  117. package/src/clickhouse/testdata/server-ddl-baseline.json +67 -0
  118. package/src/clickhouse.ts +10 -0
  119. package/src/codegen/classifier-page.ts +2 -0
  120. package/src/codegen/postgres-classifier-page.ts +2 -0
  121. package/src/core/cursor.ts +96 -0
  122. package/src/core/find-templates.ts +1 -1
  123. package/src/core/interpolation.ts +32 -1
  124. package/src/files/clickhouse.ts +7 -5
  125. package/src/files/common.ts +23 -0
  126. package/src/files/postgres.ts +12 -8
  127. package/src/import-parser.ts +2 -1
  128. package/src/lint/post-synth/clickhouse-helpers.ts +20 -97
  129. package/src/lint/post-synth/postgres-helpers.ts +18 -53
  130. package/src/lint/post-synth/postgres-names.ts +6 -6
  131. package/src/lint/post-synth/sql-helpers.ts +8 -28
  132. package/src/lint/post-synth/sqlch102.ts +2 -3
  133. package/src/lint/post-synth/sqlch103.ts +2 -3
  134. package/src/lint/post-synth/sqlch104.ts +2 -3
  135. package/src/lint/post-synth/sqlch105.ts +2 -3
  136. package/src/lint/post-synth/sqlch106.ts +2 -3
  137. package/src/lint/post-synth/sqlch107.ts +2 -3
  138. package/src/lint/post-synth/sqlch111.ts +2 -3
  139. package/src/lint/post-synth/sqlch112.ts +2 -3
  140. package/src/lint/post-synth/sqlch113.ts +2 -3
  141. package/src/lint/post-synth/sqlch114.ts +2 -3
  142. package/src/lint/post-synth/sqlch115.ts +2 -3
  143. package/src/lint/post-synth/sqlch120.ts +2 -3
  144. package/src/lint/post-synth/sqlch125.ts +2 -3
  145. package/src/lint/rules/postgres-templates.ts +1 -1
  146. package/src/lint/rules/sqlch003.ts +1 -1
  147. package/src/lint/rules/sqlpg002.ts +1 -1
  148. package/src/lint/rules/templates.ts +1 -1
  149. package/src/lint/rules/typed-names.ts +1 -1
  150. package/src/lint/rules/typescript-7.test.ts +101 -0
  151. package/src/lsp/completions.ts +1 -1
  152. package/src/lsp/postgres-template.ts +1 -1
  153. package/src/lsp/postgres.ts +1 -1
  154. package/src/lsp/refs.ts +1 -1
  155. package/src/lsp/template.ts +1 -1
  156. package/src/postgres/apply/statements.ts +2 -0
  157. package/src/postgres/entities.ts +9 -1
  158. package/src/postgres/parser.ts +31 -8
  159. package/src/postgres/plan/diff.ts +18 -0
  160. package/src/postgres/plan/normalize.ts +12 -0
  161. package/src/postgres/plan/rules.ts +7 -0
  162. package/src/postgres/testdata/server-ddl-baseline.json +38 -0
  163. package/src/postgres.ts +12 -0
  164. package/src/read/clickhouse.ts +137 -0
  165. package/src/read/objects.ts +137 -0
  166. package/src/read/postgres.ts +180 -0
  167. package/src/read-policies.e2e.test.ts +144 -0
  168. package/src/read.test.ts +143 -0
  169. package/src/read.ts +69 -0
  170. package/src/serializer.ts +37 -1
  171. package/src/sql-files.ts +27 -6
  172. package/src/sql-types.ts +19 -2
  173. package/src/unknown-clauses.test.ts +227 -0
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Small scanners over ClickHouse expression text as the parser keeps it
3
+ * (sort keys, engine arguments, column types), shared by the post-synth
4
+ * checks (`../lint/post-synth/clickhouse-helpers.ts`) and the public read API
5
+ * (`../read/clickhouse.ts`). They answer narrow questions ("is this a bare
6
+ * column name", "what family is this type"), not what an expression
7
+ * evaluates to.
8
+ */
9
+
10
+ import { TABLE_ENGINES, TYPE_FAMILIES } from "../generated/clickhouse";
11
+ import type { EngineDef } from "./entities";
12
+
13
+ /** The engine's catalog entry, when the pinned server has the engine. */
14
+ export function engineSpec(engine: EngineDef | undefined) {
15
+ return engine ? (TABLE_ENGINES as Record<string, (typeof TABLE_ENGINES)[keyof typeof TABLE_ENGINES]>)[engine.name] : undefined;
16
+ }
17
+
18
+ /** A MergeTree family engine, replicated or not. */
19
+ export function isMergeTree(engine: EngineDef | undefined): boolean {
20
+ return engineSpec(engine)?.mergeTree === true;
21
+ }
22
+
23
+ /** The engine without its `Replicated` prefix: `ReplicatedReplacingMergeTree` is `ReplacingMergeTree`. */
24
+ export function baseEngineName(engine: EngineDef): string {
25
+ const spec = engineSpec(engine) as { replicates?: string } | undefined;
26
+ return spec?.replicates ?? engine.name;
27
+ }
28
+
29
+
30
+
31
+ /** Split on commas that are not inside parentheses, brackets or quotes. */
32
+ export function splitTop(text: string, separator = ","): string[] {
33
+ const parts: string[] = [];
34
+ let depth = 0;
35
+ let quote: string | undefined;
36
+ let start = 0;
37
+ for (let i = 0; i < text.length; i++) {
38
+ const c = text[i]!;
39
+ if (quote) {
40
+ if (c === "\\") i++;
41
+ else if (c === quote) quote = undefined;
42
+ continue;
43
+ }
44
+ if (c === "'" || c === '"' || c === "`") quote = c;
45
+ else if (c === "(" || c === "[") depth++;
46
+ else if (c === ")" || c === "]") depth--;
47
+ else if (c === separator && depth === 0) {
48
+ parts.push(text.slice(start, i).trim());
49
+ start = i + 1;
50
+ }
51
+ }
52
+ const last = text.slice(start).trim();
53
+ if (last !== "" || parts.length > 0) parts.push(last);
54
+ return parts.filter((p) => p !== "");
55
+ }
56
+
57
+ /** `(a, b)` is the list `a`, `b`; `a, b` is the same; `tuple()` and `()` are empty. */
58
+ export function keyElements(expr: string): string[] {
59
+ let text = expr.trim();
60
+ if (/^tuple\s*\(\s*\)$/i.test(text) || text === "()") return [];
61
+ if (text.startsWith("(") && closingParen(text, 0) === text.length - 1) text = text.slice(1, -1);
62
+ return splitTop(text).map((e) => e.replace(/\s+(ASC|DESC)$/i, "").replace(/\s+/g, " ").trim());
63
+ }
64
+
65
+ function closingParen(text: string, open: number): number {
66
+ let depth = 0;
67
+ let quote: string | undefined;
68
+ for (let i = open; i < text.length; i++) {
69
+ const c = text[i]!;
70
+ if (quote) {
71
+ if (c === "\\") i++;
72
+ else if (c === quote) quote = undefined;
73
+ } else if (c === "'" || c === '"' || c === "`") quote = c;
74
+ else if (c === "(") depth++;
75
+ else if (c === ")" && --depth === 0) return i;
76
+ }
77
+ return -1;
78
+ }
79
+
80
+ /** The column a bare name refers to: `a` or `` `a b` ``, else undefined. */
81
+ export function bareName(element: string): string | undefined {
82
+ const text = element.trim();
83
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(text)) return text;
84
+ const quoted = /^`((?:[^`]|``)*)`$/.exec(text) ?? /^"((?:[^"]|"")*)"$/.exec(text);
85
+ return quoted ? quoted[1]!.replace(/``|""/g, (m) => m[0]!) : undefined;
86
+ }
87
+
88
+
89
+
90
+ /** A type with its `Nullable(...)` and `LowCardinality(...)` wrappers removed. */
91
+ export function unwrapType(type: string | undefined): string {
92
+ let t = (type ?? "").trim();
93
+ for (;;) {
94
+ const m = /^(Nullable|LowCardinality)\s*\(([\s\S]*)\)$/i.exec(t);
95
+ if (!m) return t;
96
+ t = m[2]!.trim();
97
+ }
98
+ }
99
+
100
+ /** The canonical family of a column's type (`BIGINT UNSIGNED` is `UInt64`), or its leading word when unknown. */
101
+ export function typeFamily(type: string | undefined): string {
102
+ const head = /^[A-Za-z_][A-Za-z0-9_ ]*?(?=\s*\(|$)/.exec(unwrapType(type))?.[0]?.trim() ?? "";
103
+ const table = TYPE_FAMILIES as Record<string, { canonical: string } | undefined>;
104
+ return (table[head] ?? table[head.toUpperCase()])?.canonical ?? head;
105
+ }
106
+
107
+ export const isNullableType = (type: string | undefined): boolean => /^Nullable\s*\(/i.test((type ?? "").trim());
108
+
@@ -20,9 +20,9 @@
20
20
  */
21
21
 
22
22
  import type { Token } from "./tokens";
23
- import { kw, SqlCursor, unquoteWith, type Span } from "../core/cursor";
23
+ import { kw, SqlCursor, unquoteWith, type Span, type UnknownClause } from "../core/cursor";
24
24
 
25
- export type { Span } from "../core/cursor";
25
+ export type { Span, UnknownClause } from "../core/cursor";
26
26
 
27
27
  export interface ColumnNode {
28
28
  /** The name as written, unquoted. Empty when the name is an interpolation. */
@@ -73,6 +73,8 @@ export interface StorageNode {
73
73
 
74
74
  export interface DatabaseNode {
75
75
  statement: "database";
76
+ /** Clauses the parser does not know (#3872), kept in the DDL as written; absent when there are none. */
77
+ unknown?: UnknownClause[];
76
78
  ifNotExists: boolean;
77
79
  name: Span;
78
80
  onCluster?: Span;
@@ -83,6 +85,8 @@ export interface DatabaseNode {
83
85
 
84
86
  export interface TableNode extends StorageNode {
85
87
  statement: "table";
88
+ /** Clauses the parser does not know (#3872), kept in the DDL as written; absent when there are none. */
89
+ unknown?: UnknownClause[];
86
90
  orReplace: boolean;
87
91
  ifNotExists: boolean;
88
92
  name: Span;
@@ -96,6 +100,8 @@ export interface TableNode extends StorageNode {
96
100
 
97
101
  export interface ViewNode extends StorageNode {
98
102
  statement: "view";
103
+ /** Clauses the parser does not know (#3872), kept in the DDL as written; absent when there are none. */
104
+ unknown?: UnknownClause[];
99
105
  materialized: boolean;
100
106
  orReplace: boolean;
101
107
  ifNotExists: boolean;
@@ -128,6 +134,8 @@ export interface DictionaryAttributeNode {
128
134
 
129
135
  export interface DictionaryNode {
130
136
  statement: "dictionary";
137
+ /** Clauses the parser does not know (#3872), kept in the DDL as written; absent when there are none. */
138
+ unknown?: UnknownClause[];
131
139
  orReplace: boolean;
132
140
  ifNotExists: boolean;
133
141
  name: Span;
@@ -151,6 +159,8 @@ export interface DictionaryNode {
151
159
  /** A SQL user-defined function: `CREATE FUNCTION name AS (x, y) -> expr`. It lives outside any database. */
152
160
  export interface FunctionNode {
153
161
  statement: "function";
162
+ /** Clauses the parser does not know (#3872), kept in the DDL as written; absent when there are none. */
163
+ unknown?: UnknownClause[];
154
164
  orReplace: boolean;
155
165
  ifNotExists: boolean;
156
166
  name: Span;
@@ -177,18 +187,75 @@ export const unquote = (text: string): string => unquoteWith(text, '`"');
177
187
 
178
188
  const countOf = (text: string, c: string): number => text.split(c).length - 1;
179
189
 
190
+ /**
191
+ * Words an expression in a DDL clause holds between two operands, or right
192
+ * after one: operators, `CASE`, `INTERVAL` and its units, a TTL's actions.
193
+ * Any other key word right after an operand starts a new clause (#3872).
194
+ */
195
+ const EXPRESSION_WORDS = new Set(
196
+ (
197
+ "AND OR NOT IN IS NULL LIKE ILIKE BETWEEN ASC DESC NULLS FIRST LAST COLLATE CASE WHEN THEN ELSE END AS INTERVAL GLOBAL ANY ALL " +
198
+ "DISTINCT FROM TO IF EXISTS DISK VOLUME DELETE RECOMPRESS CODEC WHERE GROUP BY SET WITH FILL STEP TRUE FALSE " +
199
+ "NANOSECOND MICROSECOND MILLISECOND SECOND MINUTE HOUR DAY WEEK MONTH QUARTER YEAR"
200
+ ).split(" "),
201
+ );
202
+
180
203
  const STORAGE_KEYWORDS = ["ENGINE", "ORDER", "PRIMARY", "PARTITION", "SAMPLE", "TTL", "SETTINGS", "COMMENT"];
181
204
 
205
+ /** A column's own clause words: an unknown one stops at the next of these (#3872). */
206
+ const COLUMN_WORDS = ["DEFAULT", "MATERIALIZED", "ALIAS", "EPHEMERAL", "NULL", "NOT", "COMMENT", "CODEC", "TTL", "STATISTICS", "SETTINGS", "PRIMARY"];
207
+
182
208
  class Parser extends SqlCursor {
183
- /** `ORDER`, `PARTITION`, `SAMPLE` and `GROUP` stop only before `BY`, `PRIMARY` only before `KEY`. */
209
+ /** Where the clause-aware expression being read started (#3872); undefined outside one. */
210
+ private clauseFrom: number | undefined;
211
+
212
+ /**
213
+ * `ORDER`, `PARTITION`, `SAMPLE` and `GROUP` stop only before `BY`, `PRIMARY`
214
+ * only before `KEY`. In a clause-aware expression, a clause the parser does
215
+ * not know stops it too.
216
+ */
184
217
  protected override stopsAt(stop: readonly string[]): boolean {
185
- if (!super.stopsAt(stop)) return false;
218
+ if (!super.stopsAt(stop)) return this.startsUnknownClause();
186
219
  const word = this.peek()!.text.toUpperCase();
187
220
  const two = ["ORDER", "PARTITION", "SAMPLE", "GROUP"].includes(word) ? kw(this.peek(1), "BY") : true;
188
221
  const key = word === "PRIMARY" ? kw(this.peek(1), "KEY") : true;
189
222
  return two && key;
190
223
  }
191
224
 
225
+ /**
226
+ * Whether a clause the parser does not know starts here, inside a
227
+ * clause-aware expression (#3872): a bare key word right after a complete
228
+ * operand (a name, a literal, a closing parenthesis), which no expression
229
+ * puts side by side unless the word is one of an expression's own.
230
+ */
231
+ private startsUnknownClause(): boolean {
232
+ if (this.clauseFrom === undefined || this.p <= this.clauseFrom) return false;
233
+ const t = this.peek();
234
+ const prev = this.peek(-1);
235
+ if (t?.kind !== "ident" || !/^[A-Za-z_]{3,}$/.test(t.text) || EXPRESSION_WORDS.has(t.text.toUpperCase())) return false;
236
+ if (prev === undefined) return false;
237
+ // A query read into the expression (`ORDER BY id AS SELECT ...`, which the server takes as CREATE TABLE ... AS) is the query's, as it always was.
238
+ for (let n = this.clauseFrom; n < this.p; n++) if (kw(this.tokens[this.sig[n]!], "SELECT")) return false;
239
+ return (
240
+ prev.kind === "number" ||
241
+ prev.kind === "string" ||
242
+ prev.kind === "qident" ||
243
+ (prev.kind === "punct" && prev.text === ")") ||
244
+ (prev.kind === "ident" && !EXPRESSION_WORDS.has(prev.text.toUpperCase()))
245
+ );
246
+ }
247
+
248
+ /** An expression a clause the parser does not know can follow (#3872): it ends where that clause starts. */
249
+ private clauseExpr(stop: readonly string[] = [], stopAtComma = true): Span {
250
+ const saved = this.clauseFrom;
251
+ this.clauseFrom = this.p;
252
+ try {
253
+ return this.expr(stop, stopAtComma);
254
+ } finally {
255
+ this.clauseFrom = saved;
256
+ }
257
+ }
258
+
192
259
  /** Array and map literals: `[` and `]` arrive inside operator runs. */
193
260
  protected override depthChange(t: Token): number {
194
261
  if (t.kind === "op") return countOf(t.text, "[") + countOf(t.text, "{") - countOf(t.text, "]") - countOf(t.text, "}");
@@ -255,20 +322,20 @@ class Parser extends SqlCursor {
255
322
  col.nullable = false;
256
323
  } else if (kw(this.peek(), "DEFAULT", "MATERIALIZED", "ALIAS")) {
257
324
  const kind = this.next().text.toUpperCase() as "DEFAULT" | "MATERIALIZED" | "ALIAS";
258
- col.default = { kind, expr: this.expr(STOP) };
325
+ col.default = { kind, expr: this.clauseExpr(STOP) };
259
326
  } else if (this.accept("EPHEMERAL")) {
260
327
  const t = this.peek();
261
328
  const hasExpr = t && !(t.kind === "punct" && (t.text === "," || t.text === ")")) && !kw(t, ...STOP);
262
- col.default = { kind: "EPHEMERAL", expr: hasExpr ? this.expr(STOP) : undefined };
263
- } else if (this.accept("COMMENT")) col.comment = this.expr(STOP);
329
+ col.default = { kind: "EPHEMERAL", expr: hasExpr ? this.clauseExpr(STOP) : undefined };
330
+ } else if (this.accept("COMMENT")) col.comment = this.clauseExpr(STOP);
264
331
  else if (this.accept("CODEC")) col.codec = this.parenthesized();
265
332
  else if (this.accept("STATISTICS")) col.statistics = this.parenthesized();
266
- else if (this.accept("TTL")) col.ttl = this.expr(STOP);
333
+ else if (this.accept("TTL")) col.ttl = this.clauseExpr(STOP);
267
334
  else if (this.accept("SETTINGS")) col.settings = this.parenthesized();
268
335
  else if (kw(this.peek(), "PRIMARY") && kw(this.peek(1), "KEY")) {
269
336
  this.p += 2;
270
337
  col.primaryKey = true;
271
- } else break;
338
+ } else if (!this.unknownClause(COLUMN_WORDS, { inList: true, column: text || undefined })) break;
272
339
  }
273
340
  return col;
274
341
  }
@@ -360,7 +427,7 @@ class Parser extends SqlCursor {
360
427
  for (;;) {
361
428
  const key = this.name("a setting name").text;
362
429
  this.expectPunct("=");
363
- out.push({ key, value: this.expr(stop) });
430
+ out.push({ key, value: this.clauseExpr(stop) });
364
431
  if (!this.acceptPunct(",")) return out;
365
432
  }
366
433
  }
@@ -372,27 +439,33 @@ class Parser extends SqlCursor {
372
439
  if (this.accept("ENGINE")) into.engine = this.engine();
373
440
  else if (kw(this.peek(), "ORDER") && kw(this.peek(1), "BY")) {
374
441
  this.p += 2;
375
- into.orderBy = this.expr(STOP, false);
442
+ into.orderBy = this.clauseExpr(STOP, false);
376
443
  } else if (kw(this.peek(), "PRIMARY") && kw(this.peek(1), "KEY")) {
377
444
  this.p += 2;
378
- into.primaryKey = this.expr(STOP, false);
445
+ into.primaryKey = this.clauseExpr(STOP, false);
379
446
  } else if (kw(this.peek(), "PARTITION") && kw(this.peek(1), "BY")) {
380
447
  this.p += 2;
381
- into.partitionBy = this.expr(STOP, false);
448
+ into.partitionBy = this.clauseExpr(STOP, false);
382
449
  } else if (kw(this.peek(), "SAMPLE") && kw(this.peek(1), "BY")) {
383
450
  this.p += 2;
384
- into.sampleBy = this.expr(STOP, false);
385
- } else if (this.accept("TTL")) into.ttl = this.expr(STOP, false);
451
+ into.sampleBy = this.clauseExpr(STOP, false);
452
+ } else if (this.accept("TTL")) into.ttl = this.clauseExpr(STOP, false);
386
453
  else if (kw(this.peek(), "SETTINGS")) {
387
454
  const from = this.idx();
388
455
  this.p++;
389
456
  into.settings = this.settingsList(STOP);
390
457
  into.settingsClause = this.span(from, into.settings.flatMap((s) => s.value.refs));
391
- } else return;
458
+ } else if (!this.unknownClause(STOP)) return;
392
459
  }
393
460
  }
394
461
 
395
462
  parse(): CreateNode {
463
+ const node = this.statement();
464
+ if (this.unknownClauses.length > 0) node.unknown = [...this.unknownClauses];
465
+ return node;
466
+ }
467
+
468
+ private statement(): CreateNode {
396
469
  this.expect("CREATE");
397
470
  let orReplace = false;
398
471
  if (this.accept("OR")) {
@@ -429,7 +502,9 @@ class Parser extends SqlCursor {
429
502
  if (kw(this.peek(), "UUID") && this.peek(1)?.kind === "string") this.p += 2;
430
503
  }
431
504
 
432
- private finish(what: string): void {
505
+ /** The statement's end, after any trailing clauses the parser does not know (#3872); `known` are the statement's own clause words. */
506
+ private finish(what: string, known: readonly string[] = []): void {
507
+ while (this.unknownClause(known));
433
508
  this.acceptPunct(";");
434
509
  if (this.peek() !== undefined) this.fail(`unexpected token after the ${what}`);
435
510
  }
@@ -438,10 +513,15 @@ class Parser extends SqlCursor {
438
513
  const node: DatabaseNode = { statement: "database", ifNotExists: this.ifNotExists(), name: this.qualifiedName() };
439
514
  this.uuid();
440
515
  node.onCluster = this.onCluster();
441
- if (this.accept("ENGINE")) node.engine = this.engine();
442
- if (this.accept("SETTINGS")) node.settings = this.settingsList(["COMMENT"]);
443
- if (this.accept("COMMENT")) node.comment = this.expr();
444
- this.finish("database definition");
516
+ const CLAUSES = ["ENGINE", "SETTINGS", "COMMENT"];
517
+ for (;;) {
518
+ // In the server's order: ENGINE, SETTINGS, COMMENT, with clauses chant does not know between them.
519
+ if (!node.engine && !node.settings && !node.comment && this.accept("ENGINE")) node.engine = this.engine();
520
+ else if (!node.settings && !node.comment && this.accept("SETTINGS")) node.settings = this.settingsList(CLAUSES);
521
+ else if (!node.comment && this.accept("COMMENT")) node.comment = this.clauseExpr();
522
+ else if (!this.unknownClause(CLAUSES)) break;
523
+ }
524
+ this.finish("database definition", CLAUSES);
445
525
  return node;
446
526
  }
447
527
 
@@ -463,8 +543,8 @@ class Parser extends SqlCursor {
463
543
  if (!this.isPunct("(")) this.fail("expected the column list (CREATE TABLE ... AS is not declared here)");
464
544
  this.tableElements(node, true);
465
545
  this.storage(node, []);
466
- if (this.accept("COMMENT")) node.comment = this.expr();
467
- this.finish("table definition");
546
+ if (this.accept("COMMENT")) node.comment = this.clauseExpr();
547
+ this.finish("table definition", STORAGE_KEYWORDS);
468
548
  return node;
469
549
  }
470
550
 
@@ -473,10 +553,10 @@ class Parser extends SqlCursor {
473
553
  const STOP = ["DEFAULT", "EXPRESSION", "HIERARCHICAL", "BIDIRECTIONAL", "INJECTIVE", "IS_OBJECT_ID"];
474
554
  const attr: DictionaryAttributeNode = { name: text, nameSpan: span, type: this.typeSpan(), flags: [] };
475
555
  for (;;) {
476
- if (this.accept("DEFAULT")) attr.default = this.expr(STOP);
477
- else if (this.accept("EXPRESSION")) attr.expression = this.expr(STOP);
556
+ if (this.accept("DEFAULT")) attr.default = this.clauseExpr(STOP);
557
+ else if (this.accept("EXPRESSION")) attr.expression = this.clauseExpr(STOP);
478
558
  else if (kw(this.peek(), "HIERARCHICAL", "BIDIRECTIONAL", "INJECTIVE", "IS_OBJECT_ID")) attr.flags.push(this.next().text.toUpperCase());
479
- else return attr;
559
+ else if (!this.unknownClause(STOP, { inList: true, column: text || undefined })) return attr;
480
560
  }
481
561
  }
482
562
 
@@ -499,19 +579,19 @@ class Parser extends SqlCursor {
499
579
  for (;;) {
500
580
  if (kw(this.peek(), "PRIMARY") && kw(this.peek(1), "KEY")) {
501
581
  this.p += 2;
502
- node.primaryKey = this.expr(CLAUSES, false);
582
+ node.primaryKey = this.clauseExpr(CLAUSES, false);
503
583
  } else if (this.accept("SOURCE")) node.source = this.parenthesized();
504
584
  else if (this.accept("LAYOUT")) node.layout = this.parenthesized();
505
585
  else if (this.accept("LIFETIME")) node.lifetime = this.parenthesized();
506
586
  else if (this.accept("RANGE")) node.range = this.parenthesized();
507
587
  else if (this.accept("SETTINGS")) node.settings = this.parenthesized();
508
- else if (this.accept("COMMENT")) node.comment = this.expr();
509
- else break;
588
+ else if (this.accept("COMMENT")) node.comment = this.clauseExpr();
589
+ else if (!this.unknownClause(CLAUSES)) break;
510
590
  }
511
591
  if (!node.primaryKey) this.fail("expected PRIMARY KEY: a dictionary needs its key columns");
512
592
  if (!node.source) this.fail("expected SOURCE(...): a dictionary needs a source");
513
593
  if (!node.layout) this.fail("expected LAYOUT(...): a dictionary needs a layout");
514
- this.finish("dictionary definition");
594
+ this.finish("dictionary definition", CLAUSES);
515
595
  return node;
516
596
  }
517
597
 
@@ -573,10 +653,12 @@ class Parser extends SqlCursor {
573
653
  this.tableElements(holder, false);
574
654
  node.columnsSpan = this.span(from, []);
575
655
  }
576
- this.storage(node, ["AS", "POPULATE", "EMPTY", "DEFINER", "SQL"]);
656
+ const BEFORE_AS = ["AS", "POPULATE", "EMPTY", "DEFINER", "SQL"];
657
+ this.storage(node, BEFORE_AS);
577
658
  if (this.accept("POPULATE")) node.populate = true;
578
659
  if (this.accept("EMPTY")) node.empty = true;
579
660
  if (kw(this.peek(), "DEFINER", "SQL")) node.security = this.expr(["AS"], false);
661
+ while (!kw(this.peek(), "AS") && this.unknownClause([...STORAGE_KEYWORDS, ...BEFORE_AS]));
580
662
  this.expect("AS");
581
663
  node.select = this.expr(["COMMENT"], false);
582
664
  if (this.accept("COMMENT")) node.comment = this.expr();
@@ -129,7 +129,26 @@ function diffMap(key: string, field: string, before: Record<string, string>, aft
129
129
  for (const name of changedEntries(before, after)) out.push(change(key, `${field}.${name}`, rule, before[name], after[name]));
130
130
  }
131
131
 
132
+ /**
133
+ * The clauses chant does not read (#3872), compared as text. One the
134
+ * declaration has and the server does not, or has differently, is a change
135
+ * to review (SQLCH290). One only the server prints is a hint, not a change: a
136
+ * clause a new server prints with its default cannot be told from one
137
+ * removed from the declaration.
138
+ */
139
+ function diffUnparsed(key: string, before: CanonicalObject, after: CanonicalObject, out: Change[], hints: string[]): void {
140
+ const b = before.unparsed ?? {};
141
+ const a = after.unparsed ?? {};
142
+ for (const clause of Object.keys(a)) {
143
+ if (b[clause] !== a[clause]) out.push(change(key, `unparsed.${clause}`, "SQLCH290", b[clause], a[clause], { note: "chant does not read this clause; review the change" }));
144
+ }
145
+ for (const clause of Object.keys(b)) {
146
+ if (!(clause in a)) hints.push(`${key}: the current definition has ${clause} (${b[clause]}), a clause chant does not read and the declaration does not have; it is not compared`);
147
+ }
148
+ }
149
+
132
150
  function diffObject(key: string, before: CanonicalObject, after: CanonicalObject, out: Change[], hints: string[]): void {
151
+ diffUnparsed(key, before, after, out, hints);
133
152
  if (before.kind !== after.kind) {
134
153
  out.push(change(key, "kind", "SQLCH224", before.kind, after.kind, { destructive: before.kind === "table" }));
135
154
  return;
@@ -19,7 +19,9 @@
19
19
  * - a codec's default level goes (`ZSTD(1)` is `ZSTD`, `Delta(4)` is `Delta`);
20
20
  * - a TTL rule's trailing `DELETE` goes;
21
21
  * - a setting at the pinned server's default goes;
22
- * - a view's column list is not compared: the server infers it.
22
+ * - a view's column list is not compared: the server infers it;
23
+ * - a clause the parser does not know (#3872) is kept as an expression, by
24
+ * its name, and compared as one.
23
25
  *
24
26
  * What the rules cannot see is the server's own expression formatting
25
27
  * (`a+b*2` prints as `a + (b * 2)`). When a plan reads a live server it asks
@@ -32,7 +34,7 @@
32
34
  */
33
35
 
34
36
  import { isTrivia, tokenizeText, type Token } from "../tokens";
35
- import { parseCreate, unquote, type ColumnNode, type CreateNode, type DictionaryAttributeNode, type DictionaryNode, type Span } from "../parser";
37
+ import { parseCreate, unquote, type ColumnNode, type CreateNode, type DictionaryAttributeNode, type DictionaryNode, type Span, type UnknownClause } from "../parser";
36
38
  import { MERGE_TREE_SETTINGS, TYPE_FAMILIES } from "../../generated/clickhouse";
37
39
  import { previouslyIn, splitTopLevel as splitTop } from "../../core/normalize";
38
40
  import { accessCanonical, grantsCanonical, isAccessStatement } from "../access";
@@ -100,6 +102,14 @@ export interface CanonicalObject {
100
102
  previously?: string;
101
103
  /** The tool that keeps a live table (a migration runner's history table): a plan never drops it. */
102
104
  foreign?: string;
105
+ /**
106
+ * Clauses the parser does not know (#3872), each canonical as an
107
+ * expression, keyed by the clause (`columns.<name>.<clause>` for a
108
+ * column's). Absent when there are none. The rest of the object is read
109
+ * as usual; these are compared as text, and a plan classes a change in
110
+ * one as a change to review.
111
+ */
112
+ unparsed?: Record<string, string>;
103
113
  }
104
114
 
105
115
  const KEYWORDS = new Set(
@@ -305,6 +315,8 @@ export function canonicalObject(ddl: string, defaultDatabase = "default"): Canon
305
315
  };
306
316
  const previously = previouslyIn(leading);
307
317
  if (previously) obj.previously = previously;
318
+ const unparsed = unparsedOf(tokens, node.unknown, database);
319
+ if (unparsed) obj.unparsed = unparsed;
308
320
 
309
321
  const comment0 = stringValue(spanText(tokens, node.comment));
310
322
  if (node.statement === "dictionary") {
@@ -379,6 +391,19 @@ export function canonicalObject(ddl: string, defaultDatabase = "default"): Canon
379
391
  return obj;
380
392
  }
381
393
 
394
+ /** A statement's unknown clauses (#3872), by clause, each canonical as an expression; a second of the same name is `CLAUSE#2`. */
395
+ function unparsedOf(tokens: Token[], unknown: readonly UnknownClause[] | undefined, database: string | undefined): Record<string, string> | undefined {
396
+ if (!unknown || unknown.length === 0) return undefined;
397
+ const out: Record<string, string> = {};
398
+ for (const u of unknown) {
399
+ const base = u.column !== undefined ? `columns.${u.column}.${u.clause}` : u.clause;
400
+ let key = base;
401
+ for (let n = 2; key in out; n++) key = `${base}#${n}`;
402
+ out[key] = canonicalExpression(spanText(tokens, u.span) ?? "", database);
403
+ }
404
+ return out;
405
+ }
406
+
382
407
  // ── dictionaries ──────────────────────────────────────────────────────
383
408
 
384
409
  /**
@@ -37,7 +37,8 @@ const TIME_TYPE = /^(?:Nullable\s*\(\s*)?(?:Date|Date32|DateTime|DateTime64)\b/;
37
37
  */
38
38
  export function rebuildOpSuggestions(diff: SchemaDiff, declared: ReadonlyMap<string, CanonicalObject>, env: string): RebuildOpSuggestion[] {
39
39
  const out: RebuildOpSuggestion[] = [];
40
- for (const key of [...new Set(diff.rebuilds.map((c) => c.object))]) {
40
+ // A clause chant does not read (SQLCH290) is refused for review, not because a rebuild is known to be what it takes.
41
+ for (const key of [...new Set(diff.rebuilds.filter((c) => c.rule !== "SQLCH290").map((c) => c.object))]) {
41
42
  const c = declared.get(key);
42
43
  if (!c || c.kind !== "table") continue;
43
44
  const table = `${c.database ?? "default"}.${c.name}`;
@@ -278,6 +278,13 @@ export const CLASSIFIER_RULES = {
278
278
  "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
279
  `${DOCS}/revoke`,
280
280
  ),
281
+ SQLCH290: rule(
282
+ "SQLCH290",
283
+ "rebuild",
284
+ "Change a clause chant does not read",
285
+ "chant's parser does not know this clause, so it cannot tell whether ALTER can make the change in place; the plan refuses it rather than guess. Review it: make it by hand with the ALTER the server documents for the clause, or drop and create the object.",
286
+ `${DOCS}/create/table`,
287
+ ),
281
288
  SQLCH250: rule("SQLCH250", "drop", "Drop an object", "DROP removes the object and, for a table, its data; it is not undone.", `${DOCS}/drop`),
282
289
  } as const satisfies Record<string, ClassifierRule>;
283
290
 
@@ -0,0 +1,67 @@
1
+ {
2
+ "release": "v26.8.15.10-lts",
3
+ "unknownClauses": [
4
+ "APPEND TO",
5
+ "AUTO_INCREMENT",
6
+ "CHAR",
7
+ "CHAR VARYING",
8
+ "CHARACTER",
9
+ "CHARACTER LARGE OBJECT",
10
+ "CHARACTER VARYING",
11
+ "DEFINER",
12
+ "DEPENDS ON",
13
+ "FORMAT",
14
+ "FORMAT NULL",
15
+ "HAVE",
16
+ "REFERENCES",
17
+ "SQL SECURITY INVOKER",
18
+ "SQL SECURITY NONE",
19
+ "STRING",
20
+ "UNIQUE KEY",
21
+ "UUID",
22
+ "YEARS"
23
+ ],
24
+ "failures": [
25
+ "expected ')' at 'WITH'",
26
+ "expected '='",
27
+ "expected '=' at '('",
28
+ "expected '=' at ','",
29
+ "expected -> and the function's expression at '('",
30
+ "expected AS at 'COMMENT'",
31
+ "expected AS at 'LANGUAGE'",
32
+ "expected AS at 'WASM'",
33
+ "expected PRIMARY KEY: a dictionary needs its key columns",
34
+ "expected TYPE at ')'",
35
+ "expected a clause (CREATE starts a query or a statement, which is not declared here) at 'CREATE'",
36
+ "expected a column name at '“'",
37
+ "expected a name at '{'",
38
+ "expected a setting name at ''John''",
39
+ "expected a setting name at ''val-''",
40
+ "expected a setting name at '*+'",
41
+ "expected a setting name at '1'",
42
+ "expected a setting name at '3628718300'",
43
+ "expected a type at '123'",
44
+ "expected a type at '`Int64`'",
45
+ "expected a type at '`Null`'",
46
+ "expected a type at '`a.b`'",
47
+ "expected a type at '`ab`'",
48
+ "expected an expression",
49
+ "expected an expression at ')'",
50
+ "expected an expression at 'TYPE'",
51
+ "expected the column list (CREATE TABLE ... AS is not declared here) at 'AS'",
52
+ "expected the column list (CREATE TABLE ... AS is not declared here) at 'CLONE'",
53
+ "expected the column list (CREATE TABLE ... AS is not declared here) at 'COMMENT'",
54
+ "expected the column list (CREATE TABLE ... AS is not declared here) at 'ENGINE'",
55
+ "expected the column list (CREATE TABLE ... AS is not declared here) at 'Engine'",
56
+ "expected the column list (CREATE TABLE ... AS is not declared here) at 'ORDER'",
57
+ "expected the column list (CREATE TABLE ... AS is not declared here) at 'SQL'",
58
+ "expected the column list (CREATE TABLE ... AS is not declared here) at 'TO'",
59
+ "expected the column list (CREATE TABLE ... AS is not declared here) at 'as'",
60
+ "expected the column list (CREATE TABLE ... AS is not declared here) at 'empty'",
61
+ "expected the column list (CREATE TABLE ... AS is not declared here) at 'engine'",
62
+ "unexpected end of statement",
63
+ "unexpected token after the table definition at 'AS'",
64
+ "unexpected token after the table definition at 'SETTINGS'",
65
+ "unexpected token after the table definition at 'as'"
66
+ ]
67
+ }
package/src/clickhouse.ts CHANGED
@@ -106,3 +106,13 @@ export {
106
106
  type Topology,
107
107
  type TopologyKind,
108
108
  } from "./clickhouse/topology";
109
+
110
+ // The build's ClickHouse objects as typed values, for a project's own
111
+ // `lint.policies` (#3870); also at `@intentius/chant-lexicon-sql/read`.
112
+ export {
113
+ readClickHouse,
114
+ type ClickHouseRead,
115
+ type ClickHouseTableInfo,
116
+ type ClickHouseColumnInfo,
117
+ type ClickHouseViewInfo,
118
+ } from "./read/clickhouse";
@@ -49,6 +49,8 @@ Both exit 2 when a change needs a rebuild. \`chant sql diff\` compares a pull re
49
49
 
50
50
  \`chant sql plan\` also exits 2 when a declared database holds an object whose definition it cannot read. It names each one under \`Refused\` (\`unreadable\` in \`--json\`) instead of leaving it out. Dictionaries are read and planned like the other objects: any change but a comment replaces one (SQLCH245).
51
51
 
52
+ A clause chant's parser does not know ([Plain .sql Files](../sql-files/#clauses-chant-does-not-read)) is compared as text, and the rest of its object is planned as usual. A change to one is SQLCH290, refused for review with no rebuild Op suggested, since chant cannot tell what the change takes. A clause only the server prints is a hint, not a change: a newer server printing a clause with its default looks the same as a clause removed from the declaration.
53
+
52
54
  ## After the plan
53
55
 
54
56
  The plan only reports. [\`clickhouseApply\`](../applying/) makes the metadata-only and background-rewrite changes, and refuses a rebuild the same way the plan does, sending nothing for that object. A rebuild runs as \`ClickHouseRebuildOp\`, a gated migration Op that creates the new table, backfills it, verifies it and swaps it in: see [Rebuilding a Table](../rebuild/). Both commands print the Op declaration to start from for each refused table.
@@ -44,6 +44,8 @@ chant sql plan prod schema.json # a build output against the prod server
44
44
 
45
45
  Both read the build's \`dialect\` and exit 2 when a change can only be made as expand and contract. \`--json\` prints the changes, the hints, the refused changes and the migration Op suggestions as one document. \`chant sql diff\` compares a pull request's base and head builds with no server. \`chant sql plan\` reads the server \`sql.profiles.<env>\` binds (else \`POSTGRES_URL\`), in the profile's \`schemas\` when it lists them, otherwise in the default schema and every schema the build declares an object in. Where the normalization rules leave an expression different, it asks that server: the declared view, or the table's column types, defaults, generated expressions and checks, are created as temporary objects in a transaction that is always rolled back, and read back with the same printers, so a view's \`SELECT *\` and the server's added parentheses are not reported as changes.
46
46
 
47
+ A clause chant's parser does not know ([Plain .sql Files](../sql-files/#clauses-chant-does-not-read)) is compared as text, and the rest of its object is planned as usual. A change to one is SQLPG299, refused for review with no migration Op suggested, since chant cannot tell which lock the change takes. A clause only the server prints is a hint, not a change.
48
+
47
49
  ## Statements for a migration file
48
50
 
49
51
  \`chant sql diff base.json head.json --statements\` prints the statements that take the base schema to the head one, offline: each one the applier would send for the same change, after a comment naming its object, rule and class, and whether it has to run outside a transaction block. \`--json\` prints them as one document, which \`diffStatements(before, after)\` from \`@intentius/chant-lexicon-sql\` also returns, each statement with \`transactional\` set: