@intentius/chant-lexicon-sql 0.124.19 → 0.124.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/clickhouse/access.d.ts +36 -7
- package/dist/clickhouse/access.d.ts.map +1 -1
- package/dist/clickhouse/apply/statements.d.ts.map +1 -1
- package/dist/clickhouse/entities.d.ts +60 -0
- package/dist/clickhouse/entities.d.ts.map +1 -1
- package/dist/clickhouse/import/generator.d.ts +1 -7
- package/dist/clickhouse/import/generator.d.ts.map +1 -1
- package/dist/clickhouse/live/catalog.d.ts +6 -5
- package/dist/clickhouse/live/catalog.d.ts.map +1 -1
- package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
- package/dist/clickhouse/parser.d.ts +16 -0
- package/dist/clickhouse/parser.d.ts.map +1 -1
- package/dist/clickhouse/plan/deep.d.ts.map +1 -1
- package/dist/clickhouse/plan/diff.d.ts.map +1 -1
- package/dist/clickhouse/plan/normalize.d.ts +6 -3
- package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
- package/dist/clickhouse/plan/rules.d.ts +3 -0
- package/dist/clickhouse/plan/rules.d.ts.map +1 -1
- package/dist/clickhouse/topology.d.ts.map +1 -1
- package/dist/clickhouse.d.ts +1 -1
- package/dist/clickhouse.d.ts.map +1 -1
- package/dist/codegen/registry.d.ts.map +1 -1
- package/dist/coverage/clickhouse.json +8 -8
- package/dist/coverage/postgres.json +6 -6
- package/dist/files/clickhouse.d.ts +2 -1
- package/dist/files/clickhouse.d.ts.map +1 -1
- package/dist/files/postgres.d.ts +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +10 -10
- package/dist/lint/post-synth/postgres-names.d.ts.map +1 -1
- package/dist/lint/post-synth/sql101.d.ts.map +1 -1
- package/dist/lint/rules/sqlch001.d.ts +1 -1
- package/dist/lint/rules/sqlch001.d.ts.map +1 -1
- package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
- package/dist/lint/rules/templates.d.ts +1 -1
- package/dist/lint/rules/templates.d.ts.map +1 -1
- package/dist/lsp/postgres-template.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/meta.json +40 -0
- package/dist/okf/index.md +9 -1
- package/dist/okf/rules/SQL101.md +1 -0
- package/dist/okf/rules/SQLCH001.md +2 -2
- package/dist/okf/rules/SQLPG113.md +2 -0
- package/dist/okf/types/PostgresAggregate.md +13 -0
- package/dist/okf/types/PostgresCast.md +9 -0
- package/dist/okf/types/PostgresCollation.md +9 -0
- package/dist/okf/types/PostgresCompositeType.md +13 -0
- package/dist/okf/types/PostgresEventTrigger.md +9 -0
- package/dist/okf/types/PostgresRangeType.md +13 -0
- package/dist/okf/types/Quota.md +9 -0
- package/dist/okf/types/SettingsProfile.md +9 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/postgres/apply/apply.d.ts.map +1 -1
- package/dist/postgres/apply/statements.d.ts +4 -3
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/dist/postgres/entities.d.ts +129 -6
- package/dist/postgres/entities.d.ts.map +1 -1
- package/dist/postgres/entity-types.d.ts +6 -0
- package/dist/postgres/entity-types.d.ts.map +1 -1
- package/dist/postgres/import/generator.d.ts +3 -2
- package/dist/postgres/import/generator.d.ts.map +1 -1
- package/dist/postgres/live/catalog-kinds.d.ts +28 -0
- package/dist/postgres/live/catalog-kinds.d.ts.map +1 -0
- package/dist/postgres/live/catalog.d.ts +15 -11
- package/dist/postgres/live/catalog.d.ts.map +1 -1
- package/dist/postgres/live/describe-resources.d.ts.map +1 -1
- package/dist/postgres/parser.d.ts +88 -5
- package/dist/postgres/parser.d.ts.map +1 -1
- package/dist/postgres/plan/deep.d.ts.map +1 -1
- package/dist/postgres/plan/diff.d.ts.map +1 -1
- package/dist/postgres/plan/normalize.d.ts +17 -2
- package/dist/postgres/plan/normalize.d.ts.map +1 -1
- package/dist/postgres/plan/rules.d.ts +3 -0
- package/dist/postgres/plan/rules.d.ts.map +1 -1
- package/dist/postgres/plan/schema.d.ts +3 -2
- package/dist/postgres/plan/schema.d.ts.map +1 -1
- package/dist/postgres/testing/roundtrip-schema.d.ts +4 -2
- package/dist/postgres/testing/roundtrip-schema.d.ts.map +1 -1
- package/dist/postgres.d.ts +1 -1
- package/dist/postgres.d.ts.map +1 -1
- package/dist/rules/postgres-names.ts +15 -2
- package/dist/rules/postgres-templates.ts +2 -2
- package/dist/rules/sql101.ts +1 -0
- package/dist/rules/sqlch001.ts +10 -3
- package/dist/rules/sqlpg001.ts +6 -21
- package/dist/rules/sqlpg113.ts +1 -1
- package/dist/rules/templates.ts +2 -2
- package/dist/validate.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/access.ts +197 -22
- package/src/clickhouse/apply/statements.ts +17 -5
- package/src/clickhouse/entities.ts +78 -2
- package/src/clickhouse/import/generator.ts +16 -3
- package/src/clickhouse/live/catalog.ts +57 -10
- package/src/clickhouse/live/describe-resources.ts +3 -1
- package/src/clickhouse/live/kinds.e2e.test.ts +179 -0
- package/src/clickhouse/live/undeclarable.e2e.test.ts +5 -4
- package/src/clickhouse/parser.test.ts +45 -2
- package/src/clickhouse/parser.ts +111 -3
- package/src/clickhouse/plan/deep.ts +25 -6
- package/src/clickhouse/plan/diff.ts +41 -1
- package/src/clickhouse/plan/normalize.ts +12 -4
- package/src/clickhouse/plan/rules.ts +22 -1
- package/src/clickhouse/quota-profile.test.ts +186 -0
- package/src/clickhouse/table-function.test.ts +54 -0
- package/src/clickhouse/testdata/server-ddl-baseline.json +17 -14
- package/src/clickhouse/topology.ts +4 -2
- package/src/clickhouse.ts +6 -0
- package/src/codegen/postgres-classifier-page.ts +1 -1
- package/src/codegen/registry.ts +8 -0
- package/src/core/undeclarable.test.ts +22 -19
- package/src/coverage/clickhouse.json +8 -8
- package/src/coverage/postgres.json +6 -6
- package/src/files/clickhouse.ts +14 -6
- package/src/files/postgres.ts +19 -5
- package/src/generated/lexicon-sql.json +40 -0
- package/src/index.ts +2 -2
- package/src/lint/post-synth/postgres-names.ts +15 -2
- package/src/lint/post-synth/sql101.ts +1 -0
- package/src/lint/post-synth/sqlpg113.ts +1 -1
- package/src/lint/rules/postgres-templates.ts +2 -2
- package/src/lint/rules/sqlch001.ts +10 -3
- package/src/lint/rules/sqlpg001.ts +6 -21
- package/src/lint/rules/templates.ts +2 -2
- package/src/lsp/postgres-template.ts +14 -0
- package/src/mcp/index.ts +5 -5
- package/src/mcp/postgres.ts +1 -1
- package/src/plugin.ts +7 -1
- package/src/postgres/apply/apply.ts +8 -2
- package/src/postgres/apply/statements.ts +29 -6
- package/src/postgres/entities.test.ts +2 -2
- package/src/postgres/entities.ts +316 -28
- package/src/postgres/entity-types.ts +6 -0
- package/src/postgres/import/generator.ts +31 -4
- package/src/postgres/kinds.test.ts +140 -0
- package/src/postgres/live/catalog-kinds.ts +312 -0
- package/src/postgres/live/catalog.ts +30 -12
- package/src/postgres/live/describe-resources.ts +14 -5
- package/src/postgres/live/kinds.e2e.test.ts +182 -0
- package/src/postgres/live/roundtrip.e2e.test.ts +9 -3
- package/src/postgres/live/undeclarable.e2e.test.ts +15 -13
- package/src/postgres/parser.test.ts +2 -1
- package/src/postgres/parser.ts +307 -17
- package/src/postgres/plan/deep.ts +33 -0
- package/src/postgres/plan/diff.ts +45 -0
- package/src/postgres/plan/normalize.ts +153 -2
- package/src/postgres/plan/rules.ts +21 -0
- package/src/postgres/plan/schema.ts +6 -4
- package/src/postgres/testing/roundtrip-schema.ts +19 -1
- package/src/postgres.ts +19 -0
- package/src/skill-defs.ts +1 -1
- package/src/skills/chant-sql-postgres.md +7 -3
- package/src/skills/chant-sql.md +3 -1
- package/src/unknown-clauses.test.ts +2 -1
- 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
|
-
|
|
73
|
-
|
|
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'
|
|
132
|
-
* as the server holds them (#3682):
|
|
133
|
-
* `SHOW CREATE
|
|
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([
|
|
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
|
|
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> = {
|
|
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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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", "
|
|
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
|
|
88
|
-
expect(() => parse("CREATE TABLE t AS other")).toThrow(/
|
|
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");
|
package/src/clickhouse/parser.ts
CHANGED
|
@@ -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 (
|
|
544
|
-
this.
|
|
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"))
|
|
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("(")) {
|