@intentius/chant-lexicon-sql 0.121.1 → 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 (219) hide show
  1. package/README.md +1 -1
  2. package/dist/clickhouse/catalog-types.d.ts +8 -0
  3. package/dist/clickhouse/catalog-types.d.ts.map +1 -1
  4. package/dist/clickhouse/entities.d.ts +37 -11
  5. package/dist/clickhouse/entities.d.ts.map +1 -1
  6. package/dist/clickhouse/import/generator.d.ts.map +1 -1
  7. package/dist/clickhouse/overlays/codecs.d.ts.map +1 -1
  8. package/dist/clickhouse/overlays/kinds.d.ts +1 -1
  9. package/dist/clickhouse/overlays/kinds.d.ts.map +1 -1
  10. package/dist/clickhouse/overlays/types.d.ts.map +1 -1
  11. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  12. package/dist/clickhouse/row-types.d.ts +30 -0
  13. package/dist/clickhouse/row-types.d.ts.map +1 -0
  14. package/dist/clickhouse/type-syntax.d.ts +33 -0
  15. package/dist/clickhouse/type-syntax.d.ts.map +1 -0
  16. package/dist/clickhouse.d.ts +1 -1
  17. package/dist/clickhouse.d.ts.map +1 -1
  18. package/dist/codegen/clickhouse-module.d.ts.map +1 -1
  19. package/dist/codegen/postgres-module.d.ts.map +1 -1
  20. package/dist/core/index.d.ts +4 -0
  21. package/dist/core/index.d.ts.map +1 -1
  22. package/dist/core/typed.d.ts +132 -0
  23. package/dist/core/typed.d.ts.map +1 -0
  24. package/dist/generated/clickhouse.d.ts +7 -1
  25. package/dist/generated/clickhouse.d.ts.map +1 -1
  26. package/dist/generated/postgres.d.ts +6 -0
  27. package/dist/generated/postgres.d.ts.map +1 -1
  28. package/dist/integrity.json +27 -5
  29. package/dist/lint/audit-catalog.d.ts.map +1 -1
  30. package/dist/lint/post-synth/audit-entries.d.ts.map +1 -1
  31. package/dist/lint/post-synth/clickhouse-type-check.d.ts +37 -0
  32. package/dist/lint/post-synth/clickhouse-type-check.d.ts.map +1 -0
  33. package/dist/lint/post-synth/index.d.ts.map +1 -1
  34. package/dist/lint/post-synth/postgres-audit-entries.d.ts.map +1 -1
  35. package/dist/lint/post-synth/postgres-names.d.ts +151 -0
  36. package/dist/lint/post-synth/postgres-names.d.ts.map +1 -0
  37. package/dist/lint/post-synth/sql101.d.ts +9 -0
  38. package/dist/lint/post-synth/sql101.d.ts.map +1 -0
  39. package/dist/lint/post-synth/sql102.d.ts +16 -0
  40. package/dist/lint/post-synth/sql102.d.ts.map +1 -0
  41. package/dist/lint/post-synth/sqlch121.d.ts +10 -0
  42. package/dist/lint/post-synth/sqlch121.d.ts.map +1 -0
  43. package/dist/lint/post-synth/sqlch122.d.ts +10 -0
  44. package/dist/lint/post-synth/sqlch122.d.ts.map +1 -0
  45. package/dist/lint/post-synth/sqlch123.d.ts +9 -0
  46. package/dist/lint/post-synth/sqlch123.d.ts.map +1 -0
  47. package/dist/lint/post-synth/sqlch124.d.ts +6 -0
  48. package/dist/lint/post-synth/sqlch124.d.ts.map +1 -0
  49. package/dist/lint/post-synth/sqlch125.d.ts +11 -0
  50. package/dist/lint/post-synth/sqlch125.d.ts.map +1 -0
  51. package/dist/lint/post-synth/sqlch126.d.ts +12 -0
  52. package/dist/lint/post-synth/sqlch126.d.ts.map +1 -0
  53. package/dist/lint/post-synth/sqlch127.d.ts +22 -0
  54. package/dist/lint/post-synth/sqlch127.d.ts.map +1 -0
  55. package/dist/lint/post-synth/sqlpg119.d.ts +18 -0
  56. package/dist/lint/post-synth/sqlpg119.d.ts.map +1 -0
  57. package/dist/lint/post-synth/sqlpg120.d.ts +17 -0
  58. package/dist/lint/post-synth/sqlpg120.d.ts.map +1 -0
  59. package/dist/lint/post-synth/sqlpg121.d.ts +12 -0
  60. package/dist/lint/post-synth/sqlpg121.d.ts.map +1 -0
  61. package/dist/lint/post-synth/sqlpg122.d.ts +12 -0
  62. package/dist/lint/post-synth/sqlpg122.d.ts.map +1 -0
  63. package/dist/lint/post-synth/sqlpg123.d.ts +17 -0
  64. package/dist/lint/post-synth/sqlpg123.d.ts.map +1 -0
  65. package/dist/lint/post-synth/sqlpg124.d.ts +16 -0
  66. package/dist/lint/post-synth/sqlpg124.d.ts.map +1 -0
  67. package/dist/lint/post-synth/sqlpg125.d.ts +17 -0
  68. package/dist/lint/post-synth/sqlpg125.d.ts.map +1 -0
  69. package/dist/lint/post-synth/sqlpg126.d.ts +19 -0
  70. package/dist/lint/post-synth/sqlpg126.d.ts.map +1 -0
  71. package/dist/lint/rules/index.d.ts +3 -1
  72. package/dist/lint/rules/index.d.ts.map +1 -1
  73. package/dist/lint/rules/sqlch004.d.ts +19 -0
  74. package/dist/lint/rules/sqlch004.d.ts.map +1 -0
  75. package/dist/lint/rules/sqlpg005.d.ts +18 -0
  76. package/dist/lint/rules/sqlpg005.d.ts.map +1 -0
  77. package/dist/lint/rules/typed-names.d.ts +43 -0
  78. package/dist/lint/rules/typed-names.d.ts.map +1 -0
  79. package/dist/manifest.json +1 -1
  80. package/dist/okf/index.md +19 -0
  81. package/dist/okf/rules/SQL101.md +23 -0
  82. package/dist/okf/rules/SQL102.md +11 -0
  83. package/dist/okf/rules/SQLCH004.md +11 -0
  84. package/dist/okf/rules/SQLCH121.md +11 -0
  85. package/dist/okf/rules/SQLCH122.md +11 -0
  86. package/dist/okf/rules/SQLCH123.md +11 -0
  87. package/dist/okf/rules/SQLCH124.md +18 -0
  88. package/dist/okf/rules/SQLCH125.md +11 -0
  89. package/dist/okf/rules/SQLCH126.md +15 -0
  90. package/dist/okf/rules/SQLCH127.md +15 -0
  91. package/dist/okf/rules/SQLPG005.md +11 -0
  92. package/dist/okf/rules/SQLPG119.md +16 -0
  93. package/dist/okf/rules/SQLPG120.md +16 -0
  94. package/dist/okf/rules/SQLPG121.md +11 -0
  95. package/dist/okf/rules/SQLPG122.md +15 -0
  96. package/dist/okf/rules/SQLPG123.md +16 -0
  97. package/dist/okf/rules/SQLPG124.md +11 -0
  98. package/dist/okf/rules/SQLPG125.md +11 -0
  99. package/dist/okf/rules/SQLPG126.md +11 -0
  100. package/dist/okf/types/Dictionary.md +4 -0
  101. package/dist/okf/types/Function.md +5 -0
  102. package/dist/okf/types/Grant.md +6 -0
  103. package/dist/okf/types/MaterializedView.md +1 -0
  104. package/dist/okf/types/PostgresDefaultPrivileges.md +4 -0
  105. package/dist/okf/types/PostgresDomain.md +2 -0
  106. package/dist/okf/types/PostgresEnum.md +1 -0
  107. package/dist/okf/types/PostgresFunction.md +4 -0
  108. package/dist/okf/types/PostgresGrant.md +5 -0
  109. package/dist/okf/types/PostgresPolicy.md +4 -0
  110. package/dist/okf/types/PostgresProcedure.md +4 -0
  111. package/dist/okf/types/PostgresSequence.md +2 -0
  112. package/dist/okf/types/PostgresTrigger.md +4 -0
  113. package/dist/okf/types/RowPolicy.md +4 -0
  114. package/dist/okf/types/Table.md +1 -0
  115. package/dist/okf/types/View.md +1 -0
  116. package/dist/postgres/entities.d.ts +38 -9
  117. package/dist/postgres/entities.d.ts.map +1 -1
  118. package/dist/postgres/import/generator.d.ts.map +1 -1
  119. package/dist/postgres/overlays/extension-objects.d.ts +28 -0
  120. package/dist/postgres/overlays/extension-objects.d.ts.map +1 -0
  121. package/dist/postgres/overlays/types.d.ts +10 -0
  122. package/dist/postgres/overlays/types.d.ts.map +1 -1
  123. package/dist/postgres/row-types.d.ts +17 -0
  124. package/dist/postgres/row-types.d.ts.map +1 -0
  125. package/dist/postgres/testing/migration.d.ts +3 -2
  126. package/dist/postgres/testing/migration.d.ts.map +1 -1
  127. package/dist/postgres.d.ts +1 -1
  128. package/dist/postgres.d.ts.map +1 -1
  129. package/dist/rules/audit-entries.ts +7 -0
  130. package/dist/rules/clickhouse-type-check.ts +173 -0
  131. package/dist/rules/postgres-audit-entries.ts +8 -0
  132. package/dist/rules/postgres-names.ts +510 -0
  133. package/dist/rules/sql101.ts +62 -0
  134. package/dist/rules/sql102.ts +34 -0
  135. package/dist/rules/sqlch004.ts +81 -0
  136. package/dist/rules/sqlch121.ts +12 -0
  137. package/dist/rules/sqlch122.ts +12 -0
  138. package/dist/rules/sqlch123.ts +11 -0
  139. package/dist/rules/sqlch124.ts +21 -0
  140. package/dist/rules/sqlch125.ts +56 -0
  141. package/dist/rules/sqlch126.ts +57 -0
  142. package/dist/rules/sqlch127.ts +128 -0
  143. package/dist/rules/sqlpg005.ts +73 -0
  144. package/dist/rules/sqlpg119.ts +37 -0
  145. package/dist/rules/sqlpg120.ts +70 -0
  146. package/dist/rules/sqlpg121.ts +32 -0
  147. package/dist/rules/sqlpg122.ts +28 -0
  148. package/dist/rules/sqlpg123.ts +63 -0
  149. package/dist/rules/sqlpg124.ts +47 -0
  150. package/dist/rules/sqlpg125.ts +40 -0
  151. package/dist/rules/sqlpg126.ts +37 -0
  152. package/dist/rules/typed-names.ts +120 -0
  153. package/dist/sql-types-command.d.ts +19 -0
  154. package/dist/sql-types-command.d.ts.map +1 -0
  155. package/dist/sql-types.d.ts +53 -0
  156. package/dist/sql-types.d.ts.map +1 -0
  157. package/package.json +2 -2
  158. package/src/clickhouse/catalog-types.ts +9 -0
  159. package/src/clickhouse/dictionary.test.ts +1 -1
  160. package/src/clickhouse/entities.ts +79 -14
  161. package/src/clickhouse/import/generator.ts +7 -1
  162. package/src/clickhouse/import/roundtrip.test.ts +1 -1
  163. package/src/clickhouse/overlays/codecs.ts +6 -2
  164. package/src/clickhouse/overlays/kinds.ts +1 -1
  165. package/src/clickhouse/overlays/types.ts +20 -3
  166. package/src/clickhouse/plan/commands.ts +5 -0
  167. package/src/clickhouse/row-types.ts +158 -0
  168. package/src/clickhouse/type-syntax.ts +82 -0
  169. package/src/clickhouse.ts +10 -0
  170. package/src/codegen/clickhouse-module.ts +15 -1
  171. package/src/codegen/postgres-module.ts +43 -0
  172. package/src/composites/composites.test.ts +7 -6
  173. package/src/core/index.ts +4 -0
  174. package/src/core/typed.ts +143 -0
  175. package/src/generated/clickhouse.ts +1905 -6
  176. package/src/generated/postgres.ts +2639 -0
  177. package/src/lint/audit-catalog.ts +28 -0
  178. package/src/lint/post-synth/audit-entries.ts +7 -0
  179. package/src/lint/post-synth/clickhouse-catalog-checks.test.ts +315 -0
  180. package/src/lint/post-synth/clickhouse-type-check.ts +173 -0
  181. package/src/lint/post-synth/index.ts +34 -0
  182. package/src/lint/post-synth/postgres-audit-entries.ts +8 -0
  183. package/src/lint/post-synth/postgres-checks.test.ts +214 -1
  184. package/src/lint/post-synth/postgres-names.ts +510 -0
  185. package/src/lint/post-synth/sql101.ts +62 -0
  186. package/src/lint/post-synth/sql102.ts +34 -0
  187. package/src/lint/post-synth/sqlch121.ts +12 -0
  188. package/src/lint/post-synth/sqlch122.ts +12 -0
  189. package/src/lint/post-synth/sqlch123.ts +11 -0
  190. package/src/lint/post-synth/sqlch124.ts +21 -0
  191. package/src/lint/post-synth/sqlch125.ts +56 -0
  192. package/src/lint/post-synth/sqlch126.ts +57 -0
  193. package/src/lint/post-synth/sqlch127.ts +128 -0
  194. package/src/lint/post-synth/sqlpg119.ts +37 -0
  195. package/src/lint/post-synth/sqlpg120.ts +70 -0
  196. package/src/lint/post-synth/sqlpg121.ts +32 -0
  197. package/src/lint/post-synth/sqlpg122.ts +28 -0
  198. package/src/lint/post-synth/sqlpg123.ts +63 -0
  199. package/src/lint/post-synth/sqlpg124.ts +47 -0
  200. package/src/lint/post-synth/sqlpg125.ts +40 -0
  201. package/src/lint/post-synth/sqlpg126.ts +37 -0
  202. package/src/lint/rules/index.ts +4 -2
  203. package/src/lint/rules/sqlch004.ts +81 -0
  204. package/src/lint/rules/sqlpg005.ts +73 -0
  205. package/src/lint/rules/typed-names.test.ts +151 -0
  206. package/src/lint/rules/typed-names.ts +120 -0
  207. package/src/op/__snapshots__/watch-pipeline.test.ts.snap +3 -3
  208. package/src/postgres/entities.ts +65 -12
  209. package/src/postgres/import/generator.ts +7 -1
  210. package/src/postgres/overlays/extension-objects.ts +100 -0
  211. package/src/postgres/overlays/types.ts +25 -0
  212. package/src/postgres/row-types.ts +111 -0
  213. package/src/postgres.ts +10 -0
  214. package/src/sql-types-command.ts +99 -0
  215. package/src/sql-types-staleness.test.ts +110 -0
  216. package/src/sql-types.test.ts +132 -0
  217. package/src/sql-types.ts +352 -0
  218. package/src/typecheck.test.ts +12 -3
  219. package/src/typed-columns.test.ts +92 -0
