@intentius/chant-lexicon-sql 0.124.19 → 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 (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
@@ -10,6 +10,8 @@ import type { ClickHouseTarget } from "./bind";
10
10
  import { CLICKHOUSE_ENTITY_TYPES, type ClickHouseEntityType } from "../entities";
11
11
  import { isChantWorkingObject, stripMarkerFromStatement } from "../ownership";
12
12
  import { foreignTool } from "../../core/foreign-tables";
13
+ import { parseCreate, unquote } from "../parser";
14
+ import { tokenizeText } from "../tokens";
13
15
 
14
16
  /** The server's own databases, never part of a declared schema. */
15
17
  export const SYSTEM_DATABASES = new Set(["system", "information_schema", "INFORMATION_SCHEMA"]);
@@ -69,8 +71,10 @@ export async function readLiveSchema(target: ClickHouseTarget, opts: { withState
69
71
  const databases = await q<{ name: string; engine: string; uuid: string; comment: string }>(
70
72
  `SELECT name, engine, toString(uuid) AS uuid, comment FROM system.databases WHERE name ${scope} ORDER BY name`,
71
73
  );
72
- const tables = await q<{ database: string; name: string; engine: string; uuid: string; comment: string; dictionary: number }>(
73
- `SELECT database, name, engine, toString(uuid) AS uuid, comment, startsWith(create_table_query, 'CREATE DICTIONARY') AS dictionary FROM system.tables ` +
74
+ // `system.tables` shows no comment for a table over a table function, whatever it was created with: its statement has it (#3911).
75
+ const tables = await q<{ database: string; name: string; engine: string; uuid: string; comment: string; dictionary: number; proxy: string }>(
76
+ `SELECT database, name, engine, toString(uuid) AS uuid, comment, startsWith(create_table_query, 'CREATE DICTIONARY') AS dictionary, ` +
77
+ `if(comment = '' AND position(create_table_query, ' COMMENT ') > 0, create_table_query, '') AS proxy FROM system.tables ` +
74
78
  `WHERE database ${scope} AND NOT is_temporary AND name NOT LIKE '.inner%' ` +
75
79
  `ORDER BY database, name`,
76
80
  );
@@ -94,6 +98,7 @@ export async function readLiveSchema(target: ClickHouseTarget, opts: { withState
94
98
  });
95
99
  }
96
100
  for (const t of tables) {
101
+ if (t.proxy) t.comment = commentOf(t.proxy) ?? t.comment;
97
102
  if (working.has(t.database) || isChantWorkingObject(t.comment)) continue;
98
103
  const type = Number(t.dictionary) === 1 ? CLICKHOUSE_ENTITY_TYPES.dictionary : entityTypeOfEngine(t.engine);
99
104
  const foreign = type === CLICKHOUSE_ENTITY_TYPES.table ? foreignTool(t.name) : undefined;
@@ -119,6 +124,23 @@ export async function readLiveSchema(target: ClickHouseTarget, opts: { withState
119
124
  }
120
125
 
121
126
 
127
+ /** A `CREATE` statement's own `COMMENT`, its value unquoted; undefined when it has none or does not parse. */
128
+ function commentOf(statement: string): string | undefined {
129
+ try {
130
+ const tokens = tokenizeText(statement, 0);
131
+ const node = parseCreate(tokens);
132
+ if (!node.comment) return undefined;
133
+ const literal = tokens
134
+ .slice(node.comment.from, node.comment.to)
135
+ .map((t) => t.text)
136
+ .join("")
137
+ .trim();
138
+ return /^'.*'$/s.test(literal) ? literal.slice(1, -1).replace(/\\(.)/g, "$1").replace(/''/g, "'") : unquote(literal);
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ }
143
+
122
144
  /** The access objects a build declares, by kind and name: what `readLiveAccess` reads. */
123
145
  export interface DeclaredAccess {
124
146
  kind: string;
@@ -128,9 +150,10 @@ export interface DeclaredAccess {
128
150
  }
129
151
 
130
152
  /**
131
- * The users, roles, row policies and grantees' grants the build declares,
132
- * as the server holds them (#3682): `SHOW CREATE USER`, `SHOW CREATE ROLE`,
133
- * `SHOW CREATE ROW POLICY`, and for a grantee every line of
153
+ * The users, roles, row policies, quotas, settings profiles and grantees'
154
+ * grants the build declares, as the server holds them (#3682, #3911):
155
+ * `SHOW CREATE USER`, `SHOW CREATE ROLE`, `SHOW CREATE ROW POLICY`,
156
+ * `SHOW CREATE QUOTA`, `SHOW CREATE SETTINGS PROFILE`, and for a grantee every line of
134
157
  * `SHOW GRANTS FOR`, one per line. Nothing else is read, so a project that
135
158
  * declares none needs no access to `system.users` and the like, and an
136
159
  * object made by hand is never seen. One that does not exist is left out.
@@ -155,6 +178,17 @@ export async function readLiveAccess(target: ClickHouseTarget, declared: readonl
155
178
  for (const name of roles.filter((r) => existingRoles.has(r))) {
156
179
  out.push({ type: CLICKHOUSE_ENTITY_TYPES.role, name, engine: "", statement: await first(`SHOW CREATE ROLE ${ident(name)}`) });
157
180
  }
181
+ for (const [kind, type, table, show] of [
182
+ ["quota", CLICKHOUSE_ENTITY_TYPES.quota, "system.quotas", "QUOTA"],
183
+ ["settingsProfile", CLICKHOUSE_ENTITY_TYPES.settingsProfile, "system.settings_profiles", "SETTINGS PROFILE"],
184
+ ] as const) {
185
+ const wanted = names(kind);
186
+ if (wanted.length === 0) continue;
187
+ const existing = new Set((await q<{ name: string }>(`SELECT name FROM ${table} WHERE name IN (${list(wanted)})`)).map((r) => r.name));
188
+ for (const name of wanted.filter((n) => existing.has(n))) {
189
+ out.push({ type, name, engine: "", statement: await first(`SHOW CREATE ${show} ${ident(name)}`) });
190
+ }
191
+ }
158
192
  const policies = declared.filter((d) => d.kind === "rowPolicy");
159
193
  if (policies.length > 0) {
160
194
  const live = await q<{ name: string; database: string; table: string }>(
@@ -180,13 +214,20 @@ export async function readLiveAccess(target: ClickHouseTarget, declared: readonl
180
214
  }
181
215
 
182
216
  /** The canonical kinds a profile's `access` switch decides (#3716). */
183
- export const ACCESS_KINDS: ReadonlySet<string> = new Set(["user", "role", "rowPolicy", "grants"]);
217
+ export const ACCESS_KINDS: ReadonlySet<string> = new Set(["user", "role", "rowPolicy", "quota", "settingsProfile", "grants"]);
184
218
 
185
219
  /** The entity types a profile's `access` switch decides (#3716). */
186
- export const ACCESS_ENTITY_TYPES: ReadonlySet<string> = new Set([CLICKHOUSE_ENTITY_TYPES.user, CLICKHOUSE_ENTITY_TYPES.role, CLICKHOUSE_ENTITY_TYPES.rowPolicy, CLICKHOUSE_ENTITY_TYPES.grant]);
220
+ export const ACCESS_ENTITY_TYPES: ReadonlySet<string> = new Set([
221
+ CLICKHOUSE_ENTITY_TYPES.user,
222
+ CLICKHOUSE_ENTITY_TYPES.role,
223
+ CLICKHOUSE_ENTITY_TYPES.rowPolicy,
224
+ CLICKHOUSE_ENTITY_TYPES.quota,
225
+ CLICKHOUSE_ENTITY_TYPES.settingsProfile,
226
+ CLICKHOUSE_ENTITY_TYPES.grant,
227
+ ]);
187
228
 
188
229
  /** Why an access declaration is not read, planned or applied where the profile does not manage access. */
189
- export const ACCESS_UNMANAGED_DETAIL = "access is not managed in this environment: set sql.profiles.<env>.access to plan and apply users, roles, row policies and grants";
230
+ export const ACCESS_UNMANAGED_DETAIL = "access is not managed in this environment: set sql.profiles.<env>.access to plan and apply users, roles, row policies, quotas, settings profiles and grants";
190
231
 
191
232
  /** The hint a plan gives for the access declarations it left out. */
192
233
  export function accessUnmanagedHint(n: number, environment: string): string {
@@ -200,9 +241,15 @@ export function declaredAccess(objects: ReadonlyArray<{ kind: string; name: stri
200
241
  .map((o) => ({ kind: o.kind, name: o.name, ...(o.database !== undefined ? { database: o.database } : {}), ...(o.table !== undefined ? { table: o.table } : {}) }));
201
242
  }
202
243
 
203
- /** The users, roles and row policies among declared entities, for `readLiveAccess`. */
244
+ /** The users, roles, row policies, quotas and settings profiles among declared entities, for `readLiveAccess`. */
204
245
  export function accessOf(entities: ReadonlyMap<string, { entityType: string; props: Record<string, unknown> }>, defaultDatabase: string): DeclaredAccess[] {
205
- const kinds: Record<string, string> = { [CLICKHOUSE_ENTITY_TYPES.user]: "user", [CLICKHOUSE_ENTITY_TYPES.role]: "role", [CLICKHOUSE_ENTITY_TYPES.rowPolicy]: "rowPolicy" };
246
+ const kinds: Record<string, string> = {
247
+ [CLICKHOUSE_ENTITY_TYPES.user]: "user",
248
+ [CLICKHOUSE_ENTITY_TYPES.role]: "role",
249
+ [CLICKHOUSE_ENTITY_TYPES.rowPolicy]: "rowPolicy",
250
+ [CLICKHOUSE_ENTITY_TYPES.quota]: "quota",
251
+ [CLICKHOUSE_ENTITY_TYPES.settingsProfile]: "settingsProfile",
252
+ };
206
253
  const out: DeclaredAccess[] = [];
207
254
  for (const e of entities.values()) {
208
255
  const kind = kinds[e.entityType];
@@ -42,7 +42,9 @@ export function declaredAddress(entity: DeclaredEntity, defaultDatabase: string)
42
42
  entity.type === CLICKHOUSE_ENTITY_TYPES.database ||
43
43
  entity.type === CLICKHOUSE_ENTITY_TYPES.function ||
44
44
  entity.type === CLICKHOUSE_ENTITY_TYPES.user ||
45
- entity.type === CLICKHOUSE_ENTITY_TYPES.role
45
+ entity.type === CLICKHOUSE_ENTITY_TYPES.role ||
46
+ entity.type === CLICKHOUSE_ENTITY_TYPES.quota ||
47
+ entity.type === CLICKHOUSE_ENTITY_TYPES.settingsProfile
46
48
  ) {
47
49
  return { name };
48
50
  }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The ClickHouse kinds #3911 adds, against the pinned server: quotas,
3
+ * settings profiles, a table over a table function, and a refreshable
4
+ * materialized view and a table's skip index and projection as the server
5
+ * prints them back.
6
+ *
7
+ * Each round-trips: declared in a source file, built, applied, and then
8
+ * planned with no change; imported back from the server (`exportResources`
9
+ * for the schema, `SHOW CREATE` through a `.sql` file for access control,
10
+ * which an import never reads on its own), built again, and planned with no
11
+ * change. A change to each takes the statement its rule says: `ALTER QUOTA`
12
+ * and `ALTER SETTINGS PROFILE`, never `OR REPLACE`, so a profile that
13
+ * inherits a changed one keeps it; `CREATE OR REPLACE TABLE` for the table
14
+ * over a table function.
15
+ *
16
+ * Needs Docker; skips cleanly without it. A throwaway server at the pin.
17
+ */
18
+
19
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
22
+ import { build } from "@intentius/chant/build";
23
+ import type { SerializerResult } from "@intentius/chant/serializer";
24
+ import { dockerAvailable, startScratchServer, type ScratchServer } from "../container";
25
+ import { clickhouseQuery, type ClickHouseEndpoint } from "../http";
26
+ import { clickhouseImage } from "../../spec/pin";
27
+ import { exportResources } from "../import/live-export";
28
+ import { ClickHouseGenerator } from "../import/generator";
29
+ import { ClickHouseSqlParser } from "../import/parser";
30
+ import { readLiveAccess } from "./catalog";
31
+ import { planAgainstServer } from "../plan/commands";
32
+ import { observeResourcesDeep, sqlDeepNormalizationHooks } from "../plan/deep";
33
+ import { clickhouseApply } from "../../op/activities/clickhouse-apply";
34
+ import { sqlPlugin } from "../../plugin";
35
+ import { sqlSerializer } from "../../serializer";
36
+
37
+ const DB = "chant_e2e_3911";
38
+ const enabled = await dockerAvailable();
39
+ let server: ScratchServer | undefined;
40
+ let endpoint: ClickHouseEndpoint;
41
+ const root = join(import.meta.dirname, "..", "..", "..", `.kinds-tmp-${process.pid}`);
42
+ const q = <T = Record<string, unknown>>(sql: string) => clickhouseQuery<T>(endpoint, sql);
43
+
44
+ beforeAll(async () => {
45
+ if (!enabled) return;
46
+ server = await startScratchServer(clickhouseImage(), { namePrefix: "chant-sql-kinds" });
47
+ endpoint = server.endpoint;
48
+ }, 600_000);
49
+
50
+ afterAll(async () => {
51
+ await server?.stop();
52
+ rmSync(root, { recursive: true, force: true });
53
+ });
54
+
55
+ const config = () =>
56
+ ({ ownership: { stack: "e2e3911", env: "test" }, sql: { profiles: { test: { url: endpoint.url, access: true } } } }) as never;
57
+
58
+ /** A project of these files, built: the build document's path and the entities. */
59
+ async function buildProject(name: string, files: Record<string, string>) {
60
+ const dir = join(root, name);
61
+ mkdirSync(join(dir, "src"), { recursive: true });
62
+ writeFileSync(join(dir, "chant.config.ts"), 'export default { lexicons: ["sql"] };\n');
63
+ for (const [file, content] of Object.entries(files)) writeFileSync(join(dir, "src", file), content);
64
+ const result = await build(join(dir, "src"), [sqlSerializer], undefined, { fold: true, intrinsics: sqlPlugin.intrinsics!(), lexicons: ["sql"] });
65
+ expect(result.errors).toEqual([]);
66
+ const buildPath = join(dir, "build.json");
67
+ writeFileSync(buildPath, (result.outputs.get("sql") as SerializerResult).primary);
68
+ const entities = new Map([...result.entities].map(([k, v]) => [k, { entityType: v.entityType, props: (v as unknown as { props: Record<string, unknown> }).props }]));
69
+ return { buildPath, entities };
70
+ }
71
+
72
+ async function apply(buildPath: string) {
73
+ const sent: string[] = [];
74
+ const outcome = await clickhouseApply({ buildPath, environment: "test" }, undefined, { config: config(), env: {}, log: (l) => void (/^[A-Z]+ /.test(l) && sent.push(l)) });
75
+ expect(outcome.failed).toEqual([]);
76
+ return { outcome, sent };
77
+ }
78
+
79
+ const plan = async (buildPath: string) => (await planAgainstServer("test", buildPath, { config: config(), env: {} })).changes.map((c) => [c.object, c.field, c.rule]);
80
+
81
+ const compared = (props: object): Record<string, unknown> =>
82
+ Object.fromEntries(Object.entries(JSON.parse(JSON.stringify(props)) as Record<string, unknown>).filter(([k]) => !sqlDeepNormalizationHooks.prune!({ pattern: k } as never)));
83
+
84
+ const source = (v: { limit: number; threads: number; nums: number }) => `import { database, table, view, role, quota, settingsProfile } from "@intentius/chant-lexicon-sql/clickhouse";
85
+
86
+ export const shop = database\`CREATE DATABASE ${DB}\`;
87
+
88
+ export const events = table\`
89
+ CREATE TABLE \${shop}.events (
90
+ id UInt64,
91
+ kind LowCardinality(String),
92
+ INDEX by_kind kind TYPE bloom_filter(0.01) GRANULARITY 4,
93
+ PROJECTION per_kind (SELECT kind, count() GROUP BY kind)
94
+ ) ENGINE = MergeTree ORDER BY id\`;
95
+
96
+ export const hourly = view\`
97
+ CREATE MATERIALIZED VIEW \${shop}.hourly REFRESH EVERY 1 HOUR
98
+ ENGINE = MergeTree ORDER BY kind
99
+ AS SELECT kind, count() AS n FROM \${events} GROUP BY kind\`;
100
+
101
+ export const nums = table\`CREATE TABLE \${shop}.nums AS numbers(${v.nums})\`;
102
+ export const typedNums = table\`CREATE TABLE \${shop}.typed_nums (number UInt64) AS numbers(10) COMMENT 'counted'\`;
103
+
104
+ export const reader = role\`CREATE ROLE ${DB}_reader\`;
105
+
106
+ export const base = settingsProfile\`CREATE SETTINGS PROFILE ${DB}_base SETTINGS max_threads = ${v.threads}\`;
107
+
108
+ export const analysts = settingsProfile\`
109
+ CREATE PROFILE ${DB}_analysts
110
+ SETTINGS PROFILE \${base}, max_memory_usage = 10000000000 MIN 1000000 MAX 20000000000, readonly = 1 READONLY
111
+ TO \${reader}\`;
112
+
113
+ export const perUser = quota\`
114
+ CREATE QUOTA ${DB}_per_user KEYED BY 'user_name'
115
+ FOR INTERVAL 60 MINUTE MAX QUERIES ${v.limit}, errors = 50,
116
+ FOR RANDOMIZED INTERVAL 1 DAY MAX read_bytes = 1e12
117
+ TO \${reader}\`;
118
+ `;
119
+
120
+ describe.skipIf(!enabled)("the kinds #3911 adds, against the pinned server", () => {
121
+ test("declared, built, applied, planned clean; imported back and planned clean; changed with the statement each rule says", async () => {
122
+ const v1 = await buildProject("v1", { "schema.ts": source({ limit: 1000, threads: 4, nums: 10 }) });
123
+ const first = await apply(v1.buildPath);
124
+ expect(first.outcome.applied.filter((a) => a.action === "created").map((a) => a.name)).toEqual(
125
+ expect.arrayContaining([`${DB}.events`, `${DB}.hourly`, `${DB}.nums`, `${DB}.typed_nums`, `quota ${DB}_per_user`, `settings profile ${DB}_base`, `settings profile ${DB}_analysts`]),
126
+ );
127
+ expect(await plan(v1.buildPath)).toEqual([]);
128
+
129
+ // The deep read: every declared object read back as declared.
130
+ const deep = await observeResourcesDeep({ environment: "test", entityNames: [...v1.entities.keys()], entities: v1.entities, config: config(), env: {} });
131
+ expect(deep.unobserved ?? {}).toEqual({});
132
+ for (const k of ["perUser", "base", "analysts", "nums", "typedNums", "hourly", "events"]) {
133
+ expect(compared(deep.resources[k]!.properties), k).toEqual(compared(v1.entities.get(k)!.props));
134
+ }
135
+
136
+ // Imported back: the schema from the catalog, access control from SHOW CREATE in a .sql file.
137
+ const exported = await exportResources({ environment: "test", config: config(), env: {} });
138
+ const [schemaFile] = new ClickHouseGenerator().generate(exported);
139
+ const access = await readLiveAccess({ endpoint, source: "test", defaultDatabase: "default" }, [
140
+ { kind: "role", name: `${DB}_reader` },
141
+ { kind: "settingsProfile", name: `${DB}_base` },
142
+ { kind: "settingsProfile", name: `${DB}_analysts` },
143
+ { kind: "quota", name: `${DB}_per_user` },
144
+ ]);
145
+ expect(access.map((a) => a.statement)).toEqual([
146
+ `CREATE ROLE ${DB}_reader`,
147
+ `CREATE QUOTA ${DB}_per_user KEYED BY user_name FOR INTERVAL 1 hour MAX queries = 1000, errors = 50, FOR RANDOMIZED INTERVAL 1 day MAX read_bytes = 1000000000000 TO ${DB}_reader`,
148
+ `CREATE SETTINGS PROFILE \`${DB}_base\` SETTINGS max_threads = 4`,
149
+ `CREATE SETTINGS PROFILE \`${DB}_analysts\` SETTINGS INHERIT \`${DB}_base\`, max_memory_usage = 10000000000 MIN 1000000 MAX 20000000000, readonly = 1 CONST TO ${DB}_reader`,
150
+ ]);
151
+ const [accessFile] = new ClickHouseGenerator().generate(new ClickHouseSqlParser().parse(access.map((a) => `${a.statement};`).join("\n")));
152
+ expect(schemaFile!.content).toMatch(/CREATE TABLE \$\{\w+\}\.nums\s*\(\s*number UInt64\s*\) AS numbers\(10\)/);
153
+ expect(schemaFile!.content).toContain("REFRESH EVERY 1 HOUR");
154
+ const imported = await buildProject("imported", { "schema.ts": schemaFile!.content, "access.ts": accessFile!.content });
155
+ expect(await plan(imported.buildPath)).toEqual([]);
156
+ expect((await apply(imported.buildPath)).sent).toEqual([]);
157
+
158
+ // Changed: a quota's limit, the inherited profile's settings, the table function's call.
159
+ const v2 = await buildProject("v2", { "schema.ts": source({ limit: 2000, threads: 2, nums: 20 }) });
160
+ expect(await plan(v2.buildPath)).toEqual([
161
+ [`base (settings profile ${DB}_base)`, "settings", "SQLCH276"],
162
+ [`nums (${DB}.nums)`, "asFunction", "SQLCH219"],
163
+ [`perUser (quota ${DB}_per_user)`, "interval.3600", "SQLCH275"],
164
+ ]);
165
+ const second = await apply(v2.buildPath);
166
+ expect(second.sent).toEqual(
167
+ expect.arrayContaining([
168
+ expect.stringMatching(new RegExp(`^CREATE OR REPLACE TABLE ${DB}\\.nums AS numbers\\(20\\) COMMENT '`)),
169
+ `ALTER QUOTA \`${DB}_per_user\` FOR INTERVAL 3600 second MAX errors = 50, queries = 2000`,
170
+ `ALTER SETTINGS PROFILE \`${DB}_base\` SETTINGS max_threads = 2`,
171
+ ]),
172
+ );
173
+ expect(second.sent).toHaveLength(3);
174
+ expect(await plan(v2.buildPath)).toEqual([]);
175
+ // ALTER, not OR REPLACE: the profile that inherits the changed one still does.
176
+ expect(Object.values((await q<Record<string, string>>(`SHOW CREATE SETTINGS PROFILE ${DB}_analysts`))[0]!)[0]).toContain(`INHERIT \`${DB}_base\``);
177
+ expect(Number((await q<{ n: string }>(`SELECT count() AS n FROM ${DB}.nums`))[0]!.n)).toBe(20);
178
+ }, 600_000);
179
+ });
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Import against a real ClickHouse server never leaves an object out without
3
- * a word (#3911): a server holding a quota, a settings profile, a named
4
- * collection and a workload made with SQL, beside a table, fails the import,
5
- * which names all of them; with `skipUndeclarable` the table is imported and
3
+ * a word (#3911): a server holding a named collection and a workload made
4
+ * with SQL, beside a table, fails the import, which names both; a quota and a
5
+ * settings profile are declared kinds, which an import reads only when a
6
+ * build declares them, so they are not refused; with `skipUndeclarable` the table is imported and
6
7
  * they are warnings. What `users.xml` defines (the `default` quota and
7
8
  * profile) is the server's, and is not refused.
8
9
  *
@@ -42,7 +43,7 @@ afterAll(async () => {
42
43
 
43
44
  const run = (extra: Record<string, unknown> = {}) => exportResources({ environment: "x", config: {}, env: { CLICKHOUSE_URL: server!.endpoint.url }, ...extra });
44
45
 
45
- const NAMED = ["named collection nc_s3", "quota q_reports", "settings profile p_reports", "workload all"];
46
+ const NAMED = ["named collection nc_s3", "workload all"];
46
47
 
47
48
  describe.skipIf(!enabled)("importing a server with objects chant cannot declare", () => {
48
49
  test("fails, naming every one of them", async () => {
@@ -84,8 +84,42 @@ describe("parsing CREATE TABLE", () => {
84
84
  expect(t.engine!.name).toBe("MergeTree");
85
85
  });
86
86
 
87
- test("CREATE TABLE ... AS is refused, since it declares no columns", () => {
88
- expect(() => parse("CREATE TABLE t AS other")).toThrow(/column list/);
87
+ test("CREATE TABLE ... AS a table, AS SELECT, CLONE AS and a table with no columns are refused, each saying why (#3911)", () => {
88
+ expect(() => parse("CREATE TABLE t AS other")).toThrow(/AS <table> is not declared: the server copies the other table's columns/);
89
+ expect(() => parse("CREATE TABLE t AS db.other ENGINE = Log")).toThrow(/AS <table> is not declared/);
90
+ expect(() => parse("CREATE TABLE t ENGINE = Log AS SELECT 1 AS a")).toThrow(/expected the column list \(a table declared without one/);
91
+ expect(() => parse("CREATE TABLE t AS SELECT 1 AS a")).toThrow(/AS SELECT is not declared/);
92
+ expect(() => parse("CREATE TABLE t (a UInt8) ENGINE = Memory AS SELECT 1 AS a")).toThrow(/AS SELECT is not declared/);
93
+ expect(() => parse("CREATE TABLE t CLONE AS other")).toThrow(/CLONE AS is not declared/);
94
+ expect(() => parse("CREATE TABLE t EMPTY AS SELECT 1")).toThrow(/EMPTY AS SELECT is not declared/);
95
+ });
96
+
97
+ test("a table over a table function, with or without its columns (#3911)", () => {
98
+ const bareSql = "CREATE TABLE t AS numbers(10)";
99
+ const bare = parse(bareSql) as TableNode;
100
+ expect(bare.columns).toEqual([]);
101
+ expect(bare.engine).toBeUndefined();
102
+ expect(text(bareSql, bare.asFunction)).toBe("numbers(10)");
103
+ // As SHOW CREATE prints it: the inferred columns, then the call, then the comment.
104
+ const shownSql = "CREATE TABLE default.tf\n(\n `a` UInt64,\n `b` String\n) AS remote('localhost', 'default.t')\nCOMMENT 'x'";
105
+ const shown = parse(shownSql) as TableNode;
106
+ expect(shown.columns.map((c) => c.name)).toEqual(["a", "b"]);
107
+ expect(text(shownSql, shown.asFunction)).toBe("remote('localhost', 'default.t')");
108
+ expect(shown.comment).toBeDefined();
109
+ expect(() => parse("CREATE TABLE t AS numbers(10) ENGINE = Log")).toThrow(/no engine or storage clauses/);
110
+ });
111
+
112
+ test("the CREATE kinds chant does not declare are refused by name, with why (#3911)", () => {
113
+ expect(() => parse("CREATE LIVE VIEW lv AS SELECT 1")).toThrow(/CREATE LIVE VIEW is not declared: the pinned server no longer has live views/);
114
+ expect(() => parse("CREATE WINDOW VIEW wv AS SELECT 1")).toThrow(/CREATE WINDOW VIEW is not declared: a window view is experimental/);
115
+ expect(() => parse("CREATE NAMED COLLECTION c AS user = 'u', password = 'p'")).toThrow(/CREATE NAMED COLLECTION is not declared: a named collection holds credentials/);
116
+ expect(() => parse("CREATE MASKING POLICY m ON t UPDATE a = 'x' TO r")).toThrow(/only in ClickHouse Cloud/);
117
+ expect(() => parse("CREATE WORKLOAD all")).toThrow(/CREATE WORKLOAD is not declared: a workload is server-level/);
118
+ expect(() => parse("CREATE RESOURCE r (WRITE DISK d)")).toThrow(/CREATE RESOURCE is not declared/);
119
+ expect(() => parse("CREATE TEMPORARY TABLE t (a UInt8)")).toThrow(/CREATE TEMPORARY TABLE is not declared/);
120
+ expect(() => parse("CREATE INDEX ix ON t (b) TYPE minmax")).toThrow(/CREATE INDEX is not declared: a skip index belongs to its table/);
121
+ expect(() => parse("CREATE QUOTA q")).toThrow(/CREATE QUOTA is access control: declare it with the quota tag/);
122
+ expect(() => parse("CREATE SETTINGS PROFILE p")).toThrow(/settingsProfile tag/);
89
123
  });
90
124
  });
91
125
 
@@ -103,6 +137,15 @@ describe("parsing views and databases", () => {
103
137
  expect(text(sql, v.select)).toBe("SELECT a, b FROM src");
104
138
  });
105
139
 
140
+ test("a refreshable materialized view as SHOW CREATE prints it: the column list right after the schedule (#3911)", () => {
141
+ const sql =
142
+ "CREATE MATERIALIZED VIEW default.rmv\nREFRESH EVERY 1 HOUR OFFSET 5 MINUTES\n(\n `a` UInt64\n)\nENGINE = MergeTree\nORDER BY a\nSETTINGS index_granularity = 8192\nDEFINER = default SQL SECURITY DEFINER\nAS SELECT a\nFROM default.t";
143
+ const v = parse(sql) as ViewNode;
144
+ expect(text(sql, v.refresh)).toBe("EVERY 1 HOUR OFFSET 5 MINUTES");
145
+ expect(v.columns.map((c) => c.name)).toEqual(["a"]);
146
+ expect(text(sql, v.orderBy)).toBe("a");
147
+ });
148
+
106
149
  test("a view the server printed back, with its UUID", () => {
107
150
  const v = parse("CREATE VIEW default.v UUID '6a1b' (`x` UInt8) AS SELECT 1 AS x") as ViewNode;
108
151
  expect(v.columns[0]!.name).toBe("x");
@@ -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("(")) {