@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,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the typed-column rules (SQLPG005, SQLCH004; chant #3751) share: reading
|
|
3
|
+
* a tag's type argument, comparing it with the name the template declares, and
|
|
4
|
+
* reading the names the generated `sql-types.d.ts` lists.
|
|
5
|
+
*
|
|
6
|
+
* The type argument is `table<"shop.orders">\`CREATE TABLE ...\``. tsc checks
|
|
7
|
+
* that it names an entry of the generated file's `SqlSchema` only when the
|
|
8
|
+
* argument is a constraint, and an unlisted name falls back to unchecked
|
|
9
|
+
* columns, so a mistyped argument is silent without these rules.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join, resolve } from "node:path";
|
|
14
|
+
import * as ts from "typescript";
|
|
15
|
+
|
|
16
|
+
/** The generated file's name, beside the source (`chant sql types`). */
|
|
17
|
+
export const SQL_TYPES_FILE = "sql-types.d.ts";
|
|
18
|
+
|
|
19
|
+
/** A tag's type argument, when it is a string literal: `table<"shop.orders">`. */
|
|
20
|
+
export function typeArgumentName(node: ts.TaggedTemplateExpression): { name: string; node: ts.LiteralTypeNode } | undefined {
|
|
21
|
+
const arg = node.typeArguments?.[0];
|
|
22
|
+
if (!arg || node.typeArguments!.length !== 1) return undefined;
|
|
23
|
+
if (!ts.isLiteralTypeNode(arg) || !ts.isStringLiteral(arg.literal)) return undefined;
|
|
24
|
+
return { name: arg.literal.text, node: arg };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Split a dotted name outside quotes and take the quotes off: `shop."Order Items"` is `shop`, `Order Items`. */
|
|
28
|
+
export function splitQualified(name: string): string[] {
|
|
29
|
+
const pieces: string[] = [];
|
|
30
|
+
let cur = "";
|
|
31
|
+
let quote: string | undefined;
|
|
32
|
+
for (let i = 0; i < name.length; i++) {
|
|
33
|
+
const c = name[i]!;
|
|
34
|
+
if (quote) {
|
|
35
|
+
if (c === quote && name[i + 1] === quote) {
|
|
36
|
+
cur += c;
|
|
37
|
+
i++;
|
|
38
|
+
} else if (c === quote) quote = undefined;
|
|
39
|
+
else cur += c;
|
|
40
|
+
} else if (c === '"' || c === "`") quote = c;
|
|
41
|
+
else if (c === ".") {
|
|
42
|
+
pieces.push(cur);
|
|
43
|
+
cur = "";
|
|
44
|
+
} else cur += c;
|
|
45
|
+
}
|
|
46
|
+
pieces.push(cur);
|
|
47
|
+
return pieces;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether the declared name (an empty piece is an interpolation, which lint
|
|
52
|
+
* cannot read) can be the type argument's. Compared from the last piece back.
|
|
53
|
+
* A template that does not qualify its name takes the argument's schema, since
|
|
54
|
+
* the build qualifies it with the schema or database the project sets; one
|
|
55
|
+
* that does qualify it must agree with the argument.
|
|
56
|
+
*/
|
|
57
|
+
export function nameAgrees(declared: readonly string[], argument: string): boolean {
|
|
58
|
+
const given = splitQualified(argument);
|
|
59
|
+
if (declared.length === 0) return true;
|
|
60
|
+
if (declared.length > given.length) return false;
|
|
61
|
+
for (let i = 1; i <= declared.length; i++) {
|
|
62
|
+
const d = declared[declared.length - i]!;
|
|
63
|
+
if (d === "") continue;
|
|
64
|
+
if (d !== given[given.length - i]) return false;
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The text of a declared name for a message; an interpolation is `${...}`. */
|
|
70
|
+
export const declaredText = (declared: readonly string[]): string => declared.map((p) => (p === "" ? "${...}" : p)).join(".");
|
|
71
|
+
|
|
72
|
+
/** A qualified name with quoting taken off, so `"shop"."orders"` and `shop.orders` compare equal. */
|
|
73
|
+
export const normalizedName = (name: string): string => splitQualified(name).join("\0");
|
|
74
|
+
|
|
75
|
+
/** The names (normalized, see {@link normalizedName}) a `declare module "<module>"` block in a generated file augments `SqlSchema` with. */
|
|
76
|
+
export function namesInGenerated(content: string, module: string): Set<string> | undefined {
|
|
77
|
+
const source = ts.createSourceFile(SQL_TYPES_FILE, content, ts.ScriptTarget.Latest, true);
|
|
78
|
+
let names: Set<string> | undefined;
|
|
79
|
+
for (const stmt of source.statements) {
|
|
80
|
+
if (!ts.isModuleDeclaration(stmt) || !ts.isStringLiteral(stmt.name) || stmt.name.text !== module) continue;
|
|
81
|
+
if (!stmt.body || !ts.isModuleBlock(stmt.body)) continue;
|
|
82
|
+
for (const inner of stmt.body.statements) {
|
|
83
|
+
if (!ts.isInterfaceDeclaration(inner) || inner.name.text !== "SqlSchema") continue;
|
|
84
|
+
names ??= new Set();
|
|
85
|
+
for (const member of inner.members) {
|
|
86
|
+
const n = member.name;
|
|
87
|
+
if (n && (ts.isStringLiteral(n) || ts.isIdentifier(n))) names.add(normalizedName(n.text));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return names;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The generated file nearest above a source file, up to the directory that
|
|
96
|
+
* holds the project's config. `chant sql types` writes it in `sourceDir` (or
|
|
97
|
+
* the project root), so the walk finds it from any file below.
|
|
98
|
+
*/
|
|
99
|
+
export function findGeneratedFile(filePath: string): string | undefined {
|
|
100
|
+
let dir = dirname(resolve(filePath));
|
|
101
|
+
for (;;) {
|
|
102
|
+
const candidate = join(dir, SQL_TYPES_FILE);
|
|
103
|
+
if (existsSync(candidate)) return candidate;
|
|
104
|
+
if (existsSync(join(dir, "chant.config.ts")) || existsSync(join(dir, "chant.config.json"))) return undefined;
|
|
105
|
+
const up = dirname(dir);
|
|
106
|
+
if (up === dir) return undefined;
|
|
107
|
+
dir = up;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The names the generated file lists for a dialect's module, or undefined when there is no file or it has no such block. */
|
|
112
|
+
export function generatedNamesFor(filePath: string, module: string): Set<string> | undefined {
|
|
113
|
+
const found = findGeneratedFile(filePath);
|
|
114
|
+
if (!found) return undefined;
|
|
115
|
+
try {
|
|
116
|
+
return namesInGenerated(readFileSync(found, "utf8"), module);
|
|
117
|
+
} catch {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -15,7 +15,7 @@ concurrency:
|
|
|
15
15
|
jobs:
|
|
16
16
|
schema-watch:
|
|
17
17
|
runs-on: docker
|
|
18
|
-
container: node:22
|
|
18
|
+
container: node:22
|
|
19
19
|
env:
|
|
20
20
|
CLICKHOUSE_READER_USER: chant_reader
|
|
21
21
|
CLICKHOUSE_READER_PASSWORD: '\${{ secrets.CLICKHOUSE_READER_PASSWORD }}'
|
|
@@ -47,7 +47,7 @@ permissions:
|
|
|
47
47
|
jobs:
|
|
48
48
|
schema-watch:
|
|
49
49
|
runs-on: ubuntu-latest
|
|
50
|
-
container: node:22
|
|
50
|
+
container: node:22
|
|
51
51
|
env:
|
|
52
52
|
CLICKHOUSE_READER_USER: chant_reader
|
|
53
53
|
CLICKHOUSE_READER_PASSWORD: '\${{ secrets.CLICKHOUSE_READER_PASSWORD }}'
|
|
@@ -78,7 +78,7 @@ stages:
|
|
|
78
78
|
|
|
79
79
|
schema-watch:
|
|
80
80
|
stage: ops
|
|
81
|
-
image: node:22
|
|
81
|
+
image: node:22
|
|
82
82
|
resource_group: schema-watch
|
|
83
83
|
variables:
|
|
84
84
|
CLICKHOUSE_READER_USER: chant_reader
|
package/src/postgres/entities.ts
CHANGED
|
@@ -62,11 +62,13 @@ import { feed, lineage, spanText as text, splice as spliceWith, templateSyntaxEr
|
|
|
62
62
|
import { SqlObject, isColumnRefOf, isSqlObjectOf, makeSqlEntity } from "../core/entity";
|
|
63
63
|
import type { LineageEdge } from "../core/references";
|
|
64
64
|
import { SqlLiteral, SqlTemplateError, describeValue as describe, stripUnset, templateParts } from "../core/template";
|
|
65
|
+
import { setTypedSlots, type ColumnsIn, type Named, type RowIn, type SlotsIn, type TypedSlot, type Unnamed } from "../core/typed";
|
|
65
66
|
|
|
66
67
|
export { SQL_LEXICON } from "../core/entity";
|
|
67
68
|
export type { LineageEdge } from "../core/references";
|
|
68
69
|
export { SqlLiteral, SqlTemplateError } from "../core/template";
|
|
69
70
|
export { quoteIdent } from "./keywords";
|
|
71
|
+
export type { AnyColumn, Column, ColumnTraits, KeyColumn, Named, NotNullColumn, UncheckedColumn, Unnamed } from "../core/typed";
|
|
70
72
|
|
|
71
73
|
export { POSTGRES_ENTITY_TYPES, type PostgresEntityType } from "./entity-types";
|
|
72
74
|
import { POSTGRES_ENTITY_TYPES, type PostgresEntityType } from "./entity-types";
|
|
@@ -431,18 +433,31 @@ export interface PostgresSchema extends PostgresObject {
|
|
|
431
433
|
readonly props: SchemaProps;
|
|
432
434
|
}
|
|
433
435
|
|
|
434
|
-
/**
|
|
435
|
-
|
|
436
|
+
/**
|
|
437
|
+
* Every Postgres table and view the project declares, keyed by its qualified
|
|
438
|
+
* name (`shop.orders`). Empty here; the `sql-types.d.ts` that `chant sql
|
|
439
|
+
* types` writes augments it, and a tag given the name as its type argument
|
|
440
|
+
* reads its columns from there.
|
|
441
|
+
*/
|
|
442
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- augmented by the generated declarations
|
|
443
|
+
export interface SqlSchema {}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* A table or a view: something with columns that SQL can reference. `N` is
|
|
447
|
+
* its qualified name when its tag was given one (`` table<"shop.orders">`...` ``):
|
|
448
|
+
* its columns are then the generated declarations' entry for that name.
|
|
449
|
+
*/
|
|
450
|
+
export interface PostgresRelation<N extends string = string> extends PostgresObject, Named<N> {
|
|
436
451
|
/** One reference per column, by its folded name: `${users.columns.email}`. */
|
|
437
|
-
readonly columns:
|
|
452
|
+
readonly columns: ColumnsIn<SqlSchema, N>;
|
|
438
453
|
}
|
|
439
454
|
|
|
440
|
-
export interface PostgresTable extends PostgresRelation {
|
|
455
|
+
export interface PostgresTable<N extends string = string> extends PostgresRelation<N> {
|
|
441
456
|
readonly entityType: "Postgres::Table";
|
|
442
457
|
readonly props: TableProps;
|
|
443
458
|
}
|
|
444
459
|
|
|
445
|
-
export interface PostgresView extends PostgresRelation {
|
|
460
|
+
export interface PostgresView<N extends string = string> extends PostgresRelation<N> {
|
|
446
461
|
readonly entityType: "Postgres::View" | "Postgres::MaterializedView";
|
|
447
462
|
readonly props: ViewProps;
|
|
448
463
|
}
|
|
@@ -502,6 +517,13 @@ export interface PostgresGrant extends PostgresObject {
|
|
|
502
517
|
readonly props: GrantProps | DefaultPrivilegesProps;
|
|
503
518
|
}
|
|
504
519
|
|
|
520
|
+
/**
|
|
521
|
+
* A declared table's or view's row, for application code: `RowOf<typeof orders>`
|
|
522
|
+
* for a declaration given its name, or `RowOf<"shop.users">` by name, which
|
|
523
|
+
* also covers an object only a `.sql` file declares.
|
|
524
|
+
*/
|
|
525
|
+
export type RowOf<T extends keyof SqlSchema | Named<keyof SqlSchema & string>> = RowIn<SqlSchema, T>;
|
|
526
|
+
|
|
505
527
|
export function isPostgresObject(value: unknown): value is PostgresObject {
|
|
506
528
|
return isSqlObjectOf(value, "Postgres::");
|
|
507
529
|
}
|
|
@@ -816,6 +838,23 @@ function foreignKeyDef(ctx: Ctx, c: ConstraintNode, columns: string[]): ForeignK
|
|
|
816
838
|
);
|
|
817
839
|
}
|
|
818
840
|
|
|
841
|
+
/**
|
|
842
|
+
* The `${}` in a foreign key's referenced column list that each name one
|
|
843
|
+
* column, paired with the referencing column at the same position (#3751).
|
|
844
|
+
* `chant sql types` types each as a column comparing like that one.
|
|
845
|
+
*/
|
|
846
|
+
function referenceSlots(ctx: Ctx, c: ConstraintNode, columns: readonly string[]): TypedSlot[] {
|
|
847
|
+
const out: TypedSlot[] = [];
|
|
848
|
+
c.references!.columns.forEach((x, n) => {
|
|
849
|
+
const column = columns[n];
|
|
850
|
+
if (x.name || column === undefined) return;
|
|
851
|
+
const sig = ctx.tokens.slice(x.span.from, x.span.to).filter((t) => !isTrivia(t));
|
|
852
|
+
const t = sig[0];
|
|
853
|
+
if (sig.length === 1 && t?.kind === "ref" && isColumnRef(ctx.values[t.part])) out.push({ index: t.part, kind: "references", column });
|
|
854
|
+
});
|
|
855
|
+
return out;
|
|
856
|
+
}
|
|
857
|
+
|
|
819
858
|
function exclusionDef(ctx: Ctx, c: ConstraintNode): ExclusionDef {
|
|
820
859
|
return stripUnset({
|
|
821
860
|
name: c.name,
|
|
@@ -1142,6 +1181,7 @@ function build(tag: PostgresTag, strings: TemplateStringsArray | readonly string
|
|
|
1142
1181
|
const ddl = untokenize(tokens, (i) => render(values[i], i)).trim().replace(/;\s*$/, "");
|
|
1143
1182
|
const source = { strings: rawParts };
|
|
1144
1183
|
const dependsOn = [...new Set([...values, ...(joined?.refs ?? [])].filter((v) => isPostgresObject(v) || isColumnRef(v)))];
|
|
1184
|
+
const slots: TypedSlot[] = [];
|
|
1145
1185
|
const make = <T extends PostgresObject>(type: PostgresEntityType, q: { schema?: string; name: string }, props: object, columnNames?: readonly string[]): T => {
|
|
1146
1186
|
const entity = makeSqlEntity(PostgresObject.prototype as PostgresObject, type, sqlNameOf(q), props, columnNames, dependsOn) as T;
|
|
1147
1187
|
const fed = ctx.fed.map((paths) => [...paths].sort());
|
|
@@ -1152,6 +1192,7 @@ function build(tag: PostgresTag, strings: TemplateStringsArray | readonly string
|
|
|
1152
1192
|
joined.original.forEach((orig, i) => (byOriginal[orig] = fed[i]!));
|
|
1153
1193
|
setInterpolationFields(entity, byOriginal);
|
|
1154
1194
|
}
|
|
1195
|
+
setTypedSlots(entity, slots);
|
|
1155
1196
|
return entity;
|
|
1156
1197
|
};
|
|
1157
1198
|
|
|
@@ -1264,7 +1305,10 @@ function build(tag: PostgresTag, strings: TemplateStringsArray | readonly string
|
|
|
1264
1305
|
const foreignKeys: ForeignKeyDef[] = [];
|
|
1265
1306
|
const exclusions: ExclusionDef[] = [];
|
|
1266
1307
|
const file = (c: ConstraintNode, cols: string[]) => {
|
|
1267
|
-
if (c.kind === "FOREIGN KEY")
|
|
1308
|
+
if (c.kind === "FOREIGN KEY") {
|
|
1309
|
+
foreignKeys.push(foreignKeyDef(ctx, c, cols));
|
|
1310
|
+
slots.push(...referenceSlots(ctx, c, cols));
|
|
1311
|
+
}
|
|
1268
1312
|
else if (c.kind === "PRIMARY KEY") {
|
|
1269
1313
|
if (primaryKey) throw new SqlTemplateError(tag, `${sqlNameOf(q)} declares more than one primary key`, 0, 0);
|
|
1270
1314
|
primaryKey = keyDef(ctx, c, cols, "primaryKey");
|
|
@@ -1554,9 +1598,14 @@ export function schema(strings: TemplateStringsArray, ...values: unknown[]): Pos
|
|
|
1554
1598
|
return build("schema", strings, values) as PostgresSchema;
|
|
1555
1599
|
}
|
|
1556
1600
|
|
|
1557
|
-
/**
|
|
1558
|
-
|
|
1559
|
-
|
|
1601
|
+
/**
|
|
1602
|
+
* `` table`CREATE TABLE ...` ``: one Postgres table with its columns and
|
|
1603
|
+
* constraints, parsed at build time. Given its qualified name as the type
|
|
1604
|
+
* argument (`` table<"shop.orders">`...` ``), its columns are the ones
|
|
1605
|
+
* `chant sql types` declared for that name.
|
|
1606
|
+
*/
|
|
1607
|
+
export function table<const N extends string = Unnamed>(strings: TemplateStringsArray, ...values: SlotsIn<SqlSchema, N>): PostgresTable<N> {
|
|
1608
|
+
return build("table", strings, values) as PostgresTable<N>;
|
|
1560
1609
|
}
|
|
1561
1610
|
|
|
1562
1611
|
/** `` index`CREATE [UNIQUE] INDEX [CONCURRENTLY] name ON ...` ``: one named index. */
|
|
@@ -1564,9 +1613,13 @@ export function index(strings: TemplateStringsArray, ...values: unknown[]): Post
|
|
|
1564
1613
|
return build("index", strings, values) as PostgresIndex;
|
|
1565
1614
|
}
|
|
1566
1615
|
|
|
1567
|
-
/**
|
|
1568
|
-
|
|
1569
|
-
|
|
1616
|
+
/**
|
|
1617
|
+
* `` view`CREATE [MATERIALIZED] VIEW ...` ``: one view or materialized view,
|
|
1618
|
+
* with what it reads and its column lineage. Takes its qualified name as the
|
|
1619
|
+
* type argument, as {@link table} does.
|
|
1620
|
+
*/
|
|
1621
|
+
export function view<const N extends string = Unnamed>(strings: TemplateStringsArray, ...values: SlotsIn<SqlSchema, N>): PostgresView<N> {
|
|
1622
|
+
return build("view", strings, values) as PostgresView<N>;
|
|
1570
1623
|
}
|
|
1571
1624
|
|
|
1572
1625
|
/** `` sequence`CREATE SEQUENCE ...` ``: one sequence. Reference it as `nextval(${seq})`. */
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* references cross schemas freely.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import { relationKey } from "../../core/typed";
|
|
24
25
|
import type { GeneratedFile, TypeScriptGenerator } from "@intentius/chant/import/generator";
|
|
25
26
|
import type { TemplateIR } from "@intentius/chant/import/parser";
|
|
26
27
|
import { isTrivia, tokenizeText, type Token } from "../tokens";
|
|
@@ -233,6 +234,11 @@ const indent = (body: string) =>
|
|
|
233
234
|
.map((l, i) => (i === 0 || l.length === 0 ? l : ` ${l}`))
|
|
234
235
|
.join("\n");
|
|
235
236
|
|
|
237
|
+
const RELATIONS = new Set<string>([POSTGRES_ENTITY_TYPES.table, POSTGRES_ENTITY_TYPES.view, POSTGRES_ENTITY_TYPES.materializedView]);
|
|
238
|
+
|
|
239
|
+
/** A table's or view's qualified name as its tag's type argument (#3751), so its columns are typed once `chant sql types` has run. */
|
|
240
|
+
const typeArgument = (it: Item): string => (RELATIONS.has(it.type) ? `<${JSON.stringify(relationKey(it))}>` : "");
|
|
241
|
+
|
|
236
242
|
export class PostgresGenerator implements TypeScriptGenerator {
|
|
237
243
|
readonly ownsLayout = true;
|
|
238
244
|
|
|
@@ -256,7 +262,7 @@ export class PostgresGenerator implements TypeScriptGenerator {
|
|
|
256
262
|
const lines: string[] = [];
|
|
257
263
|
if (this.header) lines.push(...this.header.split("\n").map((l) => `// ${l}`), "");
|
|
258
264
|
lines.push(`import { ${tags.join(", ")} } from "@intentius/chant-lexicon-sql/postgres";`, "");
|
|
259
|
-
for (const it of order) lines.push(`export const ${it.exportName} = ${TAG[it.type]}\`\n ${indent(bodyText(bodies.get(it.exportName)!))}\`;`, "");
|
|
265
|
+
for (const it of order) lines.push(`export const ${it.exportName} = ${TAG[it.type]}${typeArgument(it)}\`\n ${indent(bodyText(bodies.get(it.exportName)!))}\`;`, "");
|
|
260
266
|
return [{ path: "schema.ts", content: `${lines.join("\n").trimEnd()}\n` }];
|
|
261
267
|
}
|
|
262
268
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a Postgres column's value is in TypeScript, for `RowOf` (#3751): the
|
|
3
|
+
* value node-postgres (`pg`, the driver the lexicon itself uses) hands back
|
|
4
|
+
* with its default type parsers. `bigint` and `numeric` are strings, since a
|
|
5
|
+
* JavaScript number cannot hold every value; `integer`, `smallint`, `real`
|
|
6
|
+
* and `double precision` are numbers; dates and timestamps are `Date`s;
|
|
7
|
+
* `json` and `jsonb` are parsed, so their shape is the application's
|
|
8
|
+
* (`unknown`); everything the driver does not parse is its text form.
|
|
9
|
+
*
|
|
10
|
+
* The table names every built-in type of every supported major's catalog
|
|
11
|
+
* (`COLUMN_TYPES`), and a test fails on one it does not, so a type a new
|
|
12
|
+
* catalog adds is a decision rather than a silent `unknown`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { TypeSqlName } from "../generated/postgres-types";
|
|
16
|
+
|
|
17
|
+
const NUMBER = "number";
|
|
18
|
+
const STRING = "string";
|
|
19
|
+
const DATE = "Date";
|
|
20
|
+
|
|
21
|
+
/** The TypeScript type of each built-in type's value, by its `format_type()` name. */
|
|
22
|
+
export const POSTGRES_ROW_TYPES: Readonly<Record<TypeSqlName, string>> = {
|
|
23
|
+
// Numbers the driver parses; the 64-bit and arbitrary-precision ones stay text.
|
|
24
|
+
smallint: NUMBER,
|
|
25
|
+
integer: NUMBER,
|
|
26
|
+
real: NUMBER,
|
|
27
|
+
"double precision": NUMBER,
|
|
28
|
+
oid: NUMBER,
|
|
29
|
+
bigint: STRING,
|
|
30
|
+
numeric: STRING,
|
|
31
|
+
money: STRING,
|
|
32
|
+
regclass: STRING,
|
|
33
|
+
regcollation: STRING,
|
|
34
|
+
regconfig: STRING,
|
|
35
|
+
regdictionary: STRING,
|
|
36
|
+
regnamespace: STRING,
|
|
37
|
+
regoper: STRING,
|
|
38
|
+
regoperator: STRING,
|
|
39
|
+
regproc: STRING,
|
|
40
|
+
regprocedure: STRING,
|
|
41
|
+
regrole: STRING,
|
|
42
|
+
regtype: STRING,
|
|
43
|
+
boolean: "boolean",
|
|
44
|
+
// Strings.
|
|
45
|
+
text: STRING,
|
|
46
|
+
"character varying": STRING,
|
|
47
|
+
character: STRING,
|
|
48
|
+
name: STRING,
|
|
49
|
+
'"char"': STRING,
|
|
50
|
+
uuid: STRING,
|
|
51
|
+
xml: STRING,
|
|
52
|
+
bit: STRING,
|
|
53
|
+
"bit varying": STRING,
|
|
54
|
+
cidr: STRING,
|
|
55
|
+
inet: STRING,
|
|
56
|
+
macaddr: STRING,
|
|
57
|
+
macaddr8: STRING,
|
|
58
|
+
tsvector: STRING,
|
|
59
|
+
tsquery: STRING,
|
|
60
|
+
jsonpath: STRING,
|
|
61
|
+
pg_lsn: STRING,
|
|
62
|
+
pg_snapshot: STRING,
|
|
63
|
+
txid_snapshot: STRING,
|
|
64
|
+
refcursor: STRING,
|
|
65
|
+
aclitem: STRING,
|
|
66
|
+
cid: STRING,
|
|
67
|
+
tid: STRING,
|
|
68
|
+
xid: STRING,
|
|
69
|
+
xid8: STRING,
|
|
70
|
+
int2vector: STRING,
|
|
71
|
+
oidvector: STRING,
|
|
72
|
+
gtsvector: STRING,
|
|
73
|
+
pg_brin_bloom_summary: STRING,
|
|
74
|
+
pg_brin_minmax_multi_summary: STRING,
|
|
75
|
+
pg_dependencies: STRING,
|
|
76
|
+
pg_mcv_list: STRING,
|
|
77
|
+
pg_ndistinct: STRING,
|
|
78
|
+
pg_node_tree: STRING,
|
|
79
|
+
// Dates and times: the driver parses dates and timestamps, not times of day.
|
|
80
|
+
date: DATE,
|
|
81
|
+
"timestamp without time zone": DATE,
|
|
82
|
+
"timestamp with time zone": DATE,
|
|
83
|
+
"time without time zone": STRING,
|
|
84
|
+
"time with time zone": STRING,
|
|
85
|
+
interval: "{ years?: number; months?: number; days?: number; hours?: number; minutes?: number; seconds?: number; milliseconds?: number }",
|
|
86
|
+
// Ranges and multiranges arrive as their text form.
|
|
87
|
+
int4range: STRING,
|
|
88
|
+
int8range: STRING,
|
|
89
|
+
numrange: STRING,
|
|
90
|
+
tsrange: STRING,
|
|
91
|
+
tstzrange: STRING,
|
|
92
|
+
daterange: STRING,
|
|
93
|
+
int4multirange: STRING,
|
|
94
|
+
int8multirange: STRING,
|
|
95
|
+
nummultirange: STRING,
|
|
96
|
+
tsmultirange: STRING,
|
|
97
|
+
tstzmultirange: STRING,
|
|
98
|
+
datemultirange: STRING,
|
|
99
|
+
// Geometry: the driver parses a point and a circle.
|
|
100
|
+
point: "{ x: number; y: number }",
|
|
101
|
+
circle: "{ x: number; y: number; radius: number }",
|
|
102
|
+
line: STRING,
|
|
103
|
+
lseg: STRING,
|
|
104
|
+
box: STRING,
|
|
105
|
+
path: STRING,
|
|
106
|
+
polygon: STRING,
|
|
107
|
+
// Parsed, so the application's own shape.
|
|
108
|
+
json: "unknown",
|
|
109
|
+
jsonb: "unknown",
|
|
110
|
+
bytea: "Uint8Array",
|
|
111
|
+
};
|
package/src/postgres.ts
CHANGED
|
@@ -69,6 +69,16 @@ export {
|
|
|
69
69
|
type DefaultPrivilegesProps,
|
|
70
70
|
type PrivilegeDef,
|
|
71
71
|
type LineageEdge,
|
|
72
|
+
type SqlSchema,
|
|
73
|
+
type RowOf,
|
|
74
|
+
type Column,
|
|
75
|
+
type ColumnTraits,
|
|
76
|
+
type AnyColumn,
|
|
77
|
+
type UncheckedColumn,
|
|
78
|
+
type KeyColumn,
|
|
79
|
+
type NotNullColumn,
|
|
80
|
+
type Named,
|
|
81
|
+
type Unnamed,
|
|
72
82
|
} from "./postgres/entities";
|
|
73
83
|
|
|
74
84
|
export {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant sql types [--output <path>] [--check]` (#3751): build the project the way
|
|
3
|
+
* `chant build` does, folded, and write the declarations
|
|
4
|
+
* {@link renderSqlTypes} renders from its entities, so a tag given its
|
|
5
|
+
* qualified name as the type argument has typed columns.
|
|
6
|
+
*
|
|
7
|
+
* The file goes to `<sourceDir>/sql-types.d.ts` (the project root when
|
|
8
|
+
* `sourceDir` is unset), where any tsconfig that includes the schema includes
|
|
9
|
+
* it too. `--output` puts it elsewhere, as a workspace `generated` entry's
|
|
10
|
+
* generator does. The command refuses to write beside a `sql-types.ts`: tsc
|
|
11
|
+
* resolves the `.ts` first and would drop the declarations. `chant build`
|
|
12
|
+
* never writes the file. `--check` renders the same text and compares it with
|
|
13
|
+
* the file instead of writing it: exit 1, naming the file, when they differ.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
18
|
+
import type { CommandGroupContext } from "@intentius/chant/cli/command-group";
|
|
19
|
+
import { renderSqlTypes, SQL_TYPES_FILE } from "./sql-types";
|
|
20
|
+
|
|
21
|
+
const USAGE = "usage: chant sql types [--output <path>] [--check]";
|
|
22
|
+
|
|
23
|
+
/** The `--output` value, or an error message. */
|
|
24
|
+
function parseArgs(args: readonly string[]): { output?: string; check: boolean } | { error: string } {
|
|
25
|
+
let output: string | undefined;
|
|
26
|
+
let check = false;
|
|
27
|
+
for (let i = 0; i < args.length; i++) {
|
|
28
|
+
const a = args[i]!;
|
|
29
|
+
if (a === "--output") {
|
|
30
|
+
output = args[++i];
|
|
31
|
+
if (!output) return { error: "--output needs a path" };
|
|
32
|
+
} else if (a === "--check") check = true;
|
|
33
|
+
else if (a.startsWith("--output=")) output = a.slice("--output=".length);
|
|
34
|
+
else return { error: `unknown argument ${a}` };
|
|
35
|
+
}
|
|
36
|
+
return output === undefined ? { check } : { output, check };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The file tsc would read instead of `path`: `sql-types.ts` beside `sql-types.d.ts`. */
|
|
40
|
+
export function shadowingSource(path: string): string | undefined {
|
|
41
|
+
if (!path.endsWith(".d.ts")) return undefined;
|
|
42
|
+
const source = `${path.slice(0, -".d.ts".length)}.ts`;
|
|
43
|
+
return existsSync(source) ? source : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runTypes(ctx: CommandGroupContext): Promise<number> {
|
|
47
|
+
const args = parseArgs(ctx.rawArgs);
|
|
48
|
+
if ("error" in args) {
|
|
49
|
+
console.error(`${args.error}\n${USAGE}`);
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
const cwd = process.cwd();
|
|
53
|
+
const { loadChantConfigUpward } = await import("@intentius/chant/config");
|
|
54
|
+
const { config, configPath } = await loadChantConfigUpward(cwd);
|
|
55
|
+
const projectDir = configPath ? dirname(configPath) : cwd;
|
|
56
|
+
const sourceDir = resolve(projectDir, config.sourceDir ?? ".");
|
|
57
|
+
const output = args.output !== undefined ? resolve(cwd, args.output) : join(sourceDir, SQL_TYPES_FILE);
|
|
58
|
+
const shadow = shadowingSource(output);
|
|
59
|
+
if (shadow) {
|
|
60
|
+
console.error(`chant sql types: ${relative(cwd, shadow) || basename(shadow)} sits beside ${relative(cwd, output) || basename(output)}, and tsc reads it instead of the declarations; rename one`);
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const { build } = await import("@intentius/chant/build");
|
|
65
|
+
const { resolveBuildModes, resolveProjectBuildOptions } = await import("@intentius/chant/cli/build-options");
|
|
66
|
+
const { loadPlugins, resolveProjectLexicons } = await import("@intentius/chant/cli/plugins");
|
|
67
|
+
const { sqlSerializer } = await import("./serializer");
|
|
68
|
+
const plugins = await loadPlugins(await resolveProjectLexicons(projectDir, { lexicon: "sql" }));
|
|
69
|
+
// The fold build's entities: what `chant build` declares, `.sql` files and composite members included.
|
|
70
|
+
const options = resolveProjectBuildOptions({ config, configDir: projectDir, plugins, modes: resolveBuildModes(config, { fold: true }) });
|
|
71
|
+
const result = await build(sourceDir, [sqlSerializer], undefined, options);
|
|
72
|
+
if (result.errors.length > 0) {
|
|
73
|
+
for (const e of result.errors) console.error(e instanceof Error ? e.message : String(e));
|
|
74
|
+
console.error("chant sql types: the build failed, so nothing was written");
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
const text = renderSqlTypes(result.entities, { projectDir, config: config as unknown as Record<string, unknown> });
|
|
78
|
+
const shown = relative(cwd, output);
|
|
79
|
+
const named = shown && !shown.startsWith("..") ? shown : output;
|
|
80
|
+
if (args.check) {
|
|
81
|
+
// Compares only; the file is never written.
|
|
82
|
+
const onDisk = existsSync(output) ? readFileSync(output, "utf8") : undefined;
|
|
83
|
+
if (onDisk === text) {
|
|
84
|
+
console.log(`${named} is current`);
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
console.error(
|
|
88
|
+
onDisk === undefined
|
|
89
|
+
? `chant sql types: ${named} does not exist; run \`chant sql types\` to write it`
|
|
90
|
+
: `chant sql types: ${named} differs from what the build declares; run \`chant sql types\` to rewrite it`,
|
|
91
|
+
);
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
mkdirSync(dirname(output), { recursive: true });
|
|
95
|
+
writeFileSync(output, text);
|
|
96
|
+
const count = (text.match(/^ {4}"/gm) ?? []).length;
|
|
97
|
+
console.log(`wrote ${named}: ${count} relation${count === 1 ? "" : "s"}`);
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping `sql-types.d.ts` current (#3751): `chant sql types --check` and the
|
|
3
|
+
* SQL102 post-synth check, over a scratch project. The project imports the
|
|
4
|
+
* workspace's sql lexicon through a `node_modules` link, as an installed
|
|
5
|
+
* project would.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
12
|
+
import { lintCommand } from "@intentius/chant/cli/commands/lint";
|
|
13
|
+
import { runTypes } from "./sql-types-command";
|
|
14
|
+
import { sqlTypesPath } from "./sql-types";
|
|
15
|
+
import { sql102 } from "./lint/post-synth/sql102";
|
|
16
|
+
|
|
17
|
+
const repoNodeModules = resolve(fileURLToPath(new URL(".", import.meta.url)), "../../../node_modules");
|
|
18
|
+
|
|
19
|
+
const schema = (amount: string) =>
|
|
20
|
+
'import { schema, table } from "@intentius/chant-lexicon-sql/postgres";\n' +
|
|
21
|
+
"export const shop = schema`CREATE SCHEMA shop`;\n" +
|
|
22
|
+
'export const orders = table<"shop.orders">`CREATE TABLE ${shop}.orders (id bigint PRIMARY KEY, ' +
|
|
23
|
+
`amount ${amount})\`;\n`;
|
|
24
|
+
|
|
25
|
+
describe("sql-types.d.ts staleness", () => {
|
|
26
|
+
let root: string;
|
|
27
|
+
let cwd: string;
|
|
28
|
+
let errors: string[];
|
|
29
|
+
|
|
30
|
+
const write = (amount: string) => writeFileSync(join(root, "src", "schema.ts"), schema(amount));
|
|
31
|
+
const types = async (...args: string[]) => {
|
|
32
|
+
process.chdir(root);
|
|
33
|
+
return runTypes({ verb: "types", rawArgs: args });
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
cwd = process.cwd();
|
|
38
|
+
root = realpathSync(mkdtempSync(join(tmpdir(), "chant-3751-stale-")));
|
|
39
|
+
mkdirSync(join(root, "src"), { recursive: true });
|
|
40
|
+
symlinkSync(repoNodeModules, join(root, "node_modules"), "dir");
|
|
41
|
+
writeFileSync(join(root, "chant.config.json"), JSON.stringify({ lexicons: ["sql"], sourceDir: "src", sql: { dialect: "postgres" } }));
|
|
42
|
+
write("numeric(12,2)");
|
|
43
|
+
errors = [];
|
|
44
|
+
vi.spyOn(console, "error").mockImplementation((m: unknown) => void errors.push(String(m)));
|
|
45
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
46
|
+
process.env.NO_COLOR = "1";
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
process.chdir(cwd);
|
|
51
|
+
vi.restoreAllMocks();
|
|
52
|
+
delete process.env.NO_COLOR;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("--check exits 0 on a current file, and 1 naming it once the schema changes", async () => {
|
|
56
|
+
expect(await types()).toBe(0);
|
|
57
|
+
const file = join(root, "src", "sql-types.d.ts");
|
|
58
|
+
const written = readFileSync(file, "utf8");
|
|
59
|
+
expect(await types("--check")).toBe(0);
|
|
60
|
+
|
|
61
|
+
write("bigint");
|
|
62
|
+
expect(await types("--check")).toBe(1);
|
|
63
|
+
expect(errors.join("\n")).toContain("src/sql-types.d.ts differs");
|
|
64
|
+
expect(readFileSync(file, "utf8")).toBe(written);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("--check on a missing file exits 1 and writes nothing", async () => {
|
|
68
|
+
expect(await types("--check")).toBe(1);
|
|
69
|
+
expect(errors.join("\n")).toContain("src/sql-types.d.ts does not exist");
|
|
70
|
+
expect(() => readFileSync(join(root, "src", "sql-types.d.ts"))).toThrow();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("--check honours --output", async () => {
|
|
74
|
+
expect(await types("--output", "out/types.d.ts")).toBe(0);
|
|
75
|
+
expect(await types("--check", "--output", "out/types.d.ts")).toBe(0);
|
|
76
|
+
write("bigint");
|
|
77
|
+
expect(await types("--check", "--output", "out/types.d.ts")).toBe(1);
|
|
78
|
+
expect(errors.join("\n")).toContain("out/types.d.ts");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("lint reports SQL102 for a stale file, and nothing for a current or missing one", async () => {
|
|
82
|
+
const ids = async () => (await lintCommand({ path: root, format: "stylish" })).diagnostics.map((d) => d.ruleId);
|
|
83
|
+
expect(await ids()).not.toContain("SQL102");
|
|
84
|
+
|
|
85
|
+
expect(await types()).toBe(0);
|
|
86
|
+
expect(await ids()).not.toContain("SQL102");
|
|
87
|
+
|
|
88
|
+
write("bigint");
|
|
89
|
+
const result = await lintCommand({ path: root, format: "stylish" });
|
|
90
|
+
const diag = result.diagnostics.find((d) => d.ruleId === "SQL102");
|
|
91
|
+
expect(diag, result.output).toBeDefined();
|
|
92
|
+
expect(diag!.severity).toBe("error");
|
|
93
|
+
expect(diag!.message).toContain("sql-types.d.ts");
|
|
94
|
+
|
|
95
|
+
expect(await types()).toBe(0);
|
|
96
|
+
expect(await ids()).not.toContain("SQL102");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("SQL102 on a hand-built context", () => {
|
|
101
|
+
test("is silent without a project directory", () => {
|
|
102
|
+
const ctx = { outputs: new Map(), entities: new Map(), buildResult: { outputs: new Map(), entities: new Map(), warnings: [], errors: [], sourceFileCount: 0 } };
|
|
103
|
+
expect(sql102.check(ctx)).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("the default path is the source directory, else the project root", () => {
|
|
107
|
+
expect(sqlTypesPath("/p", { sourceDir: "src" })).toBe("/p/src/sql-types.d.ts");
|
|
108
|
+
expect(sqlTypesPath("/p", {})).toBe("/p/sql-types.d.ts");
|
|
109
|
+
});
|
|
110
|
+
});
|