@intentius/chant-lexicon-sql 0.122.0 → 0.123.0
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/README.md +1 -1
- package/dist/clickhouse/entities.d.ts +37 -11
- package/dist/clickhouse/entities.d.ts.map +1 -1
- package/dist/clickhouse/import/generator.d.ts.map +1 -1
- package/dist/clickhouse/plan/commands.d.ts.map +1 -1
- package/dist/clickhouse/row-types.d.ts +30 -0
- package/dist/clickhouse/row-types.d.ts.map +1 -0
- package/dist/clickhouse.d.ts +1 -1
- package/dist/clickhouse.d.ts.map +1 -1
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/typed.d.ts +132 -0
- package/dist/core/typed.d.ts.map +1 -0
- package/dist/integrity.json +7 -3
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/postgres-names.d.ts +2 -0
- package/dist/lint/post-synth/postgres-names.d.ts.map +1 -1
- package/dist/lint/post-synth/sql102.d.ts +16 -0
- package/dist/lint/post-synth/sql102.d.ts.map +1 -0
- package/dist/lint/rules/index.d.ts +3 -1
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/lint/rules/sqlch004.d.ts +19 -0
- package/dist/lint/rules/sqlch004.d.ts.map +1 -0
- package/dist/lint/rules/sqlpg005.d.ts +18 -0
- package/dist/lint/rules/sqlpg005.d.ts.map +1 -0
- package/dist/lint/rules/typed-names.d.ts +43 -0
- package/dist/lint/rules/typed-names.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +3 -0
- package/dist/okf/rules/SQL102.md +11 -0
- package/dist/okf/rules/SQLCH004.md +11 -0
- package/dist/okf/rules/SQLPG005.md +11 -0
- package/dist/postgres/entities.d.ts +38 -9
- package/dist/postgres/entities.d.ts.map +1 -1
- package/dist/postgres/import/generator.d.ts.map +1 -1
- package/dist/postgres/row-types.d.ts +17 -0
- package/dist/postgres/row-types.d.ts.map +1 -0
- package/dist/postgres/testing/migration.d.ts +3 -2
- package/dist/postgres/testing/migration.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 +6 -2
- package/dist/rules/sql102.ts +34 -0
- package/dist/rules/sqlch004.ts +81 -0
- package/dist/rules/sqlpg005.ts +73 -0
- package/dist/rules/typed-names.ts +120 -0
- package/dist/sql-types-command.d.ts +19 -0
- package/dist/sql-types-command.d.ts.map +1 -0
- package/dist/sql-types.d.ts +53 -0
- package/dist/sql-types.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/clickhouse/dictionary.test.ts +1 -1
- package/src/clickhouse/entities.ts +79 -14
- package/src/clickhouse/import/generator.ts +7 -1
- package/src/clickhouse/import/roundtrip.test.ts +1 -1
- package/src/clickhouse/plan/commands.ts +5 -0
- package/src/clickhouse/row-types.ts +158 -0
- package/src/clickhouse.ts +10 -0
- package/src/core/index.ts +4 -0
- package/src/core/typed.ts +143 -0
- package/src/lint/audit-catalog.ts +20 -0
- package/src/lint/post-synth/index.ts +2 -0
- package/src/lint/post-synth/postgres-names.ts +6 -2
- package/src/lint/post-synth/sql102.ts +34 -0
- package/src/lint/rules/index.ts +4 -2
- package/src/lint/rules/sqlch004.ts +81 -0
- package/src/lint/rules/sqlpg005.ts +73 -0
- package/src/lint/rules/typed-names.test.ts +151 -0
- package/src/lint/rules/typed-names.ts +120 -0
- package/src/op/__snapshots__/watch-pipeline.test.ts.snap +3 -3
- package/src/postgres/entities.ts +65 -12
- package/src/postgres/import/generator.ts +7 -1
- package/src/postgres/row-types.ts +111 -0
- package/src/postgres.ts +10 -0
- package/src/sql-types-command.ts +99 -0
- package/src/sql-types-staleness.test.ts +110 -0
- package/src/sql-types.test.ts +132 -0
- package/src/sql-types.ts +352 -0
- package/src/typecheck.test.ts +12 -3
- package/src/typed-columns.test.ts +92 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed columns (#3751): the types that carry a relation's columns from the
|
|
3
|
+
* declarations `chant sql types` generates to `tsc`, in every dialect.
|
|
4
|
+
*
|
|
5
|
+
* Each dialect declares an empty `SqlSchema` interface keyed by qualified
|
|
6
|
+
* name, and the generated `sql-types.d.ts` augments it. A tag given the name
|
|
7
|
+
* as its type argument (`` table<"shop.orders">`CREATE TABLE ...` ``) returns
|
|
8
|
+
* a relation whose `columns` are that entry's, each a {@link Column} branded
|
|
9
|
+
* with its SQL type. The tag's interpolations are typed by the entry's
|
|
10
|
+
* `slots`: the generator writes, per `${}` position, the column a foreign key
|
|
11
|
+
* or a sort key there must be ({@link KeyColumn}, {@link NotNullColumn}).
|
|
12
|
+
*
|
|
13
|
+
* A relation with no type argument, or one the generated file does not list
|
|
14
|
+
* yet, keeps unchecked columns: any name type-checks and every slot takes
|
|
15
|
+
* them, so a project adopts the type argument one declaration at a time.
|
|
16
|
+
*
|
|
17
|
+
* The runtime half is the record of those slots: a tag notes which `${}`
|
|
18
|
+
* feeds a foreign key's column list or a sort key, off the serialized output
|
|
19
|
+
* as `setInterpolationFields` keeps its record, for the generator to read.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { AttrRef } from "@intentius/chant/attrref";
|
|
23
|
+
|
|
24
|
+
declare const columnBrand: unique symbol;
|
|
25
|
+
declare const checkedBrand: unique symbol;
|
|
26
|
+
declare const relationName: unique symbol;
|
|
27
|
+
declare const unnamedBrand: unique symbol;
|
|
28
|
+
|
|
29
|
+
/** What the generator knows about a column. */
|
|
30
|
+
export interface ColumnTraits {
|
|
31
|
+
/** The row type `RowOf` gives the column, before `| null`. */
|
|
32
|
+
readonly ts: unknown;
|
|
33
|
+
readonly nullable: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* How the column compares in a key. Postgres: the catalog category letter
|
|
36
|
+
* (`N`, `S`), or the type itself for the user-defined category, an enum or
|
|
37
|
+
* a row type; a domain compares as its base. ClickHouse: the type family.
|
|
38
|
+
*/
|
|
39
|
+
readonly cmp: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A column reference branded with its SQL type, spelled as the catalog
|
|
44
|
+
* spells it. `Checked` is true for a column the generated declarations type,
|
|
45
|
+
* false for an unchecked one; it is a discriminant, so a slot's error names
|
|
46
|
+
* the checked member it wanted.
|
|
47
|
+
*/
|
|
48
|
+
export interface Column<SqlType extends string = string, T extends ColumnTraits = ColumnTraits, Checked extends boolean = true> extends AttrRef {
|
|
49
|
+
readonly [columnBrand]: { readonly sqlType: SqlType; readonly traits: T };
|
|
50
|
+
readonly [checkedBrand]: Checked;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A column of a relation the declarations do not cover: any name type-checks, and it fits every slot. */
|
|
54
|
+
export type UncheckedColumn = Column<string, ColumnTraits, false>;
|
|
55
|
+
|
|
56
|
+
/** Any column, checked or not: what a relation annotated without a name (`PostgresTable`) holds. */
|
|
57
|
+
export type AnyColumn = Column<string, ColumnTraits, boolean>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What a slot takes besides its checked column: an unchecked relation's
|
|
61
|
+
* column, which reads as `| undefined` under `noUncheckedIndexedAccess`. A
|
|
62
|
+
* checked relation's columns are named properties, so nothing of theirs gets
|
|
63
|
+
* through here.
|
|
64
|
+
*/
|
|
65
|
+
type Unchecked = UncheckedColumn | undefined;
|
|
66
|
+
|
|
67
|
+
/** A `${}` that must be a column comparing as `Cmp`: the referenced column of a foreign key. */
|
|
68
|
+
export type KeyColumn<Cmp extends string> = Unchecked | Column<string, { readonly ts: unknown; readonly nullable: boolean; readonly cmp: Cmp }>;
|
|
69
|
+
|
|
70
|
+
/** A `${}` that must be a column that cannot be NULL: an element of a ClickHouse sort key. */
|
|
71
|
+
export type NotNullColumn = Unchecked | Column<string, { readonly ts: unknown; readonly nullable: false; readonly cmp: string }>;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The name a relation's type carries when its tag has no type argument. Its
|
|
75
|
+
* columns are unchecked, and `RowOf` refuses it.
|
|
76
|
+
*/
|
|
77
|
+
export type Unnamed = string & { readonly [unnamedBrand]: true };
|
|
78
|
+
|
|
79
|
+
/** Carries a relation's declared name on its type, so `RowOf<typeof t>` can find its entry. */
|
|
80
|
+
export interface Named<N extends string> {
|
|
81
|
+
readonly [relationName]?: N;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The columns of relation `N` in `Registry` (a dialect's `SqlSchema`):
|
|
86
|
+
* unchecked for an {@link Unnamed} relation or a name the registry does not
|
|
87
|
+
* list, any column for the bare annotation (`N` is `string`), and the
|
|
88
|
+
* entry's own otherwise.
|
|
89
|
+
*/
|
|
90
|
+
export type ColumnsIn<Registry, N extends string> = [N] extends [Unnamed]
|
|
91
|
+
? Readonly<Record<string, UncheckedColumn>>
|
|
92
|
+
: string extends N
|
|
93
|
+
? Readonly<Record<string, AnyColumn>>
|
|
94
|
+
: N extends keyof Registry
|
|
95
|
+
? Registry[N] extends { readonly columns: infer C } ? C : never
|
|
96
|
+
: Readonly<Record<string, UncheckedColumn>>;
|
|
97
|
+
|
|
98
|
+
/** The interpolations a tag for relation `N` takes: the entry's slots, then anything. */
|
|
99
|
+
export type SlotsIn<Registry, N extends string> = N extends keyof Registry
|
|
100
|
+
? Registry[N] extends { readonly slots: infer S extends readonly unknown[] } ? [...S, ...unknown[]] : unknown[]
|
|
101
|
+
: unknown[];
|
|
102
|
+
|
|
103
|
+
type RowFromColumns<C> = {
|
|
104
|
+
-readonly [K in keyof C]: C[K] extends Column<string, infer T> ? (T["nullable"] extends true ? T["ts"] | null : T["ts"]) : unknown;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** A relation's row: `T` is its name or a relation's type carrying one. */
|
|
108
|
+
export type RowIn<Registry, T> = T extends string
|
|
109
|
+
? RowFromColumns<ColumnsIn<Registry, T>>
|
|
110
|
+
: T extends Named<infer N> ? RowFromColumns<ColumnsIn<Registry, N>> : never;
|
|
111
|
+
|
|
112
|
+
// ── The slot record ─────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* One `${}` the generator types. `references`: a column in a foreign key's
|
|
116
|
+
* referenced column list, paired with `column`, the referencing table's own
|
|
117
|
+
* column at that position. `sortKey`: a bare element of a ClickHouse
|
|
118
|
+
* `ORDER BY` or `PRIMARY KEY`.
|
|
119
|
+
*/
|
|
120
|
+
export type TypedSlot = { readonly index: number; readonly kind: "references"; readonly column: string } | { readonly index: number; readonly kind: "sortKey" };
|
|
121
|
+
|
|
122
|
+
const TYPED_SLOTS = Symbol.for("chant.sql.typedSlots");
|
|
123
|
+
|
|
124
|
+
/** Record the typed slots on an entity: non-enumerable and symbol-keyed, so no serializer sees it. */
|
|
125
|
+
export function setTypedSlots(entity: object, slots: readonly TypedSlot[]): void {
|
|
126
|
+
if (slots.length === 0 || !Object.isExtensible(entity)) return;
|
|
127
|
+
Object.defineProperty(entity, TYPED_SLOTS, { value: Object.freeze([...slots]), enumerable: false, writable: false, configurable: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** What {@link setTypedSlots} recorded on an entity; empty when nothing was. */
|
|
131
|
+
export function getTypedSlots(entity: object): readonly TypedSlot[] {
|
|
132
|
+
return ((entity as Record<symbol, unknown>)[TYPED_SLOTS] as readonly TypedSlot[] | undefined) ?? [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A relation's key in `SqlSchema`: `schema.name` (Postgres) or
|
|
137
|
+
* `database.name` (ClickHouse), unquoted and with Postgres's folding
|
|
138
|
+
* applied, or the name alone when the DDL does not qualify it.
|
|
139
|
+
*/
|
|
140
|
+
export function relationKey(props: { readonly schema?: string; readonly database?: string; readonly name: string }): string {
|
|
141
|
+
const qualifier = props.schema ?? props.database;
|
|
142
|
+
return qualifier ? `${qualifier}.${props.name}` : props.name;
|
|
143
|
+
}
|
|
@@ -34,6 +34,12 @@ export const sqlAuditCatalog: Record<string, RuleMeta> = {
|
|
|
34
34
|
"A column interpolated without .columns reads the entity's own field instead",
|
|
35
35
|
"Write ${table.columns.name}.",
|
|
36
36
|
),
|
|
37
|
+
SQLCH004: sourceRule(
|
|
38
|
+
"SQLCH004",
|
|
39
|
+
"correctness",
|
|
40
|
+
"A typed ClickHouse tag's type argument does not name the object its template declares, or is not in sql-types.d.ts",
|
|
41
|
+
"Make the type argument the qualified name the template declares, then run chant sql types.",
|
|
42
|
+
),
|
|
37
43
|
SQLPG001: sourceRule(
|
|
38
44
|
"SQLPG001",
|
|
39
45
|
"correctness",
|
|
@@ -58,6 +64,12 @@ export const sqlAuditCatalog: Record<string, RuleMeta> = {
|
|
|
58
64
|
"A declared extension the configured Postgres provider does not allow",
|
|
59
65
|
"Use an extension the provider lists, or set sql.provider to the service the project deploys to.",
|
|
60
66
|
),
|
|
67
|
+
SQLPG005: sourceRule(
|
|
68
|
+
"SQLPG005",
|
|
69
|
+
"correctness",
|
|
70
|
+
"A typed Postgres tag's type argument does not name the object its template declares, or is not in sql-types.d.ts",
|
|
71
|
+
"Make the type argument the qualified name the template declares, then run chant sql types.",
|
|
72
|
+
),
|
|
61
73
|
SQLCH101: auditRule(
|
|
62
74
|
"SQLCH101",
|
|
63
75
|
"merge-worthy",
|
|
@@ -74,6 +86,14 @@ export const sqlAuditCatalog: Record<string, RuleMeta> = {
|
|
|
74
86
|
"Keep one declaration of the object and import it where the other was used.",
|
|
75
87
|
{ category: "correctness" },
|
|
76
88
|
),
|
|
89
|
+
SQL102: auditRule(
|
|
90
|
+
"SQL102",
|
|
91
|
+
"merge-worthy",
|
|
92
|
+
"guidance",
|
|
93
|
+
"sql-types.d.ts differs from what the build declares",
|
|
94
|
+
"Run chant sql types to rewrite the file.",
|
|
95
|
+
{ category: "correctness" },
|
|
96
|
+
),
|
|
77
97
|
...sqlPostSynthAuditEntries,
|
|
78
98
|
...postgresPostSynthAuditEntries,
|
|
79
99
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Code generated by chant generate. DO NOT EDIT.
|
|
2
2
|
import type { PostSynthCheck } from "@intentius/chant/lint/post-synth";
|
|
3
3
|
import { sql101 } from "./sql101";
|
|
4
|
+
import { sql102 } from "./sql102";
|
|
4
5
|
import { sqlch101 } from "./sqlch101";
|
|
5
6
|
import { sqlch102 } from "./sqlch102";
|
|
6
7
|
import { sqlch103 } from "./sqlch103";
|
|
@@ -57,6 +58,7 @@ import { sqlpg126 } from "./sqlpg126";
|
|
|
57
58
|
|
|
58
59
|
export const postSynthChecks: PostSynthCheck[] = [
|
|
59
60
|
sql101,
|
|
61
|
+
sql102,
|
|
60
62
|
sqlch101,
|
|
61
63
|
sqlch102,
|
|
62
64
|
sqlch103,
|
|
@@ -120,12 +120,16 @@ export interface PgProject {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
export function projectOf(ctx: PostSynthContext): PgProject {
|
|
123
|
-
|
|
123
|
+
return projectFromObjects(postgresObjects(ctx), targetMajor(ctx));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The project a set of output objects declares, at `major`: for a reader that holds the build output itself (`chant sql types`). */
|
|
127
|
+
export function projectFromObjects(objects: OutputObject[], major: number): PgProject {
|
|
124
128
|
const byExport = new Map(objects.map((o) => [o.export, o]));
|
|
125
129
|
const schemas = new Set(objects.filter((o) => o.type === "Postgres::Schema").map((o) => o.name));
|
|
126
130
|
const exts = objects.filter((o) => o.type === "Postgres::Extension");
|
|
127
131
|
return {
|
|
128
|
-
major
|
|
132
|
+
major,
|
|
129
133
|
objects,
|
|
130
134
|
byExport,
|
|
131
135
|
schemas,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PostSynthCheck } from "@intentius/chant/lint/post-synth";
|
|
2
|
+
import { sqlTypesPath, sqlTypesState } from "../../sql-types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SQL102: `sql-types.d.ts` no longer matches what the build declares.
|
|
6
|
+
*
|
|
7
|
+
* tsc checks a typed tag's columns, and the foreign-key and sort-key slots,
|
|
8
|
+
* against the generated file, never against the template text, so a stale file
|
|
9
|
+
* keeps checking the old types. The check renders the declarations in memory
|
|
10
|
+
* (the render `chant sql types` writes) and compares them with the file in the
|
|
11
|
+
* project's source directory. Silent when the context carries no project
|
|
12
|
+
* directory (a hand-built one) or the file does not exist, so a project that
|
|
13
|
+
* has not adopted typed columns, or keeps the file elsewhere, sees nothing.
|
|
14
|
+
* `chant sql types --check` is the same comparison without a build, and a
|
|
15
|
+
* workspace `generated` entry runs it under `chant workspace check --generated`.
|
|
16
|
+
*/
|
|
17
|
+
export const sql102: PostSynthCheck = {
|
|
18
|
+
id: "SQL102",
|
|
19
|
+
description: "sql-types.d.ts differs from what the build declares",
|
|
20
|
+
check(ctx) {
|
|
21
|
+
if (!ctx.projectDir) return [];
|
|
22
|
+
const file = sqlTypesPath(ctx.projectDir, ctx.projectConfig);
|
|
23
|
+
const state = sqlTypesState(ctx.entities, file, { projectDir: ctx.projectDir, config: ctx.projectConfig });
|
|
24
|
+
if (state !== "stale") return [];
|
|
25
|
+
return [
|
|
26
|
+
{
|
|
27
|
+
checkId: "SQL102",
|
|
28
|
+
severity: "error",
|
|
29
|
+
message: `${file} differs from what the build declares, so tsc checks columns against out-of-date types; run \`chant sql types\` to rewrite it`,
|
|
30
|
+
lexicon: "sql",
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
},
|
|
34
|
+
};
|
package/src/lint/rules/index.ts
CHANGED
|
@@ -2,12 +2,14 @@ import type { LintRule } from "@intentius/chant/lint/rule";
|
|
|
2
2
|
import { sqlch001 } from "./sqlch001";
|
|
3
3
|
import { sqlch002 } from "./sqlch002";
|
|
4
4
|
import { sqlch003 } from "./sqlch003";
|
|
5
|
+
import { sqlch004 } from "./sqlch004";
|
|
5
6
|
import { sqlpg001 } from "./sqlpg001";
|
|
6
7
|
import { sqlpg002 } from "./sqlpg002";
|
|
7
8
|
import { sqlpg003 } from "./sqlpg003";
|
|
8
9
|
import { sqlpg004 } from "./sqlpg004";
|
|
10
|
+
import { sqlpg005 } from "./sqlpg005";
|
|
9
11
|
|
|
10
|
-
export { sqlch001, sqlch002, sqlch003, sqlpg001, sqlpg002, sqlpg003, sqlpg004 };
|
|
12
|
+
export { sqlch001, sqlch002, sqlch003, sqlch004, sqlpg001, sqlpg002, sqlpg003, sqlpg004, sqlpg005 };
|
|
11
13
|
|
|
12
14
|
/** The lexicon's source-level lint rules, returned by `lintRules()`. */
|
|
13
|
-
export const rules: LintRule[] = [sqlch001, sqlch002, sqlch003, sqlpg001, sqlpg002, sqlpg003, sqlpg004];
|
|
15
|
+
export const rules: LintRule[] = [sqlch001, sqlch002, sqlch003, sqlch004, sqlpg001, sqlpg002, sqlpg003, sqlpg004, sqlpg005];
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { LintContext, LintDiagnostic, LintRule } from "@intentius/chant/lint/rule";
|
|
2
|
+
import { parseCreate, unquote } from "../../clickhouse/parser";
|
|
3
|
+
import { isTrivia, SqlSyntaxError } from "../../clickhouse/tokens";
|
|
4
|
+
import { findTemplates, tokensOf } from "./templates";
|
|
5
|
+
import { declaredText, generatedNamesFor, nameAgrees, normalizedName, SQL_TYPES_FILE, typeArgumentName } from "./typed-names";
|
|
6
|
+
|
|
7
|
+
const MODULE = "@intentius/chant-lexicon-sql/clickhouse";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* SQLCH004: a typed ClickHouse tag whose type argument does not name the
|
|
11
|
+
* object its template declares, or is not in the generated `sql-types.d.ts`.
|
|
12
|
+
*
|
|
13
|
+
* `table<"shop.events">\`CREATE TABLE shop.events ...\``, and the same for
|
|
14
|
+
* `view` and `dictionary`, types `.columns` from the generated file's
|
|
15
|
+
* `SqlSchema["shop.events"]`. tsc does not compare the argument with the
|
|
16
|
+
* template's text, and an argument the file does not list falls back to
|
|
17
|
+
* unchecked columns, so a typo checks nothing and says nothing. The rule reads
|
|
18
|
+
* the declared name from the template (a database supplied by an interpolation
|
|
19
|
+
* is not known to lint, and is left out of the comparison) and the listed
|
|
20
|
+
* names from the nearest `sql-types.d.ts` above the file. With no generated
|
|
21
|
+
* file, only the first check runs. A name missing from the file is a warning,
|
|
22
|
+
* since an object added since the last `chant sql types` is stale and
|
|
23
|
+
* `chant sql types --check` is what fails on it.
|
|
24
|
+
*/
|
|
25
|
+
export const sqlch004: LintRule = {
|
|
26
|
+
id: "SQLCH004",
|
|
27
|
+
severity: "error",
|
|
28
|
+
category: "correctness",
|
|
29
|
+
description: "A ClickHouse tag's type argument does not name the object its template declares, or is not in sql-types.d.ts",
|
|
30
|
+
|
|
31
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
32
|
+
const source = context.sourceFile;
|
|
33
|
+
const out: LintDiagnostic[] = [];
|
|
34
|
+
let listed: Set<string> | undefined | null = null;
|
|
35
|
+
for (const found of findTemplates(source)) {
|
|
36
|
+
if (found.tag !== "table" && found.tag !== "view" && found.tag !== "dictionary") continue;
|
|
37
|
+
const arg = typeArgumentName(found.node);
|
|
38
|
+
if (!arg) continue;
|
|
39
|
+
const at = (() => {
|
|
40
|
+
const { line, character } = source.getLineAndCharacterOfPosition(arg.node.getStart(source));
|
|
41
|
+
return { line: line + 1, column: character + 1 };
|
|
42
|
+
})();
|
|
43
|
+
let declared: string[] | undefined;
|
|
44
|
+
try {
|
|
45
|
+
const tokens = tokensOf(found);
|
|
46
|
+
const node = parseCreate(tokens);
|
|
47
|
+
if (node.statement === found.tag) {
|
|
48
|
+
declared = tokens
|
|
49
|
+
.slice(node.name.from, node.name.to)
|
|
50
|
+
.filter((t) => !isTrivia(t) && !(t.kind === "punct" && t.text === "."))
|
|
51
|
+
.map((t) => (t.kind === "ref" ? "" : unquote(t.text)));
|
|
52
|
+
}
|
|
53
|
+
} catch (err) {
|
|
54
|
+
if (err instanceof SqlSyntaxError) continue; // SQLCH001 reports it
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
if (!declared) continue; // SQLCH001: the tag holds another statement
|
|
58
|
+
if (!nameAgrees(declared, arg.name)) {
|
|
59
|
+
out.push({
|
|
60
|
+
ruleId: "SQLCH004",
|
|
61
|
+
severity: "error",
|
|
62
|
+
message: `${found.tag}<"${arg.name}"> names another object than the template declares (${declaredText(declared)}); the type argument is the qualified name the build renders`,
|
|
63
|
+
file: context.filePath,
|
|
64
|
+
...at,
|
|
65
|
+
});
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (listed === null) listed = generatedNamesFor(context.filePath, MODULE);
|
|
69
|
+
if (listed && !listed.has(normalizedName(arg.name))) {
|
|
70
|
+
out.push({
|
|
71
|
+
ruleId: "SQLCH004",
|
|
72
|
+
severity: "warning",
|
|
73
|
+
message: `"${arg.name}" is not in ${SQL_TYPES_FILE}, so its columns are unchecked; run \`chant sql types\` to add it`,
|
|
74
|
+
file: context.filePath,
|
|
75
|
+
...at,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { LintContext, LintDiagnostic, LintRule } from "@intentius/chant/lint/rule";
|
|
2
|
+
import { parseStatements, SqlSyntaxError } from "../../postgres/parser";
|
|
3
|
+
import { findPostgresTemplates, postgresTokensOf } from "./postgres-templates";
|
|
4
|
+
import { declaredText, generatedNamesFor, nameAgrees, normalizedName, SQL_TYPES_FILE, typeArgumentName } from "./typed-names";
|
|
5
|
+
|
|
6
|
+
const MODULE = "@intentius/chant-lexicon-sql/postgres";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* SQLPG005: a typed Postgres tag whose type argument does not name the object
|
|
10
|
+
* its template declares, or is not in the generated `sql-types.d.ts`.
|
|
11
|
+
*
|
|
12
|
+
* `table<"shop.orders">\`CREATE TABLE shop.orders ...\`` types `.columns` from
|
|
13
|
+
* the generated file's `SqlSchema["shop.orders"]`. tsc does not compare the
|
|
14
|
+
* argument with the template's text, and an argument the file does not list
|
|
15
|
+
* falls back to unchecked columns, so a typo checks nothing and says nothing.
|
|
16
|
+
* The rule reads the declared name from the template (a schema supplied by an
|
|
17
|
+
* interpolation is not known to lint, and is left out of the comparison) and
|
|
18
|
+
* the listed names from the nearest `sql-types.d.ts` above the file. With no
|
|
19
|
+
* generated file, only the first check runs. A name missing from the file is
|
|
20
|
+
* a warning, since a table added since the last `chant sql types` is stale
|
|
21
|
+
* and `chant sql types --check` is what fails on it.
|
|
22
|
+
*/
|
|
23
|
+
export const sqlpg005: LintRule = {
|
|
24
|
+
id: "SQLPG005",
|
|
25
|
+
severity: "error",
|
|
26
|
+
category: "correctness",
|
|
27
|
+
description: "A Postgres tag's type argument does not name the object its template declares, or is not in sql-types.d.ts",
|
|
28
|
+
|
|
29
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
30
|
+
const source = context.sourceFile;
|
|
31
|
+
const out: LintDiagnostic[] = [];
|
|
32
|
+
let listed: Set<string> | undefined | null = null;
|
|
33
|
+
for (const found of findPostgresTemplates(source)) {
|
|
34
|
+
if (found.tag !== "table" && found.tag !== "view") continue;
|
|
35
|
+
const arg = typeArgumentName(found.node);
|
|
36
|
+
if (!arg) continue;
|
|
37
|
+
const at = (() => {
|
|
38
|
+
const { line, character } = source.getLineAndCharacterOfPosition(arg.node.getStart(source));
|
|
39
|
+
return { line: line + 1, column: character + 1 };
|
|
40
|
+
})();
|
|
41
|
+
let declared: string[] | undefined;
|
|
42
|
+
try {
|
|
43
|
+
const node = parseStatements(postgresTokensOf(found))[0];
|
|
44
|
+
if (node && (node.statement === "table" || node.statement === "view")) declared = node.name.pieces;
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err instanceof SqlSyntaxError) continue; // SQLPG001 reports it
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
if (!declared) continue; // SQLPG001: the tag holds another statement
|
|
50
|
+
if (!nameAgrees(declared, arg.name)) {
|
|
51
|
+
out.push({
|
|
52
|
+
ruleId: "SQLPG005",
|
|
53
|
+
severity: "error",
|
|
54
|
+
message: `${found.tag}<"${arg.name}"> names another object than the template declares (${declaredText(declared)}); the type argument is the qualified name the build renders`,
|
|
55
|
+
file: context.filePath,
|
|
56
|
+
...at,
|
|
57
|
+
});
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (listed === null) listed = generatedNamesFor(context.filePath, MODULE);
|
|
61
|
+
if (listed && !listed.has(normalizedName(arg.name))) {
|
|
62
|
+
out.push({
|
|
63
|
+
ruleId: "SQLPG005",
|
|
64
|
+
severity: "warning",
|
|
65
|
+
message: `"${arg.name}" is not in ${SQL_TYPES_FILE}, so its columns are unchecked; run \`chant sql types\` to add it`,
|
|
66
|
+
file: context.filePath,
|
|
67
|
+
...at,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
2
|
+
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { describe, expect, test } from "vitest";
|
|
6
|
+
import type { LintContext } from "@intentius/chant/lint/rule";
|
|
7
|
+
import { sqlch004 } from "./sqlch004";
|
|
8
|
+
import { sqlpg005 } from "./sqlpg005";
|
|
9
|
+
import { nameAgrees, namesInGenerated, normalizedName, splitQualified } from "./typed-names";
|
|
10
|
+
|
|
11
|
+
const PG = "@intentius/chant-lexicon-sql/postgres";
|
|
12
|
+
const CH = "@intentius/chant-lexicon-sql/clickhouse";
|
|
13
|
+
|
|
14
|
+
const ctx = (code: string, filePath = "schema.ts"): LintContext => ({
|
|
15
|
+
sourceFile: ts.createSourceFile(filePath, code, ts.ScriptTarget.Latest, true),
|
|
16
|
+
entities: [],
|
|
17
|
+
filePath,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const generated = (module: string, names: string[]): string =>
|
|
21
|
+
`// Generated by chant — do not edit directly.\nimport type { Column } from "${module}";\n\ndeclare module "${module}" {\n interface SqlSchema {\n` +
|
|
22
|
+
names.map((n) => ` /** src/schema.ts */\n ${JSON.stringify(n)}: { columns: { id: Column<"bigint", { ts: string; nullable: false; cmp: "N" }> } };\n`).join("") +
|
|
23
|
+
" }\n}\n";
|
|
24
|
+
|
|
25
|
+
/** A project directory with the generated file in src/ and a source file under src/db/. */
|
|
26
|
+
function project(module: string, names: string[] | undefined): string {
|
|
27
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-3751-")));
|
|
28
|
+
mkdirSync(join(root, "src", "db"), { recursive: true });
|
|
29
|
+
writeFileSync(join(root, "chant.config.json"), "{}");
|
|
30
|
+
if (names) writeFileSync(join(root, "src", "sql-types.d.ts"), generated(module, names));
|
|
31
|
+
return join(root, "src", "db", "schema.ts");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("typed-names helpers", () => {
|
|
35
|
+
test("splitQualified takes quotes off and splits outside them", () => {
|
|
36
|
+
expect(splitQualified('shop."Order.Items"')).toEqual(["shop", "Order.Items"]);
|
|
37
|
+
expect(splitQualified("orders")).toEqual(["orders"]);
|
|
38
|
+
expect(normalizedName('"shop"."orders"')).toBe(normalizedName("shop.orders"));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("nameAgrees compares from the last piece and skips interpolations", () => {
|
|
42
|
+
expect(nameAgrees(["shop", "orders"], "shop.orders")).toBe(true);
|
|
43
|
+
expect(nameAgrees(["orders"], "shop.orders")).toBe(true);
|
|
44
|
+
expect(nameAgrees(["", "orders"], "shop.orders")).toBe(true);
|
|
45
|
+
expect(nameAgrees(["shop", "orders"], "orders")).toBe(false);
|
|
46
|
+
expect(nameAgrees(["shop", "orders"], "shop.ordrs")).toBe(false);
|
|
47
|
+
expect(nameAgrees(["other", "orders"], "shop.orders")).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("namesInGenerated reads only the module's SqlSchema", () => {
|
|
51
|
+
const text = generated(PG, ["shop.orders", "shop.users"]);
|
|
52
|
+
expect(namesInGenerated(text, PG)).toEqual(new Set([normalizedName("shop.orders"), normalizedName("shop.users")]));
|
|
53
|
+
expect(namesInGenerated(text, CH)).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const PGX = `import { table, view } from "${PG}";\n`;
|
|
58
|
+
const CHX = `import { table, view, dictionary } from "${CH}";\n`;
|
|
59
|
+
|
|
60
|
+
describe("SQLPG005: a typed Postgres tag's type argument", () => {
|
|
61
|
+
test("a name that agrees with the template, with no generated file, is clean", () => {
|
|
62
|
+
const code = `${PGX}export const o = table<"shop.orders">\`CREATE TABLE shop.orders (id bigint)\`;`;
|
|
63
|
+
expect(sqlpg005.check(ctx(code))).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a different name is an error, at the type argument", () => {
|
|
67
|
+
const code = `${PGX}export const o = table<"shop.ordrs">\`CREATE TABLE shop.orders (id bigint)\`;`;
|
|
68
|
+
const [d, ...rest] = sqlpg005.check(ctx(code));
|
|
69
|
+
expect(rest).toEqual([]);
|
|
70
|
+
expect(d).toMatchObject({ ruleId: "SQLPG005", severity: "error", line: 2, column: 24 });
|
|
71
|
+
expect(d!.message).toContain("shop.orders");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a schema that is an interpolation is left out of the comparison", () => {
|
|
75
|
+
const ok = `${PGX}import { shopSchema } from "./s";\nexport const o = table<"shop.orders">\`CREATE TABLE \${shopSchema}.orders (id bigint)\`;`;
|
|
76
|
+
expect(sqlpg005.check(ctx(ok))).toEqual([]);
|
|
77
|
+
const bad = ok.replace("shop.orders", "shop.items");
|
|
78
|
+
expect(sqlpg005.check(ctx(bad))).toHaveLength(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("a view is read too, and quoting and case folding follow Postgres", () => {
|
|
82
|
+
const ok = `${PGX}export const v = view<"app.order_totals">\`CREATE VIEW App."order_totals" AS SELECT 1\`;`;
|
|
83
|
+
expect(sqlpg005.check(ctx(ok))).toEqual([]);
|
|
84
|
+
const bad = `${PGX}export const v = view<"app.Order_Totals">\`CREATE VIEW app.order_totals AS SELECT 1\`;`;
|
|
85
|
+
expect(sqlpg005.check(ctx(bad))).toHaveLength(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("a tag with no type argument, or a non-literal one, is not read", () => {
|
|
89
|
+
expect(sqlpg005.check(ctx(`${PGX}export const o = table\`CREATE TABLE shop.orders (id bigint)\`;`))).toEqual([]);
|
|
90
|
+
expect(sqlpg005.check(ctx(`${PGX}export const o = table<string>\`CREATE TABLE shop.orders (id bigint)\`;`))).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a tag from another module, or the ClickHouse tag, is not read", () => {
|
|
94
|
+
expect(sqlpg005.check(ctx(`import { table } from "./mine";\nexport const o = table<"x.y">\`not sql\`;`))).toEqual([]);
|
|
95
|
+
expect(sqlpg005.check(ctx(`${CHX}export const o = table<"a.b">\`CREATE TABLE c.d (id UInt64) ENGINE = Log\`;`))).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a template that does not parse is SQLPG001's to report", () => {
|
|
99
|
+
expect(sqlpg005.check(ctx(`${PGX}export const o = table<"a.b">\`CREATE TABLE\`;`))).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("a name the generated file lacks is a warning; one it lists is clean", () => {
|
|
103
|
+
const file = project(PG, ["shop.users"]);
|
|
104
|
+
const code = `${PGX}export const o = table<"shop.orders">\`CREATE TABLE shop.orders (id bigint)\`;\nexport const u = table<"shop.users">\`CREATE TABLE shop.users (id bigint)\`;`;
|
|
105
|
+
const diags = sqlpg005.check(ctx(code, file));
|
|
106
|
+
expect(diags).toHaveLength(1);
|
|
107
|
+
expect(diags[0]).toMatchObject({ ruleId: "SQLPG005", severity: "warning", line: 2 });
|
|
108
|
+
expect(diags[0]!.message).toMatch(/not in sql-types\.d\.ts.*chant sql types/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a generated file with no block for the dialect is silent", () => {
|
|
112
|
+
const file = project(CH, ["shop.events"]);
|
|
113
|
+
const code = `${PGX}export const o = table<"shop.orders">\`CREATE TABLE shop.orders (id bigint)\`;`;
|
|
114
|
+
expect(sqlpg005.check(ctx(code, file))).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("SQLCH004: a typed ClickHouse tag's type argument", () => {
|
|
119
|
+
const ddl = (db: string) => `CREATE TABLE ${db}.events (id UInt64) ENGINE = Log`;
|
|
120
|
+
|
|
121
|
+
test("a name that agrees is clean; a different one is an error", () => {
|
|
122
|
+
expect(sqlch004.check(ctx(`${CHX}export const e = table<"shop.events">\`${ddl("shop")}\`;`))).toEqual([]);
|
|
123
|
+
const [d] = sqlch004.check(ctx(`${CHX}export const e = table<"shop.event">\`${ddl("shop")}\`;`));
|
|
124
|
+
expect(d).toMatchObject({ ruleId: "SQLCH004", severity: "error", line: 2 });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("a database that is an interpolation is left out, and a name differing in case is a different name", () => {
|
|
128
|
+
expect(sqlch004.check(ctx(`${CHX}import { db } from "./d";\nexport const e = table<"shop.events">\`${ddl("${db}")}\`;`))).toEqual([]);
|
|
129
|
+
expect(sqlch004.check(ctx(`${CHX}export const e = table<"shop.Events">\`${ddl("shop")}\`;`))).toHaveLength(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("a dictionary and a view are read", () => {
|
|
133
|
+
const dict = `${CHX}export const d = dictionary<"shop.codes">\`CREATE DICTIONARY shop.codes (id UInt64) PRIMARY KEY id SOURCE(CLICKHOUSE(TABLE 'c')) LAYOUT(FLAT()) LIFETIME(0)\`;`;
|
|
134
|
+
expect(sqlch004.check(ctx(dict))).toEqual([]);
|
|
135
|
+
expect(sqlch004.check(ctx(dict.replace("shop.codes\"", "shop.cods\"")))).toHaveLength(1);
|
|
136
|
+
const view = `${CHX}export const v = view<"shop.v1">\`CREATE VIEW shop.v2 AS SELECT 1\`;`;
|
|
137
|
+
expect(sqlch004.check(ctx(view))).toHaveLength(1);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("a name the generated file lacks is a warning", () => {
|
|
141
|
+
const file = project(CH, ["shop.other"]);
|
|
142
|
+
const [d] = sqlch004.check(ctx(`${CHX}export const e = table<"shop.events">\`${ddl("shop")}\`;`, file));
|
|
143
|
+
expect(d).toMatchObject({ ruleId: "SQLCH004", severity: "warning" });
|
|
144
|
+
expect(sqlch004.check(ctx(`${CHX}export const e = table<"shop.other">\`CREATE TABLE shop.other (id UInt64) ENGINE = Log\`;`, file))).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("without a generated file only the name comparison runs", () => {
|
|
148
|
+
const file = project(CH, undefined);
|
|
149
|
+
expect(sqlch004.check(ctx(`${CHX}export const e = table<"shop.events">\`${ddl("shop")}\`;`, file))).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
});
|