@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
|
@@ -37,7 +37,7 @@ import { ACCESS_ENTITY_TYPES, ACCESS_UNMANAGED_DETAIL, accessOf, readLiveAccess,
|
|
|
37
37
|
import { clickhouseQuery } from "../http";
|
|
38
38
|
import { sqlString } from "../apply/statements";
|
|
39
39
|
import { stripMarkerFromStatement } from "../ownership";
|
|
40
|
-
import { database, dictionary, func, policy, role, table, user, view, CLICKHOUSE_ENTITY_TYPES, type ColumnDef } from "../entities";
|
|
40
|
+
import { database, dictionary, func, policy, quota, role, settingsProfile, table, user, view, CLICKHOUSE_ENTITY_TYPES, type ColumnDef } from "../entities";
|
|
41
41
|
import { canonicalObject, grantsObject, objectKey, type CanonicalColumn, type CanonicalObject } from "./normalize";
|
|
42
42
|
import { grantStatement, grantsByGrantee, revokeStatement, type GrantAtom } from "../access";
|
|
43
43
|
import { renderFor } from "../topology";
|
|
@@ -67,7 +67,11 @@ function liveProps(o: LiveObject): Props {
|
|
|
67
67
|
? role
|
|
68
68
|
: o.type === CLICKHOUSE_ENTITY_TYPES.rowPolicy
|
|
69
69
|
? policy
|
|
70
|
-
:
|
|
70
|
+
: o.type === CLICKHOUSE_ENTITY_TYPES.quota
|
|
71
|
+
? quota
|
|
72
|
+
: o.type === CLICKHOUSE_ENTITY_TYPES.settingsProfile
|
|
73
|
+
? settingsProfile
|
|
74
|
+
: view;
|
|
71
75
|
const strings = Object.assign([o.statement], { raw: [o.statement] }) as unknown as TemplateStringsArray;
|
|
72
76
|
return { ...(tag(strings).props as unknown as Props) };
|
|
73
77
|
}
|
|
@@ -89,8 +93,10 @@ export function inDeclaredVocabulary(declared: Props, live: Props, d: CanonicalO
|
|
|
89
93
|
adopt("name", d.name === l.name);
|
|
90
94
|
adopt("database", d.database === l.database);
|
|
91
95
|
adopt("engine", same("engine"));
|
|
92
|
-
for (const k of ["orderBy", "primaryKey", "partitionBy", "sampleBy", "ttl", "comment", "select", "refresh", "dataSource", "layout", "lifetime", "range"] as const) adopt(k, same(k));
|
|
96
|
+
for (const k of ["orderBy", "primaryKey", "partitionBy", "sampleBy", "ttl", "comment", "select", "refresh", "dataSource", "layout", "lifetime", "range", "asFunction"] as const) adopt(k, same(k));
|
|
93
97
|
adopt("settings", same("settings"));
|
|
98
|
+
// A view's DEFINER and SQL SECURITY are not compared by a plan; the server adds its own to a refreshable materialized view (#3911).
|
|
99
|
+
adopt("security", d.kind === "view" || d.kind === "materializedView");
|
|
94
100
|
// A user's, role's or row policy's fields, compared as `../access.ts` reads them.
|
|
95
101
|
if (d.kind === "user" || d.kind === "role" || d.kind === "rowPolicy") {
|
|
96
102
|
for (const k of ["identified", "host", "validUntil", "defaultRole", "defaultDatabase", "grantees", "settings", "as", "for", "using", "to"]) {
|
|
@@ -100,6 +106,12 @@ export function inDeclaredVocabulary(declared: Props, live: Props, d: CanonicalO
|
|
|
100
106
|
// A user's authentication is the environment's unless the declaration says it.
|
|
101
107
|
if (d.kind === "user" && declared.identified === undefined) delete out.identified;
|
|
102
108
|
}
|
|
109
|
+
// A quota's or settings profile's fields; a quota's intervals are one prop, the same when every interval is.
|
|
110
|
+
if (d.kind === "quota" || d.kind === "settingsProfile") {
|
|
111
|
+
const fields = (o: CanonicalObject, pick: (k: string) => boolean) => JSON.stringify(Object.entries(o.access ?? {}).filter(([k]) => pick(k)));
|
|
112
|
+
for (const k of ["keyed", "settings", "to"]) adopt(k, d.access?.[k] === l.access?.[k]);
|
|
113
|
+
adopt("intervals", fields(d, (k) => k.startsWith("interval.")) === fields(l, (k) => k.startsWith("interval.")));
|
|
114
|
+
}
|
|
103
115
|
// A function's parameters and expression are one lambda: the same lambda is the declaration's words.
|
|
104
116
|
adopt("params", d.kind === "function" && same("lambda"));
|
|
105
117
|
adopt("body", d.kind === "function" && same("lambda"));
|
|
@@ -108,8 +120,8 @@ export function inDeclaredVocabulary(declared: Props, live: Props, d: CanonicalO
|
|
|
108
120
|
adopt("constraints", same("constraints"));
|
|
109
121
|
|
|
110
122
|
const declaredColumns = (declared.columns as ColumnDef[] | undefined) ?? [];
|
|
111
|
-
if (d.kind === "view" || d.kind === "materializedView") {
|
|
112
|
-
// The server infers a view's columns; a declaration that lists none is not drift.
|
|
123
|
+
if (d.kind === "view" || d.kind === "materializedView" || (d.kind === "table" && d.asFunction !== undefined && declaredColumns.length === 0)) {
|
|
124
|
+
// The server infers a view's columns, and a table function's (#3911); a declaration that lists none is not drift.
|
|
113
125
|
if (declaredColumns.length === 0) out.columns = [];
|
|
114
126
|
} else if (d.kind === "table" || d.kind === "dictionary") {
|
|
115
127
|
const liveColumns = (live.columns as ColumnDef[] | undefined) ?? [];
|
|
@@ -124,7 +136,14 @@ export function inDeclaredVocabulary(declared: Props, live: Props, d: CanonicalO
|
|
|
124
136
|
}
|
|
125
137
|
|
|
126
138
|
/** The entity types that belong to no database. */
|
|
127
|
-
const GLOBAL_TYPES: ReadonlySet<string> = new Set([
|
|
139
|
+
const GLOBAL_TYPES: ReadonlySet<string> = new Set([
|
|
140
|
+
CLICKHOUSE_ENTITY_TYPES.database,
|
|
141
|
+
CLICKHOUSE_ENTITY_TYPES.function,
|
|
142
|
+
CLICKHOUSE_ENTITY_TYPES.user,
|
|
143
|
+
CLICKHOUSE_ENTITY_TYPES.role,
|
|
144
|
+
CLICKHOUSE_ENTITY_TYPES.quota,
|
|
145
|
+
CLICKHOUSE_ENTITY_TYPES.settingsProfile,
|
|
146
|
+
]);
|
|
128
147
|
|
|
129
148
|
/** Whether the server's formatter prints two expressions the same. A formatter that cannot parse them says no. */
|
|
130
149
|
async function sameExpression(target: ClickHouseTarget, a: string, b: string): Promise<boolean> {
|
|
@@ -179,6 +179,17 @@ function diffObject(key: string, before: CanonicalObject, after: CanonicalObject
|
|
|
179
179
|
return;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
if (before.kind === "quota" || before.kind === "settingsProfile") {
|
|
183
|
+
const rule = before.kind === "quota" ? "SQLCH275" : "SQLCH276";
|
|
184
|
+
const b = before.access ?? {};
|
|
185
|
+
const a = after.access ?? {};
|
|
186
|
+
// In the order the server prints them: the key, each interval by length, the settings, whom it applies to.
|
|
187
|
+
const rank = (f: string) => (f === "keyed" ? 0 : f.startsWith("interval.") ? 1 + Number(f.slice("interval.".length)) / 1e9 : f === "settings" ? 2 : 3);
|
|
188
|
+
const fields = [...new Set([...Object.keys(b), ...Object.keys(a)])].sort((x, y) => rank(x) - rank(y));
|
|
189
|
+
for (const field of fields) if (b[field] !== a[field]) out.push(change(key, field, rule, b[field], a[field]));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
182
193
|
if (before.kind === "grants") {
|
|
183
194
|
const b = before.access ?? {};
|
|
184
195
|
const a = after.access ?? {};
|
|
@@ -212,6 +223,11 @@ function diffObject(key: string, before: CanonicalObject, after: CanonicalObject
|
|
|
212
223
|
return;
|
|
213
224
|
}
|
|
214
225
|
|
|
226
|
+
if (before.asFunction !== undefined || after.asFunction !== undefined) {
|
|
227
|
+
diffTableFunction(key, before, after, out);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
215
231
|
// A table.
|
|
216
232
|
if (before.engine !== after.engine) out.push(change(key, "engine", "SQLCH223", before.engine, after.engine));
|
|
217
233
|
const added = diffColumns(key, before, after, out, hints);
|
|
@@ -244,6 +260,30 @@ function diffObject(key: string, before: CanonicalObject, after: CanonicalObject
|
|
|
244
260
|
diffMap(key, "constraints", before.constraints, after.constraints, "SQLCH215", out);
|
|
245
261
|
}
|
|
246
262
|
|
|
263
|
+
/**
|
|
264
|
+
* A table over a table function (#3911), on either side. It stores nothing,
|
|
265
|
+
* so every change but its comment replaces it (SQLCH219); a table with data
|
|
266
|
+
* of its own becoming one would lose that data, which is a rebuild
|
|
267
|
+
* (SQLCH223). A declaration with no column list takes the columns the server
|
|
268
|
+
* infers, so they are compared only when it lists them.
|
|
269
|
+
*/
|
|
270
|
+
function diffTableFunction(key: string, before: CanonicalObject, after: CanonicalObject, out: Change[]): void {
|
|
271
|
+
if (before.asFunction === undefined) {
|
|
272
|
+
out.push(change(key, "asFunction", "SQLCH223", before.engine, `AS ${after.asFunction}`));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (before.asFunction !== after.asFunction) out.push(change(key, "asFunction", "SQLCH219", before.asFunction, after.asFunction));
|
|
276
|
+
if (after.asFunction !== undefined && after.columns.length === 0) return;
|
|
277
|
+
const column = (c: CanonicalColumn) => [c.type, c.nullable ? "NULL" : "", c.defaultKind ?? "", c.defaultExpr ?? "", c.codec ? `CODEC(${c.codec})` : "", c.comment ? `COMMENT ${c.comment}` : ""].filter(Boolean).join(" ");
|
|
278
|
+
const b = new Map(before.columns.map((c) => [c.name, column(c)]));
|
|
279
|
+
const a = new Map(after.columns.map((c) => [c.name, column(c)]));
|
|
280
|
+
for (const name of new Set([...b.keys(), ...a.keys()])) {
|
|
281
|
+
if (b.get(name) !== a.get(name)) out.push(change(key, `columns.${name}`, "SQLCH219", b.get(name), a.get(name)));
|
|
282
|
+
}
|
|
283
|
+
const order = (o: CanonicalObject) => o.columns.map((c) => c.name).join(", ");
|
|
284
|
+
if (order(before) !== order(after) && a.size === b.size && [...a.keys()].every((n) => b.has(n))) out.push(change(key, "columns.order", "SQLCH219", order(before), order(after)));
|
|
285
|
+
}
|
|
286
|
+
|
|
247
287
|
/** A dictionary: every change but its comment replaces it (SQLCH245). */
|
|
248
288
|
function diffDictionary(key: string, before: CanonicalObject, after: CanonicalObject, out: Change[]): void {
|
|
249
289
|
const attribute = (c: CanonicalColumn) =>
|
|
@@ -272,7 +312,7 @@ export function diffSchemas(before: readonly SchemaObject[], after: readonly Sch
|
|
|
272
312
|
const matches = matchByIdentity(before, after, {
|
|
273
313
|
qualified,
|
|
274
314
|
// A function, a user, a role, a row policy and a grantee's grants are not renamed: one under a new name is a create.
|
|
275
|
-
previously: (o) => (o.kind === "function" || o.kind === "user" || o.kind === "role" || o.kind === "rowPolicy" || o.kind === "grants" ? undefined : o.previously),
|
|
315
|
+
previously: (o) => (o.kind === "function" || o.kind === "user" || o.kind === "role" || o.kind === "rowPolicy" || o.kind === "quota" || o.kind === "settingsProfile" || o.kind === "grants" ? undefined : o.previously),
|
|
276
316
|
previousNames: (o, prev) => [prev, `${o.database}.${prev}`],
|
|
277
317
|
});
|
|
278
318
|
for (const m of matches) {
|
|
@@ -41,7 +41,7 @@ import { accessCanonical, grantsCanonical, isAccessStatement } from "../access";
|
|
|
41
41
|
|
|
42
42
|
export { previouslyIn } from "../../core/normalize";
|
|
43
43
|
|
|
44
|
-
export type ObjectKind = "database" | "table" | "view" | "materializedView" | "dictionary" | "function" | "user" | "role" | "rowPolicy" | "grants";
|
|
44
|
+
export type ObjectKind = "database" | "table" | "view" | "materializedView" | "dictionary" | "function" | "user" | "role" | "rowPolicy" | "quota" | "settingsProfile" | "grants";
|
|
45
45
|
|
|
46
46
|
export interface CanonicalColumn {
|
|
47
47
|
name: string;
|
|
@@ -84,6 +84,8 @@ export interface CanonicalObject {
|
|
|
84
84
|
select?: string;
|
|
85
85
|
to?: string;
|
|
86
86
|
refresh?: string;
|
|
87
|
+
/** A table over a table function (#3911): the call after `AS`. */
|
|
88
|
+
asFunction?: string;
|
|
87
89
|
/** A dictionary's `SOURCE`, `LAYOUT`, `LIFETIME` and `RANGE`, each what is inside the parentheses. */
|
|
88
90
|
dataSource?: string;
|
|
89
91
|
layout?: string;
|
|
@@ -94,8 +96,9 @@ export interface CanonicalObject {
|
|
|
94
96
|
/** A row policy's table. */
|
|
95
97
|
table?: string;
|
|
96
98
|
/**
|
|
97
|
-
* A user's, role's
|
|
98
|
-
* the server's default; a grantee's
|
|
99
|
+
* A user's, role's, row policy's, quota's or settings profile's fields
|
|
100
|
+
* (`../access.ts`), each absent at the server's default; a grantee's
|
|
101
|
+
* grants, by atom.
|
|
99
102
|
*/
|
|
100
103
|
access?: Record<string, string>;
|
|
101
104
|
/** `-- previously: <name>` before the statement: the object's previous name. */
|
|
@@ -374,6 +377,7 @@ export function canonicalObject(ddl: string, defaultDatabase = "default"): Canon
|
|
|
374
377
|
}
|
|
375
378
|
|
|
376
379
|
if (node.statement === "table") {
|
|
380
|
+
if (node.asFunction) obj.asFunction = canonicalExpression(spanText(tokens, node.asFunction) ?? "", database);
|
|
377
381
|
node.columns.forEach((c, i) => obj.columns.push(column(tokens, c, i, database)));
|
|
378
382
|
for (const ix of node.indexes) {
|
|
379
383
|
obj.indexes[ix.name] = [expr(ix.expr), "TYPE", expr(ix.type), ix.granularity ? `GRANULARITY ${expr(ix.granularity)}` : ""]
|
|
@@ -556,6 +560,8 @@ export function objectKey(o: { kind?: ObjectKind; type?: string; database?: stri
|
|
|
556
560
|
if (kind === "function") return `function ${o.name}`;
|
|
557
561
|
if (kind === "user") return `user ${o.name}`;
|
|
558
562
|
if (kind === "role") return `role ${o.name}`;
|
|
563
|
+
if (kind === "quota") return `quota ${o.name}`;
|
|
564
|
+
if (kind === "settingsProfile") return `settings profile ${o.name}`;
|
|
559
565
|
if (kind === "grants") return `grants ${o.name}`;
|
|
560
566
|
if (kind === "rowPolicy") return `row policy ${o.name} ON ${o.database}.${o.table}`;
|
|
561
567
|
return o.database !== undefined ? `${o.database}.${o.name}` : o.name;
|
|
@@ -566,6 +572,8 @@ const KIND_OF_TYPE: Record<string, ObjectKind> = {
|
|
|
566
572
|
"ClickHouse::User": "user",
|
|
567
573
|
"ClickHouse::Role": "role",
|
|
568
574
|
"ClickHouse::RowPolicy": "rowPolicy",
|
|
575
|
+
"ClickHouse::Quota": "quota",
|
|
576
|
+
"ClickHouse::SettingsProfile": "settingsProfile",
|
|
569
577
|
"ClickHouse::Grant": "grants",
|
|
570
578
|
};
|
|
571
579
|
|
|
@@ -582,7 +590,7 @@ export function grantsObject(ddl: string, grantee: string, defaultDatabase = "de
|
|
|
582
590
|
*/
|
|
583
591
|
export function scopeOf(o: { kind?: ObjectKind; type?: string; database?: string; name: string; table?: string }): string {
|
|
584
592
|
const kind = o.kind ?? KIND_OF_TYPE[o.type ?? ""];
|
|
585
|
-
if (kind === "function" || kind === "user" || kind === "role" || kind === "grants") return objectKey(o);
|
|
593
|
+
if (kind === "function" || kind === "user" || kind === "role" || kind === "quota" || kind === "settingsProfile" || kind === "grants") return objectKey(o);
|
|
586
594
|
return o.database ?? o.name;
|
|
587
595
|
}
|
|
588
596
|
|
|
@@ -35,7 +35,7 @@ const DOCS = "https://clickhouse.com/docs/sql-reference/statements";
|
|
|
35
35
|
const rule = (id: string, cls: ChangeClass, title: string, restriction: string, cite: string): ClassifierRule => classifierRule(id, cls, title, restriction, cite);
|
|
36
36
|
|
|
37
37
|
export const CLASSIFIER_RULES = {
|
|
38
|
-
SQLCH200: rule("SQLCH200", "create", "Create an object", "A new database, table, view, dictionary, function, user, role
|
|
38
|
+
SQLCH200: rule("SQLCH200", "create", "Create an object", "A new database, table, view, dictionary, function, user, role, row policy, quota or settings profile is created, or a grantee gets its first grants; nothing existing changes.", `${DOCS}/create`),
|
|
39
39
|
SQLCH201: rule(
|
|
40
40
|
"SQLCH201",
|
|
41
41
|
"metadata",
|
|
@@ -150,6 +150,13 @@ export const CLASSIFIER_RULES = {
|
|
|
150
150
|
"A table setting the server marks read-only (such as index_granularity) is fixed when the table is created and needs a new table to change.",
|
|
151
151
|
`${DOCS}/alter/setting`,
|
|
152
152
|
),
|
|
153
|
+
SQLCH219: rule(
|
|
154
|
+
"SQLCH219",
|
|
155
|
+
"metadata",
|
|
156
|
+
"Replace a table over a table function",
|
|
157
|
+
"A table declared AS a table function (numbers, remote, s3, url) stores no data of its own and has no ALTER for its function; CREATE OR REPLACE TABLE replaces its call and columns, and queries read through the new one.",
|
|
158
|
+
`${DOCS}/create/table#from-a-table-function`,
|
|
159
|
+
),
|
|
153
160
|
SQLCH220: rule(
|
|
154
161
|
"SQLCH220",
|
|
155
162
|
"rebuild",
|
|
@@ -278,6 +285,20 @@ export const CLASSIFIER_RULES = {
|
|
|
278
285
|
"REVOKE takes a privilege or a role away, including one granted by hand to a grantee the build declares grants for; queries that relied on it fail from then on.",
|
|
279
286
|
`${DOCS}/revoke`,
|
|
280
287
|
),
|
|
288
|
+
SQLCH275: rule(
|
|
289
|
+
"SQLCH275",
|
|
290
|
+
"metadata",
|
|
291
|
+
"Change a quota",
|
|
292
|
+
"ALTER QUOTA sets the quota's key, one interval's limits (NO LIMITS takes the interval away) or whom it applies to, on the quota that is there, so what names it still does.",
|
|
293
|
+
`${DOCS}/alter/quota`,
|
|
294
|
+
),
|
|
295
|
+
SQLCH276: rule(
|
|
296
|
+
"SQLCH276",
|
|
297
|
+
"metadata",
|
|
298
|
+
"Change a settings profile",
|
|
299
|
+
"ALTER SETTINGS PROFILE replaces the profile's settings, constraints and inherited profiles, or whom it applies to; the users, roles and profiles that name it keep it. Sessions already open keep the old settings.",
|
|
300
|
+
`${DOCS}/alter/settings-profile`,
|
|
301
|
+
),
|
|
281
302
|
SQLCH290: rule(
|
|
282
303
|
"SQLCH290",
|
|
283
304
|
"rebuild",
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quotas and settings profiles (#3911): the `quota` and `settingsProfile`
|
|
3
|
+
* tags, each compared with what the pinned server prints for it, the
|
|
4
|
+
* `ALTER` each change takes, `ON CLUSTER` per topology, and reading them
|
|
5
|
+
* from a `.sql` file. `live/quota-profile.e2e.test.ts` runs them against a
|
|
6
|
+
* server.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "vitest";
|
|
10
|
+
import { quota, role, settingsProfile, CLICKHOUSE_ENTITY_TYPES } from "./entities";
|
|
11
|
+
import { canonicalObject, objectKey } from "./plan/normalize";
|
|
12
|
+
import { diffSchemas } from "./plan/diff";
|
|
13
|
+
import { planStatements, type DeclaredObject } from "./apply/statements";
|
|
14
|
+
import { renderStatement } from "./topology";
|
|
15
|
+
import { readClickHouse } from "../files/clickhouse";
|
|
16
|
+
import { clickhouseTemplates } from "./import/generator";
|
|
17
|
+
|
|
18
|
+
const t = (s: string) => [s] as unknown as TemplateStringsArray;
|
|
19
|
+
|
|
20
|
+
describe("the tags", () => {
|
|
21
|
+
test("a quota: its key, its intervals as written, whom it applies to", () => {
|
|
22
|
+
const reader = role`CREATE ROLE reader`;
|
|
23
|
+
const q = quota`CREATE QUOTA IF NOT EXISTS per_user KEYED BY user_name FOR INTERVAL 1 hour MAX queries = 100, errors = 5, FOR RANDOMIZED INTERVAL 1 day TRACKING ONLY TO ${reader}`;
|
|
24
|
+
expect(q.entityType).toBe("ClickHouse::Quota");
|
|
25
|
+
expect(q.props).toMatchObject({
|
|
26
|
+
name: "per_user",
|
|
27
|
+
ifNotExists: true,
|
|
28
|
+
keyed: "user_name",
|
|
29
|
+
intervals: "FOR INTERVAL 1 hour MAX queries = 100, errors = 5, FOR RANDOMIZED INTERVAL 1 day TRACKING ONLY",
|
|
30
|
+
to: "reader",
|
|
31
|
+
});
|
|
32
|
+
expect(q.props.ddl).toBe("CREATE QUOTA IF NOT EXISTS per_user KEYED BY user_name FOR INTERVAL 1 hour MAX queries = 100, errors = 5, FOR RANDOMIZED INTERVAL 1 day TRACKING ONLY TO reader");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a settings profile, written either way, inheriting another", () => {
|
|
36
|
+
const base = settingsProfile`CREATE SETTINGS PROFILE base SETTINGS max_threads = 8`;
|
|
37
|
+
const p = settingsProfile`CREATE PROFILE analysts SETTINGS PROFILE ${base}, max_memory_usage = 10000000000 MIN 1000000 MAX 20000000000, readonly = 1 CONST TO ALL EXCEPT admin`;
|
|
38
|
+
expect(p.entityType).toBe("ClickHouse::SettingsProfile");
|
|
39
|
+
expect(p.props).toMatchObject({ name: "analysts", settings: "PROFILE base, max_memory_usage = 10000000000 MIN 1000000 MAX 20000000000, readonly = 1 CONST", to: "ALL EXCEPT admin" });
|
|
40
|
+
expect(p.dependsOn).toContain(base);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("one per template, the wrong tag, and malformed intervals are refused", () => {
|
|
44
|
+
expect(() => quota(t("CREATE QUOTA a, b"))).toThrow(/one quota per template/);
|
|
45
|
+
expect(() => settingsProfile(t("CREATE SETTINGS PROFILE a, b"))).toThrow(/one settings profile per template/);
|
|
46
|
+
expect(() => quota(t("CREATE SETTINGS PROFILE p"))).toThrow(/holds a CREATE SETTINGS PROFILE; use the settingsProfile tag/);
|
|
47
|
+
expect(() => settingsProfile(t("CREATE QUOTA q"))).toThrow(/holds a CREATE QUOTA; use the quota tag/);
|
|
48
|
+
expect(() => role(t("CREATE QUOTA q"))).toThrow(/use the quota tag/);
|
|
49
|
+
expect(() => quota(t("CREATE QUOTA q KEYED BY user_name NOT KEYED"))).toThrow(/both written/);
|
|
50
|
+
expect(() => quota(t("CREATE QUOTA q FOR INTERVAL 1 hour MAX queries = 1 TO r FOR INTERVAL 1 day MAX queries = 2"))).toThrow(/FOR is written twice/);
|
|
51
|
+
expect(() => canonicalObject("CREATE QUOTA q FOR INTERVAL 1 fortnight MAX queries = 1")).toThrow(/whole number and a unit/);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("compared with what the server prints", () => {
|
|
56
|
+
const same = (declared: string, shown: string) => diffSchemas([{ key: "k", canonical: canonicalObject(shown) }], [{ key: "k", canonical: canonicalObject(declared) }]).changes;
|
|
57
|
+
|
|
58
|
+
test("a quota: units in seconds, limits sorted and lower case, numbers as the server keeps them, NO LIMITS as no interval", () => {
|
|
59
|
+
expect(
|
|
60
|
+
same(
|
|
61
|
+
"CREATE QUOTA q KEYED BY 'user_name' FOR INTERVAL 60 MINUTE MAX QUERIES 100, errors = 5, FOR RANDOMIZED INTERVAL 1 DAY MAX execution_time = 1000.0, read_bytes = 1e9, FOR INTERVAL 1 week NO LIMITS TO r1",
|
|
62
|
+
"CREATE QUOTA q KEYED BY user_name FOR INTERVAL 1 hour MAX queries = 100, errors = 5, FOR RANDOMIZED INTERVAL 1 day MAX read_bytes = 1000000000, execution_time = 1000 TO r1",
|
|
63
|
+
),
|
|
64
|
+
).toEqual([]);
|
|
65
|
+
// Three months are a quarter, as the server counts them.
|
|
66
|
+
expect(same("CREATE QUOTA q FOR INTERVAL 3 month MAX queries = 1", "CREATE QUOTA q FOR INTERVAL 1 quarter MAX queries = 1")).toEqual([]);
|
|
67
|
+
expect(same("CREATE QUOTA q NOT KEYED TO NONE", "CREATE QUOTA q")).toEqual([]);
|
|
68
|
+
// `KEY BY` is `KEYED BY`; an IP key's prefix bits are part of the key.
|
|
69
|
+
expect(same("CREATE QUOTA q KEY BY ip_address ipv6_prefix_bits 64", "CREATE QUOTA q KEYED BY ip_address IPV6_PREFIX_BITS 64")).toEqual([]);
|
|
70
|
+
expect(canonicalObject("CREATE QUOTA q KEYED BY ip_address ipv4_prefix_bits 16 ipv6_prefix_bits 48").access).toEqual({ keyed: "ip_address ipv4_prefix_bits 16 ipv6_prefix_bits 48" });
|
|
71
|
+
// A name may start with a digit, as the server's own tests write them.
|
|
72
|
+
expect(canonicalObject("CREATE SETTINGS PROFILE 02294_profile1 SETTINGS max_execution_time = 0.5 TO default").name).toBe("02294_profile1");
|
|
73
|
+
expect(canonicalObject("CREATE QUOTA `q x` KEYED BY client_key, user_name FOR INTERVAL 2 week MAX queries = 1 TO ALL").access).toEqual({
|
|
74
|
+
keyed: "client_key , user_name",
|
|
75
|
+
"interval.1209600": "MAX queries = 1",
|
|
76
|
+
to: "ALL",
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a changed quota is SQLCH275 per field and per interval", () => {
|
|
81
|
+
expect(
|
|
82
|
+
same(
|
|
83
|
+
"CREATE QUOTA q KEYED BY ip_address FOR INTERVAL 1 hour MAX queries = 7, FOR INTERVAL 1 day TRACKING ONLY",
|
|
84
|
+
"CREATE QUOTA q KEYED BY user_name FOR INTERVAL 1 hour MAX queries = 5, errors = 2, FOR INTERVAL 1 week MAX queries = 1 TO r",
|
|
85
|
+
).map((c) => [c.field, c.rule, c.before, c.after]),
|
|
86
|
+
).toEqual([
|
|
87
|
+
["keyed", "SQLCH275", "user_name", "ip_address"],
|
|
88
|
+
["interval.3600", "SQLCH275", "MAX errors = 2 , queries = 5", "MAX queries = 7"],
|
|
89
|
+
["interval.86400", "SQLCH275", undefined, "TRACKING ONLY"],
|
|
90
|
+
["interval.604800", "SQLCH275", "MAX queries = 1", undefined],
|
|
91
|
+
["to", "SQLCH275", "r", undefined],
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a settings profile: READONLY as CONST, PROFILE 'x' as INHERIT x, the name quoted or not", () => {
|
|
96
|
+
expect(
|
|
97
|
+
same(
|
|
98
|
+
"CREATE PROFILE p2 SETTINGS PROFILE 'p1', readonly = 1, max_threads READONLY, max_memory_usage = 100 MIN 90 MAX 110 TO b, a",
|
|
99
|
+
"CREATE SETTINGS PROFILE `p2` SETTINGS INHERIT `p1`, readonly = 1, max_memory_usage = 100 MIN 90 MAX 110, max_threads CONST TO a, b",
|
|
100
|
+
),
|
|
101
|
+
).toEqual([]);
|
|
102
|
+
expect(same("CREATE SETTINGS PROFILE p SETTINGS max_threads = 4", "CREATE SETTINGS PROFILE `p` SETTINGS max_threads = 5 TO r").map((c) => [c.field, c.rule])).toEqual([
|
|
103
|
+
["settings", "SQLCH276"],
|
|
104
|
+
["to", "SQLCH276"],
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("the statements", () => {
|
|
110
|
+
const decl = (type: string, ddl: string): DeclaredObject => {
|
|
111
|
+
const canonical = canonicalObject(ddl);
|
|
112
|
+
return { exportName: "x", type: type as DeclaredObject["type"], key: objectKey(canonical), ddl, canonical, dependsOn: [] };
|
|
113
|
+
};
|
|
114
|
+
const steps = (obj: DeclaredObject, shown?: string) => {
|
|
115
|
+
const live = shown === undefined ? undefined : canonicalObject(shown);
|
|
116
|
+
const changes = diffSchemas(live ? [{ key: obj.key, canonical: live }] : [], [{ key: obj.key, canonical: obj.canonical }]).changes;
|
|
117
|
+
const entry = planStatements({ declared: [obj], changes, current: new Map(live ? [[obj.key, live]] : []) }).objects[0]!;
|
|
118
|
+
return entry.verdict === "create" || entry.verdict === "alter" ? entry.steps.map((s) => s.sql) : [entry.verdict];
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
test("a quota is created as declared, then changed with ALTER QUOTA, never replaced", () => {
|
|
122
|
+
const q = decl(CLICKHOUSE_ENTITY_TYPES.quota, "CREATE QUOTA q KEYED BY ip_address FOR RANDOMIZED INTERVAL 1 hour MAX queries = 7, errors = 1 TO r");
|
|
123
|
+
expect(q.key).toBe("quota q");
|
|
124
|
+
expect(steps(q)).toEqual(["CREATE QUOTA q KEYED BY ip_address FOR RANDOMIZED INTERVAL 1 hour MAX queries = 7, errors = 1 TO r"]);
|
|
125
|
+
expect(steps(q, "CREATE QUOTA q FOR INTERVAL 1 hour MAX queries = 5, FOR INTERVAL 1 day MAX queries = 100")).toEqual([
|
|
126
|
+
"ALTER QUOTA `q` KEYED BY ip_address",
|
|
127
|
+
"ALTER QUOTA `q` FOR RANDOMIZED INTERVAL 3600 second MAX errors = 1, queries = 7",
|
|
128
|
+
"ALTER QUOTA `q` FOR INTERVAL 86400 second NO LIMITS",
|
|
129
|
+
"ALTER QUOTA `q` TO r",
|
|
130
|
+
]);
|
|
131
|
+
expect(steps(decl(CLICKHOUSE_ENTITY_TYPES.quota, "CREATE QUOTA q"), "CREATE QUOTA q KEYED BY user_name TO r")).toEqual(["ALTER QUOTA `q` NOT KEYED", "ALTER QUOTA `q` TO NONE"]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("a settings profile is changed with ALTER SETTINGS PROFILE, so what inherits it keeps it", () => {
|
|
135
|
+
const p = decl(CLICKHOUSE_ENTITY_TYPES.settingsProfile, "CREATE SETTINGS PROFILE p SETTINGS max_threads = 4 TO r");
|
|
136
|
+
expect(p.key).toBe("settings profile p");
|
|
137
|
+
expect(steps(p, "CREATE SETTINGS PROFILE `p` SETTINGS max_threads = 8")).toEqual(["ALTER SETTINGS PROFILE `p` SETTINGS max_threads = 4", "ALTER SETTINGS PROFILE `p` TO r"]);
|
|
138
|
+
expect(steps(decl(CLICKHOUSE_ENTITY_TYPES.settingsProfile, "CREATE PROFILE p"), "CREATE SETTINGS PROFILE `p` SETTINGS max_threads = 8 TO r")).toEqual([
|
|
139
|
+
"ALTER SETTINGS PROFILE `p` SETTINGS NONE",
|
|
140
|
+
"ALTER SETTINGS PROFILE `p` TO NONE",
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("neither is ever dropped", () => {
|
|
145
|
+
const live = [
|
|
146
|
+
{ key: "quota q", canonical: canonicalObject("CREATE QUOTA q") },
|
|
147
|
+
{ key: "settings profile p", canonical: canonicalObject("CREATE SETTINGS PROFILE p") },
|
|
148
|
+
];
|
|
149
|
+
const gone = diffSchemas(live, []).changes;
|
|
150
|
+
expect(planStatements({ declared: [], changes: gone, current: new Map(live.map((l) => [l.key, l.canonical])), allowDestructive: true }).drops).toEqual([]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("ON CLUSTER on a cluster, since neither is in a database", () => {
|
|
154
|
+
const cluster = { kind: "cluster" as const, cluster: "main" };
|
|
155
|
+
expect(renderStatement("CREATE QUOTA q KEYED BY user_name", cluster)).toBe("CREATE QUOTA q ON CLUSTER `main` KEYED BY user_name");
|
|
156
|
+
expect(renderStatement("CREATE SETTINGS PROFILE p SETTINGS max_threads = 1", cluster)).toBe("CREATE SETTINGS PROFILE p ON CLUSTER `main` SETTINGS max_threads = 1");
|
|
157
|
+
expect(renderStatement("CREATE PROFILE p", cluster)).toBe("CREATE PROFILE p ON CLUSTER `main`");
|
|
158
|
+
expect(renderStatement("ALTER QUOTA `q` TO r", cluster)).toBe("ALTER QUOTA `q` ON CLUSTER `main` TO r");
|
|
159
|
+
expect(renderStatement("ALTER SETTINGS PROFILE `p` SETTINGS NONE", cluster)).toBe("ALTER SETTINGS PROFILE `p` ON CLUSTER `main` SETTINGS NONE");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("import", () => {
|
|
164
|
+
test("a .sql file's quotas and settings profiles are read by their tags", () => {
|
|
165
|
+
const problems: string[] = [];
|
|
166
|
+
const objects = readClickHouse(
|
|
167
|
+
"CREATE SETTINGS PROFILE `p` SETTINGS max_threads = 4;\nCREATE QUOTA q KEYED BY user_name FOR INTERVAL 1 hour MAX queries = 5 TO r;\nCREATE NAMED COLLECTION c AS password = 'x';\n",
|
|
168
|
+
{ origin: "access.sql" },
|
|
169
|
+
problems,
|
|
170
|
+
);
|
|
171
|
+
expect(objects.map((o) => [o.type, o.name])).toEqual([
|
|
172
|
+
["ClickHouse::SettingsProfile", "p"],
|
|
173
|
+
["ClickHouse::Quota", "q"],
|
|
174
|
+
]);
|
|
175
|
+
expect(problems).toEqual([expect.stringMatching(/^named collection: chant cannot declare it \(a named collection holds credentials/)]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("the generator writes them with their tags, never as a relation another object names", () => {
|
|
179
|
+
const items = [
|
|
180
|
+
{ exportName: "q", type: CLICKHOUSE_ENTITY_TYPES.quota, name: "q", ddl: "CREATE QUOTA q TO events" },
|
|
181
|
+
{ exportName: "events", type: CLICKHOUSE_ENTITY_TYPES.table, database: "default", name: "events", ddl: "CREATE TABLE default.events (a UInt8) ENGINE = Log" },
|
|
182
|
+
];
|
|
183
|
+
const { bodies } = clickhouseTemplates(items);
|
|
184
|
+
expect(bodies.get("q")!.refs).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A table over a table function (#3911): `CREATE TABLE t AS numbers(10)`,
|
|
3
|
+
* with or without its columns, as the tag reads it, as the server prints it
|
|
4
|
+
* back, and the one statement a change takes. `live/kinds.e2e.test.ts` runs
|
|
5
|
+
* it against a server.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "vitest";
|
|
9
|
+
import { table, CLICKHOUSE_ENTITY_TYPES } from "./entities";
|
|
10
|
+
import { canonicalObject, objectKey } from "./plan/normalize";
|
|
11
|
+
import { diffSchemas } from "./plan/diff";
|
|
12
|
+
import { planStatements, type DeclaredObject } from "./apply/statements";
|
|
13
|
+
import { renderStatement } from "./topology";
|
|
14
|
+
|
|
15
|
+
const shown = "CREATE TABLE default.nums\n(\n `number` UInt64\n) AS numbers(10)\nCOMMENT 'counting'";
|
|
16
|
+
|
|
17
|
+
describe("a table over a table function", () => {
|
|
18
|
+
test("the tag reads the call, and the columns when written", () => {
|
|
19
|
+
const bare = table`CREATE TABLE default.nums AS numbers(10) COMMENT 'counting'`;
|
|
20
|
+
expect(bare.props).toMatchObject({ name: "nums", database: "default", asFunction: "numbers(10)", columns: [], comment: "counting" });
|
|
21
|
+
expect(bare.props.engine).toBeUndefined();
|
|
22
|
+
const typed = table`CREATE TABLE default.nums (number UInt64) AS numbers(10)`;
|
|
23
|
+
expect(typed.props.columns.map((c) => [c.name, c.type])).toEqual([["number", "UInt64"]]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("a declaration with no columns agrees with the columns the server inferred; one that lists them is compared on them", () => {
|
|
27
|
+
const same = (declared: string) => diffSchemas([{ key: "k", canonical: canonicalObject(shown) }], [{ key: "k", canonical: canonicalObject(declared) }]).changes;
|
|
28
|
+
expect(same("CREATE TABLE default.nums AS numbers(10) COMMENT 'counting'")).toEqual([]);
|
|
29
|
+
expect(same("CREATE TABLE default.nums (number UInt64) AS numbers(10) COMMENT 'counting'")).toEqual([]);
|
|
30
|
+
expect(same("CREATE TABLE default.nums (number UInt32) AS numbers(20) COMMENT 'counting'").map((c) => [c.field, c.rule, c.class])).toEqual([
|
|
31
|
+
["asFunction", "SQLCH219", "metadata"],
|
|
32
|
+
["columns.number", "SQLCH219", "metadata"],
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a change replaces it whole, its comment with it; a table with data becoming one is a rebuild", () => {
|
|
37
|
+
const ddl = "CREATE TABLE default.nums AS numbers(20)";
|
|
38
|
+
const canonical = canonicalObject(ddl);
|
|
39
|
+
const obj: DeclaredObject = { exportName: "nums", type: CLICKHOUSE_ENTITY_TYPES.table, key: objectKey(canonical), ddl, canonical, dependsOn: [] };
|
|
40
|
+
const live = canonicalObject(shown);
|
|
41
|
+
const changes = diffSchemas([{ key: obj.key, canonical: live }], [{ key: obj.key, canonical }]).changes;
|
|
42
|
+
expect(changes.map((c) => c.field)).toEqual(["comment", "asFunction"]);
|
|
43
|
+
const entry = planStatements({ declared: [obj], changes, current: new Map([[obj.key, live]]) }).objects[0]!;
|
|
44
|
+
expect(entry.verdict === "alter" ? entry.steps.map((s) => s.sql) : []).toEqual(["CREATE OR REPLACE TABLE default.nums AS numbers(20) COMMENT '[chant managed-by=chant]'"]);
|
|
45
|
+
|
|
46
|
+
const stored = canonicalObject("CREATE TABLE default.nums (number UInt64) ENGINE = MergeTree ORDER BY number");
|
|
47
|
+
const rebuild = diffSchemas([{ key: obj.key, canonical: stored }], [{ key: obj.key, canonical }]).changes;
|
|
48
|
+
expect(rebuild.map((c) => [c.field, c.rule, c.class])).toEqual([["asFunction", "SQLCH223", "rebuild"]]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("ON CLUSTER after the name", () => {
|
|
52
|
+
expect(renderStatement("CREATE TABLE default.nums AS numbers(10)", { kind: "cluster", cluster: "main" })).toBe("CREATE TABLE default.nums ON CLUSTER `main` AS numbers(10)");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -22,6 +22,14 @@
|
|
|
22
22
|
"YEARS"
|
|
23
23
|
],
|
|
24
24
|
"failures": [
|
|
25
|
+
"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 at 'AS'",
|
|
26
|
+
"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 at 'as'",
|
|
27
|
+
"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 at 'AS'",
|
|
28
|
+
"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 at 'as'",
|
|
29
|
+
"CREATE TABLE ... CLONE AS is not declared: it copies another table's columns, engine and data once. Declare the columns and engine at 'CLONE'",
|
|
30
|
+
"CREATE TABLE ... EMPTY AS SELECT is not declared: the server keeps the query's columns, not the query. Declare the columns at 'empty'",
|
|
31
|
+
"declare one quota per template at ','",
|
|
32
|
+
"declare one settings profile per template at ','",
|
|
25
33
|
"expected ')' at 'WITH'",
|
|
26
34
|
"expected '='",
|
|
27
35
|
"expected '=' at '('",
|
|
@@ -48,20 +56,15 @@
|
|
|
48
56
|
"expected an expression",
|
|
49
57
|
"expected an expression at ')'",
|
|
50
58
|
"expected an expression at 'TYPE'",
|
|
51
|
-
"expected the column list (CREATE TABLE ... AS is not declared
|
|
52
|
-
"expected the column list (CREATE TABLE ... AS is not declared
|
|
53
|
-
"expected the column list (CREATE TABLE ... AS is not declared
|
|
54
|
-
"expected the column list (CREATE TABLE ... AS is not declared
|
|
55
|
-
"expected the column list (CREATE TABLE ... AS is not declared
|
|
56
|
-
"expected the column list (CREATE TABLE ... AS is not declared
|
|
57
|
-
"expected the column list (CREATE TABLE ... AS is not declared
|
|
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'",
|
|
59
|
+
"expected the column list (a table declared without one, CREATE TABLE ... ENGINE = ... AS SELECT, is not declared: declare the columns) at 'COMMENT'",
|
|
60
|
+
"expected the column list (a table declared without one, CREATE TABLE ... ENGINE = ... AS SELECT, is not declared: declare the columns) at 'ENGINE'",
|
|
61
|
+
"expected the column list (a table declared without one, CREATE TABLE ... ENGINE = ... AS SELECT, is not declared: declare the columns) at 'Engine'",
|
|
62
|
+
"expected the column list (a table declared without one, CREATE TABLE ... ENGINE = ... AS SELECT, is not declared: declare the columns) at 'ORDER'",
|
|
63
|
+
"expected the column list (a table declared without one, CREATE TABLE ... ENGINE = ... AS SELECT, is not declared: declare the columns) at 'SQL'",
|
|
64
|
+
"expected the column list (a table declared without one, CREATE TABLE ... ENGINE = ... AS SELECT, is not declared: declare the columns) at 'TO'",
|
|
65
|
+
"expected the column list (a table declared without one, CREATE TABLE ... ENGINE = ... AS SELECT, is not declared: declare the columns) at 'engine'",
|
|
62
66
|
"unexpected end of statement",
|
|
63
|
-
"unexpected
|
|
64
|
-
"unexpected token after the table definition at 'SETTINGS'"
|
|
65
|
-
"unexpected token after the table definition at 'as'"
|
|
67
|
+
"unexpected ipv6_prefix_bits: expected IN, KEYED BY, KEY BY, NOT KEYED, FOR, TO at 'ipv6_prefix_bits'",
|
|
68
|
+
"unexpected token after the table definition at 'SETTINGS'"
|
|
66
69
|
]
|
|
67
70
|
}
|
|
@@ -352,7 +352,7 @@ function renderOtherCreate(tokens: Token[], topology: Topology): string {
|
|
|
352
352
|
if (w.accept("OR")) w.accept("REPLACE");
|
|
353
353
|
w.accept("TEMPORARY");
|
|
354
354
|
// Access control (`./access.ts`) belongs to no database: like a database's own DDL, it reaches every replica through the cluster.
|
|
355
|
-
if (w.accept("USER", "ROLE", "POLICY", "QUOTA") || w.acceptSeq("ROW", "POLICY") || w.acceptSeq("SETTINGS", "PROFILE")) {
|
|
355
|
+
if (w.accept("USER", "ROLE", "POLICY", "QUOTA", "PROFILE") || w.acceptSeq("ROW", "POLICY") || w.acceptSeq("SETTINGS", "PROFILE")) {
|
|
356
356
|
w.acceptSeq("IF", "NOT", "EXISTS") || w.acceptSeq("OR", "REPLACE");
|
|
357
357
|
if (!w.name()) return applyEdits(tokens, []);
|
|
358
358
|
const edit = onClusterEdit(w, databaseCluster(topology, true), w.end());
|
|
@@ -430,7 +430,9 @@ export function renderStatement(sql: string, topology: Topology): string {
|
|
|
430
430
|
if (word(head, "DROP", "DETACH", "ATTACH")) w.accept("TEMPORARY");
|
|
431
431
|
if (w.accept("DATABASE")) return renderAfterName(tokens, w, databaseCluster(topology, word(head, "DROP")));
|
|
432
432
|
// A function and access control belong to no database: like the database's own DDL, they reach every replica through the cluster.
|
|
433
|
-
if (w.accept("FUNCTION", "USER", "ROLE", "POLICY") || w.acceptSeq("ROW", "POLICY")
|
|
433
|
+
if (w.accept("FUNCTION", "USER", "ROLE", "POLICY", "QUOTA", "PROFILE") || w.acceptSeq("ROW", "POLICY") || w.acceptSeq("SETTINGS", "PROFILE")) {
|
|
434
|
+
return renderAfterName(tokens, w, databaseCluster(topology, true));
|
|
435
|
+
}
|
|
434
436
|
// `TRUNCATE t` may leave out TABLE.
|
|
435
437
|
if (w.accept("TABLE", "VIEW", "DICTIONARY") || word(head, "TRUNCATE")) return renderAfterName(tokens, w, objectCluster(topology));
|
|
436
438
|
return sql;
|
package/src/clickhouse.ts
CHANGED
|
@@ -18,6 +18,8 @@ export {
|
|
|
18
18
|
role,
|
|
19
19
|
policy,
|
|
20
20
|
grant,
|
|
21
|
+
quota,
|
|
22
|
+
settingsProfile,
|
|
21
23
|
literal,
|
|
22
24
|
SqlLiteral,
|
|
23
25
|
SqlTemplateError,
|
|
@@ -35,6 +37,10 @@ export {
|
|
|
35
37
|
type ClickHouseRole,
|
|
36
38
|
type ClickHouseRowPolicy,
|
|
37
39
|
type ClickHouseGrant,
|
|
40
|
+
type ClickHouseQuota,
|
|
41
|
+
type ClickHouseSettingsProfile,
|
|
42
|
+
type QuotaProps,
|
|
43
|
+
type SettingsProfileProps,
|
|
38
44
|
type UserProps,
|
|
39
45
|
type RoleProps,
|
|
40
46
|
type RowPolicyProps,
|
|
@@ -72,7 +72,7 @@ Two rules change class across the supported majors: a STORED generated column's
|
|
|
72
72
|
|
|
73
73
|
## Identity and renames
|
|
74
74
|
|
|
75
|
-
An object is identified by its export name between two builds, and by its schema-qualified name against a server, which has no export names. Tables, views, materialized views, sequences and indexes share one namespace per schema, types and domains another, and schemas and extensions are matched by name. Functions and
|
|
75
|
+
An object is identified by its export name between two builds, and by its schema-qualified name against a server, which has no export names. Tables, views, materialized views, sequences and indexes share one namespace per schema, types and domains another, and schemas and extensions are matched by name. Functions, procedures and aggregates share a namespace of their own and are told apart by their input parameter types, so an overload is another object; composite and range types share the types' namespace, collations have one of their own, a cast is named by its two types and an event trigger by its name in the database; a trigger is matched by its name on its table.
|
|
76
76
|
|
|
77
77
|
A rename is declared where it happens. Before a CREATE, \`-- previously: <old name>\` says the object had another name; on a column's line, it says the column did:
|
|
78
78
|
|
package/src/codegen/registry.ts
CHANGED
|
@@ -23,6 +23,8 @@ const KINDS: Record<string, string> = {
|
|
|
23
23
|
Role: CLICKHOUSE_ENTITY_TYPES.role,
|
|
24
24
|
RowPolicy: CLICKHOUSE_ENTITY_TYPES.rowPolicy,
|
|
25
25
|
Grant: CLICKHOUSE_ENTITY_TYPES.grant,
|
|
26
|
+
Quota: CLICKHOUSE_ENTITY_TYPES.quota,
|
|
27
|
+
SettingsProfile: CLICKHOUSE_ENTITY_TYPES.settingsProfile,
|
|
26
28
|
PostgresSchema: POSTGRES_ENTITY_TYPES.schema,
|
|
27
29
|
PostgresTable: POSTGRES_ENTITY_TYPES.table,
|
|
28
30
|
PostgresIndex: POSTGRES_ENTITY_TYPES.index,
|
|
@@ -30,7 +32,13 @@ const KINDS: Record<string, string> = {
|
|
|
30
32
|
PostgresMaterializedView: POSTGRES_ENTITY_TYPES.materializedView,
|
|
31
33
|
PostgresSequence: POSTGRES_ENTITY_TYPES.sequence,
|
|
32
34
|
PostgresEnum: POSTGRES_ENTITY_TYPES.enum,
|
|
35
|
+
PostgresCompositeType: POSTGRES_ENTITY_TYPES.composite,
|
|
36
|
+
PostgresRangeType: POSTGRES_ENTITY_TYPES.range,
|
|
33
37
|
PostgresDomain: POSTGRES_ENTITY_TYPES.domain,
|
|
38
|
+
PostgresAggregate: POSTGRES_ENTITY_TYPES.aggregate,
|
|
39
|
+
PostgresCollation: POSTGRES_ENTITY_TYPES.collation,
|
|
40
|
+
PostgresCast: POSTGRES_ENTITY_TYPES.cast,
|
|
41
|
+
PostgresEventTrigger: POSTGRES_ENTITY_TYPES.eventTrigger,
|
|
34
42
|
PostgresExtension: POSTGRES_ENTITY_TYPES.extension,
|
|
35
43
|
PostgresFunction: POSTGRES_ENTITY_TYPES.function,
|
|
36
44
|
PostgresProcedure: POSTGRES_ENTITY_TYPES.procedure,
|