@typesql/pg 0.0.2 → 0.0.4
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/CHANGELOG.md +27 -0
- package/README.md +23 -2
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +5 -0
- package/dist/bin.js.map +1 -0
- package/dist/client.d.ts +5 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +10 -3
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -6
- package/dist/index.js.map +1 -1
- package/dist/introspect/cli.d.ts +24 -0
- package/dist/introspect/cli.d.ts.map +1 -0
- package/dist/introspect/cli.js +121 -0
- package/dist/introspect/cli.js.map +1 -0
- package/dist/introspect/emit.d.ts +30 -0
- package/dist/introspect/emit.d.ts.map +1 -0
- package/dist/introspect/emit.js +144 -0
- package/dist/introspect/emit.js.map +1 -0
- package/dist/introspect/index.d.ts +5 -0
- package/dist/introspect/index.d.ts.map +1 -0
- package/dist/introspect/index.js +5 -0
- package/dist/introspect/index.js.map +1 -0
- package/dist/introspect/introspect.d.ts +67 -0
- package/dist/introspect/introspect.d.ts.map +1 -0
- package/dist/introspect/introspect.js +109 -0
- package/dist/introspect/introspect.js.map +1 -0
- package/dist/introspect/nullability.d.ts +60 -0
- package/dist/introspect/nullability.d.ts.map +1 -0
- package/dist/introspect/nullability.js +354 -0
- package/dist/introspect/nullability.js.map +1 -0
- package/dist/type-map.d.ts +42 -14
- package/dist/type-map.d.ts.map +1 -1
- package/package.json +10 -3
- package/src/bin.ts +5 -0
- package/src/client.ts +10 -4
- package/src/index.ts +7 -4
- package/src/introspect/cli.ts +138 -0
- package/src/introspect/emit.ts +168 -0
- package/src/introspect/index.ts +12 -0
- package/src/introspect/introspect.ts +221 -0
- package/src/introspect/nullability.ts +356 -0
- package/src/type-map.ts +42 -14
package/dist/type-map.d.ts
CHANGED
|
@@ -1,21 +1,14 @@
|
|
|
1
|
-
import type { DefaultTypeMap } from "@typesql/core";
|
|
1
|
+
import type { DefaultParamTypeMap, DefaultTypeMap } from "@typesql/core";
|
|
2
2
|
/**
|
|
3
3
|
* The object node-postgres returns for an `interval` column (package `postgres-interval` 1.x,
|
|
4
4
|
* wired up by `pg-types`). Fields are only present when non-zero; `toPostgres()` renders the
|
|
5
5
|
* value back to Postgres syntax and `toISO()`/`toISOString()` produce an ISO 8601 duration.
|
|
6
|
+
*
|
|
7
|
+
* Written inline in `PgTypeMap` (this alias only reads it back), so row types stay plain
|
|
8
|
+
* object types: declaration emit in a package without a direct `@typesql/pg` dependency does
|
|
9
|
+
* not need to name `PgInterval` (TS2883).
|
|
6
10
|
*/
|
|
7
|
-
export
|
|
8
|
-
years?: number;
|
|
9
|
-
months?: number;
|
|
10
|
-
days?: number;
|
|
11
|
-
hours?: number;
|
|
12
|
-
minutes?: number;
|
|
13
|
-
seconds?: number;
|
|
14
|
-
milliseconds?: number;
|
|
15
|
-
toPostgres(): string;
|
|
16
|
-
toISO(): string;
|
|
17
|
-
toISOString(): string;
|
|
18
|
-
}
|
|
11
|
+
export type PgInterval = PgTypeMap["interval"];
|
|
19
12
|
/**
|
|
20
13
|
* Canonical Postgres type name → TS type as `pg` 8.x returns it with its default type
|
|
21
14
|
* parsers (`pg-types` 2.x, text protocol). Verified against `pg-types/lib/textParsers.js`:
|
|
@@ -50,13 +43,48 @@ export interface PgInterval {
|
|
|
50
43
|
* line[], lseg[], box[], path[], polygon[], int4range[], int8range[], tsrange[],
|
|
51
44
|
* tstzrange[], daterange[], macaddr8[], regclass[]/regtype[]/regproc[], record[].
|
|
52
45
|
* Register your own parser (`pg.types.setTypeParser`) or cast in SQL if you depend on these.
|
|
46
|
+
* The two that `PgCatalog` columns use are declared here as the text pg returns: `aclitem[]`
|
|
47
|
+
* (`relacl`, `datacl`, ...) and `"char"[]` (`proargmodes`).
|
|
53
48
|
*/
|
|
54
49
|
export interface PgTypeMap extends Omit<DefaultTypeMap, "bytea" | "interval" | "record" | "void"> {
|
|
50
|
+
/** What parameters accept on top of the row types. */
|
|
51
|
+
$params: PgParamTypeMap;
|
|
55
52
|
bytea: Buffer;
|
|
56
|
-
interval:
|
|
53
|
+
interval: {
|
|
54
|
+
years?: number;
|
|
55
|
+
months?: number;
|
|
56
|
+
days?: number;
|
|
57
|
+
hours?: number;
|
|
58
|
+
minutes?: number;
|
|
59
|
+
seconds?: number;
|
|
60
|
+
milliseconds?: number;
|
|
61
|
+
toPostgres(): string;
|
|
62
|
+
toISO(): string;
|
|
63
|
+
toISOString(): string;
|
|
64
|
+
};
|
|
57
65
|
/** Anonymous `ROW(...)` results have no parser: raw text such as `"(1,foo)"`. */
|
|
58
66
|
record: string;
|
|
59
67
|
/** `void`-returning functions (`pg_sleep`) yield an empty string. */
|
|
60
68
|
void: string;
|
|
69
|
+
/** No array parser: the raw `{...}` text, e.g. `"{postgres=arwdDxtm/postgres}"`. */
|
|
70
|
+
"aclitem[]": string;
|
|
71
|
+
/** No array parser for `"char"[]`: the raw `{...}` text, e.g. `"{i,o}"`. */
|
|
72
|
+
"char[]": string;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* TS types `pg` parameters accept on top of the row type (`PgTypeMap["$params"]`). Verified
|
|
76
|
+
* against `pg/lib/utils.js` (`prepareValue`, 8.x): a `Date` is sent as a timestamp with the
|
|
77
|
+
* local offset, `Buffer`/typed arrays as bytea, a `bigint` or number with `toString()`, and
|
|
78
|
+
* objects with `toPostgres()` (such as `PgInterval`) through it; Postgres parses the text.
|
|
79
|
+
*
|
|
80
|
+
* int8, numeric, money number | bigint | string
|
|
81
|
+
* date, timestamp, timestamptz Date | string
|
|
82
|
+
* interval string, besides the `PgInterval` row type
|
|
83
|
+
* bytea Uint8Array, besides `Buffer`
|
|
84
|
+
*
|
|
85
|
+
* Plain objects are `JSON.stringify`d and arrays become Postgres array literals, so neither is
|
|
86
|
+
* listed for other types.
|
|
87
|
+
*/
|
|
88
|
+
export interface PgParamTypeMap extends DefaultParamTypeMap {
|
|
61
89
|
}
|
|
62
90
|
//# sourceMappingURL=type-map.d.ts.map
|
package/dist/type-map.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"type-map.d.ts","sourceRoot":"","sources":["../src/type-map.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"type-map.d.ts","sourceRoot":"","sources":["../src/type-map.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEzE;;;;;;;;GAQG;AACH,MAAM,MAAM,UAAU,GAAG,SAAS,CAAC,UAAU,CAAC,CAAC;AAE/C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,MAAM,WAAW,SAAU,SAAQ,IAAI,CAAC,cAAc,EAAE,OAAO,GAAG,UAAU,GAAG,QAAQ,GAAG,MAAM,CAAC;IAC/F,sDAAsD;IACtD,OAAO,EAAE,cAAc,CAAC;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE;QACR,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,UAAU,IAAI,MAAM,CAAC;QACrB,KAAK,IAAI,MAAM,CAAC;QAChB,WAAW,IAAI,MAAM,CAAC;KACvB,CAAC;IACF,iFAAiF;IACjF,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,IAAI,EAAE,MAAM,CAAC;IACb,oFAAoF;IACpF,WAAW,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,cAAe,SAAQ,mBAAmB;CAAG"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@typesql/pg",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "node-postgres (pg) adapter for typesql.",
|
|
3
|
+
"version": "0.0.4",
|
|
4
|
+
"description": "node-postgres (pg) adapter for typesql, with `typesql introspect` to generate a schema from a live database.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -16,8 +16,15 @@
|
|
|
16
16
|
".": {
|
|
17
17
|
"types": "./dist/index.d.ts",
|
|
18
18
|
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./introspect": {
|
|
21
|
+
"types": "./dist/introspect/index.d.ts",
|
|
22
|
+
"default": "./dist/introspect/index.js"
|
|
19
23
|
}
|
|
20
24
|
},
|
|
25
|
+
"bin": {
|
|
26
|
+
"typesql": "./dist/bin.js"
|
|
27
|
+
},
|
|
21
28
|
"files": [
|
|
22
29
|
"dist",
|
|
23
30
|
"src",
|
|
@@ -28,7 +35,7 @@
|
|
|
28
35
|
"access": "public"
|
|
29
36
|
},
|
|
30
37
|
"dependencies": {
|
|
31
|
-
"@typesql/core": "0.0.
|
|
38
|
+
"@typesql/core": "0.0.4"
|
|
32
39
|
},
|
|
33
40
|
"peerDependencies": {
|
|
34
41
|
"pg": ">=8"
|
package/src/bin.ts
ADDED
package/src/client.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { mapRows, type Executor, type Query, type RowType } from "@typesql/core";
|
|
2
2
|
|
|
3
3
|
/** The subset of a `pg` query result the helpers rely on. */
|
|
4
4
|
export interface PgResult {
|
|
@@ -44,15 +44,21 @@ function run(client: PgQueryable, q: AnyQuery): Promise<PgResult> {
|
|
|
44
44
|
return client.query(q.text, Array.from(q.values));
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* An `Executor` for core's `q.execute()`: runs queries on `client` and resolves to
|
|
49
|
+
* `{ rows, rowCount }` (pg's `rowCount`, so `q.executeCount()` works).
|
|
50
|
+
*/
|
|
48
51
|
export function executor(client: PgQueryable): Executor {
|
|
49
|
-
return async (text, values) =>
|
|
52
|
+
return async (text, values) => {
|
|
53
|
+
const { rows, rowCount } = await client.query(text, Array.from(values));
|
|
54
|
+
return { rows, rowCount };
|
|
55
|
+
};
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
/** Run a query and return all rows, typed from the query. */
|
|
53
59
|
export async function query<Q extends AnyQuery>(client: PgQueryable, q: Q): Promise<RowType<Q>[]> {
|
|
54
60
|
const result = await run(client, q);
|
|
55
|
-
return result.rows
|
|
61
|
+
return mapRows(q, result.rows);
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
/** Exactly one row, otherwise throws `RowCountError`. */
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { createSql as createCoreSql, type Sql } from "@typesql/core";
|
|
1
|
+
import { createSql as createCoreSql, type RowKeys, type Sql, type SqlOptions } from "@typesql/core";
|
|
2
2
|
import { executor, type PgQueryable } from "./client.js";
|
|
3
3
|
import type { PgTypeMap } from "./type-map.js";
|
|
4
4
|
|
|
5
5
|
export { NoResultError, type Executor } from "@typesql/core";
|
|
6
|
-
export type { PgInterval, PgTypeMap } from "./type-map.js";
|
|
6
|
+
export type { PgInterval, PgParamTypeMap, PgTypeMap } from "./type-map.js";
|
|
7
7
|
export {
|
|
8
8
|
query,
|
|
9
9
|
one,
|
|
@@ -24,7 +24,10 @@ export {
|
|
|
24
24
|
/**
|
|
25
25
|
* `createSql<DB>()` from core, pre-bound to `PgTypeMap` (`count(*)` is `string`, `int8` is `string`, dates are `Date`).
|
|
26
26
|
* With a `client` (a `Pool`, `Client` or `PoolClient`), the queries' `execute*` methods run on it.
|
|
27
|
+
* `{ rowKeys: "camelCase" }` camelCases the rows' keys, in the types and at runtime.
|
|
27
28
|
*/
|
|
28
|
-
export function createSql<DB>(client
|
|
29
|
-
|
|
29
|
+
export function createSql<DB>(client: PgQueryable | undefined, options: { readonly rowKeys: "camelCase" }): Sql<DB, PgTypeMap, "camelCase">;
|
|
30
|
+
export function createSql<DB>(client?: PgQueryable, options?: { readonly rowKeys?: "preserve" }): Sql<DB, PgTypeMap>;
|
|
31
|
+
export function createSql<DB>(client?: PgQueryable, options?: SqlOptions): Sql<DB, PgTypeMap, RowKeys> {
|
|
32
|
+
return createCoreSql<DB, PgTypeMap>(client && executor(client), options as { readonly rowKeys: "camelCase" });
|
|
30
33
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import type { PgQueryable } from "../client.js";
|
|
6
|
+
import { generateSource, type GenerateOptions } from "./emit.js";
|
|
7
|
+
import { introspect, type IntrospectOptions } from "./introspect.js";
|
|
8
|
+
|
|
9
|
+
/** What a `--config` module exports as its default: CLI settings plus every generator option. */
|
|
10
|
+
export interface IntrospectConfig extends GenerateOptions, IntrospectOptions {
|
|
11
|
+
/** Connection string. Default: `--url`, then `$DATABASE_URL`. */
|
|
12
|
+
readonly url?: string;
|
|
13
|
+
/** Output file, relative to the working directory. Default: stdout. */
|
|
14
|
+
readonly out?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const USAGE = `Usage: typesql introspect [options]
|
|
18
|
+
|
|
19
|
+
Reads a live Postgres database and writes its typesql schema (tables, views, enums).
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--url <url> connection string (default: $DATABASE_URL)
|
|
23
|
+
--out <file> write the schema to <file> instead of stdout
|
|
24
|
+
--schema <name> schema to read; repeat or comma-separate (default: all but system schemas)
|
|
25
|
+
--exclude <pattern> relation to leave out (name or schema.name, * wildcard); repeat or comma-separate
|
|
26
|
+
--type-name <name> name of the schema interface (default: DB)
|
|
27
|
+
--import-from <mod> module the column helpers come from (default: @typesql/core)
|
|
28
|
+
--config <file> module whose default export holds these options and more
|
|
29
|
+
(columnTypes for JSON columns, imports, column(...) hook)
|
|
30
|
+
--no-comments omit JSDoc from comments, domains and generated columns
|
|
31
|
+
-h, --help show this help
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
interface Io {
|
|
35
|
+
readonly stdout: (s: string) => void;
|
|
36
|
+
readonly stderr: (s: string) => void;
|
|
37
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
38
|
+
readonly cwd: string;
|
|
39
|
+
/** Opens a client for a connection string; default: a `pg.Client`. */
|
|
40
|
+
readonly connect?: (url: string) => Promise<PgQueryable & { end(): Promise<void> }>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const defaultIo = (): Io => ({
|
|
44
|
+
stdout: (s) => process.stdout.write(s),
|
|
45
|
+
stderr: (s) => process.stderr.write(s),
|
|
46
|
+
env: process.env,
|
|
47
|
+
cwd: process.cwd(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
async function pgConnect(url: string): Promise<PgQueryable & { end(): Promise<void> }> {
|
|
51
|
+
let Client: typeof import("pg").Client;
|
|
52
|
+
try {
|
|
53
|
+
({ Client } = (await import("pg")).default);
|
|
54
|
+
} catch {
|
|
55
|
+
throw new Error('typesql introspect needs the "pg" package: npm install pg');
|
|
56
|
+
}
|
|
57
|
+
const client = new Client({ connectionString: url });
|
|
58
|
+
await client.connect();
|
|
59
|
+
return client;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Runs the `typesql` CLI with `argv` (without `node` and the script). Returns the exit code. */
|
|
63
|
+
export async function main(argv: readonly string[], io: Io = defaultIo()): Promise<number> {
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
parsed = parseArgs({
|
|
67
|
+
args: [...argv],
|
|
68
|
+
allowPositionals: true,
|
|
69
|
+
options: {
|
|
70
|
+
url: { type: "string" },
|
|
71
|
+
out: { type: "string" },
|
|
72
|
+
schema: { type: "string", multiple: true },
|
|
73
|
+
exclude: { type: "string", multiple: true },
|
|
74
|
+
"type-name": { type: "string" },
|
|
75
|
+
"import-from": { type: "string" },
|
|
76
|
+
config: { type: "string" },
|
|
77
|
+
"no-comments": { type: "boolean" },
|
|
78
|
+
help: { type: "boolean", short: "h" },
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
} catch (err) {
|
|
82
|
+
io.stderr(`${(err as Error).message}\n\n${USAGE}`);
|
|
83
|
+
return 2;
|
|
84
|
+
}
|
|
85
|
+
const { values, positionals } = parsed;
|
|
86
|
+
if (values.help || positionals.length === 0) {
|
|
87
|
+
(values.help ? io.stdout : io.stderr)(USAGE);
|
|
88
|
+
return values.help ? 0 : 2;
|
|
89
|
+
}
|
|
90
|
+
if (positionals[0] !== "introspect" || positionals.length > 1) {
|
|
91
|
+
io.stderr(`unknown command: ${positionals.join(" ")}\n\n${USAGE}`);
|
|
92
|
+
return 2;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
let config: IntrospectConfig = {};
|
|
97
|
+
if (values.config) {
|
|
98
|
+
const mod = (await import(pathToFileURL(resolve(io.cwd, values.config)).href)) as { default?: IntrospectConfig };
|
|
99
|
+
config = mod.default ?? {};
|
|
100
|
+
}
|
|
101
|
+
const url = values.url ?? config.url ?? io.env["DATABASE_URL"];
|
|
102
|
+
if (!url) {
|
|
103
|
+
io.stderr("typesql introspect: pass --url or set DATABASE_URL\n");
|
|
104
|
+
return 2;
|
|
105
|
+
}
|
|
106
|
+
const list = (v: string[] | undefined) => v?.flatMap((s) => s.split(",").map((x) => x.trim()).filter(Boolean));
|
|
107
|
+
const schemas = list(values.schema) ?? config.schemas;
|
|
108
|
+
const exclude = list(values.exclude) ?? config.exclude;
|
|
109
|
+
const options: IntrospectConfig = {
|
|
110
|
+
...config,
|
|
111
|
+
...(schemas !== undefined && { schemas }),
|
|
112
|
+
...(exclude !== undefined && { exclude }),
|
|
113
|
+
...(values["type-name"] !== undefined && { typeName: values["type-name"] }),
|
|
114
|
+
...(values["import-from"] !== undefined && { importFrom: values["import-from"] }),
|
|
115
|
+
...(values["no-comments"] && { comments: false }),
|
|
116
|
+
};
|
|
117
|
+
const client = await (io.connect ?? pgConnect)(url);
|
|
118
|
+
let source: string;
|
|
119
|
+
try {
|
|
120
|
+
source = generateSource(await introspect(client, options), options);
|
|
121
|
+
} finally {
|
|
122
|
+
await client.end();
|
|
123
|
+
}
|
|
124
|
+
const out = values.out ?? config.out;
|
|
125
|
+
if (out === undefined) {
|
|
126
|
+
io.stdout(source);
|
|
127
|
+
} else {
|
|
128
|
+
const file = resolve(io.cwd, out);
|
|
129
|
+
await mkdir(dirname(file), { recursive: true });
|
|
130
|
+
await writeFile(file, source);
|
|
131
|
+
io.stderr(`typesql introspect: wrote ${out}\n`);
|
|
132
|
+
}
|
|
133
|
+
return 0;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
io.stderr(`typesql introspect: ${(err as Error).message}\n`);
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { IntrospectedColumn, IntrospectedDatabase, IntrospectedRelation } from "./introspect.js";
|
|
2
|
+
|
|
3
|
+
export interface GenerateOptions {
|
|
4
|
+
/** Name of the exported schema interface. Default `"DB"`. */
|
|
5
|
+
readonly typeName?: string;
|
|
6
|
+
/** Module the column helpers are imported from. Default `"@typesql/core"`. */
|
|
7
|
+
readonly importFrom?: string;
|
|
8
|
+
/** Extra lines after the helper import, e.g. `import type { HistoryItem } from "./json.js";`. */
|
|
9
|
+
readonly imports?: readonly string[];
|
|
10
|
+
/**
|
|
11
|
+
* TypeScript types of `json` / `jsonb` columns: a map keyed `"table.column"` (public) or
|
|
12
|
+
* `"schema.table.column"`, or a function returning the TS type source for a column (or
|
|
13
|
+
* `undefined`). Such a column is declared as `Json<T>`: still jsonb to the SQL, read as `T`,
|
|
14
|
+
* written with `sql.json(value)`. Other columns are an error (use `column` for those).
|
|
15
|
+
*/
|
|
16
|
+
readonly columnTypes?:
|
|
17
|
+
| Readonly<Record<string, string>>
|
|
18
|
+
| ((column: IntrospectedColumn, relation: IntrospectedRelation) => string | undefined);
|
|
19
|
+
/**
|
|
20
|
+
* Full control over one column: return the TypeScript source of its descriptor
|
|
21
|
+
* (`Col<"jsonb">`, a helper of your own, ...) or `undefined` to keep the default.
|
|
22
|
+
*/
|
|
23
|
+
readonly column?: (column: IntrospectedColumn, relation: IntrospectedRelation) => string | undefined;
|
|
24
|
+
/** Emit table and column comments and notes as JSDoc. Default `true`. */
|
|
25
|
+
readonly comments?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
29
|
+
const key = (name: string): string => (IDENT.test(name) ? name : JSON.stringify(name));
|
|
30
|
+
const str = (s: string): string => JSON.stringify(s);
|
|
31
|
+
|
|
32
|
+
function doc(lines: readonly string[], indent: string): string {
|
|
33
|
+
const text = lines.flatMap((l) => l.split("\n")).map((l) => l.replace(/\*\//g, "*\\/").trimEnd());
|
|
34
|
+
if (text.length === 0) return "";
|
|
35
|
+
if (text.length === 1) return `${indent}/** ${text[0]} */\n`;
|
|
36
|
+
return `${indent}/**\n${text.map((l) => `${indent} *${l ? ` ${l}` : ""}`).join("\n")}\n${indent} */\n`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Helper = "Col" | "Nullable" | "Generated" | "NullableGenerated" | "Json";
|
|
40
|
+
const helperFor = (nullable: boolean, hasDefault: boolean): Helper =>
|
|
41
|
+
nullable ? (hasDefault ? "NullableGenerated" : "Nullable") : hasDefault ? "Generated" : "Col";
|
|
42
|
+
|
|
43
|
+
function columnNotes(c: IntrospectedColumn): string[] {
|
|
44
|
+
const notes: string[] = [];
|
|
45
|
+
if (c.comment) notes.push(c.comment);
|
|
46
|
+
if (c.domain) notes.push(`Domain \`${c.domain}\`.`);
|
|
47
|
+
else if (c.declaredType === "citext") notes.push("`citext`: typed as `text`.");
|
|
48
|
+
if (c.generated !== null) notes.push(`\`GENERATED ALWAYS AS (${c.generated}) STORED\`: cannot be written.`);
|
|
49
|
+
if (c.identity === "always") notes.push("`GENERATED ALWAYS AS IDENTITY`: written only with `OVERRIDING SYSTEM VALUE`.");
|
|
50
|
+
return notes;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* TypeScript source declaring the typesql schema of an introspected database: `public`
|
|
55
|
+
* relations at the top level, other schemas nested (`DB["auth"]["sessions"]`), views and
|
|
56
|
+
* materialized views as `View<…>`, enums under `$types`.
|
|
57
|
+
*/
|
|
58
|
+
export function generateSource(db: IntrospectedDatabase, options: GenerateOptions = {}): string {
|
|
59
|
+
const typeName = options.typeName ?? "DB";
|
|
60
|
+
const comments = options.comments ?? true;
|
|
61
|
+
const columnTypes = options.columnTypes ?? {};
|
|
62
|
+
const used = new Set<string>();
|
|
63
|
+
const usedKeys = new Set<string>();
|
|
64
|
+
|
|
65
|
+
/** The TS type `columnTypes` gives a column, if any. */
|
|
66
|
+
const customType = (rel: IntrospectedRelation, col: IntrospectedColumn): string | undefined => {
|
|
67
|
+
const qualified = `${rel.schema}.${rel.name}.${col.name}`;
|
|
68
|
+
const short = `${rel.name}.${col.name}`;
|
|
69
|
+
let ts: string | undefined;
|
|
70
|
+
if (typeof columnTypes === "function") ts = columnTypes(col, rel);
|
|
71
|
+
else {
|
|
72
|
+
const k = qualified in columnTypes ? qualified : rel.schema === "public" && short in columnTypes ? short : undefined;
|
|
73
|
+
if (k !== undefined) {
|
|
74
|
+
usedKeys.add(k);
|
|
75
|
+
ts = columnTypes[k];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (ts !== undefined && col.type !== "json" && col.type !== "jsonb") {
|
|
79
|
+
throw new Error(`columnTypes: ${JSON.stringify(rel.schema === "public" ? short : qualified)} has type ${col.type}; columnTypes only types json / jsonb columns (use the column hook for others)`);
|
|
80
|
+
}
|
|
81
|
+
return ts;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** `Json<T>`, `Json<T, { nullable: true }>`, `Json<T, {}, "json">`, … */
|
|
85
|
+
const jsonDescriptor = (col: IntrospectedColumn, ts: string): string => {
|
|
86
|
+
const flags = [...(col.nullable ? ["nullable: true"] : []), ...(col.hasDefault ? ["hasDefault: true"] : [])];
|
|
87
|
+
const flagSource = flags.length > 0 ? `{ ${flags.join("; ")} }` : "{}";
|
|
88
|
+
used.add("Json");
|
|
89
|
+
if (col.type === "json") return `Json<${ts}, ${flagSource}, "json">`;
|
|
90
|
+
return flags.length > 0 ? `Json<${ts}, ${flagSource}>` : `Json<${ts}>`;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const relation = (rel: IntrospectedRelation, indent: string): string => {
|
|
94
|
+
const isView = rel.kind === "view" || rel.kind === "materialized view";
|
|
95
|
+
const cols = rel.columns.map((c) => {
|
|
96
|
+
let descriptor = options.column?.(c, rel);
|
|
97
|
+
if (descriptor === undefined) {
|
|
98
|
+
const custom = customType(rel, c);
|
|
99
|
+
if (custom !== undefined) descriptor = jsonDescriptor(c, custom);
|
|
100
|
+
else {
|
|
101
|
+
const helper = helperFor(c.nullable, c.hasDefault);
|
|
102
|
+
used.add(helper);
|
|
103
|
+
descriptor = `${helper}<${str(c.type)}>`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return `${comments ? doc(columnNotes(c), `${indent} `) : ""}${indent} ${key(c.name)}: ${descriptor};`;
|
|
107
|
+
});
|
|
108
|
+
const body = `{\n${cols.join("\n")}\n${indent}}`;
|
|
109
|
+
if (!isView) return body;
|
|
110
|
+
used.add("View");
|
|
111
|
+
return `View<${body}>`;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const relDoc = (rel: IntrospectedRelation, indent: string): string => {
|
|
115
|
+
if (!comments) return "";
|
|
116
|
+
const kind = rel.kind === "table" ? [] : [`${rel.kind[0]!.toUpperCase()}${rel.kind.slice(1)}.`];
|
|
117
|
+
return doc([...(rel.comment ? [rel.comment] : []), ...kind], indent);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const topLevel = new Set(db.relations.filter((r) => r.schema === "public").map((r) => r.name));
|
|
121
|
+
const lines: string[] = [];
|
|
122
|
+
const namespaces = new Map<string, IntrospectedRelation[]>();
|
|
123
|
+
for (const rel of db.relations) {
|
|
124
|
+
if (rel.schema === "public") lines.push(`${relDoc(rel, " ")} ${key(rel.name)}: ${relation(rel, " ")};`);
|
|
125
|
+
else namespaces.set(rel.schema, [...(namespaces.get(rel.schema) ?? []), rel]);
|
|
126
|
+
}
|
|
127
|
+
for (const [schema, rels] of namespaces) {
|
|
128
|
+
if (topLevel.has(schema)) {
|
|
129
|
+
throw new Error(`schema "${schema}" has the same name as the table "public.${schema}": typesql keeps public relations and schemas in one object`);
|
|
130
|
+
}
|
|
131
|
+
const body = rels.map((r) => `${relDoc(r, " ")} ${key(r.name)}: ${relation(r, " ")};`);
|
|
132
|
+
lines.push(` ${key(schema)}: {\n${body.join("\n")}\n };`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (db.enums.length > 0) {
|
|
136
|
+
// typesql's enum registry is flat (`Col<"mood">`, schema qualifiers are dropped), so enums
|
|
137
|
+
// of the same name in different schemas share an entry.
|
|
138
|
+
const byName = new Map<string, { values: Set<string>; schemas: string[] }>();
|
|
139
|
+
for (const e of db.enums) {
|
|
140
|
+
const entry = byName.get(e.name) ?? { values: new Set<string>(), schemas: [] };
|
|
141
|
+
for (const v of e.values) entry.values.add(v);
|
|
142
|
+
entry.schemas.push(e.schema);
|
|
143
|
+
byName.set(e.name, entry);
|
|
144
|
+
}
|
|
145
|
+
const entries = [...byName].map(([name, { values, schemas }]) => {
|
|
146
|
+
const notes = comments && schemas.length > 1 ? doc([`Enums of the same name in ${schemas.map((s) => `\`${s}\``).join(", ")}.`], " ") : "";
|
|
147
|
+
return `${notes} ${key(name)}: ${[...values].map(str).join(" | ") || "never"};`;
|
|
148
|
+
});
|
|
149
|
+
lines.push(` $types: {\n${entries.join("\n")}\n };`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const helpers = ["Col", "Generated", "Json", "Nullable", "NullableGenerated", "View"].filter((h) => used.has(h));
|
|
153
|
+
const out = [
|
|
154
|
+
`// Generated by \`typesql introspect\` (@typesql/pg) from PostgreSQL ${db.version.split(/[.\s]/)[0]}. Do not edit.`,
|
|
155
|
+
...(helpers.length > 0 ? [`import type { ${helpers.join(", ")} } from ${str(options.importFrom ?? "@typesql/core")};`] : []),
|
|
156
|
+
...(options.imports ?? []),
|
|
157
|
+
"",
|
|
158
|
+
`export interface ${key(typeName)} {`,
|
|
159
|
+
...lines,
|
|
160
|
+
"}",
|
|
161
|
+
"",
|
|
162
|
+
];
|
|
163
|
+
const unknownKeys = typeof columnTypes === "function" ? [] : Object.keys(columnTypes).filter((k) => !usedKeys.has(k));
|
|
164
|
+
if (unknownKeys.length > 0 && options.column === undefined) {
|
|
165
|
+
throw new Error(`columnTypes: no column ${unknownKeys.map((k) => JSON.stringify(k)).join(", ")} (keys are "table.column" for public tables, else "schema.table.column")`);
|
|
166
|
+
}
|
|
167
|
+
return out.join("\n");
|
|
168
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export {
|
|
2
|
+
introspect,
|
|
3
|
+
type IntrospectOptions,
|
|
4
|
+
type IntrospectedDatabase,
|
|
5
|
+
type IntrospectedRelation,
|
|
6
|
+
type IntrospectedColumn,
|
|
7
|
+
type IntrospectedEnum,
|
|
8
|
+
type RelationKind,
|
|
9
|
+
} from "./introspect.js";
|
|
10
|
+
export { generateSource, type GenerateOptions } from "./emit.js";
|
|
11
|
+
export { ViewNullability, parseNodeTree, type NullabilityOptions, type QueryFn } from "./nullability.js";
|
|
12
|
+
export { main, type IntrospectConfig } from "./cli.js";
|