@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,19 @@
1
+ /**
2
+ * `chant sql types [--output <path>] [--check]` (#3751): build the project the way
3
+ * `chant build` does, folded, and write the declarations
4
+ * {@link renderSqlTypes} renders from its entities, so a tag given its
5
+ * qualified name as the type argument has typed columns.
6
+ *
7
+ * The file goes to `<sourceDir>/sql-types.d.ts` (the project root when
8
+ * `sourceDir` is unset), where any tsconfig that includes the schema includes
9
+ * it too. `--output` puts it elsewhere, as a workspace `generated` entry's
10
+ * generator does. The command refuses to write beside a `sql-types.ts`: tsc
11
+ * resolves the `.ts` first and would drop the declarations. `chant build`
12
+ * never writes the file. `--check` renders the same text and compares it with
13
+ * the file instead of writing it: exit 1, naming the file, when they differ.
14
+ */
15
+ import type { CommandGroupContext } from "@intentius/chant/cli/command-group";
16
+ /** The file tsc would read instead of `path`: `sql-types.ts` beside `sql-types.d.ts`. */
17
+ export declare function shadowingSource(path: string): string | undefined;
18
+ export declare function runTypes(ctx: CommandGroupContext): Promise<number>;
19
+ //# sourceMappingURL=sql-types-command.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sql-types-command.d.ts","sourceRoot":"","sources":["../src/sql-types-command.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oCAAoC,CAAC;AAqB9E,yFAAyF;AACzF,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAIhE;AAED,wBAAsB,QAAQ,CAAC,GAAG,EAAE,mBAAmB,GAAG,OAAO,CAAC,MAAM,CAAC,CAqDxE"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The declarations `chant sql types` writes (#3751): one `sql-types.d.ts`
3
+ * that augments the dialect's `SqlSchema` with every table, view and
4
+ * dictionary the build declares, keyed by qualified name.
5
+ *
6
+ * {@link renderSqlTypes} is pure: the entities of a build in, the file's text
7
+ * out. It reads the objects as the serializer writes them, so a `.sql`
8
+ * file's objects and a composite's members are covered exactly as the build
9
+ * covers them, and it reads each template's slot record (`core/typed.ts`) for
10
+ * the `${}` positions a foreign key or a sort key consumes.
11
+ *
12
+ * Each column is a `Column<sqlType, { ts, nullable, cmp }>`:
13
+ *
14
+ * - `sqlType` is the type as the catalog spells it (`bigint`,
15
+ * `timestamp(3) with time zone`, `Nullable(String)`);
16
+ * - `ts` is its value in application code (`./postgres/row-types.ts`,
17
+ * `./clickhouse/row-types.ts`);
18
+ * - `nullable` is whether the value can be NULL. A Postgres view's columns
19
+ * always can, as the server's own catalog says; a column whose type the
20
+ * generator cannot tell is `never`, which fits every slot;
21
+ * - `cmp` is how it compares in a key: SQLPG124's type identity in Postgres
22
+ * (`./lint/post-synth/postgres-names.ts`'s `typeIdentity`), the type
23
+ * family in ClickHouse; `never` when it cannot be told.
24
+ *
25
+ * A view's column that is one column reference (`${t.columns.c}`, `o.${t.columns.c}`)
26
+ * is that column's type; any other expression is `unknown`.
27
+ */
28
+ import type { Declarable } from "@intentius/chant/declarable";
29
+ import type { SqlDialect } from "./dialects.js";
30
+ /** The file `chant sql types` writes, in the project's source directory. */
31
+ export declare const SQL_TYPES_FILE = "sql-types.d.ts";
32
+ /**
33
+ * Where `chant sql types` writes the file by default: the project's
34
+ * `sourceDir`, or the project root when it is unset.
35
+ */
36
+ export declare function sqlTypesPath(projectDir: string, config: Record<string, unknown> | undefined): string;
37
+ /** The file on disk against what the build's entities render: `missing`, `stale` or `current`. */
38
+ export declare function sqlTypesState(entities: ReadonlyMap<string, Declarable>, file: string, opts?: RenderSqlTypesOptions): "missing" | "stale" | "current";
39
+ /** The module each dialect's `SqlSchema` is augmented in. */
40
+ export declare const SQL_TYPES_MODULE: Readonly<Record<SqlDialect, string>>;
41
+ export interface RenderSqlTypesOptions {
42
+ /** The directory each declaration's source file is named relative to. Default: the working directory. */
43
+ projectDir?: string;
44
+ /** The project's config: `sql.postgresMajor` for the catalog, `sql.dialect` when nothing is declared. */
45
+ config?: Record<string, unknown>;
46
+ }
47
+ /**
48
+ * The `sql-types.d.ts` for a build's entities: one module that augments the
49
+ * dialect's `SqlSchema` with each table, view and dictionary, in key order.
50
+ * Pure, so a check can render it in memory and compare it with the file.
51
+ */
52
+ export declare function renderSqlTypes(entities: ReadonlyMap<string, Declarable>, opts?: RenderSqlTypesOptions): string;
53
+ //# sourceMappingURL=sql-types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sql-types.d.ts","sourceRoot":"","sources":["../src/sql-types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAW9D,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAQ7C,4EAA4E;AAC5E,eAAO,MAAM,cAAc,mBAAmB,CAAC;AAE/C;;;GAGG;AACH,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,MAAM,CAGpG;AAED,kGAAkG;AAClG,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,UAAU,CAAC,EACzC,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE,qBAA0B,GAC/B,SAAS,GAAG,OAAO,GAAG,SAAS,CASjC;AAED,6DAA6D;AAC7D,eAAO,MAAM,gBAAgB,EAAE,QAAQ,CAAC,MAAM,CAAC,UAAU,EAAE,MAAM,CAAC,CAGjE,CAAC;AAEF,MAAM,WAAW,qBAAqB;IACpC,yGAAyG;IACzG,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,yGAAyG;IACzG,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAiOD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,UAAU,CAAC,EAAE,IAAI,GAAE,qBAA0B,GAAG,MAAM,CAiClH"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-sql",
3
- "version": "0.122.0",
3
+ "version": "0.123.0",
4
4
  "description": "SQL lexicon for chant — database schema as typed declarations, one dialect at a time; ClickHouse first, with types read from a pinned server's catalog",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -70,7 +70,7 @@
70
70
  "typescript": "^5.9.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@intentius/chant": "^0.122.0",
73
+ "@intentius/chant": "^0.123.0",
74
74
  "typescript": "^5.9.3"
75
75
  }
