diff --git a/README.md b/README.md index 1ec29ff..797095f 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,13 @@ Thank you [kysely-codegen](https://npmx.dev/package/kysely-codegen) for inspirat Why `kysely-typegen` if there is already `kysely-codegen`? Comparison: -| | `kysely-codegen@0.20.0` | `kysely-typegen` | -| ----------------------------- | ---------------------------------------- | ------------------------------------------------------------ | -| **Install Size** | 6.8 MB | 5 kB | -| **Dependencies** | 35 total | 0 (no runtime dependencies) | -| **Type** | CLI | Library/Programmatic Usage | -| **Code Size/Maintainability** | Heavy | Less than 200 LOC/Simple and straightforward | -| **Database Support** | PostgreSQL, MySQL, SQLite, MSSQL, LibSQL | PostgreSQL (but can **easily be extended to more dialects**) | +| | `kysely-codegen@0.20.0` | `kysely-typegen` | +| ----------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- | +| **Install Size** | 6.8 MB | 5 kB | +| **Dependencies** | 35 total | 0 (no runtime dependencies) | +| **Type** | CLI | Library/Programmatic Usage | +| **Code Size/Maintainability** | Heavy | Lightweight/Simple and straightforward (string manipulation instead of complex AST) | +| **Database Support** | PostgreSQL, MySQL, SQLite, MSSQL, LibSQL | PostgreSQL (**can be easily extended to more**) | `kysely-typegen` is a **library** (not a CLI), which means you are in control of where and how to run it, and is designed to be **extensible**, easy to add support for more database dialects. @@ -40,48 +40,48 @@ Peer dependencies: npm install kysely ``` -Kysely dialect of your choice, for example: [kysely-postgres-js](https://github.com/kysely-org/kysely-postgres-js) - -```sh -npm install kysely-postgres-js postgres -``` +Plus a Kysely dialect driver for your database (see [Setup Kysely database](#setup-kysely-database) below). ## Usage ### Setup Kysely database -Create your Kysely database instance (example with [kysely-postgres-js](https://github.com/kysely-org/kysely-postgres-js)): +Create your Kysely database instance and export a `databaseTypegen` for the script in the next section. The rest of the guide is dialect-agnostic: only this file changes per database. + +#### PostgreSQL + +`KyselyTypegenPostgresDialect` works with any Kysely PostgreSQL dialect, including the built-in [`PostgresDialect`](https://kysely.dev/docs/dialects/postgres) (using [`pg`](https://npmx.dev/package/pg)) and [`PostgresJSDialect`](https://npmx.dev/package/kysely-postgres-js) (using [`postgres`](https://npmx.dev/package/postgres)). The example below uses `kysely-postgres-js`. + +```sh +npm install kysely-postgres-js postgres +``` ```ts // database.ts import { Kysely } from "kysely" import { PostgresJSDialect } from "kysely-postgres-js" +import { KyselyTypegenPostgresDialect } from "kysely-typegen/postgres" import postgres from "postgres" import type { DB } from "./codegen.ts" -export const DATABASE_USER = process.env["DATABASE_USER"] ?? "user" -export const DATABASE_PASSWORD = process.env["DATABASE_PASSWORD"] ?? "password" -export const DATABASE_NAME = process.env["DATABASE_NAME"] ?? "database" -export const DATABASE_HOST = process.env["DATABASE_HOST"] ?? "localhost" -export const DATABASE_PORT = Number.parseInt(process.env["DATABASE_PORT"] ?? "5432", 10) - const dialect = new PostgresJSDialect({ postgres: postgres({ - database: DATABASE_NAME, - host: DATABASE_HOST, - user: DATABASE_USER, - password: DATABASE_PASSWORD, - port: DATABASE_PORT, + database: process.env["DATABASE_NAME"] ?? "database", + host: process.env["DATABASE_HOST"] ?? "localhost", + user: process.env["DATABASE_USER"] ?? "user", + password: process.env["DATABASE_PASSWORD"] ?? "password", + port: Number.parseInt(process.env["DATABASE_PORT"] ?? "5432", 10), }), }) export const database = new Kysely({ dialect }) +export const databaseTypegen = new KyselyTypegenPostgresDialect({ database }) ``` ### Generate the type definitions -Create a script that uses `kysely-typegen` to introspect your database and write the generated types to a file: +Create a script that uses `databaseTypegen` to introspect your database and write the generated types to a file. This script is the same regardless of the underlying database: ```ts // scripts/typegen.ts @@ -90,11 +90,8 @@ Create a script that uses `kysely-typegen` to introspect your database and write import fs from "node:fs" import path from "node:path" -import { KyselyTypegenPostgresDialect } from "kysely-typegen" +import { database, databaseTypegen } from "./database.ts" -import { database } from "./database.ts" - -const databaseTypegen = new KyselyTypegenPostgresDialect({ database }) const result = await databaseTypegen.typegen() const codegenContent = result.lines.join("\n") const codegenPath = path.join(process.cwd(), "codegen.ts") @@ -125,40 +122,42 @@ Fully type-safe queries derived from your actual database schema. `kysely-typegen` ships with `KyselyTypegenPostgresDialect`, but you can add support for any database by extending the abstract `KyselyTypegenDialect` class. -Only two things are required: +Only one thing is required: - `scalars`: a `Record` mapping the database column types to TypeScript types. -- `getEnumsMap()`: a method returning a `Map` of enum names to their values (return an empty `Map` if your database doesn't support enums). ```ts import { KyselyTypegenDialect } from "kysely-typegen" -import type { Kysely } from "kysely" -export class KyselyTypegenMySQLDialect extends KyselyTypegenDialect { - public database: KyselyTypegenDialect["database"] - - public readonly scalars: Record = { - bigint: "number", +export class KyselyTypegenMSSQLDialect extends KyselyTypegenDialect { + public override readonly scalars: Record = { + bigint: "Int8", + bit: "boolean", char: "string", datetime: "Timestamp", + decimal: "Numeric", int: "number", - json: "Json", + nvarchar: "string", + smallint: "number", text: "string", - tinyint: "number", + varbinary: "Buffer", varchar: "string", // ... } +} +``` - public constructor(input: { database: Kysely }) { - super() - this.database = input.database - } +If your database supports enums, override the optional `introspectEnums()` hook, which returns two maps: - protected async getEnumsMap(): Promise> { - // Query your database's information schema for enum types. - // Key: enum name, Value: array of enum values. - return new Map() - } +- `named`: enum name → values (emitted as `export type Name = "a" | "b"`). +- `inline`: `${tableName}.${columnName}` → values (emitted inline at the column site, for databases where enums are anonymous per-column). + +```ts +import type { IntrospectedEnums } from "kysely-typegen" + +protected override async introspectEnums(): Promise { + // Query your database's information schema... + return { named: [], inline: new Map() } } ``` diff --git a/package-lock.json b/package-lock.json index 523eb13..6ce0c7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,13 @@ "devDependencies": { "@testcontainers/postgresql": "12.0.0", "@types/node": "25.9.1", + "@types/pg": "8.20.0", "kysely": "0.29.2", "kysely-postgres-js": "3.0.0", "oxfmt": "0.51.0", "oxlint": "1.66.0", "oxlint-tsgolint": "0.23.0", + "pg": "8.21.0", "postgres": "3.4.9", "semantic-release": "25.0.3", "tsdown": "0.22.0", @@ -2328,6 +2330,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/ssh2": { "version": "1.15.5", "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", @@ -7599,6 +7613,113 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/pgpass/node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7657,6 +7778,49 @@ "url": "https://github.com/sponsors/porsager" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", diff --git a/package.json b/package.json index 1280647..c574d3c 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "types": "./dist/index.d.mts", "exports": { ".": "./dist/index.mjs", + "./postgres": "./dist/postgres.mjs", "./package.json": "./package.json" }, "publishConfig": { @@ -40,11 +41,13 @@ "devDependencies": { "@testcontainers/postgresql": "12.0.0", "@types/node": "25.9.1", + "@types/pg": "8.20.0", "kysely": "0.29.2", "kysely-postgres-js": "3.0.0", "oxfmt": "0.51.0", "oxlint": "1.66.0", "oxlint-tsgolint": "0.23.0", + "pg": "8.21.0", "postgres": "3.4.9", "semantic-release": "25.0.3", "tsdown": "0.22.0", diff --git a/src/_test/__snapshots__/index.test.ts.snapshot b/src/_test/__snapshots__/index.test.ts.snapshot deleted file mode 100644 index 246c544..0000000 --- a/src/_test/__snapshots__/index.test.ts.snapshot +++ /dev/null @@ -1,109 +0,0 @@ -exports[`typegen > generate types matching snapshot 1`] = ` -{ - "lines": [ - "// This file was automatically generated by \`kysely-typegen\`.", - "// Do not edit this file manually.", - "", - "import type { ColumnType } from \\"kysely\\"", - "", - "export type Generated = T extends ColumnType ? ColumnType : ColumnType", - "", - "export type Timestamp = ColumnType", - "", - "export type Numeric = ColumnType", - "", - "export type Int8 = ColumnType", - "", - "export type Json = JsonValue", - "", - "export type JsonArray = JsonValue[]", - "", - "export interface JsonObject {", - " [x: string]: JsonValue | undefined", - "}", - "", - "export type JsonPrimitive = boolean | number | string | null", - "", - "export type JsonValue = JsonArray | JsonObject | JsonPrimitive", - "", - "export type Currency = \\"EUR\\" | \\"GBP\\" | \\"USD\\"", - "", - "export type OrderStatus = \\"cancelled\\" | \\"paid\\" | \\"pending\\" | \\"shipped\\"", - "", - "export type UserRole = \\"admin\\" | \\"guest\\" | \\"member\\"", - "", - "export interface AllTypes {", - " colBit: string", - " colBool: boolean", - " colBoolDefault: Generated", - " colBoolNullable: boolean | null", - " colBox: string", - " colBpchar: string", - " colBytea: Buffer", - " colCidr: string", - " colDate: Timestamp", - " colFloat4: number", - " colFloat8: number", - " colInet: string", - " colInt2: number", - " colInt4: number", - " colInt8: Int8", - " colJson: Json", - " colJsonb: Json", - " colJsonbDefault: Generated", - " colLine: string", - " colLseg: string", - " colMacaddr: string", - " colMoney: string", - " colNumeric: Numeric", - " colOid: number", - " colPath: string", - " colPoint: unknown", - " colPolygon: string", - " colText: string", - " colTextNullable: string | null", - " colTime: string", - " colTimestamp: Timestamp", - " colTimestampDefault: Generated", - " colTimestamptz: Timestamp", - " colTimetz: string", - " colTsquery: string", - " colTsvector: string", - " colUuid: string", - " colVarbit: string", - " colVarchar: string", - " colXml: string", - " createdAt: Generated", - " id: Generated", - " updatedAt: Timestamp | null", - "}", - "", - "export interface Orders {", - " amountCents: number", - " createdAt: Generated", - " currency: Generated", - " id: Generated", - " note: string | null", - " status: Generated", - " userId: string", - "}", - "", - "export interface Users {", - " createdAt: Generated", - " email: string | null", - " id: Generated", - " isActive: Generated", - " role: Generated", - " username: string", - "}", - "", - "export interface DB {", - " AllTypes: AllTypes", - " Orders: Orders", - " Users: Users", - "}" - ], - "tablesCount": 3, - "enumsCount": 3 -} -`; diff --git a/src/_test/__snapshots__/postgres.test.ts.snapshot b/src/_test/__snapshots__/postgres.test.ts.snapshot new file mode 100644 index 0000000..d331c35 --- /dev/null +++ b/src/_test/__snapshots__/postgres.test.ts.snapshot @@ -0,0 +1,221 @@ +exports[`typegen PostgreSQL > generate types matching snapshot (kysely-postgres-js) 1`] = ` +{ + "lines": [ + "// This file was automatically generated by \`kysely-typegen\`.", + "// Do not edit this file manually.", + "", + "import type { ColumnType } from \\"kysely\\"", + "", + "export type Generated = T extends ColumnType ? ColumnType : ColumnType", + "", + "export type Timestamp = ColumnType", + "", + "export type Numeric = ColumnType", + "", + "export type Int8 = ColumnType", + "", + "export type Json = JsonValue", + "", + "export type JsonArray = JsonValue[]", + "", + "export interface JsonObject {", + " [x: string]: JsonValue | undefined", + "}", + "", + "export type JsonPrimitive = boolean | number | string | null", + "", + "export type JsonValue = JsonArray | JsonObject | JsonPrimitive", + "", + "export type Currency = \\"EUR\\" | \\"GBP\\" | \\"USD\\"", + "", + "export type OrderStatus = \\"cancelled\\" | \\"paid\\" | \\"pending\\" | \\"shipped\\"", + "", + "export type UserRole = \\"admin\\" | \\"guest\\" | \\"member\\"", + "", + "export interface AllTypes {", + " colBit: string", + " colBool: boolean", + " colBoolDefault: Generated", + " colBoolNullable: boolean | null", + " colBox: string", + " colBpchar: string", + " colBytea: Buffer", + " colCidr: string", + " colDate: Timestamp", + " colFloat4: number", + " colFloat8: number", + " colInet: string", + " colInt2: number", + " colInt4: number", + " colInt8: Int8", + " colJson: Json", + " colJsonb: Json", + " colJsonbDefault: Generated", + " colLine: string", + " colLseg: string", + " colMacaddr: string", + " colMoney: string", + " colNumeric: Numeric", + " colOid: number", + " colPath: string", + " colPoint: unknown", + " colPolygon: string", + " colText: string", + " colTextNullable: string | null", + " colTime: string", + " colTimestamp: Timestamp", + " colTimestampDefault: Generated", + " colTimestamptz: Timestamp", + " colTimetz: string", + " colTsquery: string", + " colTsvector: string", + " colUuid: string", + " colVarbit: string", + " colVarchar: string", + " colXml: string", + " createdAt: Generated", + " id: Generated", + " updatedAt: Timestamp | null", + "}", + "", + "export interface Orders {", + " amountCents: number", + " createdAt: Generated", + " currency: Generated", + " id: Generated", + " note: string | null", + " status: Generated", + " userId: string", + "}", + "", + "export interface Users {", + " createdAt: Generated", + " email: string | null", + " id: Generated", + " isActive: Generated", + " role: Generated", + " username: string", + "}", + "", + "export interface DB {", + " AllTypes: AllTypes", + " Orders: Orders", + " Users: Users", + "}" + ], + "tablesCount": 3, + "enumsCount": 3, + "inlineEnumsCount": 0 +} +`; + +exports[`typegen PostgreSQL > generate types matching snapshot (pg) 1`] = ` +{ + "lines": [ + "// This file was automatically generated by \`kysely-typegen\`.", + "// Do not edit this file manually.", + "", + "import type { ColumnType } from \\"kysely\\"", + "", + "export type Generated = T extends ColumnType ? ColumnType : ColumnType", + "", + "export type Timestamp = ColumnType", + "", + "export type Numeric = ColumnType", + "", + "export type Int8 = ColumnType", + "", + "export type Json = JsonValue", + "", + "export type JsonArray = JsonValue[]", + "", + "export interface JsonObject {", + " [x: string]: JsonValue | undefined", + "}", + "", + "export type JsonPrimitive = boolean | number | string | null", + "", + "export type JsonValue = JsonArray | JsonObject | JsonPrimitive", + "", + "export type Currency = \\"EUR\\" | \\"GBP\\" | \\"USD\\"", + "", + "export type OrderStatus = \\"cancelled\\" | \\"paid\\" | \\"pending\\" | \\"shipped\\"", + "", + "export type UserRole = \\"admin\\" | \\"guest\\" | \\"member\\"", + "", + "export interface AllTypes {", + " colBit: string", + " colBool: boolean", + " colBoolDefault: Generated", + " colBoolNullable: boolean | null", + " colBox: string", + " colBpchar: string", + " colBytea: Buffer", + " colCidr: string", + " colDate: Timestamp", + " colFloat4: number", + " colFloat8: number", + " colInet: string", + " colInt2: number", + " colInt4: number", + " colInt8: Int8", + " colJson: Json", + " colJsonb: Json", + " colJsonbDefault: Generated", + " colLine: string", + " colLseg: string", + " colMacaddr: string", + " colMoney: string", + " colNumeric: Numeric", + " colOid: number", + " colPath: string", + " colPoint: unknown", + " colPolygon: string", + " colText: string", + " colTextNullable: string | null", + " colTime: string", + " colTimestamp: Timestamp", + " colTimestampDefault: Generated", + " colTimestamptz: Timestamp", + " colTimetz: string", + " colTsquery: string", + " colTsvector: string", + " colUuid: string", + " colVarbit: string", + " colVarchar: string", + " colXml: string", + " createdAt: Generated", + " id: Generated", + " updatedAt: Timestamp | null", + "}", + "", + "export interface Orders {", + " amountCents: number", + " createdAt: Generated", + " currency: Generated", + " id: Generated", + " note: string | null", + " status: Generated", + " userId: string", + "}", + "", + "export interface Users {", + " createdAt: Generated", + " email: string | null", + " id: Generated", + " isActive: Generated", + " role: Generated", + " username: string", + "}", + "", + "export interface DB {", + " AllTypes: AllTypes", + " Orders: Orders", + " Users: Users", + "}" + ], + "tablesCount": 3, + "enumsCount": 3, + "inlineEnumsCount": 0 +} +`; diff --git a/src/_test/_setup.ts b/src/_test/_setup.ts new file mode 100644 index 0000000..d2861da --- /dev/null +++ b/src/_test/_setup.ts @@ -0,0 +1,11 @@ +import path from "node:path" +import { snapshot } from "node:test" + +snapshot.setResolveSnapshotPath((testFilePath) => { + if (testFilePath == null) { + throw new Error('"testFilePath" is null.') + } + const dir = path.dirname(testFilePath) + const base = path.basename(testFilePath) + return path.join(dir, "__snapshots__", `${base}.snapshot`) +}) diff --git a/src/_test/index.test.ts b/src/_test/postgres.test.ts similarity index 85% rename from src/_test/index.test.ts rename to src/_test/postgres.test.ts index 475b951..8b89d0b 100644 --- a/src/_test/index.test.ts +++ b/src/_test/postgres.test.ts @@ -1,20 +1,13 @@ import type { StartedPostgreSqlContainer } from "@testcontainers/postgresql" import { PostgreSqlContainer } from "@testcontainers/postgresql" -import { Kysely, sql } from "kysely" +import { Kysely, PostgresDialect, sql } from "kysely" import { PostgresJSDialect } from "kysely-postgres-js" -import path from "node:path" -import { after, before, describe, it, snapshot } from "node:test" +import { after, before, describe, it } from "node:test" +import pg from "pg" import postgres from "postgres" -import { KyselyTypegenPostgresDialect } from "../index.ts" -snapshot.setResolveSnapshotPath((testFilePath) => { - if (testFilePath == null) { - throw new Error('"testFilePath" is null.') - } - const dir = path.dirname(testFilePath) - const base = path.basename(testFilePath) - return path.join(dir, "__snapshots__", `${base}.snapshot`) -}) +import { KyselyTypegenPostgresDialect } from "../postgres.ts" +import "./_setup.ts" const POSTGRES_IMAGE = "docker.io/postgres:18.4@sha256:f7ce845ee6873dd84be93c9828fe0d1fab0f9707dc9ac569694657398b290bce" @@ -204,7 +197,7 @@ const createSchema = async (database: Kysely): Promise => { .execute() } -describe("typegen", () => { +describe("typegen PostgreSQL", () => { let container: StartedPostgreSqlContainer let database: Kysely @@ -229,7 +222,7 @@ describe("typegen", () => { await container.stop() }) - it("generate types matching snapshot", async (testContext) => { + it("generate types matching snapshot (kysely-postgres-js)", async (testContext) => { // Arrange - Given const databaseTypegen = new KyselyTypegenPostgresDialect({ database }) @@ -241,6 +234,35 @@ describe("typegen", () => { lines: result.lines, tablesCount: result.tables.length, enumsCount: result.enums.length, + inlineEnumsCount: result.inlineEnums.size, + }) + }) + + it("generate types matching snapshot (pg)", async (testContext) => { + // Arrange - Given + const databasePg = new Kysely({ + dialect: new PostgresDialect({ + pool: new pg.Pool({ + database: container.getDatabase(), + host: container.getHost(), + port: container.getPort(), + user: container.getUsername(), + password: container.getPassword(), + }), + }), + }) + const databaseTypegen = new KyselyTypegenPostgresDialect({ database: databasePg }) + + // Act - When + const result = await databaseTypegen.typegen() + await databasePg.destroy() + + // Assert - Then + testContext.assert.snapshot({ + lines: result.lines, + tablesCount: result.tables.length, + enumsCount: result.enums.length, + inlineEnumsCount: result.inlineEnums.size, }) }) }) diff --git a/src/index.ts b/src/index.ts index 24c9342..72e8c0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,37 +1,90 @@ import type { Kysely, TableMetadata as KyselyTableMetadata } from "kysely" export type TableMetadata = KyselyTableMetadata +export type ColumnMetadata = TableMetadata["columns"][number] export interface EnumMetadata { name: string values: string[] } +export interface IntrospectedEnums { + named: EnumMetadata[] + inline: Map +} + +const PREAMBLE_LINES: string[] = [ + `// This file was automatically generated by \`kysely-typegen\`.`, + "// Do not edit this file manually.", + "", + 'import type { ColumnType } from "kysely"', + "", + "export type Generated = T extends ColumnType ? ColumnType : ColumnType", + "", + "export type Timestamp = ColumnType", + "", + "export type Numeric = ColumnType", + "", + "export type Int8 = ColumnType", + "", + "export type Json = JsonValue", + "", + "export type JsonArray = JsonValue[]", + "", + "export interface JsonObject {", + " [x: string]: JsonValue | undefined", + "}", + "", + "export type JsonPrimitive = boolean | number | string | null", + "", + "export type JsonValue = JsonArray | JsonObject | JsonPrimitive", + "", +] + export abstract class KyselyTypegenDialect { - public abstract readonly database: Kysely + public readonly database: Kysely public abstract readonly scalars: Record + public constructor(input: { database: Kysely }) { + this.database = input.database + } + public async getTables(): Promise { const tables = await this.database.introspection.getTables() return tables.sort((a, b) => { return a.name.localeCompare(b.name) }) } - public getTablesTypegen(tables: TableMetadata[], enums: EnumMetadata[]): string[] { - const enumNames = new Set( - enums.map((enumMetadata) => { - return enumMetadata.name - }), - ) + + protected resolveColumnType( + table: TableMetadata, + column: ColumnMetadata, + scalars: Record, + inlineEnums: Map, + ): string { + const inlineUnion = inlineEnums.get(`${table.name}.${column.name}`) + if (inlineUnion != null) { + return inlineUnion + } + return scalars[column.dataType] ?? "unknown" + } + + public getTablesTypegen( + tables: TableMetadata[], + enums: EnumMetadata[], + inlineEnums: Map, + ): string[] { + const scalars: Record = { ...this.scalars } + for (const enumMetadata of enums) { + scalars[enumMetadata.name] = enumMetadata.name + } const result: string[] = [] for (const table of tables) { - const interfaceName = table.name - result.push(`export interface ${interfaceName} {`) + result.push(`export interface ${table.name} {`) const columns = [...table.columns].sort((a, b) => { return a.name.localeCompare(b.name) }) for (const column of columns) { - const isEnum = enumNames.has(column.dataType) - const baseType = isEnum ? column.dataType : (this.scalars[column.dataType] ?? "unknown") + const baseType = this.resolveColumnType(table, column, scalars, inlineEnums) let columnType = column.isNullable ? `${baseType} | null` : baseType if (column.hasDefaultValue || column.isAutoIncrementing) { columnType = `Generated<${columnType}>` @@ -43,30 +96,38 @@ export abstract class KyselyTypegenDialect { return result } - protected abstract getEnumsMap(): Promise> - public async getEnums(): Promise { - const enumsMap = await this.getEnumsMap() - const enums: EnumMetadata[] = [] - for (const [name, values] of enumsMap) { - enums.push({ name, values }) - } - return enums.sort((a, b) => { + protected async introspectEnums(): Promise { + return { named: [], inline: new Map() } + } + + public async getEnums(): Promise<{ enums: EnumMetadata[]; inlineEnums: Map }> { + const { named, inline } = await this.introspectEnums() + const enums = [...named].sort((a, b) => { return a.name.localeCompare(b.name) }) + const inlineEnums = new Map() + for (const [key, values] of inline) { + inlineEnums.set(key, this.formatEnumUnion(values)) + } + return { enums, inlineEnums } } + + protected formatEnumUnion(values: string[]): string { + return [...values] + .sort((a, b) => { + return a.localeCompare(b) + }) + .map((value) => { + return `"${value}"` + }) + .join(" | ") + } + public getEnumsTypegen(enums: EnumMetadata[]): string[] { const result: string[] = [] for (const enumMetadata of enums) { - const enumName = enumMetadata.name result.push( - `export type ${enumName} = ${enumMetadata.values - .sort((a, b) => { - return a.localeCompare(b) - }) - .map((value) => { - return `"${value}"` - }) - .join(" | ")}`, + `export type ${enumMetadata.name} = ${this.formatEnumUnion(enumMetadata.values)}`, "", ) } @@ -76,110 +137,18 @@ export abstract class KyselyTypegenDialect { public async typegen(): Promise<{ lines: string[] enums: EnumMetadata[] + inlineEnums: Map tables: TableMetadata[] }> { - const [tables, enums] = await Promise.all([this.getTables(), this.getEnums()]) - const lines: string[] = [ - `// This file was automatically generated by \`kysely-typegen\`.`, - "// Do not edit this file manually.", - "", - 'import type { ColumnType } from "kysely"', - "", - "export type Generated = T extends ColumnType ? ColumnType : ColumnType", - "", - "export type Timestamp = ColumnType", - "", - "export type Numeric = ColumnType", - "", - "export type Int8 = ColumnType", - "", - "export type Json = JsonValue", - "", - "export type JsonArray = JsonValue[]", - "", - "export interface JsonObject {", - " [x: string]: JsonValue | undefined", - "}", - "", - "export type JsonPrimitive = boolean | number | string | null", - "", - "export type JsonValue = JsonArray | JsonObject | JsonPrimitive", - "", - ] + const [tables, { enums, inlineEnums }] = await Promise.all([this.getTables(), this.getEnums()]) + const lines: string[] = [...PREAMBLE_LINES] lines.push(...this.getEnumsTypegen(enums)) - lines.push(...this.getTablesTypegen(tables, enums)) + lines.push(...this.getTablesTypegen(tables, enums, inlineEnums)) lines.push("export interface DB {") for (const table of tables) { lines.push(` ${table.name}: ${table.name}`) } lines.push("}") - return { lines, enums, tables } - } -} -export class KyselyTypegenPostgresDialect extends KyselyTypegenDialect { - public database: KyselyTypegenDialect["database"] - - // These types have been found through experimentation in Adminer and in the 'pg' source code. - public readonly scalars: Record = { - bit: "string", - bool: "boolean", - box: "string", - bpchar: "string", - bytea: "Buffer", - cidr: "string", - date: "Timestamp", - float4: "number", - float8: "number", - inet: "string", - int2: "number", - int4: "number", - int8: "Int8", - json: "Json", - jsonb: "Json", - line: "string", - lseg: "string", - macaddr: "string", - money: "string", - numeric: "Numeric", - oid: "number", - path: "string", - polygon: "string", - text: "string", - time: "string", - timestamp: "Timestamp", - timestamptz: "Timestamp", - timetz: "string", - tsquery: "string", - tsvector: "string", - uuid: "string", - varbit: "string", - varchar: "string", - xml: "string", - } - - public constructor(input: { database: KyselyTypegenDialect["database"] }) { - super() - this.database = input.database - } - - protected async getEnumsMap(): Promise> { - const rows = await this.database - .withoutPlugins() - .selectFrom("pg_type as type") - .innerJoin("pg_enum as enum", "type.oid", "enum.enumtypid") - .innerJoin("pg_catalog.pg_namespace as namespace", "namespace.oid", "type.typnamespace") - .select(["type.typname as enumName", "enum.enumlabel as enumValue"]) - .execute() - const enums = new Map() - for (const row of rows) { - const data = row as { enumName: string; enumValue: string } - const existing = enums.get(data.enumName) - if (existing == null) { - enums.set(data.enumName, [data.enumValue]) - } else { - existing.push(data.enumValue) - } - } - return enums + return { lines, enums, inlineEnums, tables } } } diff --git a/src/postgres.ts b/src/postgres.ts new file mode 100644 index 0000000..e0760c4 --- /dev/null +++ b/src/postgres.ts @@ -0,0 +1,65 @@ +import type { EnumMetadata, IntrospectedEnums } from "./index.ts" +import { KyselyTypegenDialect } from "./index.ts" + +export class KyselyTypegenPostgresDialect extends KyselyTypegenDialect { + // These types have been found through experimentation in Adminer and in the 'pg' source code. + public override readonly scalars: Record = { + bit: "string", + bool: "boolean", + box: "string", + bpchar: "string", + bytea: "Buffer", + cidr: "string", + date: "Timestamp", + float4: "number", + float8: "number", + inet: "string", + int2: "number", + int4: "number", + int8: "Int8", + json: "Json", + jsonb: "Json", + line: "string", + lseg: "string", + macaddr: "string", + money: "string", + numeric: "Numeric", + oid: "number", + path: "string", + polygon: "string", + text: "string", + time: "string", + timestamp: "Timestamp", + timestamptz: "Timestamp", + timetz: "string", + tsquery: "string", + tsvector: "string", + uuid: "string", + varbit: "string", + varchar: "string", + xml: "string", + } + + protected override async introspectEnums(): Promise { + const rows = (await this.database + .withoutPlugins() + .selectFrom("pg_type as type") + .innerJoin("pg_enum as enum", "type.oid", "enum.enumtypid") + .select(["type.typname as name", "enum.enumlabel as value"]) + .execute()) as Array<{ name: string; value: string }> + const grouped = new Map() + for (const { name, value } of rows) { + const existing = grouped.get(name) + if (existing == null) { + grouped.set(name, [value]) + } else { + existing.push(value) + } + } + const named: EnumMetadata[] = [] + for (const [name, values] of grouped) { + named.push({ name, values }) + } + return { named, inline: new Map() } + } +} diff --git a/tsdown.config.ts b/tsdown.config.ts index f17aa2a..a3e9314 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsdown" export default defineConfig({ - entry: "./src/index.ts", + entry: ["./src/index.ts", "./src/postgres.ts"], dts: true, exports: true, })