@intentius/chant-lexicon-sql 0.121.1 → 0.122.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 (150) hide show
  1. package/dist/clickhouse/catalog-types.d.ts +8 -0
  2. package/dist/clickhouse/catalog-types.d.ts.map +1 -1
  3. package/dist/clickhouse/overlays/codecs.d.ts.map +1 -1
  4. package/dist/clickhouse/overlays/kinds.d.ts +1 -1
  5. package/dist/clickhouse/overlays/kinds.d.ts.map +1 -1
  6. package/dist/clickhouse/overlays/types.d.ts.map +1 -1
  7. package/dist/clickhouse/type-syntax.d.ts +33 -0
  8. package/dist/clickhouse/type-syntax.d.ts.map +1 -0
  9. package/dist/codegen/clickhouse-module.d.ts.map +1 -1
  10. package/dist/codegen/postgres-module.d.ts.map +1 -1
  11. package/dist/generated/clickhouse.d.ts +7 -1
  12. package/dist/generated/clickhouse.d.ts.map +1 -1
  13. package/dist/generated/postgres.d.ts +6 -0
  14. package/dist/generated/postgres.d.ts.map +1 -1
  15. package/dist/integrity.json +23 -5
  16. package/dist/lint/audit-catalog.d.ts.map +1 -1
  17. package/dist/lint/post-synth/audit-entries.d.ts.map +1 -1
  18. package/dist/lint/post-synth/clickhouse-type-check.d.ts +37 -0
  19. package/dist/lint/post-synth/clickhouse-type-check.d.ts.map +1 -0
  20. package/dist/lint/post-synth/index.d.ts.map +1 -1
  21. package/dist/lint/post-synth/postgres-audit-entries.d.ts.map +1 -1
  22. package/dist/lint/post-synth/postgres-names.d.ts +149 -0
  23. package/dist/lint/post-synth/postgres-names.d.ts.map +1 -0
  24. package/dist/lint/post-synth/sql101.d.ts +9 -0
  25. package/dist/lint/post-synth/sql101.d.ts.map +1 -0
  26. package/dist/lint/post-synth/sqlch121.d.ts +10 -0
  27. package/dist/lint/post-synth/sqlch121.d.ts.map +1 -0
  28. package/dist/lint/post-synth/sqlch122.d.ts +10 -0
  29. package/dist/lint/post-synth/sqlch122.d.ts.map +1 -0
  30. package/dist/lint/post-synth/sqlch123.d.ts +9 -0
  31. package/dist/lint/post-synth/sqlch123.d.ts.map +1 -0
  32. package/dist/lint/post-synth/sqlch124.d.ts +6 -0
  33. package/dist/lint/post-synth/sqlch124.d.ts.map +1 -0
  34. package/dist/lint/post-synth/sqlch125.d.ts +11 -0
  35. package/dist/lint/post-synth/sqlch125.d.ts.map +1 -0
  36. package/dist/lint/post-synth/sqlch126.d.ts +12 -0
  37. package/dist/lint/post-synth/sqlch126.d.ts.map +1 -0
  38. package/dist/lint/post-synth/sqlch127.d.ts +22 -0
  39. package/dist/lint/post-synth/sqlch127.d.ts.map +1 -0
  40. package/dist/lint/post-synth/sqlpg119.d.ts +18 -0
  41. package/dist/lint/post-synth/sqlpg119.d.ts.map +1 -0
  42. package/dist/lint/post-synth/sqlpg120.d.ts +17 -0
  43. package/dist/lint/post-synth/sqlpg120.d.ts.map +1 -0
  44. package/dist/lint/post-synth/sqlpg121.d.ts +12 -0
  45. package/dist/lint/post-synth/sqlpg121.d.ts.map +1 -0
  46. package/dist/lint/post-synth/sqlpg122.d.ts +12 -0
  47. package/dist/lint/post-synth/sqlpg122.d.ts.map +1 -0
  48. package/dist/lint/post-synth/sqlpg123.d.ts +17 -0
  49. package/dist/lint/post-synth/sqlpg123.d.ts.map +1 -0
  50. package/dist/lint/post-synth/sqlpg124.d.ts +16 -0
  51. package/dist/lint/post-synth/sqlpg124.d.ts.map +1 -0
  52. package/dist/lint/post-synth/sqlpg125.d.ts +17 -0
  53. package/dist/lint/post-synth/sqlpg125.d.ts.map +1 -0
  54. package/dist/lint/post-synth/sqlpg126.d.ts +19 -0
  55. package/dist/lint/post-synth/sqlpg126.d.ts.map +1 -0
  56. package/dist/manifest.json +1 -1
  57. package/dist/okf/index.md +16 -0
  58. package/dist/okf/rules/SQL101.md +23 -0
  59. package/dist/okf/rules/SQLCH121.md +11 -0
  60. package/dist/okf/rules/SQLCH122.md +11 -0
  61. package/dist/okf/rules/SQLCH123.md +11 -0
  62. package/dist/okf/rules/SQLCH124.md +18 -0
  63. package/dist/okf/rules/SQLCH125.md +11 -0
  64. package/dist/okf/rules/SQLCH126.md +15 -0
  65. package/dist/okf/rules/SQLCH127.md +15 -0
  66. package/dist/okf/rules/SQLPG119.md +16 -0
  67. package/dist/okf/rules/SQLPG120.md +16 -0
  68. package/dist/okf/rules/SQLPG121.md +11 -0
  69. package/dist/okf/rules/SQLPG122.md +15 -0
  70. package/dist/okf/rules/SQLPG123.md +16 -0
  71. package/dist/okf/rules/SQLPG124.md +11 -0
  72. package/dist/okf/rules/SQLPG125.md +11 -0
  73. package/dist/okf/rules/SQLPG126.md +11 -0
  74. package/dist/okf/types/Dictionary.md +4 -0
  75. package/dist/okf/types/Function.md +5 -0
  76. package/dist/okf/types/Grant.md +6 -0
  77. package/dist/okf/types/MaterializedView.md +1 -0
  78. package/dist/okf/types/PostgresDefaultPrivileges.md +4 -0
  79. package/dist/okf/types/PostgresDomain.md +2 -0
  80. package/dist/okf/types/PostgresEnum.md +1 -0
  81. package/dist/okf/types/PostgresFunction.md +4 -0
  82. package/dist/okf/types/PostgresGrant.md +5 -0
  83. package/dist/okf/types/PostgresPolicy.md +4 -0
  84. package/dist/okf/types/PostgresProcedure.md +4 -0
  85. package/dist/okf/types/PostgresSequence.md +2 -0
  86. package/dist/okf/types/PostgresTrigger.md +4 -0
  87. package/dist/okf/types/RowPolicy.md +4 -0
  88. package/dist/okf/types/Table.md +1 -0
  89. package/dist/okf/types/View.md +1 -0
  90. package/dist/postgres/overlays/extension-objects.d.ts +28 -0
  91. package/dist/postgres/overlays/extension-objects.d.ts.map +1 -0
  92. package/dist/postgres/overlays/types.d.ts +10 -0
  93. package/dist/postgres/overlays/types.d.ts.map +1 -1
  94. package/dist/rules/audit-entries.ts +7 -0
  95. package/dist/rules/clickhouse-type-check.ts +173 -0
  96. package/dist/rules/postgres-audit-entries.ts +8 -0
  97. package/dist/rules/postgres-names.ts +506 -0
  98. package/dist/rules/sql101.ts +62 -0
  99. package/dist/rules/sqlch121.ts +12 -0
  100. package/dist/rules/sqlch122.ts +12 -0
  101. package/dist/rules/sqlch123.ts +11 -0
  102. package/dist/rules/sqlch124.ts +21 -0
  103. package/dist/rules/sqlch125.ts +56 -0
  104. package/dist/rules/sqlch126.ts +57 -0
  105. package/dist/rules/sqlch127.ts +128 -0
  106. package/dist/rules/sqlpg119.ts +37 -0
  107. package/dist/rules/sqlpg120.ts +70 -0
  108. package/dist/rules/sqlpg121.ts +32 -0
  109. package/dist/rules/sqlpg122.ts +28 -0
  110. package/dist/rules/sqlpg123.ts +63 -0
  111. package/dist/rules/sqlpg124.ts +47 -0
  112. package/dist/rules/sqlpg125.ts +40 -0
  113. package/dist/rules/sqlpg126.ts +37 -0
  114. package/package.json +2 -2
  115. package/src/clickhouse/catalog-types.ts +9 -0
  116. package/src/clickhouse/overlays/codecs.ts +6 -2
  117. package/src/clickhouse/overlays/kinds.ts +1 -1
  118. package/src/clickhouse/overlays/types.ts +20 -3
  119. package/src/clickhouse/type-syntax.ts +82 -0
  120. package/src/codegen/clickhouse-module.ts +15 -1
  121. package/src/codegen/postgres-module.ts +43 -0
  122. package/src/composites/composites.test.ts +7 -6
  123. package/src/generated/clickhouse.ts +1905 -6
  124. package/src/generated/postgres.ts +2639 -0
  125. package/src/lint/audit-catalog.ts +8 -0
  126. package/src/lint/post-synth/audit-entries.ts +7 -0
  127. package/src/lint/post-synth/clickhouse-catalog-checks.test.ts +315 -0
  128. package/src/lint/post-synth/clickhouse-type-check.ts +173 -0
  129. package/src/lint/post-synth/index.ts +32 -0
  130. package/src/lint/post-synth/postgres-audit-entries.ts +8 -0
  131. package/src/lint/post-synth/postgres-checks.test.ts +214 -1
  132. package/src/lint/post-synth/postgres-names.ts +506 -0
  133. package/src/lint/post-synth/sql101.ts +62 -0
  134. package/src/lint/post-synth/sqlch121.ts +12 -0
  135. package/src/lint/post-synth/sqlch122.ts +12 -0
  136. package/src/lint/post-synth/sqlch123.ts +11 -0
  137. package/src/lint/post-synth/sqlch124.ts +21 -0
  138. package/src/lint/post-synth/sqlch125.ts +56 -0
  139. package/src/lint/post-synth/sqlch126.ts +57 -0
  140. package/src/lint/post-synth/sqlch127.ts +128 -0
  141. package/src/lint/post-synth/sqlpg119.ts +37 -0
  142. package/src/lint/post-synth/sqlpg120.ts +70 -0
  143. package/src/lint/post-synth/sqlpg121.ts +32 -0
  144. package/src/lint/post-synth/sqlpg122.ts +28 -0
  145. package/src/lint/post-synth/sqlpg123.ts +63 -0
  146. package/src/lint/post-synth/sqlpg124.ts +47 -0
  147. package/src/lint/post-synth/sqlpg125.ts +40 -0
  148. package/src/lint/post-synth/sqlpg126.ts +37 -0
  149. package/src/postgres/overlays/extension-objects.ts +100 -0
  150. package/src/postgres/overlays/types.ts +25 -0