76
76
  }
@@ -171,7 +171,7 @@ describe("importing a dictionary", () => {
171
171
  ]);
172
172
  const [file] = new ClickHouseGenerator().generate(ir);
173
173
  expect(file!.content).toContain('import { database, dictionary } from "@intentius/chant-lexicon-sql/clickhouse";');
174
- expect(file!.content).toContain("export const ratesDict = dictionary`");
174
+ expect(file!.content).toContain(`export const ratesDict = dictionary<"shop.rates_dict">\``);
175
175
  expect(file!.content).toContain("CREATE DICTIONARY ${shopDb}.rates_dict");
176
176
  });
177
177
  });
@@ -46,10 +46,12 @@ import { feed, lineage, spanText as text, splice as spliceWith, templateSyntaxEr
46
46
  import { SQL_LEXICON, SqlObject, isColumnRefOf, isSqlObjectOf, makeSqlEntity } from "../core/entity";
47
47
  import type { LineageEdge } from "../core/references";
48
48
  import { SqlLiteral, SqlTemplateError, describeValue as describe, stripUnset as strip, templateParts } from "../core/template";
49
+ import { setTypedSlots, type ColumnsIn, type Named, type RowIn, type SlotsIn, type TypedSlot, type Unnamed } from "../core/typed";
49
50
 
50
51
  export { SQL_LEXICON } from "../core/entity";
51
52
  export type { LineageEdge } from "../core/references";
52
53
  export { SqlLiteral, SqlTemplateError, templateParts, unescapeTemplateDelimiters } from "../core/template";
54
+ export type { AnyColumn, Column, ColumnTraits, KeyColumn, Named, NotNullColumn, UncheckedColumn, Unnamed } from "../core/typed";
53
55
 
54
56
  export const CLICKHOUSE_ENTITY_TYPES = {
55
57
  database: "ClickHouse::Database",
@@ -198,18 +200,31 @@ export interface ClickHouseDatabase extends ClickHouseObject {
198
200
  readonly props: DatabaseProps;
199
201
  }
200
202
 
201
- /** A table or a view: something with columns that SQL can reference. */
202
- export interface ClickHouseRelation extends ClickHouseObject {
203
+ /**
204
+ * Every ClickHouse table, view and dictionary the project declares, keyed by
205
+ * its qualified name (`shop.events`). Empty here; the `sql-types.d.ts` that
206
+ * `chant sql types` writes augments it, and a tag given the name as its type
207
+ * argument reads its columns from there.
208
+ */
209
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- augmented by the generated declarations
210
+ export interface SqlSchema {}
211
+
212
+ /**
213
+ * A table or a view: something with columns that SQL can reference. `N` is
214
+ * its qualified name when its tag was given one (`` table<"shop.events">`...` ``):
215
+ * its columns are then the generated declarations' entry for that name.
216
+ */
217
+ export interface ClickHouseRelation<N extends string = string> extends ClickHouseObject, Named<N> {
203
218
  /** One reference per column, by name: `${events.columns.user_id}`. */
204
- readonly columns: Readonly<Record<string, AttrRef>>;
219
+ readonly columns: ColumnsIn<SqlSchema, N>;
205
220
  }
206
221
 
207
- export interface ClickHouseTable extends ClickHouseRelation {
222
+ export interface ClickHouseTable<N extends string = string> extends ClickHouseRelation<N> {
208
223
  readonly entityType: "ClickHouse::Table";
209
224
  readonly props: TableProps;
210
225
  }
211
226
 
212
- export interface ClickHouseView extends ClickHouseRelation {
227
+ export interface ClickHouseView<N extends string = string> extends ClickHouseRelation<N> {
213
228
  readonly entityType: "ClickHouse::View" | "ClickHouse::MaterializedView";
214
229
  readonly props: ViewProps;
215
230
  }
@@ -312,7 +327,7 @@ export interface ClickHouseGrant extends ClickHouseObject {
312
327
  }
313
328
 
314
329
  /** A dictionary: its attributes are columns `dictGet` reads, so SQL can reference them. */
315
- export interface ClickHouseDictionary extends ClickHouseRelation {
330
+ export interface ClickHouseDictionary<N extends string = string> extends ClickHouseRelation<N> {
316
331
  readonly entityType: "ClickHouse::Dictionary";
317
332
  readonly props: DictionaryProps;
318
333
  }
@@ -327,6 +342,14 @@ function makeEntity(
327
342
  return makeSqlEntity(ClickHouseObject.prototype as ClickHouseObject, entityType, sqlName, props, columnNames, dependsOn);
328
343
  }
329
344
 
345
+ /**
346
+ * A declared table's, view's or dictionary's row, for application code:
347
+ * `RowOf<typeof events>` for a declaration given its name, or
348
+ * `RowOf<"shop.users">` by name, which also covers an object only a `.sql`
349
+ * file declares.
350
+ */
351
+ export type RowOf<T extends keyof SqlSchema | Named<keyof SqlSchema & string>> = RowIn<SqlSchema, T>;
352
+
330
353
  export function isClickHouseObject(value: unknown): value is ClickHouseObject {
331
354
  return isSqlObjectOf(value, "ClickHouse::");
332
355
  }
@@ -444,6 +467,42 @@ function qualified(ctx: Ctx, span: Span): { database?: string; name: string } {
444
467
  return pieces.length >= 2 ? { database: pieces[pieces.length - 2], name: pieces[pieces.length - 1]! } : { name: pieces[0] ?? "" };
445
468
  }
446
469
 
470
+ /**
471
+ * The `${}` that stand alone as an element of a sort key (`ORDER BY ${t.columns.a}`,
472
+ * `ORDER BY (${t.columns.a}, b)`), each a column that must not be Nullable
473
+ * (#3751). An element inside an expression (`ifNull(${...}, '')`) is not one.
474
+ */
475
+ function sortKeySlots(ctx: Ctx, span: Span | undefined): TypedSlot[] {
476
+ if (!span) return [];
477
+ let sig = ctx.tokens.slice(span.from, span.to).filter((t) => !isTrivia(t));
478
+ const isPunct = (i: number, p: string) => sig[i]?.kind === "punct" && sig[i]!.text === p;
479
+ if (isPunct(0, "(") && isPunct(sig.length - 1, ")")) {
480
+ let depth = 0;
481
+ let closesAt = -1;
482
+ for (let i = 0; i < sig.length && closesAt < 0; i++) {
483
+ if (isPunct(i, "(")) depth++;
484
+ else if (isPunct(i, ")") && --depth === 0) closesAt = i;
485
+ }
486
+ if (closesAt === sig.length - 1) sig = sig.slice(1, -1);
487
+ }
488
+ const out: TypedSlot[] = [];
489
+ let depth = 0;
490
+ let element: typeof sig = [];
491
+ const flush = () => {
492
+ const t = element[0];
493
+ if (element.length === 1 && t?.kind === "ref" && isColumnRef(ctx.values[t.part])) out.push({ index: t.part, kind: "sortKey" });
494
+ element = [];
495
+ };
496
+ for (const t of sig) {
497
+ if (t.kind === "punct" && t.text === "(") depth++;
498
+ else if (t.kind === "punct" && t.text === ")") depth--;
499
+ if (depth === 0 && t.kind === "punct" && t.text === ",") flush();
500
+ else element.push(t);
501
+ }
502
+ flush();
503
+ return out;
504
+ }
505
+
447
506
  type Expect = "database" | "table" | "view" | "dictionary" | "func";
448
507
 
449
508
  function build(tag: Expect, strings: TemplateStringsArray | readonly string[], values: readonly unknown[]): ClickHouseObject {
@@ -484,6 +543,7 @@ function build(tag: Expect, strings: TemplateStringsArray | readonly string[], v
484
543
  };
485
544
  const done = <T extends ClickHouseObject>(entity: T): T => {
486
545
  setInterpolationFields(entity, ctx.fed.map((paths) => [...paths].sort()));
546
+ if (node.statement === "table" || node.statement === "view") setTypedSlots(entity, [...sortKeySlots(ctx, node.orderBy), ...sortKeySlots(ctx, node.primaryKey)]);
487
547
  return entity;
488
548
  };
489
549
 
@@ -625,17 +685,22 @@ export function database(strings: TemplateStringsArray, ...values: unknown[]): C
625
685
  return build("database", strings, values) as ClickHouseDatabase;
626
686
  }
627
687
 
628
- /** `` table`CREATE TABLE ...` ``: one ClickHouse table, parsed at build time. */
629
- export function table(strings: TemplateStringsArray, ...values: unknown[]): ClickHouseTable {
630
- return build("table", strings, values) as ClickHouseTable;
688
+ /**
689
+ * `` table`CREATE TABLE ...` ``: one ClickHouse table, parsed at build time.
690
+ * Given its qualified name as the type argument (`` table<"shop.events">`...` ``),
691
+ * its columns are the ones `chant sql types` declared for that name.
692
+ */
693
+ export function table<const N extends string = Unnamed>(strings: TemplateStringsArray, ...values: SlotsIn<SqlSchema, N>): ClickHouseTable<N> {
694
+ return build("table", strings, values) as ClickHouseTable<N>;
631
695
  }
632
696
 
633
697
  /**
634
698
  * `` view`CREATE [MATERIALIZED] VIEW ...` ``: one view or materialized view,
635
- * with the tables it reads and its column lineage.
699
+ * with the tables it reads and its column lineage. Takes its qualified name
700
+ * as the type argument, as {@link table} does.
636
701
  */
637
- export function view(strings: TemplateStringsArray, ...values: unknown[]): ClickHouseView {
638
- return build("view", strings, values) as ClickHouseView;
702
+ export function view<const N extends string = Unnamed>(strings: TemplateStringsArray, ...values: SlotsIn<SqlSchema, N>): ClickHouseView<N> {
703
+ return build("view", strings, values) as ClickHouseView<N>;
639
704
  }
640
705
 
641
706
  /**
@@ -643,8 +708,8 @@ export function view(strings: TemplateStringsArray, ...values: unknown[]): Click
643
708
  * key, source, layout and lifetime, parsed at build time (#3682). Its
644
709
  * attributes are columns: `${rates.columns.rate}`.
645
710
  */
646
- export function dictionary(strings: TemplateStringsArray, ...values: unknown[]): ClickHouseDictionary {
647
- return build("dictionary", strings, values) as ClickHouseDictionary;
711
+ export function dictionary<const N extends string = Unnamed>(strings: TemplateStringsArray, ...values: SlotsIn<SqlSchema, N>): ClickHouseDictionary<N> {
712
+ return build("dictionary", strings, values) as ClickHouseDictionary<N>;
648
713
  }
649
714
 
650
715
  /**
@@ -6,6 +6,7 @@
6
6
  * without import cycles.
7
7
  */
8
8
 
9
+ import { relationKey } from "../../core/typed";
9
10
  import type { GeneratedFile, TypeScriptGenerator } from "@intentius/chant/import/generator";
10
11
  import type { TemplateIR } from "@intentius/chant/import/parser";
11
12
  import { isTrivia, tokenizeText, type Token } from "../tokens";
@@ -162,6 +163,11 @@ function indent(body: string): string {
162
163
  return lines.map((l, i) => (i === 0 || l.length === 0 ? l : ` ${l}`)).join("\n");
163
164
  }
164
165
 
166
+ const RELATIONS = new Set<string>([CLICKHOUSE_ENTITY_TYPES.table, CLICKHOUSE_ENTITY_TYPES.view, CLICKHOUSE_ENTITY_TYPES.materializedView, CLICKHOUSE_ENTITY_TYPES.dictionary]);
167
+
168
+ /** A table's, view's or dictionary's qualified name as its tag's type argument (#3751), so its columns are typed once `chant sql types` has run. */
169
+ const typeArgument = (it: Item): string => (RELATIONS.has(it.type) ? `<${JSON.stringify(relationKey(it))}>` : "");
170
+
165
171
  export class ClickHouseGenerator implements TypeScriptGenerator {
166
172
  readonly ownsLayout = true;
167
173
 
@@ -186,7 +192,7 @@ export class ClickHouseGenerator implements TypeScriptGenerator {
186
192
  if (this.header) lines.push(...this.header.split("\n").map((l) => `// ${l}`), "");
187
193
  lines.push(`import { ${tags.join(", ")} } from "@intentius/chant-lexicon-sql/clickhouse";`, "");
188
194
  for (const it of order) {
189
- lines.push(`export const ${it.exportName} = ${CLICKHOUSE_TAG_OF[it.type]}\`\n ${indent(bodyText(bodies.get(it.exportName)!))}\`;`, "");
195
+ lines.push(`export const ${it.exportName} = ${CLICKHOUSE_TAG_OF[it.type]}${typeArgument(it)}\`\n ${indent(bodyText(bodies.get(it.exportName)!))}\`;`, "");
190
196
  }
191
197
  return [{ path: "schema.ts", content: `${lines.join("\n").trimEnd()}\n` }];
192
198
  }
@@ -64,7 +64,7 @@ describe("import, build, import again", () => {
64
64
  "ClickHouse::View",
65
65
  "ClickHouse::Table",
66
66
  ]);
67
- expect(file!.content).toContain("export const events = table`");
67
+ expect(file!.content).toContain(`export const events = table<"analytics.events">\``);
68
68
  });
69
69
 
70
70
  test("the declarations build back to one statement per object, dependencies first", async () => {
@@ -165,5 +165,10 @@ export const sqlCommands: CommandGroup = {
165
165
  commands: [
166
166
  { name: "diff", description: "Classify the change between two chant build outputs (offline); --statements prints the statements it takes, --topology renders them for single, cluster:<name>, replicated or cloud", handler: runDiff },
167
167
  { name: "plan", description: "Classify the change from an environment's server to a chant build output", handler: runPlan },
168
+ {
169
+ name: "types",
170
+ description: "Write sql-types.d.ts: typed columns for each table, view and dictionary the build declares, for tags given their name (table<\"app.users\">) and RowOf; --output <path> writes it elsewhere; --check compares instead of writing and exits 1 when the file differs",
171
+ handler: async (ctx) => (await import("../../sql-types-command")).runTypes(ctx),
172
+ },
168
173
  ],
169
174
  };
@@ -0,0 +1,158 @@
1
+ /**
2
+ * What a ClickHouse column's value is in TypeScript, for `RowOf` (#3751): the
3
+ * value a row has in ClickHouse's JSON formats (`JSONEachRow`, what the HTTP
4
+ * clients read) with the server's default settings. 64-bit and wider
5
+ * integers are quoted (`output_format_json_quote_64bit_integers`), so they
6
+ * are strings; narrower integers, floats and decimals are numbers; dates,
7
+ * times, UUIDs and addresses are their text; a named tuple is an object
8
+ * (`output_format_json_named_tuples_as_objects`) and an unnamed one an array.
9
+ * `Nullable(T)` adds `| null` and `LowCardinality(T)` is `T`.
10
+ *
11
+ * The table names every type family of the pinned server's catalog
12
+ * (`TYPE_FAMILIES`, aliases resolved), and a test fails on one it does not,
13
+ * so a family a new release adds is a decision rather than a silent
14
+ * `unknown`.
15
+ */
16
+
17
+ import type { ColumnTypeFamily } from "../generated/clickhouse-types";
18
+ import { familyOf } from "../lint/post-synth/clickhouse-type-check";
19
+ import { readType, typeTokens, type TypeParam } from "./type-syntax";
20
+ import { isTrivia } from "./tokens";
21
+
22
+ /** How a family's parameters make its row type: `inner` maps a parameter that is itself a type. */
23
+ type Composed = (params: readonly TypeParam[], inner: (type: string) => string) => string;
24
+
25
+ const NUMBER = "number";
26
+ const STRING = "string";
27
+ const UNKNOWN = "unknown";
28
+ const POINT = "[number, number]";
29
+
30
+ /** A union needs parentheses before `[]`. */
31
+ const element = (ts: string): string => (/[|&]/.test(ts) && !/^\{.*\}$/s.test(ts) ? `(${ts})` : ts);
32
+
33
+ /** An enum's labels as a union of string literals; its text when none read. */
34
+ const enumLabels: Composed = (params) => {
35
+ const labels = params.flatMap((p) => {
36
+ const first = p.tokens.find((t) => !isTrivia(t));
37
+ return first?.kind === "string" ? [first.text.slice(1, -1).replace(/\\(.)/g, "$1").replace(/''/g, "'")] : [];
38
+ });
39
+ return labels.length > 0 ? labels.map((l) => JSON.stringify(l)).join(" | ") : STRING;
40
+ };
41
+
42
+ /** The TypeScript type of each type family's value, or how its parameters make it. */
43
+ export const CLICKHOUSE_ROW_TYPES: Readonly<Record<ColumnTypeFamily, string | Composed>> = {
44
+ UInt8: NUMBER,
45
+ UInt16: NUMBER,
46
+ UInt32: NUMBER,
47
+ Int8: NUMBER,
48
+ Int16: NUMBER,
49
+ Int32: NUMBER,
50
+ Float32: NUMBER,
51
+ Float64: NUMBER,
52
+ BFloat16: NUMBER,
53
+ Decimal: NUMBER,
54
+ Decimal32: NUMBER,
55
+ Decimal64: NUMBER,
56
+ Decimal128: NUMBER,
57
+ Decimal256: NUMBER,
58
+ // Quoted in JSON, so text.
59
+ UInt64: STRING,
60
+ UInt128: STRING,
61
+ UInt256: STRING,
62
+ Int64: STRING,
63
+ Int128: STRING,
64
+ Int256: STRING,
65
+ IntervalNanosecond: STRING,
66
+ IntervalMicrosecond: STRING,
67
+ IntervalMillisecond: STRING,
68
+ IntervalSecond: STRING,
69
+ IntervalMinute: STRING,
70
+ IntervalHour: STRING,
71
+ IntervalDay: STRING,
72
+ IntervalWeek: STRING,
73
+ IntervalMonth: STRING,
74
+ IntervalQuarter: STRING,
75
+ IntervalYear: STRING,
76
+ Bool: "boolean",
77
+ String: STRING,
78
+ FixedString: STRING,
79
+ UUID: STRING,
80
+ IPv4: STRING,
81
+ IPv6: STRING,
82
+ Date: STRING,
83
+ Date32: STRING,
84
+ DateTime: STRING,
85
+ DateTime32: STRING,
86
+ DateTime64: STRING,
87
+ Time: STRING,
88
+ Time64: STRING,
89
+ Enum: enumLabels,
90
+ Enum8: enumLabels,
91
+ Enum16: enumLabels,
92
+ // Wrappers and containers.
93
+ Nullable: (p, inner) => `${inner(p[0]?.text ?? "")} | null`,
94
+ LowCardinality: (p, inner) => inner(p[0]?.text ?? ""),
95
+ Array: (p, inner) => `${element(inner(p[0]?.text ?? ""))}[]`,
96
+ Map: (p, inner) => `Record<string, ${inner(p[1]?.text ?? "")}>`,
97
+ Tuple: (p, inner) => {
98
+ const elements = p.map((x) => named(x));
99
+ if (elements.length > 0 && elements.every((e) => e.name !== undefined)) {
100
+ return `{ ${elements.map((e) => `${propertyName(e.name!)}: ${inner(e.type)}`).join("; ")} }`;
101
+ }
102
+ return `[${elements.map((e) => inner(e.type)).join(", ")}]`;
103
+ },
104
+ SimpleAggregateFunction: (p, inner) => inner(p[1]?.text ?? ""),
105
+ AggregateFunction: UNKNOWN,
106
+ Nested: UNKNOWN,
107
+ // Self-describing: the application's own shape.
108
+ JSON: UNKNOWN,
109
+ Dynamic: UNKNOWN,
110
+ Variant: UNKNOWN,
111
+ QBit: UNKNOWN,
112
+ Nothing: "null",
113
+ // Geometry, as the JSON formats write it: points as pairs, nested in arrays.
114
+ Point: POINT,
115
+ Ring: `${POINT}[]`,
116
+ LineString: `${POINT}[]`,
117
+ Polygon: `${POINT}[][]`,
118
+ MultiLineString: `${POINT}[][]`,
119
+ MultiPoint: `${POINT}[]`,
120
+ MultiPolygon: `${POINT}[][][]`,
121
+ Geometry: UNKNOWN,
122
+ };
123
+
124
+ /** A tuple element: `a String` is named `a`, `String` is not. */
125
+ function named(param: TypeParam): { name?: string; type: string } {
126
+ const syn = readType(param.tokens);
127
+ if (!syn || syn.words.length < 2) return { type: param.text };
128
+ if (familyOf(syn.words.map((w) => w.text).join(" "))) return { type: param.text };
129
+ const first = syn.words[0]!;
130
+ const rest = param.text.slice(param.text.indexOf(first.text) + first.text.length).trim();
131
+ return { name: first.kind === "qident" ? first.text.slice(1, -1) : first.text, type: rest };
132
+ }
133
+
134
+ const propertyName = (name: string): string => (/^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name));
135
+
136
+ /** A type's row type, wrappers and all; `unknown` for a type this cannot read. */
137
+ export function clickhouseTsType(type: string): string {
138
+ const tokens = typeTokens(type);
139
+ const syn = tokens && readType(tokens);
140
+ if (!syn) return UNKNOWN;
141
+ const spec = familyOf(syn.words.map((w) => w.text).join(" "));
142
+ const entry = spec ? (CLICKHOUSE_ROW_TYPES as Readonly<Record<string, string | Composed | undefined>>)[spec.canonical] : undefined;
143
+ if (entry === undefined) return UNKNOWN;
144
+ return typeof entry === "string" ? entry : entry(syn.params ?? [], clickhouseTsType);
145
+ }
146
+
147
+ /** A column's row type and whether it can be NULL: `Nullable(...)` at the top, under any `LowCardinality(...)`. */
148
+ export function clickhouseRowType(type: string): { ts: string; nullable: boolean } {
149
+ let t = type.trim();
150
+ let nullable = false;
151
+ for (;;) {
152
+ const m = /^(Nullable|LowCardinality)\s*\(([\s\S]*)\)$/i.exec(t);
153
+ if (!m) break;
154
+ if (m[1]!.toLowerCase() === "nullable") nullable = true;
155
+ t = m[2]!.trim();
156
+ }
157
+ return { ts: clickhouseTsType(t), nullable };
158
+ }
package/src/clickhouse.ts CHANGED
@@ -50,6 +50,16 @@ export {
50
50
  type LineageEdge,
51
51
  type TableProps,
52
52
  type ViewProps,
53
+ type SqlSchema,
54
+ type RowOf,
55
+ type Column,
56
+ type ColumnTraits,
57
+ type AnyColumn,
58
+ type UncheckedColumn,
59
+ type KeyColumn,
60
+ type NotNullColumn,
61
+ type Named,
62
+ type Unnamed,
53
63
  } from "./clickhouse/entities";
54
64
  export * from "./generated/clickhouse";
55
65
  export * from "./composites/clickhouse";
package/src/core/index.ts CHANGED
@@ -25,6 +25,9 @@
25
25
  * (`classifier.ts`);
26
26
  * - normalization helpers that are not a database's grammar, such as the
27
27
  * `-- previously:` hint (`normalize.ts`);
28
+ * - typed columns: the brands, `ColumnsIn`, `SlotsIn` and `RowIn` each
29
+ * dialect's `SqlSchema` is read through, and the slot record a tag leaves
30
+ * for `chant sql types` (`typed.ts`);
28
31
  * - the ownership trailer on an object's comment (`ownership.ts`);
29
32
  * - receipts kept on the server (`receipts.ts`);
30
33
  * - the applier's tri-state outcome, its envelope projection and the build
@@ -54,6 +57,7 @@ export * from "./references";
54
57
  export * from "./diff";
55
58
  export * from "./classifier";
56
59
  export * from "./normalize";
60
+ export * from "./typed";
57
61
  export * from "./ownership";
58
62
  export * from "./receipts";
59
63
  export * from "./apply";