@intentius/chant-lexicon-sql 0.124.11 → 0.124.13

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 (173) hide show
  1. package/dist/clickhouse/apply/statements.d.ts.map +1 -1
  2. package/dist/clickhouse/entities.d.ts +7 -1
  3. package/dist/clickhouse/entities.d.ts.map +1 -1
  4. package/dist/clickhouse/expressions.d.ts +27 -0
  5. package/dist/clickhouse/expressions.d.ts.map +1 -0
  6. package/dist/clickhouse/parser.d.ts +12 -2
  7. package/dist/clickhouse/parser.d.ts.map +1 -1
  8. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  9. package/dist/clickhouse/plan/normalize.d.ts +11 -1
  10. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  11. package/dist/clickhouse/plan/rebuild-handoff.d.ts.map +1 -1
  12. package/dist/clickhouse/plan/rules.d.ts +1 -0
  13. package/dist/clickhouse/plan/rules.d.ts.map +1 -1
  14. package/dist/clickhouse.d.ts +1 -0
  15. package/dist/clickhouse.d.ts.map +1 -1
  16. package/dist/codegen/classifier-page.d.ts.map +1 -1
  17. package/dist/codegen/postgres-classifier-page.d.ts.map +1 -1
  18. package/dist/core/cursor.d.ts +41 -0
  19. package/dist/core/cursor.d.ts.map +1 -1
  20. package/dist/core/find-templates.d.ts +1 -1
  21. package/dist/core/find-templates.d.ts.map +1 -1
  22. package/dist/core/interpolation.d.ts +18 -1
  23. package/dist/core/interpolation.d.ts.map +1 -1
  24. package/dist/files/clickhouse.d.ts +2 -2
  25. package/dist/files/clickhouse.d.ts.map +1 -1
  26. package/dist/files/common.d.ts +16 -0
  27. package/dist/files/common.d.ts.map +1 -1
  28. package/dist/files/postgres.d.ts +2 -2
  29. package/dist/files/postgres.d.ts.map +1 -1
  30. package/dist/import-parser.d.ts +2 -1
  31. package/dist/import-parser.d.ts.map +1 -1
  32. package/dist/integrity.json +24 -24
  33. package/dist/lint/post-synth/clickhouse-helpers.d.ts +6 -19
  34. package/dist/lint/post-synth/clickhouse-helpers.d.ts.map +1 -1
  35. package/dist/lint/post-synth/postgres-helpers.d.ts +15 -55
  36. package/dist/lint/post-synth/postgres-helpers.d.ts.map +1 -1
  37. package/dist/lint/post-synth/postgres-names.d.ts +2 -2
  38. package/dist/lint/post-synth/postgres-names.d.ts.map +1 -1
  39. package/dist/lint/post-synth/sql-helpers.d.ts +5 -6
  40. package/dist/lint/post-synth/sql-helpers.d.ts.map +1 -1
  41. package/dist/lint/post-synth/sqlch102.d.ts.map +1 -1
  42. package/dist/lint/post-synth/sqlch103.d.ts.map +1 -1
  43. package/dist/lint/post-synth/sqlch104.d.ts.map +1 -1
  44. package/dist/lint/post-synth/sqlch105.d.ts.map +1 -1
  45. package/dist/lint/post-synth/sqlch106.d.ts.map +1 -1
  46. package/dist/lint/post-synth/sqlch107.d.ts.map +1 -1
  47. package/dist/lint/post-synth/sqlch111.d.ts.map +1 -1
  48. package/dist/lint/post-synth/sqlch112.d.ts.map +1 -1
  49. package/dist/lint/post-synth/sqlch113.d.ts.map +1 -1
  50. package/dist/lint/post-synth/sqlch114.d.ts.map +1 -1
  51. package/dist/lint/post-synth/sqlch115.d.ts.map +1 -1
  52. package/dist/lint/post-synth/sqlch120.d.ts.map +1 -1
  53. package/dist/lint/post-synth/sqlch125.d.ts.map +1 -1
  54. package/dist/lint/rules/postgres-templates.d.ts +1 -1
  55. package/dist/lint/rules/postgres-templates.d.ts.map +1 -1
  56. package/dist/lint/rules/templates.d.ts +1 -1
  57. package/dist/lint/rules/templates.d.ts.map +1 -1
  58. package/dist/lint/rules/typed-names.d.ts +1 -1
  59. package/dist/lint/rules/typed-names.d.ts.map +1 -1
  60. package/dist/manifest.json +1 -1
  61. package/dist/postgres/apply/statements.d.ts.map +1 -1
  62. package/dist/postgres/entities.d.ts +7 -1
  63. package/dist/postgres/entities.d.ts.map +1 -1
  64. package/dist/postgres/parser.d.ts +10 -2
  65. package/dist/postgres/parser.d.ts.map +1 -1
  66. package/dist/postgres/plan/diff.d.ts.map +1 -1
  67. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  68. package/dist/postgres/plan/rules.d.ts +1 -0
  69. package/dist/postgres/plan/rules.d.ts.map +1 -1
  70. package/dist/postgres.d.ts +1 -0
  71. package/dist/postgres.d.ts.map +1 -1
  72. package/dist/read/clickhouse.d.ts +66 -0
  73. package/dist/read/clickhouse.d.ts.map +1 -0
  74. package/dist/read/objects.d.ts +99 -0
  75. package/dist/read/objects.d.ts.map +1 -0
  76. package/dist/read/postgres.d.ts +83 -0
  77. package/dist/read/postgres.d.ts.map +1 -0
  78. package/dist/read.d.ts +49 -0
  79. package/dist/read.d.ts.map +1 -0
  80. package/dist/rules/clickhouse-helpers.ts +20 -97
  81. package/dist/rules/postgres-helpers.ts +18 -53
  82. package/dist/rules/postgres-names.ts +6 -6
  83. package/dist/rules/postgres-templates.ts +1 -1
  84. package/dist/rules/sql-helpers.ts +8 -28
  85. package/dist/rules/sqlch003.ts +1 -1
  86. package/dist/rules/sqlch102.ts +2 -3
  87. package/dist/rules/sqlch103.ts +2 -3
  88. package/dist/rules/sqlch104.ts +2 -3
  89. package/dist/rules/sqlch105.ts +2 -3
  90. package/dist/rules/sqlch106.ts +2 -3
  91. package/dist/rules/sqlch107.ts +2 -3
  92. package/dist/rules/sqlch111.ts +2 -3
  93. package/dist/rules/sqlch112.ts +2 -3
  94. package/dist/rules/sqlch113.ts +2 -3
  95. package/dist/rules/sqlch114.ts +2 -3
  96. package/dist/rules/sqlch115.ts +2 -3
  97. package/dist/rules/sqlch120.ts +2 -3
  98. package/dist/rules/sqlch125.ts +2 -3
  99. package/dist/rules/sqlpg002.ts +1 -1
  100. package/dist/rules/templates.ts +1 -1
  101. package/dist/rules/typed-names.ts +1 -1
  102. package/dist/serializer.d.ts +11 -1
  103. package/dist/serializer.d.ts.map +1 -1
  104. package/dist/sql-files.d.ts +17 -1
  105. package/dist/sql-files.d.ts.map +1 -1
  106. package/dist/sql-types.d.ts +3 -0
  107. package/dist/sql-types.d.ts.map +1 -1
  108. package/package.json +2 -3
  109. package/src/clickhouse/apply/statements.ts +2 -0
  110. package/src/clickhouse/entities.ts +8 -1
  111. package/src/clickhouse/expressions.ts +108 -0
  112. package/src/clickhouse/parser.ts +113 -31
  113. package/src/clickhouse/plan/diff.ts +19 -0
  114. package/src/clickhouse/plan/normalize.ts +27 -2
  115. package/src/clickhouse/plan/rebuild-handoff.ts +2 -1
  116. package/src/clickhouse/plan/rules.ts +7 -0
  117. package/src/clickhouse/testdata/server-ddl-baseline.json +67 -0
  118. package/src/clickhouse.ts +10 -0
  119. package/src/codegen/classifier-page.ts +2 -0
  120. package/src/codegen/postgres-classifier-page.ts +2 -0
  121. package/src/core/cursor.ts +96 -0
  122. package/src/core/find-templates.ts +1 -1
  123. package/src/core/interpolation.ts +32 -1
  124. package/src/files/clickhouse.ts +7 -5
  125. package/src/files/common.ts +23 -0
  126. package/src/files/postgres.ts +12 -8
  127. package/src/import-parser.ts +2 -1
  128. package/src/lint/post-synth/clickhouse-helpers.ts +20 -97
  129. package/src/lint/post-synth/postgres-helpers.ts +18 -53
  130. package/src/lint/post-synth/postgres-names.ts +6 -6
  131. package/src/lint/post-synth/sql-helpers.ts +8 -28
  132. package/src/lint/post-synth/sqlch102.ts +2 -3
  133. package/src/lint/post-synth/sqlch103.ts +2 -3
  134. package/src/lint/post-synth/sqlch104.ts +2 -3
  135. package/src/lint/post-synth/sqlch105.ts +2 -3
  136. package/src/lint/post-synth/sqlch106.ts +2 -3
  137. package/src/lint/post-synth/sqlch107.ts +2 -3
  138. package/src/lint/post-synth/sqlch111.ts +2 -3
  139. package/src/lint/post-synth/sqlch112.ts +2 -3
  140. package/src/lint/post-synth/sqlch113.ts +2 -3
  141. package/src/lint/post-synth/sqlch114.ts +2 -3
  142. package/src/lint/post-synth/sqlch115.ts +2 -3
  143. package/src/lint/post-synth/sqlch120.ts +2 -3
  144. package/src/lint/post-synth/sqlch125.ts +2 -3
  145. package/src/lint/rules/postgres-templates.ts +1 -1
  146. package/src/lint/rules/sqlch003.ts +1 -1
  147. package/src/lint/rules/sqlpg002.ts +1 -1
  148. package/src/lint/rules/templates.ts +1 -1
  149. package/src/lint/rules/typed-names.ts +1 -1
  150. package/src/lint/rules/typescript-7.test.ts +101 -0
  151. package/src/lsp/completions.ts +1 -1
  152. package/src/lsp/postgres-template.ts +1 -1
  153. package/src/lsp/postgres.ts +1 -1
  154. package/src/lsp/refs.ts +1 -1
  155. package/src/lsp/template.ts +1 -1
  156. package/src/postgres/apply/statements.ts +2 -0
  157. package/src/postgres/entities.ts +9 -1
  158. package/src/postgres/parser.ts +31 -8
  159. package/src/postgres/plan/diff.ts +18 -0
  160. package/src/postgres/plan/normalize.ts +12 -0
  161. package/src/postgres/plan/rules.ts +7 -0
  162. package/src/postgres/testdata/server-ddl-baseline.json +38 -0
  163. package/src/postgres.ts +12 -0
  164. package/src/read/clickhouse.ts +137 -0
  165. package/src/read/objects.ts +137 -0
  166. package/src/read/postgres.ts +180 -0
  167. package/src/read-policies.e2e.test.ts +144 -0
  168. package/src/read.test.ts +143 -0
  169. package/src/read.ts +69 -0
  170. package/src/serializer.ts +37 -1
  171. package/src/sql-files.ts +27 -6
  172. package/src/sql-types.ts +19 -2
  173. package/src/unknown-clauses.test.ts +227 -0
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The Postgres half of the public read API (`../read.ts`): the build's
3
+ * tables, columns, keys, indexes and views as typed values, built from the
4
+ * serialized output. The lexicon's post-synth checks read the same values
5
+ * (`../lint/post-synth/postgres-helpers.ts`).
6
+ */
7
+
8
+ import { POSTGRES_MAJORS } from "../generated/postgres";
9
+ import { relationKey } from "../core/typed";
10
+ import { parseType, projectFromObjects, resolveType, typeLabel, type PgProject } from "../lint/post-synth/postgres-names";
11
+ import {
12
+ dialectObjects,
13
+ recordedPostgresMajor,
14
+ type PostgresIndexJson,
15
+ type PostgresTableJson,
16
+ type PostgresViewJson,
17
+ type SqlObjectJson,
18
+ type SqlReadSource,
19
+ } from "./objects";
20
+ import type { ColumnDef } from "../postgres/entities";
21
+
22
+ /** A column of a Postgres table, as declared, with what follows from the declaration. */
23
+ export interface PostgresColumnInfo extends ColumnDef {
24
+ /**
25
+ * Whether the column can hold NULL: false when it is `NOT NULL`, in the
26
+ * primary key, an identity column or a `serial`, true otherwise.
27
+ */
28
+ nullable: boolean;
29
+ /**
30
+ * The type's canonical name, without modifiers or array brackets: a
31
+ * built-in as Postgres's `format_type()` spells it (`timestamptz` is
32
+ * `timestamp with time zone`, `int8` and `bigserial` are `bigint`), a type
33
+ * the project declares by its SQL name, anything else as written, lower
34
+ * case. Undefined when the column has no type of its own (a partition, a
35
+ * typed table) or the type is not a plain name.
36
+ */
37
+ typeName?: string;
38
+ /** `text[]`, `integer ARRAY`. */
39
+ array: boolean;
40
+ /** One of the primary key's columns. */
41
+ inPrimaryKey: boolean;
42
+ }
43
+
44
+ /** A `CREATE INDEX`, with its table's export name and the columns it leads with. */
45
+ export interface PostgresIndexInfo extends PostgresIndexJson {
46
+ /** The elements that are bare columns, in order, up to the first expression. */
47
+ columns: string[];
48
+ }
49
+
50
+ /** A Postgres table, as declared: its columns, keys, constraints and the indexes declared on it. */
51
+ export interface PostgresTableInfo extends Omit<PostgresTableJson, "columns"> {
52
+ /** `schema.name` when the DDL qualifies it, else the name: the table's key in `SqlSchema`. */
53
+ qualifiedName: string;
54
+ columns: PostgresColumnInfo[];
55
+ /** The `CREATE INDEX` statements on this table (constraints' own indexes are `primaryKey` and `uniques`). */
56
+ indexes: PostgresIndexInfo[];
57
+ /** The column named `name`, if the table declares one. */
58
+ column(name: string): PostgresColumnInfo | undefined;
59
+ }
60
+
61
+ /** A view or materialized view, with the names of its output columns. */
62
+ export interface PostgresViewInfo extends PostgresViewJson {
63
+ qualifiedName: string;
64
+ materialized: boolean;
65
+ /** The declared column list, else the outputs of the query's select list. */
66
+ outputs: string[];
67
+ }
68
+
69
+ /** What a Postgres build declares. */
70
+ export interface PostgresRead {
71
+ dialect: "postgres";
72
+ /** The major the build targets. */
73
+ major: number;
74
+ tables: PostgresTableInfo[];
75
+ views: PostgresViewInfo[];
76
+ indexes: PostgresIndexInfo[];
77
+ /** Every serialized object, sequences, functions, roles and grants included, in apply order. */
78
+ objects: SqlObjectJson[];
79
+ /** A table by export name, `schema.name`, or name. */
80
+ table(ref: string): PostgresTableInfo | undefined;
81
+ }
82
+
83
+ const PINNED_MAJOR: number = POSTGRES_MAJORS[POSTGRES_MAJORS.length - 1]!;
84
+
85
+ /** A column type's canonical name, array-ness and serial-ness against a project. */
86
+ function typeOf(p: PgProject, text: string | undefined): { typeName?: string; array: boolean; serial: boolean } {
87
+ const t = parseType(text);
88
+ if (!t) return { ...(text ? { typeName: text.trim().toLowerCase() } : {}), array: false, serial: false };
89
+ const r = resolveType(p, t, { serial: true });
90
+ const typeName =
91
+ r.kind === "builtin" || r.kind === "absent" ? r.canonical : r.kind === "declared" ? String(r.object.sqlName ?? r.object.name) : typeLabel(t);
92
+ return { typeName, array: t.array, serial: r.kind === "builtin" && r.serial === true };
93
+ }
94
+
95
+ /**
96
+ * The canonical name of a Postgres type as written: `timestamptz` and
97
+ * `TIMESTAMP WITH TIME ZONE` are both `timestamp with time zone`, `int4` is
98
+ * `integer`. Compare a column's {@link PostgresColumnInfo.typeName} with it,
99
+ * so a rule does not depend on how the DDL spelled the type.
100
+ */
101
+ export function canonicalPostgresType(text: string): string | undefined {
102
+ return typeOf(projectFromObjects([], PINNED_MAJOR), text).typeName;
103
+ }
104
+
105
+ function columnInfo(p: PgProject, c: ColumnDef, pk: ReadonlySet<string>): PostgresColumnInfo {
106
+ const { typeName, array, serial } = typeOf(p, c.type);
107
+ const inPrimaryKey = pk.has(c.name);
108
+ const nullable = !(c.notNull === true || inPrimaryKey || c.generated?.kind === "identity" || serial);
109
+ return { ...c, nullable, ...(typeName !== undefined ? { typeName } : {}), array, inPrimaryKey };
110
+ }
111
+
112
+ function indexInfo(i: PostgresIndexJson): PostgresIndexInfo {
113
+ const columns: string[] = [];
114
+ for (const e of i.elements ?? []) {
115
+ if (!e.column) break;
116
+ columns.push(e.column);
117
+ }
118
+ return { ...i, columns };
119
+ }
120
+
121
+ function build(objects: SqlObjectJson[], major: number): PostgresRead {
122
+ const p = projectFromObjects(objects, major);
123
+ const indexes = objects.filter((o) => o.type === "Postgres::Index").map((o) => indexInfo(o as unknown as PostgresIndexJson));
124
+ const tables = objects
125
+ .filter((o) => o.type === "Postgres::Table")
126
+ .map((o): PostgresTableInfo => {
127
+ const t = o as unknown as PostgresTableJson;
128
+ const pk = new Set(t.primaryKey?.columns ?? []);
129
+ const columns = (t.columns ?? []).map((c) => columnInfo(p, c, pk));
130
+ return {
131
+ ...t,
132
+ qualifiedName: relationKey(t),
133
+ columns,
134
+ indexes: indexes.filter((i) => i.table === t.export || i.tableName === t.sqlName),
135
+ column: (name) => columns.find((c) => c.name === name),
136
+ };
137
+ });
138
+ const views = objects
139
+ .filter((o) => o.type === "Postgres::View" || o.type === "Postgres::MaterializedView")
140
+ .map((o): PostgresViewInfo => {
141
+ const v = o as unknown as PostgresViewJson;
142
+ const declared = v.columns ?? [];
143
+ return {
144
+ ...v,
145
+ qualifiedName: relationKey(v),
146
+ materialized: v.type === "Postgres::MaterializedView",
147
+ outputs: declared.length > 0 ? [...declared] : (v.lineage ?? []).map((e) => e.output),
148
+ };
149
+ });
150
+ return {
151
+ dialect: "postgres",
152
+ major,
153
+ tables,
154
+ views,
155
+ indexes,
156
+ objects,
157
+ table: (ref) => tables.find((t) => t.export === ref) ?? tables.find((t) => t.qualifiedName === ref) ?? tables.find((t) => t.name === ref),
158
+ };
159
+ }
160
+
161
+ const cache = new WeakMap<object, PostgresRead>();
162
+
163
+ /**
164
+ * What a Postgres build declares, read from a post-synth context's `docs`
165
+ * (a `lint.policies` check's `ctx`), or from the serialized output document
166
+ * itself. Empty when the build has no Postgres output. The result is cached
167
+ * per context; treat it as read-only.
168
+ */
169
+ export function readPostgres(source: SqlReadSource | { dialect: string; objects: unknown[]; postgresMajor?: number }): PostgresRead {
170
+ if ("objects" in source) {
171
+ const objects = source.dialect === "postgres" ? (source.objects as SqlObjectJson[]) : [];
172
+ return build(objects, typeof source.postgresMajor === "number" ? source.postgresMajor : PINNED_MAJOR);
173
+ }
174
+ const key = source.docs;
175
+ const hit = key ? cache.get(key) : undefined;
176
+ if (hit) return hit;
177
+ const read = build(dialectObjects(source, "postgres"), recordedPostgresMajor(source, PINNED_MAJOR));
178
+ if (key) cache.set(key, read);
179
+ return read;
180
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * #3870: a project's own `lint.policies`, written against
3
+ * `@intentius/chant-lexicon-sql/read`, fail `chant lint` and `chant build` on
4
+ * a table that breaks them, and `tsc` checks the policy itself.
5
+ *
6
+ * Each case copies `examples/postgres-policies` into a temporary project that
7
+ * reaches the workspace packages through a `node_modules` link, as an
8
+ * installed project would.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
12
+ import { cpSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join, resolve } from "node:path";
15
+ import { lintCommand } from "@intentius/chant/cli/commands/lint";
16
+ import { buildCommand } from "@intentius/chant/cli/commands/build";
17
+ import { ts } from "@intentius/chant/lint/typescript";
18
+ import { sqlPlugin, sqlSerializer } from "@intentius/chant-lexicon-sql";
19
+
20
+ const here = import.meta.dirname;
21
+ const example = resolve(here, "..", "examples", "postgres-policies");
22
+ const repoNodeModules = resolve(here, "..", "..", "..", "node_modules");
23
+
24
+ let root: string;
25
+
26
+ beforeEach(() => {
27
+ root = realpathSync(mkdtempSync(join(tmpdir(), "chant-3870-")));
28
+ for (const f of ["chant.config.ts", "policies", "src"]) cpSync(join(example, f), join(root, f), { recursive: true });
29
+ symlinkSync(repoNodeModules, join(root, "node_modules"), "dir");
30
+ process.env.NO_COLOR = "1";
31
+ });
32
+
33
+ afterEach(() => {
34
+ rmSync(root, { recursive: true, force: true });
35
+ delete process.env.NO_COLOR;
36
+ });
37
+
38
+ /** Drop `sessions.created_at` from the example's schema. */
39
+ function breakSessions(): void {
40
+ const file = join(root, "src", "app.ts");
41
+ const text = readFileSync(file, "utf-8").replace(
42
+ " account_id bigint NOT NULL REFERENCES ${accounts} (id),\n created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()\n",
43
+ " account_id bigint NOT NULL REFERENCES ${accounts} (id)\n",
44
+ );
45
+ expect(text).not.toContain("TIMESTAMP WITH TIME ZONE");
46
+ writeFileSync(file, text);
47
+ }
48
+
49
+ const policyIds = (diags: Array<{ ruleId: string }>) => diags.map((d) => d.ruleId).filter((id) => id.startsWith("ORG-"));
50
+
51
+ describe("project policies over the sql read API (#3870)", () => {
52
+ test("chant lint: the example is clean, and a table without created_at fails", async () => {
53
+ const clean = await lintCommand({ path: join(root, "src"), format: "stylish" });
54
+ expect(policyIds(clean.diagnostics), clean.output).toEqual([]);
55
+
56
+ breakSessions();
57
+ const result = await lintCommand({ path: join(root, "src"), format: "stylish" });
58
+ expect(result.success).toBe(false);
59
+ const diag = result.diagnostics.find((d) => d.ruleId === "ORG-CREATED-AT");
60
+ expect(diag, result.output).toBeDefined();
61
+ expect(diag!.severity).toBe("error");
62
+ expect(diag!.message).toContain("app.sessions has no created_at column");
63
+ expect(diag!.file).toBe(join(root, "src", "app.ts"));
64
+ });
65
+
66
+ test("chant lint applies lint.rules to a policy's id", async () => {
67
+ breakSessions();
68
+ writeFileSync(
69
+ join(root, "chant.config.ts"),
70
+ readFileSync(join(root, "chant.config.ts"), "utf-8").replace(
71
+ 'lint: { policies: ["policies/columns.ts"] }',
72
+ 'lint: { policies: ["policies/columns.ts"], rules: { "ORG-CREATED-AT": "off" } }',
73
+ ),
74
+ );
75
+ const result = await lintCommand({ path: join(root, "src"), format: "stylish" });
76
+ expect(policyIds(result.diagnostics), result.output).toEqual([]);
77
+ });
78
+
79
+ test("chant build fails on the same table", async () => {
80
+ breakSessions();
81
+ const result = await buildCommand({ path: join(root, "src"), format: "json", serializers: [sqlSerializer], plugins: [sqlPlugin] });
82
+ expect(result.success).toBe(false);
83
+ expect(result.errors.join("\n")).toContain("[policy:ORG-CREATED-AT] [sessions]");
84
+ });
85
+
86
+ // The first case above lints the same files with no env and gets no ORG-COLUMN-COMMENTS finding.
87
+ // (The config is written before any lint here: a loaded chant.config.ts is cached for the process.)
88
+ test("an env-aware policy fires in prod", async () => {
89
+ writeFileSync(
90
+ join(root, "chant.config.ts"),
91
+ readFileSync(join(root, "chant.config.ts"), "utf-8").replace('sql: { dialect: "postgres" },', 'sql: { dialect: "postgres" },\n ownership: { env: "prod" },'),
92
+ );
93
+ const prod = await lintCommand({ path: join(root, "src"), format: "stylish" });
94
+ expect(prod.diagnostics.filter((d) => d.ruleId === "ORG-COLUMN-COMMENTS").map((d) => d.message), prod.output).toEqual([
95
+ "[accounts] app.accounts.created_at has no comment",
96
+ "[sessions] app.sessions.account_id has no comment",
97
+ "[sessions] app.sessions.created_at has no comment",
98
+ ]);
99
+ });
100
+
101
+ test("the ClickHouse policy flags a MergeTree table without created_at, and a Nullable sort key column", async () => {
102
+ rmSync(join(root, "src"), { recursive: true });
103
+ mkdirSync(join(root, "src"));
104
+ writeFileSync(
105
+ join(root, "chant.config.ts"),
106
+ 'import type { ChantConfig } from "@intentius/chant";\n' +
107
+ 'export default { lexicons: ["sql"], sql: { dialect: "clickhouse" }, lint: { policies: ["policies/clickhouse.ts"], rules: { SQLCH113: "off" } } } satisfies ChantConfig;\n',
108
+ );
109
+ writeFileSync(
110
+ join(root, "src", "schema.ts"),
111
+ 'import { database, table } from "@intentius/chant-lexicon-sql/clickhouse";\n' +
112
+ "export const shop = database`CREATE DATABASE shop ENGINE = Atomic`;\n" +
113
+ "export const events = table`CREATE TABLE ${shop}.events (id UInt64, created_at DateTime) ENGINE = MergeTree ORDER BY id`;\n" +
114
+ "export const visits = table`CREATE TABLE ${shop}.visits (id UInt64, ref Nullable(String)) ENGINE = MergeTree ORDER BY (id, ref) SETTINGS allow_nullable_key = 1`;\n",
115
+ );
116
+ const result = await lintCommand({ path: join(root, "src"), format: "stylish" });
117
+ expect(result.diagnostics.filter((d) => d.ruleId === "ORG-CH-CREATED-AT").map((d) => d.message), result.output).toEqual([
118
+ "[visits] shop.visits needs created_at DateTime, not Nullable",
119
+ "[visits] shop.visits.ref is Nullable and in the sort key",
120
+ ]);
121
+ });
122
+
123
+ test("tsc checks a policy against the read API's types", () => {
124
+ const policy = join(root, "policies", "columns.ts");
125
+ const check = () => {
126
+ const program = ts.createProgram([policy], {
127
+ target: ts.ScriptTarget.ES2022,
128
+ module: ts.ModuleKind.ESNext,
129
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
130
+ strict: true,
131
+ noEmit: true,
132
+ skipLibCheck: true,
133
+ types: [],
134
+ });
135
+ return ts
136
+ .getPreEmitDiagnostics(program)
137
+ .filter((d) => d.file?.fileName === policy)
138
+ .map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"));
139
+ };
140
+ expect(check()).toEqual([]);
141
+ writeFileSync(policy, readFileSync(policy, "utf-8").replace("c.typeName === TIMESTAMPTZ", "c.typeNmae === TIMESTAMPTZ"));
142
+ expect(check().join("\n")).toMatch(/Property 'typeNmae' does not exist on type 'PostgresColumnInfo'/);
143
+ }, 60_000);
144
+ });
@@ -0,0 +1,143 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { makePostSynthCtx } from "@intentius/chant-test-utils";
3
+ import type { PostSynthCheck, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
4
+ import type { SerializerResult } from "@intentius/chant/serializer";
5
+ import { sqlSerializer } from "./serializer";
6
+ import { database, table as chTable, view as chView } from "./clickhouse/entities";
7
+ import { index, schema, table, view } from "./postgres/entities";
8
+ import { canonicalPostgresType, readClickHouse, readPostgres, readSql } from "./read";
9
+ import { readPostgres as fromPostgresSubpath } from "./postgres";
10
+ import { readClickHouse as fromClickHouseSubpath } from "./clickhouse";
11
+ import { tablesOf as pgTablesOf } from "./lint/post-synth/postgres-helpers";
12
+ import { tablesOf as chTablesOf } from "./lint/post-synth/clickhouse-helpers";
13
+
14
+ const ctxOf = (entities: Record<string, unknown>) => {
15
+ const out = sqlSerializer.serialize(new Map(Object.entries(entities)) as never) as SerializerResult;
16
+ return makePostSynthCtx("sql", out.primary);
17
+ };
18
+
19
+ describe("readPostgres (#3870)", () => {
20
+ const app = schema`CREATE SCHEMA app`;
21
+ const users = table`
22
+ CREATE TABLE ${app}.users (
23
+ id bigserial PRIMARY KEY,
24
+ email text NOT NULL UNIQUE,
25
+ nickname varchar(40) DEFAULT 'anon',
26
+ tags text[],
27
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
28
+ )`;
29
+ const orders = table`
30
+ CREATE TABLE ${app}.orders (
31
+ id bigint GENERATED ALWAYS AS IDENTITY,
32
+ user_id bigint NOT NULL REFERENCES ${users} (id),
33
+ created_at timestamp,
34
+ PRIMARY KEY (id)
35
+ )`;
36
+ const ordersUser = index`CREATE INDEX orders_user_id ON ${orders} (user_id, (lower('x')))`;
37
+ const totals = view`CREATE VIEW ${app}.totals AS SELECT user_id, count(*) AS n FROM ${orders} GROUP BY user_id`;
38
+ const ctx = ctxOf({ app, users, orders, ordersUser, totals });
39
+
40
+ test("tables carry typed columns: canonical type, nullability, default, keys", () => {
41
+ const db = readPostgres(ctx);
42
+ expect(db.dialect).toBe("postgres");
43
+ expect(db.tables.map((t) => t.qualifiedName).sort()).toEqual(["app.orders", "app.users"]);
44
+ const u = db.table("users")!;
45
+ expect(u.column("id")).toMatchObject({ typeName: "bigint", nullable: false, inPrimaryKey: true });
46
+ expect(u.column("email")).toMatchObject({ typeName: "text", nullable: false, inPrimaryKey: false });
47
+ expect(u.column("nickname")).toMatchObject({ typeName: "character varying", nullable: true, default: "'anon'" });
48
+ expect(u.column("tags")).toMatchObject({ typeName: "text", array: true });
49
+ expect(u.column("created_at")).toMatchObject({ typeName: "timestamp with time zone", nullable: false, default: "now()" });
50
+ expect(u.uniques[0]!.columns).toEqual(["email"]);
51
+ expect(db.table("app.orders")!.column("id")).toMatchObject({ nullable: false, inPrimaryKey: true });
52
+ expect(db.table("orders")!.column("created_at")).toMatchObject({ typeName: "timestamp without time zone", nullable: true });
53
+ expect(db.table("orders")!.foreignKeys[0]).toMatchObject({ columns: ["user_id"], references: "users" });
54
+ });
55
+
56
+ test("indexes belong to their table and lead with their bare columns", () => {
57
+ const db = readPostgres(ctx);
58
+ expect(db.indexes).toHaveLength(1);
59
+ expect(db.indexes[0]).toMatchObject({ export: "ordersUser", table: "orders", columns: ["user_id"] });
60
+ expect(db.table("orders")!.indexes.map((i) => i.export)).toEqual(["ordersUser"]);
61
+ });
62
+
63
+ test("views name their outputs and keep their lineage", () => {
64
+ const v = readPostgres(ctx).views[0]!;
65
+ expect(v).toMatchObject({ qualifiedName: "app.totals", materialized: false, outputs: ["user_id", "n"] });
66
+ expect(v.lineage.map((e) => e.output)).toEqual(["user_id", "n"]);
67
+ });
68
+
69
+ test("canonicalPostgresType spells aliases the way typeName does", () => {
70
+ expect(canonicalPostgresType("timestamptz")).toBe("timestamp with time zone");
71
+ expect(canonicalPostgresType("INT4")).toBe("integer");
72
+ expect(canonicalPostgresType("varchar(10)")).toBe("character varying");
73
+ });
74
+
75
+ test("the checks read the same values, and the dialect subpath exports the same reader", () => {
76
+ expect(pgTablesOf(ctx)).toBe(readPostgres(ctx).tables);
77
+ expect(fromPostgresSubpath).toBe(readPostgres);
78
+ expect(readClickHouse(ctx).tables).toEqual([]);
79
+ expect(readSql(ctx)?.dialect).toBe("postgres");
80
+ });
81
+
82
+ test("reads a serialized document as well as a context", () => {
83
+ const doc = JSON.parse((sqlSerializer.serialize(new Map(Object.entries({ app, users })) as never) as SerializerResult).primary) as { dialect: string; objects: unknown[] };
84
+ expect(readPostgres(doc).table("users")!.column("email")!.nullable).toBe(false);
85
+ expect(readSql(doc as never)?.dialect).toBe("postgres");
86
+ });
87
+
88
+ test("a project policy written against the read API flags a table without created_at timestamptz NOT NULL", () => {
89
+ const TIMESTAMPTZ = canonicalPostgresType("timestamptz");
90
+ const policy: PostSynthCheck = {
91
+ id: "ORG-CREATED-AT",
92
+ description: "every table has created_at timestamptz NOT NULL",
93
+ check(c): PostSynthDiagnostic[] {
94
+ return readPostgres(c)
95
+ .tables.filter((t) => {
96
+ const col = t.column("created_at");
97
+ return !col || col.typeName !== TIMESTAMPTZ || col.nullable;
98
+ })
99
+ .map((t) => ({ checkId: "ORG-CREATED-AT", severity: "error", entity: t.export, message: `${t.qualifiedName} needs created_at timestamptz NOT NULL` }));
100
+ },
101
+ };
102
+ expect(policy.check(ctx).map((d) => d.entity)).toEqual(["orders"]);
103
+ });
104
+ });
105
+
106
+ describe("readClickHouse (#3870)", () => {
107
+ const shop = database`CREATE DATABASE shop ENGINE = Atomic`;
108
+ const events = chTable`
109
+ CREATE TABLE ${shop}.events (
110
+ ts DateTime64(3) CODEC(Delta, ZSTD),
111
+ kind LowCardinality(String),
112
+ user_id UInt64,
113
+ ref Nullable(String) COMMENT 'referrer',
114
+ label LowCardinality(Nullable(String)) DEFAULT 'x',
115
+ INDEX kind_idx kind TYPE set(100) GRANULARITY 4
116
+ )
117
+ ENGINE = ReplicatedReplacingMergeTree('/ch/{shard}/events', '{replica}', ts)
118
+ ORDER BY (kind, toDate(ts), user_id)`;
119
+ const daily = chView`CREATE VIEW ${shop}.daily AS SELECT toDate(ts) AS day, count() AS n FROM ${events} GROUP BY day`;
120
+ const ctx = ctxOf({ shop, events, daily });
121
+
122
+ test("tables carry typed columns, the engine family and the keys", () => {
123
+ const db = readClickHouse(ctx);
124
+ const t = db.table("shop.events")!;
125
+ expect(t).toMatchObject({ qualifiedName: "shop.events", engineFamily: "ReplacingMergeTree", mergeTree: true });
126
+ expect(t.sortKey).toEqual(["kind", "toDate(ts)", "user_id"]);
127
+ expect(t.primaryKeyElements).toEqual(t.sortKey);
128
+ expect(t.column("ts")).toMatchObject({ family: "DateTime64", nullable: false, inSortKey: false, codec: "Delta, ZSTD" });
129
+ expect(t.column("kind")).toMatchObject({ lowCardinality: true, baseType: "String", nullable: false, inSortKey: true });
130
+ expect(t.column("ref")).toMatchObject({ nullable: true, comment: "referrer" });
131
+ expect(t.column("label")).toMatchObject({ nullable: true, lowCardinality: true, default: { kind: "DEFAULT", expr: "'x'" } });
132
+ expect(t.indexes[0]).toMatchObject({ name: "kind_idx", type: "set(100)" });
133
+ });
134
+
135
+ test("views name their outputs; the checks and the subpath share the reader", () => {
136
+ const v = readClickHouse(ctx).views[0]!;
137
+ expect(v).toMatchObject({ materialized: false, outputs: ["day", "n"] });
138
+ expect(chTablesOf(ctx)).toBe(readClickHouse(ctx).tables);
139
+ expect(fromClickHouseSubpath).toBe(readClickHouse);
140
+ expect(readSql(ctx)?.dialect).toBe("clickhouse");
141
+ expect(readPostgres(ctx).tables).toEqual([]);
142
+ });
143
+ });
package/src/read.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `@intentius/chant-lexicon-sql/read`: a typed, read-only view of the SQL
3
+ * objects a build declares (#3870), for a project's own `lint.policies`.
4
+ *
5
+ * A policy's `check(ctx)` gets the build's parsed output in `ctx.docs`.
6
+ * {@link readPostgres} and {@link readClickHouse} turn the sql lexicon's part
7
+ * of it into tables with typed columns (SQL type, nullability, default,
8
+ * comment), keys, indexes and engines, and views with their outputs and
9
+ * column lineage. The lexicon's own post-synth checks read the same values,
10
+ * so a project rule sees exactly what `SQLPG101` or `SQLCH113` sees.
11
+ *
12
+ * ```ts
13
+ * import type { PostSynthCheck } from "@intentius/chant/lint/post-synth";
14
+ * import { canonicalPostgresType, readPostgres } from "@intentius/chant-lexicon-sql/read";
15
+ *
16
+ * const TIMESTAMPTZ = canonicalPostgresType("timestamptz");
17
+ *
18
+ * export const createdAt: PostSynthCheck = {
19
+ * id: "ORG-CREATED-AT",
20
+ * description: "every table has created_at timestamptz NOT NULL",
21
+ * check(ctx) {
22
+ * return readPostgres(ctx).tables
23
+ * .filter((t) => { const c = t.column("created_at"); return !c || c.typeName !== TIMESTAMPTZ || c.nullable; })
24
+ * .map((t) => ({ checkId: "ORG-CREATED-AT", severity: "error", entity: t.export,
25
+ * message: `${t.qualifiedName} needs created_at timestamptz NOT NULL` }));
26
+ * },
27
+ * };
28
+ * ```
29
+ *
30
+ * Every value keeps the fields the serializer writes (see the Serialization
31
+ * page) and adds the derived ones documented on each type. The reads also take
32
+ * the serialized document itself (`{ dialect, objects }`), for a tool that
33
+ * holds a recorded build rather than a post-synth context.
34
+ */
35
+
36
+ export { readPostgres, canonicalPostgresType } from "./read/postgres";
37
+ export type { PostgresRead, PostgresTableInfo, PostgresColumnInfo, PostgresIndexInfo, PostgresViewInfo } from "./read/postgres";
38
+ export { readClickHouse } from "./read/clickhouse";
39
+ export type { ClickHouseRead, ClickHouseTableInfo, ClickHouseColumnInfo, ClickHouseViewInfo } from "./read/clickhouse";
40
+ export { sqlDocuments, isSqlOutputDocument } from "./read/objects";
41
+ export type {
42
+ SqlReadSource,
43
+ SqlOutputDocument,
44
+ SqlObjectBase,
45
+ SqlObjectJson,
46
+ RefJson,
47
+ LineageJson,
48
+ PostgresTableJson,
49
+ PostgresIndexJson,
50
+ PostgresViewJson,
51
+ PostgresForeignKeyJson,
52
+ ClickHouseTableJson,
53
+ ClickHouseViewJson,
54
+ } from "./read/objects";
55
+
56
+ import { readClickHouse, type ClickHouseRead } from "./read/clickhouse";
57
+ import { readPostgres, type PostgresRead } from "./read/postgres";
58
+ import { sqlDocuments, type SqlOutputDocument, type SqlReadSource } from "./read/objects";
59
+
60
+ /**
61
+ * The build's SQL objects in whichever dialect it declares: one build holds
62
+ * one dialect. Undefined when the build has no sql output.
63
+ */
64
+ export function readSql(source: SqlReadSource | SqlOutputDocument): PostgresRead | ClickHouseRead | undefined {
65
+ const dialect = "objects" in source ? source.dialect : sqlDocuments(source)[0]?.dialect;
66
+ if (dialect === "postgres") return readPostgres(source);
67
+ if (dialect === "clickhouse") return readClickHouse(source);
68
+ return undefined;
69
+ }
package/src/serializer.ts CHANGED
@@ -25,6 +25,9 @@
25
25
  * targets (`sql.postgresMajor`, else the newest pinned major). Post-synth
26
26
  * checks and the lock classifier read it; ClickHouse output has no such field.
27
27
  *
28
+ * A clause chant's parser does not know (#3872) is kept in the statement as
29
+ * written and named in a build warning ({@link unparsedWarnings}).
30
+ *
28
31
  * Rule ids: `SQL` for rules that hold in every dialect, `SQLCH` for the
29
32
  * ClickHouse dialect's (#3199), `SQLPG` for Postgres's (#3289).
30
33
  */
@@ -36,10 +39,39 @@ import { isClickHouseObject, type ClickHouseObject } from "./clickhouse/entities
36
39
  import { isPostgresObject, type PostgresObject } from "./postgres/entities";
37
40
  import { POSTGRES_LATEST_MAJOR } from "./spec/postgres-pin";
38
41
  import { applyOrder, lineageJson, referenceName, type LineageEdge } from "./core/references";
39
- import { sqlFileReferences } from "./sql-files";
42
+ import { sqlFileLineOf, sqlFileOf, sqlFileReferences } from "./sql-files";
43
+ import { relative } from "node:path";
44
+ import { getProvenance } from "@intentius/chant/provenance";
45
+ import type { UnparsedClause } from "./core/interpolation";
40
46
 
41
47
  export { applyOrder } from "./core/references";
42
48
 
49
+ /**
50
+ * A warning for each clause chant's parser does not know (#3872): where it
51
+ * is (the `.sql` file and line, or the template's file and the line in its
52
+ * statement), which object, and what follows from it. The statement is sent
53
+ * as written; the server decides.
54
+ */
55
+ export function unparsedWarnings(objects: ReadonlyMap<string, Declarable>): string[] {
56
+ const out: string[] = [];
57
+ for (const [name, entity] of objects) {
58
+ const unparsed = (entity as unknown as { props?: { unparsed?: UnparsedClause[] } }).props?.unparsed;
59
+ if (!unparsed || unparsed.length === 0) continue;
60
+ const file = sqlFileOf(entity);
61
+ const start = sqlFileLineOf(entity);
62
+ const source = getProvenance(entity)?.sourceFile;
63
+ for (const u of unparsed) {
64
+ const where = file !== undefined ? `${file}:${start !== undefined ? start + u.line - 1 : u.line}` : `${source ? relative(process.cwd(), source) : "?"}: ${name}, line ${u.line} of its statement`;
65
+ const on = u.column !== undefined ? ` on column ${u.column}` : "";
66
+ out.push(
67
+ `${where}: ${(entity as { sqlName?: string }).sqlName ?? name} has a clause chant does not read, ${u.clause}${on}. ` +
68
+ "It is sent as written and the server checks it; a plan reads the rest of the object, and classes any change to the clause as one to review",
69
+ );
70
+ }
71
+ }
72
+ return out;
73
+ }
74
+
43
75
  /** The file the statements are written to, beside the primary output. */
44
76
  export const CLICKHOUSE_DDL_FILE = "clickhouse.sql";
45
77
 
@@ -104,10 +136,12 @@ function serializePostgres(
104
136
  objects: order.map((n) => postgresObjectJson(n, objects.get(n)!, names, extra.get(n))),
105
137
  };
106
138
  const ddl = order.map((n) => `${(objects.get(n)!.props as { ddl: string }).ddl};`).join("\n\n");
139
+ const warnings = unparsedWarnings(objects);
107
140
  return {
108
141
  primary: `${JSON.stringify(doc, null, 2)}\n`,
109
142
  files: { [POSTGRES_DDL_FILE]: `${ddl}\n` },
110
143
  verbatimFiles: [POSTGRES_DDL_FILE],
144
+ ...(warnings.length > 0 ? { warnings } : {}),
111
145
  };
112
146
  }
113
147
 
@@ -144,10 +178,12 @@ export const sqlSerializer: Serializer = {
144
178
  const ddl = order
145
179
  .map((n) => `${(objects.get(n)!.props as { ddl: string }).ddl};`)
146
180
  .join("\n\n");
181
+ const warnings = unparsedWarnings(objects);
147
182
  return {
148
183
  primary: `${JSON.stringify(doc, null, 2)}\n`,
149
184
  files: { [CLICKHOUSE_DDL_FILE]: `${ddl}\n` },
150
185
  verbatimFiles: [CLICKHOUSE_DDL_FILE],
186
+ ...(warnings.length > 0 ? { warnings } : {}),
151
187
  };
152
188
  },
153
189
  };