@intentius/chant-lexicon-sql 0.124.18 → 0.124.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/import/live-export.d.ts +8 -0
- package/dist/clickhouse/import/live-export.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/live/undeclarable.d.ts +32 -0
- package/dist/clickhouse/live/undeclarable.d.ts.map +1 -0
- 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/testing/fake-server.d.ts +6 -0
- package/dist/clickhouse/testing/fake-server.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/coverage-page.d.ts +7 -0
- package/dist/codegen/coverage-page.d.ts.map +1 -0
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/codegen/registry.d.ts.map +1 -1
- package/dist/core/undeclarable.d.ts +38 -0
- package/dist/core/undeclarable.d.ts.map +1 -0
- package/dist/coverage/clickhouse-commands.json +24 -0
- package/dist/coverage/clickhouse.json +25 -0
- package/dist/coverage/postgres-commands.json +245 -0
- package/dist/coverage/postgres.json +51 -0
- package/dist/coverage.d.ts +68 -0
- package/dist/coverage.d.ts.map +1 -1
- package/dist/files/clickhouse.d.ts +2 -1
- package/dist/files/clickhouse.d.ts.map +1 -1
- package/dist/files/common.d.ts +12 -0
- package/dist/files/common.d.ts.map +1 -1
- package/dist/files/postgres.d.ts +1 -1
- package/dist/files/postgres.d.ts.map +1 -1
- package/dist/import-parser.d.ts +7 -2
- package/dist/import-parser.d.ts.map +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/import/live-export.d.ts +8 -0
- package/dist/postgres/import/live-export.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 +23 -8
- package/dist/postgres/live/catalog.d.ts.map +1 -1
- package/dist/postgres/live/describe-resources.d.ts.map +1 -1
- package/dist/postgres/live/undeclarable.d.ts +42 -0
- package/dist/postgres/live/undeclarable.d.ts.map +1 -0
- 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/fake-client.d.ts +6 -0
- package/dist/postgres/testing/fake-client.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/import/live-export.ts +17 -2
- 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 +65 -0
- package/src/clickhouse/live/undeclarable.ts +66 -0
- 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/testing/fake-server.ts +5 -0
- package/src/clickhouse/topology.ts +4 -2
- package/src/clickhouse.ts +6 -0
- package/src/codegen/classifier-page.test.ts +6 -0
- package/src/codegen/coverage-page.ts +77 -0
- package/src/codegen/docs.ts +5 -0
- package/src/codegen/postgres-classifier-page.ts +1 -1
- package/src/codegen/registry.ts +8 -0
- package/src/core/undeclarable.test.ts +141 -0
- package/src/core/undeclarable.ts +66 -0
- package/src/coverage/clickhouse-commands.json +24 -0
- package/src/coverage/clickhouse.json +25 -0
- package/src/coverage/postgres-commands.json +245 -0
- package/src/coverage/postgres.json +51 -0
- package/src/coverage.test.ts +78 -0
- package/src/coverage.ts +142 -0
- package/src/files/clickhouse.ts +20 -7
- package/src/files/common.ts +18 -0
- package/src/files/postgres.ts +25 -6
- package/src/generated/lexicon-sql.json +40 -0
- package/src/import-parser.ts +12 -4
- 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 +13 -2
- 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/import/live-export.ts +25 -2
- package/src/postgres/kinds.test.ts +140 -0
- package/src/postgres/live/catalog-kinds.ts +312 -0
- package/src/postgres/live/catalog.ts +33 -11
- 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 +83 -0
- package/src/postgres/live/undeclarable.ts +141 -0
- 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/fake-client.ts +5 -0
- 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
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("(")) {
|
|
@@ -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
|
+
});
|