@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.
Files changed (81) hide show
  1. package/README.md +1 -1
  2. package/dist/clickhouse/entities.d.ts +37 -11
  3. package/dist/clickhouse/entities.d.ts.map +1 -1
  4. package/dist/clickhouse/import/generator.d.ts.map +1 -1
  5. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  6. package/dist/clickhouse/row-types.d.ts +30 -0
  7. package/dist/clickhouse/row-types.d.ts.map +1 -0
  8. package/dist/clickhouse.d.ts +1 -1
  9. package/dist/clickhouse.d.ts.map +1 -1
  10. package/dist/core/index.d.ts +4 -0
  11. package/dist/core/index.d.ts.map +1 -1
  12. package/dist/core/typed.d.ts +132 -0
  13. package/dist/core/typed.d.ts.map +1 -0
  14. package/dist/integrity.json +7 -3
  15. package/dist/lint/audit-catalog.d.ts.map +1 -1
  16. package/dist/lint/post-synth/index.d.ts.map +1 -1
  17. package/dist/lint/post-synth/postgres-names.d.ts +2 -0
  18. package/dist/lint/post-synth/postgres-names.d.ts.map +1 -1
  19. package/dist/lint/post-synth/sql102.d.ts +16 -0
  20. package/dist/lint/post-synth/sql102.d.ts.map +1 -0
  21. package/dist/lint/rules/index.d.ts +3 -1
  22. package/dist/lint/rules/index.d.ts.map +1 -1
  23. package/dist/lint/rules/sqlch004.d.ts +19 -0
  24. package/dist/lint/rules/sqlch004.d.ts.map +1 -0
  25. package/dist/lint/rules/sqlpg005.d.ts +18 -0
  26. package/dist/lint/rules/sqlpg005.d.ts.map +1 -0
  27. package/dist/lint/rules/typed-names.d.ts +43 -0
  28. package/dist/lint/rules/typed-names.d.ts.map +1 -0
  29. package/dist/manifest.json +1 -1
  30. package/dist/okf/index.md +3 -0
  31. package/dist/okf/rules/SQL102.md +11 -0
  32. package/dist/okf/rules/SQLCH004.md +11 -0
  33. package/dist/okf/rules/SQLPG005.md +11 -0
  34. package/dist/postgres/entities.d.ts +38 -9
  35. package/dist/postgres/entities.d.ts.map +1 -1
  36. package/dist/postgres/import/generator.d.ts.map +1 -1
  37. package/dist/postgres/row-types.d.ts +17 -0
  38. package/dist/postgres/row-types.d.ts.map +1 -0
  39. package/dist/postgres/testing/migration.d.ts +3 -2
  40. package/dist/postgres/testing/migration.d.ts.map +1 -1
  41. package/dist/postgres.d.ts +1 -1
  42. package/dist/postgres.d.ts.map +1 -1
  43. package/dist/rules/postgres-names.ts +6 -2
  44. package/dist/rules/sql102.ts +34 -0
  45. package/dist/rules/sqlch004.ts +81 -0
  46. package/dist/rules/sqlpg005.ts +73 -0
  47. package/dist/rules/typed-names.ts +120 -0
  48. package/dist/sql-types-command.d.ts +19 -0
  49. package/dist/sql-types-command.d.ts.map +1 -0
  50. package/dist/sql-types.d.ts +53 -0
  51. package/dist/sql-types.d.ts.map +1 -0
  52. package/package.json +2 -2
  53. package/src/clickhouse/dictionary.test.ts +1 -1
  54. package/src/clickhouse/entities.ts +79 -14
  55. package/src/clickhouse/import/generator.ts +7 -1
  56. package/src/clickhouse/import/roundtrip.test.ts +1 -1
  57. package/src/clickhouse/plan/commands.ts +5 -0
  58. package/src/clickhouse/row-types.ts +158 -0
  59. package/src/clickhouse.ts +10 -0
  60. package/src/core/index.ts +4 -0
  61. package/src/core/typed.ts +143 -0
  62. package/src/lint/audit-catalog.ts +20 -0
  63. package/src/lint/post-synth/index.ts +2 -0
  64. package/src/lint/post-synth/postgres-names.ts +6 -2
  65. package/src/lint/post-synth/sql102.ts +34 -0
  66. package/src/lint/rules/index.ts +4 -2
  67. package/src/lint/rules/sqlch004.ts +81 -0
  68. package/src/lint/rules/sqlpg005.ts +73 -0
  69. package/src/lint/rules/typed-names.test.ts +151 -0
  70. package/src/lint/rules/typed-names.ts +120 -0
  71. package/src/op/__snapshots__/watch-pipeline.test.ts.snap +3 -3
  72. package/src/postgres/entities.ts +65 -12
  73. package/src/postgres/import/generator.ts +7 -1
  74. package/src/postgres/row-types.ts +111 -0
  75. package/src/postgres.ts +10 -0
  76. package/src/sql-types-command.ts +99 -0
  77. package/src/sql-types-staleness.test.ts +110 -0
  78. package/src/sql-types.test.ts +132 -0
  79. package/src/sql-types.ts +352 -0
  80. package/src/typecheck.test.ts +12 -3
  81. 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
- const objects = postgresObjects(ctx);
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: targetMajor(ctx),
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
+ };
@@ -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
+ });