├── .cspell.json ├── .github └── FUNDING.yml ├── .gitignore ├── .kysely-codegenrc.json ├── .ncurc.json ├── .npmignore ├── .prettierignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── assets └── kysely-codegen-logo.svg ├── docker-compose.yml ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── src ├── cli │ ├── bin.ts │ ├── cli.test.ts │ ├── cli.ts │ ├── config-error.ts │ ├── config.ts │ ├── constants.ts │ ├── flags.ts │ ├── index.ts │ └── test │ │ ├── config-with-custom-serializer.ts │ │ ├── config.cjs │ │ └── output.snapshot.ts ├── db.ts ├── generator │ ├── adapter.ts │ ├── ast │ │ ├── alias-declaration-node.ts │ │ ├── array-expression-node.ts │ │ ├── column-type-node.ts │ │ ├── definition-node.ts │ │ ├── export-statement-node.ts │ │ ├── expression-node.ts │ │ ├── extends-clause-node.ts │ │ ├── generic-expression-node.ts │ │ ├── identifier-node.ts │ │ ├── import-clause-node.ts │ │ ├── import-statement-node.ts │ │ ├── infer-clause-node.ts │ │ ├── interface-declaration-node.ts │ │ ├── json-column-type-node.ts │ │ ├── literal-node.ts │ │ ├── mapped-type-node.ts │ │ ├── module-reference-node.ts │ │ ├── object-expression-node.ts │ │ ├── property-node.ts │ │ ├── raw-expression-node.ts │ │ ├── runtime-enum-declaration-node.ts │ │ ├── statement-node.ts │ │ ├── template-node.ts │ │ └── union-expression-node.ts │ ├── connection-string-parser.test.ts │ ├── connection-string-parser.ts │ ├── constants.ts │ ├── dialect.ts │ ├── dialects │ │ ├── kysely-bun-sqlite │ │ │ ├── kysely-bun-sqlite-adapter.ts │ │ │ └── kysely-bun-sqlite-dialect.ts │ │ ├── libsql │ │ │ ├── libsql-adapter.ts │ │ │ └── libsql-dialect.ts │ │ ├── mssql │ │ │ ├── mssql-adapter.ts │ │ │ └── mssql-dialect.ts │ │ ├── mysql │ │ │ ├── mysql-adapter.ts │ │ │ └── mysql-dialect.ts │ │ ├── postgres │ │ │ ├── postgres-adapter.ts │ │ │ └── postgres-dialect.ts │ │ ├── sqlite │ │ │ ├── sqlite-adapter.ts │ │ │ └── sqlite-dialect.ts │ │ └── worker-bun-sqlite │ │ │ └── worker-bun-sqlite-dialect.ts │ ├── generator │ │ ├── diff-checker.test.ts │ │ ├── diff-checker.ts │ │ ├── generate.test.ts │ │ ├── generate.ts │ │ ├── runtime-enums-style.ts │ │ ├── serializer.test.ts │ │ ├── serializer.ts │ │ ├── singularizer.test.ts │ │ ├── singularizer.ts │ │ └── snapshots │ │ │ ├── libsql.snapshot.ts │ │ │ ├── mysql.snapshot.ts │ │ │ ├── postgres.snapshot.ts │ │ │ ├── postgres2.snapshot.ts │ │ │ └── sqlite.snapshot.ts │ ├── index.ts │ ├── logger │ │ ├── log-level.test.ts │ │ ├── log-level.ts │ │ └── logger.ts │ ├── transformer │ │ ├── definitions.ts │ │ ├── identifier-style.ts │ │ ├── imports.ts │ │ ├── symbol-collection.test.ts │ │ ├── symbol-collection.ts │ │ ├── transformer.test.ts │ │ └── transformer.ts │ └── utils │ │ ├── case-converter.test.ts │ │ └── case-converter.ts ├── index.ts └── introspector │ ├── dialect.ts │ ├── dialects │ ├── kysely-bun-sqlite │ │ ├── kysely-bun-sqlite-dialect.ts │ │ └── kysely-bun-sqlite-introspector.ts │ ├── libsql │ │ ├── libsql-dialect.ts │ │ └── libsql-introspector.ts │ ├── mssql │ │ ├── mssql-dialect.ts │ │ └── mssql-introspector.ts │ ├── mysql │ │ ├── mysql-db.ts │ │ ├── mysql-dialect.ts │ │ ├── mysql-introspector.ts │ │ └── mysql-parser.ts │ ├── postgres │ │ ├── date-parser.ts │ │ ├── numeric-parser.ts │ │ ├── postgres-db.ts │ │ ├── postgres-dialect.ts │ │ └── postgres-introspector.ts │ └── sqlite │ │ ├── sqlite-dialect.ts │ │ └── sqlite-introspector.ts │ ├── enum-collection.ts │ ├── index.ts │ ├── introspector.fixtures.ts │ ├── introspector.test.ts │ ├── introspector.ts │ ├── metadata │ ├── column-metadata.ts │ ├── database-metadata.ts │ └── table-metadata.ts │ ├── table-matcher.test.ts │ └── table-matcher.ts ├── tsconfig.build.json └── tsconfig.json /.cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "enableFiletypes": ["dockercompose"], 3 | "words": [ 4 | "Adminer", 5 | "attname", 6 | "attnum", 7 | "attrelid", 8 | "bacch", 9 | "bacchi", 10 | "Blomberg", 11 | "bpchar", 12 | "bytea", 13 | "codegen", 14 | "codegenrc", 15 | "conindid", 16 | "conrelid", 17 | "contype", 18 | "datetime", 19 | "datetimeoffset", 20 | "deunionize", 21 | "dockercompose", 22 | "enumlabel", 23 | "enumtypid", 24 | "esbuild", 25 | "execa", 26 | "geomcollection", 27 | "geometrycollection", 28 | "indexrelid", 29 | "indkey", 30 | "indrelid", 31 | "inet", 32 | "inhrelid", 33 | "Insertable", 34 | "Introspector", 35 | "knip", 36 | "kysely", 37 | "libsql", 38 | "linestring", 39 | "longblob", 40 | "longtext", 41 | "lseg", 42 | "macaddr", 43 | "mediumblob", 44 | "mediumint", 45 | "mediumtext", 46 | "mssql", 47 | "multilinestring", 48 | "multipoint", 49 | "multipolygon", 50 | "mysqlx", 51 | "nocase", 52 | "nspname", 53 | "ntext", 54 | "nvarchar", 55 | "objoid", 56 | "plpgsql", 57 | "regclass", 58 | "regnamespace", 59 | "relkind", 60 | "relname", 61 | "relnamespace", 62 | "robinblomberg", 63 | "schemaname", 64 | "Singularizer", 65 | "smalldatetime", 66 | "smallmoney", 67 | "sqld", 68 | "tablename", 69 | "tediousjs", 70 | "timestamptz", 71 | "tinyblob", 72 | "tinyint", 73 | "tinytext", 74 | "tsquery", 75 | "tsvector", 76 | "txid", 77 | "typbasetype", 78 | "typname", 79 | "typnamespace", 80 | "typtype", 81 | "uniqueidentifier", 82 | "Updateable", 83 | "upvote", 84 | "varbinary", 85 | "varbit", 86 | "vitest" 87 | ] 88 | } 89 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: RobinBlomberg 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.git 2 | **/.coverage 3 | **/dist 4 | **/node_modules 5 | **/*.DS_Store 6 | **/*.tsbuildinfo 7 | **/.env 8 | -------------------------------------------------------------------------------- /.kysely-codegenrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "camelCase": true 3 | } 4 | -------------------------------------------------------------------------------- /.ncurc.json: -------------------------------------------------------------------------------- 1 | { 2 | "reject": ["chalk", "eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /.coverage 2 | /.git 3 | /.vscode 4 | /node_modules 5 | /src 6 | **/*.DS_Store 7 | **/*.spec.js 8 | **/*.spec.jsx 9 | **/*.spec.ts 10 | **/*.spec.tsx 11 | **/*.test.js 12 | **/*.test.jsx 13 | **/*.test.ts 14 | **/*.test.tsx 15 | **/*.tsbuildinfo 16 | .env 17 | .gitignore 18 | docker-compose.yml 19 | pnpm-lock.yaml 20 | tsconfig.json 21 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/src/**/*.snapshot.ts 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "explicit", 4 | "source.addMissingImports": "explicit", 5 | "source.organizeImports": "explicit" 6 | }, 7 | "editor.defaultFormatter": "esbenp.prettier-vscode", 8 | "editor.formatOnSave": true, 9 | "editor.insertSpaces": true, 10 | "editor.rulers": [100], 11 | "editor.tabSize": 2, 12 | "editor.trimAutoWhitespace": true, 13 | "editor.wordWrapColumn": 100, 14 | "eslint.format.enable": false, 15 | "files.encoding": "utf8", 16 | "files.eol": "\n", 17 | "files.insertFinalNewline": true, 18 | "files.trimTrailingWhitespace": true 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Robin Blomberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 6 | associated documentation files (the "Software"), to deal in the Software without restriction, 7 | including without limitation the rights to use, copy, modify, merge, publish, distribute, 8 | sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or substantial 12 | portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT 15 | NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 16 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES 17 | OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![kysely-codegen](./assets/kysely-codegen-logo.svg) 2 | 3 | `kysely-codegen` generates Kysely type definitions from your database. That's it. 4 | 5 | ## Table of contents 6 | 7 | - [Installation](#installation) 8 | - [Generating type definitions](#generating-type-definitions) 9 | - [Using the type definitions](#using-the-type-definitions) 10 | - [CLI arguments](#cli-arguments) 11 | - [Configuration file](#configuration-file) 12 | - [Issue funding](#issue-funding) 13 | 14 | ## Installation 15 | 16 | ```sh 17 | npm install --save-dev kysely-codegen 18 | ``` 19 | 20 | You will also need to install Kysely with your driver of choice: 21 | 22 | ```sh 23 | # PostgreSQL 24 | npm install kysely pg 25 | 26 | # MySQL 27 | npm install kysely mysql2 28 | 29 | # SQLite 30 | npm install kysely better-sqlite3 31 | 32 | # MSSQL 33 | npm install kysely tedious tarn @tediousjs/connection-string 34 | 35 | # LibSQL 36 | npm install @libsql/kysely-libsql 37 | ``` 38 | 39 | ## Generating type definitions 40 | 41 | The most convenient way to get started is to create an `.env` file with your database connection string: 42 | 43 | ```sh 44 | # PostgreSQL 45 | DATABASE_URL=postgres://username:password@yourdomain.com/database 46 | 47 | # MySQL 48 | DATABASE_URL=mysql://username:password@yourdomain.com/database 49 | 50 | # SQLite 51 | DATABASE_URL=C:/Program Files/sqlite3/db 52 | 53 | # MSSQL 54 | DATABASE_URL=Server=mssql;Database=database;User Id=user;Password=password 55 | 56 | # LibSQL 57 | DATABASE_URL=libsql://token@host:port/database 58 | ``` 59 | 60 | > If your URL contains a password with special characters, those characters may need to be [percent-encoded](https://en.wikipedia.org/wiki/Percent-encoding#Reserved_characters). 61 | > 62 | > If you are using _PlanetScale_, make sure your URL contains this SSL query string parameter: `ssl={"rejectUnauthorized":true}` 63 | 64 | Then run the following command, or add it to the scripts section in your package.json file: 65 | 66 | ```sh 67 | kysely-codegen 68 | ``` 69 | 70 | This command will generate a `.d.ts` file from your database, for example: 71 | 72 | 73 | ```ts 74 | import { ColumnType } from 'kysely'; 75 | 76 | export type Generated = T extends ColumnType 77 | ? ColumnType 78 | : ColumnType; 79 | 80 | export type Timestamp = ColumnType; 81 | 82 | export interface Company { 83 | id: Generated; 84 | name: string; 85 | } 86 | 87 | export interface User { 88 | company_id: number | null; 89 | created_at: Generated; 90 | email: string; 91 | id: Generated; 92 | is_active: boolean; 93 | name: string; 94 | updated_at: Timestamp; 95 | } 96 | 97 | export interface DB { 98 | company: Company; 99 | user: User; 100 | } 101 | ``` 102 | 103 | To specify a different output file: 104 | 105 | ```sh 106 | kysely-codegen --out-file ./src/db/db.d.ts 107 | ``` 108 | 109 | ## Using the type definitions 110 | 111 | Import `DB` into `new Kysely`, and you're done! 112 | 113 | ```ts 114 | import { Kysely, PostgresDialect } from 'kysely'; 115 | import { DB } from 'kysely-codegen'; 116 | import { Pool } from 'pg'; 117 | 118 | const db = new Kysely({ 119 | dialect: new PostgresDialect({ 120 | pool: new Pool({ 121 | connectionString: process.env.DATABASE_URL, 122 | }), 123 | }), 124 | }); 125 | 126 | const rows = await db.selectFrom('users').selectAll().execute(); 127 | // ^ { created_at: Date; email: string; id: number; ... }[] 128 | ``` 129 | 130 | If you need to use the generated types in e.g. function parameters and type definitions, you may need to use the Kysely `Insertable`, `Selectable`, `Updateable` types. Note that you don't need to explicitly annotate query return values, as it's recommended to let Kysely infer the types for you. 131 | 132 | ```ts 133 | import { Insertable, Updateable } from 'kysely'; 134 | import { DB } from 'kysely-codegen'; 135 | import { db } from './db'; 136 | 137 | async function insertUser(user: Insertable) { 138 | return await db 139 | .insertInto('users') 140 | .values(user) 141 | .returningAll() 142 | .executeTakeFirstOrThrow(); 143 | // ^ Selectable 144 | } 145 | 146 | async function updateUser(id: number, user: Updateable) { 147 | return await db 148 | .updateTable('users') 149 | .set(user) 150 | .where('id', '=', id) 151 | .returning(['email', 'id']) 152 | .executeTakeFirstOrThrow(); 153 | // ^ { email: string; id: number; } 154 | } 155 | ``` 156 | 157 | Read the [Kysely documentation](https://kysely.dev/docs/getting-started) for more information. 158 | 159 | ## CLI arguments 160 | 161 | #### --camel-case 162 | 163 | Use the Kysely CamelCasePlugin for generated table column names. 164 | 165 | **Example:** 166 | 167 | ```ts 168 | export interface User { 169 | companyId: number | null; 170 | createdAt: Generated; 171 | email: string; 172 | id: Generated; 173 | isActive: boolean; 174 | name: string; 175 | updatedAt: Timestamp; 176 | } 177 | ``` 178 | 179 | #### --date-parser 180 | 181 | Specify which parser to use for PostgreSQL date values. (values: `string`/`timestamp`, default: `timestamp`) 182 | 183 | #### --default-schema [value] 184 | 185 | Set the default schema(s) for the database connection. 186 | 187 | Multiple schemas can be specified: 188 | 189 | ```sh 190 | kysely-codegen --default-schema=public --default-schema=hidden 191 | ``` 192 | 193 | #### --dialect [value] 194 | 195 | Set the SQL dialect. (values: `postgres`/`mysql`/`sqlite`/`mssql`/`libsql`/`bun-sqlite`/`kysely-bun-sqlite`/`worker-bun-sqlite`) 196 | 197 | #### --env-file [value] 198 | 199 | Specify the path to an environment file to use. 200 | 201 | #### --help, -h 202 | 203 | Print all command line options. 204 | 205 | #### --include-pattern [value], --exclude-pattern [value] 206 | 207 | You can choose which tables should be included during code generation by providing a glob pattern to the `--include-pattern` and `--exclude-pattern` flags. We use [micromatch](https://github.com/micromatch/micromatch) under the hood, which provides advanced glob support. For instance, if you only want to include your public tables: 208 | 209 | ```sh 210 | kysely-codegen --include-pattern="public.*" 211 | ``` 212 | 213 | You can also include only certain tables within a schema: 214 | 215 | ```sh 216 | kysely-codegen --include-pattern="public.+(user|post)" 217 | ``` 218 | 219 | Or exclude an entire class of tables: 220 | 221 | ```sh 222 | kysely-codegen --exclude-pattern="documents.*" 223 | ``` 224 | 225 | #### --log-level [value] 226 | 227 | Set the terminal log level. (values: `debug`/`info`/`warn`/`error`/`silent`, default: `warn`) 228 | 229 | #### --no-domains 230 | 231 | Skip generating types for PostgreSQL domains. (default: `false`) 232 | 233 | #### --numeric-parser 234 | 235 | Specify which parser to use for PostgreSQL numeric values. (values: `string`/`number`/`number-or-string`, default: `string`) 236 | 237 | #### --overrides 238 | 239 | Specify type overrides for specific table columns in JSON format. 240 | 241 | **Example:** 242 | 243 | ```sh 244 | kysely-codegen --overrides='{"columns":{"table_name.column_name":"{foo:\"bar\"}"}}' 245 | ``` 246 | 247 | #### --out-file [value] 248 | 249 | Set the file build path. (default: `./node_modules/kysely-codegen/dist/db.d.ts`) 250 | 251 | #### --partitions 252 | 253 | Include partitions of PostgreSQL tables in the generated code. 254 | 255 | #### --print 256 | 257 | Print the generated output to the terminal instead of a file. 258 | 259 | #### --runtime-enums 260 | 261 | The PostgreSQL `--runtime-enums` option generates runtime enums instead of string unions. You can optionally specify which naming convention to use for runtime enum keys. (values: [`pascal-case`, `screaming-snake-case`], default: `screaming-snake-case`) 262 | 263 | **Examples:** 264 | 265 | `--runtime-enums=false` 266 | 267 | ```ts 268 | export type Status = 'CONFIRMED' | 'UNCONFIRMED'; 269 | ``` 270 | 271 | `--runtime-enums` or `--runtime-enums=screaming-snake-case` 272 | 273 | ```ts 274 | export enum Status { 275 | CONFIRMED = 'CONFIRMED', 276 | UNCONFIRMED = 'UNCONFIRMED', 277 | } 278 | ``` 279 | 280 | `--runtime-enums=pascal-case` 281 | 282 | ```ts 283 | export enum Status { 284 | Confirmed = 'CONFIRMED', 285 | Unconfirmed = 'UNCONFIRMED', 286 | } 287 | ``` 288 | 289 | #### --singularize 290 | 291 | Singularize generated type aliases, e.g. as `BlogPost` instead of `BlogPosts`. The codegen uses the [pluralize](https://www.npmjs.com/package/pluralize) package for singularization. 292 | 293 | You can specify custom singularization rules in the [configuration file](#configuration-file). 294 | 295 | #### --type-only-imports 296 | 297 | Generate code using the TypeScript 3.8+ `import type` syntax. (default: `true`) 298 | 299 | #### --url [value] 300 | 301 | Set the database connection string URL. This may point to an environment variable. (default: `env(DATABASE_URL)`) 302 | 303 | #### --verify 304 | 305 | Verify that the generated types are up-to-date. (default: `false`) 306 | 307 | ## Configuration file 308 | 309 | All codegen options can also be configured in a `.kysely-codegenrc.json` (or `.js`, `.ts`, `.yaml` etc.) file or the `kysely-codegen` property in `package.json`. See [Cosmiconfig](https://github.com/cosmiconfig/cosmiconfig) for all available configuration file formats. 310 | 311 | The default configuration: 312 | 313 | ```json 314 | { 315 | "camelCase": false, 316 | "dateParser": "timestamp", 317 | "defaultSchemas": [], // ["public"] for PostgreSQL. 318 | "dialect": null, 319 | "domains": true, 320 | "envFile": null, 321 | "excludePattern": null, 322 | "includePattern": null, 323 | "logLevel": "warn", 324 | "numericParser": "string", 325 | "outFile": "./node_modules/kysely-codegen/dist/db.d.ts", 326 | "overrides": {}, 327 | "partitions": false, 328 | "print": false, 329 | "runtimeEnums": false, 330 | "singularize": false, 331 | "typeOnlyImports": true, 332 | "url": "env(DATABASE_URL)", 333 | "verify": false 334 | } 335 | ``` 336 | 337 | The configuration object adds support for more advanced options: 338 | 339 | ```json 340 | { 341 | "camelCase": true, 342 | "overrides": { 343 | "columns": { 344 | "users.settings": "{ theme: 'dark' }" 345 | } 346 | }, 347 | "singularize": { 348 | "/^(.*?)s?$/": "$1_model", 349 | "/(bacch)(?:us|i)$/i": "$1us" 350 | } 351 | } 352 | ``` 353 | 354 | The generated output: 355 | 356 | ```ts 357 | export interface UserModel { 358 | settings: { theme: 'dark' }; 359 | } 360 | 361 | // ... 362 | 363 | export interface DB { 364 | bacchi: Bacchus; 365 | users: UserModel; 366 | } 367 | ``` 368 | 369 | ## Issue funding 370 | 371 | We use [Polar.sh](https://polar.sh/RobinBlomberg) to upvote and promote specific features that you would like to see implemented. Check the backlog and help out: 372 | 373 | 374 | -------------------------------------------------------------------------------- /assets/kysely-codegen-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | kysely_codegen_adminer: 5 | container_name: kysely_codegen_adminer 6 | image: adminer 7 | ports: 8 | - 8081:8080 9 | restart: always 10 | kysely_codegen_libsql: 11 | container_name: kysely_codegen_libsql 12 | image: ghcr.io/libsql/sqld:latest 13 | ports: 14 | - 8080:8080 15 | restart: always 16 | kysely_codegen_mysql: 17 | container_name: kysely_codegen_mysql 18 | environment: 19 | - MYSQL_DATABASE=database 20 | - MYSQL_PASSWORD=password 21 | - MYSQL_ROOT_PASSWORD=password 22 | - MYSQL_USER=user 23 | image: mysql:latest 24 | ports: 25 | - 3306:3306 26 | restart: always 27 | kysely_codegen_postgres: 28 | container_name: kysely_codegen_postgres 29 | environment: 30 | - POSTGRES_DB=database 31 | - POSTGRES_PASSWORD=password 32 | - POSTGRES_USER=user 33 | image: postgres:latest 34 | ports: 35 | - 5433:5432 36 | restart: always 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kysely-codegen", 3 | "version": "0.18.5", 4 | "author": "Robin Blomberg", 5 | "license": "MIT", 6 | "main": "./dist/index.js", 7 | "types": "./dist/index.d.ts", 8 | "bin": { 9 | "kysely-codegen": "./dist/cli/bin.js" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/RobinBlomberg/kysely-codegen.git" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/RobinBlomberg/kysely-codegen/issues" 17 | }, 18 | "homepage": "https://github.com/RobinBlomberg/kysely-codegen#readme", 19 | "scripts": { 20 | "build": "rimraf dist && tsc --project ./tsconfig.build.json", 21 | "check:types": "tsc --noEmit", 22 | "ci": "run-s ci:*", 23 | "ci:build": "pnpm build", 24 | "ci:circular-imports": "madge -c ./src/index.ts", 25 | "ci:eslint": "pnpm lint:eslint --max-warnings=0 --report-unused-disable-directives", 26 | "ci:prettier": "pnpm lint:prettier", 27 | "ci:spelling": "cspell-cli -e dist -e pnpm-lock.yaml -e '*.svg' .", 28 | "ci:test": "pnpm test", 29 | "ci:unused": "knip", 30 | "dev": "tsx watch ./src/cli/bin.ts", 31 | "docker:up": "docker-compose up -d", 32 | "fix": "run-s fix:*", 33 | "fix:eslint": "eslint --fix src", 34 | "fix:prettier": "prettier --write src", 35 | "lint": "run-p lint:*", 36 | "lint:eslint": "eslint src", 37 | "lint:prettier": "prettier --check src", 38 | "prepublishOnly": "pnpm run ci", 39 | "start": "pnpm build && DATABASE_URL=postgres://user:password@localhost:5433/database node ./dist/cli/bin.js", 40 | "test": "vitest run --globals --fileParallelism=false --maxConcurrency=1 --maxWorkers=1 --sequence.seed=1", 41 | "test:watch": "vitest watch --globals --fileParallelism=false --maxConcurrency=1 --maxWorkers=1 --sequence.seed=1", 42 | "upgrade": "ncu -u" 43 | }, 44 | "dependencies": { 45 | "chalk": "4.1.2", 46 | "cosmiconfig": "^9.0.0", 47 | "dotenv": "^16.5.0", 48 | "dotenv-expand": "^12.0.2", 49 | "git-diff": "^2.0.6", 50 | "micromatch": "^4.0.8", 51 | "minimist": "^1.2.8", 52 | "pluralize": "^8.0.0", 53 | "zod": "^3.24.4" 54 | }, 55 | "devDependencies": { 56 | "@libsql/kysely-libsql": "^0.4.1", 57 | "@robinblomberg/eslint-config-prettier": "^0.1.4", 58 | "@robinblomberg/eslint-config-robinblomberg": "0.28.0", 59 | "@robinblomberg/prettier-config": "^0.2.0", 60 | "@types/better-sqlite3": "^7.6.13", 61 | "@types/bun": "^1.2.13", 62 | "@types/git-diff": "^2.0.7", 63 | "@types/micromatch": "^4.0.9", 64 | "@types/minimist": "^1.2.5", 65 | "@types/node": "^22.15.17", 66 | "@types/pg": "^8.15.1", 67 | "@types/pluralize": "^0.0.33", 68 | "better-sqlite3": "^11.10.0", 69 | "cspell-cli": "^9.0.1", 70 | "eslint": "^8.57.0", 71 | "eslint-plugin-import": "^2.31.0", 72 | "execa": "^9.5.3", 73 | "knip": "^5.55.1", 74 | "kysely": "^0.28.2", 75 | "madge": "^8.0.0", 76 | "mysql2": "^3.14.1", 77 | "npm-run-all": "^4.1.5", 78 | "pg": "^8.15.6", 79 | "postgres-interval": "^4.0.2", 80 | "prettier": "^3.5.3", 81 | "rimraf": "^6.0.1", 82 | "tedious": "^19.0.0", 83 | "ts-dedent": "^2.2.0", 84 | "tsx": "^4.19.4", 85 | "typescript": "^5.8.3", 86 | "vitest": "^3.1.3" 87 | }, 88 | "peerDependencies": { 89 | "@libsql/kysely-libsql": ">=0.3.0 <0.5.0", 90 | "@tediousjs/connection-string": ">=0.5.0 <0.6.0", 91 | "better-sqlite3": ">=7.6.2 <8.0.0", 92 | "kysely": ">=0.27.0 <1.0.0", 93 | "kysely-bun-sqlite": ">=0.3.2 <1.0.0", 94 | "kysely-bun-worker": ">=1.2.0 <2.0.0", 95 | "mysql2": ">=2.3.3 <4.0.0", 96 | "pg": ">=8.8.0 <9.0.0", 97 | "tarn": ">=3.0.0 <4.0.0", 98 | "tedious": ">=18.0.0 <20.0.0" 99 | }, 100 | "peerDependenciesMeta": { 101 | "@libsql/kysely-libsql": { 102 | "optional": true 103 | }, 104 | "@tediousjs/connection-string": { 105 | "optional": true 106 | }, 107 | "better-sqlite3": { 108 | "optional": true 109 | }, 110 | "kysely": { 111 | "optional": false 112 | }, 113 | "kysely-bun-sqlite": { 114 | "optional": true 115 | }, 116 | "kysely-bun-worker": { 117 | "optional": true 118 | }, 119 | "mysql2": { 120 | "optional": true 121 | }, 122 | "pg": { 123 | "optional": true 124 | }, 125 | "tarn": { 126 | "optional": true 127 | }, 128 | "tedious": { 129 | "optional": true 130 | } 131 | }, 132 | "engines": { 133 | "node": ">=20.0.0" 134 | }, 135 | "packageManager": "pnpm@10.9.0+sha512.0486e394640d3c1fb3c9d43d49cf92879ff74f8516959c235308f5a8f62e2e19528a65cdc2a3058f587cde71eba3d5b56327c8c33a97e4c4051ca48a10ca2d5f", 136 | "eslintConfig": { 137 | "extends": [ 138 | "@robinblomberg/robinblomberg", 139 | "@robinblomberg/prettier" 140 | ], 141 | "ignorePatterns": "**/*.snapshot.ts", 142 | "rules": { 143 | "@typescript-eslint/consistent-type-imports": [ 144 | 1, 145 | { 146 | "disallowTypeAnnotations": false, 147 | "fixStyle": "separate-type-imports", 148 | "prefer": "type-imports" 149 | } 150 | ], 151 | "unicorn/no-typeof-undefined": [ 152 | 1, 153 | { 154 | "checkGlobalVariables": false 155 | } 156 | ], 157 | "unicorn/prefer-node-protocol": 0 158 | } 159 | }, 160 | "knip": { 161 | "ignore": [ 162 | "src/cli/test/config-with-custom-serializer.ts", 163 | "src/cli/test/config.cjs", 164 | "src/db.ts", 165 | "**/*.snapshot.ts" 166 | ], 167 | "ignoreBinaries": [ 168 | "docker-compose", 169 | "ncu" 170 | ], 171 | "ignoreDependencies": [ 172 | "@libsql/kysely-libsql", 173 | "@tediousjs/connection-string", 174 | "better-sqlite3", 175 | "eslint-plugin-import", 176 | "kysely-bun-sqlite", 177 | "mysql2", 178 | "pg", 179 | "tarn", 180 | "tedious" 181 | ] 182 | }, 183 | "madge": { 184 | "detectiveOptions": { 185 | "ts": { 186 | "skipTypeImports": true 187 | } 188 | } 189 | }, 190 | "prettier": "@robinblomberg/prettier-config" 191 | } 192 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - better-sqlite3 3 | - esbuild 4 | -------------------------------------------------------------------------------- /src/cli/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Cli } from './cli'; 3 | 4 | void new Cli().run({ argv: process.argv.slice(2) }).then(() => process.exit(0)); 5 | -------------------------------------------------------------------------------- /src/cli/cli.test.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'cosmiconfig'; 2 | import { execa, ExecaError } from 'execa'; 3 | import { Kysely, PostgresDialect, sql } from 'kysely'; 4 | import { deepStrictEqual } from 'node:assert'; 5 | import fs from 'node:fs/promises'; 6 | import { join } from 'node:path'; 7 | import { Pool } from 'pg'; 8 | import { dedent } from 'ts-dedent'; 9 | import packageJson from '../../package.json'; 10 | import { Cli } from './cli'; 11 | import { ConfigError } from './config-error'; 12 | 13 | const BINARY_PATH = join(process.cwd(), packageJson.bin['kysely-codegen']); 14 | const OUTPUT_PATH = join(__dirname, 'test', 'output.snapshot.ts'); 15 | 16 | const OUTPUT = dedent` 17 | /** 18 | * This file was generated by kysely-codegen. 19 | * Please do not edit it manually. 20 | */ 21 | 22 | import { ColumnType } from "kysely"; 23 | 24 | export enum Status { 25 | Confirmed = "CONFIRMED", 26 | Unconfirmed = "UNCONFIRMED", 27 | } 28 | 29 | export type Generated = T extends ColumnType 30 | ? ColumnType 31 | : ColumnType; 32 | 33 | export interface Bacchus { 34 | bacchusId: Generated; 35 | status: Status | null; 36 | } 37 | 38 | export interface DB { 39 | bacchi: Bacchus; 40 | } 41 | 42 | `; 43 | 44 | const down = async (db: Kysely) => { 45 | await db.schema.dropSchema('cli').cascade().execute(); 46 | }; 47 | 48 | const up = async () => { 49 | const db = new Kysely({ 50 | dialect: new PostgresDialect({ 51 | pool: new Pool({ 52 | connectionString: 'postgres://user:password@localhost:5433/database', 53 | }), 54 | }), 55 | }); 56 | 57 | await db.schema.dropSchema('cli').ifExists().cascade().execute(); 58 | await db.schema.createSchema('cli').execute(); 59 | await db.schema 60 | .withSchema('cli') 61 | .createType('status') 62 | .asEnum(['CONFIRMED', 'UNCONFIRMED']) 63 | .execute(); 64 | await db.schema 65 | .createTable('cli.bacchi') 66 | .addColumn('status', sql`cli.status`) 67 | .addColumn('bacchus_id', 'serial', (col) => col.primaryKey()) 68 | .execute(); 69 | 70 | return db; 71 | }; 72 | 73 | describe(Cli.name, () => { 74 | beforeAll(async () => { 75 | await execa`pnpm build`; 76 | }); 77 | 78 | it('should be able to start the CLI', async () => { 79 | const output = await execa`node ${BINARY_PATH} --help`.then( 80 | (r) => r.stdout, 81 | ); 82 | deepStrictEqual(output.includes('--help, -h'), true); 83 | }); 84 | 85 | it('should be able to run the CLI programmatically with a custom config object', async () => { 86 | const db = await up(); 87 | 88 | const output = await new Cli().run({ 89 | argv: ['--camel-case'], 90 | config: { 91 | camelCase: false, 92 | defaultSchemas: ['cli'], 93 | dialect: 'postgres', 94 | includePattern: 'cli.*', 95 | logLevel: 'silent', 96 | outFile: null, 97 | runtimeEnums: 'pascal-case', 98 | singularize: { '/(bacch)(?:us|i)$/i': '$1us' }, 99 | typeOnlyImports: false, 100 | url: 'postgres://user:password@localhost:5433/database', 101 | }, 102 | }); 103 | 104 | expect(output).toStrictEqual( 105 | dedent` 106 | /** 107 | * This file was generated by kysely-codegen. 108 | * Please do not edit it manually. 109 | */ 110 | 111 | import { ColumnType } from "kysely"; 112 | 113 | export enum Status { 114 | Confirmed = "CONFIRMED", 115 | Unconfirmed = "UNCONFIRMED", 116 | } 117 | 118 | export type Generated = T extends ColumnType 119 | ? ColumnType 120 | : ColumnType; 121 | 122 | export interface Bacchus { 123 | bacchusId: Generated; 124 | status: Status | null; 125 | } 126 | 127 | export interface DB { 128 | bacchi: Bacchus; 129 | } 130 | 131 | `, 132 | ); 133 | 134 | await down(db); 135 | }); 136 | 137 | it('should be able to supply a custom serializer to the config', async () => { 138 | const db = await up(); 139 | 140 | const output = await new Cli().run({ 141 | argv: [ 142 | '--config-file', 143 | './src/cli/test/config-with-custom-serializer.ts', 144 | ], 145 | }); 146 | 147 | expect(output).toStrictEqual( 148 | dedent` 149 | table bacchi { 150 | status: status 151 | bacchus_id: int4 152 | } 153 | 154 | table enum { 155 | name: text 156 | } 157 | 158 | table foo_bar { 159 | false: bool 160 | true: bool 161 | overridden: text 162 | id: int4 163 | date: date 164 | user_status: status 165 | user_status_2: status 166 | array: text 167 | nullable_pos_int: int4 168 | defaulted_nullable_pos_int: int4 169 | defaulted_required_pos_int: int4 170 | child_domain: int4 171 | test_domain_is_bool: bool 172 | timestamps: timestamptz 173 | interval1: interval 174 | interval2: interval 175 | json: json 176 | json_typed: json 177 | numeric1: numeric 178 | numeric2: numeric 179 | enum: text 180 | } 181 | 182 | table partitioned_table { 183 | id: int4 184 | } 185 | `, 186 | ); 187 | 188 | await down(db); 189 | }); 190 | 191 | it('should be able to run the CLI successfully using a config file', async () => { 192 | const db = await up(); 193 | await fs.writeFile(OUTPUT_PATH, OUTPUT); 194 | await execa`node ${BINARY_PATH} --config-file ./src/cli/test/config.cjs --out-file ${OUTPUT_PATH} --verify`; 195 | await down(db); 196 | }); 197 | 198 | it('should return an exit code of 1 if the generated types are not up-to-date', async () => { 199 | const db = await up(); 200 | const incorrectOutput = OUTPUT.replace('"CONFIRMED"', '"INVALID"'); 201 | await fs.writeFile(OUTPUT_PATH, incorrectOutput); 202 | let error: ExecaError | undefined; 203 | 204 | try { 205 | await execa`node ${BINARY_PATH} --config-file ./src/cli/test/config.cjs --out-file ${OUTPUT_PATH} --verify`; 206 | } catch (caughtError) { 207 | if (caughtError instanceof ExecaError) { 208 | error = caughtError; 209 | } 210 | } 211 | 212 | expect(error?.exitCode).toBe(1); 213 | expect(error?.stderr).toContain( 214 | "Generated types are not up-to-date! Use '--log-level=error' option to view the diff.", 215 | ); 216 | 217 | await down(db); 218 | await fs.writeFile(OUTPUT_PATH, OUTPUT); 219 | }); 220 | 221 | it('should parse options correctly', () => { 222 | const assert = (args: string[], expectedOptions: Partial) => { 223 | const cliOptions = new Cli().parseOptions(args, { silent: true }); 224 | deepStrictEqual(cliOptions, { camelCase: true, ...expectedOptions }); 225 | }; 226 | 227 | assert(['--camel-case'], { camelCase: true }); 228 | assert(['--date-parser=timestamp'], { dateParser: 'timestamp' }); 229 | assert(['--date-parser=string'], { dateParser: 'string' }); 230 | assert(['--default-schema=foo'], { defaultSchemas: ['foo'] }); 231 | assert(['--default-schema=foo', '--default-schema=bar'], { 232 | defaultSchemas: ['foo', 'bar'], 233 | }); 234 | assert(['--dialect=mysql'], { dialect: 'mysql' }); 235 | assert(['--domains'], { domains: true }); 236 | assert(['--exclude-pattern=public._*'], { excludePattern: 'public._*' }); 237 | assert(['--help'], {}); 238 | assert(['-h'], {}); 239 | assert(['--include-pattern=public._*'], { includePattern: 'public._*' }); 240 | assert(['--log-level=debug'], { logLevel: 'debug' }); 241 | assert(['--no-domains'], { domains: false }); 242 | assert(['--no-type-only-imports'], { typeOnlyImports: false }); 243 | assert(['--out-file=./db.ts'], { outFile: './db.ts' }); 244 | assert( 245 | [`--overrides={"columns":{"table.override":"{ foo: \\"bar\\" }"}}`], 246 | { overrides: { columns: { 'table.override': '{ foo: "bar" }' } } }, 247 | ); 248 | assert(['--print'], { print: true }); 249 | assert(['--singularize'], { singularize: true }); 250 | assert(['--type-only-imports'], { typeOnlyImports: true }); 251 | assert(['--type-only-imports=false'], { typeOnlyImports: false }); 252 | assert(['--type-only-imports=true'], { typeOnlyImports: true }); 253 | assert(['--url=postgres://u:p@s/d'], { url: 'postgres://u:p@s/d' }); 254 | assert(['--verify'], { verify: true }); 255 | assert(['--verify=false'], { verify: false }); 256 | assert(['--verify=true'], { verify: true }); 257 | }); 258 | 259 | it('should throw an error if a flag is deprecated', () => { 260 | expect(() => new Cli().parseOptions(['--schema'])).toThrow( 261 | new RangeError( 262 | "The flag 'schema' has been deprecated. Use 'default-schema' instead.", 263 | ), 264 | ); 265 | expect(() => new Cli().parseOptions(['--singular'])).toThrow( 266 | new RangeError( 267 | "The flag 'singular' has been deprecated. Use 'singularize' instead.", 268 | ), 269 | ); 270 | }); 271 | 272 | it('should throw an error if the config has an invalid schema', () => { 273 | const assert = ( 274 | config: any, 275 | message: string, 276 | path = [Object.keys(config)[0]!], 277 | ) => { 278 | expect(() => new Cli().parseOptions([], { config })).toThrow( 279 | new ConfigError({ message, path }), 280 | ); 281 | }; 282 | 283 | assert({ camelCase: 'true' }, 'Expected boolean, received string'); 284 | assert( 285 | { dateParser: 'timestamps' }, 286 | "Invalid enum value. Expected 'string' | 'timestamp', received 'timestamps'", 287 | ); 288 | assert({ defaultSchemas: 'public' }, 'Expected array, received string'); 289 | assert( 290 | { dialect: 'sqlite3' }, 291 | "Invalid enum value. Expected 'bun-sqlite' | 'kysely-bun-sqlite' | 'libsql' | 'mssql' | 'mysql' | 'postgres' | 'sqlite' | 'worker-bun-sqlite', received 'sqlite3'", 292 | ); 293 | assert({ domains: 'true' }, 'Expected boolean, received string'); 294 | assert({ envFile: null }, 'Expected string, received null'); 295 | assert({ excludePattern: false }, 'Expected string, received boolean'); 296 | assert({ includePattern: false }, 'Expected string, received boolean'); 297 | assert( 298 | { logLevel: 0 }, 299 | "Expected 'silent' | 'error' | 'warn' | 'info' | 'debug', received number", 300 | ); 301 | assert( 302 | { numericParser: 'numbers' }, 303 | "Invalid enum value. Expected 'number' | 'number-or-string' | 'string', received 'numbers'", 304 | ); 305 | assert({ outFile: false }, 'Expected string, received boolean'); 306 | assert({ overrides: { columns: [] } }, 'Expected object, received array', [ 307 | 'overrides', 308 | 'columns', 309 | ]); 310 | assert({ partitions: 'true' }, 'Expected boolean, received string'); 311 | assert({ print: 'true' }, 'Expected boolean, received string'); 312 | assert({ runtimeEnums: 'true' }, 'Invalid input'); 313 | assert({ singularize: 'true' }, 'Invalid input'); 314 | assert({ typeOnlyImports: 'true' }, 'Expected boolean, received string'); 315 | assert({ url: null }, 'Expected string, received null'); 316 | assert({ verify: 'true' }, 'Expected boolean, received string'); 317 | }); 318 | }); 319 | -------------------------------------------------------------------------------- /src/cli/cli.ts: -------------------------------------------------------------------------------- 1 | import { cosmiconfigSync } from 'cosmiconfig'; 2 | import minimist from 'minimist'; 3 | import { resolve } from 'node:path'; 4 | import type { RuntimeEnumsStyle } from '../generator'; 5 | import { getDialect } from '../generator'; 6 | import { ConnectionStringParser } from '../generator/connection-string-parser'; 7 | import { generate } from '../generator/generator/generate'; 8 | import { DEFAULT_LOG_LEVEL } from '../generator/logger/log-level'; 9 | import { Logger } from '../generator/logger/logger'; 10 | import type { DateParser } from '../introspector/dialects/postgres/date-parser'; 11 | import type { NumericParser } from '../introspector/dialects/postgres/numeric-parser'; 12 | import type { Config, DialectName } from './config'; 13 | import { configSchema, dialectNameSchema } from './config'; 14 | import { ConfigError } from './config-error'; 15 | import { DEFAULT_URL, VALID_DIALECTS } from './constants'; 16 | import { FLAGS, serializeFlags } from './flags'; 17 | 18 | const compact = >(object: T) => { 19 | return Object.fromEntries( 20 | Object.entries(object).filter(([, value]) => value !== undefined), 21 | ) as T; 22 | }; 23 | 24 | /** 25 | * Creates a kysely-codegen command-line interface. 26 | */ 27 | export class Cli { 28 | logLevel = DEFAULT_LOG_LEVEL; 29 | 30 | async generate(options: Config) { 31 | const connectionStringParser = new ConnectionStringParser(); 32 | const logger = options.logger ?? new Logger(options.logLevel); 33 | 34 | logger.debug('Options:'); 35 | logger.debug(options); 36 | logger.debug(); 37 | 38 | const { connectionString, dialect: dialectName } = 39 | connectionStringParser.parse({ 40 | connectionString: options.url ?? DEFAULT_URL, 41 | dialect: options.dialect, 42 | envFile: options.envFile, 43 | logger, 44 | }); 45 | 46 | if (options.dialect) { 47 | logger.info(`Using dialect '${options.dialect}'.`); 48 | } else { 49 | logger.info(`No dialect specified. Assuming '${dialectName}'.`); 50 | } 51 | 52 | const dialect = getDialect(dialectName, { 53 | dateParser: options.dateParser, 54 | domains: options.domains, 55 | numericParser: options.numericParser, 56 | partitions: options.partitions, 57 | }); 58 | 59 | const db = await dialect.introspector.connect({ 60 | connectionString, 61 | dialect, 62 | }); 63 | 64 | const output = await generate({ 65 | camelCase: options.camelCase, 66 | db, 67 | defaultSchemas: options.defaultSchemas, 68 | dialect, 69 | excludePattern: options.excludePattern, 70 | includePattern: options.includePattern, 71 | logger, 72 | outFile: options.outFile, 73 | overrides: options.overrides, 74 | partitions: options.partitions, 75 | print: options.print, 76 | runtimeEnums: options.runtimeEnums, 77 | serializer: options.serializer, 78 | singularize: options.singularize, 79 | typeOnlyImports: options.typeOnlyImports, 80 | verify: options.verify, 81 | }); 82 | 83 | await db.destroy(); 84 | 85 | return output; 86 | } 87 | 88 | #loadConfig(config?: { 89 | configFile?: string; 90 | }): { config: unknown; filepath: string } | null { 91 | const explorer = cosmiconfigSync('kysely-codegen'); 92 | return config?.configFile 93 | ? explorer.load(config.configFile) 94 | : explorer.search(); 95 | } 96 | 97 | #parseBoolean(input: any): boolean | undefined { 98 | if (input === undefined) return undefined; 99 | return !!input && input !== 'false'; 100 | } 101 | 102 | #parseDateParser(input: any): DateParser | undefined { 103 | if (input === undefined) return undefined; 104 | switch (input) { 105 | case 'string': 106 | case 'timestamp': 107 | return input; 108 | } 109 | } 110 | 111 | #parseDialectName(input: any): DialectName | undefined { 112 | const result = dialectNameSchema.safeParse(input); 113 | return result.success ? result.data : undefined; 114 | } 115 | 116 | #parseNumericParser(input: any): NumericParser | undefined { 117 | if (input === undefined) return undefined; 118 | switch (input) { 119 | case 'number': 120 | case 'number-or-string': 121 | case 'string': 122 | return input; 123 | } 124 | } 125 | 126 | #parseRuntimeEnums(input: any): RuntimeEnumsStyle | boolean | undefined { 127 | if (input === undefined) return undefined; 128 | switch (input) { 129 | case 'pascal-case': 130 | case 'screaming-snake-case': 131 | return input; 132 | default: 133 | return this.#parseBoolean(input); 134 | } 135 | } 136 | 137 | #parseString(input: any): string | undefined { 138 | if (input === undefined) return undefined; 139 | return String(input); 140 | } 141 | 142 | #parseStringArray(input: any): string[] | undefined { 143 | if (input === undefined) return undefined; 144 | if (!Array.isArray(input)) return [String(input)]; 145 | return input.map(String); 146 | } 147 | 148 | #showHelp() { 149 | console.info( 150 | ['', 'kysely-codegen [options]', '', serializeFlags(FLAGS), ''].join( 151 | '\n', 152 | ), 153 | ); 154 | process.exit(0); 155 | } 156 | 157 | parseOptions( 158 | args: string[], 159 | options?: { config?: Config; silent?: boolean }, 160 | ): Config { 161 | const argv = minimist(args); 162 | const logLevel = argv['log-level']; 163 | 164 | if (logLevel !== undefined) { 165 | this.logLevel = logLevel; 166 | } 167 | 168 | for (const key in argv) { 169 | if (key === 'schema') { 170 | throw new RangeError( 171 | `The flag '${key}' has been deprecated. Use 'default-schema' instead.`, 172 | ); 173 | } 174 | 175 | if (key === 'singular') { 176 | throw new RangeError( 177 | `The flag '${key}' has been deprecated. Use 'singularize' instead.`, 178 | ); 179 | } 180 | 181 | if ( 182 | key !== '_' && 183 | !FLAGS.some((flag) => { 184 | return [ 185 | flag.shortName, 186 | flag.longName, 187 | ...(flag.longName.startsWith('no-') 188 | ? [flag.longName.slice(3)] 189 | : []), 190 | ].includes(key); 191 | }) 192 | ) { 193 | throw new RangeError(`Invalid flag: '${key}'`); 194 | } 195 | } 196 | 197 | const _: string[] = argv._; 198 | const help = 199 | !!argv.h || !!argv.help || _.includes('-h') || _.includes('--help'); 200 | 201 | if (help && !options?.silent) { 202 | this.#showHelp(); 203 | process.exit(1); 204 | } 205 | 206 | const configFile = this.#parseString(argv['config-file']); 207 | const configResult = options?.config 208 | ? { config: options.config, filepath: null } 209 | : this.#loadConfig({ configFile }); 210 | const configParseResult = configResult 211 | ? configSchema.safeParse(configResult.config) 212 | : null; 213 | const configError = configParseResult?.error?.errors[0]; 214 | 215 | if (configError) { 216 | throw new ConfigError(configError); 217 | } 218 | 219 | const config = configParseResult?.data; 220 | const configOptions = config 221 | ? compact({ 222 | ...config, 223 | ...(configResult?.filepath && config.outFile 224 | ? { outFile: resolve(configResult.filepath, '..', config.outFile) } 225 | : {}), 226 | }) 227 | : {}; 228 | 229 | const cliOptions: Config = compact({ 230 | camelCase: this.#parseBoolean(argv['camel-case']), 231 | dateParser: this.#parseDateParser(argv['date-parser']), 232 | defaultSchemas: this.#parseStringArray(argv['default-schema']), 233 | dialect: this.#parseDialectName(argv.dialect), 234 | domains: this.#parseBoolean(argv.domains), 235 | envFile: this.#parseString(argv['env-file']), 236 | excludePattern: this.#parseString(argv['exclude-pattern']), 237 | includePattern: this.#parseString(argv['include-pattern']), 238 | logLevel, 239 | numericParser: this.#parseNumericParser(argv['numeric-parser']), 240 | outFile: this.#parseString(argv['out-file']), 241 | overrides: 242 | typeof argv.overrides === 'string' 243 | ? JSON.parse(argv.overrides) 244 | : undefined, 245 | partitions: this.#parseBoolean(argv.partitions), 246 | print: this.#parseBoolean(argv.print), 247 | runtimeEnums: this.#parseRuntimeEnums(argv['runtime-enums']), 248 | singularize: this.#parseBoolean(argv.singularize), 249 | typeOnlyImports: this.#parseBoolean(argv['type-only-imports']), 250 | url: this.#parseString(argv.url), 251 | verify: this.#parseBoolean(argv.verify), 252 | }); 253 | 254 | const print = cliOptions.print ?? configOptions.print; 255 | const outFile = print 256 | ? undefined 257 | : (cliOptions.outFile ?? configOptions.outFile); 258 | 259 | const generateOptions: Config = { 260 | ...configOptions, 261 | ...cliOptions, 262 | ...(logLevel === undefined ? {} : { logLevel }), 263 | ...(outFile === undefined ? {} : { outFile }), 264 | }; 265 | 266 | if ( 267 | generateOptions.dialect && 268 | !VALID_DIALECTS.includes(generateOptions.dialect) 269 | ) { 270 | const dialectValues = VALID_DIALECTS.join(', '); 271 | throw new RangeError( 272 | `Parameter '--dialect' must have one of the following values: ${dialectValues}`, 273 | ); 274 | } 275 | 276 | return generateOptions; 277 | } 278 | 279 | async run(options?: { argv?: string[]; config?: Config }) { 280 | const generateOptions = this.parseOptions(options?.argv ?? [], { 281 | config: options?.config, 282 | }); 283 | return await this.generate(generateOptions); 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/cli/config-error.ts: -------------------------------------------------------------------------------- 1 | export class ConfigError extends TypeError { 2 | constructor(error: { message: string; path: (number | string)[] }) { 3 | super(`Invalid value for option "${error.path}": ${error.message}`); 4 | this.name = ConfigError.name; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/cli/config.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import type { 3 | LogLevel, 4 | Overrides, 5 | RuntimeEnumsStyle, 6 | Serializer, 7 | } from '../generator'; 8 | import { 9 | ArrayExpressionNode, 10 | ExtendsClauseNode, 11 | GenericExpressionNode, 12 | IdentifierNode, 13 | InferClauseNode, 14 | LiteralNode, 15 | LOG_LEVELS, 16 | Logger, 17 | MappedTypeNode, 18 | ObjectExpressionNode, 19 | RawExpressionNode, 20 | UnionExpressionNode, 21 | } from '../generator'; 22 | import type { DateParser, NumericParser } from '../introspector'; 23 | import { DatabaseMetadata, IntrospectorDialect } from '../introspector'; 24 | 25 | export type Config = { 26 | camelCase?: boolean; 27 | dateParser?: DateParser; 28 | defaultSchemas?: string[]; 29 | dialect?: DialectName; 30 | domains?: boolean; 31 | envFile?: string; 32 | excludePattern?: string | null; 33 | includePattern?: string | null; 34 | logger?: Logger; 35 | logLevel?: LogLevel; 36 | numericParser?: NumericParser; 37 | outFile?: string | null; 38 | overrides?: Overrides; 39 | partitions?: boolean; 40 | print?: boolean; 41 | runtimeEnums?: boolean | RuntimeEnumsStyle; 42 | serializer?: Serializer; 43 | singularize?: boolean | Record; 44 | skipAutogeneratedFileComment?: boolean; 45 | typeOnlyImports?: boolean; 46 | url?: string; 47 | verify?: boolean; 48 | }; 49 | 50 | export type DialectName = z.infer; 51 | 52 | export const dialectNameSchema = z.enum([ 53 | 'bun-sqlite', 54 | 'kysely-bun-sqlite', 55 | 'libsql', 56 | 'mssql', 57 | 'mysql', 58 | 'postgres', 59 | 'sqlite', 60 | 'worker-bun-sqlite', 61 | ]); 62 | 63 | const expressionNodeSchema = z.union([ 64 | z.instanceof(ArrayExpressionNode), 65 | z.instanceof(ExtendsClauseNode), 66 | z.instanceof(GenericExpressionNode), 67 | z.instanceof(IdentifierNode), 68 | z.instanceof(InferClauseNode), 69 | z.instanceof(LiteralNode), 70 | z.instanceof(MappedTypeNode), 71 | z.instanceof(ObjectExpressionNode), 72 | z.instanceof(RawExpressionNode), 73 | z.instanceof(UnionExpressionNode), 74 | z.string(), 75 | ]); 76 | 77 | const overridesSchema = z 78 | .object({ columns: z.record(z.string(), expressionNodeSchema).optional() }) 79 | .optional(); 80 | 81 | export const configSchema = z.object({ 82 | camelCase: z.boolean().optional(), 83 | dateParser: z 84 | .enum(['string', 'timestamp']) 85 | .optional(), 86 | defaultSchemas: z.array(z.string()).optional(), 87 | dialect: dialectNameSchema.optional(), 88 | domains: z.boolean().optional(), 89 | envFile: z.string().optional(), 90 | excludePattern: z.string().nullable().optional(), 91 | includePattern: z.string().nullable().optional(), 92 | logger: z.instanceof(Logger).optional(), 93 | logLevel: z.enum(LOG_LEVELS).optional(), 94 | numericParser: z 95 | .enum< 96 | NumericParser, 97 | ['number', 'number-or-string', 'string'] 98 | >(['number', 'number-or-string', 'string']) 99 | .optional(), 100 | outFile: z.string().nullable().optional(), 101 | overrides: overridesSchema.optional(), 102 | partitions: z.boolean().optional(), 103 | print: z.boolean().optional(), 104 | runtimeEnums: z 105 | .union([ 106 | z.boolean(), 107 | z.enum([ 108 | 'pascal-case', 109 | 'screaming-snake-case', 110 | ]), 111 | ]) 112 | .optional(), 113 | serializer: z 114 | .object({ 115 | serializeFile: z.function( 116 | z.tuple([ 117 | z.instanceof(DatabaseMetadata), 118 | z.instanceof(IntrospectorDialect), 119 | z 120 | .object({ 121 | camelCase: z.boolean().optional(), 122 | defaultSchemas: z.string().array().optional(), 123 | overrides: overridesSchema.optional(), 124 | }) 125 | .optional(), 126 | ]), 127 | z.string(), 128 | ), 129 | }) 130 | .optional(), 131 | singularize: z 132 | .union([z.boolean(), z.record(z.string(), z.string())]) 133 | .optional(), 134 | skipAutogeneratedFileComment: z.boolean().optional(), 135 | typeOnlyImports: z.boolean().optional(), 136 | url: z.string().optional(), 137 | verify: z.boolean().optional(), 138 | }); 139 | -------------------------------------------------------------------------------- /src/cli/constants.ts: -------------------------------------------------------------------------------- 1 | import type { RuntimeEnumsStyle } from '../generator/generator/runtime-enums-style'; 2 | 3 | export const DEFAULT_RUNTIME_ENUMS_STYLE: RuntimeEnumsStyle = 4 | 'screaming-snake-case'; 5 | 6 | export const DEFAULT_URL = 'env(DATABASE_URL)'; 7 | 8 | export const LOG_LEVEL_NAMES = [ 9 | 'debug', 10 | 'info', 11 | 'warn', 12 | 'error', 13 | 'silent', 14 | ] as const; 15 | 16 | export const VALID_DIALECTS = [ 17 | 'postgres', 18 | 'mysql', 19 | 'sqlite', 20 | 'mssql', 21 | 'libsql', 22 | 'bun-sqlite', 23 | 'kysely-bun-sqlite', 24 | 'worker-bun-sqlite', 25 | ]; 26 | -------------------------------------------------------------------------------- /src/cli/flags.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_OUT_FILE } from '../generator'; 2 | import { 3 | DEFAULT_RUNTIME_ENUMS_STYLE, 4 | DEFAULT_URL, 5 | LOG_LEVEL_NAMES, 6 | VALID_DIALECTS, 7 | } from './constants'; 8 | 9 | type Flag = { 10 | default?: string; 11 | description: string; 12 | example?: string; 13 | examples?: string[]; 14 | longName: string; 15 | shortName?: string; 16 | values?: readonly string[]; 17 | }; 18 | 19 | export const FLAGS = [ 20 | { 21 | description: 'Use the Kysely CamelCasePlugin.', 22 | longName: 'camel-case', 23 | }, 24 | { 25 | description: 'Specify the path to the configuration file to use.', 26 | longName: 'config-file', 27 | }, 28 | { 29 | default: 'timestamp', 30 | description: 'Specify which parser to use for PostgreSQL date values.', 31 | longName: 'date-parser', 32 | values: ['string', 'timestamp'], 33 | }, 34 | { 35 | description: 'Set the default schema(s) for the database connection.', 36 | longName: 'default-schema', 37 | }, 38 | { 39 | description: 'Set the SQL dialect.', 40 | longName: 'dialect', 41 | values: VALID_DIALECTS, 42 | }, 43 | { 44 | description: 'Specify the path to an environment file to use.', 45 | longName: 'env-file', 46 | }, 47 | { 48 | description: 'Exclude tables matching the specified glob pattern.', 49 | examples: ['users', '*.table', 'secrets.*', '*._*'], 50 | longName: 'exclude-pattern', 51 | }, 52 | { 53 | description: 'Print this message.', 54 | longName: 'help', 55 | shortName: 'h', 56 | }, 57 | { 58 | description: 'Only include tables matching the specified glob pattern.', 59 | examples: ['users', '*.table', 'secrets.*', '*._*'], 60 | longName: 'include-pattern', 61 | }, 62 | { 63 | default: 'warn', 64 | description: 'Set the terminal log level.', 65 | longName: 'log-level', 66 | values: LOG_LEVEL_NAMES, 67 | }, 68 | { 69 | description: 'Skip generating types for PostgreSQL domains.', 70 | longName: 'no-domains', 71 | }, 72 | { 73 | default: 'string', 74 | description: 'Specify which parser to use for PostgreSQL numeric values.', 75 | longName: 'numeric-parser', 76 | values: ['string', 'number', 'number-or-string'], 77 | }, 78 | { 79 | default: DEFAULT_OUT_FILE, 80 | description: 'Set the file build path.', 81 | longName: 'out-file', 82 | }, 83 | { 84 | description: 85 | 'Specify type overrides for specific table columns, in JSON format.', 86 | example: '{"columns":{"table_name.column_name":"{foo:\\"bar\\"}"}}', 87 | longName: 'overrides', 88 | }, 89 | { 90 | description: 91 | 'Include partitions of PostgreSQL tables in the generated code.', 92 | longName: 'partitions', 93 | }, 94 | { 95 | description: 96 | 'Print the generated output to the terminal instead of a file.', 97 | longName: 'print', 98 | }, 99 | { 100 | default: DEFAULT_RUNTIME_ENUMS_STYLE, 101 | description: 102 | 'Generate runtime enums instead of string unions for PostgreSQL enums.', 103 | longName: 'runtime-enums', 104 | values: ['pascal-case', 'screaming-snake-case'], 105 | }, 106 | { 107 | description: 108 | 'Singularize generated table names, e.g. `BlogPost` instead of `BlogPosts`.', 109 | longName: 'singularize', 110 | }, 111 | { 112 | default: 'true', 113 | description: 114 | 'Generate code using the TypeScript 3.8+ `import type` syntax.', 115 | longName: 'type-only-imports', 116 | }, 117 | { 118 | default: DEFAULT_URL, 119 | description: 120 | 'Set the database connection string URL. This may point to an environment variable.', 121 | longName: 'url', 122 | }, 123 | { 124 | description: 'Verify that the generated types are up-to-date.', 125 | longName: 'verify', 126 | }, 127 | ]; 128 | 129 | export const serializeFlags = (flags: Flag[]) => { 130 | const lines: { fullDescription: string; line: string }[] = []; 131 | const sortedFlags = flags.sort((a, b) => { 132 | return a.longName.localeCompare(b.longName); 133 | }); 134 | let maxLineLength = 0; 135 | 136 | for (const flag of sortedFlags) { 137 | let line = ` --${flag.longName}`; 138 | 139 | if (flag.shortName) { 140 | line += `, -${flag.shortName}`; 141 | } 142 | 143 | if (line.length > maxLineLength) { 144 | maxLineLength = line.length; 145 | } 146 | 147 | let fullDescription = flag.description; 148 | 149 | const notes = [ 150 | ...(flag.values ? [`values: [${flag.values.join(', ')}]`] : []), 151 | ...(flag.default ? [`default: ${flag.default}`] : []), 152 | ...(flag.example ? [`example: ${flag.example}`] : []), 153 | ...(flag.examples ? [`examples: ${flag.examples.join(', ')}`] : []), 154 | ]; 155 | 156 | if (notes.length > 0) { 157 | fullDescription += ` (${notes.join(', ')})`; 158 | } 159 | 160 | lines.push({ fullDescription, line }); 161 | } 162 | 163 | return lines 164 | .map(({ fullDescription, line }) => { 165 | const padding = ' '.repeat(maxLineLength - line.length + 2); 166 | return `${line}${padding}${fullDescription}`; 167 | }) 168 | .join('\n'); 169 | }; 170 | -------------------------------------------------------------------------------- /src/cli/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cli'; 2 | export * from './config'; 3 | export * from './constants'; 4 | export * from './flags'; 5 | -------------------------------------------------------------------------------- /src/cli/test/config-with-custom-serializer.ts: -------------------------------------------------------------------------------- 1 | import type { DatabaseMetadata } from '../../introspector'; 2 | import type { Config } from '../config'; 3 | 4 | const config: Config = { 5 | logLevel: 'debug', 6 | outFile: null, 7 | serializer: { 8 | serializeFile(metadata: DatabaseMetadata) { 9 | return metadata.tables 10 | .map((table) => { 11 | return ( 12 | 'table ' + 13 | table.name + 14 | ' {\n' + 15 | table.columns 16 | .map((column) => ` ${column.name}: ${column.dataType}`) 17 | .join('\n') + 18 | '\n}' 19 | ); 20 | }) 21 | .join('\n\n'); 22 | }, 23 | }, 24 | url: 'postgres://user:password@localhost:5433/database', 25 | }; 26 | 27 | export default config; 28 | -------------------------------------------------------------------------------- /src/cli/test/config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | camelCase: true, 3 | defaultSchemas: ['cli'], 4 | dialect: 'postgres', 5 | includePattern: 'cli.*', 6 | logLevel: 'error', 7 | outFile: './output.snapshot.ts', 8 | runtimeEnums: 'pascal-case', 9 | singularize: { '/(bacch)(?:us|i)$/i': '$1us' }, 10 | typeOnlyImports: false, 11 | url: 'postgres://user:password@localhost:5433/database', 12 | verify: true, 13 | }; 14 | -------------------------------------------------------------------------------- /src/cli/test/output.snapshot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file was generated by kysely-codegen. 3 | * Please do not edit it manually. 4 | */ 5 | 6 | import { ColumnType } from "kysely"; 7 | 8 | export enum Status { 9 | Confirmed = "CONFIRMED", 10 | Unconfirmed = "UNCONFIRMED", 11 | } 12 | 13 | export type Generated = T extends ColumnType 14 | ? ColumnType 15 | : ColumnType; 16 | 17 | export interface Bacchus { 18 | bacchusId: Generated; 19 | status: Status | null; 20 | } 21 | 22 | export interface DB { 23 | bacchi: Bacchus; 24 | } 25 | -------------------------------------------------------------------------------- /src/db.ts: -------------------------------------------------------------------------------- 1 | export type DB = {}; 2 | -------------------------------------------------------------------------------- /src/generator/adapter.ts: -------------------------------------------------------------------------------- 1 | import type { DefinitionNode } from './ast/definition-node'; 2 | import type { ExpressionNode } from './ast/expression-node'; 3 | import { IdentifierNode } from './ast/identifier-node'; 4 | import type { ModuleReferenceNode } from './ast/module-reference-node'; 5 | 6 | export type Definitions = Record; 7 | 8 | export type Imports = Record; 9 | 10 | export type Scalars = Record; 11 | 12 | /** 13 | * Specifies settings for how code should be generated for the given database library. 14 | */ 15 | export abstract class Adapter { 16 | readonly defaultScalar: ExpressionNode = new IdentifierNode('unknown'); 17 | readonly defaultSchemas: string[] = []; 18 | readonly definitions: Definitions = {}; 19 | readonly imports: Imports = {}; 20 | readonly scalars: Scalars = {}; 21 | } 22 | -------------------------------------------------------------------------------- /src/generator/ast/alias-declaration-node.ts: -------------------------------------------------------------------------------- 1 | import type { ExpressionNode } from './expression-node'; 2 | import { IdentifierNode } from './identifier-node'; 3 | import type { TemplateNode } from './template-node'; 4 | 5 | export class AliasDeclarationNode { 6 | readonly body: ExpressionNode | TemplateNode; 7 | readonly id: IdentifierNode; 8 | readonly type = 'AliasDeclaration'; 9 | 10 | constructor(name: string, body: ExpressionNode | TemplateNode) { 11 | this.id = new IdentifierNode(name); 12 | this.body = body; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/generator/ast/array-expression-node.ts: -------------------------------------------------------------------------------- 1 | import type { ExpressionNode } from './expression-node'; 2 | 3 | export class ArrayExpressionNode { 4 | readonly type = 'ArrayExpression'; 5 | readonly values: ExpressionNode; 6 | 7 | constructor(values: ExpressionNode) { 8 | this.values = values; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/generator/ast/column-type-node.ts: -------------------------------------------------------------------------------- 1 | import type { ExpressionNode } from './expression-node'; 2 | import { GenericExpressionNode } from './generic-expression-node'; 3 | 4 | export class ColumnTypeNode extends GenericExpressionNode { 5 | constructor( 6 | selectType: ExpressionNode, 7 | ...insertAndUpdateTypes: 8 | | [] 9 | | [insertType: ExpressionNode] 10 | | [insertType: ExpressionNode, updateType: ExpressionNode] 11 | ) { 12 | super('ColumnType', [selectType, ...insertAndUpdateTypes]); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/generator/ast/definition-node.ts: -------------------------------------------------------------------------------- 1 | import type { ExpressionNode } from './expression-node'; 2 | import type { TemplateNode } from './template-node'; 3 | 4 | export type DefinitionNode = ExpressionNode | TemplateNode; 5 | -------------------------------------------------------------------------------- /src/generator/ast/export-statement-node.ts: -------------------------------------------------------------------------------- 1 | import type { AliasDeclarationNode } from './alias-declaration-node'; 2 | import type { InterfaceDeclarationNode } from './interface-declaration-node'; 3 | import type { RuntimeEnumDeclarationNode } from './runtime-enum-declaration-node'; 4 | 5 | export class ExportStatementNode { 6 | readonly argument: 7 | | AliasDeclarationNode 8 | | InterfaceDeclarationNode 9 | | RuntimeEnumDeclarationNode; 10 | readonly type = 'ExportStatement'; 11 | 12 | constructor( 13 | argument: 14 | | AliasDeclarationNode 15 | | InterfaceDeclarationNode 16 | | RuntimeEnumDeclarationNode, 17 | ) { 18 | this.argument = argument; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/generator/ast/expression-node.ts: -------------------------------------------------------------------------------- 1 | import type { ArrayExpressionNode } from './array-expression-node'; 2 | import type { ExtendsClauseNode } from './extends-clause-node'; 3 | import type { GenericExpressionNode } from './generic-expression-node'; 4 | import type { IdentifierNode } from './identifier-node'; 5 | import type { InferClauseNode } from './infer-clause-node'; 6 | import type { LiteralNode } from './literal-node'; 7 | import type { MappedTypeNode } from './mapped-type-node'; 8 | import type { ObjectExpressionNode } from './object-expression-node'; 9 | import type { RawExpressionNode } from './raw-expression-node'; 10 | import type { UnionExpressionNode } from './union-expression-node'; 11 | 12 | export type ExpressionNode = 13 | | ArrayExpressionNode 14 | | ExtendsClauseNode 15 | | GenericExpressionNode 16 | | IdentifierNode 17 | | InferClauseNode 18 | | LiteralNode 19 | | MappedTypeNode 20 | | ObjectExpressionNode 21 | | RawExpressionNode 22 | | UnionExpressionNode; 23 | -------------------------------------------------------------------------------- /src/generator/ast/extends-clause-node.ts: -------------------------------------------------------------------------------- 1 | import type { ExpressionNode } from './expression-node'; 2 | 3 | export class ExtendsClauseNode { 4 | readonly checkType: ExpressionNode; 5 | readonly extendsType: ExpressionNode; 6 | readonly trueType: ExpressionNode; 7 | readonly falseType: ExpressionNode; 8 | readonly type = 'ExtendsClause'; 9 | 10 | constructor( 11 | checkType: ExpressionNode, 12 | extendsType: ExpressionNode, 13 | trueType: ExpressionNode, 14 | falseType: ExpressionNode, 15 | ) { 16 | this.checkType = checkType; 17 | this.extendsType = extendsType; 18 | this.trueType = trueType; 19 | this.falseType = falseType; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/generator/ast/generic-expression-node.ts: -------------------------------------------------------------------------------- 1 | import type { ExpressionNode } from './expression-node'; 2 | 3 | export class GenericExpressionNode { 4 | readonly args: ExpressionNode[]; 5 | readonly name: string; 6 | readonly type = 'GenericExpression'; 7 | 8 | constructor(name: string, args: ExpressionNode[]) { 9 | this.name = name; 10 | this.args = args; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/generator/ast/identifier-node.ts: -------------------------------------------------------------------------------- 1 | export class IdentifierNode { 2 | readonly isTableIdentifier: boolean; 3 | name: string; 4 | readonly type = 'Identifier'; 5 | 6 | constructor(name: string, options?: { isTableIdentifier?: boolean }) { 7 | this.isTableIdentifier = !!options?.isTableIdentifier; 8 | this.name = name; 9 | } 10 | } 11 | 12 | export class TableIdentifierNode extends IdentifierNode { 13 | constructor(name: string) { 14 | super(name, { isTableIdentifier: true }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/generator/ast/import-clause-node.ts: -------------------------------------------------------------------------------- 1 | export class ImportClauseNode { 2 | readonly alias: string | null; 3 | readonly name: string; 4 | readonly type = 'ImportClause'; 5 | 6 | constructor(name: string, alias: string | null = null) { 7 | this.name = name; 8 | this.alias = alias; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/generator/ast/import-statement-node.ts: -------------------------------------------------------------------------------- 1 | import type { ImportClauseNode } from './import-clause-node'; 2 | 3 | export class ImportStatementNode { 4 | readonly imports: ImportClauseNode[]; 5 | readonly moduleName: string; 6 | readonly type = 'ImportStatement'; 7 | 8 | constructor(moduleName: string, imports: ImportClauseNode[]) { 9 | this.moduleName = moduleName; 10 | this.imports = imports; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/generator/ast/infer-clause-node.ts: -------------------------------------------------------------------------------- 1 | export class InferClauseNode { 2 | readonly name: string; 3 | readonly type = 'InferClause'; 4 | 5 | constructor(name: string) { 6 | this.name = name; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/generator/ast/interface-declaration-node.ts: -------------------------------------------------------------------------------- 1 | import type { IdentifierNode } from './identifier-node'; 2 | import type { ObjectExpressionNode } from './object-expression-node'; 3 | 4 | export class InterfaceDeclarationNode { 5 | readonly body: ObjectExpressionNode; 6 | readonly id: IdentifierNode; 7 | readonly type = 'InterfaceDeclaration'; 8 | 9 | constructor(name: IdentifierNode, body: ObjectExpressionNode) { 10 | this.id = name; 11 | this.body = body; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/generator/ast/json-column-type-node.ts: -------------------------------------------------------------------------------- 1 | import type { ExpressionNode } from './expression-node'; 2 | import { GenericExpressionNode } from './generic-expression-node'; 3 | 4 | export class JsonColumnTypeNode extends GenericExpressionNode { 5 | constructor( 6 | selectType: ExpressionNode, 7 | ...args: 8 | | [] 9 | | [insertType: ExpressionNode] 10 | | [insertType: ExpressionNode, updateType: ExpressionNode] 11 | ) { 12 | super('JSONColumnType', [selectType, ...args]); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/generator/ast/literal-node.ts: -------------------------------------------------------------------------------- 1 | type Literal = number | string; 2 | 3 | export class LiteralNode { 4 | readonly type = 'Literal'; 5 | readonly value: T; 6 | 7 | constructor(value: T) { 8 | this.value = value; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/generator/ast/mapped-type-node.ts: -------------------------------------------------------------------------------- 1 | import type { ExpressionNode } from './expression-node'; 2 | 3 | export class MappedTypeNode { 4 | readonly type = 'MappedType'; 5 | readonly value: ExpressionNode; 6 | 7 | constructor(value: ExpressionNode) { 8 | this.value = value; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/generator/ast/module-reference-node.ts: -------------------------------------------------------------------------------- 1 | export class ModuleReferenceNode { 2 | readonly name: string; 3 | readonly type = 'ModuleReference'; 4 | 5 | constructor(name: string) { 6 | this.name = name; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/generator/ast/object-expression-node.ts: -------------------------------------------------------------------------------- 1 | import type { PropertyNode } from './property-node'; 2 | 3 | export class ObjectExpressionNode { 4 | readonly properties: PropertyNode[]; 5 | readonly type = 'ObjectExpression'; 6 | 7 | constructor(properties: PropertyNode[]) { 8 | this.properties = properties; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/generator/ast/property-node.ts: -------------------------------------------------------------------------------- 1 | import type { ExpressionNode } from './expression-node'; 2 | 3 | export class PropertyNode { 4 | readonly comment: string | null; 5 | readonly key: string; 6 | readonly type = 'Property'; 7 | readonly value: ExpressionNode; 8 | 9 | constructor( 10 | key: string, 11 | value: ExpressionNode, 12 | comment: string | null = null, 13 | ) { 14 | this.comment = comment; 15 | this.key = key; 16 | this.value = value; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/generator/ast/raw-expression-node.ts: -------------------------------------------------------------------------------- 1 | export class RawExpressionNode { 2 | readonly expression: string; 3 | readonly type = 'RawExpression'; 4 | 5 | constructor(expression: string) { 6 | this.expression = expression; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/generator/ast/runtime-enum-declaration-node.ts: -------------------------------------------------------------------------------- 1 | import type { IdentifierStyle } from '../transformer/identifier-style'; 2 | import type { SymbolEntry } from '../transformer/symbol-collection'; 3 | import { SymbolCollection } from '../transformer/symbol-collection'; 4 | import { IdentifierNode } from './identifier-node'; 5 | import { LiteralNode } from './literal-node'; 6 | 7 | type RuntimeEnumMember = [key: string, value: LiteralNode]; 8 | 9 | export class RuntimeEnumDeclarationNode { 10 | readonly members: RuntimeEnumMember[]; 11 | id: IdentifierNode; 12 | readonly type = 'RuntimeEnumDeclaration'; 13 | 14 | constructor( 15 | name: string, 16 | literals: string[], 17 | options?: { identifierStyle?: IdentifierStyle }, 18 | ) { 19 | this.members = []; 20 | this.id = new IdentifierNode(name); 21 | 22 | const symbolCollection = new SymbolCollection({ 23 | entries: literals.map( 24 | (literal): SymbolEntry => [ 25 | literal, 26 | { node: new LiteralNode(literal), type: 'RuntimeEnumMember' }, 27 | ], 28 | ), 29 | identifierStyle: options?.identifierStyle, 30 | }); 31 | 32 | for (const { id, symbol } of symbolCollection.entries()) { 33 | if (symbol.type !== 'RuntimeEnumMember') { 34 | continue; 35 | } 36 | 37 | this.members.push([id, symbol.node]); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/generator/ast/statement-node.ts: -------------------------------------------------------------------------------- 1 | import type { ExportStatementNode } from './export-statement-node'; 2 | import type { ImportStatementNode } from './import-statement-node'; 3 | 4 | export type StatementNode = ExportStatementNode | ImportStatementNode; 5 | -------------------------------------------------------------------------------- /src/generator/ast/template-node.ts: -------------------------------------------------------------------------------- 1 | import type { ExpressionNode } from './expression-node'; 2 | 3 | export class TemplateNode { 4 | readonly expression: ExpressionNode; 5 | readonly params: string[]; 6 | readonly type = 'Template'; 7 | 8 | constructor(params: string[], expression: ExpressionNode) { 9 | this.params = params; 10 | this.expression = expression; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/generator/ast/union-expression-node.ts: -------------------------------------------------------------------------------- 1 | import type { ExpressionNode } from './expression-node'; 2 | 3 | export class UnionExpressionNode { 4 | readonly args: ExpressionNode[]; 5 | readonly type = 'UnionExpression'; 6 | 7 | constructor(args: ExpressionNode[]) { 8 | this.args = args; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/generator/connection-string-parser.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual } from 'node:assert'; 2 | import { ConnectionStringParser } from './connection-string-parser'; 3 | 4 | describe(ConnectionStringParser.name, () => { 5 | const parser = new ConnectionStringParser(); 6 | 7 | describe('postgres', () => { 8 | it('should infer the correct dialect name', () => { 9 | deepStrictEqual( 10 | parser.parse({ 11 | connectionString: 'postgres://username:password@hostname/database', 12 | }), 13 | { 14 | connectionString: 'postgres://username:password@hostname/database', 15 | dialect: 'postgres', 16 | }, 17 | ); 18 | deepStrictEqual( 19 | parser.parse({ 20 | connectionString: 'postgresql://username:password@hostname/database', 21 | }), 22 | { 23 | connectionString: 'postgresql://username:password@hostname/database', 24 | dialect: 'postgres', 25 | }, 26 | ); 27 | deepStrictEqual( 28 | parser.parse({ 29 | connectionString: 'pg://username:password@hostname/database', 30 | }), 31 | { 32 | connectionString: 'postgres://username:password@hostname/database', 33 | dialect: 'postgres', 34 | }, 35 | ); 36 | }); 37 | }); 38 | 39 | describe('mysql', () => { 40 | it('should infer the correct dialect name', () => { 41 | deepStrictEqual( 42 | parser.parse({ 43 | connectionString: 'mysql://username:password@hostname/database', 44 | }), 45 | { 46 | connectionString: 'mysql://username:password@hostname/database', 47 | dialect: 'mysql', 48 | }, 49 | ); 50 | deepStrictEqual( 51 | parser.parse({ 52 | connectionString: 'mysqlx://username:password@hostname/database', 53 | }), 54 | { 55 | connectionString: 'mysqlx://username:password@hostname/database', 56 | dialect: 'mysql', 57 | }, 58 | ); 59 | }); 60 | }); 61 | 62 | describe('sqlite', () => { 63 | it('should infer the correct dialect name', () => { 64 | deepStrictEqual( 65 | parser.parse({ 66 | connectionString: 'C:/Program Files/sqlite3/db', 67 | }), 68 | { 69 | connectionString: 'C:/Program Files/sqlite3/db', 70 | dialect: 'sqlite', 71 | }, 72 | ); 73 | deepStrictEqual( 74 | parser.parse({ 75 | connectionString: '/usr/local/bin', 76 | }), 77 | { 78 | connectionString: '/usr/local/bin', 79 | dialect: 'sqlite', 80 | }, 81 | ); 82 | }); 83 | }); 84 | 85 | describe('libsql', () => { 86 | it('should infer the correct dialect name', () => { 87 | deepStrictEqual( 88 | parser.parse({ 89 | connectionString: 'libsql://token@hostname:port/db', 90 | }), 91 | { 92 | connectionString: 'libsql://token@hostname:port/db', 93 | dialect: 'libsql', 94 | }, 95 | ); 96 | deepStrictEqual( 97 | parser.parse({ 98 | connectionString: 'libsql://hostname:port/db', 99 | }), 100 | { 101 | connectionString: 'libsql://hostname:port/db', 102 | dialect: 'libsql', 103 | }, 104 | ); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/generator/connection-string-parser.ts: -------------------------------------------------------------------------------- 1 | import { config as loadEnv } from 'dotenv'; 2 | import { expand as expandEnv } from 'dotenv-expand'; 3 | import type { DialectName } from '../cli/config'; 4 | import type { Logger } from './logger/logger'; 5 | 6 | const CALL_STATEMENT_REGEXP = /^\s*([a-z]+)\s*\(\s*(.*)\s*\)\s*$/; 7 | const DIALECT_PARTS_REGEXP = /([^:]*)(.*)/; 8 | 9 | /** 10 | * @see https://dev.mysql.com/doc/refman/8.0/en/connecting-using-uri-or-key-value-pairs.html 11 | */ 12 | type ParseConnectionStringOptions = { 13 | connectionString: string; 14 | dialect?: DialectName; 15 | envFile?: string; 16 | logger?: Logger; 17 | }; 18 | 19 | type ParsedConnectionString = { 20 | connectionString: string; 21 | dialect: DialectName; 22 | }; 23 | 24 | /** 25 | * Parses a connection string URL or loads it from an environment file. 26 | * Upon success, it also returns which dialect was inferred from the connection string. 27 | */ 28 | export class ConnectionStringParser { 29 | #inferDialectName(connectionString: string): DialectName { 30 | if (connectionString.startsWith('libsql')) { 31 | return 'libsql'; 32 | } 33 | 34 | if (connectionString.startsWith('mysql')) { 35 | return 'mysql'; 36 | } 37 | 38 | if ( 39 | connectionString.startsWith('postgres') || 40 | connectionString.startsWith('pg') 41 | ) { 42 | return 'postgres'; 43 | } 44 | 45 | if (connectionString.toLowerCase().includes('user id=')) { 46 | return 'mssql'; 47 | } 48 | 49 | return 'sqlite'; 50 | } 51 | 52 | parse(options: ParseConnectionStringOptions): ParsedConnectionString { 53 | let connectionString = options.connectionString; 54 | 55 | const expressionMatch = connectionString.match(CALL_STATEMENT_REGEXP); 56 | 57 | if (expressionMatch) { 58 | const name = expressionMatch[1]!; 59 | 60 | if (name !== 'env') { 61 | throw new ReferenceError(`Function '${name}' is not defined.`); 62 | } 63 | 64 | const keyToken = expressionMatch[2]!; 65 | let key: string | undefined; 66 | 67 | try { 68 | key = keyToken.includes('"') ? JSON.parse(keyToken) : keyToken; 69 | } catch { 70 | throw new SyntaxError( 71 | `Invalid connection string: '${connectionString}'`, 72 | ); 73 | } 74 | 75 | if (typeof key !== 'string') { 76 | throw new TypeError( 77 | `Argument 0 of function '${name}' must be a string.`, 78 | ); 79 | } 80 | 81 | const { error } = expandEnv(loadEnv({ path: options.envFile })); 82 | const displayEnvFile = options.envFile ?? '.env'; 83 | 84 | if (error) { 85 | if ( 86 | 'code' in error && 87 | typeof error.code === 'string' && 88 | error.code === 'ENOENT' 89 | ) { 90 | if (options.envFile !== undefined) { 91 | throw new ReferenceError( 92 | `Could not resolve connection string '${connectionString}'. ` + 93 | `Environment file '${displayEnvFile}' could not be found. ` + 94 | "Use '--env-file' to specify a different file.", 95 | ); 96 | } 97 | } else { 98 | throw error; 99 | } 100 | } else { 101 | options.logger?.info( 102 | `Loaded environment variables from '${displayEnvFile}'.`, 103 | ); 104 | } 105 | 106 | const envConnectionString = process.env[key]; 107 | 108 | if (!envConnectionString) { 109 | throw new ReferenceError( 110 | `Environment variable '${key}' could not be found.`, 111 | ); 112 | } 113 | 114 | connectionString = envConnectionString; 115 | } 116 | 117 | const parts = connectionString.match(DIALECT_PARTS_REGEXP)!; 118 | const protocol = parts[1]!; 119 | const tail = parts[2]!; 120 | const normalizedConnectionString = 121 | protocol === 'pg' ? `postgres${tail}` : connectionString; 122 | const dialect = options.dialect ?? this.#inferDialectName(connectionString); 123 | 124 | return { 125 | connectionString: normalizedConnectionString, 126 | dialect, 127 | }; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/generator/constants.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'node:path'; 2 | 3 | export const DEFAULT_OUT_FILE = join( 4 | process.cwd(), 5 | 'node_modules', 6 | 'kysely-codegen', 7 | 'dist', 8 | 'db.d.ts', 9 | ); 10 | -------------------------------------------------------------------------------- /src/generator/dialect.ts: -------------------------------------------------------------------------------- 1 | import type { DialectName } from '../cli/config'; 2 | import { IntrospectorDialect } from '../introspector/dialect'; 3 | import type { Adapter } from './adapter'; 4 | import { KyselyBunSqliteDialect } from './dialects/kysely-bun-sqlite/kysely-bun-sqlite-dialect'; 5 | import { LibsqlDialect } from './dialects/libsql/libsql-dialect'; 6 | import { MssqlDialect } from './dialects/mssql/mssql-dialect'; 7 | import { MysqlDialect } from './dialects/mysql/mysql-dialect'; 8 | import { 9 | type PostgresDialectOptions, 10 | PostgresDialect, 11 | } from './dialects/postgres/postgres-dialect'; 12 | import { SqliteDialect } from './dialects/sqlite/sqlite-dialect'; 13 | import { WorkerBunSqliteDialect } from './dialects/worker-bun-sqlite/worker-bun-sqlite-dialect'; 14 | 15 | /** 16 | * A Dialect is the glue between the codegen and the specified database. 17 | */ 18 | export abstract class GeneratorDialect extends IntrospectorDialect { 19 | abstract readonly adapter: Adapter; 20 | } 21 | 22 | export const getDialect = ( 23 | name: DialectName, 24 | options?: PostgresDialectOptions, 25 | ): GeneratorDialect => { 26 | switch (name) { 27 | case 'kysely-bun-sqlite': 28 | return new KyselyBunSqliteDialect(); 29 | case 'libsql': 30 | return new LibsqlDialect(); 31 | case 'mssql': 32 | return new MssqlDialect(); 33 | case 'mysql': 34 | return new MysqlDialect(); 35 | case 'postgres': 36 | return new PostgresDialect(options); 37 | case 'bun-sqlite': // Legacy. 38 | case 'worker-bun-sqlite': 39 | return new WorkerBunSqliteDialect(); 40 | default: 41 | return new SqliteDialect(); 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /src/generator/dialects/kysely-bun-sqlite/kysely-bun-sqlite-adapter.ts: -------------------------------------------------------------------------------- 1 | import { SqliteAdapter } from '../sqlite/sqlite-adapter'; 2 | 3 | export class KyselyBunSqliteAdapter extends SqliteAdapter {} 4 | -------------------------------------------------------------------------------- /src/generator/dialects/kysely-bun-sqlite/kysely-bun-sqlite-dialect.ts: -------------------------------------------------------------------------------- 1 | import { KyselyBunSqliteIntrospectorDialect } from '../../../introspector/dialects/kysely-bun-sqlite/kysely-bun-sqlite-dialect'; 2 | import type { GeneratorDialect } from '../../dialect'; 3 | import { KyselyBunSqliteAdapter } from './kysely-bun-sqlite-adapter'; 4 | 5 | export class KyselyBunSqliteDialect 6 | extends KyselyBunSqliteIntrospectorDialect 7 | implements GeneratorDialect 8 | { 9 | readonly adapter = new KyselyBunSqliteAdapter(); 10 | } 11 | -------------------------------------------------------------------------------- /src/generator/dialects/libsql/libsql-adapter.ts: -------------------------------------------------------------------------------- 1 | import { SqliteAdapter } from '../sqlite/sqlite-adapter'; 2 | 3 | export class LibsqlAdapter extends SqliteAdapter {} 4 | -------------------------------------------------------------------------------- /src/generator/dialects/libsql/libsql-dialect.ts: -------------------------------------------------------------------------------- 1 | import { LibsqlIntrospectorDialect } from '../../../introspector/dialects/libsql/libsql-dialect'; 2 | import type { GeneratorDialect } from '../../dialect'; 3 | import { LibsqlAdapter } from './libsql-adapter'; 4 | 5 | export class LibsqlDialect 6 | extends LibsqlIntrospectorDialect 7 | implements GeneratorDialect 8 | { 9 | readonly adapter = new LibsqlAdapter(); 10 | } 11 | -------------------------------------------------------------------------------- /src/generator/dialects/mssql/mssql-adapter.ts: -------------------------------------------------------------------------------- 1 | import { Adapter } from '../../adapter'; 2 | import { IdentifierNode } from '../../ast/identifier-node'; 3 | 4 | export class MssqlAdapter extends Adapter { 5 | // https://github.com/tediousjs/tedious/tree/master/src/data-types 6 | override readonly scalars = { 7 | bigint: new IdentifierNode('number'), 8 | binary: new IdentifierNode('Buffer'), 9 | bit: new IdentifierNode('boolean'), 10 | char: new IdentifierNode('string'), 11 | date: new IdentifierNode('Date'), 12 | datetime: new IdentifierNode('Date'), 13 | datetime2: new IdentifierNode('Date'), 14 | datetimeoffset: new IdentifierNode('Date'), 15 | decimal: new IdentifierNode('number'), 16 | double: new IdentifierNode('number'), 17 | float: new IdentifierNode('number'), 18 | image: new IdentifierNode('Buffer'), 19 | int: new IdentifierNode('number'), 20 | money: new IdentifierNode('number'), 21 | nchar: new IdentifierNode('string'), 22 | ntext: new IdentifierNode('string'), 23 | number: new IdentifierNode('number'), 24 | numeric: new IdentifierNode('number'), 25 | nvarchar: new IdentifierNode('string'), 26 | real: new IdentifierNode('number'), 27 | smalldatetime: new IdentifierNode('Date'), 28 | smallint: new IdentifierNode('number'), 29 | smallmoney: new IdentifierNode('number'), 30 | text: new IdentifierNode('string'), 31 | time: new IdentifierNode('Date'), 32 | tinyint: new IdentifierNode('number'), 33 | tvp: new IdentifierNode('unknown'), 34 | uniqueidentifier: new IdentifierNode('string'), 35 | varbinary: new IdentifierNode('Buffer'), 36 | varchar: new IdentifierNode('string'), 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /src/generator/dialects/mssql/mssql-dialect.ts: -------------------------------------------------------------------------------- 1 | import { MssqlIntrospectorDialect } from '../../../introspector/dialects/mssql/mssql-dialect'; 2 | import type { GeneratorDialect } from '../../dialect'; 3 | import { MssqlAdapter } from './mssql-adapter'; 4 | 5 | const DEFAULT_MSSQL_PORT = 1433; 6 | 7 | export class MssqlDialect 8 | extends MssqlIntrospectorDialect 9 | implements GeneratorDialect 10 | { 11 | readonly adapter = new MssqlAdapter(); 12 | 13 | /** 14 | * @see https://www.connectionstrings.com/microsoft-data-sqlclient/using-a-non-standard-port/ 15 | */ 16 | async #parseConnectionString(connectionString: string) { 17 | const { parseConnectionString } = await import( 18 | '@tediousjs/connection-string' 19 | ); 20 | 21 | const parsed = parseConnectionString(connectionString) as Record< 22 | string, 23 | string 24 | >; 25 | const tokens = parsed.server!.split(','); 26 | const server = tokens[0]!; 27 | const port = tokens[1] 28 | ? Number.parseInt(tokens[1], 10) 29 | : DEFAULT_MSSQL_PORT; 30 | 31 | return { 32 | database: parsed.database!, 33 | password: parsed.password!, 34 | port, 35 | server, 36 | userName: parsed['user id']!, 37 | }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/generator/dialects/mysql/mysql-adapter.ts: -------------------------------------------------------------------------------- 1 | import { Adapter } from '../../adapter'; 2 | import { ArrayExpressionNode } from '../../ast/array-expression-node'; 3 | import { ColumnTypeNode } from '../../ast/column-type-node'; 4 | import { IdentifierNode } from '../../ast/identifier-node'; 5 | import { ObjectExpressionNode } from '../../ast/object-expression-node'; 6 | import { PropertyNode } from '../../ast/property-node'; 7 | import { UnionExpressionNode } from '../../ast/union-expression-node'; 8 | import { 9 | JSON_ARRAY_DEFINITION, 10 | JSON_OBJECT_DEFINITION, 11 | JSON_PRIMITIVE_DEFINITION, 12 | JSON_VALUE_DEFINITION, 13 | } from '../../transformer/definitions'; 14 | 15 | export class MysqlAdapter extends Adapter { 16 | override readonly definitions = { 17 | Decimal: new ColumnTypeNode( 18 | new IdentifierNode('string'), 19 | new UnionExpressionNode([ 20 | new IdentifierNode('string'), 21 | new IdentifierNode('number'), 22 | ]), 23 | ), 24 | Geometry: new UnionExpressionNode([ 25 | new IdentifierNode('LineString'), 26 | new IdentifierNode('Point'), 27 | new IdentifierNode('Polygon'), 28 | new ArrayExpressionNode(new IdentifierNode('Geometry')), 29 | ]), 30 | Json: new ColumnTypeNode( 31 | new IdentifierNode('JsonValue'), 32 | new IdentifierNode('string'), 33 | new IdentifierNode('string'), 34 | ), 35 | JsonArray: JSON_ARRAY_DEFINITION, 36 | JsonObject: JSON_OBJECT_DEFINITION, 37 | JsonPrimitive: JSON_PRIMITIVE_DEFINITION, 38 | JsonValue: JSON_VALUE_DEFINITION, 39 | LineString: new ArrayExpressionNode(new IdentifierNode('Point')), 40 | Point: new ObjectExpressionNode([ 41 | new PropertyNode('x', new IdentifierNode('number')), 42 | new PropertyNode('y', new IdentifierNode('number')), 43 | ]), 44 | Polygon: new ArrayExpressionNode(new IdentifierNode('LineString')), 45 | }; 46 | // These types have been found through experimentation in Adminer. 47 | override readonly scalars = { 48 | bigint: new IdentifierNode('number'), 49 | binary: new IdentifierNode('Buffer'), 50 | bit: new IdentifierNode('Buffer'), 51 | blob: new IdentifierNode('Buffer'), 52 | char: new IdentifierNode('string'), 53 | date: new IdentifierNode('Date'), 54 | datetime: new IdentifierNode('Date'), 55 | decimal: new IdentifierNode('Decimal'), 56 | double: new IdentifierNode('number'), 57 | float: new IdentifierNode('number'), 58 | geomcollection: new ArrayExpressionNode(new IdentifierNode('Geometry')), // Specified as "geometrycollection" in Adminer. 59 | geometry: new IdentifierNode('Geometry'), 60 | int: new IdentifierNode('number'), 61 | json: new IdentifierNode('Json'), 62 | linestring: new IdentifierNode('LineString'), 63 | longblob: new IdentifierNode('Buffer'), 64 | longtext: new IdentifierNode('string'), 65 | mediumblob: new IdentifierNode('Buffer'), 66 | mediumint: new IdentifierNode('number'), 67 | mediumtext: new IdentifierNode('string'), 68 | multilinestring: new ArrayExpressionNode(new IdentifierNode('LineString')), 69 | multipoint: new ArrayExpressionNode(new IdentifierNode('Point')), 70 | multipolygon: new ArrayExpressionNode(new IdentifierNode('Polygon')), 71 | point: new IdentifierNode('Point'), 72 | polygon: new IdentifierNode('Polygon'), 73 | set: new IdentifierNode('unknown'), 74 | smallint: new IdentifierNode('number'), 75 | text: new IdentifierNode('string'), 76 | time: new IdentifierNode('string'), 77 | timestamp: new IdentifierNode('Date'), 78 | tinyblob: new IdentifierNode('Buffer'), 79 | tinyint: new IdentifierNode('number'), 80 | tinytext: new IdentifierNode('string'), 81 | varbinary: new IdentifierNode('Buffer'), 82 | varchar: new IdentifierNode('string'), 83 | year: new IdentifierNode('number'), 84 | }; 85 | } 86 | -------------------------------------------------------------------------------- /src/generator/dialects/mysql/mysql-dialect.ts: -------------------------------------------------------------------------------- 1 | import { MysqlIntrospectorDialect } from '../../../introspector/dialects/mysql/mysql-dialect'; 2 | import type { GeneratorDialect } from '../../dialect'; 3 | import { MysqlAdapter } from './mysql-adapter'; 4 | 5 | export class MysqlDialect 6 | extends MysqlIntrospectorDialect 7 | implements GeneratorDialect 8 | { 9 | readonly adapter = new MysqlAdapter(); 10 | } 11 | -------------------------------------------------------------------------------- /src/generator/dialects/postgres/postgres-adapter.ts: -------------------------------------------------------------------------------- 1 | import type { DateParser } from '../../../introspector/dialects/postgres/date-parser'; 2 | import type { NumericParser } from '../../../introspector/dialects/postgres/numeric-parser'; 3 | import { Adapter } from '../../adapter'; 4 | import { ColumnTypeNode } from '../../ast/column-type-node'; 5 | import { IdentifierNode } from '../../ast/identifier-node'; 6 | import { ModuleReferenceNode } from '../../ast/module-reference-node'; 7 | import { ObjectExpressionNode } from '../../ast/object-expression-node'; 8 | import { PropertyNode } from '../../ast/property-node'; 9 | import { UnionExpressionNode } from '../../ast/union-expression-node'; 10 | import { 11 | JSON_ARRAY_DEFINITION, 12 | JSON_DEFINITION, 13 | JSON_OBJECT_DEFINITION, 14 | JSON_PRIMITIVE_DEFINITION, 15 | JSON_VALUE_DEFINITION, 16 | } from '../../transformer/definitions'; 17 | 18 | type PostgresAdapterOptions = { 19 | dateParser?: DateParser; 20 | numericParser?: NumericParser; 21 | }; 22 | 23 | export class PostgresAdapter extends Adapter { 24 | // From https://node-postgres.com/features/types: 25 | // "node-postgres will convert a database type to a JavaScript string if it doesn't have a 26 | // registered type parser for the database type. Furthermore, you can send any type to the 27 | // PostgreSQL server as a string and node-postgres will pass it through without modifying it in 28 | // any way." 29 | override readonly defaultScalar = new IdentifierNode('string'); 30 | override readonly defaultSchemas = ['public']; 31 | override readonly definitions = { 32 | Circle: new ObjectExpressionNode([ 33 | new PropertyNode('x', new IdentifierNode('number')), 34 | new PropertyNode('y', new IdentifierNode('number')), 35 | new PropertyNode('radius', new IdentifierNode('number')), 36 | ]), 37 | Int8: new ColumnTypeNode( 38 | new IdentifierNode('string'), 39 | new UnionExpressionNode([ 40 | new IdentifierNode('string'), 41 | new IdentifierNode('number'), 42 | new IdentifierNode('bigint'), 43 | ]), 44 | new UnionExpressionNode([ 45 | new IdentifierNode('string'), 46 | new IdentifierNode('number'), 47 | new IdentifierNode('bigint'), 48 | ]), 49 | ), 50 | Interval: new ColumnTypeNode( 51 | new IdentifierNode('IPostgresInterval'), 52 | new UnionExpressionNode([ 53 | new IdentifierNode('IPostgresInterval'), 54 | new IdentifierNode('number'), 55 | new IdentifierNode('string'), 56 | ]), 57 | new UnionExpressionNode([ 58 | new IdentifierNode('IPostgresInterval'), 59 | new IdentifierNode('number'), 60 | new IdentifierNode('string'), 61 | ]), 62 | ), 63 | Json: JSON_DEFINITION, 64 | JsonArray: JSON_ARRAY_DEFINITION, 65 | JsonObject: JSON_OBJECT_DEFINITION, 66 | JsonPrimitive: JSON_PRIMITIVE_DEFINITION, 67 | JsonValue: JSON_VALUE_DEFINITION, 68 | Numeric: new ColumnTypeNode( 69 | new IdentifierNode('string'), 70 | new UnionExpressionNode([ 71 | new IdentifierNode('number'), 72 | new IdentifierNode('string'), 73 | ]), 74 | new UnionExpressionNode([ 75 | new IdentifierNode('number'), 76 | new IdentifierNode('string'), 77 | ]), 78 | ), 79 | Point: new ObjectExpressionNode([ 80 | new PropertyNode('x', new IdentifierNode('number')), 81 | new PropertyNode('y', new IdentifierNode('number')), 82 | ]), 83 | Timestamp: new ColumnTypeNode( 84 | new IdentifierNode('Date'), 85 | new UnionExpressionNode([ 86 | new IdentifierNode('Date'), 87 | new IdentifierNode('string'), 88 | ]), 89 | new UnionExpressionNode([ 90 | new IdentifierNode('Date'), 91 | new IdentifierNode('string'), 92 | ]), 93 | ), 94 | }; 95 | override readonly imports = { 96 | IPostgresInterval: new ModuleReferenceNode('postgres-interval'), 97 | }; 98 | // These types have been found through experimentation in Adminer and in the 'pg' source code. 99 | override readonly scalars = { 100 | bit: new IdentifierNode('string'), 101 | bool: new IdentifierNode('boolean'), // Specified as "boolean" in Adminer. 102 | box: new IdentifierNode('string'), 103 | bpchar: new IdentifierNode('string'), // Specified as "character" in Adminer. 104 | bytea: new IdentifierNode('Buffer'), 105 | cidr: new IdentifierNode('string'), 106 | circle: new IdentifierNode('Circle'), 107 | date: new IdentifierNode('Timestamp'), 108 | float4: new IdentifierNode('number'), // Specified as "real" in Adminer. 109 | float8: new IdentifierNode('number'), // Specified as "double precision" in Adminer. 110 | inet: new IdentifierNode('string'), 111 | int2: new IdentifierNode('number'), // Specified in 'pg' source code. 112 | int4: new IdentifierNode('number'), // Specified in 'pg' source code. 113 | int8: new IdentifierNode('Int8'), // Specified as "bigint" in Adminer. 114 | interval: new IdentifierNode('Interval'), 115 | json: new IdentifierNode('Json'), 116 | jsonb: new IdentifierNode('Json'), 117 | line: new IdentifierNode('string'), 118 | lseg: new IdentifierNode('string'), 119 | macaddr: new IdentifierNode('string'), 120 | money: new IdentifierNode('string'), 121 | numeric: new IdentifierNode('Numeric'), 122 | oid: new IdentifierNode('number'), // Specified in 'pg' source code. 123 | path: new IdentifierNode('string'), 124 | point: new IdentifierNode('Point'), 125 | polygon: new IdentifierNode('string'), 126 | text: new IdentifierNode('string'), 127 | time: new IdentifierNode('string'), 128 | timestamp: new IdentifierNode('Timestamp'), 129 | timestamptz: new IdentifierNode('Timestamp'), 130 | tsquery: new IdentifierNode('string'), 131 | tsvector: new IdentifierNode('string'), 132 | txid_snapshot: new IdentifierNode('string'), 133 | uuid: new IdentifierNode('string'), 134 | varbit: new IdentifierNode('string'), // Specified as "bit varying" in Adminer. 135 | varchar: new IdentifierNode('string'), // Specified as "character varying" in Adminer. 136 | xml: new IdentifierNode('string'), 137 | }; 138 | 139 | constructor(options?: PostgresAdapterOptions) { 140 | super(); 141 | 142 | if (options?.dateParser === 'string') { 143 | this.scalars.date = new IdentifierNode('string'); 144 | } else { 145 | this.scalars.date = new IdentifierNode('Timestamp'); 146 | } 147 | 148 | if (options?.numericParser === 'number') { 149 | this.definitions.Numeric = new ColumnTypeNode( 150 | new IdentifierNode('number'), 151 | new UnionExpressionNode([ 152 | new IdentifierNode('number'), 153 | new IdentifierNode('string'), 154 | ]), 155 | new UnionExpressionNode([ 156 | new IdentifierNode('number'), 157 | new IdentifierNode('string'), 158 | ]), 159 | ); 160 | } else if (options?.numericParser === 'number-or-string') { 161 | this.definitions.Numeric = new ColumnTypeNode( 162 | new UnionExpressionNode([ 163 | new IdentifierNode('number'), 164 | new IdentifierNode('string'), 165 | ]), 166 | ); 167 | } 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/generator/dialects/postgres/postgres-dialect.ts: -------------------------------------------------------------------------------- 1 | import type { DateParser } from '../../../introspector/dialects/postgres/date-parser'; 2 | import type { NumericParser } from '../../../introspector/dialects/postgres/numeric-parser'; 3 | import { PostgresIntrospectorDialect } from '../../../introspector/dialects/postgres/postgres-dialect'; 4 | import type { GeneratorDialect } from '../../dialect'; 5 | import { PostgresAdapter } from './postgres-adapter'; 6 | 7 | export type PostgresDialectOptions = { 8 | dateParser?: DateParser; 9 | defaultSchemas?: string[]; 10 | domains?: boolean; 11 | numericParser?: NumericParser; 12 | partitions?: boolean; 13 | }; 14 | 15 | export class PostgresDialect 16 | extends PostgresIntrospectorDialect 17 | implements GeneratorDialect 18 | { 19 | readonly adapter: PostgresAdapter; 20 | 21 | constructor(options?: PostgresDialectOptions) { 22 | super({ 23 | dateParser: options?.dateParser, 24 | defaultSchemas: options?.defaultSchemas, 25 | domains: options?.domains, 26 | numericParser: options?.numericParser, 27 | partitions: options?.partitions, 28 | }); 29 | 30 | this.adapter = new PostgresAdapter({ 31 | dateParser: this.options.dateParser, 32 | numericParser: this.options.numericParser, 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/generator/dialects/sqlite/sqlite-adapter.ts: -------------------------------------------------------------------------------- 1 | import { Adapter } from '../../adapter'; 2 | import { IdentifierNode } from '../../ast/identifier-node'; 3 | 4 | export class SqliteAdapter extends Adapter { 5 | override readonly defaultScalar = new IdentifierNode('string'); 6 | override readonly scalars = { 7 | any: new IdentifierNode('unknown'), 8 | blob: new IdentifierNode('Buffer'), 9 | boolean: new IdentifierNode('number'), 10 | integer: new IdentifierNode('number'), 11 | numeric: new IdentifierNode('number'), 12 | real: new IdentifierNode('number'), 13 | text: new IdentifierNode('string'), 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/generator/dialects/sqlite/sqlite-dialect.ts: -------------------------------------------------------------------------------- 1 | import { SqliteIntrospectorDialect } from '../../../introspector/dialects/sqlite/sqlite-dialect'; 2 | import type { GeneratorDialect } from '../../dialect'; 3 | import { SqliteAdapter } from './sqlite-adapter'; 4 | 5 | export class SqliteDialect 6 | extends SqliteIntrospectorDialect 7 | implements GeneratorDialect 8 | { 9 | readonly adapter = new SqliteAdapter(); 10 | } 11 | -------------------------------------------------------------------------------- /src/generator/dialects/worker-bun-sqlite/worker-bun-sqlite-dialect.ts: -------------------------------------------------------------------------------- 1 | import { SqliteIntrospectorDialect } from '../../../introspector/dialects/sqlite/sqlite-dialect'; 2 | import type { GeneratorDialect } from '../../dialect'; 3 | import { SqliteAdapter } from '../sqlite/sqlite-adapter'; 4 | 5 | export class WorkerBunSqliteDialect 6 | extends SqliteIntrospectorDialect 7 | implements GeneratorDialect 8 | { 9 | readonly adapter = new SqliteAdapter(); 10 | } 11 | -------------------------------------------------------------------------------- /src/generator/generator/diff-checker.test.ts: -------------------------------------------------------------------------------- 1 | import { strictEqual } from 'node:assert'; 2 | import { DiffChecker } from './diff-checker'; 3 | 4 | test(DiffChecker.name, () => { 5 | strictEqual( 6 | new DiffChecker().diff('Foo\nBar\nBaz', 'Foo\nBar\nBaz'), 7 | undefined, 8 | ); 9 | strictEqual( 10 | new DiffChecker().diff('Foo\nBar\nBaz', 'Foo\nQux\nBaz'), 11 | '@@ -1,3 +1,3 @@\n Foo\n-Bar\n+Qux\n Baz\n', 12 | ); 13 | }); 14 | -------------------------------------------------------------------------------- /src/generator/generator/diff-checker.ts: -------------------------------------------------------------------------------- 1 | import gitDiff from 'git-diff'; 2 | 3 | export class DiffChecker { 4 | #sanitize(string: string) { 5 | // Add `\n` to the end to avoid the "No newline at end of file" warning: 6 | return `${string.trim()}\n`; 7 | } 8 | 9 | diff(oldTypes: string, newTypes: string) { 10 | return gitDiff(this.#sanitize(oldTypes), this.#sanitize(newTypes)); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/generator/generator/generate.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely } from 'kysely'; 2 | import { promises as fs } from 'node:fs'; 3 | import { parse, relative, resolve, sep } from 'node:path'; 4 | import { performance } from 'node:perf_hooks'; 5 | import type { DatabaseMetadata } from '../../introspector'; 6 | import { DEFAULT_OUT_FILE } from '../constants'; 7 | import type { GeneratorDialect } from '../dialect'; 8 | import type { Logger } from '../logger/logger'; 9 | import { type Overrides } from '../transformer/transformer'; 10 | import { DiffChecker } from './diff-checker'; 11 | import type { RuntimeEnumsStyle } from './runtime-enums-style'; 12 | import type { Serializer } from './serializer'; 13 | import { TypeScriptSerializer } from './serializer'; 14 | 15 | export type GenerateOptions = { 16 | camelCase?: boolean; 17 | db: Kysely; 18 | defaultSchemas?: string[]; 19 | dialect: GeneratorDialect; 20 | excludePattern?: string | null; 21 | includePattern?: string | null; 22 | logger?: Logger; 23 | outFile?: string | null; 24 | overrides?: Overrides; 25 | partitions?: boolean; 26 | print?: boolean; 27 | runtimeEnums?: boolean | RuntimeEnumsStyle; 28 | serializer?: Serializer; 29 | singularize?: boolean | Record; 30 | skipAutogeneratedFileComment?: boolean; 31 | typeOnlyImports?: boolean; 32 | verify?: boolean; 33 | }; 34 | 35 | export type SerializeFromMetadataOptions = Omit< 36 | GenerateOptions, 37 | | 'db' 38 | | 'excludePattern' 39 | | 'includePattern' 40 | | 'outFile' 41 | | 'partitions' 42 | | 'print' 43 | | 'verify' 44 | > & { 45 | metadata: DatabaseMetadata; 46 | startTime?: number; 47 | }; 48 | 49 | export const generate = async (options: GenerateOptions) => { 50 | const startTime = performance.now(); 51 | 52 | options.logger?.info('Introspecting database...'); 53 | 54 | const metadata = await options.dialect.introspector.introspect({ 55 | db: options.db, 56 | excludePattern: options.excludePattern, 57 | includePattern: options.includePattern, 58 | partitions: options.partitions, 59 | }); 60 | 61 | const newOutput = serializeFromMetadata({ ...options, metadata, startTime }); 62 | 63 | const outFile = 64 | options.outFile === undefined 65 | ? DEFAULT_OUT_FILE 66 | : options.outFile === null 67 | ? null 68 | : resolve(process.cwd(), options.outFile); 69 | 70 | if (options.print) { 71 | console.info(); 72 | console.info(newOutput); 73 | } else if (outFile) { 74 | if (options.verify) { 75 | const oldOutput = await fs.readFile(outFile, 'utf8'); 76 | const diffChecker = new DiffChecker(); 77 | const diff = diffChecker.diff(newOutput, oldOutput); 78 | 79 | if (diff) { 80 | options.logger?.error(diff); 81 | throw new Error( 82 | "Generated types are not up-to-date! Use '--log-level=error' option to view the diff.", 83 | ); 84 | } 85 | 86 | const endTime = performance.now(); 87 | const duration = Math.round(endTime - startTime); 88 | 89 | options.logger?.success( 90 | `Generated types are up-to-date! (${duration}ms)`, 91 | ); 92 | } else { 93 | const outDir = parse(outFile).dir; 94 | 95 | await fs.mkdir(outDir, { recursive: true }); 96 | await fs.writeFile(outFile, newOutput); 97 | 98 | const endTime = performance.now(); 99 | const duration = Math.round(endTime - startTime); 100 | const tableCount = metadata.tables.length; 101 | const s = tableCount === 1 ? '' : 's'; 102 | const relativePath = `.${sep}${relative(process.cwd(), outFile)}`; 103 | 104 | options.logger?.success( 105 | `Introspected ${tableCount} table${s} and generated ${relativePath} in ${duration}ms.\n`, 106 | ); 107 | } 108 | } else { 109 | options.logger?.success('No output file specified. Skipping file write.'); 110 | } 111 | 112 | return newOutput; 113 | }; 114 | 115 | export const serializeFromMetadata = ( 116 | options: SerializeFromMetadataOptions, 117 | ) => { 118 | options.logger?.debug(); 119 | 120 | const s = options.metadata.tables.length === 1 ? '' : 's'; 121 | options.logger?.debug( 122 | `Found ${options.metadata.tables.length} public table${s}:`, 123 | ); 124 | 125 | for (const table of options.metadata.tables) { 126 | options.logger?.debug(` - ${table.name}`); 127 | } 128 | 129 | options.logger?.debug(); 130 | 131 | const serializer = 132 | options.serializer ?? 133 | new TypeScriptSerializer({ 134 | runtimeEnums: options.runtimeEnums, 135 | singularize: options.singularize, 136 | skipAutogeneratedFileComment: options.skipAutogeneratedFileComment, 137 | typeOnlyImports: options.typeOnlyImports, 138 | }); 139 | 140 | return serializer.serializeFile(options.metadata, options.dialect, { 141 | camelCase: options.camelCase, 142 | defaultSchemas: options.defaultSchemas, 143 | overrides: options.overrides, 144 | }); 145 | }; 146 | -------------------------------------------------------------------------------- /src/generator/generator/runtime-enums-style.ts: -------------------------------------------------------------------------------- 1 | export type RuntimeEnumsStyle = 'pascal-case' | 'screaming-snake-case'; 2 | -------------------------------------------------------------------------------- /src/generator/generator/serializer.ts: -------------------------------------------------------------------------------- 1 | import type { DatabaseMetadata } from '../../introspector'; 2 | import type { AliasDeclarationNode } from '../ast/alias-declaration-node'; 3 | import type { ArrayExpressionNode } from '../ast/array-expression-node'; 4 | import type { ExportStatementNode } from '../ast/export-statement-node'; 5 | import type { ExpressionNode } from '../ast/expression-node'; 6 | import type { ExtendsClauseNode } from '../ast/extends-clause-node'; 7 | import type { GenericExpressionNode } from '../ast/generic-expression-node'; 8 | import { type IdentifierNode } from '../ast/identifier-node'; 9 | import type { ImportClauseNode } from '../ast/import-clause-node'; 10 | import type { ImportStatementNode } from '../ast/import-statement-node'; 11 | import type { InferClauseNode } from '../ast/infer-clause-node'; 12 | import type { InterfaceDeclarationNode } from '../ast/interface-declaration-node'; 13 | import type { LiteralNode } from '../ast/literal-node'; 14 | import type { MappedTypeNode } from '../ast/mapped-type-node'; 15 | import type { ObjectExpressionNode } from '../ast/object-expression-node'; 16 | import type { PropertyNode } from '../ast/property-node'; 17 | import type { RawExpressionNode } from '../ast/raw-expression-node'; 18 | import type { RuntimeEnumDeclarationNode } from '../ast/runtime-enum-declaration-node'; 19 | import type { StatementNode } from '../ast/statement-node'; 20 | import type { UnionExpressionNode } from '../ast/union-expression-node'; 21 | import type { GeneratorDialect } from '../dialect'; 22 | import { transform, type Overrides } from '../transformer/transformer'; 23 | import { toPascalCase, toScreamingSnakeCase } from '../utils/case-converter'; 24 | import type { RuntimeEnumsStyle } from './runtime-enums-style'; 25 | import { createSingularizer } from './singularizer'; 26 | 27 | const IDENTIFIER_REGEXP = /^[$A-Z_a-z][\w$]*$/; 28 | 29 | export type SerializeFileOptions = { 30 | camelCase?: boolean; 31 | defaultSchemas?: string[]; 32 | overrides?: Overrides; 33 | }; 34 | 35 | type TypeScriptSerializerOptions = { 36 | runtimeEnums?: boolean | RuntimeEnumsStyle; 37 | singularize?: boolean | Record; 38 | skipAutogeneratedFileComment?: boolean; 39 | typeOnlyImports?: boolean; 40 | }; 41 | 42 | export abstract class Serializer { 43 | abstract serializeFile( 44 | metadata: DatabaseMetadata, 45 | dialect: GeneratorDialect, 46 | options?: SerializeFileOptions, 47 | ): string; 48 | } 49 | 50 | export class TypeScriptSerializer implements Serializer { 51 | readonly runtimeEnums: boolean | RuntimeEnumsStyle; 52 | readonly singularize: ((word: string) => string) | undefined; 53 | readonly skipAutogeneratedFileComment: boolean; 54 | readonly typeOnlyImports: boolean; 55 | 56 | constructor(options: TypeScriptSerializerOptions = {}) { 57 | this.runtimeEnums = options.runtimeEnums ?? false; 58 | this.skipAutogeneratedFileComment = 59 | options.skipAutogeneratedFileComment ?? false; 60 | this.typeOnlyImports = options.typeOnlyImports ?? true; 61 | 62 | if (options.singularize) { 63 | this.singularize = createSingularizer(options.singularize); 64 | } 65 | } 66 | 67 | serializeAliasDeclaration(node: AliasDeclarationNode) { 68 | const expression = 69 | node.body.type === 'Template' ? node.body.expression : node.body; 70 | let data = ''; 71 | 72 | data += 'type '; 73 | data += node.id.name; 74 | 75 | if (node.body.type === 'Template') { 76 | data += '<'; 77 | 78 | for (let i = 0; i < node.body.params.length; i++) { 79 | if (i >= 1) { 80 | data += ', '; 81 | } 82 | 83 | data += node.body.params[i]!; 84 | } 85 | 86 | data += '>'; 87 | } 88 | 89 | data += ' = '; 90 | data += this.serializeExpression(expression); 91 | data += ';'; 92 | 93 | return data; 94 | } 95 | 96 | serializeArrayExpression(node: ArrayExpressionNode) { 97 | const shouldParenthesize = 98 | node.values.type === 'InferClause' || 99 | (node.values.type === 'UnionExpression' && node.values.args.length >= 2); 100 | let data = ''; 101 | 102 | if (shouldParenthesize) { 103 | data += '('; 104 | } 105 | 106 | data += this.serializeExpression(node.values); 107 | 108 | if (shouldParenthesize) { 109 | data += ')'; 110 | } 111 | 112 | data += '[]'; 113 | 114 | return data; 115 | } 116 | 117 | serializeExportStatement(node: ExportStatementNode) { 118 | let data = ''; 119 | 120 | data += 'export '; 121 | 122 | switch (node.argument.type) { 123 | case 'AliasDeclaration': 124 | data += this.serializeAliasDeclaration(node.argument); 125 | break; 126 | case 'InterfaceDeclaration': 127 | data += this.serializeInterfaceDeclaration(node.argument); 128 | break; 129 | case 'RuntimeEnumDeclaration': 130 | data += this.serializeRuntimeEnum(node.argument); 131 | break; 132 | } 133 | 134 | return data; 135 | } 136 | 137 | serializeExpression(node: ExpressionNode) { 138 | switch (node.type) { 139 | case 'ArrayExpression': 140 | return this.serializeArrayExpression(node); 141 | case 'ExtendsClause': 142 | return this.serializeExtendsClause(node); 143 | case 'GenericExpression': 144 | return this.serializeGenericExpression(node); 145 | case 'Identifier': 146 | return this.serializeIdentifier(node); 147 | case 'InferClause': 148 | return this.serializeInferClause(node); 149 | case 'Literal': 150 | return this.serializeLiteral(node); 151 | case 'MappedType': 152 | return this.serializeMappedType(node); 153 | case 'ObjectExpression': 154 | return this.serializeObjectExpression(node); 155 | case 'RawExpression': 156 | return this.serializeRawExpression(node); 157 | case 'UnionExpression': 158 | return this.serializeUnionExpression(node); 159 | } 160 | } 161 | 162 | serializeExtendsClause(node: ExtendsClauseNode) { 163 | let data = ''; 164 | 165 | data += this.serializeExpression(node.checkType); 166 | data += ' extends '; 167 | data += this.serializeExpression(node.extendsType); 168 | data += '\n ? '; 169 | data += this.serializeExpression(node.trueType); 170 | data += '\n : '; 171 | data += this.serializeExpression(node.falseType); 172 | 173 | return data; 174 | } 175 | 176 | serializeFile( 177 | metadata: DatabaseMetadata, 178 | dialect: GeneratorDialect, 179 | options?: SerializeFileOptions, 180 | ) { 181 | let data = ''; 182 | 183 | if (!this.skipAutogeneratedFileComment) { 184 | data += '/**\n'; 185 | data += ' * This file was generated by kysely-codegen.\n'; 186 | data += ' * Please do not edit it manually.\n'; 187 | data += ' */\n\n'; 188 | } 189 | 190 | data += this.serializeStatements( 191 | transform({ 192 | camelCase: options?.camelCase, 193 | defaultSchemas: options?.defaultSchemas, 194 | dialect, 195 | metadata, 196 | overrides: options?.overrides, 197 | runtimeEnums: this.runtimeEnums, 198 | }), 199 | ); 200 | 201 | return data; 202 | } 203 | 204 | serializeGenericExpression(node: GenericExpressionNode) { 205 | let data = ''; 206 | 207 | data += node.name; 208 | data += '<'; 209 | 210 | for (let i = 0; i < node.args.length; i++) { 211 | if (i >= 1) { 212 | data += ', '; 213 | } 214 | 215 | data += this.serializeExpression(node.args[i]!); 216 | } 217 | 218 | data += '>'; 219 | 220 | return data; 221 | } 222 | 223 | serializeIdentifier(node: IdentifierNode) { 224 | return this.singularize && node.isTableIdentifier 225 | ? toPascalCase(this.singularize(node.name)) 226 | : node.name; 227 | } 228 | 229 | serializeImportClause(node: ImportClauseNode) { 230 | let data = ''; 231 | 232 | data += node.name; 233 | 234 | if (node.alias) { 235 | data += ' as '; 236 | data += node.alias; 237 | } 238 | 239 | return data; 240 | } 241 | 242 | serializeImportStatement(node: ImportStatementNode) { 243 | let data = ''; 244 | let i = 0; 245 | 246 | data += 'import '; 247 | 248 | if (this.typeOnlyImports) { 249 | data += 'type '; 250 | } 251 | 252 | data += '{'; 253 | 254 | for (const importClause of node.imports) { 255 | if (i >= 1) { 256 | data += ','; 257 | } 258 | 259 | data += ' '; 260 | data += this.serializeImportClause(importClause); 261 | i++; 262 | } 263 | 264 | data += ' } from '; 265 | data += JSON.stringify(node.moduleName); 266 | data += ';'; 267 | 268 | return data; 269 | } 270 | 271 | serializeInferClause(node: InferClauseNode) { 272 | let data = ''; 273 | 274 | data += 'infer '; 275 | data += node.name; 276 | 277 | return data; 278 | } 279 | 280 | serializeInterfaceDeclaration(node: InterfaceDeclarationNode) { 281 | let data = ''; 282 | 283 | data += 'interface '; 284 | data += this.serializeIdentifier(node.id); 285 | data += ' '; 286 | data += this.serializeObjectExpression(node.body); 287 | 288 | return data; 289 | } 290 | 291 | serializeLiteral(node: LiteralNode) { 292 | return JSON.stringify(node.value); 293 | } 294 | 295 | serializeKey(key: string) { 296 | return IDENTIFIER_REGEXP.test(key) ? key : JSON.stringify(key); 297 | } 298 | 299 | serializeMappedType(node: MappedTypeNode) { 300 | let data = ''; 301 | 302 | data += '{\n [x: string]: '; 303 | data += this.serializeExpression(node.value); 304 | data += ' | undefined;\n}'; 305 | 306 | return data; 307 | } 308 | 309 | serializeObjectExpression(node: ObjectExpressionNode) { 310 | let data = ''; 311 | 312 | data += '{'; 313 | 314 | if (node.properties.length > 0) { 315 | data += '\n'; 316 | 317 | const sortedProperties = [...node.properties].sort((a, b) => 318 | a.key.localeCompare(b.key), 319 | ); 320 | 321 | for (const property of sortedProperties) { 322 | data += ' '; 323 | data += this.serializeProperty(property); 324 | } 325 | } 326 | 327 | data += '}'; 328 | 329 | return data; 330 | } 331 | 332 | serializeProperty(node: PropertyNode) { 333 | let data = ''; 334 | 335 | if (node.comment) { 336 | data += '/**\n'; 337 | 338 | for (const line of node.comment.split(/\r?\n/)) { 339 | data += ` *${line ? ` ${line}` : ''}\n`; 340 | } 341 | 342 | data += ' */\n '; 343 | } 344 | 345 | data += this.serializeKey(node.key); 346 | data += ': '; 347 | data += this.serializeExpression(node.value); 348 | data += ';\n'; 349 | 350 | return data; 351 | } 352 | 353 | serializeRawExpression(node: RawExpressionNode) { 354 | return node.expression; 355 | } 356 | 357 | serializeRuntimeEnum(node: RuntimeEnumDeclarationNode) { 358 | let data = 'enum '; 359 | 360 | data += node.id.name; 361 | data += ' {\n'; 362 | 363 | const members = [...node.members].sort(([a], [b]) => { 364 | return a.localeCompare(b); 365 | }); 366 | 367 | for (const member of members) { 368 | data += ' '; 369 | 370 | if (this.runtimeEnums === 'pascal-case') { 371 | data += toPascalCase(member[0]); 372 | } else { 373 | data += toScreamingSnakeCase(member[0]); 374 | } 375 | 376 | data += ' = '; 377 | data += this.serializeLiteral(member[1]); 378 | data += ','; 379 | data += '\n'; 380 | } 381 | 382 | data += '}'; 383 | 384 | return data; 385 | } 386 | 387 | serializeStatements(nodes: StatementNode[]) { 388 | let data = ''; 389 | let i = 0; 390 | 391 | for (const node of nodes) { 392 | if (i >= 1) { 393 | data += '\n'; 394 | 395 | if (node.type !== 'ImportStatement') { 396 | data += '\n'; 397 | } 398 | } 399 | 400 | switch (node.type) { 401 | case 'ExportStatement': 402 | data += this.serializeExportStatement(node); 403 | break; 404 | case 'ImportStatement': 405 | data += this.serializeImportStatement(node); 406 | break; 407 | } 408 | 409 | i++; 410 | } 411 | 412 | data += '\n'; 413 | 414 | return data; 415 | } 416 | 417 | serializeUnionExpression(node: UnionExpressionNode) { 418 | let data = ''; 419 | let i = 0; 420 | 421 | const sortedArgs = [...node.args].sort((a, b) => { 422 | if (a.type !== 'Identifier' || b.type !== 'Identifier') { 423 | return 0; 424 | } 425 | if (a.name === undefined || a.name === 'undefined') return 1; 426 | if (b.name === undefined || b.name === 'undefined') return -1; 427 | if (a.name === null || a.name === 'null') return 1; 428 | if (b.name === null || b.name === 'null') return -1; 429 | if (a.name < b.name) return -1; 430 | if (a.name > b.name) return 1; 431 | return 0; 432 | }); 433 | 434 | for (const arg of sortedArgs) { 435 | if (i >= 1) { 436 | data += ' | '; 437 | } 438 | 439 | data += this.serializeExpression(arg); 440 | i++; 441 | } 442 | 443 | return data; 444 | } 445 | } 446 | -------------------------------------------------------------------------------- /src/generator/generator/singularizer.test.ts: -------------------------------------------------------------------------------- 1 | import { createSingularizer } from './singularizer'; 2 | 3 | describe(createSingularizer.name, () => { 4 | test('rules array', () => { 5 | const singularize = createSingularizer([ 6 | ['/^(.*?)s?$/', '$1_model'], 7 | ['/(bacch)(?:us|i)$/i', '$1us'], 8 | ['beeves', 'beef'], 9 | ]); 10 | 11 | expect(singularize('bacchus')).toStrictEqual('bacchus'); 12 | expect(singularize('bacchi')).toStrictEqual('bacchus'); 13 | expect(singularize('beef')).toStrictEqual('beef_model'); 14 | expect(singularize('beeves')).toStrictEqual('beef'); 15 | expect(singularize('users')).toStrictEqual('user_model'); 16 | }); 17 | 18 | test('rules object', () => { 19 | const singularize = createSingularizer({ 20 | '/^(.*?)s?$/': '$1_model', 21 | '/(bacch)(?:us|i)$/i': '$1us', 22 | beeves: 'beef', 23 | }); 24 | 25 | expect(singularize('bacchus')).toStrictEqual('bacchus'); 26 | expect(singularize('bacchi')).toStrictEqual('bacchus'); 27 | expect(singularize('beef')).toStrictEqual('beef_model'); 28 | expect(singularize('beeves')).toStrictEqual('beef'); 29 | expect(singularize('users')).toStrictEqual('user_model'); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/generator/generator/singularizer.ts: -------------------------------------------------------------------------------- 1 | const REGEXP_KEY = /^\/(.*)\/(.*)$/; 2 | 3 | const addSingularizationRules = ( 4 | pluralize: typeof import('pluralize'), 5 | rules: Record | [string, string][], 6 | ) => { 7 | const entries = Array.isArray(rules) ? rules : Object.entries(rules); 8 | 9 | for (const [key, replacement] of entries) { 10 | const regExpMatch = key.match(REGEXP_KEY); 11 | const rule = regExpMatch 12 | ? new RegExp(regExpMatch[1]!, regExpMatch[2]) 13 | : key; 14 | pluralize.addSingularRule(rule, replacement); 15 | } 16 | }; 17 | 18 | const importPluralize = () => { 19 | const moduleId = Object.values(require.cache).find((module) => { 20 | return module?.path.endsWith('/pluralize'); 21 | })?.id; 22 | 23 | if (moduleId) { 24 | delete require.cache[moduleId]; 25 | } 26 | 27 | return require('pluralize') as typeof import('pluralize'); 28 | }; 29 | 30 | export const createSingularizer = ( 31 | rules?: Record | [string, string][] | boolean, 32 | ) => { 33 | const pluralize = importPluralize(); 34 | 35 | if (typeof rules === 'object') { 36 | addSingularizationRules(pluralize, rules); 37 | } 38 | 39 | return pluralize.singular; 40 | }; 41 | -------------------------------------------------------------------------------- /src/generator/generator/snapshots/libsql.snapshot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file was generated by kysely-codegen. 3 | * Please do not edit it manually. 4 | */ 5 | 6 | import type { ColumnType } from "kysely"; 7 | 8 | export type Generated = T extends ColumnType 9 | ? ColumnType 10 | : ColumnType; 11 | 12 | export interface FooBar { 13 | false: number; 14 | id: Generated; 15 | overridden: "OVERRIDDEN"; 16 | true: number; 17 | userStatus: string | null; 18 | } 19 | 20 | export interface LibsqlWasmFuncTable { 21 | body: string | null; 22 | name: string; 23 | } 24 | 25 | export interface DB { 26 | fooBar: FooBar; 27 | libsqlWasmFuncTable: LibsqlWasmFuncTable; 28 | } 29 | -------------------------------------------------------------------------------- /src/generator/generator/snapshots/mysql.snapshot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file was generated by kysely-codegen. 3 | * Please do not edit it manually. 4 | */ 5 | 6 | import type { ColumnType } from "kysely"; 7 | 8 | export type Generated = T extends ColumnType 9 | ? ColumnType 10 | : ColumnType; 11 | 12 | export interface FooBar { 13 | false: number; 14 | id: Generated; 15 | overridden: "OVERRIDDEN"; 16 | true: number; 17 | userStatus: "CONFIRMED" | "UNCONFIRMED" | null; 18 | } 19 | 20 | export interface DB { 21 | fooBar: FooBar; 22 | } 23 | -------------------------------------------------------------------------------- /src/generator/generator/snapshots/postgres.snapshot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file was generated by kysely-codegen. 3 | * Please do not edit it manually. 4 | */ 5 | 6 | import type { ColumnType, JSONColumnType } from "kysely"; 7 | import type { IPostgresInterval } from "postgres-interval"; 8 | 9 | export type ArrayType = ArrayTypeImpl extends (infer U)[] 10 | ? U[] 11 | : ArrayTypeImpl; 12 | 13 | export type ArrayTypeImpl = T extends ColumnType 14 | ? ColumnType 15 | : T[]; 16 | 17 | export type Generated = T extends ColumnType 18 | ? ColumnType 19 | : ColumnType; 20 | 21 | export type Interval = ColumnType; 22 | 23 | export type Json = JsonValue; 24 | 25 | export type JsonArray = JsonValue[]; 26 | 27 | export type JsonObject = { 28 | [x: string]: JsonValue | undefined; 29 | }; 30 | 31 | export type JsonPrimitive = boolean | number | string | null; 32 | 33 | export type JsonValue = JsonArray | JsonObject | JsonPrimitive; 34 | 35 | export type Numeric = ColumnType; 36 | 37 | export type Status = "CONFIRMED" | "UNCONFIRMED"; 38 | 39 | export type TestStatus = "ABC_DEF" | "GHI_JKL"; 40 | 41 | export type Timestamp = ColumnType; 42 | 43 | export interface Enum { 44 | name: string; 45 | } 46 | 47 | export interface FooBar { 48 | array: string[] | null; 49 | childDomain: number | null; 50 | date: Timestamp | null; 51 | defaultedNullablePosInt: Generated; 52 | defaultedRequiredPosInt: Generated; 53 | enum: string; 54 | /** 55 | * This is a comment on a column. 56 | * 57 | * It's nice, isn't it? 58 | */ 59 | false: boolean; 60 | id: Generated; 61 | interval1: Interval | null; 62 | interval2: Interval | null; 63 | json: Json | null; 64 | jsonTyped: JSONColumnType<{ foo: "bar" }>; 65 | nullablePosInt: number | null; 66 | numeric1: Numeric | null; 67 | numeric2: Numeric | null; 68 | overridden: "OVERRIDDEN"; 69 | testDomainIsBool: boolean | null; 70 | timestamps: ArrayType | null; 71 | true: boolean; 72 | userStatus: Status | null; 73 | userStatus2: TestStatus | null; 74 | } 75 | 76 | export interface PartitionedTable { 77 | id: Generated; 78 | } 79 | 80 | export interface DB { 81 | enum: Enum; 82 | fooBar: FooBar; 83 | partitionedTable: PartitionedTable; 84 | } 85 | -------------------------------------------------------------------------------- /src/generator/generator/snapshots/postgres2.snapshot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file was generated by kysely-codegen. 3 | * Please do not edit it manually. 4 | */ 5 | 6 | import type { ColumnType, JSONColumnType } from "kysely"; 7 | import type { IPostgresInterval } from "postgres-interval"; 8 | 9 | export enum Status { 10 | CONFIRMED = "CONFIRMED", 11 | UNCONFIRMED = "UNCONFIRMED", 12 | } 13 | 14 | export enum TestStatus { 15 | ABC_DEF = "ABC_DEF", 16 | GHI_JKL = "GHI_JKL", 17 | } 18 | 19 | export type ArrayType = ArrayTypeImpl extends (infer U)[] 20 | ? U[] 21 | : ArrayTypeImpl; 22 | 23 | export type ArrayTypeImpl = T extends ColumnType 24 | ? ColumnType 25 | : T[]; 26 | 27 | export type Generated = T extends ColumnType 28 | ? ColumnType 29 | : ColumnType; 30 | 31 | export type Interval = ColumnType; 32 | 33 | export type Json = JsonValue; 34 | 35 | export type JsonArray = JsonValue[]; 36 | 37 | export type JsonObject = { 38 | [x: string]: JsonValue | undefined; 39 | }; 40 | 41 | export type JsonPrimitive = boolean | number | string | null; 42 | 43 | export type JsonValue = JsonArray | JsonObject | JsonPrimitive; 44 | 45 | export type Numeric = ColumnType; 46 | 47 | export type Timestamp = ColumnType; 48 | 49 | export interface Enum { 50 | name: string; 51 | } 52 | 53 | export interface FooBar { 54 | array: string[] | null; 55 | childDomain: number | null; 56 | date: string | null; 57 | defaultedNullablePosInt: Generated; 58 | defaultedRequiredPosInt: Generated; 59 | enum: string; 60 | /** 61 | * This is a comment on a column. 62 | * 63 | * It's nice, isn't it? 64 | */ 65 | false: boolean; 66 | id: Generated; 67 | interval1: Interval | null; 68 | interval2: Interval | null; 69 | json: Json | null; 70 | jsonTyped: JSONColumnType<{ foo: "bar" }>; 71 | nullablePosInt: number | null; 72 | numeric1: Numeric | null; 73 | numeric2: Numeric | null; 74 | overridden: "OVERRIDDEN"; 75 | testDomainIsBool: boolean | null; 76 | timestamps: ArrayType | null; 77 | true: boolean; 78 | userStatus: Status | null; 79 | userStatus2: TestStatus | null; 80 | } 81 | 82 | export interface PartitionedTable { 83 | id: Generated; 84 | } 85 | 86 | export interface DB { 87 | enum: Enum; 88 | fooBar: FooBar; 89 | partitionedTable: PartitionedTable; 90 | } 91 | -------------------------------------------------------------------------------- /src/generator/generator/snapshots/sqlite.snapshot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file was generated by kysely-codegen. 3 | * Please do not edit it manually. 4 | */ 5 | 6 | import type { ColumnType } from "kysely"; 7 | 8 | export type Generated = T extends ColumnType 9 | ? ColumnType 10 | : ColumnType; 11 | 12 | export interface FooBar { 13 | false: number; 14 | id: Generated; 15 | overridden: "OVERRIDDEN"; 16 | true: number; 17 | userStatus: string | null; 18 | } 19 | 20 | export interface DB { 21 | fooBar: FooBar; 22 | } 23 | -------------------------------------------------------------------------------- /src/generator/index.ts: -------------------------------------------------------------------------------- 1 | export * from './adapter'; 2 | export * from './ast/alias-declaration-node'; 3 | export * from './ast/array-expression-node'; 4 | export * from './ast/column-type-node'; 5 | export * from './ast/definition-node'; 6 | export * from './ast/export-statement-node'; 7 | export * from './ast/expression-node'; 8 | export * from './ast/extends-clause-node'; 9 | export * from './ast/generic-expression-node'; 10 | export * from './ast/identifier-node'; 11 | export * from './ast/import-clause-node'; 12 | export * from './ast/import-statement-node'; 13 | export * from './ast/infer-clause-node'; 14 | export * from './ast/interface-declaration-node'; 15 | export * from './ast/json-column-type-node'; 16 | export * from './ast/literal-node'; 17 | export * from './ast/mapped-type-node'; 18 | export * from './ast/module-reference-node'; 19 | export * from './ast/object-expression-node'; 20 | export * from './ast/property-node'; 21 | export * from './ast/raw-expression-node'; 22 | export * from './ast/runtime-enum-declaration-node'; 23 | export * from './ast/statement-node'; 24 | export * from './ast/template-node'; 25 | export * from './ast/union-expression-node'; 26 | export * from './connection-string-parser'; 27 | export * from './constants'; 28 | export * from './dialect'; 29 | export * from './dialects/kysely-bun-sqlite/kysely-bun-sqlite-dialect'; 30 | export * from './dialects/libsql/libsql-adapter'; 31 | export * from './dialects/libsql/libsql-dialect'; 32 | export * from './dialects/mysql/mysql-adapter'; 33 | export * from './dialects/mysql/mysql-dialect'; 34 | export * from './dialects/postgres/postgres-adapter'; 35 | export * from './dialects/postgres/postgres-dialect'; 36 | export * from './dialects/sqlite/sqlite-adapter'; 37 | export * from './dialects/sqlite/sqlite-dialect'; 38 | export * from './dialects/worker-bun-sqlite/worker-bun-sqlite-dialect'; 39 | export * from './generator/diff-checker'; 40 | export * from './generator/generate'; 41 | export * from './generator/runtime-enums-style'; 42 | export * from './generator/serializer'; 43 | export * from './logger/log-level'; 44 | export * from './logger/logger'; 45 | export * from './transformer/definitions'; 46 | export * from './transformer/identifier-style'; 47 | export * from './transformer/imports'; 48 | export * from './transformer/symbol-collection'; 49 | export * from './transformer/transformer'; 50 | export * from './utils/case-converter'; 51 | -------------------------------------------------------------------------------- /src/generator/logger/log-level.test.ts: -------------------------------------------------------------------------------- 1 | import type { LogLevel } from './log-level'; 2 | import { getLogLevelNumber, matchLogLevel } from './log-level'; 3 | 4 | test(getLogLevelNumber.name, () => { 5 | expect(getLogLevelNumber('invalid' as LogLevel)).toStrictEqual(-1); 6 | expect(getLogLevelNumber('silent')).toStrictEqual(0); 7 | expect(getLogLevelNumber('error')).toStrictEqual(1); 8 | expect(getLogLevelNumber('warn')).toStrictEqual(2); 9 | expect(getLogLevelNumber('info')).toStrictEqual(3); 10 | expect(getLogLevelNumber('debug')).toStrictEqual(4); 11 | }); 12 | 13 | test(matchLogLevel.name, () => { 14 | expect(matchLogLevel({ actual: 'error', expected: 'error' })).toStrictEqual( 15 | true, 16 | ); 17 | expect(matchLogLevel({ actual: 'error', expected: 'warn' })).toStrictEqual( 18 | false, 19 | ); 20 | expect(matchLogLevel({ actual: 'warn', expected: 'error' })).toStrictEqual( 21 | true, 22 | ); 23 | expect(matchLogLevel({ actual: 'warn', expected: 'warn' })).toStrictEqual( 24 | true, 25 | ); 26 | expect(matchLogLevel({ actual: 'warn', expected: 'info' })).toStrictEqual( 27 | false, 28 | ); 29 | expect(matchLogLevel({ actual: 'debug', expected: 'error' })).toStrictEqual( 30 | true, 31 | ); 32 | expect(matchLogLevel({ actual: 'debug', expected: 'info' })).toStrictEqual( 33 | true, 34 | ); 35 | expect(matchLogLevel({ actual: 'debug', expected: 'debug' })).toStrictEqual( 36 | true, 37 | ); 38 | }); 39 | -------------------------------------------------------------------------------- /src/generator/logger/log-level.ts: -------------------------------------------------------------------------------- 1 | export type LogLevel = (typeof LOG_LEVELS)[number]; 2 | 3 | export const DEFAULT_LOG_LEVEL: LogLevel = 'warn'; 4 | 5 | export const LOG_LEVELS = ['silent', 'error', 'warn', 'info', 'debug'] as const; 6 | 7 | export const getLogLevelNumber = (logLevel: LogLevel) => { 8 | return ['silent', 'error', 'warn', 'info', 'debug'].indexOf(logLevel); 9 | }; 10 | 11 | export const matchLogLevel = ({ 12 | actual, 13 | expected, 14 | }: { 15 | actual: LogLevel; 16 | expected: LogLevel; 17 | }) => { 18 | return getLogLevelNumber(actual) >= getLogLevelNumber(expected); 19 | }; 20 | -------------------------------------------------------------------------------- /src/generator/logger/logger.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { inspect } from 'util'; 3 | import type { LogLevel } from './log-level'; 4 | import { matchLogLevel } from './log-level'; 5 | 6 | export class Logger { 7 | readonly logLevel: LogLevel; 8 | 9 | constructor(logLevel: LogLevel = 'info') { 10 | this.logLevel = logLevel; 11 | } 12 | 13 | #log( 14 | consoleMethod: 'debug' | 'error' | 'info' | 'log' | 'warn', 15 | color: 'blue' | 'gray' | 'green' | 'red' | 'yellow' | null, 16 | icon: string | null, 17 | values: unknown[], 18 | ) { 19 | const texts = [...(icon === null ? [] : [icon]), ...values]; 20 | return console[consoleMethod]( 21 | ...texts.map((value) => { 22 | const text = ( 23 | typeof value === 'string' ? value : inspect(value, { colors: true }) 24 | ).replaceAll(/(\r?\n)/g, icon === null ? '$1' : '$1 '); 25 | return color ? chalk[color](text) : text; 26 | }), 27 | ); 28 | } 29 | 30 | #shouldLog(messageLogLevel: LogLevel) { 31 | return matchLogLevel({ actual: this.logLevel, expected: messageLogLevel }); 32 | } 33 | 34 | debug(...values: unknown[]) { 35 | if (this.#shouldLog('debug')) { 36 | this.#log('debug', 'gray', null, values); 37 | } 38 | } 39 | 40 | error(...values: unknown[]) { 41 | if (this.#shouldLog('error')) { 42 | this.#log('error', 'red', '✗', values); 43 | } 44 | } 45 | 46 | info(...values: unknown[]) { 47 | if (this.#shouldLog('info')) { 48 | this.#log('info', 'blue', '•', values); 49 | } 50 | } 51 | 52 | log(...values: unknown[]) { 53 | if (this.#shouldLog('info')) { 54 | console.log(...values); 55 | } 56 | } 57 | 58 | success(...values: unknown[]) { 59 | if (this.#shouldLog('info')) { 60 | this.#log('log', 'green', '✓', values); 61 | } 62 | } 63 | 64 | warn(...values: unknown[]) { 65 | if (this.#shouldLog('warn')) { 66 | this.#log('warn', 'yellow', '⚠', values); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/generator/transformer/definitions.ts: -------------------------------------------------------------------------------- 1 | import { ArrayExpressionNode } from '../ast/array-expression-node'; 2 | import { ColumnTypeNode } from '../ast/column-type-node'; 3 | import type { DefinitionNode } from '../ast/definition-node'; 4 | import { ExtendsClauseNode } from '../ast/extends-clause-node'; 5 | import { GenericExpressionNode } from '../ast/generic-expression-node'; 6 | import { IdentifierNode } from '../ast/identifier-node'; 7 | import { InferClauseNode } from '../ast/infer-clause-node'; 8 | import { MappedTypeNode } from '../ast/mapped-type-node'; 9 | import { TemplateNode } from '../ast/template-node'; 10 | import { UnionExpressionNode } from '../ast/union-expression-node'; 11 | 12 | export const GLOBAL_DEFINITIONS = { 13 | /** 14 | * @see https://github.com/RobinBlomberg/kysely-codegen/issues/135 15 | */ 16 | ArrayType: new TemplateNode( 17 | ['T'], 18 | new ExtendsClauseNode( 19 | new GenericExpressionNode('ArrayTypeImpl', [new IdentifierNode('T')]), 20 | new ArrayExpressionNode(new InferClauseNode('U')), 21 | new ArrayExpressionNode(new IdentifierNode('U')), 22 | new GenericExpressionNode('ArrayTypeImpl', [new IdentifierNode('T')]), 23 | ), 24 | ), 25 | /** 26 | * @see https://github.com/RobinBlomberg/kysely-codegen/issues/135 27 | */ 28 | ArrayTypeImpl: new TemplateNode( 29 | ['T'], 30 | new ExtendsClauseNode( 31 | new IdentifierNode('T'), 32 | new ColumnTypeNode( 33 | new InferClauseNode('S'), 34 | new InferClauseNode('I'), 35 | new InferClauseNode('U'), 36 | ), 37 | new ColumnTypeNode( 38 | new ArrayExpressionNode(new IdentifierNode('S')), 39 | new ArrayExpressionNode(new IdentifierNode('I')), 40 | new ArrayExpressionNode(new IdentifierNode('U')), 41 | ), 42 | new ArrayExpressionNode(new IdentifierNode('T')), 43 | ), 44 | ), 45 | Generated: new TemplateNode( 46 | ['T'], 47 | new ExtendsClauseNode( 48 | new IdentifierNode('T'), 49 | new ColumnTypeNode( 50 | new InferClauseNode('S'), 51 | new InferClauseNode('I'), 52 | new InferClauseNode('U'), 53 | ), 54 | new ColumnTypeNode( 55 | new IdentifierNode('S'), 56 | new UnionExpressionNode([ 57 | new IdentifierNode('I'), 58 | new IdentifierNode('undefined'), 59 | ]), 60 | new IdentifierNode('U'), 61 | ), 62 | new ColumnTypeNode( 63 | new IdentifierNode('T'), 64 | new UnionExpressionNode([ 65 | new IdentifierNode('T'), 66 | new IdentifierNode('undefined'), 67 | ]), 68 | new IdentifierNode('T'), 69 | ), 70 | ), 71 | ), 72 | }; 73 | 74 | export const JSON_ARRAY_DEFINITION: DefinitionNode = new ArrayExpressionNode( 75 | new IdentifierNode('JsonValue'), 76 | ); 77 | 78 | export const JSON_OBJECT_DEFINITION: DefinitionNode = new MappedTypeNode( 79 | new IdentifierNode('JsonValue'), 80 | ); 81 | 82 | export const JSON_PRIMITIVE_DEFINITION: DefinitionNode = 83 | new UnionExpressionNode([ 84 | new IdentifierNode('boolean'), 85 | new IdentifierNode('null'), 86 | new IdentifierNode('number'), 87 | new IdentifierNode('string'), 88 | ]); 89 | 90 | export const JSON_VALUE_DEFINITION: DefinitionNode = new UnionExpressionNode([ 91 | new IdentifierNode('JsonArray'), 92 | new IdentifierNode('JsonObject'), 93 | new IdentifierNode('JsonPrimitive'), 94 | ]); 95 | 96 | export const JSON_DEFINITION: DefinitionNode = new IdentifierNode('JsonValue'); 97 | -------------------------------------------------------------------------------- /src/generator/transformer/identifier-style.ts: -------------------------------------------------------------------------------- 1 | export type IdentifierStyle = 'kysely-pascal-case' | 'screaming-snake-case'; 2 | -------------------------------------------------------------------------------- /src/generator/transformer/imports.ts: -------------------------------------------------------------------------------- 1 | import { ModuleReferenceNode } from '../ast/module-reference-node'; 2 | 3 | export const GLOBAL_IMPORTS = { 4 | ColumnType: new ModuleReferenceNode('kysely'), 5 | JSONColumnType: new ModuleReferenceNode('kysely'), 6 | }; 7 | -------------------------------------------------------------------------------- /src/generator/transformer/symbol-collection.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual } from 'node:assert'; 2 | import { IdentifierNode } from '../ast/identifier-node'; 3 | import type { SymbolNode } from './symbol-collection'; 4 | import { SymbolCollection } from './symbol-collection'; 5 | 6 | test(SymbolCollection.name, () => { 7 | const symbols = new SymbolCollection(); 8 | const symbol: SymbolNode = { 9 | node: new IdentifierNode('FooBar'), 10 | type: 'Definition', 11 | }; 12 | 13 | symbols.set('foo-bar', symbol); 14 | symbols.set('foo__bar__', symbol); 15 | symbols.set('__foo__bar__', symbol); 16 | symbols.set('Foo, Bar!', symbol); 17 | symbols.set('Foo$Bar', symbol); 18 | symbols.set('0x123', symbol); 19 | symbols.set('!', symbol); 20 | symbols.set('"', symbol); 21 | 22 | deepStrictEqual(symbols.symbolNames, { 23 | 'foo-bar': 'FooBar', 24 | foo__bar__: 'FooBar2', 25 | __foo__bar__: '_FooBar', 26 | 'Foo, Bar!': 'FooBar3', 27 | Foo$Bar: 'Foo$Bar', 28 | '0x123': '_0x123', 29 | '!': '_', 30 | '"': '_2', 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/generator/transformer/symbol-collection.ts: -------------------------------------------------------------------------------- 1 | import type { ExpressionNode } from '../ast/expression-node'; 2 | import type { LiteralNode } from '../ast/literal-node'; 3 | import type { ModuleReferenceNode } from '../ast/module-reference-node'; 4 | import type { RuntimeEnumDeclarationNode } from '../ast/runtime-enum-declaration-node'; 5 | import type { TemplateNode } from '../ast/template-node'; 6 | import { 7 | toKyselyPascalCase, 8 | toScreamingSnakeCase, 9 | } from '../utils/case-converter'; 10 | import type { IdentifierStyle } from './identifier-style'; 11 | 12 | export type SymbolEntry = [id: string, symbol: SymbolNode]; 13 | 14 | type SymbolMap = Record; 15 | 16 | type SymbolNameMap = Record; 17 | 18 | export type SymbolNode = 19 | | { node: ExpressionNode | TemplateNode; type: 'Definition' } 20 | | { node: ModuleReferenceNode; type: 'ModuleReference' } 21 | | { node: RuntimeEnumDeclarationNode; type: 'RuntimeEnumDefinition' } 22 | | { node: LiteralNode; type: 'RuntimeEnumMember' } 23 | | { type: 'Table' }; 24 | 25 | export type SymbolType = 26 | | 'Definition' 27 | | 'ModuleReference' 28 | | 'RuntimeEnumDefinition' 29 | | 'RuntimeEnumMember' 30 | | 'Table'; 31 | 32 | export class SymbolCollection { 33 | readonly identifierStyle: IdentifierStyle; 34 | readonly symbolNames: SymbolNameMap = {}; 35 | readonly symbols: SymbolMap = {}; 36 | 37 | constructor(options?: { 38 | entries?: SymbolEntry[]; 39 | identifierStyle?: IdentifierStyle; 40 | }) { 41 | this.identifierStyle = options?.identifierStyle ?? 'kysely-pascal-case'; 42 | 43 | const entries = 44 | options?.entries?.sort(([a], [b]) => a.localeCompare(b)) ?? []; 45 | 46 | for (const [id, symbol] of entries) { 47 | this.set(id, symbol); 48 | } 49 | } 50 | 51 | entries() { 52 | return Object.entries(this.symbols) 53 | .sort(([a], [b]) => a.localeCompare(b)) 54 | .map(([id, symbol]) => ({ 55 | id, 56 | name: this.symbolNames[id]!, 57 | symbol: symbol!, 58 | })); 59 | } 60 | 61 | get(id: string) { 62 | return this.symbols[id]; 63 | } 64 | 65 | getName(id: string) { 66 | return this.symbolNames[id]; 67 | } 68 | 69 | has(id: string) { 70 | return this.symbols[id] !== undefined; 71 | } 72 | 73 | set(id: string, symbol: SymbolNode) { 74 | let symbolName = this.symbolNames[id]; 75 | 76 | if (symbolName) { 77 | return symbolName; 78 | } 79 | 80 | const symbolNames = new Set(Object.values(this.symbolNames)); 81 | const caseConverter = 82 | this.identifierStyle === 'screaming-snake-case' 83 | ? toScreamingSnakeCase 84 | : toKyselyPascalCase; 85 | symbolName = caseConverter(id.replaceAll(/[^\w$]/g, '_')); 86 | 87 | if (symbolNames.has(symbolName)) { 88 | let suffix = 2; 89 | 90 | while (symbolNames.has(`${symbolName}${suffix}`)) { 91 | suffix++; 92 | } 93 | 94 | symbolName += suffix; 95 | } 96 | 97 | if (/^\d/.test(symbolName)) { 98 | symbolName = `_${symbolName}`; 99 | } 100 | 101 | this.symbols[id] = symbol; 102 | this.symbolNames[id] = symbolName; 103 | 104 | return symbolName; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/generator/utils/case-converter.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | toKyselyCamelCase, 3 | toKyselyPascalCase, 4 | toPascalCase, 5 | toScreamingSnakeCase, 6 | toWords, 7 | } from './case-converter'; 8 | 9 | test(toKyselyCamelCase, () => { 10 | expect(toKyselyCamelCase('checklist_item_1')).toBe('checklistItem1'); 11 | }); 12 | 13 | test(toKyselyPascalCase, () => { 14 | expect(toKyselyPascalCase('checklist_item_1')).toBe('ChecklistItem1'); 15 | }); 16 | 17 | test(toPascalCase, () => { 18 | expect(toPascalCase('checklist_item_1')).toBe('ChecklistItem1'); 19 | }); 20 | 21 | test(toScreamingSnakeCase, () => { 22 | expect(toScreamingSnakeCase('checklist_item_1')).toBe('CHECKLIST_ITEM_1'); 23 | }); 24 | 25 | test(toWords, () => { 26 | expect(toWords('FooBar123Baz_Qux')).toStrictEqual([ 27 | 'Foo', 28 | 'Bar', 29 | '123', 30 | 'Baz', 31 | 'Qux', 32 | ]); 33 | }); 34 | -------------------------------------------------------------------------------- /src/generator/utils/case-converter.ts: -------------------------------------------------------------------------------- 1 | import { CamelCasePlugin } from 'kysely'; 2 | 3 | class CaseConverter extends CamelCasePlugin { 4 | toCamelCase(string: string) { 5 | return this.camelCase(string); 6 | } 7 | } 8 | 9 | /** 10 | * @example 11 | * toUpperFirst('fooBar') 12 | * // => 'FooBar' 13 | */ 14 | const toUpperFirst = (string: string): string => { 15 | return `${string.slice(0, 1).toUpperCase()}${string.slice(1)}`; 16 | }; 17 | 18 | /** 19 | * @example 20 | * toKyselyCamelCase('foo_bar') 21 | * // => 'fooBar' 22 | */ 23 | export const toKyselyCamelCase = (string: string) => { 24 | return new CaseConverter().toCamelCase(string); 25 | }; 26 | 27 | /** 28 | * @example 29 | * toKyselyPascalCase('foo_bar') 30 | * // => 'FooBar' 31 | */ 32 | export const toKyselyPascalCase = (string: string) => { 33 | return toUpperFirst(toKyselyCamelCase(string)); 34 | }; 35 | 36 | /** 37 | * @example 38 | * toPascalCase('foo_bar') 39 | * // => 'FooBar' 40 | */ 41 | export const toPascalCase = (string: string) => { 42 | return toWords(string) 43 | .map((w) => toUpperFirst(w.toLowerCase())) 44 | .join(''); 45 | }; 46 | 47 | /** 48 | * @example 49 | * pascalCase('foo_bar') 50 | * // => 'FOO_BAR' 51 | */ 52 | export const toScreamingSnakeCase = (string: string) => { 53 | return toWords(string) 54 | .map((w, i) => `${i ? '_' : ''}${w.toUpperCase()}`) 55 | .join(''); 56 | }; 57 | 58 | /** 59 | * @example 60 | * toWords('FooBar') 61 | * // => ['Foo', 'Bar'] 62 | */ 63 | export const toWords = (string: string) => { 64 | return ( 65 | string.match(/(?:\p{Lu}(?!\p{Ll}))+|\p{L}\p{Ll}*|\d+/gu)?.slice() ?? [] 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cli'; 2 | export * from './db'; 3 | export * from './generator'; 4 | export * from './introspector'; 5 | -------------------------------------------------------------------------------- /src/introspector/dialect.ts: -------------------------------------------------------------------------------- 1 | import type { Dialect as KyselyDialect } from 'kysely'; 2 | import type { Introspector } from './introspector'; 3 | 4 | export type CreateKyselyDialectOptions = { 5 | connectionString: string; 6 | ssl?: boolean; 7 | }; 8 | 9 | /** 10 | * A Dialect is the glue between the codegen and the specified database. 11 | */ 12 | export abstract class IntrospectorDialect { 13 | /** 14 | * The introspector for the dialect. 15 | */ 16 | abstract readonly introspector: Introspector; 17 | 18 | /** 19 | * Creates a Kysely dialect. 20 | */ 21 | abstract createKyselyDialect( 22 | options: CreateKyselyDialectOptions, 23 | ): Promise; 24 | } 25 | -------------------------------------------------------------------------------- /src/introspector/dialects/kysely-bun-sqlite/kysely-bun-sqlite-dialect.ts: -------------------------------------------------------------------------------- 1 | import type { CreateKyselyDialectOptions } from '../../dialect'; 2 | import { IntrospectorDialect } from '../../dialect'; 3 | import { KyselyBunSqliteIntrospector } from './kysely-bun-sqlite-introspector'; 4 | 5 | export class KyselyBunSqliteIntrospectorDialect extends IntrospectorDialect { 6 | override readonly introspector = new KyselyBunSqliteIntrospector(); 7 | 8 | async createKyselyDialect(options: CreateKyselyDialectOptions) { 9 | if (typeof Bun === 'undefined') { 10 | throw new ReferenceError( 11 | "Dialect 'kysely-bun-sqlite' is only available in a Bun environment.", 12 | ); 13 | } 14 | 15 | const { default: Database } = await import('bun:sqlite'); 16 | const { BunSqliteDialect: KyselyBunSqliteDialect } = await import( 17 | 'kysely-bun-sqlite' 18 | ); 19 | 20 | return new KyselyBunSqliteDialect({ 21 | database: new Database(options.connectionString), 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/introspector/dialects/kysely-bun-sqlite/kysely-bun-sqlite-introspector.ts: -------------------------------------------------------------------------------- 1 | import { EnumCollection } from '../../enum-collection'; 2 | import type { IntrospectOptions } from '../../introspector'; 3 | import { Introspector } from '../../introspector'; 4 | import { DatabaseMetadata } from '../../metadata/database-metadata'; 5 | 6 | export class KyselyBunSqliteIntrospector extends Introspector { 7 | async introspect(options: IntrospectOptions) { 8 | const tables = await this.getTables(options); 9 | const enums = new EnumCollection(); 10 | return new DatabaseMetadata({ enums, tables }); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/introspector/dialects/libsql/libsql-dialect.ts: -------------------------------------------------------------------------------- 1 | import type { CreateKyselyDialectOptions } from '../../dialect'; 2 | import { IntrospectorDialect } from '../../dialect'; 3 | import { LibsqlIntrospector } from './libsql-introspector'; 4 | 5 | export class LibsqlIntrospectorDialect extends IntrospectorDialect { 6 | override readonly introspector = new LibsqlIntrospector(); 7 | 8 | async createKyselyDialect(options: CreateKyselyDialectOptions) { 9 | const { LibsqlDialect: KyselyLibsqlDialect } = await import( 10 | '@libsql/kysely-libsql' 11 | ); 12 | 13 | // LibSQL URLs are of the form `libsql://token@host:port/db`: 14 | const url = new URL(options.connectionString); 15 | 16 | if (url.username) { 17 | // The token takes the place of the username in the url: 18 | const token = url.username; 19 | 20 | // Remove the token from the url to get a "normal" connection string: 21 | url.username = ''; 22 | 23 | return new KyselyLibsqlDialect({ authToken: token, url: url.toString() }); 24 | } 25 | 26 | return new KyselyLibsqlDialect({ url: options.connectionString }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/introspector/dialects/libsql/libsql-introspector.ts: -------------------------------------------------------------------------------- 1 | import { EnumCollection } from '../../enum-collection'; 2 | import type { IntrospectOptions } from '../../introspector'; 3 | import { Introspector } from '../../introspector'; 4 | import { DatabaseMetadata } from '../../metadata/database-metadata'; 5 | 6 | export class LibsqlIntrospector extends Introspector { 7 | async introspect(options: IntrospectOptions) { 8 | const tables = await this.getTables(options); 9 | const enums = new EnumCollection(); 10 | return new DatabaseMetadata({ enums, tables }); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/introspector/dialects/mssql/mssql-dialect.ts: -------------------------------------------------------------------------------- 1 | import { MssqlDialect as KyselyMssqlDialect } from 'kysely'; 2 | import type { CreateKyselyDialectOptions } from '../../dialect'; 3 | import { IntrospectorDialect } from '../../dialect'; 4 | import { MssqlIntrospector } from './mssql-introspector'; 5 | 6 | const DEFAULT_MSSQL_PORT = 1433; 7 | 8 | export class MssqlIntrospectorDialect extends IntrospectorDialect { 9 | override readonly introspector = new MssqlIntrospector(); 10 | 11 | /** 12 | * @see https://www.connectionstrings.com/microsoft-data-sqlclient/using-a-non-standard-port/ 13 | */ 14 | async #parseConnectionString(connectionString: string) { 15 | const { parseConnectionString } = await import( 16 | '@tediousjs/connection-string' 17 | ); 18 | 19 | const parsed = parseConnectionString(connectionString) as Record< 20 | string, 21 | string 22 | >; 23 | const tokens = parsed.server!.split(','); 24 | const serverAndInstance = tokens[0]!.split('\\'); 25 | const server = serverAndInstance[0]!; 26 | const instanceName = serverAndInstance[1]; 27 | 28 | // Instance name and port are mutually exclusive. 29 | // See https://tediousjs.github.io/tedious/api-connection.html#:~:text=options.instanceName. 30 | const port = 31 | instanceName === undefined 32 | ? tokens[1] 33 | ? Number.parseInt(tokens[1], 10) 34 | : DEFAULT_MSSQL_PORT 35 | : undefined; 36 | 37 | return { 38 | database: parsed.database!, 39 | instanceName, 40 | password: parsed.password!, 41 | port, 42 | server, 43 | userName: parsed['user id']!, 44 | }; 45 | } 46 | 47 | async createKyselyDialect(options: CreateKyselyDialectOptions) { 48 | const tarn = await import('tarn'); 49 | const tedious = await import('tedious'); 50 | 51 | const { database, instanceName, password, port, server, userName } = 52 | await this.#parseConnectionString(options.connectionString); 53 | 54 | return new KyselyMssqlDialect({ 55 | tarn: { 56 | ...tarn, 57 | options: { min: 0, max: 1 }, 58 | }, 59 | tedious: { 60 | ...tedious, 61 | connectionFactory: () => { 62 | return new tedious.Connection({ 63 | authentication: { 64 | options: { password, userName }, 65 | type: 'default', 66 | }, 67 | options: { 68 | database, 69 | encrypt: options.ssl ?? true, 70 | instanceName, 71 | port, 72 | trustServerCertificate: true, 73 | }, 74 | server, 75 | }); 76 | }, 77 | }, 78 | }); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/introspector/dialects/mssql/mssql-introspector.ts: -------------------------------------------------------------------------------- 1 | import { EnumCollection } from '../../enum-collection'; 2 | import type { IntrospectOptions } from '../../introspector'; 3 | import { Introspector } from '../../introspector'; 4 | import { DatabaseMetadata } from '../../metadata/database-metadata'; 5 | 6 | export class MssqlIntrospector extends Introspector { 7 | async introspect(options: IntrospectOptions) { 8 | const tables = await this.getTables(options); 9 | const enums = new EnumCollection(); 10 | return new DatabaseMetadata({ enums, tables }); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/introspector/dialects/mysql/mysql-db.ts: -------------------------------------------------------------------------------- 1 | export type MysqlDB = { 2 | 'information_schema.COLUMNS': { 3 | COLUMN_NAME: string; 4 | COLUMN_TYPE: string; 5 | TABLE_NAME: string; 6 | TABLE_SCHEMA: string; 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /src/introspector/dialects/mysql/mysql-dialect.ts: -------------------------------------------------------------------------------- 1 | import { MysqlDialect as KyselyMysqlDialect } from 'kysely'; 2 | import type { CreateKyselyDialectOptions } from '../../dialect'; 3 | import { IntrospectorDialect } from '../../dialect'; 4 | import { MysqlIntrospector } from './mysql-introspector'; 5 | 6 | export class MysqlIntrospectorDialect extends IntrospectorDialect { 7 | override readonly introspector = new MysqlIntrospector(); 8 | 9 | async createKyselyDialect(options: CreateKyselyDialectOptions) { 10 | const { createPool } = await import('mysql2'); 11 | 12 | return new KyselyMysqlDialect({ 13 | pool: createPool({ 14 | uri: options.connectionString, 15 | }), 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/introspector/dialects/mysql/mysql-introspector.ts: -------------------------------------------------------------------------------- 1 | import type { Kysely, TableMetadata as KyselyTableMetadata } from 'kysely'; 2 | import { EnumCollection } from '../../enum-collection'; 3 | import type { IntrospectOptions } from '../../introspector'; 4 | import { Introspector } from '../../introspector'; 5 | import { DatabaseMetadata } from '../../metadata/database-metadata'; 6 | import type { MysqlDB } from './mysql-db'; 7 | import { MysqlParser } from './mysql-parser'; 8 | 9 | const ENUM_REGEXP = /^enum\(.*\)$/; 10 | 11 | export class MysqlIntrospector extends Introspector { 12 | createDatabaseMetadata({ 13 | enums, 14 | tables: rawTables, 15 | }: { 16 | enums: EnumCollection; 17 | tables: KyselyTableMetadata[]; 18 | }) { 19 | const tables = rawTables.map((table) => ({ 20 | ...table, 21 | columns: table.columns.map((column) => ({ 22 | ...column, 23 | enumValues: 24 | column.dataType === 'enum' 25 | ? enums.get(`${table.schema ?? ''}.${table.name}.${column.name}`) 26 | : null, 27 | })), 28 | })); 29 | return new DatabaseMetadata({ tables }); 30 | } 31 | 32 | async introspect(options: IntrospectOptions) { 33 | const tables = await this.getTables(options); 34 | const enums = await this.introspectEnums(options.db); 35 | return this.createDatabaseMetadata({ enums, tables }); 36 | } 37 | 38 | async introspectEnums(db: Kysely) { 39 | const enums = new EnumCollection(); 40 | 41 | const rows = await db 42 | .withoutPlugins() 43 | .selectFrom('information_schema.COLUMNS') 44 | .select(['COLUMN_NAME', 'COLUMN_TYPE', 'TABLE_NAME', 'TABLE_SCHEMA']) 45 | .execute(); 46 | 47 | for (const row of rows) { 48 | if (ENUM_REGEXP.test(row.COLUMN_TYPE)) { 49 | const key = `${row.TABLE_SCHEMA}.${row.TABLE_NAME}.${row.COLUMN_NAME}`; 50 | const parser = new MysqlParser(row.COLUMN_TYPE); 51 | const values = parser.parseEnum(); 52 | enums.set(key, values); 53 | } 54 | } 55 | 56 | return enums; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/introspector/dialects/mysql/mysql-parser.ts: -------------------------------------------------------------------------------- 1 | export class MysqlParser { 2 | data = ''; 3 | index = 0; 4 | 5 | constructor(data: string) { 6 | this.data = data; 7 | } 8 | 9 | #consume(character: string) { 10 | if (this.data[this.index] !== character) { 11 | throw this.#createSyntaxError(); 12 | } 13 | 14 | this.index++; 15 | } 16 | 17 | #createSyntaxError() { 18 | const character = JSON.stringify(this.data[this.index]) ?? 'EOF'; 19 | return new SyntaxError( 20 | `Unexpected character ${character} at index ${this.index}`, 21 | ); 22 | } 23 | 24 | #parseEnumBody() { 25 | const enums: string[] = []; 26 | 27 | while (this.index < this.data.length && this.data[this.index] !== ')') { 28 | if (enums.length > 0) { 29 | this.#consume(','); 30 | } 31 | 32 | const value = this.#parseEnumValue(); 33 | enums.push(value); 34 | } 35 | 36 | return enums; 37 | } 38 | 39 | #parseEnumValue() { 40 | let value = ''; 41 | 42 | this.#consume("'"); 43 | 44 | while (this.index < this.data.length) { 45 | if (this.data[this.index] === "'") { 46 | this.index++; 47 | 48 | if (this.data[this.index] === "'") { 49 | value += this.data[this.index++]; 50 | } else { 51 | break; 52 | } 53 | } else { 54 | value += this.data[this.index++]; 55 | } 56 | } 57 | 58 | return value; 59 | } 60 | 61 | parseEnum() { 62 | this.#consume('e'); 63 | this.#consume('n'); 64 | this.#consume('u'); 65 | this.#consume('m'); 66 | this.#consume('('); 67 | 68 | const enums = this.#parseEnumBody(); 69 | 70 | this.#consume(')'); 71 | 72 | return enums; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/introspector/dialects/postgres/date-parser.ts: -------------------------------------------------------------------------------- 1 | export type DateParser = 'string' | 'timestamp'; 2 | 3 | export const DEFAULT_DATE_PARSER: DateParser = 'timestamp'; 4 | -------------------------------------------------------------------------------- /src/introspector/dialects/postgres/numeric-parser.ts: -------------------------------------------------------------------------------- 1 | export type NumericParser = 'number' | 'number-or-string' | 'string'; 2 | 3 | export const DEFAULT_NUMERIC_PARSER: NumericParser = 'string'; 4 | -------------------------------------------------------------------------------- /src/introspector/dialects/postgres/postgres-db.ts: -------------------------------------------------------------------------------- 1 | export type PostgresDB = { 2 | 'pg_catalog.pg_namespace': { 3 | nspname: string; 4 | oid: number; 5 | }; 6 | pg_enum: { 7 | enumlabel: string; 8 | enumtypid: number; 9 | }; 10 | pg_type: { 11 | oid: number; 12 | typname: string; 13 | typnamespace: number; 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /src/introspector/dialects/postgres/postgres-dialect.ts: -------------------------------------------------------------------------------- 1 | import { PostgresDialect as KyselyPostgresDialect } from 'kysely'; 2 | import type { CreateKyselyDialectOptions } from '../../dialect'; 3 | import { IntrospectorDialect } from '../../dialect'; 4 | import type { DateParser } from './date-parser'; 5 | import { DEFAULT_DATE_PARSER } from './date-parser'; 6 | import type { NumericParser } from './numeric-parser'; 7 | import { DEFAULT_NUMERIC_PARSER } from './numeric-parser'; 8 | import { PostgresIntrospector } from './postgres-introspector'; 9 | 10 | type PostgresDialectOptions = { 11 | dateParser?: DateParser; 12 | defaultSchemas?: string[]; 13 | domains?: boolean; 14 | numericParser?: NumericParser; 15 | partitions?: boolean; 16 | }; 17 | 18 | export class PostgresIntrospectorDialect extends IntrospectorDialect { 19 | protected readonly options: PostgresDialectOptions; 20 | override readonly introspector: PostgresIntrospector; 21 | 22 | constructor(options?: PostgresDialectOptions) { 23 | super(); 24 | 25 | this.introspector = new PostgresIntrospector({ 26 | defaultSchemas: options?.defaultSchemas, 27 | domains: options?.domains, 28 | partitions: options?.partitions, 29 | }); 30 | this.options = { 31 | dateParser: options?.dateParser ?? DEFAULT_DATE_PARSER, 32 | defaultSchemas: options?.defaultSchemas, 33 | domains: options?.domains ?? true, 34 | numericParser: options?.numericParser ?? DEFAULT_NUMERIC_PARSER, 35 | }; 36 | } 37 | 38 | async createKyselyDialect(options: CreateKyselyDialectOptions) { 39 | const { default: pg } = await import('pg'); 40 | 41 | if (this.options.dateParser === 'string') { 42 | pg.types.setTypeParser(1082, (date) => date); 43 | } 44 | 45 | if (this.options.numericParser === 'number') { 46 | pg.types.setTypeParser(1700, Number); 47 | } else if (this.options.numericParser === 'number-or-string') { 48 | pg.types.setTypeParser(1700, (value) => { 49 | const number = Number(value); 50 | return number > Number.MAX_SAFE_INTEGER || 51 | number < Number.MIN_SAFE_INTEGER 52 | ? value 53 | : number; 54 | }); 55 | } 56 | 57 | return new KyselyPostgresDialect({ 58 | pool: new pg.Pool({ 59 | connectionString: options.connectionString, 60 | ssl: options.ssl ? { rejectUnauthorized: false } : false, 61 | }), 62 | }); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/introspector/dialects/postgres/postgres-introspector.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Kysely, 3 | ColumnMetadata as KyselyColumnMetaData, 4 | TableMetadata as KyselyTableMetadata, 5 | } from 'kysely'; 6 | import { sql } from 'kysely'; 7 | import { EnumCollection } from '../../enum-collection'; 8 | import type { IntrospectOptions } from '../../introspector'; 9 | import { Introspector } from '../../introspector'; 10 | import type { ColumnMetadata } from '../../metadata/column-metadata'; 11 | import { DatabaseMetadata } from '../../metadata/database-metadata'; 12 | import type { TableMetadata } from '../../metadata/table-metadata'; 13 | import type { PostgresDB } from './postgres-db'; 14 | 15 | export type PostgresDomainInspector = { 16 | rootType: string; 17 | typeName: string; 18 | typeSchema: string; 19 | }; 20 | 21 | export type TableReference = { 22 | schema?: string; 23 | name: string; 24 | }; 25 | 26 | export type PostgresIntrospectorOptions = { 27 | defaultSchemas?: string[]; 28 | domains?: boolean; 29 | partitions?: boolean; 30 | }; 31 | 32 | export class PostgresIntrospector extends Introspector { 33 | protected readonly options: PostgresIntrospectorOptions; 34 | 35 | constructor(options?: PostgresIntrospectorOptions) { 36 | super(); 37 | 38 | this.options = { 39 | defaultSchemas: 40 | options?.defaultSchemas && options.defaultSchemas.length > 0 41 | ? options.defaultSchemas 42 | : ['public'], 43 | domains: options?.domains ?? true, 44 | partitions: options?.partitions, 45 | }; 46 | } 47 | 48 | createDatabaseMetadata({ 49 | domains, 50 | enums, 51 | partitions, 52 | tables: rawTables, 53 | }: { 54 | domains: PostgresDomainInspector[]; 55 | enums: EnumCollection; 56 | partitions: TableReference[]; 57 | tables: KyselyTableMetadata[]; 58 | }) { 59 | const tables = rawTables 60 | .map((table): TableMetadata => { 61 | const columns = table.columns.map((column): ColumnMetadata => { 62 | const dataType = this.getRootType(column, domains); 63 | const enumValues = enums.get( 64 | `${column.dataTypeSchema ?? this.options.defaultSchemas}.${dataType}`, 65 | ); 66 | const isArray = dataType.startsWith('_'); 67 | 68 | return { 69 | comment: column.comment ?? null, 70 | dataType: isArray ? dataType.slice(1) : dataType, 71 | dataTypeSchema: column.dataTypeSchema, 72 | enumValues, 73 | hasDefaultValue: column.hasDefaultValue, 74 | isArray, 75 | isAutoIncrementing: column.isAutoIncrementing, 76 | isNullable: column.isNullable, 77 | name: column.name, 78 | }; 79 | }); 80 | 81 | const isPartition = partitions.some((partition) => { 82 | return ( 83 | partition.schema === table.schema && partition.name === table.name 84 | ); 85 | }); 86 | 87 | return { 88 | columns, 89 | isPartition, 90 | isView: table.isView, 91 | name: table.name, 92 | schema: table.schema, 93 | }; 94 | }) 95 | .filter((table) => { 96 | return this.options.partitions ? true : !table.isPartition; 97 | }); 98 | 99 | return new DatabaseMetadata({ enums, tables }); 100 | } 101 | 102 | getRootType( 103 | column: KyselyColumnMetaData, 104 | domains: PostgresDomainInspector[], 105 | ) { 106 | const foundDomain = domains.find((domain) => { 107 | return ( 108 | domain.typeName === column.dataType && 109 | domain.typeSchema === column.dataTypeSchema 110 | ); 111 | }); 112 | return foundDomain?.rootType ?? column.dataType; 113 | } 114 | 115 | async introspect(options: IntrospectOptions) { 116 | const tables = await this.getTables(options); 117 | 118 | const [domains, enums, partitions] = await Promise.all([ 119 | this.introspectDomains(options.db), 120 | this.introspectEnums(options.db), 121 | this.introspectPartitions(options.db), 122 | ]); 123 | 124 | return this.createDatabaseMetadata({ enums, domains, partitions, tables }); 125 | } 126 | 127 | async introspectDomains(db: Kysely) { 128 | if (!this.options.domains) { 129 | return []; 130 | } 131 | 132 | const result = await sql` 133 | with recursive domain_hierarchy as ( 134 | select oid, typbasetype 135 | from pg_type 136 | where typtype = 'd' 137 | and 'information_schema'::regnamespace::oid <> typnamespace 138 | 139 | union all 140 | 141 | select dh.oid, t.typbasetype 142 | from domain_hierarchy as dh 143 | join pg_type as t ON t.oid = dh.typbasetype 144 | ) 145 | 146 | select 147 | t.typname as "typeName", 148 | t.typnamespace::regnamespace::text as "typeSchema", 149 | bt.typname as "rootType" 150 | from domain_hierarchy as dh 151 | join pg_type as t on dh.oid = t.oid 152 | join pg_type as bt on dh.typbasetype = bt.oid 153 | where bt.typbasetype = 0; 154 | `.execute(db); 155 | 156 | return result.rows; 157 | } 158 | 159 | async introspectEnums(db: Kysely) { 160 | const enums = new EnumCollection(); 161 | 162 | const rows = await db 163 | .withoutPlugins() 164 | .selectFrom('pg_type as type') 165 | .innerJoin('pg_enum as enum', 'type.oid', 'enum.enumtypid') 166 | .innerJoin( 167 | 'pg_catalog.pg_namespace as namespace', 168 | 'namespace.oid', 169 | 'type.typnamespace', 170 | ) 171 | .select([ 172 | 'namespace.nspname as schemaName', 173 | 'type.typname as enumName', 174 | 'enum.enumlabel as enumValue', 175 | ]) 176 | .execute(); 177 | 178 | for (const row of rows) { 179 | enums.add(`${row.schemaName}.${row.enumName}`, row.enumValue); 180 | } 181 | 182 | return enums; 183 | } 184 | 185 | async introspectPartitions(db: Kysely) { 186 | const result = await sql` 187 | select pg_namespace.nspname as schema, pg_class.relname as name 188 | from pg_inherits 189 | join pg_class on pg_inherits.inhrelid = pg_class.oid 190 | join pg_namespace on pg_namespace.oid = pg_class.relnamespace; 191 | `.execute(db); 192 | 193 | return result.rows; 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/introspector/dialects/sqlite/sqlite-dialect.ts: -------------------------------------------------------------------------------- 1 | import { SqliteDialect as KyselySqliteDialect } from 'kysely'; 2 | import type { CreateKyselyDialectOptions } from '../../dialect'; 3 | import { IntrospectorDialect } from '../../dialect'; 4 | import { SqliteIntrospector } from './sqlite-introspector'; 5 | 6 | export class SqliteIntrospectorDialect extends IntrospectorDialect { 7 | override readonly introspector = new SqliteIntrospector(); 8 | 9 | async createKyselyDialect(options: CreateKyselyDialectOptions) { 10 | const { default: Database } = await import('better-sqlite3'); 11 | 12 | return new KyselySqliteDialect({ 13 | database: new Database(options.connectionString), 14 | }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/introspector/dialects/sqlite/sqlite-introspector.ts: -------------------------------------------------------------------------------- 1 | import { EnumCollection } from '../../enum-collection'; 2 | import type { IntrospectOptions } from '../../introspector'; 3 | import { Introspector } from '../../introspector'; 4 | import { DatabaseMetadata } from '../../metadata/database-metadata'; 5 | 6 | export class SqliteIntrospector extends Introspector { 7 | async introspect(options: IntrospectOptions) { 8 | const tables = await this.getTables(options); 9 | const enums = new EnumCollection(); 10 | return new DatabaseMetadata({ enums, tables }); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/introspector/enum-collection.ts: -------------------------------------------------------------------------------- 1 | type EnumMap = Record; 2 | 3 | export class EnumCollection { 4 | readonly enums: EnumMap = {}; 5 | 6 | constructor(enums: EnumMap = {}) { 7 | this.enums = Object.fromEntries( 8 | Object.entries(enums).map(([key, value]) => { 9 | return [key.toLowerCase(), value]; 10 | }), 11 | ); 12 | } 13 | 14 | add(key: string, value: string) { 15 | (this.enums[key.toLowerCase()] ??= []).push(value); 16 | } 17 | 18 | get(key: string) { 19 | return ( 20 | this.enums[key.toLowerCase()]?.sort((a, b) => a.localeCompare(b)) ?? null 21 | ); 22 | } 23 | 24 | has(key: string) { 25 | return !!this.enums[key.toLowerCase()]; 26 | } 27 | 28 | set(key: string, values: string[]) { 29 | this.enums[key.toLowerCase()] = values; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/introspector/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dialect'; 2 | export * from './dialects/kysely-bun-sqlite/kysely-bun-sqlite-dialect'; 3 | export * from './dialects/kysely-bun-sqlite/kysely-bun-sqlite-introspector'; 4 | export * from './dialects/libsql/libsql-dialect'; 5 | export * from './dialects/libsql/libsql-introspector'; 6 | export * from './dialects/mssql/mssql-dialect'; 7 | export * from './dialects/mssql/mssql-introspector'; 8 | export * from './dialects/mysql/mysql-db'; 9 | export * from './dialects/mysql/mysql-dialect'; 10 | export * from './dialects/mysql/mysql-introspector'; 11 | export * from './dialects/mysql/mysql-parser'; 12 | export * from './dialects/postgres/date-parser'; 13 | export * from './dialects/postgres/numeric-parser'; 14 | export * from './dialects/postgres/postgres-db'; 15 | export * from './dialects/postgres/postgres-dialect'; 16 | export * from './dialects/postgres/postgres-introspector'; 17 | export * from './dialects/sqlite/sqlite-dialect'; 18 | export * from './dialects/sqlite/sqlite-introspector'; 19 | export * from './enum-collection'; 20 | export * from './introspector'; 21 | export * from './metadata/column-metadata'; 22 | export * from './metadata/database-metadata'; 23 | export * from './metadata/table-metadata'; 24 | export * from './table-matcher'; 25 | -------------------------------------------------------------------------------- /src/introspector/introspector.fixtures.ts: -------------------------------------------------------------------------------- 1 | import { CamelCasePlugin, Kysely, sql } from 'kysely'; 2 | import assert from 'node:assert'; 3 | import { IntrospectorDialect } from './dialect'; 4 | import { MysqlIntrospectorDialect } from './dialects/mysql/mysql-dialect'; 5 | import { PostgresIntrospectorDialect } from './dialects/postgres/postgres-dialect'; 6 | 7 | const down = async (db: Kysely, dialect: IntrospectorDialect) => { 8 | assert(dialect instanceof IntrospectorDialect); 9 | 10 | await db.schema.dropTable('boolean').ifExists().execute(); 11 | await db.schema.dropTable('foo_bar').ifExists().execute(); 12 | 13 | if (dialect instanceof PostgresIntrospectorDialect) { 14 | await db.schema.dropSchema('cli').ifExists().cascade().execute(); 15 | await db.schema.withSchema('test').dropType('status').ifExists().execute(); 16 | await db.schema.withSchema('test').dropType('is_bool').ifExists().execute(); 17 | await db.schema.dropSchema('test').ifExists().execute(); 18 | await db.schema.dropType('status').ifExists().execute(); 19 | await db.schema.dropType('pos_int_child').ifExists().execute(); 20 | await db.schema.dropType('pos_int').ifExists().execute(); 21 | await db.schema.dropTable('partitioned_table').ifExists().execute(); 22 | await db.schema.dropTable('enum').ifExists().execute(); 23 | } 24 | }; 25 | 26 | const up = async (db: Kysely, dialect: IntrospectorDialect) => { 27 | assert(dialect instanceof IntrospectorDialect); 28 | 29 | await down(db, dialect); 30 | 31 | if (dialect instanceof PostgresIntrospectorDialect) { 32 | await db.schema.createSchema('test').execute(); 33 | await sql`create domain test.is_bool as boolean;`.execute(db); 34 | await db.schema 35 | .withSchema('test') 36 | .createType('status') 37 | .asEnum(['ABC_DEF', 'GHI_JKL']) 38 | .execute(); 39 | await db.schema 40 | .createType('status') 41 | .asEnum(['CONFIRMED', 'UNCONFIRMED']) 42 | .execute(); 43 | 44 | await sql`create domain pos_int as integer constraint positive_number check (value >= 0);`.execute( 45 | db, 46 | ); 47 | // Edge case where a domain is a child of another domain: 48 | await sql`create domain pos_int_child as pos_int;`.execute(db); 49 | } 50 | 51 | let builder = db.schema 52 | .createTable('foo_bar') 53 | .addColumn('false', 'boolean', (col) => col.notNull()) 54 | .addColumn('true', 'boolean', (col) => col.notNull()) 55 | .addColumn('overridden', sql`text`); 56 | 57 | if (dialect instanceof MysqlIntrospectorDialect) { 58 | builder = builder 59 | .addColumn('id', 'serial') 60 | .addColumn('user_status', sql`enum('CONFIRMED','UNCONFIRMED')`); 61 | } else if (dialect instanceof PostgresIntrospectorDialect) { 62 | builder = builder 63 | .addColumn('id', 'serial') 64 | .addColumn('date', 'date') 65 | .addColumn('user_status', sql`status`) 66 | .addColumn('user_status_2', sql`test.status`) 67 | .addColumn('array', sql`text[]`) 68 | .addColumn('nullable_pos_int', sql`pos_int`) 69 | .addColumn('defaulted_nullable_pos_int', sql`pos_int`, (col) => 70 | col.defaultTo(0), 71 | ) 72 | .addColumn('defaulted_required_pos_int', sql`pos_int`, (col) => 73 | col.notNull().defaultTo(0), 74 | ) 75 | .addColumn('child_domain', sql`pos_int_child`) 76 | .addColumn('test_domain_is_bool', sql`test.is_bool`) 77 | .addColumn('timestamps', sql`timestamp with time zone[]`) 78 | .addColumn('interval1', sql`interval`) 79 | .addColumn('interval2', sql`interval`) 80 | .addColumn('json', sql`json`) 81 | .addColumn('json_typed', sql`json`) 82 | .addColumn('numeric1', sql`numeric`) 83 | .addColumn('numeric2', sql`numeric`); 84 | } else { 85 | builder = builder 86 | .addColumn('id', 'integer', (col) => 87 | col.autoIncrement().notNull().primaryKey(), 88 | ) 89 | .addColumn('user_status', 'text'); 90 | } 91 | 92 | await builder.execute(); 93 | 94 | if (dialect instanceof PostgresIntrospectorDialect) { 95 | await db.executeQuery( 96 | sql` 97 | comment on column foo_bar.false is 98 | 'This is a comment on a column.\r\n\r\nIt''s nice, isn''t it?'; 99 | `.compile(db), 100 | ); 101 | await db.schema 102 | .createTable('partitioned_table') 103 | .addColumn('id', 'serial') 104 | .modifyEnd(sql`partition by range (id)`) 105 | .execute(); 106 | await db.schema 107 | .createTable('enum') 108 | .addColumn('name', 'text', (col) => col.primaryKey().notNull()) 109 | .execute(); 110 | await db.executeQuery(sql`comment on table enum is '@enum';`.compile(db)); 111 | await db.schema 112 | .alterTable('foo_bar') 113 | .addColumn('enum', sql`text not null references enum(name)`) 114 | .execute(); 115 | await db 116 | .insertInto('enum') 117 | .values([{ name: 'foo' }, { name: 'bar' }]) 118 | .onConflict((oc) => oc.doNothing()) 119 | .execute(); 120 | await db.executeQuery( 121 | sql` 122 | create table partition_1 partition of partitioned_table for values from (1) to (100); 123 | `.compile(db), 124 | ); 125 | } 126 | }; 127 | 128 | export const addExtraColumn = async (db: Kysely) => { 129 | await db.schema 130 | .alterTable('foo_bar') 131 | .addColumn('user_name', 'varchar(50)', (col) => col.defaultTo('test')) 132 | .execute(); 133 | }; 134 | 135 | export const migrate = async ( 136 | dialect: IntrospectorDialect, 137 | connectionString: string, 138 | ) => { 139 | const db = new Kysely({ 140 | dialect: await dialect.createKyselyDialect({ connectionString }), 141 | plugins: [new CamelCasePlugin()], 142 | }); 143 | 144 | await up(db, dialect); 145 | 146 | return db; 147 | }; 148 | -------------------------------------------------------------------------------- /src/introspector/introspector.test.ts: -------------------------------------------------------------------------------- 1 | import { type Kysely } from 'kysely'; 2 | import { deepStrictEqual } from 'node:assert'; 3 | import parsePostgresInterval from 'postgres-interval'; 4 | import { migrate } from '../introspector/introspector.fixtures'; 5 | import type { IntrospectorDialect } from './dialect'; 6 | import { LibsqlIntrospectorDialect } from './dialects/libsql/libsql-dialect'; 7 | import { MysqlIntrospectorDialect } from './dialects/mysql/mysql-dialect'; 8 | import { PostgresIntrospectorDialect } from './dialects/postgres/postgres-dialect'; 9 | import { SqliteIntrospectorDialect } from './dialects/sqlite/sqlite-dialect'; 10 | import { EnumCollection } from './enum-collection'; 11 | import { Introspector } from './introspector'; 12 | import { ColumnMetadata } from './metadata/column-metadata'; 13 | import { DatabaseMetadata } from './metadata/database-metadata'; 14 | import { TableMetadata } from './metadata/table-metadata'; 15 | 16 | type Test = { 17 | connectionString: string; 18 | dialect: IntrospectorDialect; 19 | inputValues: Record; 20 | outputValues: Record; 21 | }; 22 | 23 | const TESTS: Test[] = [ 24 | { 25 | connectionString: 'mysql://user:password@localhost/database', 26 | dialect: new MysqlIntrospectorDialect(), 27 | inputValues: { false: 0, id: 1, true: 1 }, 28 | outputValues: { false: 0, id: 1, true: 1 }, 29 | }, 30 | { 31 | connectionString: 'postgres://user:password@localhost:5433/database', 32 | dialect: new PostgresIntrospectorDialect({ 33 | dateParser: 'string', 34 | numericParser: 'number-or-string', 35 | }), 36 | inputValues: { 37 | date: '2024-10-14', 38 | enum: 'foo', 39 | false: false, 40 | id: 1, 41 | interval1: parsePostgresInterval('1 day'), 42 | interval2: '24 months', 43 | numeric1: Number.MAX_SAFE_INTEGER, 44 | numeric2: String(Number.MAX_SAFE_INTEGER + 1), 45 | timestamps: ['2024-09-17T08:05:00.000Z'], 46 | true: true, 47 | }, 48 | outputValues: { 49 | date: '2024-10-14', 50 | enum: 'foo', 51 | false: false, 52 | id: 1, 53 | interval1: { days: 1 }, 54 | interval2: { years: 2 }, 55 | numeric1: Number.MAX_SAFE_INTEGER, 56 | numeric2: String(Number.MAX_SAFE_INTEGER + 1), 57 | timestamps: [new Date('2024-09-17T08:05:00.000Z')], 58 | true: true, 59 | }, 60 | }, 61 | { 62 | connectionString: ':memory:', 63 | dialect: new SqliteIntrospectorDialect(), 64 | inputValues: { false: 0, id: 1, true: 1 }, 65 | outputValues: { false: 0, id: 1, true: 1 }, 66 | }, 67 | { 68 | connectionString: 'libsql://localhost:8080?tls=0', 69 | dialect: new LibsqlIntrospectorDialect(), 70 | inputValues: { false: 0, id: 1, true: 1 }, 71 | outputValues: { false: 0, id: 1, true: 1 }, 72 | }, 73 | ]; 74 | 75 | const testValues = async ( 76 | db: Kysely, 77 | inputValues: Record, 78 | outputValues: Record, 79 | ) => { 80 | await db.insertInto('fooBar').values(inputValues).execute(); 81 | 82 | const row = await db 83 | .selectFrom('fooBar') 84 | .selectAll() 85 | .executeTakeFirstOrThrow(); 86 | 87 | for (const [key, expectedValue] of Object.entries(outputValues)) { 88 | const actualValue = row[key]; 89 | 90 | if ( 91 | actualValue instanceof Object && 92 | actualValue.constructor.name === 'PostgresInterval' 93 | ) { 94 | deepStrictEqual({ ...actualValue }, expectedValue); 95 | } else { 96 | deepStrictEqual(actualValue, expectedValue); 97 | } 98 | } 99 | }; 100 | 101 | describe(Introspector.name, () => { 102 | it('should return the correct metadata for each dialect', async () => { 103 | for (const { 104 | connectionString, 105 | dialect, 106 | inputValues, 107 | outputValues, 108 | } of TESTS) { 109 | const db = await migrate(dialect, connectionString); 110 | await testValues(db, inputValues, outputValues); 111 | const metadata = await dialect.introspector.introspect({ db }); 112 | 113 | if (dialect instanceof MysqlIntrospectorDialect) { 114 | deepStrictEqual( 115 | metadata, 116 | new DatabaseMetadata({ 117 | tables: [ 118 | new TableMetadata({ 119 | columns: [ 120 | new ColumnMetadata({ 121 | dataType: 'tinyint', 122 | name: 'false', 123 | }), 124 | new ColumnMetadata({ 125 | dataType: 'tinyint', 126 | name: 'true', 127 | }), 128 | new ColumnMetadata({ 129 | dataType: 'text', 130 | isNullable: true, 131 | name: 'overridden', 132 | }), 133 | new ColumnMetadata({ 134 | dataType: 'bigint', 135 | isAutoIncrementing: true, 136 | name: 'id', 137 | }), 138 | new ColumnMetadata({ 139 | dataType: 'enum', 140 | enumValues: ['CONFIRMED', 'UNCONFIRMED'], 141 | isNullable: true, 142 | name: 'user_status', 143 | }), 144 | ], 145 | name: 'foo_bar', 146 | schema: 'database', 147 | }), 148 | ], 149 | }), 150 | ); 151 | } else if (dialect instanceof PostgresIntrospectorDialect) { 152 | deepStrictEqual( 153 | metadata, 154 | new DatabaseMetadata({ 155 | enums: new EnumCollection({ 156 | 'public.status': ['CONFIRMED', 'UNCONFIRMED'], 157 | 'test.status': ['ABC_DEF', 'GHI_JKL'], 158 | }), 159 | tables: [ 160 | new TableMetadata({ 161 | columns: [ 162 | new ColumnMetadata({ 163 | dataType: 'text', 164 | dataTypeSchema: 'pg_catalog', 165 | name: 'name', 166 | }), 167 | ], 168 | name: 'enum', 169 | schema: 'public', 170 | }), 171 | new TableMetadata({ 172 | columns: [ 173 | new ColumnMetadata({ 174 | comment: 175 | "This is a comment on a column.\r\n\r\nIt's nice, isn't it?", 176 | dataType: 'bool', 177 | dataTypeSchema: 'pg_catalog', 178 | name: 'false', 179 | }), 180 | new ColumnMetadata({ 181 | dataType: 'bool', 182 | dataTypeSchema: 'pg_catalog', 183 | name: 'true', 184 | }), 185 | new ColumnMetadata({ 186 | dataType: 'text', 187 | dataTypeSchema: 'pg_catalog', 188 | isNullable: true, 189 | name: 'overridden', 190 | }), 191 | new ColumnMetadata({ 192 | dataType: 'int4', 193 | dataTypeSchema: 'pg_catalog', 194 | hasDefaultValue: true, 195 | isAutoIncrementing: true, 196 | name: 'id', 197 | }), 198 | new ColumnMetadata({ 199 | dataType: 'date', 200 | dataTypeSchema: 'pg_catalog', 201 | isNullable: true, 202 | name: 'date', 203 | }), 204 | new ColumnMetadata({ 205 | dataType: 'status', 206 | dataTypeSchema: 'public', 207 | enumValues: ['CONFIRMED', 'UNCONFIRMED'], 208 | isNullable: true, 209 | name: 'user_status', 210 | }), 211 | new ColumnMetadata({ 212 | dataType: 'status', 213 | dataTypeSchema: 'test', 214 | enumValues: ['ABC_DEF', 'GHI_JKL'], 215 | isNullable: true, 216 | name: 'user_status_2', 217 | }), 218 | new ColumnMetadata({ 219 | dataType: 'text', 220 | dataTypeSchema: 'pg_catalog', 221 | isArray: true, 222 | isNullable: true, 223 | name: 'array', 224 | }), 225 | new ColumnMetadata({ 226 | dataType: 'int4', 227 | dataTypeSchema: 'public', 228 | isNullable: true, 229 | name: 'nullable_pos_int', 230 | }), 231 | new ColumnMetadata({ 232 | dataType: 'int4', 233 | dataTypeSchema: 'public', 234 | hasDefaultValue: true, 235 | isNullable: true, 236 | name: 'defaulted_nullable_pos_int', 237 | }), 238 | new ColumnMetadata({ 239 | dataType: 'int4', 240 | dataTypeSchema: 'public', 241 | hasDefaultValue: true, 242 | name: 'defaulted_required_pos_int', 243 | }), 244 | new ColumnMetadata({ 245 | dataType: 'int4', 246 | dataTypeSchema: 'public', 247 | isNullable: true, 248 | name: 'child_domain', 249 | }), 250 | new ColumnMetadata({ 251 | dataType: 'bool', 252 | dataTypeSchema: 'test', 253 | isNullable: true, 254 | name: 'test_domain_is_bool', 255 | }), 256 | new ColumnMetadata({ 257 | dataType: 'timestamptz', 258 | dataTypeSchema: 'pg_catalog', 259 | isArray: true, 260 | isNullable: true, 261 | name: 'timestamps', 262 | }), 263 | new ColumnMetadata({ 264 | dataType: 'interval', 265 | dataTypeSchema: 'pg_catalog', 266 | isNullable: true, 267 | name: 'interval1', 268 | }), 269 | new ColumnMetadata({ 270 | dataType: 'interval', 271 | dataTypeSchema: 'pg_catalog', 272 | isNullable: true, 273 | name: 'interval2', 274 | }), 275 | new ColumnMetadata({ 276 | dataType: 'json', 277 | dataTypeSchema: 'pg_catalog', 278 | isNullable: true, 279 | name: 'json', 280 | }), 281 | new ColumnMetadata({ 282 | dataType: 'json', 283 | dataTypeSchema: 'pg_catalog', 284 | isNullable: true, 285 | name: 'json_typed', 286 | }), 287 | new ColumnMetadata({ 288 | dataType: 'numeric', 289 | dataTypeSchema: 'pg_catalog', 290 | isNullable: true, 291 | name: 'numeric1', 292 | }), 293 | new ColumnMetadata({ 294 | dataType: 'numeric', 295 | dataTypeSchema: 'pg_catalog', 296 | isNullable: true, 297 | name: 'numeric2', 298 | }), 299 | new ColumnMetadata({ 300 | dataType: 'text', 301 | dataTypeSchema: 'pg_catalog', 302 | name: 'enum', 303 | }), 304 | ], 305 | name: 'foo_bar', 306 | schema: 'public', 307 | }), 308 | new TableMetadata({ 309 | columns: [ 310 | new ColumnMetadata({ 311 | dataType: 'int4', 312 | dataTypeSchema: 'pg_catalog', 313 | hasDefaultValue: true, 314 | isAutoIncrementing: true, 315 | name: 'id', 316 | }), 317 | ], 318 | name: 'partitioned_table', 319 | schema: 'public', 320 | }), 321 | ], 322 | }), 323 | ); 324 | } else if (dialect instanceof SqliteIntrospectorDialect) { 325 | deepStrictEqual( 326 | metadata, 327 | new DatabaseMetadata({ 328 | tables: [ 329 | new TableMetadata({ 330 | columns: [ 331 | new ColumnMetadata({ 332 | dataType: 'boolean', 333 | name: 'false', 334 | }), 335 | new ColumnMetadata({ 336 | dataType: 'boolean', 337 | name: 'true', 338 | }), 339 | new ColumnMetadata({ 340 | dataType: 'TEXT', 341 | isNullable: true, 342 | name: 'overridden', 343 | }), 344 | new ColumnMetadata({ 345 | dataType: 'INTEGER', 346 | isAutoIncrementing: true, 347 | name: 'id', 348 | }), 349 | new ColumnMetadata({ 350 | dataType: 'TEXT', 351 | isNullable: true, 352 | name: 'user_status', 353 | }), 354 | ], 355 | name: 'foo_bar', 356 | }), 357 | ], 358 | }), 359 | ); 360 | } 361 | } 362 | }); 363 | }); 364 | -------------------------------------------------------------------------------- /src/introspector/introspector.ts: -------------------------------------------------------------------------------- 1 | import { Kysely, sql } from 'kysely'; 2 | import type { IntrospectorDialect } from './dialect'; 3 | import type { DatabaseMetadata } from './metadata/database-metadata'; 4 | import { TableMatcher } from './table-matcher'; 5 | 6 | type ConnectOptions = { 7 | connectionString: string; 8 | dialect: IntrospectorDialect; 9 | }; 10 | 11 | export type IntrospectOptions = { 12 | db: Kysely; 13 | excludePattern?: string | null; 14 | includePattern?: string | null; 15 | partitions?: boolean; 16 | }; 17 | 18 | /** 19 | * Analyzes and returns metadata for a connected database. 20 | */ 21 | export abstract class Introspector { 22 | private async establishDatabaseConnection(db: Kysely) { 23 | return await sql`SELECT 1;`.execute(db); 24 | } 25 | 26 | async connect(options: ConnectOptions) { 27 | // Insane solution in lieu of a better one. 28 | // We'll create a database connection with SSL, and if it complains about SSL, try without it. 29 | for (const ssl of [true, false]) { 30 | try { 31 | const dialect = await options.dialect.createKyselyDialect({ 32 | connectionString: options.connectionString, 33 | ssl, 34 | }); 35 | 36 | const db = new Kysely({ dialect }); 37 | 38 | await this.establishDatabaseConnection(db); 39 | 40 | return db; 41 | } catch (error) { 42 | const isSslError = 43 | error instanceof Error && /\bSSL\b/.test(error.message); 44 | const isUnexpectedError = !ssl || !isSslError; 45 | 46 | if (isUnexpectedError) { 47 | throw error; 48 | } 49 | } 50 | } 51 | 52 | throw new Error('Failed to connect to database.'); 53 | } 54 | 55 | protected async getTables(options: IntrospectOptions) { 56 | let tables = await options.db.introspection.getTables(); 57 | 58 | if (options.includePattern) { 59 | const tableMatcher = new TableMatcher(options.includePattern); 60 | tables = tables.filter(({ name, schema }) => 61 | tableMatcher.match(schema, name), 62 | ); 63 | } 64 | 65 | if (options.excludePattern) { 66 | const tableMatcher = new TableMatcher(options.excludePattern); 67 | tables = tables.filter( 68 | ({ name, schema }) => !tableMatcher.match(schema, name), 69 | ); 70 | } 71 | 72 | return tables; 73 | } 74 | 75 | abstract introspect( 76 | options: IntrospectOptions, 77 | ): Promise; 78 | } 79 | -------------------------------------------------------------------------------- /src/introspector/metadata/column-metadata.ts: -------------------------------------------------------------------------------- 1 | export type ColumnMetadataOptions = { 2 | comment?: string | null; 3 | dataType: string; 4 | dataTypeSchema?: string; 5 | enumValues?: string[] | null; 6 | hasDefaultValue?: boolean; 7 | isArray?: boolean; 8 | isAutoIncrementing?: boolean; 9 | isNullable?: boolean; 10 | name: string; 11 | }; 12 | 13 | export class ColumnMetadata { 14 | comment: string | null; 15 | dataType: string; 16 | dataTypeSchema: string | undefined; 17 | enumValues: string[] | null; 18 | hasDefaultValue: boolean; 19 | isArray: boolean; 20 | isAutoIncrementing: boolean; 21 | isNullable: boolean; 22 | name: string; 23 | 24 | constructor(options: ColumnMetadataOptions) { 25 | this.comment = options.comment ?? null; 26 | this.dataType = options.dataType; 27 | this.dataTypeSchema = options.dataTypeSchema; 28 | this.enumValues = options.enumValues ?? null; 29 | this.hasDefaultValue = options.hasDefaultValue ?? false; 30 | this.isArray = options.isArray ?? false; 31 | this.isAutoIncrementing = options.isAutoIncrementing ?? false; 32 | this.isNullable = options.isNullable ?? false; 33 | this.name = options.name; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/introspector/metadata/database-metadata.ts: -------------------------------------------------------------------------------- 1 | import { EnumCollection } from '../enum-collection'; 2 | import type { TableMetadataOptions } from './table-metadata'; 3 | import { TableMetadata } from './table-metadata'; 4 | 5 | export type DatabaseMetadataOptions = { 6 | enums?: EnumCollection; 7 | tables: TableMetadataOptions[]; 8 | }; 9 | 10 | export class DatabaseMetadata { 11 | enums: EnumCollection; 12 | tables: TableMetadata[]; 13 | 14 | constructor({ enums, tables }: DatabaseMetadataOptions) { 15 | this.enums = enums ?? new EnumCollection(); 16 | this.tables = tables.map((table) => new TableMetadata(table)); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/introspector/metadata/table-metadata.ts: -------------------------------------------------------------------------------- 1 | import type { ColumnMetadataOptions } from './column-metadata'; 2 | import { ColumnMetadata } from './column-metadata'; 3 | 4 | export type TableMetadataOptions = { 5 | columns: ColumnMetadataOptions[]; 6 | isPartition?: boolean; 7 | isView?: boolean; 8 | name: string; 9 | schema?: string; 10 | }; 11 | 12 | export class TableMetadata { 13 | columns: ColumnMetadata[]; 14 | isPartition: boolean; 15 | isView: boolean; 16 | name: string; 17 | schema: string | undefined; 18 | 19 | constructor(options: TableMetadataOptions) { 20 | this.columns = options.columns.map((column) => new ColumnMetadata(column)); 21 | this.isPartition = !!options.isPartition; 22 | this.isView = !!options.isView; 23 | this.name = options.name; 24 | this.schema = options.schema; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/introspector/table-matcher.test.ts: -------------------------------------------------------------------------------- 1 | import { strictEqual } from 'node:assert'; 2 | import { TableMatcher } from './table-matcher'; 3 | 4 | describe(TableMatcher.name, () => { 5 | it('should match tables without schemas', () => { 6 | strictEqual(new TableMatcher('foo').match(undefined, 'foo'), true); 7 | strictEqual(new TableMatcher('.foo').match(undefined, 'foo'), false); 8 | strictEqual(new TableMatcher('*.foo').match(undefined, 'foo'), true); 9 | strictEqual(new TableMatcher('public.foo').match(undefined, 'foo'), false); 10 | }); 11 | 12 | it('should match tables with schemas', () => { 13 | strictEqual(new TableMatcher('foo').match('public', 'foo'), true); 14 | strictEqual(new TableMatcher('.foo').match('public', 'foo'), false); 15 | strictEqual(new TableMatcher('*.foo').match('public', 'foo'), true); 16 | strictEqual(new TableMatcher('public.foo').match('public', 'foo'), true); 17 | }); 18 | 19 | it('should be able to match tables containing "." without schemas', () => { 20 | strictEqual(new TableMatcher('foo.bar').match(undefined, 'foo.bar'), false); 21 | strictEqual( 22 | new TableMatcher('.foo.bar').match(undefined, 'foo.bar'), 23 | false, 24 | ); 25 | strictEqual( 26 | new TableMatcher('*.foo.bar').match(undefined, 'foo.bar'), 27 | true, 28 | ); 29 | strictEqual( 30 | new TableMatcher('public.foo.bar').match(undefined, 'foo.bar'), 31 | false, 32 | ); 33 | }); 34 | 35 | it('should be able to match tables containing "." with schemas', () => { 36 | strictEqual(new TableMatcher('foo.bar').match('public', 'foo.bar'), false); 37 | strictEqual(new TableMatcher('.foo.bar').match('public', 'foo.bar'), false); 38 | strictEqual(new TableMatcher('*.foo.bar').match('public', 'foo.bar'), true); 39 | strictEqual( 40 | new TableMatcher('public.foo.bar').match('public', 'foo.bar'), 41 | true, 42 | ); 43 | }); 44 | 45 | it('should match case-insensitively', () => { 46 | strictEqual(new TableMatcher('FoO_bAr').match(undefined, 'foo_bar'), true); 47 | }); 48 | 49 | it('should support logical OR', () => { 50 | strictEqual(new TableMatcher('(foo|bar)').match(undefined, 'foo'), true); 51 | strictEqual(new TableMatcher('(foo|bar)').match(undefined, 'bar'), true); 52 | strictEqual(new TableMatcher('(foo|bar)').match(undefined, 'baz'), false); 53 | strictEqual( 54 | new TableMatcher('foo_(bar|baz)').match(undefined, 'foo_bar'), 55 | true, 56 | ); 57 | strictEqual( 58 | new TableMatcher('foo_(bar|baz)').match(undefined, 'foo_baz'), 59 | true, 60 | ); 61 | strictEqual( 62 | new TableMatcher('foo_(bar|baz)').match(undefined, 'foo_qux'), 63 | false, 64 | ); 65 | }); 66 | 67 | it('should support simple brace expansion', () => { 68 | strictEqual(new TableMatcher('foo_{1,2}').match(undefined, 'foo_1'), true); 69 | strictEqual(new TableMatcher('foo_{1,2}').match(undefined, 'foo_2'), true); 70 | strictEqual(new TableMatcher('foo_{1,2}').match(undefined, 'foo_3'), false); 71 | }); 72 | 73 | it('should support negation', () => { 74 | strictEqual( 75 | new TableMatcher('!foo_(bar|baz)').match(undefined, 'foo_bar'), 76 | false, 77 | ); 78 | strictEqual( 79 | new TableMatcher('!foo_(bar|baz)').match(undefined, 'foo_baz'), 80 | false, 81 | ); 82 | strictEqual( 83 | new TableMatcher('!foo_(bar|baz)').match(undefined, 'foo_qux'), 84 | true, 85 | ); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/introspector/table-matcher.ts: -------------------------------------------------------------------------------- 1 | import { matcher } from 'micromatch'; 2 | 3 | export class TableMatcher { 4 | isMatch: (string: string) => boolean; 5 | isSimpleGlob: boolean; 6 | 7 | constructor(pattern: string) { 8 | this.isMatch = matcher(pattern, { nocase: true }); 9 | this.isSimpleGlob = !pattern.includes('.'); 10 | } 11 | 12 | match(schema: string | undefined, name: string) { 13 | const string = this.isSimpleGlob ? name : `${schema ?? '*'}.${name}`; 14 | return this.isMatch(string); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["**/*.snapshot.ts", "**/test", "**/*.test.ts"] 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "declaration": true, 5 | "erasableSyntaxOnly": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "isolatedModules": true, 9 | "lib": ["dom", "dom.iterable", "esnext"], 10 | "module": "commonjs", 11 | "moduleResolution": "node", 12 | "newLine": "lf", 13 | "noEmit": false, 14 | "noErrorTruncation": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "noUncheckedIndexedAccess": true, 17 | "outDir": "dist", 18 | "resolveJsonModule": true, 19 | "skipLibCheck": true, 20 | "sourceMap": true, 21 | "strict": true, 22 | "target": "es2020", 23 | "types": ["bun", "vitest/globals"] 24 | }, 25 | "include": ["src"], 26 | "exclude": ["**/*.snapshot.ts"] 27 | } 28 | --------------------------------------------------------------------------------