@@ -0,0 +1,506 @@
1
+ /**
2
+ * Names in a Postgres declaration, read against the target major's catalog:
3
+ * column types, function calls, index access methods and operator classes.
4
+ * SQLPG119 to SQLPG126 share it.
5
+ *
6
+ * The line every check here keeps: a name the catalog or the project can
7
+ * answer for is checked; a name qualified with a schema the project does not
8
+ * declare (`billing.money`, `pg_catalog.int4`) is a plain-text reference and
9
+ * left to the server; a name the core catalog lacks is a warning, not an
10
+ * error, when the project declares an extension, since extension objects are
11
+ * not in the catalog (`overlays/extension-objects.ts` covers the common ones).
12
+ */
13
+
14
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
15
+ import { COLUMN_TYPES, FUNCTIONS, INDEX_ACCESS_METHODS, KEYWORDS, OPERATOR_CLASSES } from "../../generated/postgres";
16
+ import { EXTENSION_OBJECTS } from "../../postgres/overlays/extension-objects";
17
+ import { GRAMMAR_TYPE_SPELLINGS, SERIAL_TYPES, SERIAL_WIDTH_SPELLINGS, TYPE_SPELLINGS } from "../../postgres/overlays/types";
18
+ import { isTrivia, tokenizeText, type Token } from "../../postgres/tokens";
19
+ import type { ColumnTypeSpec } from "../../postgres/catalog-types";
20
+ import { targetMajor, type PgTable } from "./postgres-helpers";
21
+ import { postgresObjects, type OutputObject } from "./sql-helpers";
22
+
23
+ // ── Catalog lookups ────────────────────────────────────────────────────
24
+
25
+ type Presence = true | readonly number[] | undefined;
26
+ const presentAt = (p: Presence, major: number): boolean => p === true || (Array.isArray(p) && p.includes(major));
27
+ const majorsOf = (p: Presence): readonly number[] => (Array.isArray(p) ? p : []);
28
+
29
+ const columnTypes = COLUMN_TYPES as Readonly<Record<string, ColumnTypeSpec>>;
30
+
31
+ /** Every spelling of a built-in type (lower case, single spaces) to its `format_type()` name. */
32
+ const BUILTIN_SPELLINGS: ReadonlyMap<string, string> = (() => {
33
+ const m = new Map<string, string>();
34
+ for (const [sql, spec] of Object.entries(columnTypes)) {
35
+ m.set(sql, sql);
36
+ if (spec.catalogName) m.set(spec.catalogName, sql);
37
+ for (const a of spec.aliases) if (!m.has(a)) m.set(a, sql);
38
+ }
39
+ for (const [a, sql] of [...Object.entries(TYPE_SPELLINGS), ...Object.entries(GRAMMAR_TYPE_SPELLINGS)]) if (!m.has(a)) m.set(a, sql);
40
+ // `char` alone is `character` (bpchar); quoted, `"char"` is the one-byte type.
41
+ m.set("char", "character");
42
+ return m;
43
+ })();
44
+
45
+ const SERIALS: Readonly<Record<string, string>> = { ...SERIAL_TYPES, ...SERIAL_WIDTH_SPELLINGS };
46
+
47
+ /** The words that may follow `interval`: its fields. */
48
+ const INTERVAL_FIELDS = new Set(["year", "month", "day", "hour", "minute", "second", "to"]);
49
+
50
+ /** The catalog category of a built-in type (`N` numeric, `S` string...). */
51
+ export const categoryOf = (canonical: string): string | undefined => columnTypes[canonical]?.category;
52
+
53
+ /** The parameters a built-in type takes, from the overlay. */
54
+ export const parametersOf = (canonical: string) => columnTypes[canonical]?.parameters ?? [];
55
+
56
+ // ── Identifiers ────────────────────────────────────────────────────────
57
+
58
+ /** An identifier's value: a quoted one as written, a bare one folded to lower case. */
59
+ export function identValue(text: string): string {
60
+ const t = text.trim();
61
+ if (t.startsWith('"') && t.endsWith('"') && t.length >= 2) return t.slice(1, -1).replace(/""/g, '"');
62
+ return t.toLowerCase();
63
+ }
64
+
65
+ /** Split on commas at depth 0. */
66
+ export function splitTopLevel(text: string): string[] {
67
+ const out: string[] = [];
68
+ let depth = 0;
69
+ let quote: string | undefined;
70
+ let cur = "";
71
+ for (const ch of text) {
72
+ if (quote) {
73
+ cur += ch;
74
+ if (ch === quote) quote = undefined;
75
+ continue;
76
+ }
77
+ if (ch === '"' || ch === "'") quote = ch;
78
+ else if (ch === "(" || ch === "[") depth++;
79
+ else if (ch === ")" || ch === "]") depth--;
80
+ else if (ch === "," && depth === 0) {
81
+ out.push(cur.trim());
82
+ cur = "";
83
+ continue;
84
+ }
85
+ cur += ch;
86
+ }
87
+ if (cur.trim()) out.push(cur.trim());
88
+ return out;
89
+ }
90
+
91
+ /** The bare column names of an `INCLUDE` list as the output keeps it (`b, "X"`, or `(b, c)`). */
92
+ export function includeColumns(text: string | undefined): string[] {
93
+ if (!text) return [];
94
+ const inner = text.trim().replace(/^\(/, "").replace(/\)$/, "");
95
+ return splitTopLevel(inner).filter((s) => /^("([^"]|"")+"|[A-Za-z_][\w$]*)$/.test(s)).map(identValue);
96
+ }
97
+
98
+ function significant(text: string): Token[] | undefined {
99
+ try {
100
+ return tokenizeText(text, 0).filter((t) => !isTrivia(t));
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ }
105
+
106
+ const isName = (t: Token | undefined): boolean => t?.kind === "ident" || t?.kind === "qident";
107
+ const isPunct = (t: Token | undefined, p: string): boolean => t?.kind === "punct" && t.text === p;
108
+ const nameOf = (t: Token): string => (t.kind === "qident" ? identValue(t.text) : t.text.toLowerCase());
109
+
110
+ // ── The project ────────────────────────────────────────────────────────
111
+
112
+ /** What the build declares, for resolving a name to it. */
113
+ export interface PgProject {
114
+ major: number;
115
+ objects: OutputObject[];
116
+ byExport: Map<string, OutputObject>;
117
+ schemas: Set<string>;
118
+ /** Declared extension names. */
119
+ extensions: Set<string>;
120
+ }
121
+
122
+ export function projectOf(ctx: PostSynthContext): PgProject {
123
+ const objects = postgresObjects(ctx);
124
+ const byExport = new Map(objects.map((o) => [o.export, o]));
125
+ const schemas = new Set(objects.filter((o) => o.type === "Postgres::Schema").map((o) => o.name));
126
+ const exts = objects.filter((o) => o.type === "Postgres::Extension");
127
+ return {
128
+ major: targetMajor(ctx),
129
+ objects,
130
+ byExport,
131
+ schemas,
132
+ extensions: new Set(exts.map((o) => o.name)),
133
+ };
134
+ }
135
+
136
+ const TYPE_OBJECTS = new Set(["Postgres::Enum", "Postgres::Domain", "Postgres::Table", "Postgres::View", "Postgres::MaterializedView"]);
137
+ const ROUTINES = new Set(["Postgres::Function", "Postgres::Procedure"]);
138
+
139
+ function declared(p: PgProject, kinds: ReadonlySet<string>, schema: string | undefined, name: string): OutputObject | undefined {
140
+ return p.objects.find((o) => kinds.has(o.type) && o.name === name && (schema === undefined || o.schema === schema));
141
+ }
142
+
143
+ /** The extension that provides `name` in `section`, if the overlay knows one; declared ones first. */
144
+ function extensionFor(p: PgProject, test: (e: (typeof EXTENSION_OBJECTS)[string]) => boolean): { name: string; declared: boolean } | undefined {
145
+ let found: { name: string; declared: boolean } | undefined;
146
+ for (const [name, e] of Object.entries(EXTENSION_OBJECTS)) {
147
+ if (!test(e)) continue;
148
+ if (p.extensions.has(name)) return { name, declared: true };
149
+ found ??= { name, declared: false };
150
+ }
151
+ return found;
152
+ }
153
+
154
+ /** A name the core catalog does not have: the finding's severity and a note, or nothing when an extension accounts for it. */
155
+ export interface Miss {
156
+ severity: "error" | "warning";
157
+ /** `, which comes from the pg_trgm extension the project does not declare` and the like. */
158
+ note: string;
159
+ }
160
+
161
+ function miss(p: PgProject, ext: { name: string; declared: boolean } | undefined): Miss | undefined {
162
+ if (ext?.declared) return undefined;
163
+ if (ext) return { severity: "error", note: `; it comes from the ${ext.name} extension, which the project does not declare` };
164
+ if (p.extensions.size > 0) return { severity: "warning", note: `; if an extension (${[...p.extensions].sort().join(", ")}) provides it, ignore this` };
165
+ return { severity: "error", note: "" };
166
+ }
167
+
168
+ /** A schema-qualified name the project cannot answer for: one in a schema it does not declare. */
169
+ const outsideProject = (p: PgProject, schema: string): boolean => !p.schemas.has(schema);
170
+
171
+ // ── Types ──────────────────────────────────────────────────────────────
172
+
173
+ export interface ParsedType {
174
+ schema?: string;
175
+ /** The name: words joined by one space, bare words lower case. */
176
+ name: string;
177
+ quoted: boolean;
178
+ /** The modifiers of the first `(...)`, as written. */
179
+ modifiers?: string[];
180
+ array: boolean;
181
+ /** `interval`'s fields, upper case (`DAY TO SECOND`). */
182
+ fields?: string;
183
+ }
184
+
185
+ /** A type as written in a column, domain or sequence: `numeric(12,2)`, `time(3) with time zone`, `app.mood[]`. Undefined when it is not a plain type name. */
186
+ export function parseType(text: string | undefined): ParsedType | undefined {
187
+ if (!text) return undefined;
188
+ const toks = significant(text);
189
+ if (!toks || toks.length === 0) return undefined;
190
+ const words: string[] = [];
191
+ let schema: string | undefined;
192
+ let quoted = false;
193
+ let modifiers: string[] | undefined;
194
+ let array = false;
195
+ for (let i = 0; i < toks.length; i++) {
196
+ const t = toks[i]!;
197
+ if (isName(t)) {
198
+ if (t.kind === "ident" && t.text.toLowerCase() === "array" && words.length > 0) {
199
+ array = true;
200
+ continue;
201
+ }
202
+ if (array) return undefined;
203
+ if (isPunct(toks[i + 1], ".") && words.length === 0 && schema === undefined && isName(toks[i + 2])) {
204
+ schema = nameOf(t);
205
+ i++;
206
+ continue;
207
+ }
208
+ if (t.kind === "qident") quoted = true;
209
+ words.push(nameOf(t));
210
+ } else if (isPunct(t, "(")) {
211
+ let depth = 1;
212
+ let j = i + 1;
213
+ for (; j < toks.length && depth > 0; j++) {
214
+ if (isPunct(toks[j], "(")) depth++;
215
+ else if (isPunct(toks[j], ")")) depth--;
216
+ }
217
+ if (depth !== 0) return undefined;
218
+ if (modifiers === undefined) {
219
+ const start = t.end;
220
+ const end = toks[j - 1]!.start;
221
+ modifiers = splitTopLevel(text.slice(start, end));
222
+ }
223
+ i = j - 1;
224
+ } else if (isPunct(t, "[")) {
225
+ array = true;
226
+ let j = i + 1;
227
+ while (j < toks.length && !isPunct(toks[j], "]")) j++;
228
+ i = j;
229
+ } else {
230
+ return undefined;
231
+ }
232
+ }
233
+ if (words.length === 0) return undefined;
234
+ if (!quoted && words[0] === "interval" && words.length > 1 && words.slice(1).every((w) => INTERVAL_FIELDS.has(w))) {
235
+ return { schema, name: "interval", quoted, modifiers, array, fields: words.slice(1).join(" ").toUpperCase() };
236
+ }
237
+ return { schema, name: words.join(" "), quoted, modifiers, array };
238
+ }
239
+
240
+ export type TypeResolution =
241
+ | { kind: "builtin"; canonical: string; serial?: boolean }
242
+ | { kind: "declared"; object: OutputObject }
243
+ /** Qualified with a schema the project does not declare, or provided by a declared extension: not checked. */
244
+ | { kind: "outside" }
245
+ | { kind: "missing"; miss: Miss }
246
+ /** A built-in type the target major does not have. */
247
+ | { kind: "absent"; canonical: string };
248
+
249
+ /** Resolve a parsed type at the target major. `serial` admits `serial` and friends (a column). */
250
+ export function resolveType(p: PgProject, t: ParsedType, opts: { serial?: boolean } = {}): TypeResolution {
251
+ if (t.schema !== undefined) {
252
+ if (outsideProject(p, t.schema)) return { kind: "outside" };
253
+ const d = declared(p, TYPE_OBJECTS, t.schema, t.name);
254
+ if (d) return { kind: "declared", object: d };
255
+ const m = miss(p, extensionFor(p, (e) => e.types?.includes(t.name) ?? false));
256
+ return m ? { kind: "missing", miss: m } : { kind: "outside" };
257
+ }
258
+ if (!t.quoted) {
259
+ if (opts.serial && SERIALS[t.name] && !t.modifiers) return { kind: "builtin", canonical: SERIALS[t.name]!, serial: true };
260
+ if (t.name === "float" && t.modifiers?.length === 1 && /^\d+$/.test(t.modifiers[0]!)) {
261
+ return { kind: "builtin", canonical: Number(t.modifiers[0]) <= 24 ? "real" : "double precision" };
262
+ }
263
+ const canonical = BUILTIN_SPELLINGS.get(t.name) ?? (t.name.startsWith("_") ? BUILTIN_SPELLINGS.get(t.name.slice(1)) : undefined);
264
+ if (canonical) return { kind: "builtin", canonical };
265
+ } else {
266
+ if (t.name === "char") return { kind: "builtin", canonical: '"char"' };
267
+ const byCatalog = Object.entries(columnTypes).find(([sql, s]) => (s.catalogName ?? sql) === t.name);
268
+ if (byCatalog) return { kind: "builtin", canonical: byCatalog[0] };
269
+ }
270
+ const d = declared(p, TYPE_OBJECTS, undefined, t.name);
271
+ if (d) return { kind: "declared", object: d };
272
+ const m = miss(p, extensionFor(p, (e) => e.types?.includes(t.name) ?? false));
273
+ return m ? { kind: "missing", miss: m } : { kind: "outside" };
274
+ }
275
+
276
+ /** A type as the user wrote it, for a message. */
277
+ export const typeLabel = (t: ParsedType): string => `${t.schema ? `${t.schema}.` : ""}${t.name}`;
278
+
279
+ /**
280
+ * A comparable identity for a column's type, for a foreign key: a built-in
281
+ * type's catalog category (`U`, the catch-all, by name), a domain's base type,
282
+ * an enum or row type by name; `[]` for an array. Undefined when it cannot be
283
+ * told (a type outside the project).
284
+ */
285
+ export function typeIdentity(p: PgProject, text: string | undefined, depth = 0): string | undefined {
286
+ const t = parseType(text);
287
+ if (!t || depth > 5) return undefined;
288
+ const r = resolveType(p, t, { serial: true });
289
+ const suffix = t.array ? "[]" : "";
290
+ if (r.kind === "builtin") {
291
+ const cat = categoryOf(r.canonical);
292
+ if (!cat) return undefined;
293
+ return (cat === "U" ? `U:${r.canonical}` : cat) + suffix;
294
+ }
295
+ if (r.kind === "declared") {
296
+ if (r.object.type === "Postgres::Domain") {
297
+ const base = typeIdentity(p, r.object.dataType as string | undefined, depth + 1);
298
+ return base === undefined ? undefined : base + suffix;
299
+ }
300
+ return `${r.object.type}:${r.object.sqlName as string}${suffix}`;
301
+ }
302
+ return undefined;
303
+ }
304
+
305
+ // ── Calls ──────────────────────────────────────────────────────────────
306
+
307
+ /**
308
+ * Words that take a parenthesis in an expression without being a call to a
309
+ * function in `pg_proc`. Reserved and column-name key words (`COALESCE`,
310
+ * `CAST`, `EXTRACT`, `ROW`, `ARRAY`, `EXISTS`, `IN`, `ANY`, `SUBSTRING`...)
311
+ * are not function names to the grammar and are skipped by their category;
312
+ * these are the unreserved ones that read the same way.
313
+ */
314
+ const NOT_CALLS = new Set(["filter", "over", "within", "zone", "varying", "precision", "value", "and", "or", "not", "as", "then", "else", "when"]);
315
+
316
+ export interface Call {
317
+ schema?: string;
318
+ name: string;
319
+ }
320
+
321
+ /** The function calls in an expression: a name directly followed by `(`, outside string literals, not a key word construct. */
322
+ export function callsIn(expr: string | undefined): Call[] {
323
+ if (!expr) return [];
324
+ const toks = significant(expr);
325
+ if (!toks) return [];
326
+ const out: Call[] = [];
327
+ for (let i = 0; i < toks.length; i++) {
328
+ const t = toks[i]!;
329
+ if (!isName(t)) continue;
330
+ const prev = toks[i - 1];
331
+ if (isPunct(prev, ".")) continue;
332
+ // A type's modifiers after a cast: `x::numeric(10, 2)`, `CAST(x AS varchar(3))`.
333
+ if (prev?.kind === "op" && prev.text === "::") continue;
334
+ if (prev?.kind === "ident" && prev.text.toLowerCase() === "as") continue;
335
+ if (isPunct(toks[i + 1], ".") && isName(toks[i + 2]) && isPunct(toks[i + 3], "(")) {
336
+ out.push({ schema: nameOf(t), name: nameOf(toks[i + 2]!) });
337
+ i += 2;
338
+ continue;
339
+ }
340
+ if (!isPunct(toks[i + 1], "(")) continue;
341
+ if (t.kind === "ident") {
342
+ const w = t.text.toLowerCase();
343
+ const cat = KEYWORDS[w];
344
+ if (cat === "R" || cat === "C" || cat === "T") continue;
345
+ if (NOT_CALLS.has(w)) continue;
346
+ }
347
+ out.push({ name: nameOf(t) });
348
+ }
349
+ return out;
350
+ }
351
+
352
+ export type CallResolution = { ok: true } | { ok: false; miss: Miss; since?: number; until?: number };
353
+
354
+ /** Resolve a call at the target major. */
355
+ export function resolveCall(p: PgProject, c: Call): CallResolution {
356
+ if (c.schema !== undefined) {
357
+ if (outsideProject(p, c.schema)) return { ok: true };
358
+ if (declared(p, ROUTINES, c.schema, c.name)) return { ok: true };
359
+ const m = miss(p, extensionFor(p, (e) => providesFunction(e, c.name)));
360
+ return m ? { ok: false, miss: m } : { ok: true };
361
+ }
362
+ const presence = (FUNCTIONS as Readonly<Record<string, Presence>>)[c.name];
363
+ if (presentAt(presence, p.major)) return { ok: true };
364
+ if (declared(p, ROUTINES, undefined, c.name)) return { ok: true };
365
+ // A function-style cast: `int4(x)`, `text(x)`, or a declared type's name.
366
+ if (BUILTIN_SPELLINGS.has(c.name) || declared(p, TYPE_OBJECTS, undefined, c.name)) return { ok: true };
367
+ if (presence !== undefined) {
368
+ const ms = majorsOf(presence);
369
+ const since = ms[0]! > p.major ? ms[0] : undefined;
370
+ const until = ms[ms.length - 1]! < p.major ? ms[ms.length - 1] : undefined;
371
+ return { ok: false, miss: { severity: "error", note: "" }, since, until };
372
+ }
373
+ const m = miss(p, extensionFor(p, (e) => providesFunction(e, c.name)));
374
+ return m ? { ok: false, miss: m } : { ok: true };
375
+ }
376
+
377
+ function providesFunction(e: (typeof EXTENSION_OBJECTS)[string], name: string): boolean {
378
+ return (e.functions?.includes(name) ?? false) || (e.functionPrefixes?.some((pre) => name.startsWith(pre)) ?? false);
379
+ }
380
+
381
+ /** The expressions of a table, domain or index that may call functions, each with where it sits. */
382
+ export function expressionsOf(o: OutputObject): Array<{ where: string; expr: string }> {
383
+ const out: Array<{ where: string; expr: string }> = [];
384
+ if (o.type === "Postgres::Table") {
385
+ const t = o as PgTable;
386
+ for (const c of t.columns) {
387
+ if (c.default) out.push({ where: `column ${c.name}'s DEFAULT`, expr: c.default });
388
+ if (c.generated?.expr) out.push({ where: `generated column ${c.name}`, expr: c.generated.expr });
389
+ }
390
+ for (const k of t.checks) out.push({ where: k.name ? `CHECK ${k.name}` : "a CHECK", expr: k.expr });
391
+ } else if (o.type === "Postgres::Domain") {
392
+ if (o.default) out.push({ where: "its DEFAULT", expr: o.default as string });
393
+ for (const k of (o.checks as Array<{ name?: string; expr: string }> | undefined) ?? []) out.push({ where: k.name ? `CHECK ${k.name}` : "a CHECK", expr: k.expr });
394
+ } else if (o.type === "Postgres::Index") {
395
+ for (const e of (o.elements as Array<{ expr: string; column?: string }>) ?? []) {
396
+ if (e.column) continue;
397
+ const el = parseIndexElement(e.expr);
398
+ out.push({ where: "an index expression", expr: el?.expr ?? e.expr });
399
+ }
400
+ }
401
+ return out;
402
+ }
403
+
404
+ // ── Index elements ─────────────────────────────────────────────────────
405
+
406
+ export interface IndexElement {
407
+ /** The column or expression, without its collation, operator class or ordering. */
408
+ expr: string;
409
+ opclass?: { schema?: string; name: string };
410
+ }
411
+
412
+ const ORDERING = new Set(["asc", "desc", "nulls", "first", "last"]);
413
+
414
+ /** An index element (`(lower(x)) text_pattern_ops DESC`, `x gin_trgm_ops`) split into its expression and operator class. */
415
+ export function parseIndexElement(text: string): IndexElement | undefined {
416
+ const toks = significant(text);
417
+ if (!toks || toks.length === 0) return undefined;
418
+ let i = 0;
419
+ const skipGroup = (at: number): number => {
420
+ let depth = 0;
421
+ for (let j = at; j < toks.length; j++) {
422
+ if (isPunct(toks[j], "(")) depth++;
423
+ else if (isPunct(toks[j], ")") && --depth === 0) return j + 1;
424
+ }
425
+ return toks.length;
426
+ };
427
+ if (isPunct(toks[0], "(")) i = skipGroup(0);
428
+ else if (isName(toks[0])) {
429
+ i = 1;
430
+ while (isPunct(toks[i], ".") && isName(toks[i + 1])) i += 2;
431
+ if (isPunct(toks[i], "(")) i = skipGroup(i);
432
+ } else return undefined;
433
+ const expr = text.slice(toks[0]!.start, toks[i - 1]!.end);
434
+ if (toks[i]?.kind === "ident" && toks[i]!.text.toLowerCase() === "collate") {
435
+ i += 2;
436
+ while (isPunct(toks[i], ".") && isName(toks[i + 1])) i += 2;
437
+ }
438
+ const t = toks[i];
439
+ if (!isName(t) || (t!.kind === "ident" && ORDERING.has(t!.text.toLowerCase()))) return { expr };
440
+ if (isPunct(toks[i + 1], ".") && isName(toks[i + 2])) return { expr, opclass: { schema: nameOf(t!), name: nameOf(toks[i + 2]!) } };
441
+ return { expr, opclass: { name: nameOf(t!) } };
442
+ }
443
+
444
+ export type MethodResolution = { ok: true } | { ok: false; miss: Miss; since?: number; until?: number };
445
+
446
+ /** An index access method at the target major. */
447
+ export function resolveMethod(p: PgProject, method: string): MethodResolution {
448
+ const presence = (INDEX_ACCESS_METHODS as Readonly<Record<string, Presence>>)[method];
449
+ if (presentAt(presence, p.major)) return { ok: true };
450
+ if (presence !== undefined) return { ok: false, miss: { severity: "error", note: "" } };
451
+ const m = miss(p, extensionFor(p, (e) => e.accessMethods?.includes(method) ?? false));
452
+ return m ? { ok: false, miss: m } : { ok: true };
453
+ }
454
+
455
+ export type OpClassResolution = { ok: true } | { ok: false; miss: Miss; otherMethods?: string[] };
456
+
457
+ /** An operator class for `method` at the target major. */
458
+ export function resolveOpClass(p: PgProject, method: string, opclass: { schema?: string; name: string }): OpClassResolution {
459
+ if (opclass.schema !== undefined && outsideProject(p, opclass.schema)) return { ok: true };
460
+ const classes = OPERATOR_CLASSES as Readonly<Record<string, Readonly<Record<string, Presence>>>>;
461
+ if (presentAt(classes[method]?.[opclass.name], p.major)) return { ok: true };
462
+ const ext = extensionFor(p, (e) => e.opclasses?.[method]?.includes(opclass.name) ?? false);
463
+ if (ext?.declared) return { ok: true };
464
+ if (!ext) {
465
+ const others = Object.keys(classes).filter((am) => am !== method && presentAt(classes[am]![opclass.name], p.major));
466
+ const extOthers = Object.entries(EXTENSION_OBJECTS).flatMap(([, e]) => Object.entries(e.opclasses ?? {}).filter(([am, names]) => am !== method && names.includes(opclass.name)).map(([am]) => am));
467
+ const all = [...new Set([...others, ...extOthers])].sort();
468
+ if (all.length > 0) return { ok: false, miss: { severity: "error", note: "" }, otherMethods: all };
469
+ }
470
+ const m = miss(p, ext);
471
+ return m ? { ok: false, miss: m } : { ok: true };
472
+ }
473
+
474
+ // ── Tables ─────────────────────────────────────────────────────────────
475
+
476
+ /** The table an interpolated reference names (`references`, `table`, a grant's object): the entity when it is one, else undefined. */
477
+ export function interpolatedTable(p: PgProject, ref: unknown, sqlName: string | undefined): PgTable | undefined {
478
+ if (typeof ref !== "string") return undefined;
479
+ const o = p.byExport.get(ref);
480
+ if (!o || o.type !== "Postgres::Table") return undefined;
481
+ if (sqlName !== undefined && o.sqlName !== sqlName) return undefined;
482
+ return o as PgTable;
483
+ }
484
+
485
+ /**
486
+ * Every column a table has, its own and those it inherits or copies from a
487
+ * declared parent; undefined when some come from somewhere the build cannot
488
+ * see (a plain-text parent, `LIKE`, `OF type`).
489
+ */
490
+ export function columnsOf(p: PgProject, t: PgTable, seen = new Set<string>()): Map<string, PgTable["columns"][number]> | undefined {
491
+ if (seen.has(t.export)) return undefined;
492
+ seen.add(t.export);
493
+ if (t.ofType !== undefined || t.like.length > 0) return undefined;
494
+ const out = new Map<string, PgTable["columns"][number]>();
495
+ const parents: unknown[] = [...(t.inherits ?? []), ...(t.partitionOf !== undefined ? [t.partitionOf] : [])];
496
+ for (const parent of parents) {
497
+ const pt = interpolatedTable(p, parent, undefined);
498
+ if (!pt) return undefined;
499
+ const pc = columnsOf(p, pt, seen);
500
+ if (!pc) return undefined;
501
+ for (const [k, v] of pc) out.set(k, v);
502
+ }
503
+ // A partition's `col WITH OPTIONS ...` carries no type; the parent's column stands.
504
+ for (const c of t.columns) if (c.type !== undefined || !out.has(c.name)) out.set(c.name, c);
505
+ return out;
506
+ }
@@ -0,0 +1,62 @@
1
+ import type { PostSynthCheck, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
2
+ import { clickhouseObjects, postgresObjects, type OutputObject } from "./sql-helpers";
3
+
4
+ /**
5
+ * Kinds whose name is not the object's identity on the server, so two
6
+ * exports may share it: grants; Postgres functions and procedures, which
7
+ * overload; triggers and policies, named per table; ClickHouse row policies,
8
+ * also per table.
9
+ */
10
+ const NOT_UNIQUE = new Set([
11
+ "ClickHouse::Grant",
12
+ "ClickHouse::RowPolicy",
13
+ "Postgres::Grant",
14
+ "Postgres::DefaultPrivileges",
15
+ "Postgres::Function",
16
+ "Postgres::Procedure",
17
+ "Postgres::Trigger",
18
+ "Postgres::Policy",
19
+ ]);
20
+
21
+ const kindOf = (type: string): string =>
22
+ type
23
+ .replace(/^(ClickHouse|Postgres)::/, "")
24
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
25
+ .toLowerCase();
26
+
27
+ /**
28
+ * SQL101: two exports declare the same object, the same kind under the same
29
+ * qualified name (`shop.events` twice). The build writes both `CREATE`
30
+ * statements, and the second fails on the server, or replaces the first
31
+ * under `OR REPLACE`.
32
+ */
33
+ export const sql101: PostSynthCheck = {
34
+ id: "SQL101",
35
+ description: "Two exports declare the same object",
36
+ check(ctx) {
37
+ const out: PostSynthDiagnostic[] = [];
38
+ const scan = (dialect: string, objects: readonly OutputObject[]) => {
39
+ const first = new Map<string, OutputObject>();
40
+ for (const o of objects) {
41
+ const name = typeof o.sqlName === "string" && o.sqlName !== "" ? o.sqlName : undefined;
42
+ if (!name || NOT_UNIQUE.has(o.type)) continue;
43
+ const key = `${o.type}\u0000${name}`;
44
+ const prior = first.get(key);
45
+ if (!prior) {
46
+ first.set(key, o);
47
+ continue;
48
+ }
49
+ out.push({
50
+ checkId: "SQL101",
51
+ severity: "error",
52
+ message: `${prior.export} and ${o.export} both declare ${dialect} ${kindOf(o.type)} ${name}; the build would create it twice`,
53
+ entity: o.export,
54
+ lexicon: "sql",
55
+ });
56
+ }
57
+ };
58
+ scan("ClickHouse", clickhouseObjects(ctx));
59
+ scan("Postgres", postgresObjects(ctx));
60
+ return out;
61
+ },
62
+ };
@@ -0,0 +1,12 @@
1
+ import { checkOf } from "./clickhouse-helpers";
2
+ import { reportTypeFindings } from "./clickhouse-type-check";
3
+
4
+ /**
5
+ * SQLCH121: a column type names a family the pinned server does not have
6
+ * (`UInt46`, `uint64`, `Nullabel(String)`), at any depth of the type.
7
+ *
8
+ * The family list is the pinned server's own (`system.data_type_families`),
9
+ * aliases included; a name is case-sensitive unless the catalog marks it
10
+ * case-insensitive.
11
+ */
12
+ export const sqlch121 = checkOf({ id: "SQLCH121", description: "A column type names a family the pinned server does not have" }, reportTypeFindings("SQLCH121"));
@@ -0,0 +1,12 @@
1
+ import { checkOf } from "./clickhouse-helpers";
2
+ import { reportTypeFindings } from "./clickhouse-type-check";
3
+
4
+ /**
5
+ * SQLCH122: a column type's parameters do not fit the family: parameters on a
6
+ * family that takes none, a required one missing (`FixedString`), too many,
7
+ * or a number outside its range (`Decimal(100, 2)`).
8
+ *
9
+ * The parameter grammar is the `TYPE_PARAMETERS` overlay; no system table
10
+ * holds it.
11
+ */
12
+ export const sqlch122 = checkOf({ id: "SQLCH122", description: "A column type's parameters do not fit its family" }, reportTypeFindings("SQLCH122"));
@@ -0,0 +1,11 @@
1
+ import { checkOf } from "./clickhouse-helpers";
2
+ import { reportTypeFindings } from "./clickhouse-type-check";
3
+
4
+ /**
5
+ * SQLCH123: `Nullable` around a type ClickHouse keeps out of it: `Array`,
6
+ * `Map`, `Nested`, `LowCardinality`, `Variant` or another `Nullable`. The
7
+ * server refuses the column ("Nested type ... cannot be inside Nullable
8
+ * type"). `Nullable(Tuple(...))` is left alone: the server takes it when the
9
+ * profile enables `enable_nullable_tuple_type`.
10
+ */
11
+ export const sqlch123 = checkOf({ id: "SQLCH123", description: "A Nullable wraps a type ClickHouse does not allow inside Nullable" }, reportTypeFindings("SQLCH123"));
@@ -0,0 +1,21 @@
1
+ import { checkOf } from "./clickhouse-helpers";
2
+ import { clickhouseObjects } from "./sql-helpers";
3
+
4
+ /**
5
+ * SQLCH124: a table, view or dictionary declares a column twice. The server
6
+ * refuses the `CREATE` ("Column ... already exists").
7
+ */
8
+ export const sqlch124 = checkOf({ id: "SQLCH124", description: "A table declares a column twice" }, (ctx, report) => {
9
+ for (const o of clickhouseObjects(ctx)) {
10
+ if (!/^ClickHouse::(Table|View|MaterializedView|Dictionary)$/.test(o.type)) continue;
11
+ const seen = new Set<string>();
12
+ const reported = new Set<string>();
13
+ for (const c of (o.columns ?? []) as Array<{ name: string }>) {
14
+ if (seen.has(c.name) && !reported.has(c.name)) {
15
+ reported.add(c.name);
16
+ report({ severity: "error", message: `${o.export} (${o.name}) declares column ${c.name} twice`, entity: o.export });
17
+ }
18
+ seen.add(c.name);
19
+ }
20
+ }
21
+ });