├── .nvmrc ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── snapshot.yml │ ├── publish.yml │ └── linters.yml ├── .prettierignore ├── src ├── bin.ts ├── constants.ts ├── utils │ ├── applyJSDocWorkaround.ts │ ├── isValidTSIdentifier.ts │ ├── sorted.ts │ ├── formatFile.test.ts │ ├── normalizeCase.ts │ ├── testUtils.ts │ ├── formatFile.ts │ ├── writeFileSafely.ts │ ├── validateConfig.test.ts │ ├── normalizeCase.test.ts │ ├── validateConfig.ts │ └── words.ts ├── helpers │ ├── generateTypedReferenceNode.ts │ ├── generateTypedReferenceNode.test.ts │ ├── generateStringLiteralUnion.ts │ ├── generateEnumType.test.ts │ ├── generateStringLiteralUnion.test.ts │ ├── generateFile.test.ts │ ├── wrappedTypeHelpers.test.ts │ ├── generateFile.ts │ ├── generatedEnumType.test.ts │ ├── wrappedTypeHelpers.ts │ ├── generateDatabaseType.ts │ ├── generateTypeOverrideFromDocumentation.test.ts │ ├── generateTypeOverrideFromDocumentation.ts │ ├── multiSchemaHelpers.test.ts │ ├── generateEnumType.ts │ ├── generateField.ts │ ├── generateModel.ts │ ├── generateFieldType.ts │ ├── generateFieldType.test.ts │ ├── generateImplicitManyToManyModels.ts │ ├── generateField.test.ts │ ├── generateDatabaseType.test.ts │ ├── multiSchemaHelpers.ts │ ├── generateFiles.ts │ ├── generateModel.test.ts │ └── generateImplicitManyToManyModels.test.ts ├── dialectTests │ ├── postgresql.ts │ ├── mysql.ts │ ├── common.ts │ └── sqlite.ts ├── generator.ts └── __test__ │ └── e2e.test.ts ├── assets ├── hero.png └── logo-hero.png ├── .npmignore ├── .gitignore ├── vitest.config.mts ├── .prettierrc ├── .changeset ├── config.json └── README.md ├── docker-compose.yml ├── tsconfig.json ├── LICENSE ├── eslint.config.mjs ├── package.json ├── CHANGELOG.md └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v24.7.0 -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @valtyr @alii @arthurfiorette 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | prisma/generated/* 4 | -------------------------------------------------------------------------------- /src/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import "~/generator"; 3 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const GENERATOR_NAME = "Kysely types"; 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: valtyr 2 | github: [alii, arthurfiorette] 3 | -------------------------------------------------------------------------------- /assets/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valtyr/prisma-kysely/HEAD/assets/hero.png -------------------------------------------------------------------------------- /assets/logo-hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valtyr/prisma-kysely/HEAD/assets/logo-hero.png -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | node_modules 3 | tsconfig.json 4 | README.md 5 | jest.config.js 6 | yarn.lock 7 | yarn-error.log 8 | .pnpm-debug.log 9 | dist -------------------------------------------------------------------------------- /src/utils/applyJSDocWorkaround.ts: -------------------------------------------------------------------------------- 1 | export const applyJSDocWorkaround = (comment: string) => { 2 | return `*\n * ${comment.split("\n").join("\n * ")}\n `; 3 | }; 4 | -------------------------------------------------------------------------------- /src/utils/isValidTSIdentifier.ts: -------------------------------------------------------------------------------- 1 | const isValidTSIdentifier = (ident: string) => 2 | !!ident && /^[a-zA-Z_$][a-zA-Z_$0-9]*$/.test(ident); 3 | export default isValidTSIdentifier; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env* 3 | build 4 | coverage 5 | dist 6 | node_modules 7 | output 8 | pnpm-debug.log 9 | prisma 10 | prisma-old 11 | src/generated/prisma 12 | yarn-error.log -------------------------------------------------------------------------------- /src/utils/sorted.ts: -------------------------------------------------------------------------------- 1 | export const sorted = (list: T[], sortFunction?: (a: T, b: T) => number) => { 2 | const newList = [...list]; 3 | newList.sort(sortFunction); 4 | return newList; 5 | }; 6 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | import tsconfigPaths from "vite-tsconfig-paths"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | plugins: [tsconfigPaths()], 6 | }); 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "importOrder": ["^\\./env$", "", "^~/.*$", "^[./]"], 3 | "importOrderSeparation": true, 4 | "importOrderSortSpecifiers": true, 5 | "trailingComma": "es5", 6 | "plugins": ["@trivago/prettier-plugin-sort-imports"] 7 | } 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "./" 5 | schedule: 6 | interval: "monthly" 7 | 8 | - package-ecosystem: "github-actions" 9 | directory: "./" 10 | schedule: 11 | interval: "monthly" 12 | -------------------------------------------------------------------------------- /src/utils/formatFile.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, expect, test, vi } from "vitest"; 2 | 3 | import { formatFile } from "~/utils/formatFile"; 4 | 5 | afterEach(() => { 6 | vi.clearAllMocks(); 7 | }); 8 | 9 | test("formats a file!", () => { 10 | expect(() => { 11 | formatFile(""); 12 | }).not.toThrow(); 13 | }); 14 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/normalizeCase.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "~/utils/validateConfig"; 2 | import { createCamelCaseMapper } from "~/utils/words"; 3 | 4 | const snakeToCamel = createCamelCaseMapper(); 5 | 6 | export const normalizeCase = (name: string, config: Config) => { 7 | if (!config.camelCase) return name; 8 | return snakeToCamel(name); 9 | }; 10 | -------------------------------------------------------------------------------- /src/utils/testUtils.ts: -------------------------------------------------------------------------------- 1 | import ts, { createPrinter } from "typescript"; 2 | 3 | export const stringifyTsNode = (node: ts.Node) => { 4 | return createPrinter().printNode( 5 | ts.EmitHint.Unspecified, 6 | node, 7 | ts.factory.createSourceFile( 8 | [], 9 | ts.factory.createToken(ts.SyntaxKind.EndOfFileToken), 10 | ts.NodeFlags.None 11 | ) 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /src/helpers/generateTypedReferenceNode.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | 3 | export const generateTypedReferenceNode = (name: string) => { 4 | return ts.factory.createTypeAliasDeclaration( 5 | [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], 6 | name, 7 | undefined, 8 | ts.factory.createTypeReferenceNode( 9 | `(typeof ${name})[keyof typeof ${name}]`, 10 | undefined 11 | ) 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | postgres: 5 | image: postgres:14.5 6 | environment: 7 | - POSTGRES_USER=postgres 8 | - POSTGRES_PASSWORD=postgres 9 | ports: 10 | - "22331:5432" 11 | mysql: 12 | image: mysql 13 | command: --default-authentication-plugin=mysql_native_password 14 | restart: always 15 | environment: 16 | MYSQL_ROOT_PASSWORD: mysql 17 | ports: 18 | - "22332:3306" 19 | -------------------------------------------------------------------------------- /src/helpers/generateTypedReferenceNode.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | 3 | import { stringifyTsNode } from "~/utils/testUtils"; 4 | 5 | import { generateTypedReferenceNode } from "./generateTypedReferenceNode"; 6 | 7 | test("it generated the typed reference node", () => { 8 | const node = generateTypedReferenceNode("Name"); 9 | 10 | const result = stringifyTsNode(node); 11 | 12 | expect(result).toEqual( 13 | "export type Name = (typeof Name)[keyof typeof Name];" 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /src/helpers/generateStringLiteralUnion.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | 3 | export const generateStringLiteralUnion = (stringLiterals: string[]) => { 4 | if (stringLiterals.length === 0) return null; 5 | if (stringLiterals.length === 1) 6 | return ts.factory.createLiteralTypeNode( 7 | ts.factory.createStringLiteral(stringLiterals[0]) 8 | ); 9 | return ts.factory.createUnionTypeNode( 10 | stringLiterals.map((literal) => 11 | ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(literal)) 12 | ) 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /src/utils/formatFile.ts: -------------------------------------------------------------------------------- 1 | export const formatFile = async (content: string) => { 2 | // If user has prettier, we find their config and 3 | // format. Otherwise we don't alter the file. 4 | 5 | try { 6 | const { default: prettier } = await import("prettier"); 7 | 8 | const config = await prettier.resolveConfig(process.cwd()); 9 | if (!config) return content; 10 | 11 | const formatted = prettier.format(content, { 12 | ...config, 13 | parser: "typescript", 14 | }); 15 | 16 | return formatted; 17 | } catch {} 18 | 19 | return content; 20 | }; 21 | -------------------------------------------------------------------------------- /src/utils/writeFileSafely.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | import { formatFile } from "~/utils/formatFile"; 5 | 6 | export const writeFileSafely = async ( 7 | writeLocation: string, 8 | content: string 9 | ) => { 10 | fs.mkdirSync(path.dirname(writeLocation), { 11 | recursive: true, 12 | }); 13 | 14 | fs.writeFileSync(writeLocation, await formatFile(content)); 15 | }; 16 | 17 | export const writeFileSafelyWithoutFormatting = async ( 18 | writeLocation: string, 19 | content: string 20 | ) => { 21 | fs.mkdirSync(path.dirname(writeLocation), { 22 | recursive: true, 23 | }); 24 | 25 | fs.writeFileSync(writeLocation, content); 26 | }; 27 | -------------------------------------------------------------------------------- /src/utils/validateConfig.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, expect, test, vi } from "vitest"; 2 | 3 | import { validateConfig } from "./validateConfig"; 4 | 5 | afterEach(() => { 6 | vi.clearAllMocks(); 7 | }); 8 | 9 | test("should exit with error code when invalid config encountered", () => { 10 | const mockExitFunction = vi.fn(); 11 | const consoleErrorFunction = vi.fn(); 12 | 13 | process.exit = mockExitFunction; 14 | console.error = consoleErrorFunction; 15 | 16 | validateConfig({ 17 | databaseProvider: "postgers", 18 | testField: "wrong", 19 | }); 20 | 21 | expect(mockExitFunction).toHaveBeenCalled(); 22 | expect(consoleErrorFunction).toHaveBeenCalled(); 23 | }); 24 | -------------------------------------------------------------------------------- /.github/workflows/snapshot.yml: -------------------------------------------------------------------------------- 1 | name: Snapshot release 2 | 3 | permissions: 4 | pull-requests: write 5 | 6 | on: 7 | issue_comment: 8 | types: [created] 9 | 10 | jobs: 11 | release: 12 | name: Publish snapshot release 13 | runs-on: ubuntu-latest 14 | if: github.event.issue.pull_request && contains(github.event.comment.body, '/snapshot') 15 | steps: 16 | - name: Checkout Repo 17 | uses: actions/checkout@v5 18 | 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v5 21 | with: 22 | node-version-file: .nvmrc 23 | 24 | - name: Install Dependencies 25 | run: yarn 26 | 27 | - name: Build 28 | run: yarn build 29 | 30 | - run: npx pkg-pr-new publish 31 | -------------------------------------------------------------------------------- /src/helpers/generateEnumType.test.ts: -------------------------------------------------------------------------------- 1 | import ts, { createPrinter } from "typescript"; 2 | import { expect, test } from "vitest"; 3 | 4 | import { generateEnumType } from "./generateEnumType"; 5 | 6 | test("it generates the enum type", () => { 7 | const { objectDeclaration, typeDeclaration } = generateEnumType("Name", [ 8 | { name: "FOO", dbName: "FOO" }, 9 | { name: "BAR", dbName: "BAR" }, 10 | ])!; 11 | 12 | const printer = createPrinter(); 13 | 14 | const result = printer.printList( 15 | ts.ListFormat.MultiLine, 16 | ts.factory.createNodeArray([objectDeclaration, typeDeclaration]), 17 | ts.createSourceFile("", "", ts.ScriptTarget.Latest) 18 | ); 19 | 20 | expect(result).toEqual(`export const Name = { 21 | FOO: "FOO", 22 | BAR: "BAR" 23 | } as const; 24 | export type Name = (typeof Name)[keyof typeof Name];\n`); 25 | }); 26 | -------------------------------------------------------------------------------- /src/helpers/generateStringLiteralUnion.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | 3 | import { generateStringLiteralUnion } from "~/helpers/generateStringLiteralUnion"; 4 | import { stringifyTsNode } from "~/utils/testUtils"; 5 | 6 | test("it returns null for 0 items", () => { 7 | const node = generateStringLiteralUnion([]); 8 | 9 | expect(node).toBeNull(); 10 | }); 11 | 12 | test("it generates string literal unions for 1 item", () => { 13 | const node = generateStringLiteralUnion(["option1"]); 14 | 15 | expect(node).toBeDefined(); 16 | 17 | const result = stringifyTsNode(node!); 18 | 19 | expect(result).toEqual('"option1"'); 20 | }); 21 | 22 | test("it generates string literal unions for 2 items", () => { 23 | const node = generateStringLiteralUnion(["option1", "option2"]); 24 | 25 | expect(node).toBeDefined(); 26 | 27 | const result = stringifyTsNode(node!); 28 | 29 | expect(result).toEqual('"option1" | "option2"'); 30 | }); 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": ["esnext"], 6 | "strict": true, 7 | "strictPropertyInitialization": false, 8 | "esModuleInterop": true, 9 | "experimentalDecorators": true, 10 | "emitDecoratorMetadata": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "removeComments": true, 14 | "sourceMap": true, 15 | "baseUrl": ".", 16 | "moduleResolution": "Node", 17 | "outDir": "./dist", 18 | "rootDir": "./src", 19 | "newLine": "lf", 20 | "paths": { 21 | "~/*": ["./src/*"] 22 | }, 23 | "plugins": [ 24 | { 25 | "transform": "typescript-transform-paths" 26 | }, 27 | { 28 | "transform": "typescript-transform-paths", 29 | "afterDeclarations": true 30 | } 31 | ] 32 | }, 33 | "include": ["src/**/*"], 34 | "exclude": ["**/node_modules", "**/dest"] 35 | } 36 | -------------------------------------------------------------------------------- /src/dialectTests/postgresql.ts: -------------------------------------------------------------------------------- 1 | import { Kysely, PostgresDialect } from "kysely"; 2 | import { Pool } from "pg"; 3 | 4 | import { POSTGRES_URL, preparePrisma } from "~/dialectTests/common"; 5 | 6 | const main = async () => { 7 | await preparePrisma("postgresql"); 8 | const db = new Kysely({ 9 | dialect: new PostgresDialect({ 10 | pool: new Pool({ 11 | connectionString: POSTGRES_URL, 12 | }), 13 | }), 14 | }); 15 | 16 | await db 17 | .insertInto("Widget") 18 | .values({ bytes: Buffer.from([]) }) 19 | .execute(); 20 | 21 | const result = await db 22 | .selectFrom("Widget") 23 | .selectAll() 24 | .executeTakeFirstOrThrow(); 25 | 26 | const entries = Object.entries(result).map(([key, value]) => ({ 27 | key, 28 | value, 29 | typeOf: typeof value, 30 | })); 31 | entries.sort((a, b) => a.key.localeCompare(b.key)); 32 | console.table(entries); 33 | 34 | await db.destroy(); 35 | }; 36 | 37 | main(); 38 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: 📦 Create release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: false 11 | 12 | permissions: 13 | contents: write 14 | issues: write 15 | pull-requests: write 16 | id-token: write 17 | 18 | jobs: 19 | release: 20 | name: Release 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout Repo 24 | uses: actions/checkout@v5 25 | 26 | - name: Setup Node.js 27 | uses: actions/setup-node@v5 28 | with: 29 | node-version-file: .nvmrc 30 | 31 | - name: Install Dependencies 32 | run: yarn 33 | 34 | - name: Create Release Pull Request or Publish to npm 35 | id: changesets 36 | uses: changesets/action@v1 37 | with: 38 | publish: yarn release 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | NPM_CONFIG_PROVENANCE: true 42 | -------------------------------------------------------------------------------- /src/dialectTests/mysql.ts: -------------------------------------------------------------------------------- 1 | import { Kysely, MysqlDialect } from "kysely"; 2 | import { createPool } from "mysql2"; 3 | 4 | import { preparePrisma } from "~/dialectTests/common"; 5 | 6 | const main = async () => { 7 | await preparePrisma("mysql"); 8 | const db = new Kysely({ 9 | dialect: new MysqlDialect({ 10 | pool: createPool({ 11 | user: "root", 12 | password: "mysql", 13 | host: "localhost", 14 | database: "test", 15 | port: 22332, 16 | }), 17 | }), 18 | }); 19 | 20 | await db 21 | .insertInto("Widget") 22 | .values({ bytes: Buffer.from([]) }) 23 | .execute(); 24 | 25 | const result = await db 26 | .selectFrom("Widget") 27 | .selectAll() 28 | .executeTakeFirstOrThrow(); 29 | 30 | const entries = Object.entries(result).map(([key, value]) => ({ 31 | key, 32 | value, 33 | typeOf: typeof value, 34 | })); 35 | entries.sort((a, b) => a.key.localeCompare(b.key)); 36 | 37 | console.table(entries); 38 | 39 | await db.destroy(); 40 | }; 41 | 42 | main(); 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Valtýr Örn Kjartansson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils/normalizeCase.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | 3 | import { normalizeCase } from "~/utils/normalizeCase"; 4 | 5 | test("converts names to camel case when config value is set", () => { 6 | const originalName = "user_id"; 7 | const newName = normalizeCase(originalName, { 8 | camelCase: true, 9 | databaseProvider: "postgresql", 10 | fileName: "", 11 | enumFileName: "", 12 | readOnlyIds: false, 13 | groupBySchema: false, 14 | defaultSchema: "public", 15 | dbTypeName: "DB", 16 | importExtension: "", 17 | exportWrappedTypes: false, 18 | }); 19 | 20 | expect(newName).toEqual("userId"); 21 | }); 22 | 23 | test("doesn't convert names to camel case when config value isn't set", () => { 24 | const originalName = "user_id"; 25 | const newName = normalizeCase(originalName, { 26 | camelCase: false, 27 | databaseProvider: "postgresql", 28 | fileName: "", 29 | enumFileName: "", 30 | readOnlyIds: false, 31 | groupBySchema: false, 32 | defaultSchema: "public", 33 | dbTypeName: "DB", 34 | importExtension: "", 35 | exportWrappedTypes: false, 36 | }); 37 | 38 | expect(newName).toEqual("user_id"); 39 | }); 40 | -------------------------------------------------------------------------------- /src/helpers/generateFile.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | 3 | import { generateFile } from "~/helpers/generateFile"; 4 | 5 | test("generates a file!", () => { 6 | const resultwithLeader = generateFile([], { 7 | withEnumImport: false, 8 | withLeader: true, 9 | exportWrappedTypes: false, 10 | }); 11 | expect(resultwithLeader).toContain('from "kysely";'); 12 | // assert that unnecessary types are not imported 13 | expect(resultwithLeader).not.toContain( 14 | ', Insertable, Selectable, Updateable } from "kysely";' 15 | ); 16 | 17 | const resultwithEnumImport = generateFile([], { 18 | withEnumImport: { importPath: "./enums", names: ["Foo", "Bar"] }, 19 | withLeader: false, 20 | exportWrappedTypes: false, 21 | }); 22 | 23 | expect(resultwithEnumImport).toContain( 24 | 'import type { Foo, Bar } from "./enums";' 25 | ); 26 | expect(resultwithEnumImport).not.toContain('from "kysely";'); 27 | }); 28 | 29 | test("generates a file which imports Kysely wrapper types.", () => { 30 | const resultwithLeader = generateFile([], { 31 | withEnumImport: false, 32 | withLeader: true, 33 | exportWrappedTypes: true, 34 | }); 35 | expect(resultwithLeader).toContain( 36 | ', Insertable, Selectable, Updateable } from "kysely";' 37 | ); 38 | }); 39 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { FlatCompat } from "@eslint/eslintrc"; 2 | import js from "@eslint/js"; 3 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 4 | import tsParser from "@typescript-eslint/parser"; 5 | import { defineConfig, globalIgnores } from "eslint/config"; 6 | import path from "node:path"; 7 | import { fileURLToPath } from "node:url"; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | const compat = new FlatCompat({ 12 | baseDirectory: __dirname, 13 | recommendedConfig: js.configs.recommended, 14 | allConfig: js.configs.all, 15 | }); 16 | 17 | export default defineConfig([ 18 | globalIgnores([ 19 | "prisma/**/*.ts", 20 | "**/*.test.ts", 21 | "./eslint.config.mjs", 22 | "./vitest.config.mts", 23 | ]), 24 | { 25 | extends: compat.extends("plugin:@typescript-eslint/recommended"), 26 | 27 | plugins: { 28 | "@typescript-eslint": typescriptEslint, 29 | }, 30 | 31 | languageOptions: { 32 | parser: tsParser, 33 | ecmaVersion: 5, 34 | sourceType: "script", 35 | 36 | parserOptions: { 37 | project: "./tsconfig.json", 38 | }, 39 | }, 40 | 41 | rules: { 42 | "@typescript-eslint/consistent-type-imports": "warn", 43 | }, 44 | }, 45 | ]); 46 | -------------------------------------------------------------------------------- /src/helpers/wrappedTypeHelpers.test.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import { expect, test } from "vitest"; 3 | 4 | import { stringifyTsNode } from "~/utils/testUtils"; 5 | 6 | import { convertToWrappedTypes } from "./wrappedTypeHelpers"; 7 | 8 | test("it returns Kysely wrapped types", () => { 9 | const modelDefinition = ts.factory.createTypeAliasDeclaration( 10 | [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], 11 | "User", 12 | undefined, 13 | ts.factory.createTypeLiteralNode([ 14 | ts.factory.createPropertySignature( 15 | undefined, 16 | "id", 17 | undefined, 18 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword) 19 | ), 20 | ts.factory.createPropertySignature( 21 | undefined, 22 | "name", 23 | undefined, 24 | ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword) 25 | ), 26 | ]) 27 | ); 28 | 29 | const results = convertToWrappedTypes(modelDefinition); 30 | const resultsAsCode = results.map(stringifyTsNode); 31 | 32 | expect(resultsAsCode).toEqual([ 33 | `export type UserTable = { 34 | id: string; 35 | name: string; 36 | };`, 37 | "export type User = Selectable;", 38 | "export type NewUser = Insertable;", 39 | "export type UserUpdate = Updateable;", 40 | ]); 41 | }); 42 | -------------------------------------------------------------------------------- /src/helpers/generateFile.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | 3 | const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); 4 | 5 | type Options = { 6 | withEnumImport: false | { importPath: string; names: string[] }; 7 | withLeader: boolean; 8 | exportWrappedTypes: boolean; 9 | }; 10 | 11 | export const generateFile = ( 12 | statements: readonly ts.Statement[], 13 | { withEnumImport, withLeader, exportWrappedTypes }: Options 14 | ) => { 15 | const file = ts.factory.createSourceFile( 16 | statements, 17 | ts.factory.createToken(ts.SyntaxKind.EndOfFileToken), 18 | ts.NodeFlags.None 19 | ); 20 | 21 | const result = printer.printFile(file); 22 | 23 | const leader = `import type { ColumnType${ 24 | result.includes("GeneratedAlways") ? ", GeneratedAlways" : "" 25 | }${ 26 | exportWrappedTypes ? ", Insertable, Selectable, Updateable" : "" 27 | } } from "kysely"; 28 | export type Generated = T extends ColumnType 29 | ? ColumnType 30 | : ColumnType; 31 | export type Timestamp = ColumnType;`; 32 | 33 | if (withEnumImport) { 34 | const enumImportStatement = `import type { ${withEnumImport.names.join( 35 | ", " 36 | )} } from "${withEnumImport.importPath}";`; 37 | 38 | return withLeader 39 | ? `${leader}\n\n${enumImportStatement}\n\n${result}` 40 | : `${enumImportStatement}\n\n${result}`; 41 | } 42 | 43 | return withLeader ? `${leader}\n\n${result}` : result; 44 | }; 45 | -------------------------------------------------------------------------------- /src/helpers/generatedEnumType.test.ts: -------------------------------------------------------------------------------- 1 | import ts, { createPrinter } from "typescript"; 2 | import { expect, test } from "vitest"; 3 | 4 | import { generateEnumType } from "./generateEnumType"; 5 | 6 | test("it generates the enum type", () => { 7 | const { objectDeclaration, typeDeclaration } = generateEnumType("Name", [ 8 | { name: "FOO", dbName: "FOO" }, 9 | { name: "BAR", dbName: "BAR" }, 10 | ])!; 11 | 12 | const printer = createPrinter(); 13 | 14 | const result = printer.printList( 15 | ts.ListFormat.MultiLine, 16 | ts.factory.createNodeArray([objectDeclaration, typeDeclaration]), 17 | ts.createSourceFile("", "", ts.ScriptTarget.Latest) 18 | ); 19 | 20 | expect(result).toEqual(`export const Name = { 21 | FOO: "FOO", 22 | BAR: "BAR" 23 | } as const; 24 | export type Name = (typeof Name)[keyof typeof Name];\n`); 25 | }); 26 | 27 | test("it generates the enum type when using Prisma's @map()", () => { 28 | const { objectDeclaration, typeDeclaration } = generateEnumType("Name", [ 29 | { name: "FOO", dbName: "foo" }, 30 | { name: "BAR", dbName: "bar" }, 31 | ])!; 32 | 33 | const printer = createPrinter(); 34 | 35 | const result = printer.printList( 36 | ts.ListFormat.MultiLine, 37 | ts.factory.createNodeArray([objectDeclaration, typeDeclaration]), 38 | ts.createSourceFile("", "", ts.ScriptTarget.Latest) 39 | ); 40 | 41 | expect(result).toEqual(`export const Name = { 42 | FOO: "foo", 43 | BAR: "bar" 44 | } as const; 45 | export type Name = (typeof Name)[keyof typeof Name];\n`); 46 | }); 47 | -------------------------------------------------------------------------------- /src/helpers/wrappedTypeHelpers.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | 3 | export const toTableTypeName = (modelName: string) => `${modelName}Table`; 4 | 5 | /** 6 | * Convert to Kysely wrapped types. 7 | * e.g.) `Model` will be `ModelTable` (as-is), `Model` (Selectable), `NewModel` (Insertable), and `ModelUpdate` (Updateable). 8 | */ 9 | export const convertToWrappedTypes = ( 10 | modelDefinition: ts.TypeAliasDeclaration 11 | ): ts.TypeAliasDeclaration[] => { 12 | const modelName = modelDefinition.name.text; 13 | const tableTypeName = toTableTypeName(modelName); 14 | return [ 15 | { ...modelDefinition, name: ts.factory.createIdentifier(tableTypeName) }, 16 | ts.factory.createTypeAliasDeclaration( 17 | [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], 18 | ts.factory.createIdentifier(modelName), 19 | undefined, 20 | ts.factory.createTypeReferenceNode("Selectable", [ 21 | ts.factory.createTypeReferenceNode(tableTypeName, undefined), 22 | ]) 23 | ), 24 | ts.factory.createTypeAliasDeclaration( 25 | [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], 26 | ts.factory.createIdentifier(`New${modelName}`), 27 | undefined, 28 | ts.factory.createTypeReferenceNode("Insertable", [ 29 | ts.factory.createTypeReferenceNode(tableTypeName, undefined), 30 | ]) 31 | ), 32 | ts.factory.createTypeAliasDeclaration( 33 | [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], 34 | ts.factory.createIdentifier(`${modelName}Update`), 35 | undefined, 36 | ts.factory.createTypeReferenceNode("Updateable", [ 37 | ts.factory.createTypeReferenceNode(tableTypeName, undefined), 38 | ]) 39 | ), 40 | ]; 41 | }; 42 | -------------------------------------------------------------------------------- /src/helpers/generateDatabaseType.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | 3 | import isValidTSIdentifier from "~/utils/isValidTSIdentifier"; 4 | import { normalizeCase } from "~/utils/normalizeCase"; 5 | import { sorted } from "~/utils/sorted"; 6 | import type { Config } from "~/utils/validateConfig"; 7 | 8 | import { toTableTypeName } from "./wrappedTypeHelpers"; 9 | 10 | export const generateDatabaseType = ( 11 | models: { tableName: string; typeName: string }[], 12 | config: Config 13 | ) => { 14 | const sortedModels = sorted(models, (a, b) => 15 | a.tableName.localeCompare(b.tableName) 16 | ); 17 | 18 | const properties = sortedModels.map((field) => { 19 | const caseNormalizedTableName = normalizeCase(field.tableName, config); 20 | 21 | /* 22 | * If the table name isn't a valid typescript identifier we need 23 | * to wrap it with quotes 24 | */ 25 | const nameIdentifier = isValidTSIdentifier(caseNormalizedTableName) 26 | ? ts.factory.createIdentifier(caseNormalizedTableName) 27 | : ts.factory.createStringLiteral(caseNormalizedTableName); 28 | 29 | const typeName = config.exportWrappedTypes 30 | ? toTableTypeName(field.typeName) 31 | : field.typeName; 32 | 33 | return ts.factory.createPropertySignature( 34 | undefined, 35 | nameIdentifier, 36 | undefined, 37 | ts.factory.createTypeReferenceNode( 38 | ts.factory.createIdentifier(typeName), 39 | undefined 40 | ) 41 | ); 42 | }); 43 | 44 | return ts.factory.createTypeAliasDeclaration( 45 | [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], 46 | ts.factory.createIdentifier(config.dbTypeName), 47 | undefined, 48 | ts.factory.createTypeLiteralNode(properties) 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/helpers/generateTypeOverrideFromDocumentation.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | 3 | import { generateTypeOverrideFromDocumentation } from "./generateTypeOverrideFromDocumentation"; 4 | 5 | test("finds a type override", () => { 6 | const docString = 7 | "this is some property \n here is the type override @kyselyType('admin' | 'member') "; 8 | 9 | expect(generateTypeOverrideFromDocumentation(docString)).toEqual( 10 | "'admin' | 'member'" 11 | ); 12 | }); 13 | 14 | test("supports parentheses in type", () => { 15 | const docString = 16 | "this is some property \n here is the type override @kyselyType(('admin' | 'member')) "; 17 | 18 | expect(generateTypeOverrideFromDocumentation(docString)).toEqual( 19 | "('admin' | 'member')" 20 | ); 21 | }); 22 | 23 | test("reacts correctly to unbalanced parens", () => { 24 | const docString = 25 | "this is some property \n here is the type override @kyselyType(('admin' | 'member') "; 26 | 27 | expect(generateTypeOverrideFromDocumentation(docString)).toEqual(null); 28 | }); 29 | 30 | test("reacts correctly to extra parens", () => { 31 | const docString = 32 | "this is some property \n here is the type override @kyselyType(('admin' | 'member'))) "; 33 | 34 | expect(generateTypeOverrideFromDocumentation(docString)).toEqual( 35 | "('admin' | 'member')" 36 | ); 37 | }); 38 | 39 | test("finds type following incomplete one", () => { 40 | const docString = 41 | "this is some property \n here is the type @kyselyType( override @kyselyType('admin' | 'member') "; 42 | 43 | expect(generateTypeOverrideFromDocumentation(docString)).toEqual( 44 | "'admin' | 'member'" 45 | ); 46 | }); 47 | 48 | test("doesn't do anything in case of no type", () => { 49 | const docString = "this is some property with no override"; 50 | 51 | expect(generateTypeOverrideFromDocumentation(docString)).toEqual(null); 52 | }); 53 | 54 | test("bails when we have an at sign and no match", () => { 55 | const docString = "hit me up at squiggly@goofy.af"; 56 | 57 | expect(generateTypeOverrideFromDocumentation(docString)).toEqual(null); 58 | }); 59 | -------------------------------------------------------------------------------- /src/dialectTests/common.ts: -------------------------------------------------------------------------------- 1 | import { exec as execCb } from "node:child_process"; 2 | import fs from "node:fs/promises"; 3 | import { promisify } from "node:util"; 4 | 5 | const exec = promisify(execCb); 6 | 7 | type Dialect = "sqlite" | "postgresql" | "mysql"; 8 | 9 | export const POSTGRES_URL = 10 | "postgres://postgres:postgres@localhost:22331/postgres"; 11 | export const MYSQL_URL = "mysql://root:mysql@localhost:22332/test"; 12 | 13 | function generateDatasource(dialect: Dialect) { 14 | switch (dialect) { 15 | case "sqlite": 16 | return 'provider = "sqlite"\nurl = "file:./dev.db"'; 17 | case "postgresql": 18 | return `provider = "postgresql"\nurl = "${POSTGRES_URL}"`; 19 | case "mysql": 20 | return `provider = "mysql"\nurl = "${MYSQL_URL}"`; 21 | } 22 | } 23 | 24 | export const generateSchema = (dialect: Dialect) => { 25 | return `datasource db { 26 | ${generateDatasource(dialect)} 27 | } 28 | 29 | generator kysely { 30 | provider = "node ./dist/bin.js" 31 | } 32 | 33 | model Widget { 34 | int Int @id @default(autoincrement()) 35 | dateTime DateTime @default(now()) 36 | string String @default("hello") 37 | boolean Boolean @default(true) 38 | bytes Bytes 39 | decimal Decimal @default(1.0) 40 | bigInt BigInt @default(1) 41 | float Float @default(1.0) 42 | }`; 43 | }; 44 | 45 | export const preparePrisma = async (dialect: Dialect) => { 46 | console.log("🪄 Deleting old prisma directory"); 47 | await fs.rm("./prisma", { recursive: true, force: true }); 48 | 49 | console.log("🪄 Recreating prisma directory"); 50 | await fs.mkdir("./prisma"); 51 | 52 | console.log("🪄 Writing new schema"); 53 | await fs.writeFile("./prisma/schema.prisma", generateSchema(dialect), { 54 | encoding: "utf-8", 55 | }); 56 | console.log("🪄 Pushing schema to db"); 57 | await exec("yarn prisma db push --force-reset"); 58 | 59 | console.log("🪄 Generating new types"); 60 | await exec("yarn prisma generate"); 61 | }; 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prisma-kysely", 3 | "version": "2.2.1", 4 | "description": "Generate Kysely database types from a Prisma schema", 5 | "repository": { 6 | "url": "git+https://github.com/valtyr/prisma-kysely.git" 7 | }, 8 | "license": "MIT", 9 | "author": { 10 | "name": "Valtyr Orn Kjartansson", 11 | "url": "http://valtyr.is" 12 | }, 13 | "contributors": [ 14 | { 15 | "name": "Alistair Smith", 16 | "url": "https://alistair.sh" 17 | }, 18 | { 19 | "name": "Arthur Fiorette", 20 | "url": "https://arthur.place" 21 | } 22 | ], 23 | "main": "dist/generator.js", 24 | "bin": { 25 | "prisma-kysely": "dist/bin.js" 26 | }, 27 | "files": [ 28 | "dist" 29 | ], 30 | "scripts": { 31 | "build": "tspc", 32 | "dev": "tspc --watch", 33 | "fix": "prettier --write .", 34 | "lint": "eslint ./src", 35 | "prepack": "yarn build", 36 | "release": "yarn build && yarn changeset publish", 37 | "start": "node dist/bin.js", 38 | "test": "yarn build && vitest --passWithNoTests --coverage", 39 | "typecheck": "tspc --noemit" 40 | }, 41 | "dependencies": { 42 | "@mrleebo/prisma-ast": "^0.13.1", 43 | "@prisma/generator-helper": "^6.18", 44 | "@prisma/internals": "^6.18", 45 | "typescript": "^5.9.2", 46 | "zod": "^4.1.5" 47 | }, 48 | "devDependencies": { 49 | "@changesets/cli": "^2.29.6", 50 | "@trivago/prettier-plugin-sort-imports": "^5.2.2", 51 | "@types/node": "24.6.1", 52 | "@types/pg": "^8.15.5", 53 | "@types/prettier": "3.0.0", 54 | "@typescript-eslint/eslint-plugin": "^8.41.0", 55 | "@typescript-eslint/parser": "^8.41.0", 56 | "@typescript-eslint/typescript-estree": "^8.41.0", 57 | "@vitest/coverage-v8": "^3.2.4", 58 | "eslint": "^9.34.0", 59 | "kysely": "^0.28.2", 60 | "mysql2": "^3.14.1", 61 | "pg": "^8.16.2", 62 | "prettier": "^3.6.2", 63 | "prisma": "^6.18", 64 | "ts-patch": "^3.3.0", 65 | "typescript-transform-paths": "^3.5.5", 66 | "vite-tsconfig-paths": "^5.1.4", 67 | "vitest": "^3.2.4" 68 | }, 69 | "peerDependencies": { 70 | "prisma": "^6.18" 71 | }, 72 | "packageManager": "yarn@1.22.22" 73 | } 74 | -------------------------------------------------------------------------------- /src/helpers/generateTypeOverrideFromDocumentation.ts: -------------------------------------------------------------------------------- 1 | const START_LEXEME = "@kyselyType("; 2 | 3 | /** 4 | * Searches the field for a string matching @kyselyType(...) and uses 5 | * that as the typescript type of the field. 6 | * 7 | * @param documentation - The documentation string of the field 8 | * @returns 9 | */ 10 | export const generateTypeOverrideFromDocumentation = ( 11 | documentation: string 12 | ) => { 13 | const tokens = documentation.split(""); 14 | 15 | let matchState: { tokens: string[]; startLocation: number } | null = null; 16 | let parentheses = 0; 17 | let i = 0; 18 | while (i < documentation.length) { 19 | const currentToken = tokens[i]; 20 | 21 | // If we're working on a match 22 | if (matchState) { 23 | // If we're working on a match and we find the end 24 | // return the result. 25 | if (currentToken === ")" && parentheses === 0) 26 | return matchState.tokens.join(""); 27 | 28 | // Increment or decrement the parentheses counter 29 | // if we reach parentheses 30 | if (currentToken === ")") parentheses--; 31 | if (currentToken === "(") parentheses++; 32 | 33 | // Append the current token to the match state. 34 | matchState.tokens.push(currentToken); 35 | 36 | i++; 37 | 38 | // If we've reached the end of the documentaion 39 | // without a match we should bail and continue 40 | // scanning. 41 | if (i === documentation.length) { 42 | i = matchState.startLocation + 1; 43 | matchState = null; 44 | continue; 45 | } 46 | 47 | continue; 48 | } 49 | 50 | // If we find the beginning of the start lexeme 51 | // we can continue checking for the full lexeme 52 | if (currentToken === "@") { 53 | const isMatch = 54 | tokens.slice(i, i + START_LEXEME.length).join("") === START_LEXEME; 55 | 56 | // If we don't find a match we bail. 57 | if (!isMatch) { 58 | i++; 59 | continue; 60 | } 61 | 62 | // Else start checking for the rest of the match 63 | matchState = { tokens: [], startLocation: i }; 64 | i += START_LEXEME.length; 65 | 66 | continue; 67 | } 68 | 69 | i++; 70 | } 71 | 72 | return null; 73 | }; 74 | -------------------------------------------------------------------------------- /src/helpers/multiSchemaHelpers.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | 3 | import { 4 | convertToMultiSchemaModels, 5 | parseMultiSchemaMap, 6 | } from "./multiSchemaHelpers"; 7 | 8 | const testDataModel = `generator kysely { 9 | provider = "node ./dist/bin.js" 10 | previewFeatures = ["multiSchema"] 11 | } 12 | 13 | datasource db { 14 | provider = "postgresql" 15 | schemas = ["mammals", "birds"] 16 | url = env("TEST_DATABASE_URL") 17 | } 18 | 19 | model Elephant { 20 | id Int @id 21 | name String 22 | 23 | @@map("elephants") 24 | @@schema("mammals") 25 | } 26 | 27 | model Eagle { 28 | id Int @id 29 | name String 30 | 31 | @@map("eagles") 32 | @@schema("birds") 33 | } 34 | 35 | model Fish { 36 | id Int @id 37 | name String 38 | 39 | @@map("fish") 40 | @@schema("public") 41 | } 42 | 43 | `; 44 | 45 | test("returns a list of models with schemas appended to the table name", () => { 46 | const initialModels = [ 47 | { typeName: "Elephant", tableName: "elephants" }, 48 | { typeName: "Eagle", tableName: "eagles" }, 49 | { typeName: "Fish", tableName: "fish" }, 50 | ]; 51 | 52 | const result = convertToMultiSchemaModels({ 53 | models: initialModels, 54 | groupBySchema: false, 55 | defaultSchema: "public", 56 | filterBySchema: null, 57 | multiSchemaMap: parseMultiSchemaMap(testDataModel), 58 | }); 59 | 60 | expect(result).toEqual([ 61 | { typeName: "Elephant", tableName: "mammals.elephants" }, 62 | { typeName: "Eagle", tableName: "birds.eagles" }, 63 | { typeName: "Fish", tableName: "fish" }, 64 | ]); 65 | }); 66 | 67 | test("returns a list of models with schemas appended to the table name filtered by schema", () => { 68 | const initialModels = [ 69 | { typeName: "Elephant", tableName: "elephants" }, 70 | { typeName: "Eagle", tableName: "eagles" }, 71 | ]; 72 | 73 | const result = convertToMultiSchemaModels({ 74 | models: initialModels, 75 | groupBySchema: false, 76 | defaultSchema: "public", 77 | filterBySchema: new Set(["mammals"]), 78 | multiSchemaMap: parseMultiSchemaMap(testDataModel), 79 | }); 80 | 81 | expect(result).toEqual([ 82 | { typeName: "Elephant", tableName: "mammals.elephants" }, 83 | ]); 84 | }); 85 | -------------------------------------------------------------------------------- /src/helpers/generateEnumType.ts: -------------------------------------------------------------------------------- 1 | import type { DMMF } from "@prisma/generator-helper"; 2 | import ts from "typescript"; 3 | 4 | import isValidTSIdentifier from "~/utils/isValidTSIdentifier"; 5 | 6 | import { generateStringLiteralUnion } from "./generateStringLiteralUnion"; 7 | import { generateTypedReferenceNode } from "./generateTypedReferenceNode"; 8 | 9 | export type EnumType = { 10 | objectDeclaration: ts.VariableStatement; 11 | typeDeclaration: ts.TypeAliasDeclaration; 12 | schema?: string; 13 | typeName: string; 14 | }; 15 | 16 | export const generateEnumType = ( 17 | name: string, 18 | values: readonly DMMF.EnumValue[] 19 | ): EnumType | undefined => { 20 | const type = generateStringLiteralUnion(values.map((v) => v.name)); 21 | 22 | if (!type) { 23 | return undefined; 24 | } 25 | 26 | const objectDeclaration = ts.factory.createVariableStatement( 27 | [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], 28 | ts.factory.createVariableDeclarationList( 29 | [ 30 | ts.factory.createVariableDeclaration( 31 | name, 32 | undefined, 33 | undefined, 34 | ts.factory.createAsExpression( 35 | ts.factory.createObjectLiteralExpression( 36 | values.map((v) => { 37 | const identifier = isValidTSIdentifier(v.name) 38 | ? ts.factory.createIdentifier(v.name) 39 | : ts.factory.createStringLiteral(v.name); 40 | 41 | return ts.factory.createPropertyAssignment( 42 | identifier, 43 | // dbName holds the "@map("value")" value from the Prisma schema if it exists, otherwise fallback to the name 44 | ts.factory.createStringLiteral(v.dbName || v.name) 45 | ); 46 | }), 47 | true 48 | ), 49 | ts.factory.createTypeReferenceNode( 50 | ts.factory.createIdentifier("const"), 51 | undefined 52 | ) 53 | ) 54 | ), 55 | ], 56 | ts.NodeFlags.Const 57 | ) 58 | ); 59 | 60 | const typeDeclaration = generateTypedReferenceNode(name); 61 | 62 | return { 63 | typeName: name, 64 | objectDeclaration, 65 | typeDeclaration, 66 | }; 67 | }; 68 | -------------------------------------------------------------------------------- /src/helpers/generateField.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | 3 | import { applyJSDocWorkaround } from "~/utils/applyJSDocWorkaround"; 4 | import isValidTSIdentifier from "~/utils/isValidTSIdentifier"; 5 | 6 | type GenerateFieldArgs = { 7 | name: string; 8 | type: ts.TypeNode; 9 | nullable: boolean; 10 | generated: boolean; 11 | isId: boolean; 12 | list: boolean; 13 | documentation?: string; 14 | 15 | config: { 16 | readOnlyIds: boolean; 17 | }; 18 | }; 19 | export const generateField = (args: GenerateFieldArgs) => { 20 | const { name, type, nullable, generated, list, documentation, isId, config } = 21 | args; 22 | 23 | /* 24 | * I'm not totally sure in which order these should be applied when it comes 25 | * to lists. Is the whole list nullable or is each entry in the list nullable? 26 | * If you run into problems here please file an issue or create a pull request 27 | * with a fix and some proof please. Thank you :D 28 | */ 29 | 30 | let fieldType = type; 31 | 32 | if (nullable) 33 | fieldType = ts.factory.createUnionTypeNode([ 34 | fieldType, 35 | ts.factory.createLiteralTypeNode( 36 | ts.factory.createToken(ts.SyntaxKind.NullKeyword) 37 | ), 38 | ]); 39 | 40 | if (list) fieldType = ts.factory.createArrayTypeNode(fieldType); 41 | 42 | if (generated) { 43 | if (isId && config.readOnlyIds) { 44 | fieldType = ts.factory.createTypeReferenceNode( 45 | ts.factory.createIdentifier("GeneratedAlways"), 46 | [fieldType] 47 | ); 48 | } else { 49 | fieldType = ts.factory.createTypeReferenceNode( 50 | ts.factory.createIdentifier("Generated"), 51 | [fieldType] 52 | ); 53 | } 54 | } 55 | 56 | const nameIdentifier = isValidTSIdentifier(name) 57 | ? ts.factory.createIdentifier(name) 58 | : ts.factory.createStringLiteral(name); 59 | 60 | const propertySignature = ts.factory.createPropertySignature( 61 | undefined, 62 | nameIdentifier, 63 | undefined, 64 | fieldType 65 | ); 66 | 67 | if (documentation) { 68 | return ts.addSyntheticLeadingComment( 69 | propertySignature, 70 | ts.SyntaxKind.MultiLineCommentTrivia, 71 | applyJSDocWorkaround(documentation), 72 | true 73 | ); 74 | } 75 | 76 | return propertySignature; 77 | }; 78 | -------------------------------------------------------------------------------- /src/dialectTests/sqlite.ts: -------------------------------------------------------------------------------- 1 | import type { SqliteDatabase, SqliteStatement } from "kysely"; 2 | import { Kysely, SqliteDialect } from "kysely"; 3 | import type { 4 | DatabaseSyncOptions, 5 | SQLInputValue, 6 | StatementSync, 7 | } from "node:sqlite"; 8 | import { DatabaseSync } from "node:sqlite"; 9 | 10 | import { preparePrisma } from "~/dialectTests/common"; 11 | 12 | class KyselyNodeSQLiteDatabase implements SqliteDatabase { 13 | private readonly database: DatabaseSync; 14 | 15 | constructor(url: string | Buffer | URL, options?: DatabaseSyncOptions) { 16 | this.database = new DatabaseSync(url, options); 17 | } 18 | 19 | close(): void { 20 | this.database.close(); 21 | } 22 | prepare(sql: string): SqliteStatement { 23 | return new KyselyNodeSQLiteStatement(this.database.prepare(sql)); 24 | } 25 | } 26 | 27 | class KyselyNodeSQLiteStatement implements SqliteStatement { 28 | private readonly statement: StatementSync; 29 | 30 | constructor(statement: StatementSync) { 31 | this.statement = statement; 32 | } 33 | 34 | iterate(parameters: ReadonlyArray): IterableIterator { 35 | return this.statement.iterate(...parameters); 36 | } 37 | 38 | reader: boolean; 39 | all(parameters: ReadonlyArray): unknown[] { 40 | return this.statement.all(...parameters); 41 | } 42 | run(parameters: ReadonlyArray): { 43 | changes: number | bigint; 44 | lastInsertRowid: number | bigint; 45 | } { 46 | return this.statement.run(...parameters); 47 | } 48 | } 49 | 50 | const main = async () => { 51 | await preparePrisma("sqlite"); 52 | 53 | const db = new Kysely({ 54 | dialect: new SqliteDialect({ 55 | database: new KyselyNodeSQLiteDatabase("./prisma/dev.db"), 56 | }), 57 | }); 58 | 59 | await db 60 | .insertInto("Widget") 61 | .values({ bytes: Buffer.from([]) }) 62 | .execute(); 63 | 64 | const result = await db 65 | .selectFrom("Widget") 66 | .selectAll() 67 | .executeTakeFirstOrThrow(); 68 | 69 | const entries = Object.entries(result).map(([key, value]) => ({ 70 | key, 71 | value, 72 | typeOf: typeof value, 73 | })); 74 | entries.sort((a, b) => a.key.localeCompare(b.key)); 75 | console.table(entries); 76 | 77 | await db.destroy(); 78 | }; 79 | 80 | main(); 81 | -------------------------------------------------------------------------------- /.github/workflows/linters.yml: -------------------------------------------------------------------------------- 1 | name: 😵‍💫 Sanity checks 2 | on: 3 | pull_request: 4 | types: [opened, synchronize] 5 | push: 6 | branches: 7 | - main 8 | permissions: 9 | pull-requests: write 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | test: 17 | name: 🧪 Tests 18 | runs-on: ubuntu-latest 19 | permissions: 20 | contents: read 21 | steps: 22 | - name: 🏗 Setup repo 23 | uses: actions/checkout@v5 24 | 25 | - name: 🏗 Setup Node 26 | uses: actions/setup-node@v5 27 | with: 28 | node-version-file: .nvmrc 29 | cache: yarn 30 | 31 | - name: 📦 Install dependencies 32 | run: yarn install 33 | 34 | - name: 🧪 Run tests 35 | run: yarn run test 36 | 37 | typecheck: 38 | name: 🤓 Type checker 39 | runs-on: ubuntu-latest 40 | permissions: 41 | contents: read 42 | steps: 43 | - name: 🏗 Setup repo 44 | uses: actions/checkout@v5 45 | 46 | - name: 🏗 Setup Node 47 | uses: actions/setup-node@v5 48 | with: 49 | node-version-file: .nvmrc 50 | cache: yarn 51 | 52 | - name: 📦 Install dependencies 53 | run: yarn install 54 | 55 | - name: 🤓 Run type checker 56 | run: yarn run typecheck 57 | 58 | lint: 59 | name: 👮‍♂️ Linters and formatters 60 | runs-on: ubuntu-latest 61 | permissions: 62 | checks: write # Allow creating checks 63 | contents: read 64 | steps: 65 | - name: 🏗 Setup repo 66 | uses: actions/checkout@v5 67 | 68 | - name: 🏗 Setup Node 69 | uses: actions/setup-node@v5 70 | with: 71 | node-version-file: .nvmrc 72 | cache: yarn 73 | 74 | - name: 📦 Install dependencies 75 | run: yarn install 76 | 77 | - name: 👮‍♂️ Run linters 78 | run: yarn run lint 79 | 80 | - name: 💅 Run fixers, and check diff 81 | id: diffCheck 82 | run: yarn run fix && git diff --exit-code -- ':!yarn.lock' 83 | 84 | - name: 💬 Post a check explaining the issue 85 | if: ${{ failure() && steps.diffCheck.conclusion == 'failure' }} 86 | uses: LouisBrunner/checks-action@v2 87 | with: 88 | token: ${{ secrets.GITHUB_TOKEN }} 89 | name: 🧹 Check all files are formatted correctly 90 | conclusion: failure 91 | output: | 92 | {"summary": "Hrm, seems like you don't have prettier set up properly. Make sure your editor is configured to format code automatically, and that it respects the project's prettier config. [Click here](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) to view the Prettier extension for VS Code.\n\n> _**💡 Tip:**_ \n> \n> In the meantime you can run `npm run fix` and commit the changes."} 93 | -------------------------------------------------------------------------------- /src/utils/validateConfig.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "@prisma/internals"; 2 | import z from "zod"; 3 | 4 | const booleanStringLiteral = z 5 | .union([z.boolean(), z.literal("true"), z.literal("false")]) 6 | .transform((arg) => { 7 | if (typeof arg === "boolean") return arg; 8 | return arg === "true"; 9 | }); 10 | 11 | export const configValidator = z 12 | .object({ 13 | // Meta information (not provided through user input) 14 | databaseProvider: z.union([ 15 | z.literal("postgresql"), 16 | z.literal("cockroachdb"), 17 | z.literal("mysql"), 18 | z.literal("sqlite"), 19 | z.literal("sqlserver"), 20 | ]), 21 | 22 | // Output overrides 23 | fileName: z.string().optional().default("types.ts"), 24 | importExtension: z.string().default(""), 25 | enumFileName: z.string().optional(), 26 | 27 | // Typescript type overrides 28 | stringTypeOverride: z.string().optional(), 29 | booleanTypeOverride: z.string().optional(), 30 | intTypeOverride: z.string().optional(), 31 | bigIntTypeOverride: z.string().optional(), 32 | floatTypeOverride: z.string().optional(), 33 | decimalTypeOverride: z.string().optional(), 34 | dateTimeTypeOverride: z.string().optional(), 35 | jsonTypeOverride: z.string().optional(), 36 | bytesTypeOverride: z.string().optional(), 37 | unsupportedTypeOverride: z.string().optional(), 38 | 39 | // The DB type name to use in the generated types. 40 | dbTypeName: z.string().default("DB"), 41 | 42 | // Support the Kysely camel case plugin 43 | camelCase: booleanStringLiteral.default(false), 44 | 45 | // Use GeneratedAlways for IDs instead of Generated 46 | readOnlyIds: booleanStringLiteral.default(false), 47 | 48 | // Group models in a namespace by their schema. Cannot be defined if enumFileName is defined. 49 | groupBySchema: booleanStringLiteral.default(false), 50 | 51 | // Which schema should not be wrapped in a namespace 52 | defaultSchema: z.string().default("public"), 53 | 54 | // Group models in a namespace by their schema. Cannot be defined if enumFileName is defined. 55 | filterBySchema: z.array(z.string()).optional(), 56 | 57 | // Export Kysely wrapped types such as `Selectable` 58 | exportWrappedTypes: booleanStringLiteral.default(false), 59 | }) 60 | .strict() 61 | .transform((config) => { 62 | if (!config.enumFileName) { 63 | config.enumFileName = config.fileName; 64 | } 65 | 66 | if (config.groupBySchema && config.enumFileName !== config.fileName) { 67 | // would require https://www.typescriptlang.org/docs/handbook/namespaces.html#splitting-across-files 68 | // which is considered a bad practice 69 | throw new Error("groupBySchema is not compatible with enumFileName"); 70 | } 71 | 72 | return config as Omit & 73 | Required>; 74 | }); 75 | 76 | export type Config = z.infer; 77 | 78 | export const validateConfig = (config: unknown) => { 79 | const parsed = configValidator.safeParse(config); 80 | if (!parsed.success) { 81 | logger.error("Invalid prisma-kysely config"); 82 | Object.entries(parsed.error.flatten().fieldErrors).forEach( 83 | ([key, value]) => { 84 | logger.error(`${key}: ${value.join(", ")}`); 85 | } 86 | ); 87 | Object.values(parsed.error.flatten().formErrors).forEach((value) => { 88 | logger.error(`${value}`); 89 | }); 90 | 91 | process.exit(1); 92 | } 93 | return parsed.data; 94 | }; 95 | -------------------------------------------------------------------------------- /src/helpers/generateModel.ts: -------------------------------------------------------------------------------- 1 | import type { DMMF } from "@prisma/generator-helper"; 2 | import ts from "typescript"; 3 | 4 | import { generateField } from "~/helpers/generateField"; 5 | import { generateFieldType } from "~/helpers/generateFieldType"; 6 | import { generateTypeOverrideFromDocumentation } from "~/helpers/generateTypeOverrideFromDocumentation"; 7 | import { normalizeCase } from "~/utils/normalizeCase"; 8 | import type { Config } from "~/utils/validateConfig"; 9 | import { capitalize } from "~/utils/words"; 10 | 11 | /** 12 | * Some of Prisma's default values are implemented in 13 | * JS. These should therefore not be annotated as Generated. 14 | * See https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#attribute-functions. 15 | */ 16 | const defaultTypesImplementedInJS = ["cuid", "uuid"]; 17 | 18 | export type ModelType = { 19 | typeName: string; 20 | tableName: string; 21 | definition: ts.TypeAliasDeclaration; 22 | schema?: string; 23 | }; 24 | 25 | export type GenerateModelOptions = { 26 | groupBySchema: boolean; 27 | defaultSchema: string; 28 | multiSchemaMap?: Map; 29 | }; 30 | 31 | export const generateModel = ( 32 | model: DMMF.Model, 33 | config: Config, 34 | { defaultSchema, groupBySchema, multiSchemaMap }: GenerateModelOptions 35 | ): ModelType => { 36 | const properties = model.fields.flatMap((field) => { 37 | const isGenerated = 38 | field.hasDefaultValue && 39 | !( 40 | typeof field.default === "object" && 41 | "name" in field.default && 42 | defaultTypesImplementedInJS.includes(field.default.name) 43 | ); 44 | 45 | const typeOverride = field.documentation 46 | ? generateTypeOverrideFromDocumentation(field.documentation) 47 | : null; 48 | 49 | if (field.kind === "object" || field.kind === "unsupported") return []; 50 | 51 | const dbName = typeof field.dbName === "string" ? field.dbName : null; 52 | 53 | const schemaPrefix = groupBySchema && multiSchemaMap?.get(field.type); 54 | 55 | if (field.kind === "enum") { 56 | return generateField({ 57 | isId: field.isId, 58 | name: normalizeCase(dbName || field.name, config), 59 | type: ts.factory.createTypeReferenceNode( 60 | ts.factory.createIdentifier( 61 | schemaPrefix && defaultSchema !== schemaPrefix 62 | ? `${capitalize(schemaPrefix)}.${field.type}` 63 | : field.type 64 | ), 65 | undefined 66 | ), 67 | nullable: !field.isRequired, 68 | generated: isGenerated, 69 | list: field.isList, 70 | documentation: field.documentation, 71 | config, 72 | }); 73 | } 74 | 75 | return generateField({ 76 | name: normalizeCase(dbName || field.name, config), 77 | type: ts.factory.createTypeReferenceNode( 78 | ts.factory.createIdentifier( 79 | generateFieldType(field.type, config, typeOverride) 80 | ), 81 | undefined 82 | ), 83 | nullable: !field.isRequired, 84 | generated: isGenerated, 85 | list: field.isList, 86 | documentation: field.documentation, 87 | isId: field.isId, 88 | config, 89 | }); 90 | }); 91 | 92 | return { 93 | typeName: model.name, 94 | tableName: model.dbName || model.name, 95 | definition: ts.factory.createTypeAliasDeclaration( 96 | [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], 97 | ts.factory.createIdentifier(model.name), 98 | undefined, 99 | ts.factory.createTypeLiteralNode(properties) 100 | ), 101 | }; 102 | }; 103 | -------------------------------------------------------------------------------- /src/helpers/generateFieldType.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "~/utils/validateConfig"; 2 | 3 | type TypeName = 4 | | "BigInt" 5 | | "Boolean" 6 | | "Bytes" 7 | | "DateTime" 8 | | "Decimal" 9 | | "Float" 10 | | "Int" 11 | | "Json" 12 | | "String" 13 | | "Unsupported"; 14 | 15 | type TypeMap = Partial> & { [x: string]: string }; 16 | 17 | export const sqliteTypeMap: TypeMap = { 18 | BigInt: "number", 19 | Boolean: "number", 20 | Bytes: "Buffer", 21 | DateTime: "string", 22 | Decimal: "number", 23 | Float: "number", 24 | Int: "number", 25 | Json: "unknown", 26 | String: "string", 27 | Unsupported: "unknown", 28 | }; 29 | 30 | export const mysqlTypeMap: TypeMap = { 31 | BigInt: "number", 32 | Boolean: "number", 33 | Bytes: "Buffer", 34 | DateTime: "Timestamp", 35 | Decimal: "string", 36 | Float: "number", 37 | Int: "number", 38 | Json: "unknown", 39 | String: "string", 40 | Unsupported: "unknown", 41 | }; 42 | 43 | export const postgresqlTypeMap: TypeMap = { 44 | BigInt: "string", 45 | Boolean: "boolean", 46 | Bytes: "Buffer", 47 | DateTime: "Timestamp", 48 | Decimal: "string", 49 | Float: "number", 50 | Int: "number", 51 | Json: "unknown", 52 | String: "string", 53 | Unsupported: "unknown", 54 | }; 55 | 56 | export const sqlServerTypeMap: TypeMap = { 57 | BigInt: "number", 58 | Boolean: "boolean", 59 | Bytes: "Buffer", 60 | DateTime: "Timestamp", 61 | Decimal: "string", 62 | Float: "number", 63 | Int: "number", 64 | Json: "unknown", 65 | String: "string", 66 | Unsupported: "unknown", 67 | }; 68 | 69 | export const overrideType = (type: string, config: Config) => { 70 | switch (type) { 71 | case "String": 72 | return config.stringTypeOverride; 73 | case "DateTime": 74 | return config.dateTimeTypeOverride; 75 | case "Boolean": 76 | return config.booleanTypeOverride; 77 | case "BigInt": 78 | return config.bigIntTypeOverride; 79 | case "Int": 80 | return config.intTypeOverride; 81 | case "Float": 82 | return config.floatTypeOverride; 83 | case "Decimal": 84 | return config.decimalTypeOverride; 85 | case "Bytes": 86 | return config.bytesTypeOverride; 87 | case "Json": 88 | return config.jsonTypeOverride; 89 | case "Unsupported": 90 | return config.unsupportedTypeOverride; 91 | } 92 | }; 93 | 94 | export const generateFieldTypeInner = ( 95 | type: string, 96 | config: Config, 97 | typeOverride: string | null 98 | ) => { 99 | switch (config.databaseProvider) { 100 | case "sqlite": 101 | return typeOverride || overrideType(type, config) || sqliteTypeMap[type]; 102 | case "mysql": 103 | return typeOverride || overrideType(type, config) || mysqlTypeMap[type]; 104 | case "postgresql": 105 | return ( 106 | typeOverride || overrideType(type, config) || postgresqlTypeMap[type] 107 | ); 108 | case "cockroachdb": 109 | return ( 110 | typeOverride || overrideType(type, config) || postgresqlTypeMap[type] 111 | ); 112 | case "sqlserver": 113 | return ( 114 | typeOverride || overrideType(type, config) || sqlServerTypeMap[type] 115 | ); 116 | } 117 | }; 118 | 119 | export const generateFieldType = ( 120 | type: string, 121 | config: Config, 122 | typeOverride?: string | null 123 | ) => { 124 | const fieldType = generateFieldTypeInner(type, config, typeOverride || null); 125 | if (!fieldType) 126 | throw new Error( 127 | `Unsupported type ${type} for database ${config.databaseProvider}` 128 | ); 129 | return fieldType; 130 | }; 131 | -------------------------------------------------------------------------------- /src/helpers/generateFieldType.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | 3 | import { generateFieldType } from "~/helpers/generateFieldType"; 4 | import { Config } from "~/utils/validateConfig"; 5 | 6 | test("it respects overrides when generating field types", () => { 7 | const overrides = { 8 | stringTypeOverride: "stringOverride", 9 | bigIntTypeOverride: "smallInt", 10 | booleanTypeOverride: "bootilt", 11 | bytesTypeOverride: "bits", 12 | dateTimeTypeOverride: "aloneTime", 13 | decimalTypeOverride: "octal", 14 | floatTypeOverride: "sink", 15 | intTypeOverride: "lol", 16 | jsonTypeOverride: "freddy", 17 | unsupportedTypeOverride: "valid", 18 | }; 19 | 20 | const config = { 21 | ...overrides, 22 | databaseProvider: "postgresql" as const, 23 | fileName: "types.ts", 24 | enumFileName: "types.ts", 25 | camelCase: false, 26 | readOnlyIds: false, 27 | groupBySchema: false, 28 | defaultSchema: "public", 29 | dbTypeName: "DB", 30 | importExtension: "", 31 | exportWrappedTypes: false, 32 | }; 33 | 34 | const sourceTypes = [ 35 | "String", 36 | "BigInt", 37 | "Boolean", 38 | "Bytes", 39 | "DateTime", 40 | "Decimal", 41 | "Float", 42 | "Int", 43 | "Json", 44 | "Unsupported", 45 | ]; 46 | 47 | expect( 48 | sourceTypes.map((source) => generateFieldType(source, config)) 49 | ).toEqual(Object.values(overrides)); 50 | }); 51 | 52 | test("it respects overrides when generating field types", () => { 53 | const node = generateFieldType("String", { 54 | databaseProvider: "mysql", 55 | fileName: "types.ts", 56 | enumFileName: "types.ts", 57 | stringTypeOverride: "cheese", 58 | camelCase: false, 59 | readOnlyIds: false, 60 | groupBySchema: false, 61 | defaultSchema: "public", 62 | dbTypeName: "DB", 63 | importExtension: "", 64 | exportWrappedTypes: false, 65 | }); 66 | 67 | expect(node).toEqual("cheese"); 68 | }); 69 | 70 | test("it respects differences between database engines", () => { 71 | const postgresBooleanType = generateFieldType("Boolean", { 72 | databaseProvider: "postgresql", 73 | fileName: "types.ts", 74 | enumFileName: "types.ts", 75 | camelCase: false, 76 | readOnlyIds: false, 77 | groupBySchema: false, 78 | defaultSchema: "public", 79 | dbTypeName: "DB", 80 | importExtension: "", 81 | exportWrappedTypes: false, 82 | }); 83 | 84 | const mysqlBooleanType = generateFieldType("Boolean", { 85 | databaseProvider: "mysql", 86 | fileName: "types.ts", 87 | enumFileName: "types.ts", 88 | camelCase: false, 89 | readOnlyIds: false, 90 | groupBySchema: false, 91 | defaultSchema: "public", 92 | dbTypeName: "DB", 93 | importExtension: "", 94 | exportWrappedTypes: false, 95 | }); 96 | 97 | const sqliteBooleanType = generateFieldType("Boolean", { 98 | databaseProvider: "sqlite", 99 | fileName: "types.ts", 100 | enumFileName: "types.ts", 101 | camelCase: false, 102 | readOnlyIds: false, 103 | groupBySchema: false, 104 | defaultSchema: "public", 105 | dbTypeName: "DB", 106 | importExtension: "", 107 | exportWrappedTypes: false, 108 | }); 109 | 110 | expect(postgresBooleanType).toEqual("boolean"); 111 | expect(mysqlBooleanType).toEqual("number"); 112 | expect(sqliteBooleanType).toEqual("number"); 113 | }); 114 | 115 | test("it supports JSON type in SQLite", () => { 116 | const config: Config = { 117 | databaseProvider: "sqlite", 118 | fileName: "types.ts", 119 | enumFileName: "types.ts", 120 | camelCase: false, 121 | readOnlyIds: false, 122 | groupBySchema: false, 123 | defaultSchema: "public", 124 | dbTypeName: "DB", 125 | importExtension: "", 126 | exportWrappedTypes: false, 127 | }; 128 | 129 | expect(generateFieldType("Json", config)).toEqual("unknown"); 130 | 131 | expect( 132 | generateFieldType("Json", { ...config, jsonTypeOverride: "custom" }) 133 | ).toEqual("custom"); 134 | }); 135 | -------------------------------------------------------------------------------- /src/helpers/generateImplicitManyToManyModels.ts: -------------------------------------------------------------------------------- 1 | import type { DMMF } from "@prisma/generator-helper"; 2 | 3 | import { sorted } from "~/utils/sorted"; 4 | 5 | /* 6 | 7 | Credit where credit is due: 8 | 9 | This is heavily borrowed from prisma-dbml-generator 10 | https://github.com/notiz-dev/prisma-dbml-generator/blob/752f89cf40257a9698913294b38843ac742f8345/src/generator/many-to-many-tables.ts 11 | 12 | */ 13 | 14 | export const getModelByType = ( 15 | models: DMMF.Model[], 16 | type: string 17 | ): DMMF.Model | undefined => { 18 | return models.find((model) => model.name === type); 19 | }; 20 | 21 | export function generateImplicitManyToManyModels( 22 | models: readonly DMMF.Model[] 23 | ): DMMF.Model[] { 24 | const manyToManyFields = filterManyToManyRelationFields(models); 25 | 26 | if (!manyToManyFields.length) { 27 | return []; 28 | } 29 | 30 | return generateModels(manyToManyFields, models, []); 31 | } 32 | 33 | function generateModels( 34 | manyToManyFields: DMMF.Field[], 35 | models: readonly DMMF.Model[], 36 | manyToManyTables: DMMF.Model[] = [] 37 | ): DMMF.Model[] { 38 | const manyFirst = manyToManyFields.shift(); 39 | 40 | if (!manyFirst) { 41 | return manyToManyTables; 42 | } 43 | 44 | const manySecond = manyToManyFields.find( 45 | (field) => field.relationName === manyFirst.relationName 46 | ); 47 | 48 | if (!manySecond) { 49 | return manyToManyTables; 50 | } 51 | 52 | manyToManyTables.push({ 53 | dbName: `_${manyFirst.relationName}`, 54 | name: manyFirst.relationName || "", 55 | primaryKey: null, 56 | schema: null, 57 | uniqueFields: [], 58 | uniqueIndexes: [], 59 | fields: generateJoinFields([manyFirst, manySecond], models), 60 | }); 61 | 62 | return generateModels( 63 | manyToManyFields.filter( 64 | (field) => field.relationName !== manyFirst.relationName 65 | ), 66 | models, 67 | manyToManyTables 68 | ); 69 | } 70 | 71 | function generateJoinFields( 72 | fields: [DMMF.Field, DMMF.Field], 73 | models: readonly DMMF.Model[] 74 | ): DMMF.Field[] { 75 | if (fields.length !== 2) throw new Error("Huh?"); 76 | 77 | const sortedFields = sorted(fields, (a, b) => a.type.localeCompare(b.type)); 78 | const joinedA = getJoinIdField(sortedFields[0], models); 79 | const joinedB = getJoinIdField(sortedFields[1], models); 80 | 81 | return [ 82 | { 83 | name: "A", 84 | type: joinedA.type, 85 | kind: joinedA.kind, 86 | isRequired: true, 87 | isList: false, 88 | isUnique: false, 89 | isId: false, 90 | isReadOnly: true, 91 | hasDefaultValue: false, 92 | }, 93 | { 94 | name: "B", 95 | type: joinedB.type, 96 | kind: joinedB.kind, 97 | isRequired: true, 98 | isList: false, 99 | isUnique: false, 100 | isId: false, 101 | isReadOnly: true, 102 | hasDefaultValue: false, 103 | }, 104 | ]; 105 | } 106 | 107 | function getJoinIdField( 108 | joinField: DMMF.Field, 109 | models: readonly DMMF.Model[] 110 | ): DMMF.Field { 111 | const joinedModel = models.find((m) => m.name === joinField.type); 112 | if (!joinedModel) throw new Error("Could not find referenced model"); 113 | 114 | const idField = joinedModel.fields.find((f) => f.isId); 115 | if (!idField) throw new Error("No ID field on referenced model"); 116 | 117 | return idField; 118 | } 119 | 120 | function filterManyToManyRelationFields(models: readonly DMMF.Model[]) { 121 | const fields = models.flatMap((model) => model.fields); 122 | 123 | const relationFields = fields.filter( 124 | (field): field is DMMF.Field & Required> => 125 | !!field.relationName 126 | ); 127 | 128 | const nonManyToManyRelationNames = relationFields 129 | .filter((field) => !field.isList) 130 | .map((field) => field.relationName); 131 | 132 | const notManyToMany = new Set(nonManyToManyRelationNames); 133 | 134 | return relationFields.filter( 135 | (field) => !notManyToMany.has(field.relationName) 136 | ); 137 | } 138 | -------------------------------------------------------------------------------- /src/utils/words.ts: -------------------------------------------------------------------------------- 1 | // Copied directly from the Kysely repo 2 | // https://github.com/koskimas/kysely/blob/a8e28d9edd6284d5410f93bc24d3c6252add6ea1/src/plugin/camel-case/camel-case.ts 3 | 4 | export type StringMapper = (str: string) => string; 5 | 6 | /** 7 | * Creates a function that transforms camel case strings to snake case. 8 | */ 9 | export function createSnakeCaseMapper({ 10 | upperCase = false, 11 | underscoreBeforeDigits = false, 12 | underscoreBetweenUppercaseLetters = false, 13 | } = {}): StringMapper { 14 | return memoize((str: string): string => { 15 | if (str.length === 0) { 16 | return str; 17 | } 18 | 19 | const upper = str.toUpperCase(); 20 | const lower = str.toLowerCase(); 21 | 22 | let out = lower[0]; 23 | 24 | for (let i = 1, l = str.length; i < l; ++i) { 25 | const char = str[i]; 26 | const prevChar = str[i - 1]; 27 | 28 | const upperChar = upper[i]; 29 | const prevUpperChar = upper[i - 1]; 30 | 31 | const lowerChar = lower[i]; 32 | const prevLowerChar = lower[i - 1]; 33 | 34 | // If underScoreBeforeDigits is true then, well, insert an underscore 35 | // before digits :). Only the first digit gets an underscore if 36 | // there are multiple. 37 | if (underscoreBeforeDigits && isDigit(char) && !isDigit(prevChar)) { 38 | out += "_" + char; 39 | continue; 40 | } 41 | 42 | // Test if `char` is an upper-case character and that the character 43 | // actually has different upper and lower case versions. 44 | if (char === upperChar && upperChar !== lowerChar) { 45 | const prevCharacterIsUppercase = 46 | prevChar === prevUpperChar && prevUpperChar !== prevLowerChar; 47 | 48 | // If underscoreBetweenUppercaseLetters is true, we always place an underscore 49 | // before consecutive uppercase letters (e.g. "fooBAR" becomes "foo_b_a_r"). 50 | // Otherwise, we don't (e.g. "fooBAR" becomes "foo_bar"). 51 | if (underscoreBetweenUppercaseLetters || !prevCharacterIsUppercase) { 52 | out += "_" + lowerChar; 53 | } else { 54 | out += lowerChar; 55 | } 56 | } else { 57 | out += char; 58 | } 59 | } 60 | 61 | if (upperCase) { 62 | return out.toUpperCase(); 63 | } else { 64 | return out; 65 | } 66 | }); 67 | } 68 | 69 | /** 70 | * Creates a function that transforms snake case strings to camel case. 71 | */ 72 | export function createCamelCaseMapper({ 73 | upperCase = false, 74 | } = {}): StringMapper { 75 | return memoize((str: string): string => { 76 | if (str.length === 0) { 77 | return str; 78 | } 79 | 80 | if (upperCase && isAllUpperCaseSnakeCase(str)) { 81 | // Only convert to lower case if the string is all upper 82 | // case snake_case. This allows camelCase strings to go 83 | // through without changing. 84 | str = str.toLowerCase(); 85 | } 86 | 87 | let out = str[0]; 88 | 89 | for (let i = 1, l = str.length; i < l; ++i) { 90 | const char = str[i]; 91 | const prevChar = str[i - 1]; 92 | 93 | if (char !== "_") { 94 | if (prevChar === "_") { 95 | out += char.toUpperCase(); 96 | } else { 97 | out += char; 98 | } 99 | } 100 | } 101 | 102 | return out; 103 | }); 104 | } 105 | 106 | function isAllUpperCaseSnakeCase(str: string): boolean { 107 | for (let i = 1, l = str.length; i < l; ++i) { 108 | const char = str[i]; 109 | 110 | if (char !== "_" && char !== char.toUpperCase()) { 111 | return false; 112 | } 113 | } 114 | 115 | return true; 116 | } 117 | 118 | function isDigit(char: string): boolean { 119 | return char >= "0" && char <= "9"; 120 | } 121 | 122 | function memoize(func: StringMapper): StringMapper { 123 | const cache = new Map(); 124 | 125 | return (str: string) => { 126 | let mapped = cache.get(str); 127 | 128 | if (!mapped) { 129 | mapped = func(str); 130 | cache.set(str, mapped); 131 | } 132 | 133 | return mapped; 134 | }; 135 | } 136 | 137 | export function capitalize(str: string) { 138 | return str.charAt(0).toUpperCase() + str.slice(1); 139 | } 140 | -------------------------------------------------------------------------------- /src/helpers/generateField.test.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import { expect, test } from "vitest"; 3 | 4 | import { generateField } from "~/helpers/generateField"; 5 | import { stringifyTsNode } from "~/utils/testUtils"; 6 | 7 | const stringTypeNode = ts.factory.createTypeReferenceNode( 8 | ts.factory.createIdentifier("string"), 9 | undefined 10 | ); 11 | 12 | test("it creates correct annotation for non-nullable types", () => { 13 | const node = generateField({ 14 | name: "name", 15 | type: stringTypeNode, 16 | nullable: false, 17 | generated: false, 18 | list: false, 19 | isId: false, 20 | config: { 21 | readOnlyIds: false, 22 | }, 23 | }); 24 | const result = stringifyTsNode(node); 25 | 26 | expect(result).toEqual("name: string;"); 27 | }); 28 | 29 | test("it creates correct annotation for nullable types", () => { 30 | const node = generateField({ 31 | name: "name", 32 | type: stringTypeNode, 33 | nullable: true, 34 | generated: false, 35 | list: false, 36 | isId: false, 37 | config: { 38 | readOnlyIds: false, 39 | }, 40 | }); 41 | const result = stringifyTsNode(node); 42 | 43 | expect(result).toEqual("name: string | null;"); 44 | }); 45 | 46 | test("it creates correct annotation for generated types", () => { 47 | const node = generateField({ 48 | name: "name", 49 | type: stringTypeNode, 50 | nullable: false, 51 | generated: true, 52 | list: false, 53 | isId: false, 54 | config: { 55 | readOnlyIds: false, 56 | }, 57 | }); 58 | const result = stringifyTsNode(node); 59 | 60 | expect(result).toEqual("name: Generated;"); 61 | }); 62 | 63 | test("it creates correct annotation for list types", () => { 64 | const node = generateField({ 65 | name: "name", 66 | type: stringTypeNode, 67 | nullable: false, 68 | generated: false, 69 | list: true, 70 | isId: false, 71 | config: { 72 | readOnlyIds: false, 73 | }, 74 | }); 75 | const result = stringifyTsNode(node); 76 | 77 | expect(result).toEqual("name: string[];"); 78 | }); 79 | 80 | test("it creates correct annotation for generated list types", () => { 81 | const node = generateField({ 82 | name: "name", 83 | type: stringTypeNode, 84 | nullable: false, 85 | generated: true, 86 | list: true, 87 | isId: false, 88 | config: { 89 | readOnlyIds: false, 90 | }, 91 | }); 92 | const result = stringifyTsNode(node); 93 | 94 | expect(result).toEqual("name: Generated;"); 95 | }); 96 | 97 | test("it creates correct annotation for generated nullable list types (do these exist?)", () => { 98 | // Is this how these work? I have no clue. I don't even know if Kysely supports these. 99 | // If you run into problems here, please file an issue or create a pull request. 100 | 101 | const node = generateField({ 102 | name: "name", 103 | type: stringTypeNode, 104 | nullable: true, 105 | generated: true, 106 | list: true, 107 | isId: false, 108 | config: { 109 | readOnlyIds: false, 110 | }, 111 | }); 112 | const result = stringifyTsNode(node); 113 | 114 | expect(result).toEqual("name: Generated<(string | null)[]>;"); 115 | }); 116 | 117 | test("it prepends a JSDoc comment if documentation is provided", () => { 118 | const node = generateField({ 119 | name: "name", 120 | type: stringTypeNode, 121 | nullable: false, 122 | generated: false, 123 | list: false, 124 | isId: false, 125 | documentation: "This is a comment", 126 | config: { 127 | readOnlyIds: false, 128 | }, 129 | }); 130 | const result = stringifyTsNode(node); 131 | 132 | expect(result).toEqual("/**\n * This is a comment\n */\nname: string;"); 133 | }); 134 | 135 | test("it uses generated always for ids if config item is specified", () => { 136 | const node = generateField({ 137 | name: "id", 138 | type: stringTypeNode, 139 | nullable: false, 140 | generated: true, 141 | list: false, 142 | isId: true, 143 | config: { 144 | readOnlyIds: true, 145 | }, 146 | }); 147 | const result = stringifyTsNode(node); 148 | 149 | expect(result).toEqual("id: GeneratedAlways;"); 150 | }); 151 | -------------------------------------------------------------------------------- /src/generator.ts: -------------------------------------------------------------------------------- 1 | import type { GeneratorOptions } from "@prisma/generator-helper"; 2 | import { generatorHandler } from "@prisma/generator-helper"; 3 | import path from "node:path"; 4 | 5 | import { GENERATOR_NAME } from "~/constants"; 6 | import { generateDatabaseType } from "~/helpers/generateDatabaseType"; 7 | import { generateFiles } from "~/helpers/generateFiles"; 8 | import { generateImplicitManyToManyModels } from "~/helpers/generateImplicitManyToManyModels"; 9 | import { generateModel } from "~/helpers/generateModel"; 10 | import { sorted } from "~/utils/sorted"; 11 | import { validateConfig } from "~/utils/validateConfig"; 12 | import { writeFileSafely } from "~/utils/writeFileSafely"; 13 | 14 | import { type EnumType, generateEnumType } from "./helpers/generateEnumType"; 15 | import { 16 | convertToMultiSchemaModels, 17 | parseMultiSchemaMap, 18 | } from "./helpers/multiSchemaHelpers"; 19 | 20 | // eslint-disable-next-line @typescript-eslint/no-require-imports 21 | const { version } = require("../package.json"); 22 | 23 | generatorHandler({ 24 | onManifest() { 25 | return { 26 | version, 27 | defaultOutput: "./generated", 28 | prettyName: GENERATOR_NAME, 29 | }; 30 | }, 31 | onGenerate: async (options: GeneratorOptions) => { 32 | // Parse the config 33 | const config = validateConfig({ 34 | ...options.generator.config, 35 | databaseProvider: options.datasources[0].provider, 36 | }); 37 | 38 | // Generate enum types 39 | let enums = options.dmmf.datamodel.enums 40 | .map(({ name, values }) => generateEnumType(name, values)) 41 | .filter((e): e is EnumType => !!e); 42 | 43 | // Generate DMMF models for implicit many to many tables 44 | // 45 | // (I don't know why you would want to use implicit tables 46 | // with kysely, but hey, you do you) 47 | const implicitManyToManyModels = generateImplicitManyToManyModels( 48 | options.dmmf.datamodel.models 49 | ); 50 | 51 | const hasMultiSchema = options.datasources.some( 52 | (d) => d.schemas.length > 0 53 | ); 54 | 55 | const multiSchemaMap = 56 | config.groupBySchema || hasMultiSchema 57 | ? parseMultiSchemaMap(options.datamodel) 58 | : undefined; 59 | 60 | // Generate model types 61 | let models = sorted( 62 | [...options.dmmf.datamodel.models, ...implicitManyToManyModels], 63 | (a, b) => a.name.localeCompare(b.name) 64 | ).map((m) => 65 | generateModel(m, config, { 66 | groupBySchema: config.groupBySchema, 67 | defaultSchema: config.defaultSchema, 68 | multiSchemaMap, 69 | }) 70 | ); 71 | 72 | // Extend model table names with schema names if using multi-schemas 73 | if (hasMultiSchema) { 74 | const filterBySchema = config.filterBySchema 75 | ? new Set(config.filterBySchema) 76 | : null; 77 | 78 | models = convertToMultiSchemaModels({ 79 | models, 80 | groupBySchema: config.groupBySchema, 81 | defaultSchema: config.defaultSchema, 82 | filterBySchema, 83 | multiSchemaMap, 84 | }); 85 | 86 | enums = convertToMultiSchemaModels({ 87 | models: enums, 88 | groupBySchema: config.groupBySchema, 89 | defaultSchema: config.defaultSchema, 90 | filterBySchema, 91 | multiSchemaMap, 92 | }); 93 | } 94 | 95 | // Generate the database type that ties it all together 96 | const databaseType = generateDatabaseType(models, config); 97 | 98 | // Parse it all into a string. Either 1 or 2 files depending on user config 99 | const files = generateFiles({ 100 | databaseType, 101 | enumNames: options.dmmf.datamodel.enums.map((e) => e.name), 102 | models, 103 | enums, 104 | enumsOutfile: config.enumFileName, 105 | typesOutfile: config.fileName, 106 | groupBySchema: config.groupBySchema, 107 | defaultSchema: config.defaultSchema, 108 | importExtension: config.importExtension, 109 | exportWrappedTypes: config.exportWrappedTypes, 110 | }); 111 | 112 | // And write it to a file! 113 | await Promise.allSettled( 114 | files.map(({ filepath, content }) => { 115 | const writeLocation = path.join( 116 | options.generator.output?.value || "", 117 | filepath 118 | ); 119 | return writeFileSafely(writeLocation, content); 120 | }) 121 | ); 122 | }, 123 | }); 124 | -------------------------------------------------------------------------------- /src/helpers/generateDatabaseType.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | 3 | import { generateDatabaseType } from "~/helpers/generateDatabaseType"; 4 | import { stringifyTsNode } from "~/utils/testUtils"; 5 | 6 | test("it works for plain vanilla type names", () => { 7 | const node = generateDatabaseType( 8 | [ 9 | { tableName: "Bookmark", typeName: "Bookmark" }, 10 | { tableName: "Session", typeName: "Session" }, 11 | { tableName: "User", typeName: "User" }, 12 | ], 13 | { 14 | databaseProvider: "postgresql", 15 | fileName: "", 16 | enumFileName: "", 17 | camelCase: false, 18 | readOnlyIds: false, 19 | groupBySchema: false, 20 | defaultSchema: "public", 21 | dbTypeName: "DB", 22 | importExtension: "", 23 | exportWrappedTypes: false, 24 | } 25 | ); 26 | const result = stringifyTsNode(node); 27 | 28 | expect(result).toEqual(`export type DB = { 29 | Bookmark: Bookmark; 30 | Session: Session; 31 | User: User; 32 | };`); 33 | }); 34 | 35 | test("it respects camelCase option names", () => { 36 | const node = generateDatabaseType( 37 | [ 38 | { tableName: "book_mark", typeName: "Bookmark" }, 39 | { tableName: "session", typeName: "Session" }, 40 | { tableName: "user_table", typeName: "User" }, 41 | ], 42 | { 43 | databaseProvider: "postgresql", 44 | fileName: "", 45 | enumFileName: "", 46 | camelCase: true, 47 | readOnlyIds: false, 48 | groupBySchema: false, 49 | defaultSchema: "public", 50 | dbTypeName: "DB", 51 | importExtension: "", 52 | exportWrappedTypes: false, 53 | } 54 | ); 55 | const result = stringifyTsNode(node); 56 | 57 | expect(result).toEqual(`export type DB = { 58 | bookMark: Bookmark; 59 | session: Session; 60 | userTable: User; 61 | };`); 62 | }); 63 | 64 | test("it respects exportWrappedTypes option", () => { 65 | const node = generateDatabaseType( 66 | [ 67 | { tableName: "book_mark", typeName: "Bookmark" }, 68 | { tableName: "session", typeName: "Session" }, 69 | { tableName: "user_table", typeName: "User" }, 70 | ], 71 | { 72 | databaseProvider: "postgresql", 73 | fileName: "", 74 | enumFileName: "", 75 | camelCase: false, 76 | readOnlyIds: false, 77 | groupBySchema: false, 78 | defaultSchema: "public", 79 | dbTypeName: "DB", 80 | importExtension: "", 81 | exportWrappedTypes: true, 82 | } 83 | ); 84 | const result = stringifyTsNode(node); 85 | 86 | expect(result).toEqual(`export type DB = { 87 | book_mark: BookmarkTable; 88 | session: SessionTable; 89 | user_table: UserTable; 90 | };`); 91 | }); 92 | 93 | test("it works for table names with spaces and weird symbols", () => { 94 | const node = generateDatabaseType( 95 | [ 96 | { tableName: "Bookmark", typeName: "Bookmark" }, 97 | { tableName: "user session_*table ;D", typeName: "Session" }, 98 | { tableName: "User", typeName: "User" }, 99 | ], 100 | { 101 | databaseProvider: "postgresql", 102 | fileName: "", 103 | enumFileName: "", 104 | camelCase: false, 105 | readOnlyIds: false, 106 | groupBySchema: false, 107 | defaultSchema: "public", 108 | dbTypeName: "DB", 109 | importExtension: "", 110 | exportWrappedTypes: false, 111 | } 112 | ); 113 | const result = stringifyTsNode(node); 114 | 115 | expect(result).toEqual(`export type DB = { 116 | Bookmark: Bookmark; 117 | User: User; 118 | "user session_*table ;D": Session; 119 | };`); 120 | }); 121 | 122 | test("ensure dbTypeName works", () => { 123 | const random = `T${Math.random().toString(36).substring(2, 15)}`; 124 | 125 | const node = generateDatabaseType( 126 | [ 127 | { tableName: "Bookmark", typeName: "Bookmark" }, 128 | { tableName: "user session_*table ;D", typeName: "Session" }, 129 | { tableName: "User", typeName: "User" }, 130 | ], 131 | { 132 | databaseProvider: "postgresql", 133 | fileName: "", 134 | enumFileName: "", 135 | camelCase: false, 136 | readOnlyIds: false, 137 | groupBySchema: false, 138 | defaultSchema: "public", 139 | dbTypeName: random, 140 | importExtension: "", 141 | exportWrappedTypes: false, 142 | } 143 | ); 144 | const result = stringifyTsNode(node); 145 | 146 | expect(result).toEqual(`export type ${random} = { 147 | Bookmark: Bookmark; 148 | User: User; 149 | "user session_*table ;D": Session; 150 | };`); 151 | }); 152 | -------------------------------------------------------------------------------- /src/helpers/multiSchemaHelpers.ts: -------------------------------------------------------------------------------- 1 | import { type BlockAttribute, getSchema } from "@mrleebo/prisma-ast"; 2 | import ts from "typescript"; 3 | 4 | import { capitalize } from "~/utils/words"; 5 | 6 | type ModelLike = { 7 | typeName: string; 8 | tableName?: string; 9 | schema?: string; 10 | }; 11 | 12 | export type ConvertToMultiSchemaModelsOptions = { 13 | models: T[]; 14 | groupBySchema: boolean; 15 | defaultSchema: string; 16 | filterBySchema: Set | null; 17 | multiSchemaMap?: Map; 18 | }; 19 | 20 | /** 21 | * Appends schema names to the table names of models. 22 | * 23 | * @param models list of model names 24 | * @param multiSchemaMap map of model names to schema names 25 | * @param groupBySchema whether to group models by schema 26 | * @param filterBySchema set of schema names to filter by. Use `null` to disable filtering. 27 | * @returns list of models with schema names appended to the table names ("schema.table") 28 | */ 29 | export const convertToMultiSchemaModels = ({ 30 | defaultSchema, 31 | filterBySchema, 32 | groupBySchema, 33 | models, 34 | multiSchemaMap, 35 | }: ConvertToMultiSchemaModelsOptions): T[] => { 36 | return models.flatMap((model) => { 37 | const schemaName = multiSchemaMap?.get(model.typeName); 38 | 39 | if (!schemaName) { 40 | return model; 41 | } 42 | 43 | // Filter out models that don't match the schema filter 44 | if (filterBySchema && !filterBySchema.has(schemaName)) { 45 | return []; 46 | } 47 | 48 | // If the schema is the default schema, we don't need to modify the model 49 | if (schemaName === defaultSchema) { 50 | return model; 51 | } 52 | 53 | return [ 54 | { 55 | ...model, 56 | typeName: groupBySchema 57 | ? `${capitalize(schemaName)}.${model.typeName}` 58 | : model.typeName, 59 | tableName: model.tableName 60 | ? `${schemaName}.${model.tableName}` 61 | : undefined, 62 | schema: groupBySchema ? schemaName : undefined, 63 | }, 64 | ]; 65 | }); 66 | }; 67 | 68 | // https://github.com/microsoft/TypeScript/blob/a53c37d59aa0c20f566dec7e5498f05afe45dc6b/src/compiler/scanner.ts#L985 69 | const isIdentifierText: ( 70 | name: string, 71 | languageVersion?: ts.ScriptTarget | undefined, 72 | identifierVariant?: ts.LanguageVariant 73 | ) => boolean = 74 | // @ts-expect-error - Internal TS API 75 | ts.isIdentifierText; 76 | 77 | /** 78 | * Parses a data model string and returns a map of model names to schema names. 79 | * 80 | * Prisma supports multi-schema databases, but currently doens't include the schema name in the DMMT output. 81 | * As a workaround, this function parses the schema separately and matches the schema to the model name. 82 | * 83 | * TODO: Remove this when @prisma/generator-helper exposes schema names in the models by default. 84 | * See thread: https://github.com/prisma/prisma/issues/19987 85 | * 86 | * @param dataModelStr the full datamodel string (schema.prisma contents) 87 | * @returns a map of model names to schema names 88 | */ 89 | export function parseMultiSchemaMap(dataModelStr: string) { 90 | const parsedSchema = getSchema(dataModelStr); 91 | const multiSchemaMap = new Map(); 92 | 93 | for (const block of parsedSchema.list) { 94 | if ( 95 | // Model 96 | block.type !== "model" && 97 | block.type !== "view" && 98 | block.type !== "type" && 99 | // Enum 100 | block.type !== "enum" 101 | ) { 102 | continue; 103 | } 104 | 105 | const properties = 106 | block.type === "enum" ? block.enumerators : block.properties; 107 | 108 | const schemaProperty = properties.find( 109 | (prop): prop is BlockAttribute => 110 | prop.type === "attribute" && prop.name === "schema" 111 | ); 112 | 113 | const schemaName = schemaProperty?.args?.[0].value; 114 | 115 | if (typeof schemaName !== "string") { 116 | multiSchemaMap.set(block.name, ""); 117 | } else { 118 | const schema: string = JSON.parse(schemaName).toString(); 119 | 120 | if (isIdentifierText && !isIdentifierText(schema)) { 121 | throw new Error( 122 | `Cannot generate identifier for schema "${schema}" in model "${block.name}" because it is not a valid Identifier, please disable \`groupBySchema\` or rename it.` 123 | ); 124 | } 125 | 126 | // don't capitalize it here because the DB key is case-sensitive 127 | multiSchemaMap.set(block.name, schema); 128 | } 129 | } 130 | 131 | return multiSchemaMap; 132 | } 133 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # prisma-kysely 2 | 3 | ## 2.2.1 4 | 5 | ### Patch Changes 6 | 7 | - 649f4b7: Bump Prisma to 6.16 8 | 9 | ## 2.2.0 10 | 11 | ### Minor Changes 12 | 13 | - f3d93bc: Introduce the `exportWrappedTypes` option for exporting types wrapped in Kysely's Selectable, Insertable and Updatable helpers 14 | 15 | ## 2.1.0 16 | 17 | ### Minor Changes 18 | 19 | - 37bcf6d: Support the `Json` type with SQLite 20 | 21 | ## 2.0.0 22 | 23 | ### Major Changes 24 | 25 | - 8fab339: Support for prisma 6.10.1 26 | 27 | ### Minor Changes 28 | 29 | - 8fab339: Add `groupBySchema` to group types and enums under a namespace 30 | - 8fab339: Move from Node 16 to Node 24 31 | - 8fab339: Add dbTypeName 32 | 33 | ### Patch Changes 34 | 35 | - 8fab339: Use node:sqlite inside tests 36 | - 8fab339: Handle enum primary keys on many-to-many relationships 37 | 38 | ## 1.8.0 39 | 40 | ### Minor Changes 41 | 42 | - 4526321: Added support for the Kysely SQL Server dialect Awesome work from @dylel 🎊 43 | 44 | ### Patch Changes 45 | 46 | - 04de4dc: Fixed automatic relation code generation bug. Thanks @Bayezid1989 🥳 47 | 48 | ## 1.7.1 49 | 50 | ### Patch Changes 51 | 52 | - 22a1e5c: Fixes array types (Thanks Karrui! 🥳🇸🇬) 53 | - 21980b2: Updates dependencies that were throwing deprectaion warnings. (Thank you @delight! 🍺) 54 | 55 | ## 1.7.0 56 | 57 | ### Minor Changes 58 | 59 | - bf0ccf6: Implicit many to many relations are finally fixed thanks to @dextertanyj 🇸🇬🎉🥂. Huge thanks to him! 60 | 61 | ## 1.6.0 62 | 63 | ### Minor Changes 64 | 65 | - defb3fa: Update typescript to 5 and migrate from ttypescript to ts-patch (Thank you @alandotcom! 🎉) 66 | 67 | ## 1.5.0 68 | 69 | ### Minor Changes 70 | 71 | - 3ec4465: Support `multiSchema` preview feature. (Thanks to @duniul 🇸🇪🪅) 72 | 73 | ### Patch Changes 74 | 75 | - 7923981: Adds per field type overrides 76 | - 6a50fe8: Respect mapped names for fields with enum types. (Thank you @fehnomenal 🇩🇪🎉) 77 | - 3ec4465: Sort DB properties by table name instead of type name. (Thank you @duniul 🇸🇪🪅) 78 | 79 | ## 1.4.2 80 | 81 | ### Patch Changes 82 | 83 | - 744b666: Uses the value of fileName when no enumFileName provided. Previously now if you used a different fileName and you didn't provide enumFileName it put the database in the fileName and enums in types.ts. 84 | 85 | Now imports GeneratedAlways only when needed. Previously it was always added, even if not needed which caused problems with the linter. 86 | 87 | ## 1.4.1 88 | 89 | ### Patch Changes 90 | 91 | - 36393b6: Bugfix: revert to own generated type, that supports ColumnType 92 | 93 | ## 1.4.0 94 | 95 | ### Minor Changes 96 | 97 | - 3288b72: Support @map statement for enum values (Thank you @jvandenaardweg 🔥🇳🇱) 98 | - 299de40: Adds support for Kysely's `GeneratedAlways` through a new config parameter `readOnlyIds`. The generated type file no longer includes and exports the `Generated` generic. 99 | - 66019e8: Brings back support for implicit many to many models after DMMF changes introduced in new version of Prisma 100 | 101 | ### Patch Changes 102 | 103 | - 2659cc3: Now using narrower types for enum objects bringing `prisma-kysely`'s enums in line with `prisma-client-js` (Thank you @jvandenaardweg 🎉) 104 | 105 | ## 1.3.0 106 | 107 | ### Minor Changes 108 | 109 | - a96f2d7: Add option to output runtime enums to a separate file (Thank you @juliusmarminge! 🇸🇪🎉) 110 | 111 | ## 1.2.1 112 | 113 | ### Patch Changes 114 | 115 | - ff5bc59: Add object declarations for enums, that can be used (among other things) for runtime validation. Thanks @jvandenaardweg for the idea! 😎👍 116 | 117 | ## 1.2.0 118 | 119 | ### Minor Changes 120 | 121 | - 533e41e: Pass Prisma comments on Prisma fields to the generated TypeScript types 122 | - 8892135: Add support for field level @map and update core dependencies 123 | 124 | ## 1.1.0 125 | 126 | ### Minor Changes 127 | 128 | - 7ab12d5: The first minor version bump 😮. Turns out some of the type maps were wrong. This update corrects `BigInt` and `Decimal` types for all dialects, and corrects the `DateTime` type for SQLite. 129 | 130 | ## 1.0.11 131 | 132 | ### Patch Changes 133 | 134 | - 1cb96de: Update README 135 | 136 | ## 1.0.10 137 | 138 | ### Patch Changes 139 | 140 | - Add support for CockroachDB (thanks to @yevhenii-horbenko 🥳) 141 | 142 | ## 1.0.9 143 | 144 | ### Patch Changes 145 | 146 | - 3bb5d89: Add support for Kysely's camel case plugin 147 | 148 | ## 1.0.8 149 | 150 | ### Patch Changes 151 | 152 | - e7ecabe: Adds Typescript as a primary dependency in order to fix issue #8 153 | 154 | ## 1.0.7 155 | 156 | ### Patch Changes 157 | 158 | - 0a50f6f: Add support for @@map("...") statements (mapping models to different table names) and table names with spaces 159 | -------------------------------------------------------------------------------- /src/helpers/generateFiles.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import type { TypeAliasDeclaration } from "typescript"; 3 | import ts from "typescript"; 4 | 5 | import { generateFile } from "~/helpers/generateFile"; 6 | import { capitalize } from "~/utils/words"; 7 | 8 | import type { EnumType } from "./generateEnumType"; 9 | import type { ModelType } from "./generateModel"; 10 | import { convertToWrappedTypes } from "./wrappedTypeHelpers"; 11 | 12 | type File = { filepath: string; content: ReturnType }; 13 | type MultiDefsModelType = Omit & { 14 | definitions: ts.TypeAliasDeclaration[]; 15 | }; 16 | 17 | export function generateFiles(opts: { 18 | typesOutfile: string; 19 | enums: EnumType[]; 20 | models: ModelType[]; 21 | enumNames: string[]; 22 | enumsOutfile: string; 23 | databaseType: TypeAliasDeclaration; 24 | groupBySchema: boolean; 25 | defaultSchema: string; 26 | importExtension: string; 27 | exportWrappedTypes: boolean; 28 | }) { 29 | const models = opts.models.map( 30 | ({ definition, ...rest }: ModelType): MultiDefsModelType => ({ 31 | ...rest, 32 | definitions: opts.exportWrappedTypes 33 | ? convertToWrappedTypes(definition) 34 | : [definition], 35 | }) 36 | ); 37 | 38 | // Don't generate a separate file for enums if there are no enums 39 | if (opts.enumsOutfile === opts.typesOutfile || opts.enums.length === 0) { 40 | let statements: Iterable; 41 | 42 | if (!opts.groupBySchema) { 43 | statements = [ 44 | ...opts.enums.flatMap((e) => [e.objectDeclaration, e.typeDeclaration]), 45 | ...models.flatMap((m) => m.definitions), 46 | ]; 47 | } else { 48 | statements = groupModelsAndEnum(opts.enums, models, opts.defaultSchema); 49 | } 50 | 51 | const typesFileWithEnums: File = { 52 | filepath: opts.typesOutfile, 53 | content: generateFile([...statements, opts.databaseType], { 54 | withEnumImport: false, 55 | withLeader: true, 56 | exportWrappedTypes: opts.exportWrappedTypes, 57 | }), 58 | }; 59 | 60 | return [typesFileWithEnums]; 61 | } 62 | 63 | const typesFileWithoutEnums: File = { 64 | filepath: opts.typesOutfile, 65 | content: generateFile( 66 | [...models.flatMap((m) => m.definitions), opts.databaseType], 67 | { 68 | withEnumImport: { 69 | importPath: `./${path.parse(opts.enumsOutfile).name}${opts.importExtension}`, 70 | names: opts.enumNames, 71 | }, 72 | withLeader: true, 73 | exportWrappedTypes: opts.exportWrappedTypes, 74 | } 75 | ), 76 | }; 77 | 78 | if (opts.enums.length === 0) return [typesFileWithoutEnums]; 79 | 80 | const enumFile: File = { 81 | filepath: opts.enumsOutfile, 82 | content: generateFile( 83 | opts.enums.flatMap((e) => [e.objectDeclaration, e.typeDeclaration]), 84 | { 85 | withEnumImport: false, 86 | withLeader: false, 87 | exportWrappedTypes: opts.exportWrappedTypes, 88 | } 89 | ), 90 | }; 91 | 92 | return [typesFileWithoutEnums, enumFile]; 93 | } 94 | 95 | export function* groupModelsAndEnum( 96 | enums: EnumType[], 97 | models: MultiDefsModelType[], 98 | defaultSchema: string 99 | ): Generator { 100 | const groupsMap = new Map(); 101 | 102 | for (const enumType of enums) { 103 | if (!enumType.schema || enumType.schema === defaultSchema) { 104 | yield enumType.objectDeclaration; 105 | yield enumType.typeDeclaration; 106 | continue; 107 | } 108 | 109 | const group = groupsMap.get(enumType.schema); 110 | 111 | if (!group) { 112 | groupsMap.set(enumType.schema, [ 113 | enumType.objectDeclaration, 114 | enumType.typeDeclaration, 115 | ]); 116 | } else { 117 | group.push(enumType.objectDeclaration, enumType.typeDeclaration); 118 | } 119 | } 120 | 121 | for (const model of models) { 122 | if (!model.schema || model.schema === defaultSchema) { 123 | yield* model.definitions; 124 | continue; 125 | } 126 | 127 | const group = groupsMap.get(model.schema); 128 | 129 | if (!group) { 130 | groupsMap.set(model.schema, model.definitions); 131 | } else { 132 | group.push(...model.definitions); 133 | } 134 | } 135 | 136 | for (const [schema, group] of groupsMap) { 137 | yield ts.factory.createModuleDeclaration( 138 | [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], 139 | ts.factory.createIdentifier(capitalize(schema)), 140 | ts.factory.createModuleBlock(group), 141 | ts.NodeFlags.Namespace 142 | ); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/helpers/generateModel.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | 3 | import { generateModel } from "~/helpers/generateModel"; 4 | import { stringifyTsNode } from "~/utils/testUtils"; 5 | 6 | test("it generates a model!", () => { 7 | const model = generateModel( 8 | { 9 | name: "User", 10 | fields: [ 11 | { 12 | name: "id", 13 | isId: true, 14 | isGenerated: true, 15 | default: { name: "uuid", args: [] }, 16 | kind: "scalar", 17 | type: "String", 18 | hasDefaultValue: true, 19 | isList: false, 20 | isReadOnly: false, 21 | isRequired: true, 22 | isUnique: false, 23 | }, 24 | { 25 | name: "id2", 26 | isId: false, 27 | isGenerated: false, 28 | default: { name: "dbgenerated", args: ["uuid()"] }, 29 | kind: "scalar", 30 | type: "String", 31 | hasDefaultValue: true, 32 | isList: false, 33 | isReadOnly: false, 34 | isRequired: true, 35 | isUnique: false, 36 | }, 37 | { 38 | name: "name", 39 | isId: false, 40 | isGenerated: false, 41 | kind: "scalar", 42 | type: "String", 43 | hasDefaultValue: false, 44 | isList: false, 45 | isReadOnly: false, 46 | isRequired: false, 47 | isUnique: false, 48 | }, 49 | { 50 | name: "unsupportedField", 51 | isId: false, 52 | isGenerated: false, 53 | kind: "unsupported", 54 | type: "String", 55 | hasDefaultValue: false, 56 | isList: false, 57 | isReadOnly: false, 58 | isRequired: false, 59 | isUnique: false, 60 | }, 61 | { 62 | name: "objectField", 63 | isId: false, 64 | isGenerated: false, 65 | kind: "object", 66 | type: "SomeOtherObject", 67 | hasDefaultValue: false, 68 | isList: false, 69 | isReadOnly: false, 70 | isRequired: false, 71 | isUnique: false, 72 | }, 73 | { 74 | name: "enumField", 75 | isId: false, 76 | isGenerated: false, 77 | kind: "enum", 78 | type: "CoolEnum", 79 | hasDefaultValue: false, 80 | isList: false, 81 | isReadOnly: false, 82 | isRequired: true, 83 | isUnique: false, 84 | }, 85 | ], 86 | schema: null, 87 | primaryKey: null, 88 | dbName: null, 89 | uniqueFields: [], 90 | uniqueIndexes: [], 91 | }, 92 | { 93 | databaseProvider: "sqlite", 94 | fileName: "", 95 | enumFileName: "", 96 | camelCase: false, 97 | readOnlyIds: false, 98 | groupBySchema: false, 99 | defaultSchema: "public", 100 | dbTypeName: "DB", 101 | importExtension: "", 102 | exportWrappedTypes: false, 103 | }, 104 | { 105 | groupBySchema: false, 106 | defaultSchema: "public", 107 | } 108 | ); 109 | 110 | expect(model.tableName).toEqual("User"); 111 | expect(model.typeName).toEqual("User"); 112 | 113 | const source = stringifyTsNode(model.definition); 114 | 115 | expect(source).toEqual(`export type User = { 116 | id: string; 117 | id2: Generated; 118 | name: string | null; 119 | enumField: CoolEnum; 120 | };`); 121 | }); 122 | 123 | test("it respects camelCase option", () => { 124 | const model = generateModel( 125 | { 126 | name: "User", 127 | fields: [ 128 | { 129 | name: "id", 130 | isId: true, 131 | isGenerated: true, 132 | default: { name: "uuid", args: [] }, 133 | kind: "scalar", 134 | type: "String", 135 | hasDefaultValue: true, 136 | isList: false, 137 | isReadOnly: false, 138 | isRequired: true, 139 | isUnique: false, 140 | }, 141 | { 142 | name: "user_name", 143 | isId: false, 144 | isGenerated: false, 145 | kind: "scalar", 146 | type: "String", 147 | hasDefaultValue: false, 148 | isList: false, 149 | isReadOnly: false, 150 | isRequired: false, 151 | isUnique: false, 152 | }, 153 | ], 154 | schema: null, 155 | primaryKey: null, 156 | dbName: null, 157 | uniqueFields: [], 158 | uniqueIndexes: [], 159 | }, 160 | { 161 | databaseProvider: "sqlite", 162 | fileName: "", 163 | enumFileName: "", 164 | camelCase: true, 165 | readOnlyIds: false, 166 | groupBySchema: false, 167 | defaultSchema: "public", 168 | dbTypeName: "DB", 169 | importExtension: "", 170 | exportWrappedTypes: false, 171 | }, 172 | { 173 | groupBySchema: false, 174 | defaultSchema: "public", 175 | } 176 | ); 177 | 178 | expect(model.tableName).toEqual("User"); 179 | expect(model.typeName).toEqual("User"); 180 | 181 | const source = stringifyTsNode(model.definition); 182 | 183 | expect(source).toEqual(`export type User = { 184 | id: string; 185 | userName: string | null; 186 | };`); 187 | }); 188 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Prisma Kysely](assets/logo-hero.png) 2 | 3 |

4 | 5 | 6 |

7 | 8 |
9 | 10 | ![Hero image "Generate Kysely types directly from your Prisma 11 | schema"](assets/hero.png) 12 | 13 |
14 | 15 | Do you like Prisma's migration flow, schema language and DX but not the 16 | limitations of the Prisma Client? Do you want to harness the raw power of SQL 17 | without losing the safety of the TypeScript type system? 18 | 19 | **Enter `prisma-kysely`**! 20 | 21 | ### Setup 22 | 23 | 1. Install `prisma-kysely` using your package manager of choice: 24 | 25 | ```sh 26 | yarn add prisma-kysely 27 | ``` 28 | 29 | 2. Replace (or augment) the default client generator in your `schema.prisma` 30 | file with the following: 31 | 32 | ```prisma 33 | generator kysely { 34 | provider = "prisma-kysely" 35 | 36 | // Optionally provide a destination directory for the generated file 37 | // and a filename of your choice 38 | output = "../src/db" 39 | fileName = "types.ts" 40 | // Optionally generate runtime enums to a separate file 41 | enumFileName = "enums.ts" 42 | } 43 | ``` 44 | 45 | 3. Run `prisma migrate dev` or `prisma generate` and use your freshly generated 46 | types when instantiating Kysely! 47 | 48 | ### Motivation 49 | 50 | Prisma's migration and schema definition workflow is undeniably great, and the 51 | typesafety of the Prisma client is top notch, but there comes a time in every 52 | Prisma user's life where the client becomes just a bit too limiting. Sometimes 53 | we just need to write our own multi table joins and squeeze that extra drop of 54 | performance out of our apps. The Prisma client offers two options: using their 55 | simplified query API or going all-in with raw SQL strings, sacrificing type 56 | safety. 57 | 58 | This is where Kysely shines. Kysely provides a toolbox to write expressive, 59 | type-safe SQL queries with full autocompletion. The problem with Kysely though 60 | is that it's not super opinionated when it comes to schema definition and 61 | migration. What many users resort to is using something like Prisma to define 62 | the structure of their databases, and `kysely-codegen` to introspect their 63 | databases post-migration. 64 | 65 | This package, `prisma-kysely`, is meant as a more integrated and convenient way 66 | to keep Kysely types in sync with Prisma schemas. After making the prerequisite 67 | changes to your schema file, it's just as convenient and foolproof as using 68 | Prisma's own client. 69 | 70 | I've been using this combo for a few months now in tandem with Cloudflare's D1 71 | for my private projects and Postgres at work. It's been a game-changer, and I 72 | hope it's just as useful for you! 😎 73 | 74 | ### Config 75 | 76 | | Key | Description | Default | 77 | | :----------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | 78 | | `output` | The directory where generated code will be saved | | 79 | | `fileName` | The filename for the generated file | `types.ts` | 80 | | `importExtension` | The extension to append to imports. E.g: `".js"` or `".ts"`. Use `""` to append nothing. | `""` | 81 | | `enumFileName` | The filename for the generated enums. Omitting this will generate enums and files in the same file. | | 82 | | `camelCase` | Enable support for Kysely's camelCase plugin | `false` | 83 | | `exportWrappedTypes` | Kysely wrapped types such as `Selectable` are also exported as described in the [Kysely documentation](https://kysely.dev/docs/getting-started#types). The exported types follow the naming conventions of the document. | `false` | 84 | | `readOnlyIds` | Use Kysely's `GeneratedAlways` for `@id` fields with default values, preventing insert and update. | `false` | 85 | | `[typename]TypeOverride` | Allows you to override the resulting TypeScript type for any Prisma type. Useful when targeting a different environment than Node (e.g. WinterCG compatible runtimes that use UInt8Arrays instead of Buffers for binary types etc.) Check out the [config validator](https://github.com/valtyr/prisma-kysely/blob/main/src/utils/validateConfig.ts) for a complete list of options. | | 86 | | `dbTypeName` | Allows you to override the exported type with all tables | `DB` | 87 | | `groupBySchema` | When using multiple schemas, group all models and enums for a schema into their own namespace. (Ex: `model Dog { @@schema("animals") }` will be available under `Animals.Dog`) | `false` | 88 | | `filterBySchema` | When using multiple schemas, only include models and enums for the specified schema. | `false` | 89 | | `defaultSchema` | When using multiple schemas, declare `which schema should not be wrapped by a namespace. | `'public'` | 90 | 91 | ### Per-field type overrides 92 | 93 | In some cases, you might want to override a type for a specific field. This 94 | could be useful, for example, for constraining string types to certain literal 95 | values. Be aware though that this does not of course come with any runtime 96 | validation, and in most cases won't be guaranteed to match the actual data in 97 | the database. 98 | 99 | That disclaimer aside, here's how it works: Add a `@kyselyType(...)` declaration 100 | to the Prisma docstring (deliniated using three slashes `///`) for the field 101 | with your type inside the parentheses. 102 | 103 | ```prisma 104 | model User { 105 | id String @id 106 | name String 107 | 108 | /// @kyselyType('member' | 'admin') 109 | role String 110 | } 111 | ``` 112 | 113 | The parentheses can include any valid TS type declaration. 114 | 115 | The output for the example above would be as follows: 116 | 117 | ```ts 118 | export type User = { 119 | id: string; 120 | name: string; 121 | role: "member" | "owner"; 122 | }; 123 | ``` 124 | 125 | ### Gotchas 126 | 127 | #### Default values 128 | 129 | By default (no pun intended) the Prisma Query Engine uses JS based 130 | implementations for certain default values, namely: `uuid()` and `cuid()`. This 131 | means that they don't end up getting defined as default values on the database 132 | level, and end up being pretty useless for us. 133 | 134 | Prisma does provide a nice solution to this though, in the form of 135 | `dbgenerated()`. This allows us to use any valid default value expression that 136 | our database supports: 137 | 138 | ```prisma 139 | model PostgresUser { 140 | id String @id @default(dbgenerated("gen_random_uuid()")) 141 | } 142 | 143 | model SQLiteUser { 144 | id String @id @default(dbgenerated("(uuid())")) 145 | } 146 | ``` 147 | 148 | [Check out the Prisma Docs for more 149 | info.](https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#attribute-functions) 150 | 151 | ### Contributions 152 | 153 | OMG you actually want to contribute? I'm so thankful! 🙇‍♂️ 154 | 155 | Here's everything you need to do (let me know if something's missing...) 156 | 157 | 1. Fork and pull the repository 158 | 2. Run `yarn install` and `yarn dev` to start `tsc` in watch mode. 159 | 3. Make changes to the source code 160 | 4. Test your changes by creating `prisma/schema.prisma`, running `yarn prisma generate` and checking the output in `prisma/types.ts`. The provider must be set 161 | as follows to reference the dev build: 162 | ```prisma 163 | generator kysely { 164 | provider = "node ./dist/bin.js" 165 | } 166 | ``` 167 | 5. Create a pull request! If your changes make sense, I'll try my best to review 168 | and merge them quickly. 169 | 170 | I'm not 100% sure the [type 171 | maps](https://github.com/valtyr/prisma-kysely/blob/main/src/helpers/generateFieldType.ts) 172 | are correct for every dialect, so any and all contributions on that front would 173 | be greatly appreciated. The same goes for any bug you come across or improvement 174 | you can think of. 175 | 176 | ### Shoutouts 177 | 178 | - I wouldn't have made this library if I hadn't used Robin Blomberg's amazing 179 | [Kysely Codegen](https://github.com/RobinBlomberg/kysely-codegen). For anyone 180 | that isn't using Prisma for migrations I wholeheartedly recommend his package. 181 | - The implicit many-to-many table generation code is partly inspired by and 182 | partly stolen from 183 | [`prisma-dbml-generator`](https://github.com/notiz-dev/prisma-dbml-generator/blob/752f89cf40257a9698913294b38843ac742f8345/src/generator/many-to-many-tables.ts). 184 | Many-too-many thanks to them! 185 | - Igal Klebanov ([@igalklebanov](https://github.com/igalklebanov)) and Jökull Sólberg ([@jokull](https://github.com/jokull)) for being this library's 186 | main proponents on Twitter! 187 | - The authors and maintainers of Kysely ❤️‍🔥 188 | 189 | ```diff 190 | + Boyce-Codd gang unite! 💽 191 | ``` 192 | -------------------------------------------------------------------------------- /src/helpers/generateImplicitManyToManyModels.test.ts: -------------------------------------------------------------------------------- 1 | import type { DMMF } from "@prisma/generator-helper"; 2 | import { expect, test } from "vitest"; 3 | 4 | import { generateImplicitManyToManyModels } from "~/helpers/generateImplicitManyToManyModels"; 5 | 6 | test("it respects overrides when generating field types", () => { 7 | const newModels = generateImplicitManyToManyModels([ 8 | { 9 | name: "Category", 10 | fields: [ 11 | { 12 | name: "id", 13 | type: "String", 14 | isId: true, 15 | hasDefaultValue: true, 16 | isList: false, 17 | isReadOnly: false, 18 | isRequired: true, 19 | isUnique: true, 20 | kind: "scalar", 21 | }, 22 | { 23 | name: "posts", 24 | kind: "object", 25 | isList: true, 26 | isRequired: true, 27 | isUnique: false, 28 | isId: false, 29 | isReadOnly: false, 30 | type: "Post", 31 | hasDefaultValue: false, 32 | relationName: "CategoryToPost", 33 | relationFromFields: [], 34 | relationToFields: [], 35 | isGenerated: false, 36 | isUpdatedAt: false, 37 | }, 38 | ], 39 | schema: null, 40 | primaryKey: null, 41 | dbName: null, 42 | uniqueFields: [], 43 | uniqueIndexes: [], 44 | }, 45 | { 46 | name: "Post", 47 | fields: [ 48 | { 49 | name: "id", 50 | type: "String", 51 | isId: true, 52 | hasDefaultValue: true, 53 | isList: false, 54 | isReadOnly: false, 55 | isRequired: true, 56 | isUnique: true, 57 | kind: "scalar", 58 | }, 59 | { 60 | name: "categories", 61 | kind: "object", 62 | isList: true, 63 | isRequired: true, 64 | isUnique: false, 65 | isId: false, 66 | isReadOnly: false, 67 | type: "Category", 68 | hasDefaultValue: false, 69 | relationName: "CategoryToPost", 70 | relationFromFields: [], 71 | relationToFields: [], 72 | isGenerated: false, 73 | isUpdatedAt: false, 74 | }, 75 | ], 76 | schema: null, 77 | primaryKey: null, 78 | dbName: null, 79 | uniqueFields: [], 80 | uniqueIndexes: [], 81 | }, 82 | ]); 83 | 84 | expect(newModels).toEqual([ 85 | { 86 | name: "CategoryToPost", 87 | dbName: "_CategoryToPost", 88 | fields: [ 89 | { 90 | hasDefaultValue: false, 91 | isId: false, 92 | isList: false, 93 | isReadOnly: true, 94 | isRequired: true, 95 | isUnique: false, 96 | kind: "scalar", 97 | name: "A", 98 | type: "String", 99 | }, 100 | { 101 | hasDefaultValue: false, 102 | isId: false, 103 | isList: false, 104 | isReadOnly: true, 105 | isRequired: true, 106 | isUnique: false, 107 | kind: "scalar", 108 | name: "B", 109 | type: "String", 110 | }, 111 | ], 112 | schema: null, 113 | primaryKey: null, 114 | uniqueFields: [], 115 | uniqueIndexes: [], 116 | }, 117 | ]); 118 | }); 119 | 120 | test("it filters out many-to-one relations safely", () => { 121 | const newModels = generateImplicitManyToManyModels([ 122 | { 123 | name: "Category", 124 | fields: [ 125 | { 126 | name: "id", 127 | type: "String", 128 | isId: true, 129 | hasDefaultValue: true, 130 | isList: false, 131 | isReadOnly: false, 132 | isRequired: true, 133 | isUnique: true, 134 | kind: "scalar", 135 | }, 136 | { 137 | name: "posts", 138 | kind: "object", 139 | isList: true, 140 | isRequired: true, 141 | isUnique: false, 142 | isId: false, 143 | isReadOnly: false, 144 | type: "Post", 145 | hasDefaultValue: false, 146 | relationName: "CategoryToPost", 147 | relationFromFields: [], 148 | relationToFields: [], 149 | isGenerated: false, 150 | isUpdatedAt: false, 151 | }, 152 | ], 153 | schema: null, 154 | primaryKey: null, 155 | dbName: null, 156 | uniqueFields: [], 157 | uniqueIndexes: [], 158 | }, 159 | { 160 | name: "Post", 161 | fields: [ 162 | { 163 | name: "id", 164 | type: "String", 165 | isId: true, 166 | hasDefaultValue: true, 167 | isList: false, 168 | isReadOnly: false, 169 | isRequired: true, 170 | isUnique: true, 171 | kind: "scalar", 172 | }, 173 | { 174 | name: "categories", 175 | kind: "object", 176 | isList: true, 177 | isRequired: true, 178 | isUnique: false, 179 | isId: false, 180 | isReadOnly: false, 181 | type: "Category", 182 | hasDefaultValue: false, 183 | relationName: "CategoryToPost", 184 | relationFromFields: [], 185 | relationToFields: [], 186 | isGenerated: false, 187 | isUpdatedAt: false, 188 | }, 189 | { 190 | name: "userId", 191 | kind: "scalar", 192 | isList: false, 193 | isRequired: true, 194 | isUnique: false, 195 | isId: false, 196 | isReadOnly: true, 197 | hasDefaultValue: false, 198 | type: "String", 199 | isGenerated: false, 200 | isUpdatedAt: false, 201 | }, 202 | { 203 | name: "user", 204 | kind: "object", 205 | isList: false, 206 | isRequired: true, 207 | isUnique: false, 208 | isId: false, 209 | isReadOnly: false, 210 | hasDefaultValue: false, 211 | type: "User", 212 | relationName: "PostToUser", 213 | relationFromFields: ["userId"], 214 | relationToFields: ["id"], 215 | relationOnDelete: "Cascade", 216 | isGenerated: false, 217 | isUpdatedAt: false, 218 | }, 219 | ], 220 | schema: null, 221 | primaryKey: null, 222 | dbName: null, 223 | uniqueFields: [], 224 | uniqueIndexes: [], 225 | }, 226 | { 227 | name: "User", 228 | fields: [ 229 | { 230 | name: "id", 231 | type: "String", 232 | isId: true, 233 | hasDefaultValue: true, 234 | isList: false, 235 | isReadOnly: false, 236 | isRequired: true, 237 | isUnique: true, 238 | kind: "scalar", 239 | }, 240 | { 241 | name: "posts", 242 | kind: "object", 243 | isList: true, 244 | isRequired: true, 245 | isUnique: false, 246 | isId: false, 247 | isReadOnly: false, 248 | type: "Post", 249 | hasDefaultValue: false, 250 | relationName: "PostToUser", 251 | relationFromFields: [], 252 | relationToFields: [], 253 | isGenerated: false, 254 | isUpdatedAt: false, 255 | }, 256 | ], 257 | schema: null, 258 | primaryKey: null, 259 | dbName: null, 260 | uniqueFields: [], 261 | uniqueIndexes: [], 262 | }, 263 | ]); 264 | 265 | expect(newModels).toEqual([ 266 | { 267 | name: "CategoryToPost", 268 | dbName: "_CategoryToPost", 269 | fields: [ 270 | { 271 | hasDefaultValue: false, 272 | isId: false, 273 | isList: false, 274 | isReadOnly: true, 275 | isRequired: true, 276 | isUnique: false, 277 | kind: "scalar", 278 | name: "A", 279 | type: "String", 280 | }, 281 | { 282 | hasDefaultValue: false, 283 | isId: false, 284 | isList: false, 285 | isReadOnly: true, 286 | isRequired: true, 287 | isUnique: false, 288 | kind: "scalar", 289 | name: "B", 290 | type: "String", 291 | }, 292 | ], 293 | schema: null, 294 | primaryKey: null, 295 | uniqueFields: [], 296 | uniqueIndexes: [], 297 | }, 298 | ]); 299 | }); 300 | 301 | test("it generates correct field types when field types are defferent", () => { 302 | const newModels = generateImplicitManyToManyModels([ 303 | { 304 | name: "Category", 305 | fields: [ 306 | { 307 | name: "id", 308 | type: "String", 309 | isId: true, 310 | hasDefaultValue: true, 311 | isList: false, 312 | isReadOnly: false, 313 | isRequired: true, 314 | isUnique: true, 315 | kind: "scalar", 316 | }, 317 | { 318 | name: "posts", 319 | kind: "object", 320 | isList: true, 321 | isRequired: true, 322 | isUnique: false, 323 | isId: false, 324 | isReadOnly: false, 325 | type: "Post", 326 | hasDefaultValue: false, 327 | relationName: "CategoryToPost", 328 | relationFromFields: [], 329 | relationToFields: [], 330 | isGenerated: false, 331 | isUpdatedAt: false, 332 | }, 333 | ], 334 | schema: null, 335 | primaryKey: null, 336 | dbName: null, 337 | uniqueFields: [], 338 | uniqueIndexes: [], 339 | }, 340 | { 341 | name: "Post", 342 | fields: [ 343 | { 344 | name: "id", 345 | type: "Int", 346 | isId: true, 347 | hasDefaultValue: true, 348 | isList: false, 349 | isReadOnly: false, 350 | isRequired: true, 351 | isUnique: true, 352 | kind: "scalar", 353 | }, 354 | { 355 | name: "categories", 356 | kind: "object", 357 | isList: true, 358 | isRequired: true, 359 | isUnique: false, 360 | isId: false, 361 | isReadOnly: false, 362 | type: "Category", 363 | hasDefaultValue: false, 364 | relationName: "CategoryToPost", 365 | relationFromFields: [], 366 | relationToFields: [], 367 | isGenerated: false, 368 | isUpdatedAt: false, 369 | }, 370 | ], 371 | schema: null, 372 | primaryKey: null, 373 | dbName: null, 374 | uniqueFields: [], 375 | uniqueIndexes: [], 376 | }, 377 | ]); 378 | 379 | expect(newModels).toEqual([ 380 | { 381 | name: "CategoryToPost", 382 | dbName: "_CategoryToPost", 383 | fields: [ 384 | { 385 | hasDefaultValue: false, 386 | isId: false, 387 | isList: false, 388 | isReadOnly: true, 389 | isRequired: true, 390 | isUnique: false, 391 | kind: "scalar", 392 | name: "A", 393 | type: "String", 394 | }, 395 | { 396 | hasDefaultValue: false, 397 | isId: false, 398 | isList: false, 399 | isReadOnly: true, 400 | isRequired: true, 401 | isUnique: false, 402 | kind: "scalar", 403 | name: "B", 404 | type: "Int", 405 | }, 406 | ], 407 | schema: null, 408 | primaryKey: null, 409 | uniqueFields: [], 410 | uniqueIndexes: [], 411 | }, 412 | ]); 413 | }); 414 | 415 | test("it generates correct field types when a field name is defferent from model name", () => { 416 | const newModels = generateImplicitManyToManyModels([ 417 | { 418 | name: "Category", 419 | fields: [ 420 | { 421 | name: "id", 422 | type: "String", 423 | isId: true, 424 | hasDefaultValue: true, 425 | isList: false, 426 | isReadOnly: false, 427 | isRequired: true, 428 | isUnique: true, 429 | kind: "scalar", 430 | }, 431 | { 432 | name: "articles", 433 | kind: "object", 434 | isList: true, 435 | isRequired: true, 436 | isUnique: false, 437 | isId: false, 438 | isReadOnly: false, 439 | type: "Post", 440 | hasDefaultValue: false, 441 | relationName: "CategoryToPost", 442 | relationFromFields: [], 443 | relationToFields: [], 444 | isGenerated: false, 445 | isUpdatedAt: false, 446 | }, 447 | ], 448 | schema: null, 449 | primaryKey: null, 450 | dbName: null, 451 | uniqueFields: [], 452 | uniqueIndexes: [], 453 | }, 454 | { 455 | name: "Post", 456 | fields: [ 457 | { 458 | name: "id", 459 | type: "Int", 460 | isId: true, 461 | hasDefaultValue: true, 462 | isList: false, 463 | isReadOnly: false, 464 | isRequired: true, 465 | isUnique: true, 466 | kind: "scalar", 467 | }, 468 | { 469 | name: "categories", 470 | kind: "object", 471 | isList: true, 472 | isRequired: true, 473 | isUnique: false, 474 | isId: false, 475 | isReadOnly: false, 476 | type: "Category", 477 | hasDefaultValue: false, 478 | relationName: "CategoryToPost", 479 | relationFromFields: [], 480 | relationToFields: [], 481 | isGenerated: false, 482 | isUpdatedAt: false, 483 | }, 484 | ], 485 | schema: null, 486 | primaryKey: null, 487 | dbName: null, 488 | uniqueFields: [], 489 | uniqueIndexes: [], 490 | }, 491 | ]); 492 | 493 | expect(newModels).toEqual([ 494 | { 495 | name: "CategoryToPost", 496 | dbName: "_CategoryToPost", 497 | fields: [ 498 | { 499 | hasDefaultValue: false, 500 | isId: false, 501 | isList: false, 502 | isReadOnly: true, 503 | isRequired: true, 504 | isUnique: false, 505 | kind: "scalar", 506 | name: "A", 507 | type: "String", 508 | }, 509 | { 510 | hasDefaultValue: false, 511 | isId: false, 512 | isList: false, 513 | isReadOnly: true, 514 | isRequired: true, 515 | isUnique: false, 516 | kind: "scalar", 517 | name: "B", 518 | type: "Int", 519 | }, 520 | ], 521 | schema: null, 522 | primaryKey: null, 523 | uniqueFields: [], 524 | uniqueIndexes: [], 525 | }, 526 | ]); 527 | }); 528 | 529 | test("it generates correct field kinds when an enum is used", () => { 530 | const newModels = generateImplicitManyToManyModels([ 531 | { 532 | name: "UserRole", 533 | schema: null, 534 | fields: [ 535 | { 536 | name: "roleId", 537 | kind: "enum", 538 | isList: false, 539 | isRequired: true, 540 | isUnique: false, 541 | isId: true, 542 | isReadOnly: false, 543 | hasDefaultValue: false, 544 | type: "UserRoleType", 545 | isGenerated: false, 546 | isUpdatedAt: false, 547 | }, 548 | { 549 | name: "permissions", 550 | kind: "object", 551 | isList: true, 552 | isRequired: true, 553 | isUnique: false, 554 | isId: false, 555 | isReadOnly: false, 556 | hasDefaultValue: false, 557 | type: "Permission", 558 | relationName: "PermissionToUserRole", 559 | relationFromFields: [], 560 | relationToFields: [], 561 | isGenerated: false, 562 | isUpdatedAt: false, 563 | }, 564 | ], 565 | primaryKey: null, 566 | dbName: null, 567 | uniqueFields: [], 568 | uniqueIndexes: [], 569 | }, 570 | { 571 | name: "Permission", 572 | schema: null, 573 | fields: [ 574 | { 575 | name: "permissionId", 576 | kind: "scalar", 577 | isList: false, 578 | isRequired: true, 579 | isUnique: false, 580 | isId: true, 581 | isReadOnly: false, 582 | hasDefaultValue: false, 583 | type: "String", 584 | isGenerated: false, 585 | isUpdatedAt: false, 586 | }, 587 | { 588 | name: "roles", 589 | kind: "object", 590 | isList: true, 591 | isRequired: true, 592 | isUnique: false, 593 | isId: false, 594 | isReadOnly: false, 595 | hasDefaultValue: false, 596 | type: "UserRole", 597 | relationName: "PermissionToUserRole", 598 | relationFromFields: [], 599 | relationToFields: [], 600 | isGenerated: false, 601 | isUpdatedAt: false, 602 | }, 603 | ], 604 | primaryKey: null, 605 | dbName: null, 606 | uniqueFields: [], 607 | uniqueIndexes: [], 608 | }, 609 | ]); 610 | 611 | expect(newModels).toEqual([ 612 | { 613 | schema: null, 614 | dbName: "_PermissionToUserRole", 615 | name: "PermissionToUserRole", 616 | primaryKey: null, 617 | uniqueFields: [], 618 | uniqueIndexes: [], 619 | fields: [ 620 | { 621 | name: "A", 622 | type: "String", 623 | kind: "scalar", 624 | isRequired: true, 625 | isList: false, 626 | isUnique: false, 627 | isId: false, 628 | isReadOnly: true, 629 | hasDefaultValue: false, 630 | }, 631 | { 632 | name: "B", 633 | type: "UserRoleType", 634 | kind: "enum", 635 | isRequired: true, 636 | isList: false, 637 | isUnique: false, 638 | isId: false, 639 | isReadOnly: true, 640 | hasDefaultValue: false, 641 | }, 642 | ], 643 | }, 644 | ]); 645 | }); 646 | -------------------------------------------------------------------------------- /src/__test__/e2e.test.ts: -------------------------------------------------------------------------------- 1 | import { exec as execCb } from "node:child_process"; 2 | import fs from "node:fs/promises"; 3 | import { promisify } from "node:util"; 4 | import { afterEach, beforeEach, expect, test } from "vitest"; 5 | 6 | const exec = promisify(execCb); 7 | 8 | beforeEach(async () => { 9 | await fs.rename("./prisma", "./prisma-old").catch(() => {}); 10 | }); 11 | 12 | afterEach(async () => { 13 | await fs 14 | .rm("./prisma", { 15 | force: true, 16 | recursive: true, 17 | }) 18 | .catch(() => {}); 19 | await fs.rename("./prisma-old", "./prisma").catch(() => {}); 20 | }); 21 | 22 | test("End to end test", { timeout: 20000 }, async () => { 23 | // Initialize prisma: 24 | await exec("yarn prisma init --datasource-provider sqlite"); 25 | 26 | // Set up a schema 27 | await fs.writeFile( 28 | "./prisma/schema.prisma", 29 | `datasource db { 30 | provider = "sqlite" 31 | url = "file:./dev.db" 32 | } 33 | 34 | generator kysely { 35 | provider = "node ./dist/bin.js" 36 | } 37 | 38 | model TestUser { 39 | id String @id 40 | name String 41 | age Int 42 | rating Float 43 | updatedAt DateTime 44 | sprockets Sprocket[] 45 | } 46 | 47 | model Sprocket { 48 | id Int @id 49 | users TestUser[] 50 | }` 51 | ); 52 | 53 | // Run Prisma commands without fail 54 | await exec("yarn prisma generate"); 55 | 56 | const generatedSource = await fs.readFile("./prisma/generated/types.ts", { 57 | encoding: "utf-8", 58 | }); 59 | 60 | expect(generatedSource).toContain(`export type SprocketToTestUser = { 61 | A: number; 62 | B: string; 63 | };`); 64 | 65 | expect(generatedSource).toContain("_SprocketToTestUser: SprocketToTestUser"); 66 | 67 | // Expect no Kysely wrapped types to be exported since we don't enable exportWrappedTypes 68 | expect(generatedSource).not.toContain("Insertable"); 69 | }); 70 | 71 | test( 72 | "End to end test - with custom type override", 73 | { timeout: 20000 }, 74 | async () => { 75 | // Initialize prisma: 76 | await exec("yarn prisma init --datasource-provider sqlite"); 77 | 78 | // Set up a schema 79 | await fs.writeFile( 80 | "./prisma/schema.prisma", 81 | `datasource db { 82 | provider = "sqlite" 83 | url = "file:./dev.db" 84 | } 85 | 86 | generator kysely { 87 | provider = "node ./dist/bin.js" 88 | } 89 | 90 | model TestUser { 91 | id String @id 92 | name String 93 | 94 | /// @kyselyType('member' | 'owner') 95 | role String 96 | }` 97 | ); 98 | 99 | // Run Prisma commands without fail 100 | await exec("yarn prisma generate"); 101 | 102 | const generatedSource = await fs.readFile("./prisma/generated/types.ts", { 103 | encoding: "utf-8", 104 | }); 105 | 106 | // Expect many to many models to have been generated 107 | expect(generatedSource).toContain( 108 | `export type TestUser = { 109 | id: string; 110 | name: string; 111 | /** 112 | * @kyselyType('member' | 'owner') 113 | */ 114 | role: 'member' | 'owner'; 115 | };` 116 | ); 117 | } 118 | ); 119 | 120 | test("End to end test - separate entrypoints", { timeout: 20000 }, async () => { 121 | // Initialize prisma: 122 | await exec("yarn prisma init --datasource-provider mysql"); 123 | 124 | // Set up a schema 125 | await fs.writeFile( 126 | "./prisma/schema.prisma", 127 | `datasource db { 128 | provider = "mysql" 129 | url = "mysql://root:password@localhost:3306/test" 130 | } 131 | 132 | generator kysely { 133 | provider = "node ./dist/bin.js" 134 | enumFileName = "enums.ts" 135 | } 136 | 137 | enum TestEnum { 138 | A 139 | B 140 | C 141 | } 142 | 143 | model TestUser { 144 | id String @id 145 | name String 146 | age Int 147 | rating Float 148 | updatedAt DateTime 149 | abc TestEnum 150 | }` 151 | ); 152 | 153 | // Run Prisma commands without fail 154 | // await exec("yarn prisma db push"); -- can't push to mysql, enums not supported in sqlite 155 | await exec("yarn prisma generate"); // so just generate 156 | 157 | const typeFile = await fs.readFile("./prisma/generated/types.ts", { 158 | encoding: "utf-8", 159 | }); 160 | expect(typeFile).not.toContain("export const"); 161 | expect(typeFile).toContain(`import type { TestEnum } from "./enums";`); 162 | 163 | const enumFile = await fs.readFile("./prisma/generated/enums.ts", { 164 | encoding: "utf-8", 165 | }); 166 | expect(enumFile).toEqual(`export const TestEnum = { 167 | A: "A", 168 | B: "B", 169 | C: "C" 170 | } as const; 171 | export type TestEnum = (typeof TestEnum)[keyof typeof TestEnum]; 172 | `); 173 | }); 174 | 175 | test( 176 | "End to end test - separate entrypoints but no enums", 177 | { timeout: 20000 }, 178 | async () => { 179 | // Initialize prisma: 180 | await exec("yarn prisma init --datasource-provider sqlite"); 181 | 182 | // Set up a schema 183 | await fs.writeFile( 184 | "./prisma/schema.prisma", 185 | `datasource db { 186 | provider = "sqlite" 187 | url = "file:./dev.db" 188 | } 189 | 190 | generator kysely { 191 | provider = "node ./dist/bin.js" 192 | enumFileName = "enums.ts" 193 | } 194 | 195 | model TestUser { 196 | id String @id 197 | name String 198 | age Int 199 | rating Float 200 | updatedAt DateTime 201 | }` 202 | ); 203 | 204 | // Run Prisma commands without fail 205 | await exec("yarn prisma db push"); 206 | await exec("yarn prisma generate"); 207 | 208 | // Shouldn't have an empty import statement 209 | const typeFile = await fs.readFile("./prisma/generated/types.ts", { 210 | encoding: "utf-8", 211 | }); 212 | expect(typeFile).not.toContain('from "./enums"'); 213 | 214 | // Shouldn't have generated an empty file 215 | await expect( 216 | fs.readFile("./prisma/generated/enums.ts", { 217 | encoding: "utf-8", 218 | }) 219 | ).rejects.toThrow(); 220 | } 221 | ); 222 | 223 | test("End to end test - multi-schema support", { timeout: 20000 }, async () => { 224 | // Initialize prisma: 225 | await exec("yarn prisma init --datasource-provider postgresql"); 226 | 227 | // Set up a schema 228 | await fs.writeFile( 229 | "./prisma/schema.prisma", 230 | `generator kysely { 231 | provider = "node ./dist/bin.js" 232 | previewFeatures = ["multiSchema"] 233 | } 234 | 235 | datasource db { 236 | provider = "postgresql" 237 | schemas = ["mammals", "birds"] 238 | url = env("TEST_DATABASE_URL") 239 | } 240 | 241 | model Elephant { 242 | id Int @id 243 | name String 244 | 245 | @@map("elephants") 246 | @@schema("mammals") 247 | } 248 | 249 | model Eagle { 250 | id Int @id 251 | name String 252 | 253 | @@map("eagles") 254 | @@schema("birds") 255 | }` 256 | ); 257 | 258 | await exec("yarn prisma generate"); 259 | 260 | // Shouldn't have an empty import statement 261 | const typeFile = await fs.readFile("./prisma/generated/types.ts", { 262 | encoding: "utf-8", 263 | }); 264 | 265 | expect(typeFile).toContain(`export type DB = { 266 | "birds.eagles": Eagle; 267 | "mammals.elephants": Elephant; 268 | };`); 269 | }); 270 | 271 | test( 272 | "End to end test - multi-schema and filterBySchema support", 273 | { timeout: 20000 }, 274 | async () => { 275 | // Initialize prisma: 276 | await exec("yarn prisma init --datasource-provider postgresql"); 277 | 278 | // Set up a schema 279 | await fs.writeFile( 280 | "./prisma/schema.prisma", 281 | `generator kysely { 282 | provider = "node ./dist/bin.js" 283 | previewFeatures = ["multiSchema"] 284 | filterBySchema = ["mammals"] 285 | } 286 | 287 | datasource db { 288 | provider = "postgresql" 289 | schemas = ["mammals", "birds"] 290 | url = env("TEST_DATABASE_URL") 291 | } 292 | 293 | model Elephant { 294 | id Int @id 295 | name String 296 | 297 | @@map("elephants") 298 | @@schema("mammals") 299 | } 300 | 301 | model Eagle { 302 | id Int @id 303 | name String 304 | 305 | @@map("eagles") 306 | @@schema("birds") 307 | }` 308 | ); 309 | 310 | await exec("yarn prisma generate"); 311 | 312 | // Shouldn't have an empty import statement 313 | const typeFile = await fs.readFile("./prisma/generated/types.ts", { 314 | encoding: "utf-8", 315 | }); 316 | 317 | expect(typeFile).toContain(`export type DB = { 318 | "mammals.elephants": Elephant; 319 | };`); 320 | } 321 | ); 322 | 323 | test( 324 | "End to end test - multi-schema and groupBySchema support", 325 | { timeout: 20000 }, 326 | async () => { 327 | // Initialize prisma: 328 | await exec("yarn prisma init --datasource-provider postgresql"); 329 | 330 | // Set up a schema 331 | await fs.writeFile( 332 | "./prisma/schema.prisma", 333 | ` 334 | generator kysely { 335 | provider = "node ./dist/bin.js" 336 | previewFeatures = ["multiSchema"] 337 | groupBySchema = true 338 | } 339 | 340 | datasource db { 341 | provider = "postgresql" 342 | schemas = ["mammals", "birds", "world"] 343 | url = env("TEST_DATABASE_URL") 344 | } 345 | 346 | model Elephant { 347 | id Int @id 348 | name String 349 | ability Ability @default(WALK) 350 | color Color 351 | 352 | @@map("elephants") 353 | @@schema("mammals") 354 | } 355 | 356 | model Eagle { 357 | id Int @id 358 | name String 359 | ability Ability @default(FLY) 360 | 361 | @@map("eagles") 362 | @@schema("birds") 363 | } 364 | 365 | enum Ability { 366 | FLY 367 | WALK 368 | 369 | @@schema("world") 370 | } 371 | 372 | enum Color { 373 | GRAY 374 | PINK 375 | 376 | @@schema("mammals") 377 | } 378 | ` 379 | ); 380 | 381 | await exec("yarn prisma generate"); 382 | 383 | // Shouldn't have an empty import statement 384 | const typeFile = await fs.readFile("./prisma/generated/types.ts", { 385 | encoding: "utf-8", 386 | }); 387 | 388 | expect(typeFile).toContain(`export namespace Birds { 389 | export type Eagle = {`); 390 | 391 | expect(typeFile).toContain(`export namespace Mammals { 392 | export const Color = {`); 393 | 394 | // correctly references the color enum 395 | expect(typeFile).toContain("color: Mammals.Color;"); 396 | 397 | expect(typeFile).toContain(`export type DB = { 398 | "birds.eagles": Birds.Eagle; 399 | "mammals.elephants": Mammals.Elephant; 400 | };`); 401 | } 402 | ); 403 | 404 | test( 405 | "End to end test - multi-schema, groupBySchema and defaultSchema support", 406 | { timeout: 20000 }, 407 | async () => { 408 | // Initialize prisma: 409 | await exec("yarn prisma init --datasource-provider postgresql"); 410 | 411 | // Set up a schema 412 | await fs.writeFile( 413 | "./prisma/schema.prisma", 414 | ` 415 | generator kysely { 416 | provider = "node ./dist/bin.js" 417 | previewFeatures = ["multiSchema"] 418 | groupBySchema = true 419 | defaultSchema = "fish" 420 | } 421 | 422 | datasource db { 423 | provider = "postgresql" 424 | schemas = ["mammals", "birds", "world", "fish"] 425 | url = env("TEST_DATABASE_URL") 426 | } 427 | 428 | model Elephant { 429 | id Int @id 430 | name String 431 | ability Ability @default(WALK) 432 | color Color 433 | 434 | @@map("elephants") 435 | @@schema("mammals") 436 | } 437 | 438 | model Eagle { 439 | id Int @id 440 | name String 441 | ability Ability @default(FLY) 442 | 443 | @@map("eagles") 444 | @@schema("birds") 445 | } 446 | 447 | model Shark { 448 | id Int @id 449 | name String 450 | color Color 451 | 452 | @@map("shark") 453 | @@schema("fish") 454 | } 455 | 456 | enum Ability { 457 | FLY 458 | WALK 459 | 460 | @@schema("world") 461 | } 462 | 463 | enum Color { 464 | GRAY 465 | PINK 466 | 467 | @@schema("mammals") 468 | } 469 | ` 470 | ); 471 | 472 | await exec("yarn prisma generate"); 473 | 474 | // Shouldn't have an empty import statement 475 | const typeFile = await fs.readFile("./prisma/generated/types.ts", { 476 | encoding: "utf-8", 477 | }); 478 | 479 | expect(typeFile).toContain(`export namespace Birds { 480 | export type Eagle = {`); 481 | 482 | expect(typeFile).toContain(`export namespace Mammals { 483 | export const Color = {`); 484 | 485 | // outside of enum 486 | expect(typeFile).toContain("export type Shark = {"); 487 | 488 | // correctly references the color enum 489 | expect(typeFile).toContain("color: Mammals.Color;"); 490 | 491 | expect(typeFile).toContain(`export type DB = { 492 | "birds.eagles": Birds.Eagle; 493 | "mammals.elephants": Mammals.Elephant; 494 | shark: Shark; 495 | };`); 496 | } 497 | ); 498 | 499 | test( 500 | "End to end test - multi-schema, groupBySchema and filterBySchema support", 501 | { timeout: 20000 }, 502 | async () => { 503 | // Initialize prisma: 504 | await exec("yarn prisma init --datasource-provider postgresql"); 505 | 506 | // Set up a schema 507 | await fs.writeFile( 508 | "./prisma/schema.prisma", 509 | ` 510 | generator kysely { 511 | provider = "node ./dist/bin.js" 512 | previewFeatures = ["multiSchema"] 513 | groupBySchema = true 514 | filterBySchema = ["mammals", "world"] 515 | } 516 | 517 | datasource db { 518 | provider = "postgresql" 519 | schemas = ["mammals", "birds", "world"] 520 | url = env("TEST_DATABASE_URL") 521 | } 522 | 523 | model Elephant { 524 | id Int @id 525 | name String 526 | ability Ability @default(WALK) 527 | color Color 528 | 529 | @@map("elephants") 530 | @@schema("mammals") 531 | } 532 | 533 | model Eagle { 534 | id Int @id 535 | name String 536 | ability Ability @default(FLY) 537 | 538 | @@map("eagles") 539 | @@schema("birds") 540 | } 541 | 542 | enum Ability { 543 | FLY 544 | WALK 545 | 546 | @@schema("world") 547 | } 548 | 549 | enum Color { 550 | GRAY 551 | PINK 552 | 553 | @@schema("mammals") 554 | } 555 | ` 556 | ); 557 | 558 | await exec("yarn prisma generate"); 559 | 560 | // Shouldn't have an empty import statement 561 | const typeFile = await fs.readFile("./prisma/generated/types.ts", { 562 | encoding: "utf-8", 563 | }); 564 | 565 | expect(typeFile).not.toContain(`export namespace Birds { 566 | export type Eagle = {`); 567 | 568 | expect(typeFile).toContain(`export namespace Mammals { 569 | export const Color = {`); 570 | 571 | // correctly references the color enum 572 | expect(typeFile).toContain("color: Mammals.Color;"); 573 | 574 | expect(typeFile).toContain(`export type DB = { 575 | "mammals.elephants": Mammals.Elephant; 576 | };`); 577 | } 578 | ); 579 | 580 | test( 581 | "End to end test - SQLite with JSON support", 582 | { timeout: 20000 }, 583 | async () => { 584 | await exec("yarn prisma init --datasource-provider sqlite"); 585 | 586 | await fs.writeFile( 587 | "./prisma/schema.prisma", 588 | `datasource db { 589 | provider = "sqlite" 590 | url = "file:./dev.db" 591 | } 592 | 593 | generator kysely { 594 | provider = "node ./dist/bin.js" 595 | } 596 | 597 | model TestUser { 598 | id String @id 599 | name String 600 | metadata Json // JSON field supported in SQLite since Prisma 6.2 601 | preferences Json? // Optional JSON field 602 | 603 | /// @kyselyType({ theme: 'light' | 'dark', language: string }) 604 | settings Json 605 | 606 | createdAt DateTime @default(now()) 607 | } 608 | 609 | model Product { 610 | id Int @id @default(autoincrement()) 611 | name String 612 | details Json // Product details as JSON 613 | 614 | /// @kyselyType(string[]) 615 | tags Json? // Optional tags as JSON array 616 | }` 617 | ); 618 | 619 | await exec("yarn prisma generate"); 620 | 621 | const generatedSource = await fs.readFile("./prisma/generated/types.ts", { 622 | encoding: "utf-8", 623 | }); 624 | 625 | expect(generatedSource).toContain(`export type TestUser = { 626 | id: string; 627 | name: string; 628 | metadata: unknown; 629 | preferences: unknown | null; 630 | /** 631 | * @kyselyType({ theme: 'light' | 'dark', language: string }) 632 | */ 633 | settings: { theme: 'light' | 'dark', language: string }; 634 | createdAt: Generated; 635 | };`); 636 | 637 | expect(generatedSource).toContain(`export type Product = { 638 | id: Generated; 639 | name: string; 640 | details: unknown; 641 | /** 642 | * @kyselyType(string[]) 643 | */ 644 | tags: string[] | null; 645 | };`); 646 | 647 | expect(generatedSource).toContain(`export type DB = { 648 | Product: Product; 649 | TestUser: TestUser; 650 | };`); 651 | } 652 | ); 653 | 654 | test( 655 | "End to end test - multi-schema with views support", 656 | { timeout: 20000 }, 657 | async () => { 658 | // Initialize prisma: 659 | await exec("yarn prisma init --datasource-provider postgresql"); 660 | 661 | // Set up a schema with views in different schemas 662 | await fs.writeFile( 663 | "./prisma/schema.prisma", 664 | `generator kysely { 665 | provider = "node ./dist/bin.js" 666 | previewFeatures = ["multiSchema", "views"] 667 | } 668 | 669 | datasource db { 670 | provider = "postgresql" 671 | schemas = ["public", "analytics"] 672 | url = env("TEST_DATABASE_URL") 673 | } 674 | 675 | model User { 676 | id Int @id 677 | name String 678 | email String 679 | posts Post[] 680 | 681 | @@schema("public") 682 | } 683 | 684 | model Post { 685 | id Int @id 686 | title String 687 | content String 688 | authorId Int 689 | author User @relation(fields: [authorId], references: [id]) 690 | 691 | @@schema("public") 692 | } 693 | 694 | view UserStats { 695 | id Int @unique 696 | name String 697 | postCount Int 698 | 699 | @@schema("analytics") 700 | } 701 | 702 | view PostSummary { 703 | id Int @unique 704 | title String 705 | author String 706 | 707 | @@schema("public") 708 | }` 709 | ); 710 | 711 | await exec("yarn prisma generate"); 712 | 713 | const typeFile = await fs.readFile("./prisma/generated/types.ts", { 714 | encoding: "utf-8", 715 | }); 716 | 717 | // Verify that views are properly prefixed with their schema names 718 | expect(typeFile).toContain(`export type DB = { 719 | "analytics.UserStats": UserStats; 720 | Post: Post; 721 | PostSummary: PostSummary; 722 | User: User; 723 | };`); 724 | 725 | // Verify view types are generated correctly 726 | expect(typeFile).toContain(`export type UserStats = { 727 | id: number; 728 | name: string; 729 | postCount: number; 730 | };`); 731 | 732 | expect(typeFile).toContain(`export type PostSummary = { 733 | id: number; 734 | title: string; 735 | author: string; 736 | };`); 737 | } 738 | ); 739 | 740 | test("End to end test - exportWrappedTypes", { timeout: 20000 }, async () => { 741 | await exec("yarn prisma init --datasource-provider sqlite"); 742 | 743 | await fs.writeFile( 744 | "./prisma/schema.prisma", 745 | `datasource db { 746 | provider = "sqlite" 747 | url = "file:./dev.db" 748 | } 749 | 750 | generator kysely { 751 | provider = "node ./dist/bin.js" 752 | exportWrappedTypes = true 753 | } 754 | 755 | model User { 756 | id String @id 757 | name String 758 | }` 759 | ); 760 | 761 | await exec("yarn prisma generate"); 762 | 763 | const generatedSource = await fs.readFile("./prisma/generated/types.ts", { 764 | encoding: "utf-8", 765 | }); 766 | 767 | expect(generatedSource).toContain("export type UserTable = {"); 768 | expect(generatedSource).toContain( 769 | "export type User = Selectable;" 770 | ); 771 | expect(generatedSource).toContain( 772 | "export type NewUser = Insertable;" 773 | ); 774 | expect(generatedSource).toContain( 775 | "export type UserUpdate = Updateable;" 776 | ); 777 | expect(generatedSource).toContain( 778 | "export type DB = {\n User: UserTable;\n};" 779 | ); 780 | }); 781 | 782 | test( 783 | "End to end test - groupBySchema and exportWrappedTypes support", 784 | { timeout: 20000 }, 785 | async () => { 786 | // Initialize prisma: 787 | await exec("yarn prisma init --datasource-provider postgresql"); 788 | 789 | // Set up a schema 790 | await fs.writeFile( 791 | "./prisma/schema.prisma", 792 | ` 793 | generator kysely { 794 | provider = "node ./dist/bin.js" 795 | previewFeatures = ["multiSchema"] 796 | groupBySchema = true 797 | exportWrappedTypes = true 798 | } 799 | 800 | datasource db { 801 | provider = "postgresql" 802 | schemas = ["mammals", "birds", "world"] 803 | url = env("TEST_DATABASE_URL") 804 | } 805 | 806 | model Elephant { 807 | id Int @id 808 | name String 809 | ability Ability @default(WALK) 810 | color Color 811 | 812 | @@map("elephants") 813 | @@schema("mammals") 814 | } 815 | 816 | model Eagle { 817 | id Int @id 818 | name String 819 | ability Ability @default(FLY) 820 | 821 | @@map("eagles") 822 | @@schema("birds") 823 | } 824 | 825 | enum Ability { 826 | FLY 827 | WALK 828 | 829 | @@schema("world") 830 | } 831 | 832 | enum Color { 833 | GRAY 834 | PINK 835 | 836 | @@schema("mammals") 837 | } 838 | ` 839 | ); 840 | 841 | await exec("yarn prisma generate"); 842 | 843 | // Shouldn't have an empty import statement 844 | const typeFile = await fs.readFile("./prisma/generated/types.ts", { 845 | encoding: "utf-8", 846 | }); 847 | 848 | expect(typeFile).toContain(`export namespace Birds { 849 | export type EagleTable = {`); 850 | 851 | expect(typeFile).toContain(`export namespace Mammals { 852 | export const Color = {`); 853 | 854 | // correctly references the color enum 855 | expect(typeFile).toContain("color: Mammals.Color;"); 856 | 857 | expect(typeFile).toContain(`export type DB = { 858 | "birds.eagles": Birds.EagleTable; 859 | "mammals.elephants": Mammals.ElephantTable; 860 | };`); 861 | } 862 | ); 863 | --------------------------------------------------------------------------------