@@ -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
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * What the typed-column rules (SQLPG005, SQLCH004; chant #3751) share: reading
3
+ * a tag's type argument, comparing it with the name the template declares, and
4
+ * reading the names the generated `sql-types.d.ts` lists.
5
+ *
6
+ * The type argument is `table<"shop.orders">\`CREATE TABLE ...\``. tsc checks
7
+ * that it names an entry of the generated file's `SqlSchema` only when the
8
+ * argument is a constraint, and an unlisted name falls back to unchecked
9
+ * columns, so a mistyped argument is silent without these rules.
10
+ */
11
+
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import * as ts from "typescript";
15
+
16
+ /** The generated file's name, beside the source (`chant sql types`). */
17
+ export const SQL_TYPES_FILE = "sql-types.d.ts";
18
+
19
+ /** A tag's type argument, when it is a string literal: `table<"shop.orders">`. */
20
+ export function typeArgumentName(node: ts.TaggedTemplateExpression): { name: string; node: ts.LiteralTypeNode } | undefined {
21
+ const arg = node.typeArguments?.[0];
22
+ if (!arg || node.typeArguments!.length !== 1) return undefined;
23
+ if (!ts.isLiteralTypeNode(arg) || !ts.isStringLiteral(arg.literal)) return undefined;
24
+ return { name: arg.literal.text, node: arg };
25
+ }
26
+
27
+ /** Split a dotted name outside quotes and take the quotes off: `shop."Order Items"` is `shop`, `Order Items`. */
28
+ export function splitQualified(name: string): string[] {
29
+ const pieces: string[] = [];
30
+ let cur = "";
31
+ let quote: string | undefined;
32
+ for (let i = 0; i < name.length; i++) {
33
+ const c = name[i]!;
34
+ if (quote) {
35
+ if (c === quote && name[i + 1] === quote) {
36
+ cur += c;
37
+ i++;
38
+ } else if (c === quote) quote = undefined;
39
+ else cur += c;
40
+ } else if (c === '"' || c === "`") quote = c;
41
+ else if (c === ".") {
42
+ pieces.push(cur);
43
+ cur = "";
44
+ } else cur += c;
45
+ }
46
+ pieces.push(cur);
47
+ return pieces;
48
+ }
49
+
50
+ /**
51
+ * Whether the declared name (an empty piece is an interpolation, which lint
52
+ * cannot read) can be the type argument's. Compared from the last piece back.
53
+ * A template that does not qualify its name takes the argument's schema, since
54
+ * the build qualifies it with the schema or database the project sets; one
55
+ * that does qualify it must agree with the argument.
56
+ */
57
+ export function nameAgrees(declared: readonly string[], argument: string): boolean {
58
+ const given = splitQualified(argument);
59
+ if (declared.length === 0) return true;
60
+ if (declared.length > given.length) return false;
61
+ for (let i = 1; i <= declared.length; i++) {
62
+ const d = declared[declared.length - i]!;
63
+ if (d === "") continue;
64
+ if (d !== given[given.length - i]) return false;
65
+ }
66
+ return true;
67
+ }
68
+
69
+ /** The text of a declared name for a message; an interpolation is `${...}`. */
70
+ export const declaredText = (declared: readonly string[]): string => declared.map((p) => (p === "" ? "${...}" : p)).join(".");
71
+
72
+ /** A qualified name with quoting taken off, so `"shop"."orders"` and `shop.orders` compare equal. */
73
+ export const normalizedName = (name: string): string => splitQualified(name).join("\0");
74
+
75
+ /** The names (normalized, see {@link normalizedName}) a `declare module "<module>"` block in a generated file augments `SqlSchema` with. */
76
+ export function namesInGenerated(content: string, module: string): Set<string> | undefined {
77
+ const source = ts.createSourceFile(SQL_TYPES_FILE, content, ts.ScriptTarget.Latest, true);
78
+ let names: Set<string> | undefined;
79
+ for (const stmt of source.statements) {
80
+ if (!ts.isModuleDeclaration(stmt) || !ts.isStringLiteral(stmt.name) || stmt.name.text !== module) continue;
81
+ if (!stmt.body || !ts.isModuleBlock(stmt.body)) continue;
82
+ for (const inner of stmt.body.statements) {
83
+ if (!ts.isInterfaceDeclaration(inner) || inner.name.text !== "SqlSchema") continue;
84
+ names ??= new Set();
85
+ for (const member of inner.members) {
86
+ const n = member.name;
87
+ if (n && (ts.isStringLiteral(n) || ts.isIdentifier(n))) names.add(normalizedName(n.text));
88
+ }
89
+ }
90
+ }
91
+ return names;
92
+ }
93
+
94
+ /**
95
+ * The generated file nearest above a source file, up to the directory that
96
+ * holds the project's config. `chant sql types` writes it in `sourceDir` (or
97
+ * the project root), so the walk finds it from any file below.
98
+ */
99
+ export function findGeneratedFile(filePath: string): string | undefined {
100
+ let dir = dirname(resolve(filePath));
101
+ for (;;) {
102
+ const candidate = join(dir, SQL_TYPES_FILE);
103
+ if (existsSync(candidate)) return candidate;
104
+ if (existsSync(join(dir, "chant.config.ts")) || existsSync(join(dir, "chant.config.json"))) return undefined;
105
+ const up = dirname(dir);
106
+ if (up === dir) return undefined;
107
+ dir = up;
108
+ }
109
+ }
110
+
111
+ /** The names the generated file lists for a dialect's module, or undefined when there is no file or it has no such block. */
112
+ export function generatedNamesFor(filePath: string, module: string): Set<string> | undefined {
113
+ const found = findGeneratedFile(filePath);
114
+ if (!found) return undefined;
115
+ try {
116
+ return namesInGenerated(readFileSync(found, "utf8"), module);
117
+ } catch {
118
+ return undefined;
119
+ }
120
+ }
@@ -15,7 +15,7 @@ concurrency:
15
15
  jobs:
16
16
  schema-watch:
17
17
  runs-on: docker
18
- container: node:22-slim
18
+ container: node:22
19
19
  env:
20
20
  CLICKHOUSE_READER_USER: chant_reader
21
21
  CLICKHOUSE_READER_PASSWORD: '\${{ secrets.CLICKHOUSE_READER_PASSWORD }}'
@@ -47,7 +47,7 @@ permissions:
47
47
  jobs:
48
48
  schema-watch:
49
49
  runs-on: ubuntu-latest
50
- container: node:22-slim
50
+ container: node:22
51
51
  env:
52
52
  CLICKHOUSE_READER_USER: chant_reader
53
53
  CLICKHOUSE_READER_PASSWORD: '\${{ secrets.CLICKHOUSE_READER_PASSWORD }}'
@@ -78,7 +78,7 @@ stages:
78
78
 
79
79
  schema-watch:
80
80
  stage: ops
81
- image: node:22-slim
81
+ image: node:22
82
82
  resource_group: schema-watch
83
83
  variables:
84
84
  CLICKHOUSE_READER_USER: chant_reader
@@ -62,11 +62,13 @@ import { feed, lineage, spanText as text, splice as spliceWith, templateSyntaxEr
62
62
  import { SqlObject, isColumnRefOf, isSqlObjectOf, makeSqlEntity } from "../core/entity";
63
63
  import type { LineageEdge } from "../core/references";
64
64
  import { SqlLiteral, SqlTemplateError, describeValue as describe, stripUnset, templateParts } from "../core/template";
65
+ import { setTypedSlots, type ColumnsIn, type Named, type RowIn, type SlotsIn, type TypedSlot, type Unnamed } from "../core/typed";
65
66
 
66
67
  export { SQL_LEXICON } from "../core/entity";
67
68
  export type { LineageEdge } from "../core/references";
68
69
  export { SqlLiteral, SqlTemplateError } from "../core/template";
69
70
  export { quoteIdent } from "./keywords";
71
+ export type { AnyColumn, Column, ColumnTraits, KeyColumn, Named, NotNullColumn, UncheckedColumn, Unnamed } from "../core/typed";
70
72
 
71
73
  export { POSTGRES_ENTITY_TYPES, type PostgresEntityType } from "./entity-types";
72
74
  import { POSTGRES_ENTITY_TYPES, type PostgresEntityType } from "./entity-types";
@@ -431,18 +433,31 @@ export interface PostgresSchema extends PostgresObject {
431
433
  readonly props: SchemaProps;
432
434
  }
433
435
 
434
- /** A table or a view: something with columns that SQL can reference. */
435
- export interface PostgresRelation extends PostgresObject {
436
+ /**
437
+ * Every Postgres table and view the project declares, keyed by its qualified
438
+ * name (`shop.orders`). Empty here; the `sql-types.d.ts` that `chant sql
439
+ * types` writes augments it, and a tag given the name as its type argument
440
+ * reads its columns from there.
441
+ */
442
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- augmented by the generated declarations
443
+ export interface SqlSchema {}
444
+
445
+ /**
446
+ * A table or a view: something with columns that SQL can reference. `N` is
447
+ * its qualified name when its tag was given one (`` table<"shop.orders">`...` ``):
448
+ * its columns are then the generated declarations' entry for that name.
449
+ */
450
+ export interface PostgresRelation<N extends string = string> extends PostgresObject, Named<N> {
436
451
  /** One reference per column, by its folded name: `${users.columns.email}`. */
437
- readonly columns: Readonly<Record<string, AttrRef>>;
452
+ readonly columns: ColumnsIn<SqlSchema, N>;
438
453
  }
439
454
 
440
- export interface PostgresTable extends PostgresRelation {
455
+ export interface PostgresTable<N extends string = string> extends PostgresRelation<N> {
441
456
  readonly entityType: "Postgres::Table";
442
457
  readonly props: TableProps;
443
458
  }
444
459
 
445
- export interface PostgresView extends PostgresRelation {
460
+ export interface PostgresView<N extends string = string> extends PostgresRelation<N> {
446
461
  readonly entityType: "Postgres::View" | "Postgres::MaterializedView";
447
462
  readonly props: ViewProps;
448
463
  }
@@ -502,6 +517,13 @@ export interface PostgresGrant extends PostgresObject {
502
517
  readonly props: GrantProps | DefaultPrivilegesProps;
503
518
  }
504
519
 
520
+ /**
521
+ * A declared table's or view's row, for application code: `RowOf<typeof orders>`
522
+ * for a declaration given its name, or `RowOf<"shop.users">` by name, which
523
+ * also covers an object only a `.sql` file declares.
524
+ */
525
+ export type RowOf<T extends keyof SqlSchema | Named<keyof SqlSchema & string>> = RowIn<SqlSchema, T>;
526
+
505
527
  export function isPostgresObject(value: unknown): value is PostgresObject {
506
528
  return isSqlObjectOf(value, "Postgres::");
507
529
  }
@@ -816,6 +838,23 @@ function foreignKeyDef(ctx: Ctx, c: ConstraintNode, columns: string[]): ForeignK
816
838
  );
817
839
  }
818
840
 
841
+ /**
842
+ * The `${}` in a foreign key's referenced column list that each name one
843
+ * column, paired with the referencing column at the same position (#3751).
844
+ * `chant sql types` types each as a column comparing like that one.
845
+ */
846
+ function referenceSlots(ctx: Ctx, c: ConstraintNode, columns: readonly string[]): TypedSlot[] {
847
+ const out: TypedSlot[] = [];
848
+ c.references!.columns.forEach((x, n) => {
849
+ const column = columns[n];
850
+ if (x.name || column === undefined) return;
851
+ const sig = ctx.tokens.slice(x.span.from, x.span.to).filter((t) => !isTrivia(t));
852
+ const t = sig[0];
853
+ if (sig.length === 1 && t?.kind === "ref" && isColumnRef(ctx.values[t.part])) out.push({ index: t.part, kind: "references", column });
854
+ });
855
+ return out;
856
+ }
857
+
819
858
  function exclusionDef(ctx: Ctx, c: ConstraintNode): ExclusionDef {
820
859
  return stripUnset({
821
860
  name: c.name,
@@ -1142,6 +1181,7 @@ function build(tag: PostgresTag, strings: TemplateStringsArray | readonly string
1142
1181
  const ddl = untokenize(tokens, (i) => render(values[i], i)).trim().replace(/;\s*$/, "");
1143
1182
  const source = { strings: rawParts };
1144
1183
  const dependsOn = [...new Set([...values, ...(joined?.refs ?? [])].filter((v) => isPostgresObject(v) || isColumnRef(v)))];
1184
+ const slots: TypedSlot[] = [];
1145
1185
  const make = <T extends PostgresObject>(type: PostgresEntityType, q: { schema?: string; name: string }, props: object, columnNames?: readonly string[]): T => {
1146
1186
  const entity = makeSqlEntity(PostgresObject.prototype as PostgresObject, type, sqlNameOf(q), props, columnNames, dependsOn) as T;
1147
1187
  const fed = ctx.fed.map((paths) => [...paths].sort());
@@ -1152,6 +1192,7 @@ function build(tag: PostgresTag, strings: TemplateStringsArray | readonly string
1152
1192
  joined.original.forEach((orig, i) => (byOriginal[orig] = fed[i]!));
1153
1193
  setInterpolationFields(entity, byOriginal);
1154
1194
  }
1195
+ setTypedSlots(entity, slots);
1155
1196
  return entity;
1156
1197
  };
1157
1198
 
@@ -1264,7 +1305,10 @@ function build(tag: PostgresTag, strings: TemplateStringsArray | readonly string
1264
1305
  const foreignKeys: ForeignKeyDef[] = [];
1265
1306
  const exclusions: ExclusionDef[] = [];
1266
1307
  const file = (c: ConstraintNode, cols: string[]) => {
1267
- if (c.kind === "FOREIGN KEY") foreignKeys.push(foreignKeyDef(ctx, c, cols));
1308
+ if (c.kind === "FOREIGN KEY") {
1309
+ foreignKeys.push(foreignKeyDef(ctx, c, cols));
1310
+ slots.push(...referenceSlots(ctx, c, cols));
1311
+ }
1268
1312
  else if (c.kind === "PRIMARY KEY") {
1269
1313
  if (primaryKey) throw new SqlTemplateError(tag, `${sqlNameOf(q)} declares more than one primary key`, 0, 0);
1270
1314
  primaryKey = keyDef(ctx, c, cols, "primaryKey");
@@ -1554,9 +1598,14 @@ export function schema(strings: TemplateStringsArray, ...values: unknown[]): Pos
1554
1598
  return build("schema", strings, values) as PostgresSchema;
1555
1599
  }
1556
1600
 
1557
- /** `` table`CREATE TABLE ...` ``: one Postgres table with its columns and constraints, parsed at build time. */
1558
- export function table(strings: TemplateStringsArray, ...values: unknown[]): PostgresTable {
1559
- return build("table", strings, values) as PostgresTable;
1601
+ /**
1602
+ * `` table`CREATE TABLE ...` ``: one Postgres table with its columns and
1603
+ * constraints, parsed at build time. Given its qualified name as the type
1604
+ * argument (`` table<"shop.orders">`...` ``), its columns are the ones
1605
+ * `chant sql types` declared for that name.
1606
+ */
1607
+ export function table<const N extends string = Unnamed>(strings: TemplateStringsArray, ...values: SlotsIn<SqlSchema, N>): PostgresTable<N> {
1608
+ return build("table", strings, values) as PostgresTable<N>;
1560
1609
  }
1561
1610
 
1562
1611
  /** `` index`CREATE [UNIQUE] INDEX [CONCURRENTLY] name ON ...` ``: one named index. */
@@ -1564,9 +1613,13 @@ export function index(strings: TemplateStringsArray, ...values: unknown[]): Post
1564
1613
  return build("index", strings, values) as PostgresIndex;
1565
1614
  }
1566
1615
 
1567
- /** `` view`CREATE [MATERIALIZED] VIEW ...` ``: one view or materialized view, with what it reads and its column lineage. */
1568
- export function view(strings: TemplateStringsArray, ...values: unknown[]): PostgresView {
1569
- return build("view", strings, values) as PostgresView;
1616
+ /**
1617
+ * `` view`CREATE [MATERIALIZED] VIEW ...` ``: one view or materialized view,
1618
+ * with what it reads and its column lineage. Takes its qualified name as the
1619
+ * type argument, as {@link table} does.
1620
+ */
1621
+ export function view<const N extends string = Unnamed>(strings: TemplateStringsArray, ...values: SlotsIn<SqlSchema, N>): PostgresView<N> {
1622
+ return build("view", strings, values) as PostgresView<N>;
1570
1623
  }
1571
1624
 
1572
1625
  /** `` sequence`CREATE SEQUENCE ...` ``: one sequence. Reference it as `nextval(${seq})`. */
@@ -21,6 +21,7 @@
21
21
  * references cross schemas freely.
22
22
  */
23
23
 
24
+ import { relationKey } from "../../core/typed";
24
25
  import type { GeneratedFile, TypeScriptGenerator } from "@intentius/chant/import/generator";
25
26
  import type { TemplateIR } from "@intentius/chant/import/parser";
26
27
  import { isTrivia, tokenizeText, type Token } from "../tokens";
@@ -233,6 +234,11 @@ const indent = (body: string) =>
233
234
  .map((l, i) => (i === 0 || l.length === 0 ? l : ` ${l}`))
234
235
  .join("\n");
235
236
 
237
+ const RELATIONS = new Set<string>([POSTGRES_ENTITY_TYPES.table, POSTGRES_ENTITY_TYPES.view, POSTGRES_ENTITY_TYPES.materializedView]);
238
+
239
+ /** A table's or view's qualified name as its tag's type argument (#3751), so its columns are typed once `chant sql types` has run. */
240
+ const typeArgument = (it: Item): string => (RELATIONS.has(it.type) ? `<${JSON.stringify(relationKey(it))}>` : "");
241
+
236
242
  export class PostgresGenerator implements TypeScriptGenerator {
237
243
  readonly ownsLayout = true;
238
244
 
@@ -256,7 +262,7 @@ export class PostgresGenerator implements TypeScriptGenerator {
256
262
  const lines: string[] = [];
257
263
  if (this.header) lines.push(...this.header.split("\n").map((l) => `// ${l}`), "");
258
264
  lines.push(`import { ${tags.join(", ")} } from "@intentius/chant-lexicon-sql/postgres";`, "");
259
- for (const it of order) lines.push(`export const ${it.exportName} = ${TAG[it.type]}\`\n ${indent(bodyText(bodies.get(it.exportName)!))}\`;`, "");
265
+ for (const it of order) lines.push(`export const ${it.exportName} = ${TAG[it.type]}${typeArgument(it)}\`\n ${indent(bodyText(bodies.get(it.exportName)!))}\`;`, "");
260
266
  return [{ path: "schema.ts", content: `${lines.join("\n").trimEnd()}\n` }];
261
267
  }
262
268
  }