@intentius/chant-lexicon-sql 0.122.0 → 0.123.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/clickhouse/entities.d.ts +37 -11
- package/dist/clickhouse/entities.d.ts.map +1 -1
- package/dist/clickhouse/import/generator.d.ts.map +1 -1
- package/dist/clickhouse/plan/commands.d.ts.map +1 -1
- package/dist/clickhouse/row-types.d.ts +30 -0
- package/dist/clickhouse/row-types.d.ts.map +1 -0
- package/dist/clickhouse.d.ts +1 -1
- package/dist/clickhouse.d.ts.map +1 -1
- package/dist/core/index.d.ts +4 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/typed.d.ts +132 -0
- package/dist/core/typed.d.ts.map +1 -0
- package/dist/integrity.json +7 -3
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/postgres-names.d.ts +2 -0
- package/dist/lint/post-synth/postgres-names.d.ts.map +1 -1
- package/dist/lint/post-synth/sql102.d.ts +16 -0
- package/dist/lint/post-synth/sql102.d.ts.map +1 -0
- package/dist/lint/rules/index.d.ts +3 -1
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/lint/rules/sqlch004.d.ts +19 -0
- package/dist/lint/rules/sqlch004.d.ts.map +1 -0
- package/dist/lint/rules/sqlpg005.d.ts +18 -0
- package/dist/lint/rules/sqlpg005.d.ts.map +1 -0
- package/dist/lint/rules/typed-names.d.ts +43 -0
- package/dist/lint/rules/typed-names.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +3 -0
- package/dist/okf/rules/SQL102.md +11 -0
- package/dist/okf/rules/SQLCH004.md +11 -0
- package/dist/okf/rules/SQLPG005.md +11 -0
- package/dist/postgres/entities.d.ts +38 -9
- package/dist/postgres/entities.d.ts.map +1 -1
- package/dist/postgres/import/generator.d.ts.map +1 -1
- package/dist/postgres/row-types.d.ts +17 -0
- package/dist/postgres/row-types.d.ts.map +1 -0
- package/dist/postgres/testing/migration.d.ts +3 -2
- package/dist/postgres/testing/migration.d.ts.map +1 -1
- package/dist/postgres.d.ts +1 -1
- package/dist/postgres.d.ts.map +1 -1
- package/dist/rules/postgres-names.ts +6 -2
- package/dist/rules/sql102.ts +34 -0
- package/dist/rules/sqlch004.ts +81 -0
- package/dist/rules/sqlpg005.ts +73 -0
- package/dist/rules/typed-names.ts +120 -0
- package/dist/sql-types-command.d.ts +19 -0
- package/dist/sql-types-command.d.ts.map +1 -0
- package/dist/sql-types.d.ts +53 -0
- package/dist/sql-types.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/clickhouse/dictionary.test.ts +1 -1
- package/src/clickhouse/entities.ts +79 -14
- package/src/clickhouse/import/generator.ts +7 -1
- package/src/clickhouse/import/roundtrip.test.ts +1 -1
- package/src/clickhouse/plan/commands.ts +5 -0
- package/src/clickhouse/row-types.ts +158 -0
- package/src/clickhouse.ts +10 -0
- package/src/core/index.ts +4 -0
- package/src/core/typed.ts +143 -0
- package/src/lint/audit-catalog.ts +20 -0
- package/src/lint/post-synth/index.ts +2 -0
- package/src/lint/post-synth/postgres-names.ts +6 -2
- package/src/lint/post-synth/sql102.ts +34 -0
- package/src/lint/rules/index.ts +4 -2
- package/src/lint/rules/sqlch004.ts +81 -0
- package/src/lint/rules/sqlpg005.ts +73 -0
- package/src/lint/rules/typed-names.test.ts +151 -0
- package/src/lint/rules/typed-names.ts +120 -0
- package/src/op/__snapshots__/watch-pipeline.test.ts.snap +3 -3
- package/src/postgres/entities.ts +65 -12
- package/src/postgres/import/generator.ts +7 -1
- package/src/postgres/row-types.ts +111 -0
- package/src/postgres.ts +10 -0
- package/src/sql-types-command.ts +99 -0
- package/src/sql-types-staleness.test.ts +110 -0
- package/src/sql-types.test.ts +132 -0
- package/src/sql-types.ts +352 -0
- package/src/typecheck.test.ts +12 -3
- package/src/typed-columns.test.ts +92 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, expect, test } from "vitest";
|
|
5
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
6
|
+
import { COLUMN_TYPES } from "./generated/postgres";
|
|
7
|
+
import { TYPE_FAMILIES } from "./generated/clickhouse";
|
|
8
|
+
import { POSTGRES_ROW_TYPES } from "./postgres/row-types";
|
|
9
|
+
import { CLICKHOUSE_ROW_TYPES, clickhouseRowType } from "./clickhouse/row-types";
|
|
10
|
+
import * as pg from "./postgres/entities";
|
|
11
|
+
import * as ch from "./clickhouse/entities";
|
|
12
|
+
import { getTypedSlots, relationKey } from "./core/typed";
|
|
13
|
+
import { sqlSerializer } from "./serializer";
|
|
14
|
+
import { renderSqlTypes } from "./sql-types";
|
|
15
|
+
import { runTypes, shadowingSource } from "./sql-types-command";
|
|
16
|
+
|
|
17
|
+
const entities = (o: Record<string, unknown>) => new Map(Object.entries(o)) as Map<string, Declarable>;
|
|
18
|
+
|
|
19
|
+
describe("the row type mapping covers the pinned catalogs", () => {
|
|
20
|
+
test("every Postgres built-in type of every supported major has a row type, and nothing else does", () => {
|
|
21
|
+
expect(Object.keys(POSTGRES_ROW_TYPES).sort()).toEqual(Object.keys(COLUMN_TYPES).sort());
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("every ClickHouse type family has a row type, and nothing else does", () => {
|
|
25
|
+
const families = [...new Set(Object.values(TYPE_FAMILIES).map((f) => f.canonical))].sort();
|
|
26
|
+
expect(Object.keys(CLICKHOUSE_ROW_TYPES).sort()).toEqual(families);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("ClickHouse wrappers and containers compose; an alias reads as its family", () => {
|
|
30
|
+
expect(clickhouseRowType("Nullable(UInt64)")).toEqual({ ts: "string", nullable: true });
|
|
31
|
+
expect(clickhouseRowType("LowCardinality(Nullable(String))")).toEqual({ ts: "string", nullable: true });
|
|
32
|
+
expect(clickhouseRowType("Array(Nullable(Int32))").ts).toBe("(number | null)[]");
|
|
33
|
+
expect(clickhouseRowType("Map(String, Array(UInt8))").ts).toBe("Record<string, number[]>");
|
|
34
|
+
expect(clickhouseRowType("Tuple(String, UInt8)").ts).toBe("[string, number]");
|
|
35
|
+
expect(clickhouseRowType("Enum8('a' = 1, 'it''s' = 2)").ts).toBe('"a" | "it\'s"');
|
|
36
|
+
expect(clickhouseRowType("BIGINT UNSIGNED").ts).toBe("string");
|
|
37
|
+
expect(clickhouseRowType("SimpleAggregateFunction(sum, Float64)").ts).toBe("number");
|
|
38
|
+
expect(clickhouseRowType("NoSuchType").ts).toBe("unknown");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("the slots a tag records", () => {
|
|
43
|
+
test("a Postgres foreign key's interpolated referenced column, paired with the referencing column, off the output", () => {
|
|
44
|
+
const s = pg.schema`CREATE SCHEMA shop`;
|
|
45
|
+
const a = pg.table`CREATE TABLE ${s}.a (id bigint PRIMARY KEY, code text UNIQUE)`;
|
|
46
|
+
const b = pg.table<"shop.b">`
|
|
47
|
+
CREATE TABLE ${s}.b (
|
|
48
|
+
a_id bigint REFERENCES ${a} (${a.columns.id}),
|
|
49
|
+
a_code text,
|
|
50
|
+
FOREIGN KEY (a_code) REFERENCES ${a} (${a.columns.code}),
|
|
51
|
+
other bigint REFERENCES ${a}
|
|
52
|
+
)`;
|
|
53
|
+
expect(getTypedSlots(b)).toEqual([
|
|
54
|
+
{ index: 2, kind: "references", column: "a_id" },
|
|
55
|
+
{ index: 4, kind: "references", column: "a_code" },
|
|
56
|
+
]);
|
|
57
|
+
expect(Object.getOwnPropertySymbols(b).some((k) => Object.getOwnPropertyDescriptor(b, k)!.enumerable)).toBe(false);
|
|
58
|
+
const out = sqlSerializer.serialize(entities({ s, a, b })) as { primary: string };
|
|
59
|
+
expect(out.primary).not.toContain("references\", \"column");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a ClickHouse sort key's bare interpolated columns, and nothing inside an expression", () => {
|
|
63
|
+
const db = ch.database`CREATE DATABASE shop`;
|
|
64
|
+
const e = ch.table`CREATE TABLE ${db}.e (k String, c Nullable(String), n UInt8) ENGINE = MergeTree ORDER BY k`;
|
|
65
|
+
const v = ch.view`
|
|
66
|
+
CREATE MATERIALIZED VIEW ${db}.v ENGINE = MergeTree
|
|
67
|
+
ORDER BY (${e.columns.k}, ifNull(${e.columns.c}, ''))
|
|
68
|
+
PRIMARY KEY ${e.columns.k}
|
|
69
|
+
AS SELECT ${e.columns.k}, ${e.columns.c} FROM ${e}`;
|
|
70
|
+
expect(getTypedSlots(v)).toEqual([
|
|
71
|
+
{ index: 1, kind: "sortKey" },
|
|
72
|
+
{ index: 3, kind: "sortKey" },
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("renderSqlTypes", () => {
|
|
78
|
+
test("keys are qualified and folded, a view passes its column through, slots stop at the last checked one", () => {
|
|
79
|
+
const s = pg.schema`CREATE SCHEMA Shop`;
|
|
80
|
+
const users = pg.table`CREATE TABLE ${s}.Users (Id serial PRIMARY KEY, mood text, born date NOT NULL)`;
|
|
81
|
+
const posts = pg.table`CREATE TABLE ${s}.posts (author integer REFERENCES ${users} (${users.columns.id}), body text)`;
|
|
82
|
+
const feed = pg.view`CREATE VIEW ${s}.feed AS SELECT p.${posts.columns.body}, upper(p.${posts.columns.body}) AS loud FROM ${posts} p`;
|
|
83
|
+
const text = renderSqlTypes(entities({ s, users, posts, feed }), { projectDir: "/" });
|
|
84
|
+
expect(text.split("\n")[0]).toContain("Generated by chant");
|
|
85
|
+
expect(text).toContain('import type { Column, KeyColumn } from "@intentius/chant-lexicon-sql/postgres";');
|
|
86
|
+
expect(text).toContain('"shop.users": {');
|
|
87
|
+
expect(text).toContain('id: Column<"integer", { ts: number; nullable: false; cmp: "N" }>;');
|
|
88
|
+
expect(text).toContain('born: Column<"date", { ts: Date; nullable: false; cmp: "D" }>;');
|
|
89
|
+
expect(text).toContain("slots: [unknown, unknown, KeyColumn<\"N\">];");
|
|
90
|
+
expect(text).toContain('body: Column<"text", { ts: string; nullable: true; cmp: "S" }>;');
|
|
91
|
+
expect(text).toContain('loud: Column<"unknown", { ts: unknown; nullable: never; cmp: never }>;');
|
|
92
|
+
expect(text.indexOf('"shop.feed"')).toBeLessThan(text.indexOf('"shop.posts"'));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("with nothing declared, a module that augments nothing", () => {
|
|
96
|
+
expect(renderSqlTypes(new Map(), { config: { sql: { dialect: "postgres" } } })).toBe(
|
|
97
|
+
[
|
|
98
|
+
"// Generated by chant — do not edit directly. `chant sql types` writes it from the build's entities.",
|
|
99
|
+
'import type {} from "@intentius/chant-lexicon-sql/postgres";',
|
|
100
|
+
"",
|
|
101
|
+
'declare module "@intentius/chant-lexicon-sql/postgres" {',
|
|
102
|
+
" interface SqlSchema {}",
|
|
103
|
+
"}",
|
|
104
|
+
"",
|
|
105
|
+
].join("\n"),
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("relationKey: qualified when the DDL qualifies it", () => {
|
|
110
|
+
expect(relationKey({ schema: "app", name: "users" })).toBe("app.users");
|
|
111
|
+
expect(relationKey({ database: "shop", name: "events" })).toBe("shop.events");
|
|
112
|
+
expect(relationKey({ name: "events" })).toBe("events");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("chant sql types", () => {
|
|
117
|
+
test("refuses to write beside a sql-types.ts, which tsc would read instead", async () => {
|
|
118
|
+
const dir = mkdtempSync(join(tmpdir(), "sql-types-"));
|
|
119
|
+
writeFileSync(join(dir, "sql-types.ts"), "export {};\n");
|
|
120
|
+
expect(shadowingSource(join(dir, "sql-types.d.ts"))).toBe(join(dir, "sql-types.ts"));
|
|
121
|
+
expect(shadowingSource(join(dir, "other.d.ts"))).toBeUndefined();
|
|
122
|
+
const cwd = process.cwd();
|
|
123
|
+
process.chdir(dir);
|
|
124
|
+
try {
|
|
125
|
+
expect(await runTypes({ verb: "types", rawArgs: ["--output", "sql-types.d.ts"] })).toBe(1);
|
|
126
|
+
expect(await runTypes({ verb: "types", rawArgs: ["--bogus"] })).toBe(1);
|
|
127
|
+
} finally {
|
|
128
|
+
process.chdir(cwd);
|
|
129
|
+
}
|
|
130
|
+
expect(readFileSync(join(dir, "sql-types.ts"), "utf8")).toBe("export {};\n");
|
|
131
|
+
});
|
|
132
|
+
});
|
package/src/sql-types.ts
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declarations `chant sql types` writes (#3751): one `sql-types.d.ts`
|
|
3
|
+
* that augments the dialect's `SqlSchema` with every table, view and
|
|
4
|
+
* dictionary the build declares, keyed by qualified name.
|
|
5
|
+
*
|
|
6
|
+
* {@link renderSqlTypes} is pure: the entities of a build in, the file's text
|
|
7
|
+
* out. It reads the objects as the serializer writes them, so a `.sql`
|
|
8
|
+
* file's objects and a composite's members are covered exactly as the build
|
|
9
|
+
* covers them, and it reads each template's slot record (`core/typed.ts`) for
|
|
10
|
+
* the `${}` positions a foreign key or a sort key consumes.
|
|
11
|
+
*
|
|
12
|
+
* Each column is a `Column<sqlType, { ts, nullable, cmp }>`:
|
|
13
|
+
*
|
|
14
|
+
* - `sqlType` is the type as the catalog spells it (`bigint`,
|
|
15
|
+
* `timestamp(3) with time zone`, `Nullable(String)`);
|
|
16
|
+
* - `ts` is its value in application code (`./postgres/row-types.ts`,
|
|
17
|
+
* `./clickhouse/row-types.ts`);
|
|
18
|
+
* - `nullable` is whether the value can be NULL. A Postgres view's columns
|
|
19
|
+
* always can, as the server's own catalog says; a column whose type the
|
|
20
|
+
* generator cannot tell is `never`, which fits every slot;
|
|
21
|
+
* - `cmp` is how it compares in a key: SQLPG124's type identity in Postgres
|
|
22
|
+
* (`./lint/post-synth/postgres-names.ts`'s `typeIdentity`), the type
|
|
23
|
+
* family in ClickHouse; `never` when it cannot be told.
|
|
24
|
+
*
|
|
25
|
+
* A view's column that is one column reference (`${t.columns.c}`, `o.${t.columns.c}`)
|
|
26
|
+
* is that column's type; any other expression is `unknown`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
30
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
31
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
32
|
+
import { GENERATED_MARKER } from "@intentius/chant/discovery/files";
|
|
33
|
+
import { getProvenance } from "@intentius/chant/provenance";
|
|
34
|
+
import type { SerializerResult } from "@intentius/chant/serializer";
|
|
35
|
+
import { isPostgresObject } from "./postgres/entities";
|
|
36
|
+
import { isClickHouseObject, quoteIdentifier } from "./clickhouse/entities";
|
|
37
|
+
import { quoteIdent } from "./postgres/keywords";
|
|
38
|
+
import { getTypedSlots, relationKey } from "./core/typed";
|
|
39
|
+
import { sqlSerializer } from "./serializer";
|
|
40
|
+
import { sqlFileOf } from "./sql-files";
|
|
41
|
+
import { filesDialect } from "./files/build-root";
|
|
42
|
+
import type { SqlDialect } from "./dialects";
|
|
43
|
+
import { POSTGRES_ROW_TYPES } from "./postgres/row-types";
|
|
44
|
+
import { clickhouseRowType } from "./clickhouse/row-types";
|
|
45
|
+
import { typeFamily } from "./lint/post-synth/clickhouse-helpers";
|
|
46
|
+
import { PINNED_MAJOR } from "./lint/post-synth/postgres-helpers";
|
|
47
|
+
import { parseType, projectFromObjects, resolveType, typeIdentity, type ParsedType, type PgProject } from "./lint/post-synth/postgres-names";
|
|
48
|
+
import type { OutputObject } from "./lint/post-synth/sql-helpers";
|
|
49
|
+
|
|
50
|
+
/** The file `chant sql types` writes, in the project's source directory. */
|
|
51
|
+
export const SQL_TYPES_FILE = "sql-types.d.ts";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Where `chant sql types` writes the file by default: the project's
|
|
55
|
+
* `sourceDir`, or the project root when it is unset.
|
|
56
|
+
*/
|
|
57
|
+
export function sqlTypesPath(projectDir: string, config: Record<string, unknown> | undefined): string {
|
|
58
|
+
const sourceDir = typeof config?.sourceDir === "string" ? config.sourceDir : ".";
|
|
59
|
+
return join(resolve(projectDir, sourceDir), SQL_TYPES_FILE);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The file on disk against what the build's entities render: `missing`, `stale` or `current`. */
|
|
63
|
+
export function sqlTypesState(
|
|
64
|
+
entities: ReadonlyMap<string, Declarable>,
|
|
65
|
+
file: string,
|
|
66
|
+
opts: RenderSqlTypesOptions = {},
|
|
67
|
+
): "missing" | "stale" | "current" {
|
|
68
|
+
if (!existsSync(file)) return "missing";
|
|
69
|
+
let onDisk: string;
|
|
70
|
+
try {
|
|
71
|
+
onDisk = readFileSync(file, "utf8");
|
|
72
|
+
} catch {
|
|
73
|
+
return "missing";
|
|
74
|
+
}
|
|
75
|
+
return onDisk === renderSqlTypes(entities, opts) ? "current" : "stale";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The module each dialect's `SqlSchema` is augmented in. */
|
|
79
|
+
export const SQL_TYPES_MODULE: Readonly<Record<SqlDialect, string>> = {
|
|
80
|
+
postgres: "@intentius/chant-lexicon-sql/postgres",
|
|
81
|
+
clickhouse: "@intentius/chant-lexicon-sql/clickhouse",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export interface RenderSqlTypesOptions {
|
|
85
|
+
/** The directory each declaration's source file is named relative to. Default: the working directory. */
|
|
86
|
+
projectDir?: string;
|
|
87
|
+
/** The project's config: `sql.postgresMajor` for the catalog, `sql.dialect` when nothing is declared. */
|
|
88
|
+
config?: Record<string, unknown>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface ColumnEntry {
|
|
92
|
+
name: string;
|
|
93
|
+
sqlType: string;
|
|
94
|
+
ts: string;
|
|
95
|
+
/** `never` when the generator cannot tell. */
|
|
96
|
+
nullable: boolean | "never";
|
|
97
|
+
/** Undefined when it cannot be told; written `never`. */
|
|
98
|
+
cmp?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface RelationEntry {
|
|
102
|
+
key: string;
|
|
103
|
+
origin: string;
|
|
104
|
+
columns: ColumnEntry[];
|
|
105
|
+
/** Per `${}` index, the slot's type; holes are `unknown`. */
|
|
106
|
+
slots: Map<number, { type: string; note: string }>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const UNKNOWN_COLUMN = (name: string): ColumnEntry => ({ name, sqlType: "unknown", ts: "unknown", nullable: "never" });
|
|
110
|
+
|
|
111
|
+
const posix = (p: string) => p.split(sep).join("/");
|
|
112
|
+
|
|
113
|
+
/** Where a declaration came from: its file relative to the project, and its export. */
|
|
114
|
+
function originOf(entity: Declarable, exportName: string, projectDir: string): string {
|
|
115
|
+
const prov = getProvenance(entity);
|
|
116
|
+
const file = sqlFileOf(entity) ?? (prov?.sourceFile ? relative(projectDir, prov.sourceFile) : undefined);
|
|
117
|
+
const composite = prov?.composite ? ` (${prov.composite})` : "";
|
|
118
|
+
return `${file ? posix(file) : "?"}: ${exportName}${composite}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The names a relation's `.columns` record holds at run time, in order. */
|
|
122
|
+
const runtimeColumns = (entity: Declarable | undefined): string[] => Object.keys((entity as { columns?: object } | undefined)?.columns ?? {});
|
|
123
|
+
|
|
124
|
+
/** Whether `expr` is the column `column` alone, bare or qualified (`o.user_id`). */
|
|
125
|
+
function isBareColumn(expr: string, quoted: string): boolean {
|
|
126
|
+
const unqualified = expr.trim().replace(/^(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[A-Za-z_][\w$]*)\./, "");
|
|
127
|
+
return unqualified === quoted;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface LineageJson {
|
|
131
|
+
output: string;
|
|
132
|
+
expr: string;
|
|
133
|
+
from: Array<string | null>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A view's column that passes one column through: that column's export and name. */
|
|
137
|
+
function passthrough(o: OutputObject, name: string, quote: (c: string) => string): { export: string; column: string } | undefined {
|
|
138
|
+
const edge = ((o.lineage as LineageJson[] | undefined) ?? []).find((e) => e.output === name);
|
|
139
|
+
const ref = edge?.from.length === 1 ? edge.from[0] : undefined;
|
|
140
|
+
if (!edge || !ref) return undefined;
|
|
141
|
+
const dot = ref.indexOf(".");
|
|
142
|
+
if (dot < 0) return undefined;
|
|
143
|
+
const column = ref.slice(dot + 1);
|
|
144
|
+
return isBareColumn(edge.expr, quote(column)) ? { export: ref.slice(0, dot), column } : undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Postgres ────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
const PG_RELATIONS = new Set(["Postgres::Table", "Postgres::View", "Postgres::MaterializedView"]);
|
|
150
|
+
|
|
151
|
+
/** A built-in type spelled as `format_type()` spells it, with its modifiers. */
|
|
152
|
+
function spell(canonical: string, t: ParsedType): string {
|
|
153
|
+
let s = canonical;
|
|
154
|
+
if (t.fields) s = `${s} ${t.fields.toLowerCase()}`;
|
|
155
|
+
if (t.modifiers && t.modifiers.length > 0) {
|
|
156
|
+
const mods = `(${t.modifiers.join(",")})`;
|
|
157
|
+
const zoned = /^(time|timestamp)( with(?:out)? time zone)$/.exec(s);
|
|
158
|
+
s = zoned ? `${zoned[1]}${mods}${zoned[2]}` : `${s}${mods}`;
|
|
159
|
+
}
|
|
160
|
+
return s;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const arrayOf = (ts: string): string => `${/[|&]/.test(ts) && !/^\{.*\}$/s.test(ts) ? `(${ts})` : ts}[]`;
|
|
164
|
+
|
|
165
|
+
/** A Postgres type's spelling, row type, and whether the type itself forbids NULL (a serial, a NOT NULL domain). */
|
|
166
|
+
function pgType(p: PgProject, text: string | undefined, depth = 0): { sqlType: string; ts: string; notNull: boolean } {
|
|
167
|
+
const t = parseType(text);
|
|
168
|
+
if (!t || depth > 5) return { sqlType: text?.trim() || "unknown", ts: "unknown", notNull: false };
|
|
169
|
+
const r = resolveType(p, t, { serial: true });
|
|
170
|
+
const array = (x: { sqlType: string; ts: string; notNull: boolean }) => (t.array ? { sqlType: `${x.sqlType}[]`, ts: arrayOf(x.ts), notNull: x.notNull } : x);
|
|
171
|
+
if (r.kind === "builtin") {
|
|
172
|
+
const ts = (POSTGRES_ROW_TYPES as Readonly<Record<string, string | undefined>>)[r.canonical] ?? "unknown";
|
|
173
|
+
return array({ sqlType: spell(r.canonical, t), ts, notNull: r.serial === true });
|
|
174
|
+
}
|
|
175
|
+
if (r.kind === "declared") {
|
|
176
|
+
const o = r.object;
|
|
177
|
+
const sqlType = String(o.sqlName ?? o.name);
|
|
178
|
+
if (o.type === "Postgres::Enum") {
|
|
179
|
+
const labels = (o.labels as string[] | undefined) ?? [];
|
|
180
|
+
return array({ sqlType, ts: labels.length > 0 ? labels.map((l) => JSON.stringify(l)).join(" | ") : "string", notNull: false });
|
|
181
|
+
}
|
|
182
|
+
if (o.type === "Postgres::Domain") {
|
|
183
|
+
const base = pgType(p, o.dataType as string | undefined, depth + 1);
|
|
184
|
+
return array({ sqlType, ts: base.ts, notNull: o.notNull === true || base.notNull });
|
|
185
|
+
}
|
|
186
|
+
// A row type: the driver hands back its text.
|
|
187
|
+
return array({ sqlType, ts: "string", notNull: false });
|
|
188
|
+
}
|
|
189
|
+
return { sqlType: text!.trim(), ts: "unknown", notNull: false };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface PgTableJson extends OutputObject {
|
|
193
|
+
columns: Array<{ name: string; type?: string; notNull?: boolean; generated?: { kind: string } }>;
|
|
194
|
+
primaryKey?: { columns: string[] };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function postgresEntries(objects: OutputObject[], major: number, entities: ReadonlyMap<string, Declarable>, projectDir: string): RelationEntry[] {
|
|
198
|
+
const p = projectFromObjects(objects, major);
|
|
199
|
+
const done = new Map<string, RelationEntry | undefined>();
|
|
200
|
+
const entryOf = (exportName: string): RelationEntry | undefined => {
|
|
201
|
+
if (done.has(exportName)) return done.get(exportName);
|
|
202
|
+
done.set(exportName, undefined);
|
|
203
|
+
const o = p.byExport.get(exportName);
|
|
204
|
+
const entity = entities.get(exportName);
|
|
205
|
+
if (!o || !entity || !PG_RELATIONS.has(o.type)) return undefined;
|
|
206
|
+
const names = runtimeColumns(entity);
|
|
207
|
+
let columns: ColumnEntry[];
|
|
208
|
+
if (o.type === "Postgres::Table") {
|
|
209
|
+
const t = o as PgTableJson;
|
|
210
|
+
const own = new Map(t.columns.map((c) => [c.name, c]));
|
|
211
|
+
// A partition's `col WITH OPTIONS` has no type of its own: its parent's stands.
|
|
212
|
+
const inherited = new Map<string, string | undefined>();
|
|
213
|
+
for (const parent of [...(((t.inherits as unknown[]) ?? []) as unknown[]), ...(t.partitionOf !== undefined ? [t.partitionOf] : [])]) {
|
|
214
|
+
const pt = typeof parent === "string" ? (p.byExport.get(parent) as PgTableJson | undefined) : undefined;
|
|
215
|
+
for (const c of pt?.columns ?? []) if (!inherited.has(c.name)) inherited.set(c.name, c.type);
|
|
216
|
+
}
|
|
217
|
+
const pk = new Set(t.primaryKey?.columns ?? []);
|
|
218
|
+
columns = names.map((name) => {
|
|
219
|
+
const c = own.get(name);
|
|
220
|
+
const text = c?.type ?? inherited.get(name);
|
|
221
|
+
if (text === undefined) return UNKNOWN_COLUMN(name);
|
|
222
|
+
const ty = pgType(p, text);
|
|
223
|
+
const notNull = c?.notNull === true || pk.has(name) || c?.generated?.kind === "identity" || ty.notNull;
|
|
224
|
+
return { name, sqlType: ty.sqlType, ts: ty.ts, nullable: !notNull, cmp: typeIdentity(p, text) };
|
|
225
|
+
});
|
|
226
|
+
} else {
|
|
227
|
+
columns = names.map((name) => {
|
|
228
|
+
const via = passthrough(o, name, quoteIdent);
|
|
229
|
+
const source = via && entryOf(via.export)?.columns.find((c) => c.name === via.column);
|
|
230
|
+
// A view's column is nullable to Postgres whatever it reads.
|
|
231
|
+
return source ? { ...source, name, nullable: source.nullable === "never" ? "never" : true } : UNKNOWN_COLUMN(name);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
const slots = new Map<number, { type: string; note: string }>();
|
|
235
|
+
for (const s of getTypedSlots(entity)) {
|
|
236
|
+
if (s.kind !== "references") continue;
|
|
237
|
+
const cmp = columns.find((c) => c.name === s.column)?.cmp;
|
|
238
|
+
if (cmp !== undefined) slots.set(s.index, { type: `KeyColumn<${JSON.stringify(cmp)}>`, note: `the column ${s.column} references` });
|
|
239
|
+
}
|
|
240
|
+
const entry: RelationEntry = { key: relationKey(o as { schema?: string; name: string }), origin: originOf(entity, exportName, projectDir), columns, slots };
|
|
241
|
+
done.set(exportName, entry);
|
|
242
|
+
return entry;
|
|
243
|
+
};
|
|
244
|
+
return objects.filter((o) => PG_RELATIONS.has(o.type)).map((o) => entryOf(o.export)).filter((e): e is RelationEntry => e !== undefined);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── ClickHouse ──────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
const CH_RELATIONS = new Set(["ClickHouse::Table", "ClickHouse::View", "ClickHouse::MaterializedView", "ClickHouse::Dictionary"]);
|
|
250
|
+
|
|
251
|
+
function chColumn(name: string, type: string | undefined, nullableModifier: boolean | undefined): ColumnEntry {
|
|
252
|
+
if (!type) return UNKNOWN_COLUMN(name);
|
|
253
|
+
const sqlType = nullableModifier === true && !/^Nullable\s*\(/i.test(type.trim()) ? `Nullable(${type.trim()})` : type.trim();
|
|
254
|
+
const row = clickhouseRowType(sqlType);
|
|
255
|
+
return { name, sqlType, ts: row.ts, nullable: row.nullable, cmp: typeFamily(sqlType) || undefined };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function clickhouseEntries(objects: OutputObject[], entities: ReadonlyMap<string, Declarable>, projectDir: string): RelationEntry[] {
|
|
259
|
+
const byExport = new Map(objects.map((o) => [o.export, o]));
|
|
260
|
+
const done = new Map<string, RelationEntry | undefined>();
|
|
261
|
+
const entryOf = (exportName: string): RelationEntry | undefined => {
|
|
262
|
+
if (done.has(exportName)) return done.get(exportName);
|
|
263
|
+
done.set(exportName, undefined);
|
|
264
|
+
const o = byExport.get(exportName);
|
|
265
|
+
const entity = entities.get(exportName);
|
|
266
|
+
if (!o || !entity || !CH_RELATIONS.has(o.type)) return undefined;
|
|
267
|
+
const declared = new Map(((o.columns as Array<{ name: string; type?: string; nullable?: boolean }> | undefined) ?? []).map((c) => [c.name, c]));
|
|
268
|
+
const columns = runtimeColumns(entity).map((name) => {
|
|
269
|
+
const c = declared.get(name);
|
|
270
|
+
if (c) return chColumn(name, c.type, c.nullable);
|
|
271
|
+
const via = passthrough(o, name, quoteIdentifier);
|
|
272
|
+
const source = via && entryOf(via.export)?.columns.find((x) => x.name === via.column);
|
|
273
|
+
return source ? { ...source, name } : UNKNOWN_COLUMN(name);
|
|
274
|
+
});
|
|
275
|
+
const slots = new Map<number, { type: string; note: string }>();
|
|
276
|
+
for (const s of getTypedSlots(entity)) if (s.kind === "sortKey") slots.set(s.index, { type: "NotNullColumn", note: "a sort key element" });
|
|
277
|
+
const entry: RelationEntry = { key: relationKey(o as { database?: string; name: string }), origin: originOf(entity, exportName, projectDir), columns, slots };
|
|
278
|
+
done.set(exportName, entry);
|
|
279
|
+
return entry;
|
|
280
|
+
};
|
|
281
|
+
return objects.filter((o) => CH_RELATIONS.has(o.type)).map((o) => entryOf(o.export)).filter((e): e is RelationEntry => e !== undefined);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Rendering ───────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
const propertyName = (name: string): string => (/^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name));
|
|
287
|
+
|
|
288
|
+
function renderEntry(e: RelationEntry, lines: string[]): void {
|
|
289
|
+
lines.push(` /** ${e.origin.replace(/\*\//g, "* /")} */`, ` ${JSON.stringify(e.key)}: {`, " columns: {");
|
|
290
|
+
for (const c of e.columns) {
|
|
291
|
+
const nullable = c.nullable === "never" ? "never" : String(c.nullable);
|
|
292
|
+
const cmp = c.cmp === undefined ? "never" : JSON.stringify(c.cmp);
|
|
293
|
+
lines.push(` ${propertyName(c.name)}: Column<${JSON.stringify(c.sqlType)}, { ts: ${c.ts}; nullable: ${nullable}; cmp: ${cmp} }>;`);
|
|
294
|
+
}
|
|
295
|
+
lines.push(" };");
|
|
296
|
+
if (e.slots.size > 0) {
|
|
297
|
+
const last = Math.max(...e.slots.keys());
|
|
298
|
+
const types = Array.from({ length: last + 1 }, (_, i) => e.slots.get(i)?.type ?? "unknown");
|
|
299
|
+
const notes = [...e.slots].sort(([a], [b]) => a - b).map(([i, s]) => `\`\${}\` ${i + 1} is ${s.note}`);
|
|
300
|
+
lines.push(` /** ${notes.join("; ")}. */`, ` slots: [${types.join(", ")}];`);
|
|
301
|
+
}
|
|
302
|
+
lines.push(" };");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** The dialect a build's entities declare, else the config's, else ClickHouse. */
|
|
306
|
+
function dialectOf(entities: ReadonlyMap<string, Declarable>, config: Record<string, unknown>): SqlDialect {
|
|
307
|
+
try {
|
|
308
|
+
return filesDialect(entities, config);
|
|
309
|
+
} catch {
|
|
310
|
+
return "clickhouse";
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The `sql-types.d.ts` for a build's entities: one module that augments the
|
|
316
|
+
* dialect's `SqlSchema` with each table, view and dictionary, in key order.
|
|
317
|
+
* Pure, so a check can render it in memory and compare it with the file.
|
|
318
|
+
*/
|
|
319
|
+
export function renderSqlTypes(entities: ReadonlyMap<string, Declarable>, opts: RenderSqlTypesOptions = {}): string {
|
|
320
|
+
const config = opts.config ?? {};
|
|
321
|
+
const projectDir = opts.projectDir ?? process.cwd();
|
|
322
|
+
const dialect = dialectOf(entities, config);
|
|
323
|
+
const sqlEntities = new Map([...entities].filter(([, e]) => isPostgresObject(e) || isClickHouseObject(e)));
|
|
324
|
+
let entries: RelationEntry[] = [];
|
|
325
|
+
if (sqlEntities.size > 0) {
|
|
326
|
+
const out = sqlSerializer.serialize(new Map(entities), undefined, { config }) as SerializerResult | string;
|
|
327
|
+
const doc = JSON.parse(typeof out === "string" ? out : out.primary) as { objects: OutputObject[]; postgresMajor?: number };
|
|
328
|
+
entries = dialect === "postgres" ? postgresEntries(doc.objects, doc.postgresMajor ?? PINNED_MAJOR, entities, projectDir) : clickhouseEntries(doc.objects, entities, projectDir);
|
|
329
|
+
}
|
|
330
|
+
const byKey = new Map<string, RelationEntry>();
|
|
331
|
+
for (const e of entries) if (!byKey.has(e.key)) byKey.set(e.key, e);
|
|
332
|
+
const sorted = [...byKey.values()].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
333
|
+
|
|
334
|
+
const brands = new Set<string>();
|
|
335
|
+
if (sorted.length > 0) brands.add("Column");
|
|
336
|
+
for (const e of sorted) for (const s of e.slots.values()) brands.add(s.type.replace(/<.*$/, ""));
|
|
337
|
+
const module = SQL_TYPES_MODULE[dialect];
|
|
338
|
+
const lines = [
|
|
339
|
+
`// ${GENERATED_MARKER}. \`chant sql types\` writes it from the build's entities.`,
|
|
340
|
+
`import type ${brands.size > 0 ? `{ ${[...brands].sort().join(", ")} }` : "{}"} from ${JSON.stringify(module)};`,
|
|
341
|
+
"",
|
|
342
|
+
`declare module ${JSON.stringify(module)} {`,
|
|
343
|
+
];
|
|
344
|
+
if (sorted.length === 0) lines.push(" interface SqlSchema {}");
|
|
345
|
+
else {
|
|
346
|
+
lines.push(" interface SqlSchema {");
|
|
347
|
+
for (const e of sorted) renderEntry(e, lines);
|
|
348
|
+
lines.push(" }");
|
|
349
|
+
}
|
|
350
|
+
lines.push("}");
|
|
351
|
+
return `${lines.join("\n")}\n`;
|
|
352
|
+
}
|
package/src/typecheck.test.ts
CHANGED
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
type SkipIndexType,
|
|
24
24
|
type SqlLiteral,
|
|
25
25
|
type TableEngineName,
|
|
26
|
+
type UncheckedColumn,
|
|
27
|
+
type Unnamed,
|
|
26
28
|
} from "./clickhouse";
|
|
27
29
|
|
|
28
30
|
describe("the tags", () => {
|
|
@@ -32,9 +34,16 @@ describe("the tags", () => {
|
|
|
32
34
|
const recent = view`CREATE VIEW ${db}.recent AS SELECT ${events.columns.user_id} FROM ${events}`;
|
|
33
35
|
|
|
34
36
|
expectTypeOf(db).toEqualTypeOf<ClickHouseDatabase>();
|
|
35
|
-
|
|
36
|
-
expectTypeOf(
|
|
37
|
-
expectTypeOf(
|
|
37
|
+
// No type argument: the relation is unnamed and its columns unchecked, each still an AttrRef.
|
|
38
|
+
expectTypeOf(events).toEqualTypeOf<ClickHouseTable<Unnamed>>();
|
|
39
|
+
expectTypeOf(recent).toEqualTypeOf<ClickHouseView<Unnamed>>();
|
|
40
|
+
expectTypeOf(events.columns.user_id).toEqualTypeOf<UncheckedColumn>();
|
|
41
|
+
expectTypeOf(events.columns.user_id).toExtend<AttrRef>();
|
|
42
|
+
expectTypeOf(events).toExtend<ClickHouseTable>();
|
|
43
|
+
// A name no generated declaration lists yet keeps unchecked columns too.
|
|
44
|
+
const fresh = table<"analytics.fresh">`CREATE TABLE ${db}.fresh (a UInt8) ENGINE = Log`;
|
|
45
|
+
expectTypeOf(fresh.columns.anything).toEqualTypeOf<UncheckedColumn>();
|
|
46
|
+
expectTypeOf(fresh).toExtend<ClickHouseTable>();
|
|
38
47
|
expectTypeOf(events.entityType).toEqualTypeOf<"ClickHouse::Table">();
|
|
39
48
|
expectTypeOf(db.entityType).toEqualTypeOf<"ClickHouse::Database">();
|
|
40
49
|
expectTypeOf(recent.entityType).toEqualTypeOf<"ClickHouse::View" | "ClickHouse::MaterializedView">();
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed columns reach plain `tsc` (#3751). Each fixture project under
|
|
3
|
+
* `test-fixtures/typed-columns/` is built the way `chant sql types` builds
|
|
4
|
+
* it, its declarations rendered and compared with the committed
|
|
5
|
+
* `src/sql-types.d.ts`, and then the project is type-checked with the
|
|
6
|
+
* compiler options a strict application uses. tsc must report exactly the
|
|
7
|
+
* errors the fixture plants: a misspelled column, a foreign key between
|
|
8
|
+
* types that do not compare, a Nullable column in a sort key, and `RowOf` of
|
|
9
|
+
* a name no declaration has or of a relation with no name.
|
|
10
|
+
*
|
|
11
|
+
* The fixtures sit outside `src/` and are excluded from the repo's
|
|
12
|
+
* typecheck: their declarations augment `SqlSchema`, which would leak into
|
|
13
|
+
* every other program, and their errors are on purpose.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { dirname, join, relative } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import ts from "typescript";
|
|
20
|
+
import { describe, expect, test } from "vitest";
|
|
21
|
+
import { build } from "@intentius/chant/build";
|
|
22
|
+
import { resolveProjectBuildOptions } from "@intentius/chant/cli/build-options";
|
|
23
|
+
import { sqlPlugin } from "./plugin";
|
|
24
|
+
import { sqlSerializer } from "./serializer";
|
|
25
|
+
import { renderSqlTypes, SQL_TYPES_FILE } from "./sql-types";
|
|
26
|
+
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const repoRoot = join(here, "..", "..", "..");
|
|
29
|
+
const fixtures = join(here, "..", "test-fixtures", "typed-columns");
|
|
30
|
+
|
|
31
|
+
async function rendered(dir: string): Promise<string> {
|
|
32
|
+
const options = resolveProjectBuildOptions({ config: {}, configDir: dir, plugins: [sqlPlugin], modes: { fold: true, sandbox: false } });
|
|
33
|
+
const result = await build(join(dir, "src"), [sqlSerializer], undefined, options);
|
|
34
|
+
expect(result.errors.map(String)).toEqual([]);
|
|
35
|
+
return renderSqlTypes(result.entities, { projectDir: dir });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Every error tsc reports in the fixture's own files, as `file(line): TScode message`. */
|
|
39
|
+
function tscErrors(dir: string): string[] {
|
|
40
|
+
const options: ts.CompilerOptions = {
|
|
41
|
+
target: ts.ScriptTarget.ES2022,
|
|
42
|
+
lib: ["lib.es2023.d.ts"],
|
|
43
|
+
module: ts.ModuleKind.ESNext,
|
|
44
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
45
|
+
strict: true,
|
|
46
|
+
noUncheckedIndexedAccess: true,
|
|
47
|
+
exactOptionalPropertyTypes: true,
|
|
48
|
+
isolatedModules: true,
|
|
49
|
+
noEmit: true,
|
|
50
|
+
skipLibCheck: true,
|
|
51
|
+
types: ["node"],
|
|
52
|
+
typeRoots: [join(repoRoot, "node_modules", "@types")],
|
|
53
|
+
paths: {
|
|
54
|
+
"@intentius/chant": [join(repoRoot, "packages/core/src/index.ts")],
|
|
55
|
+
"@intentius/chant/*": [join(repoRoot, "packages/core/src/*")],
|
|
56
|
+
"@intentius/chant-lexicon-sql/*": [join(repoRoot, "lexicons/sql/src/*")],
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
const files = ts.sys.readDirectory(dir, [".ts"]);
|
|
60
|
+
const program = ts.createProgram(files, options);
|
|
61
|
+
const out: string[] = [];
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
const sf = program.getSourceFile(file)!;
|
|
64
|
+
for (const d of [...program.getSyntacticDiagnostics(sf), ...program.getSemanticDiagnostics(sf)]) {
|
|
65
|
+
const { line } = sf.getLineAndCharacterOfPosition(d.start ?? 0);
|
|
66
|
+
out.push(`${relative(dir, file)}(${line + 1}): TS${d.code} ${ts.flattenDiagnosticMessageText(d.messageText, "\n").split("\n")[0]}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out.sort();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("chant sql types, through tsc", () => {
|
|
73
|
+
test("Postgres: a misspelled column, a foreign key of another category and RowOf of no declaration fail; the rest compiles", async () => {
|
|
74
|
+
const dir = join(fixtures, "postgres");
|
|
75
|
+
expect(await rendered(dir)).toBe(readFileSync(join(dir, "src", SQL_TYPES_FILE), "utf8"));
|
|
76
|
+
expect(tscErrors(dir)).toEqual([
|
|
77
|
+
expect.stringMatching(/^checks\/misspelled\.ts\(4\): TS2551 Property 'emial' does not exist .* Did you mean 'email'\?$/),
|
|
78
|
+
expect.stringMatching(/^checks\/rows\.ts\(23\): TS2344 Type '"shop\.ordrs"' does not satisfy the constraint/),
|
|
79
|
+
expect.stringMatching(/^checks\/rows\.ts\(24\): TS2344 Type 'PostgresTable<Unnamed>' does not satisfy the constraint/),
|
|
80
|
+
expect.stringMatching(/^src\/refunds\.ts\(8\): TS2345 Argument of type 'Column<"bigint", .*' is not assignable to parameter of type 'KeyColumn<"S">'/),
|
|
81
|
+
]);
|
|
82
|
+
}, 120_000);
|
|
83
|
+
|
|
84
|
+
test("ClickHouse: a Nullable column in a materialized view's ORDER BY and a misspelled column fail; the rest compiles", async () => {
|
|
85
|
+
const dir = join(fixtures, "clickhouse");
|
|
86
|
+
expect(await rendered(dir)).toBe(readFileSync(join(dir, "src", SQL_TYPES_FILE), "utf8"));
|
|
87
|
+
expect(tscErrors(dir)).toEqual([
|
|
88
|
+
expect.stringMatching(/^checks\/rows\.ts\(18\): TS2551 Property 'contry' does not exist .* Did you mean 'country'\?$/),
|
|
89
|
+
expect.stringMatching(/^src\/by-country\.ts\(7\): TS2345 Argument of type 'Column<"Nullable\(String\)", .*' is not assignable to parameter of type 'NotNullColumn'/),
|
|
90
|
+
]);
|
|
91
|
+
}, 120_000);
|
|
92
|
+
});
|