@intentius/chant-lexicon-sql 0.124.12 → 0.124.14

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 (148) 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/interpolation.d.ts +18 -1
  21. package/dist/core/interpolation.d.ts.map +1 -1
  22. package/dist/files/clickhouse.d.ts +2 -2
  23. package/dist/files/clickhouse.d.ts.map +1 -1
  24. package/dist/files/common.d.ts +16 -0
  25. package/dist/files/common.d.ts.map +1 -1
  26. package/dist/files/postgres.d.ts +2 -2
  27. package/dist/files/postgres.d.ts.map +1 -1
  28. package/dist/import-parser.d.ts +2 -1
  29. package/dist/import-parser.d.ts.map +1 -1
  30. package/dist/integrity.json +19 -19
  31. package/dist/lint/post-synth/clickhouse-helpers.d.ts +6 -19
  32. package/dist/lint/post-synth/clickhouse-helpers.d.ts.map +1 -1
  33. package/dist/lint/post-synth/postgres-helpers.d.ts +15 -55
  34. package/dist/lint/post-synth/postgres-helpers.d.ts.map +1 -1
  35. package/dist/lint/post-synth/postgres-names.d.ts +2 -2
  36. package/dist/lint/post-synth/postgres-names.d.ts.map +1 -1
  37. package/dist/lint/post-synth/sql-helpers.d.ts +5 -6
  38. package/dist/lint/post-synth/sql-helpers.d.ts.map +1 -1
  39. package/dist/lint/post-synth/sqlch102.d.ts.map +1 -1
  40. package/dist/lint/post-synth/sqlch103.d.ts.map +1 -1
  41. package/dist/lint/post-synth/sqlch104.d.ts.map +1 -1
  42. package/dist/lint/post-synth/sqlch105.d.ts.map +1 -1
  43. package/dist/lint/post-synth/sqlch106.d.ts.map +1 -1
  44. package/dist/lint/post-synth/sqlch107.d.ts.map +1 -1
  45. package/dist/lint/post-synth/sqlch111.d.ts.map +1 -1
  46. package/dist/lint/post-synth/sqlch112.d.ts.map +1 -1
  47. package/dist/lint/post-synth/sqlch113.d.ts.map +1 -1
  48. package/dist/lint/post-synth/sqlch114.d.ts.map +1 -1
  49. package/dist/lint/post-synth/sqlch115.d.ts.map +1 -1
  50. package/dist/lint/post-synth/sqlch120.d.ts.map +1 -1
  51. package/dist/lint/post-synth/sqlch125.d.ts.map +1 -1
  52. package/dist/manifest.json +1 -1
  53. package/dist/postgres/apply/statements.d.ts.map +1 -1
  54. package/dist/postgres/entities.d.ts +7 -1
  55. package/dist/postgres/entities.d.ts.map +1 -1
  56. package/dist/postgres/parser.d.ts +10 -2
  57. package/dist/postgres/parser.d.ts.map +1 -1
  58. package/dist/postgres/plan/diff.d.ts.map +1 -1
  59. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  60. package/dist/postgres/plan/rules.d.ts +1 -0
  61. package/dist/postgres/plan/rules.d.ts.map +1 -1
  62. package/dist/postgres.d.ts +1 -0
  63. package/dist/postgres.d.ts.map +1 -1
  64. package/dist/read/clickhouse.d.ts +66 -0
  65. package/dist/read/clickhouse.d.ts.map +1 -0
  66. package/dist/read/objects.d.ts +99 -0
  67. package/dist/read/objects.d.ts.map +1 -0
  68. package/dist/read/postgres.d.ts +83 -0
  69. package/dist/read/postgres.d.ts.map +1 -0
  70. package/dist/read.d.ts +49 -0
  71. package/dist/read.d.ts.map +1 -0
  72. package/dist/rules/clickhouse-helpers.ts +20 -97
  73. package/dist/rules/postgres-helpers.ts +18 -53
  74. package/dist/rules/postgres-names.ts +6 -6
  75. package/dist/rules/sql-helpers.ts +8 -28
  76. package/dist/rules/sqlch102.ts +2 -3
  77. package/dist/rules/sqlch103.ts +2 -3
  78. package/dist/rules/sqlch104.ts +2 -3
  79. package/dist/rules/sqlch105.ts +2 -3
  80. package/dist/rules/sqlch106.ts +2 -3
  81. package/dist/rules/sqlch107.ts +2 -3
  82. package/dist/rules/sqlch111.ts +2 -3
  83. package/dist/rules/sqlch112.ts +2 -3
  84. package/dist/rules/sqlch113.ts +2 -3
  85. package/dist/rules/sqlch114.ts +2 -3
  86. package/dist/rules/sqlch115.ts +2 -3
  87. package/dist/rules/sqlch120.ts +2 -3
  88. package/dist/rules/sqlch125.ts +2 -3
  89. package/dist/serializer.d.ts +11 -1
  90. package/dist/serializer.d.ts.map +1 -1
  91. package/dist/sql-files.d.ts +17 -1
  92. package/dist/sql-files.d.ts.map +1 -1
  93. package/dist/sql-types.d.ts +3 -0
  94. package/dist/sql-types.d.ts.map +1 -1
  95. package/package.json +2 -2
  96. package/src/clickhouse/apply/statements.ts +2 -0
  97. package/src/clickhouse/entities.ts +8 -1
  98. package/src/clickhouse/expressions.ts +108 -0
  99. package/src/clickhouse/parser.ts +113 -31
  100. package/src/clickhouse/plan/diff.ts +19 -0
  101. package/src/clickhouse/plan/normalize.ts +27 -2
  102. package/src/clickhouse/plan/rebuild-handoff.ts +2 -1
  103. package/src/clickhouse/plan/rules.ts +7 -0
  104. package/src/clickhouse/testdata/server-ddl-baseline.json +67 -0
  105. package/src/clickhouse.ts +10 -0
  106. package/src/codegen/classifier-page.ts +2 -0
  107. package/src/codegen/postgres-classifier-page.ts +2 -0
  108. package/src/core/cursor.ts +96 -0
  109. package/src/core/interpolation.ts +32 -1
  110. package/src/files/clickhouse.ts +7 -5
  111. package/src/files/common.ts +23 -0
  112. package/src/files/postgres.ts +12 -8
  113. package/src/import-parser.ts +2 -1
  114. package/src/lint/post-synth/clickhouse-helpers.ts +20 -97
  115. package/src/lint/post-synth/postgres-helpers.ts +18 -53
  116. package/src/lint/post-synth/postgres-names.ts +6 -6
  117. package/src/lint/post-synth/sql-helpers.ts +8 -28
  118. package/src/lint/post-synth/sqlch102.ts +2 -3
  119. package/src/lint/post-synth/sqlch103.ts +2 -3
  120. package/src/lint/post-synth/sqlch104.ts +2 -3
  121. package/src/lint/post-synth/sqlch105.ts +2 -3
  122. package/src/lint/post-synth/sqlch106.ts +2 -3
  123. package/src/lint/post-synth/sqlch107.ts +2 -3
  124. package/src/lint/post-synth/sqlch111.ts +2 -3
  125. package/src/lint/post-synth/sqlch112.ts +2 -3
  126. package/src/lint/post-synth/sqlch113.ts +2 -3
  127. package/src/lint/post-synth/sqlch114.ts +2 -3
  128. package/src/lint/post-synth/sqlch115.ts +2 -3
  129. package/src/lint/post-synth/sqlch120.ts +2 -3
  130. package/src/lint/post-synth/sqlch125.ts +2 -3
  131. package/src/postgres/apply/statements.ts +2 -0
  132. package/src/postgres/entities.ts +9 -1
  133. package/src/postgres/parser.ts +31 -8
  134. package/src/postgres/plan/diff.ts +18 -0
  135. package/src/postgres/plan/normalize.ts +12 -0
  136. package/src/postgres/plan/rules.ts +7 -0
  137. package/src/postgres/testdata/server-ddl-baseline.json +38 -0
  138. package/src/postgres.ts +12 -0
  139. package/src/read/clickhouse.ts +137 -0
  140. package/src/read/objects.ts +137 -0
  141. package/src/read/postgres.ts +180 -0
  142. package/src/read-policies.e2e.test.ts +144 -0
  143. package/src/read.test.ts +143 -0
  144. package/src/read.ts +69 -0
  145. package/src/serializer.ts +37 -1
  146. package/src/sql-files.ts +27 -6
  147. package/src/sql-types.ts +19 -2
  148. package/src/unknown-clauses.test.ts +227 -0
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The ClickHouse half of the public read API (`../read.ts`): the build's
3
+ * tables, columns, sort keys, skip indexes, engines and views as typed values,
4
+ * built from the serialized output. The lexicon's post-synth checks read the
5
+ * same values (`../lint/post-synth/clickhouse-helpers.ts`).
6
+ */
7
+
8
+ import { relationKey } from "../core/typed";
9
+ import type { ColumnDef } from "../clickhouse/entities";
10
+ import { bareName, baseEngineName, isMergeTree, isNullableType, keyElements, typeFamily, unwrapType } from "../clickhouse/expressions";
11
+ import { dialectObjects, type ClickHouseTableJson, type ClickHouseViewJson, type SqlObjectJson, type SqlReadSource } from "./objects";
12
+
13
+ /** A column of a ClickHouse table or view, as declared, with what follows from the declaration. */
14
+ export interface ClickHouseColumnInfo extends ColumnDef {
15
+ /** Whether the column can hold NULL: its type is `Nullable(...)` (inside `LowCardinality` too), or it is declared `NULL`. */
16
+ nullable: boolean;
17
+ /** `LowCardinality(...)`. */
18
+ lowCardinality: boolean;
19
+ /** The type without its `Nullable` and `LowCardinality` wrappers, as written: `DateTime64(3, 'UTC')`. */
20
+ baseType?: string;
21
+ /** The type family's canonical name, as the pinned server's catalog has it: `BIGINT UNSIGNED` is `UInt64`, `DateTime64(3)` is `DateTime64`. */
22
+ family?: string;
23
+ /** A bare element of the table's sort key (`ORDER BY`). */
24
+ inSortKey: boolean;
25
+ }
26
+
27
+ /** A ClickHouse table, as declared: its columns, engine, keys and skip indexes. */
28
+ export interface ClickHouseTableInfo extends Omit<ClickHouseTableJson, "columns"> {
29
+ /** `database.name` when the DDL qualifies it, else the name: the table's key in `SqlSchema`. */
30
+ qualifiedName: string;
31
+ columns: ClickHouseColumnInfo[];
32
+ /** The engine without a `Replicated` prefix: `ReplicatedReplacingMergeTree` is `ReplacingMergeTree`. */
33
+ engineFamily?: string;
34
+ /** A MergeTree family engine, replicated or not. */
35
+ mergeTree: boolean;
36
+ /** The `ORDER BY` elements, as written, without `ASC`/`DESC`; empty for `tuple()` or none. */
37
+ sortKey: string[];
38
+ /** The `PRIMARY KEY` elements, else the sort key's, which is what ClickHouse uses when none is written. */
39
+ primaryKeyElements: string[];
40
+ /** The column named `name`, if the table declares one. */
41
+ column(name: string): ClickHouseColumnInfo | undefined;
42
+ }
43
+
44
+ /** A view or materialized view, with the names of its output columns. */
45
+ export interface ClickHouseViewInfo extends Omit<ClickHouseViewJson, "columns"> {
46
+ qualifiedName: string;
47
+ materialized: boolean;
48
+ columns: ClickHouseColumnInfo[];
49
+ /** The declared columns, else the outputs of the query's select list. */
50
+ outputs: string[];
51
+ }
52
+
53
+ /** What a ClickHouse build declares. */
54
+ export interface ClickHouseRead {
55
+ dialect: "clickhouse";
56
+ tables: ClickHouseTableInfo[];
57
+ views: ClickHouseViewInfo[];
58
+ /** Every serialized object, databases, dictionaries, users and grants included, in apply order. */
59
+ objects: SqlObjectJson[];
60
+ /** A table by export name, `database.name`, or name. */
61
+ table(ref: string): ClickHouseTableInfo | undefined;
62
+ }
63
+
64
+ function columnInfo(c: ColumnDef, sortKey: ReadonlySet<string>): ClickHouseColumnInfo {
65
+ const inner = /^LowCardinality\s*\(/i.test((c.type ?? "").trim());
66
+ const nullable = c.nullable === true || isNullableType(c.type) || (inner && isNullableType(unwrapLowCardinality(c.type!)));
67
+ return {
68
+ ...c,
69
+ nullable,
70
+ lowCardinality: inner,
71
+ ...(c.type !== undefined ? { baseType: unwrapType(c.type), family: typeFamily(c.type) } : {}),
72
+ inSortKey: sortKey.has(c.name),
73
+ };
74
+ }
75
+
76
+ function unwrapLowCardinality(type: string): string {
77
+ return type.trim().replace(/^LowCardinality\s*\(([\s\S]*)\)$/i, "$1").trim();
78
+ }
79
+
80
+ function build(objects: SqlObjectJson[]): ClickHouseRead {
81
+ const tables = objects
82
+ .filter((o) => o.type === "ClickHouse::Table")
83
+ .map((o): ClickHouseTableInfo => {
84
+ const t = o as unknown as ClickHouseTableJson;
85
+ const sortKey = t.orderBy ? keyElements(t.orderBy) : [];
86
+ const bare = new Set(sortKey.map(bareName).filter((n): n is string => n !== undefined));
87
+ const columns = (t.columns ?? []).map((c) => columnInfo(c, bare));
88
+ return {
89
+ ...t,
90
+ qualifiedName: relationKey(t),
91
+ columns,
92
+ ...(t.engine ? { engineFamily: baseEngineName(t.engine) } : {}),
93
+ mergeTree: isMergeTree(t.engine),
94
+ sortKey,
95
+ primaryKeyElements: t.primaryKey ? keyElements(t.primaryKey) : sortKey,
96
+ column: (name) => columns.find((c) => c.name === name),
97
+ };
98
+ });
99
+ const views = objects
100
+ .filter((o) => o.type === "ClickHouse::View" || o.type === "ClickHouse::MaterializedView")
101
+ .map((o): ClickHouseViewInfo => {
102
+ const v = o as unknown as ClickHouseViewJson;
103
+ const columns = (v.columns ?? []).map((c) => columnInfo(c, new Set()));
104
+ return {
105
+ ...v,
106
+ qualifiedName: relationKey(v),
107
+ materialized: v.type === "ClickHouse::MaterializedView",
108
+ columns,
109
+ outputs: columns.length > 0 ? columns.map((c) => c.name) : (v.lineage ?? []).map((e) => e.output),
110
+ };
111
+ });
112
+ return {
113
+ dialect: "clickhouse",
114
+ tables,
115
+ views,
116
+ objects,
117
+ table: (ref) => tables.find((t) => t.export === ref) ?? tables.find((t) => t.qualifiedName === ref) ?? tables.find((t) => t.name === ref),
118
+ };
119
+ }
120
+
121
+ const cache = new WeakMap<object, ClickHouseRead>();
122
+
123
+ /**
124
+ * What a ClickHouse build declares, read from a post-synth context's `docs`
125
+ * (a `lint.policies` check's `ctx`), or from the serialized output document
126
+ * itself. Empty when the build has no ClickHouse output. The result is cached
127
+ * per context; treat it as read-only.
128
+ */
129
+ export function readClickHouse(source: SqlReadSource | { dialect: string; objects: unknown[] }): ClickHouseRead {
130
+ if ("objects" in source) return build(source.dialect === "clickhouse" ? (source.objects as SqlObjectJson[]) : []);
131
+ const key = source.docs;
132
+ const hit = key ? cache.get(key) : undefined;
133
+ if (hit) return hit;
134
+ const read = build(dialectObjects(source, "clickhouse"));
135
+ if (key) cache.set(key, read);
136
+ return read;
137
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The sql build output as the serializer writes it (`../serializer.ts`): one
3
+ * JSON document per build with `dialect`, `applyOrder` and `objects`, each
4
+ * object its entity's props with the template source dropped, the DDL moved
5
+ * to the end, and every reference to another declared object written as that
6
+ * object's export name.
7
+ *
8
+ * This is the one place that finds those documents in a post-synth context.
9
+ * The public read API (`../read.ts`) and the lexicon's own post-synth checks
10
+ * (`../lint/post-synth/sql-helpers.ts`) both start here.
11
+ */
12
+
13
+ import type { OutputDoc } from "@intentius/chant/lint/post-synth";
14
+ import type * as ch from "../clickhouse/entities";
15
+ import type * as pg from "../postgres/entities";
16
+
17
+ /** What a reader takes: a post-synth context, or anything else carrying its `docs`. */
18
+ export interface SqlReadSource {
19
+ readonly docs?: readonly OutputDoc[];
20
+ }
21
+
22
+ /** One build output document, as the serializer writes it. */
23
+ export interface SqlOutputDocument {
24
+ dialect: "clickhouse" | "postgres";
25
+ /** Postgres only: the major the build targets (`sql.postgresMajor`, else the newest pinned). */
26
+ postgresMajor?: number;
27
+ applyOrder?: string[];
28
+ objects: SqlObjectJson[];
29
+ }
30
+
31
+ /** A reference to another declared object: its export name, or null when it is not one the build declares. */
32
+ export type RefJson = string | null;
33
+
34
+ /** The fields every serialized object has. */
35
+ export interface SqlObjectBase {
36
+ /** The export name of the declaration. */
37
+ export: string;
38
+ /** The entity type: `Postgres::Table`, `ClickHouse::MaterializedView`, ... */
39
+ type: string;
40
+ /** The object's name in the database, unquoted. */
41
+ name: string;
42
+ /** The name it is referred to by in SQL: `name`, or `schema.name` / `database.name`. */
43
+ sqlName?: string;
44
+ /** The export names it must be created after. */
45
+ dependsOn?: string[];
46
+ /** The statements as they will be sent. */
47
+ ddl?: string;
48
+ }
49
+
50
+ /** Any serialized object: the common fields, and the rest of its props as written. */
51
+ export interface SqlObjectJson extends SqlObjectBase {
52
+ [key: string]: unknown;
53
+ }
54
+
55
+ type Serialized<P> = Omit<P, "source" | "ddl"> & Omit<SqlObjectBase, "type"> & { sqlName: string; dependsOn: string[]; ddl: string };
56
+
57
+ /** A lineage edge as written: the output column, its expression, and the `export.column` names it reads. */
58
+ export interface LineageJson {
59
+ output: string;
60
+ expr: string;
61
+ from: Array<string | null>;
62
+ }
63
+
64
+ // ── Postgres ──────────────────────────────────────────────────────────
65
+
66
+ export interface PostgresForeignKeyJson extends Omit<pg.ForeignKeyDef, "references"> {
67
+ /** The referenced table's export name, or null when the build does not declare it. */
68
+ references: RefJson;
69
+ }
70
+
71
+ export interface PostgresTableJson
72
+ extends Omit<Serialized<pg.TableProps>, "partitionOf" | "ofType" | "inherits" | "foreignKeys"> {
73
+ type: "Postgres::Table";
74
+ partitionOf?: RefJson;
75
+ ofType?: RefJson;
76
+ inherits: RefJson[];
77
+ foreignKeys: PostgresForeignKeyJson[];
78
+ }
79
+
80
+ export interface PostgresIndexJson extends Omit<Serialized<pg.IndexProps>, "table"> {
81
+ type: "Postgres::Index";
82
+ /** The indexed table's export name, or null when the build does not declare it. */
83
+ table: RefJson;
84
+ }
85
+
86
+ export interface PostgresViewJson extends Omit<Serialized<pg.ViewProps>, "reads" | "lineage"> {
87
+ type: "Postgres::View" | "Postgres::MaterializedView";
88
+ reads: RefJson[];
89
+ lineage: LineageJson[];
90
+ }
91
+
92
+ // ── ClickHouse ────────────────────────────────────────────────────────
93
+
94
+ export interface ClickHouseTableJson extends Serialized<ch.TableProps> {
95
+ type: "ClickHouse::Table";
96
+ }
97
+
98
+ export interface ClickHouseViewJson extends Omit<Serialized<ch.ViewProps>, "to" | "reads" | "lineage"> {
99
+ type: "ClickHouse::View" | "ClickHouse::MaterializedView";
100
+ /** A materialized view's target: its export name when declared, else the name as written. */
101
+ to?: RefJson;
102
+ reads: RefJson[];
103
+ lineage: LineageJson[];
104
+ }
105
+
106
+ // ── Finding the documents ─────────────────────────────────────────────
107
+
108
+ /** True for a value shaped like the serializer's document. */
109
+ export function isSqlOutputDocument(value: unknown): value is SqlOutputDocument {
110
+ const v = value as { dialect?: unknown; objects?: unknown } | null;
111
+ return v !== null && typeof v === "object" && (v.dialect === "clickhouse" || v.dialect === "postgres") && Array.isArray(v.objects);
112
+ }
113
+
114
+ /** Every sql output document in the context, in output order. */
115
+ export function sqlDocuments(source: SqlReadSource): SqlOutputDocument[] {
116
+ const out: SqlOutputDocument[] = [];
117
+ for (const doc of source.docs ?? []) {
118
+ if (doc.error || doc.lexicon !== "sql" || doc.format !== "json") continue;
119
+ if (isSqlOutputDocument(doc.value)) out.push(doc.value);
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /** Every serialized object of one dialect, across the context's sql documents. */
125
+ export function dialectObjects(source: SqlReadSource, dialect: SqlOutputDocument["dialect"]): SqlObjectJson[] {
126
+ const out: SqlObjectJson[] = [];
127
+ for (const doc of sqlDocuments(source)) if (doc.dialect === dialect) out.push(...doc.objects);
128
+ return out;
129
+ }
130
+
131
+ /** The Postgres major the build output records, else `fallback`. */
132
+ export function recordedPostgresMajor(source: SqlReadSource, fallback: number): number {
133
+ for (const doc of sqlDocuments(source)) {
134
+ if (doc.dialect === "postgres" && typeof doc.postgresMajor === "number") return doc.postgresMajor;
135
+ }
136
+ return fallback;
137
+ }
@@ -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
+ });