true-pg 0.0.1

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.
@@ -0,0 +1,315 @@
1
+ import type { CanonicalType, Schema, TableColumn } from "pg-extract";
2
+ import { allowed_kind_names, createGenerator, Nodes, type SchemaGenerator } from "../types.ts";
3
+ import { builtins } from "./builtins.ts";
4
+
5
+ const isIdentifierInvalid = (str: string) => {
6
+ const invalid = str.match(/[^a-zA-Z0-9_]/);
7
+ return invalid !== null;
8
+ };
9
+
10
+ const toPascalCase = (str: string) =>
11
+ str
12
+ .replace(/^[^a-zA-Z]+/, "") // remove leading non-alphabetic characters
13
+ .replace(/[^a-zA-Z0-9_]+/g, "") // remove non-alphanumeric/underscore characters
14
+ .replace(" ", "_") // replace spaces with underscores
15
+ .replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) // capitalize letters after underscores
16
+ .replace(/^([a-z])/, (_, letter) => letter.toUpperCase()); // capitalize first letter
17
+
18
+ export const Kysely = createGenerator(opts => {
19
+ const defaultSchema = opts?.defaultSchema ?? "public";
20
+
21
+ const ky = (imports: Nodes.ImportList, name: string) =>
22
+ imports.add(
23
+ new Nodes.ExternalImport({
24
+ name,
25
+ module: "kysely",
26
+ typeOnly: true,
27
+ star: false,
28
+ }),
29
+ );
30
+
31
+ const add = (imports: Nodes.ImportList, type: CanonicalType) => {
32
+ if (type.schema === "pg_catalog") return;
33
+ imports.add(
34
+ new Nodes.InternalImport({
35
+ name: generator.formatType(type),
36
+ canonical_type: type,
37
+ typeOnly: true,
38
+ star: false,
39
+ }),
40
+ );
41
+ };
42
+
43
+ const column = (
44
+ /** "this" */
45
+ generator: SchemaGenerator,
46
+ /** @out Append used types to this array */
47
+ imports: Nodes.ImportList,
48
+ /** Information about the column */
49
+ col: TableColumn,
50
+ ) => {
51
+ let base = generator.formatType(col.type);
52
+ if (col.type.dimensions > 0) base += "[]".repeat(col.type.dimensions);
53
+ if (col.isNullable) base += " | null";
54
+
55
+ let qualified = base;
56
+ if (col.generated === "ALWAYS") {
57
+ qualified = `GeneratedAlways<${qualified}>`;
58
+ ky(imports, "GeneratedAlways");
59
+ } else if (col.generated === "BY DEFAULT") {
60
+ qualified = `Generated<${qualified}>`;
61
+ ky(imports, "Generated");
62
+ } else if (col.defaultValue) {
63
+ qualified = `Generated<${qualified}>`;
64
+ ky(imports, "Generated");
65
+ }
66
+
67
+ let out = col.comment ? `/** ${col.comment} */\n\t` : "";
68
+ out += col.name;
69
+ // TODO: update imports for non-primitive types
70
+ out += `: ${qualified}`;
71
+ add(imports, col.type);
72
+
73
+ return `\t${out};\n`;
74
+ };
75
+
76
+ const composite_attribute = (
77
+ generator: SchemaGenerator,
78
+ imports: Nodes.ImportList,
79
+ attr: CanonicalType.CompositeAttribute,
80
+ ) => {
81
+ let out = attr.name;
82
+
83
+ if (attr.isNullable) out += "?";
84
+ out += `: ${generator.formatType(attr.type)}`;
85
+ add(imports, attr.type);
86
+ if (attr.type.dimensions > 0) out += "[]".repeat(attr.type.dimensions);
87
+ if (attr.isNullable) out += " | null";
88
+
89
+ return out;
90
+ };
91
+
92
+ const generator: SchemaGenerator = {
93
+ formatSchema(name) {
94
+ return toPascalCase(name) + "Schema";
95
+ },
96
+
97
+ formatSchemaType(type) {
98
+ return toPascalCase(type.name);
99
+ },
100
+
101
+ formatType(type) {
102
+ if (type.schema === "pg_catalog") {
103
+ const name = type.canonical_name;
104
+ const format = builtins[name];
105
+ if (format) return format;
106
+ opts?.warnings?.push(
107
+ `Unknown builtin type: ${name}! Pass customBuiltinMap to map this type. Defaulting to "unknown".`,
108
+ );
109
+ return "unknown";
110
+ }
111
+ return toPascalCase(type.name);
112
+ },
113
+
114
+ table(imports, table) {
115
+ let out = "";
116
+
117
+ if (table.comment) out += `/** ${table.comment} */\n`;
118
+ out += `export interface ${this.formatSchemaType(table)} {\n`;
119
+ for (const col of table.columns) out += column(this, imports, col);
120
+ out += "}";
121
+
122
+ return out;
123
+ },
124
+
125
+ enum(imports, en) {
126
+ let out = "";
127
+ if (en.comment) out += `/** ${en.comment} */\n`;
128
+ out += `export type ${this.formatSchemaType(en)} = ${en.values.map(v => `"${v}"`).join(" | ")};`;
129
+ return out;
130
+ },
131
+
132
+ composite(imports, type) {
133
+ let out = "";
134
+
135
+ if (type.comment) out += `/** ${type.comment} */\n`;
136
+ out += `export interface ${this.formatSchemaType(type)} {\n`;
137
+
138
+ const props = type.canonical.attributes.map(c => composite_attribute(this, imports, c)).map(t => `\t${t};`);
139
+ out += props.join("\n");
140
+ out += "\n}";
141
+
142
+ return out;
143
+ },
144
+
145
+ function(imports, type) {
146
+ let out = "";
147
+
148
+ out += "/**\n";
149
+ if (type.comment) out += ` * ${type.comment}\n`;
150
+ out += ` * @volatility ${type.volatility}\n`;
151
+ out += ` * @parallelSafety ${type.parallelSafety}\n`;
152
+ out += ` * @isStrict ${type.isStrict}\n`;
153
+ out += " */\n";
154
+ out += `export interface ${this.formatSchemaType(type)} {\n\t`;
155
+
156
+ // Get the input parameters (those that appear in function signature)
157
+ const inputParams = type.parameters.filter(
158
+ p => p.mode === "IN" || p.mode === "INOUT" || p.mode === "VARIADIC",
159
+ );
160
+
161
+ if (inputParams.length === 0) {
162
+ out += "(): ";
163
+ } else if (inputParams.length === 1) {
164
+ out += "(";
165
+ out += inputParams[0]!.name;
166
+ out += ": ";
167
+ out += this.formatType(inputParams[0]!.type);
168
+ add(imports, inputParams[0]!.type);
169
+ if (inputParams[0]!.type.dimensions > 0) out += "[]".repeat(inputParams[0]!.type.dimensions);
170
+ out += "): ";
171
+ } else if (inputParams.length > 0) {
172
+ out += "(\n";
173
+
174
+ for (const param of inputParams) {
175
+ // Handle variadic parameters differently if needed
176
+ const isVariadic = param.mode === "VARIADIC";
177
+ const paramName = isVariadic ? `...${param.name}` : param.name;
178
+
179
+ out += `\t\t${paramName}`;
180
+ if (param.hasDefault && !isVariadic) out += "?";
181
+ out += `: ${this.formatType(param.type)}`;
182
+ add(imports, param.type);
183
+ if (param.type.dimensions > 0) out += "[]".repeat(param.type.dimensions);
184
+ if (!isVariadic) out += ",";
185
+ out += "\n";
186
+ }
187
+
188
+ out += "\t): ";
189
+ }
190
+
191
+ if (type.returnType.kind === "table") {
192
+ out += "{\n";
193
+ for (const col of type.returnType.columns) {
194
+ out += `\t\t${col.name}: `;
195
+ out += this.formatType(col.type);
196
+ add(imports, col.type);
197
+ if (col.type.dimensions > 0) out += "[]".repeat(col.type.dimensions);
198
+ out += `;\n`;
199
+ }
200
+ out += "\t}";
201
+ } else {
202
+ out += this.formatType(type.returnType.type);
203
+ add(imports, type.returnType.type);
204
+ if (type.returnType.type.dimensions > 0) out += "[]".repeat(type.returnType.type.dimensions);
205
+ }
206
+
207
+ // Add additional array brackets if it returns a set
208
+ if (type.returnType.isSet) {
209
+ out += "[]";
210
+ }
211
+
212
+ out += ";\n}";
213
+
214
+ return out;
215
+ },
216
+
217
+ schemaKindIndex(schema, kind, main_generator) {
218
+ const generator = main_generator ?? this;
219
+ const imports = schema[kind];
220
+ if (imports.length === 0) return "";
221
+
222
+ return imports
223
+ .map(each => {
224
+ const name = this.formatSchemaType(each);
225
+ const file = generator.formatSchemaType(each);
226
+ return `export type { ${name} } from "./${file}.ts";`;
227
+ })
228
+ .join("\n");
229
+ },
230
+
231
+ schemaIndex(schema) {
232
+ let out = allowed_kind_names.map(kind => `import type * as ${kind} from "./${kind}/index.ts";`).join("\n");
233
+
234
+ out += "\n\n";
235
+ out += `export interface ${this.formatSchema(schema.name)} {\n`;
236
+
237
+ for (const kind of allowed_kind_names) {
238
+ const items = schema[kind];
239
+ if (items.length === 0) continue;
240
+
241
+ out += `\t${kind}: {\n`;
242
+
243
+ const formatted = items
244
+ .map(each => {
245
+ const formatted = this.formatSchemaType(each);
246
+ return { ...each, formatted };
247
+ })
248
+ .filter(x => x !== undefined);
249
+
250
+ out += formatted
251
+ .map(t => {
252
+ let name = t.name;
253
+ if (isIdentifierInvalid(name)) name = `"${name}"`;
254
+ return `\t\t${name}: ${t.kind}s.${t.formatted};`;
255
+ })
256
+ .join("\n");
257
+ out += "\n\t};\n";
258
+ }
259
+
260
+ out += "}";
261
+
262
+ return out;
263
+ },
264
+
265
+ fullIndex(schemas: Schema[]) {
266
+ let out = "";
267
+
268
+ out += schemas
269
+ .map(s => `import type { ${this.formatSchema(s.name)} } from "./${s.name}/index.ts";`)
270
+ .join("\n");
271
+
272
+ out += "\n\n";
273
+ out += `export interface Database {\n`;
274
+ out += schemas
275
+ .map(schema => {
276
+ // Kysely only wants tables
277
+ const tables = schema.tables;
278
+
279
+ let out = "";
280
+
281
+ const seen = new Set<string>();
282
+ const formatted = tables
283
+ .map(each => {
284
+ const formatted = this.formatSchemaType(each);
285
+ // skip clashing names
286
+ if (seen.has(formatted)) return;
287
+ seen.add(formatted);
288
+ return { ...each, formatted };
289
+ })
290
+ .filter(x => x !== undefined);
291
+
292
+ if (out.length) out += "\n\n";
293
+ out += formatted
294
+ .map(t => {
295
+ const prefix = defaultSchema === schema.name ? "" : schema.name + ".";
296
+ let qualified = prefix + t.name;
297
+ if (isIdentifierInvalid(qualified)) qualified = `"${qualified}"`;
298
+ return `\t${qualified}: ${this.formatSchema(schema.name)}["${t.kind}s"]["${t.name}"];`;
299
+ })
300
+ .join("\n");
301
+
302
+ return out;
303
+ })
304
+ .join("");
305
+
306
+ out += "\n}\n\n";
307
+
308
+ out += schemas.map(s => `export type { ${this.formatSchema(s.name)} };`).join("\n");
309
+
310
+ return out;
311
+ },
312
+ };
313
+
314
+ return generator;
315
+ });
package/src/types.ts ADDED
@@ -0,0 +1,297 @@
1
+ import type {
2
+ CanonicalType,
3
+ CompositeTypeDetails,
4
+ EnumDetails,
5
+ FunctionDetails,
6
+ TableDetails,
7
+ SchemaType,
8
+ Schema,
9
+ } from "pg-extract";
10
+
11
+ import { dirname, relative } from "node:path";
12
+ import { join } from "./util.ts";
13
+
14
+ // To be updated when we add support for other kinds
15
+ export const allowed_kind_names = ["tables", "enums", "composites", "functions"] as const;
16
+ export type allowed_kind_names = (typeof allowed_kind_names)[number];
17
+
18
+ export interface FolderStructure {
19
+ name: string;
20
+ type: "root";
21
+ children: {
22
+ [realname: string]: {
23
+ name: string;
24
+ type: "schema";
25
+ children: {
26
+ [kind: string]: {
27
+ kind: allowed_kind_names;
28
+ type: "kind";
29
+ children: {
30
+ [realname: string]: {
31
+ name: string;
32
+ type: "type";
33
+ };
34
+ };
35
+ };
36
+ };
37
+ };
38
+ };
39
+ }
40
+
41
+ export namespace Nodes {
42
+ export class ExternalImport {
43
+ // what to import
44
+ name: string;
45
+ // use `type` syntax?
46
+ typeOnly: boolean;
47
+ // use `* as` syntax?
48
+ star: boolean;
49
+
50
+ // this is an external import
51
+ external: true;
52
+ // what module to import from
53
+ module: string;
54
+
55
+ constructor(args: { name: string; module: string; typeOnly: boolean; star: boolean }) {
56
+ this.name = args.name;
57
+ this.typeOnly = args.typeOnly;
58
+ this.star = args.star;
59
+ this.external = true;
60
+ this.module = args.module;
61
+ }
62
+ }
63
+
64
+ export class InternalImport {
65
+ // what to import
66
+ name: string;
67
+ // underlying type that is being imported
68
+ canonical_type: CanonicalType;
69
+ // use `type` syntax?
70
+ typeOnly: boolean;
71
+ // use `* as` syntax?
72
+ star: boolean;
73
+
74
+ // this is an internal import
75
+ external: false;
76
+
77
+ constructor(args: { name: string; canonical_type: CanonicalType; typeOnly: boolean; star: boolean }) {
78
+ this.name = args.name;
79
+ this.canonical_type = args.canonical_type;
80
+ this.star = args.star;
81
+ this.typeOnly = args.typeOnly;
82
+ this.external = false;
83
+ }
84
+ }
85
+
86
+ export class ImportList {
87
+ constructor(public imports: (ExternalImport | InternalImport)[]) {}
88
+
89
+ static merge(lists: ImportList[]) {
90
+ return new ImportList(lists.flatMap(l => l.imports));
91
+ }
92
+
93
+ add(item: ExternalImport | InternalImport) {
94
+ this.imports.push(item);
95
+ }
96
+
97
+ stringify(context_file: string, files: FolderStructure) {
98
+ const externals = this.imports.filter(i => i.external);
99
+ const internals = this.imports.filter(i => !i.external);
100
+
101
+ const modulegroups: Record<string, ExternalImport[]> = {};
102
+ for (const item of externals) {
103
+ const group = modulegroups[item.module];
104
+ if (group) group.push(item);
105
+ else modulegroups[item.module] = [item];
106
+ }
107
+
108
+ const out = [];
109
+
110
+ // TODO: normalise external and internal imports and handle the stringification of the imports in a single place
111
+
112
+ {
113
+ // EXTERNAL IMPORTS
114
+
115
+ const imports = [];
116
+ for (const module in modulegroups) {
117
+ const items = modulegroups[module]!;
118
+ const star = items.find(i => i.star);
119
+ const unique = items.filter((i, index, arr) => {
120
+ if (i.star) return false;
121
+ if (arr.findIndex(i2 => i2.name === i.name) !== index) return false;
122
+ return true;
123
+ });
124
+
125
+ const bits = [];
126
+ const typeOnlys = unique.filter(i => i.typeOnly);
127
+ const values = unique.filter(i => !i.typeOnly);
128
+
129
+ // if no values to import, use `import type { ... }` instead of `import { type ... }`
130
+ const typeInline = values.length !== 0;
131
+
132
+ let import_line = `import `;
133
+ for (const type of typeOnlys) bits.push(typeInline ? "type " : "" + type.name);
134
+ for (const type of values) bits.push(type.name);
135
+ if (bits.length) import_line += (typeInline ? "" : "type ") + "{ " + bits.join(", ") + " }";
136
+ if (bits.length && star) import_line += `, `;
137
+ if (star) import_line += `* as ${star.name}`;
138
+ if (bits.length || star) import_line += ` from`;
139
+ import_line += `"${module}";`;
140
+ imports.push(import_line);
141
+ }
142
+ out.push(join(imports, "\n"));
143
+ }
144
+
145
+ {
146
+ // INTERNAL IMPORTS
147
+
148
+ const imports = [];
149
+ const unique_types = internals
150
+ .filter(({ name: name1, canonical_type: int }, index, arr) => {
151
+ return (
152
+ arr.findIndex(({ name: name2, canonical_type: int2 }) => {
153
+ return (
154
+ // adapter-assigned name
155
+ name2 === name1 &&
156
+ // canonical type details
157
+ int2.name === int.name &&
158
+ int2.schema === int.schema &&
159
+ int2.kind === int.kind
160
+ );
161
+ }) === index
162
+ );
163
+ })
164
+ .map(imp => {
165
+ const t = imp.canonical_type;
166
+ const schema = files.children[t.schema]!;
167
+ const kind = schema.children[`${t.kind}s`]!;
168
+ const type = kind.children[t.name]!;
169
+ const located_file = `${files.name}/${schema.name}/${kind.kind}/${type.name}.ts`;
170
+ return { ...imp, located_file };
171
+ });
172
+
173
+ const group_by_file: Record<string, (InternalImport & { located_file: string })[]> = {};
174
+ for (const type of unique_types) {
175
+ const file = group_by_file[type.located_file] || [];
176
+ file.push(type);
177
+ group_by_file[type.located_file] = file;
178
+ }
179
+
180
+ for (const group in group_by_file) {
181
+ let relative_path = relative(dirname(context_file), group);
182
+ if (/^[^\.+\/]/.test(relative_path)) relative_path = "./" + relative_path;
183
+ const items = group_by_file[group]!;
184
+ const typeOnlys = items.filter(i => i.typeOnly);
185
+ const values = items.filter(i => !i.typeOnly);
186
+ const star = values.find(i => i.star);
187
+ let import_line = "import ";
188
+ const bits = [];
189
+
190
+ // if no values to import, use `import type { ... }` instead of `import { type ... }`
191
+ const typeInline = values.length !== 0;
192
+
193
+ for (const type of typeOnlys) bits.push((typeInline ? "type " : "") + type.name);
194
+ for (const type of values) bits.push(type.name);
195
+ if (bits.length) import_line += (typeInline ? "" : "type ") + "{ " + bits.join(", ") + " }";
196
+ if (star) import_line += `* as ${star.name}`;
197
+ import_line += ` from "${relative_path}";`;
198
+ imports.push(import_line);
199
+ }
200
+
201
+ out.push(join(imports, "\n"));
202
+ }
203
+
204
+ return join(out);
205
+ }
206
+ }
207
+
208
+ export interface Export {
209
+ // what to export
210
+ name: string;
211
+ // what kind of thing to export
212
+ kind: SchemaType["kind"];
213
+ // what schema to export from
214
+ schema: string;
215
+ // use `* as` syntax?
216
+ star: boolean;
217
+ }
218
+ }
219
+
220
+ export interface TruePGOpts {
221
+ uri: string;
222
+ out: string;
223
+ adapters: string[];
224
+ defaultSchema?: string;
225
+ }
226
+
227
+ export interface CreateGeneratorOpts {
228
+ defaultSchema?: string;
229
+ warnings: string[];
230
+ }
231
+
232
+ export interface createGenerator {
233
+ (opts?: CreateGeneratorOpts): SchemaGenerator;
234
+ }
235
+
236
+ /* convenience function to create a generator with type inference */
237
+ export const createGenerator = (generatorCreator: createGenerator): createGenerator => generatorCreator;
238
+
239
+ export interface SchemaGenerator {
240
+ /**
241
+ * Use this function to define a name mapping for schema names.
242
+ * This is useful if you want to use a different name for a schema in the generated code.
243
+ * Example: "public" -> "PublicSchema"
244
+ */
245
+ formatSchema(name: string): string;
246
+
247
+ /**
248
+ * Use this function to define a name mapping for schema types.
249
+ * This is useful if you want to use a different name for a type in the generated code.
250
+ * Example: "users" -> "UsersTable"
251
+ */
252
+ formatSchemaType(type: SchemaType): string;
253
+
254
+ /**
255
+ * Use this function to define a name mapping for type names.
256
+ * This is useful if you want to use a different name for a type in the generated code.
257
+ * Example: "users" -> "UsersTable"
258
+ */
259
+ formatType(type: CanonicalType): string;
260
+
261
+ table(
262
+ /** @out Append used types to this array */
263
+ imports: Nodes.ImportList,
264
+ /** Information about the table */
265
+ table: TableDetails,
266
+ ): string;
267
+
268
+ enum(
269
+ /** @out Append used types to this array */
270
+ imports: Nodes.ImportList,
271
+ /** Information about the enum */
272
+ en: EnumDetails,
273
+ ): string;
274
+
275
+ composite(
276
+ /** @out Append used types to this array */
277
+ imports: Nodes.ImportList,
278
+ /** Information about the composite type */
279
+ type: CompositeTypeDetails,
280
+ ): string;
281
+
282
+ function(
283
+ /** @out Append used types to this array */
284
+ imports: Nodes.ImportList,
285
+ /** Information about the function */
286
+ type: FunctionDetails,
287
+ ): string;
288
+
289
+ /** create the file `$out/$schema.name/$kind/index.ts` */
290
+ schemaKindIndex(schema: Schema, kind: Exclude<keyof Schema, "name">, main_generator?: SchemaGenerator): string;
291
+
292
+ /** create the file `$out/$schema.name/index.ts` */
293
+ schemaIndex(schema: Schema, main_generator?: SchemaGenerator): string;
294
+
295
+ /** create the file `$out/index.ts` */
296
+ fullIndex(schemas: Schema[], main_generator?: SchemaGenerator): string;
297
+ }
package/src/util.ts ADDED
@@ -0,0 +1 @@
1
+ export const join = (parts: Iterable<string>, joiner = "\n\n") => Array.from(parts).filter(Boolean).join(joiner);
@@ -0,0 +1,38 @@
1
+ // extended from https://github.com/kristiandupont/kanel/blob/e9332f03ff5e38f5b844dd7a4563580c0d9d1444/packages/kanel/src/defaultTypeMap.ts
2
+
3
+ export const builtins: Record<string, string> = {
4
+ "pg_catalog.int2": "z.number()",
5
+ "pg_catalog.int4": "z.number()",
6
+
7
+ // JS numbers are always floating point, so there is only 53 bits of precision
8
+ // for the integer part. Thus, storing a 64-bit integer in a JS number will
9
+ // result in potential data loss. We therefore use strings for 64-bit integers
10
+ // the same way that the pg driver does.
11
+ "pg_catalog.int8": "z.string()",
12
+
13
+ "pg_catalog.float4": "z.number()",
14
+ "pg_catalog.float8": "z.number()",
15
+ "pg_catalog.numeric": "z.string()",
16
+ "pg_catalog.bool": "z.boolean()",
17
+ "pg_catalog.json": "z.unknown()",
18
+ "pg_catalog.jsonb": "z.unknown()",
19
+ "pg_catalog.char": "z.string()",
20
+ "pg_catalog.bpchar": "z.string()",
21
+ "pg_catalog.varchar": "z.string()",
22
+ "pg_catalog.text": "z.string()",
23
+ "pg_catalog.uuid": "z.string()",
24
+ "pg_catalog.inet": "z.string()",
25
+ "pg_catalog.date": "z.date()",
26
+ "pg_catalog.time": "z.date()",
27
+ "pg_catalog.timetz": "z.date()",
28
+ "pg_catalog.timestamp": "z.date()",
29
+ "pg_catalog.timestamptz": "z.date()",
30
+ "pg_catalog.int4range": "z.string()",
31
+ "pg_catalog.int8range": "z.string()",
32
+ "pg_catalog.numrange": "z.string()",
33
+ "pg_catalog.tsrange": "z.string()",
34
+ "pg_catalog.tstzrange": "z.string()",
35
+ "pg_catalog.daterange": "z.string()",
36
+ "pg_catalog.record": "z.record(z.string(), z.unknown())",
37
+ "pg_catalog.void": "z.void()",
38
+ };