diff --git a/.changeset/fast-geese-smell.md b/.changeset/fast-geese-smell.md new file mode 100644 index 0000000..b6aeb28 --- /dev/null +++ b/.changeset/fast-geese-smell.md @@ -0,0 +1,5 @@ +--- +"prisma-kysely": minor +--- + +Added groupBySchema option diff --git a/README.md b/README.md index 574545b..dbffffc 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ without losing the safety of the TypeScript type system? output = "../src/db" fileName = "types.ts" // Optionally generate runtime enums to a separate file - enumFileName = "enums.ts" + enumFileName = "enums.ts" } ``` @@ -83,6 +83,8 @@ hope it's just as useful for you! 😎 | `camelCase` | Enable support for Kysely's camelCase plugin | | `readOnlyIds` | Use Kysely's `GeneratedAlways` for `@id` fields with default values, preventing insert and update. | | `[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. | +| `groupBySchema` | When using `multiSchema` preview features, group all models and enums for a schema into their own namespace. (Ex: `model Dog { @@schema("animals") }` will be available under `Animals.Dog`) | +| `filterBySchema` | When using `multiSchema` preview features, only include models and enums for the specified schema. | ### Per-field type overrides diff --git a/src/__test__/e2e.test.ts b/src/__test__/e2e.test.ts index a7c4e12..67a2a77 100644 --- a/src/__test__/e2e.test.ts +++ b/src/__test__/e2e.test.ts @@ -1,6 +1,6 @@ -import { exec as execCb } from "child_process"; -import fs from "fs/promises"; -import { promisify } from "util"; +import { exec as execCb } from "node:child_process"; +import fs from "node:fs/promises"; +import { promisify } from "node:util"; import { afterEach, beforeEach, expect, test } from "vitest"; const exec = promisify(execCb); @@ -280,3 +280,217 @@ test( }, { timeout: 20000 } ); + +test( + "End to end test - multi-schema support", + async () => { + // Initialize prisma: + await exec("yarn prisma init --datasource-provider postgresql"); + + // Set up a schema + await fs.writeFile( + "./prisma/schema.prisma", + `generator kysely { + provider = "node ./dist/bin.js" + previewFeatures = ["multiSchema"] + filterBySchema = ["mammals"] + } + + datasource db { + provider = "postgresql" + schemas = ["mammals", "birds"] + url = env("TEST_DATABASE_URL") + } + + model Elephant { + id Int @id + name String + + @@map("elephants") + @@schema("mammals") + } + + model Eagle { + id Int @id + name String + + @@map("eagles") + @@schema("birds") + }` + ); + + await exec("yarn prisma generate"); + + // Shouldn't have an empty import statement + const typeFile = await fs.readFile("./prisma/generated/types.ts", { + encoding: "utf-8", + }); + + expect(typeFile).toContain(`export type DB = { + "mammals.elephants": Elephant; +};`); + }, + { timeout: 20000 } +); + +test( + "End to end test - multi-schema and groupBySchema support", + async () => { + // Initialize prisma: + await exec("yarn prisma init --datasource-provider postgresql"); + + // Set up a schema + await fs.writeFile( + "./prisma/schema.prisma", + ` +generator kysely { + provider = "node ./dist/bin.js" + previewFeatures = ["multiSchema"] + groupBySchema = true +} + +datasource db { + provider = "postgresql" + schemas = ["mammals", "birds", "world"] + url = env("TEST_DATABASE_URL") +} + +model Elephant { + id Int @id + name String + ability Ability @default(WALK) + color Color + + @@map("elephants") + @@schema("mammals") +} + +model Eagle { + id Int @id + name String + ability Ability @default(FLY) + + @@map("eagles") + @@schema("birds") +} + +enum Ability { + FLY + WALK + + @@schema("world") +} + +enum Color { + GRAY + PINK + + @@schema("mammals") +} + ` + ); + + await exec("yarn prisma generate"); + + // Shouldn't have an empty import statement + const typeFile = await fs.readFile("./prisma/generated/types.ts", { + encoding: "utf-8", + }); + + expect(typeFile).toContain(`export namespace Birds { + export type Eagle = {`); + + expect(typeFile).toContain(`export namespace Mammals { + export const Color = {`); + + // correctly references the color enum + expect(typeFile).toContain("color: Mammals.Color;"); + + expect(typeFile).toContain(`export type DB = { + "birds.eagles": Birds.Eagle; + "mammals.elephants": Mammals.Elephant; +};`); + }, + { timeout: 20000 } +); + +test( + "End to end test - multi-schema, groupBySchema and filterBySchema support", + async () => { + // Initialize prisma: + await exec("yarn prisma init --datasource-provider postgresql"); + + // Set up a schema + await fs.writeFile( + "./prisma/schema.prisma", + ` +generator kysely { + provider = "node ./dist/bin.js" + previewFeatures = ["multiSchema"] + groupBySchema = true + filterBySchema = ["mammals", "world"] +} + +datasource db { + provider = "postgresql" + schemas = ["mammals", "birds", "world"] + url = env("TEST_DATABASE_URL") +} + +model Elephant { + id Int @id + name String + ability Ability @default(WALK) + color Color + + @@map("elephants") + @@schema("mammals") +} + +model Eagle { + id Int @id + name String + ability Ability @default(FLY) + + @@map("eagles") + @@schema("birds") +} + +enum Ability { + FLY + WALK + + @@schema("world") +} + +enum Color { + GRAY + PINK + + @@schema("mammals") +} + ` + ); + + await exec("yarn prisma generate"); + + // Shouldn't have an empty import statement + const typeFile = await fs.readFile("./prisma/generated/types.ts", { + encoding: "utf-8", + }); + + expect(typeFile).not.toContain(`export namespace Birds { + export type Eagle = {`); + + expect(typeFile).toContain(`export namespace Mammals { + export const Color = {`); + + // correctly references the color enum + expect(typeFile).toContain("color: Mammals.Color;"); + + expect(typeFile).toContain(`export type DB = { + "mammals.elephants": Mammals.Elephant; +};`); + }, + { timeout: 20000 } +); diff --git a/src/generator.ts b/src/generator.ts index 186eca0..19367b2 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -1,6 +1,6 @@ import type { GeneratorOptions } from "@prisma/generator-helper"; import { generatorHandler } from "@prisma/generator-helper"; -import path from "path"; +import path from "node:path"; import { GENERATOR_NAME } from "~/constants"; import { generateDatabaseType } from "~/helpers/generateDatabaseType"; @@ -11,8 +11,11 @@ import { sorted } from "~/utils/sorted"; import { validateConfig } from "~/utils/validateConfig"; import { writeFileSafely } from "~/utils/writeFileSafely"; -import { generateEnumType } from "./helpers/generateEnumType"; -import { convertToMultiSchemaModels } from "./helpers/multiSchemaHelpers"; +import { type EnumType, generateEnumType } from "./helpers/generateEnumType"; +import { + convertToMultiSchemaModels, + parseMultiSchemaMap, +} from "./helpers/multiSchemaHelpers"; // eslint-disable-next-line @typescript-eslint/no-var-requires const { version } = require("../package.json"); @@ -33,9 +36,9 @@ generatorHandler({ }); // Generate enum types - const enums = options.dmmf.datamodel.enums.flatMap(({ name, values }) => { - return generateEnumType(name, values); - }); + let enums = options.dmmf.datamodel.enums + .map(({ name, values }) => generateEnumType(name, values)) + .filter((e): e is EnumType => !!e); // Generate DMMF models for implicit many to many tables // @@ -45,31 +48,53 @@ generatorHandler({ options.dmmf.datamodel.models ); + const multiSchemaMap = + config.groupBySchema || + options.generator.previewFeatures?.includes("multiSchema") + ? parseMultiSchemaMap(options.datamodel) + : undefined; + // Generate model types let models = sorted( [...options.dmmf.datamodel.models, ...implicitManyToManyModels], (a, b) => a.name.localeCompare(b.name) - ).map((m) => generateModel(m, config)); + ).map((m) => + generateModel(m, config, config.groupBySchema, multiSchemaMap) + ); // Extend model table names with schema names if using multi-schemas if (options.generator.previewFeatures?.includes("multiSchema")) { - models = convertToMultiSchemaModels(models, options.datamodel); + const filterBySchema = config.filterBySchema + ? new Set(config.filterBySchema) + : null; + + models = convertToMultiSchemaModels( + models, + config.groupBySchema, + filterBySchema, + multiSchemaMap + ); + + enums = convertToMultiSchemaModels( + enums, + config.groupBySchema, + filterBySchema, + multiSchemaMap + ); } // Generate the database type that ties it all together - const databaseType = generateDatabaseType( - models.map((m) => ({ tableName: m.tableName, typeName: m.typeName })), - config - ); + const databaseType = generateDatabaseType(models, config); // Parse it all into a string. Either 1 or 2 files depending on user config const files = generateFiles({ databaseType, - modelDefinitions: models.map((m) => m.definition), enumNames: options.dmmf.datamodel.enums.map((e) => e.name), + models, enums, enumsOutfile: config.enumFileName, typesOutfile: config.fileName, + groupBySchema: config.groupBySchema, }); // And write it to a file! diff --git a/src/helpers/generateDatabaseType.test.ts b/src/helpers/generateDatabaseType.test.ts index 81e1ae1..33bfb99 100644 --- a/src/helpers/generateDatabaseType.test.ts +++ b/src/helpers/generateDatabaseType.test.ts @@ -16,6 +16,7 @@ test("it works for plain vanilla type names", () => { enumFileName: "", camelCase: false, readOnlyIds: false, + groupBySchema: false, } ); const result = stringifyTsNode(node); @@ -40,6 +41,7 @@ test("it respects camelCase option names", () => { enumFileName: "", camelCase: true, readOnlyIds: false, + groupBySchema: false, } ); const result = stringifyTsNode(node); @@ -64,6 +66,7 @@ test("it works for table names with spaces and weird symbols", () => { enumFileName: "", camelCase: false, readOnlyIds: false, + groupBySchema: false, } ); const result = stringifyTsNode(node); diff --git a/src/helpers/generateEnumType.test.ts b/src/helpers/generateEnumType.test.ts index a275d9b..4bcb743 100644 --- a/src/helpers/generateEnumType.test.ts +++ b/src/helpers/generateEnumType.test.ts @@ -4,10 +4,10 @@ import { expect, test } from "vitest"; import { generateEnumType } from "./generateEnumType"; test("it generates the enum type", () => { - const [objectDeclaration, typeDeclaration] = generateEnumType("Name", [ + const { objectDeclaration, typeDeclaration } = generateEnumType("Name", [ { name: "FOO", dbName: "FOO" }, { name: "BAR", dbName: "BAR" }, - ]); + ])!; const printer = createPrinter(); diff --git a/src/helpers/generateEnumType.ts b/src/helpers/generateEnumType.ts index 8367749..99e6cee 100644 --- a/src/helpers/generateEnumType.ts +++ b/src/helpers/generateEnumType.ts @@ -6,10 +6,22 @@ import isValidTSIdentifier from "~/utils/isValidTSIdentifier"; import { generateStringLiteralUnion } from "./generateStringLiteralUnion"; import { generateTypedReferenceNode } from "./generateTypedReferenceNode"; -export const generateEnumType = (name: string, values: DMMF.EnumValue[]) => { +export type EnumType = { + objectDeclaration: ts.VariableStatement; + typeDeclaration: ts.TypeAliasDeclaration; + schema?: string; + typeName: string; +}; + +export const generateEnumType = ( + name: string, + values: DMMF.EnumValue[] +): EnumType | undefined => { const type = generateStringLiteralUnion(values.map((v) => v.name)); - if (!type) return []; + if (!type) { + return undefined; + } const objectDeclaration = ts.factory.createVariableStatement( [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], @@ -47,5 +59,9 @@ export const generateEnumType = (name: string, values: DMMF.EnumValue[]) => { const typeDeclaration = generateTypedReferenceNode(name); - return [objectDeclaration, typeDeclaration]; + return { + typeName: name, + objectDeclaration, + typeDeclaration, + }; }; diff --git a/src/helpers/generateFieldType.test.ts b/src/helpers/generateFieldType.test.ts index d1f6d90..bb19ccc 100644 --- a/src/helpers/generateFieldType.test.ts +++ b/src/helpers/generateFieldType.test.ts @@ -23,6 +23,7 @@ test("it respects overrides when generating field types", () => { enumFileName: "types.ts", camelCase: false, readOnlyIds: false, + groupBySchema: false, }; const sourceTypes = [ @@ -51,6 +52,7 @@ test("it respects overrides when generating field types", () => { stringTypeOverride: "cheese", camelCase: false, readOnlyIds: false, + groupBySchema: false, }); expect(node).toEqual("cheese"); @@ -63,6 +65,7 @@ test("it respects differences between database engines", () => { enumFileName: "types.ts", camelCase: false, readOnlyIds: false, + groupBySchema: false, }); const mysqlBooleanType = generateFieldType("Boolean", { @@ -71,6 +74,7 @@ test("it respects differences between database engines", () => { enumFileName: "types.ts", camelCase: false, readOnlyIds: false, + groupBySchema: false, }); const sqliteBooleanType = generateFieldType("Boolean", { @@ -79,6 +83,7 @@ test("it respects differences between database engines", () => { enumFileName: "types.ts", camelCase: false, readOnlyIds: false, + groupBySchema: false, }); expect(postgresBooleanType).toEqual("boolean"); @@ -94,6 +99,7 @@ test("it throws an error when unsupported type is encountered", () => { enumFileName: "types.ts", camelCase: false, readOnlyIds: false, + groupBySchema: false, }) ).toThrowError(new Error("Unsupported type Json for database sqlite")); }); diff --git a/src/helpers/generateFile.test.ts b/src/helpers/generateFile.test.ts index 24189c2..a19846b 100644 --- a/src/helpers/generateFile.test.ts +++ b/src/helpers/generateFile.test.ts @@ -13,7 +13,7 @@ test("generates a file!", () => { withEnumImport: { importPath: "./enums", names: ["Foo", "Bar"] }, withLeader: false, }); - console.log(resultwithEnumImport); + expect(resultwithEnumImport).toContain( 'import type { Foo, Bar } from "./enums";' ); diff --git a/src/helpers/generateFiles.ts b/src/helpers/generateFiles.ts index 22885fb..78ac553 100644 --- a/src/helpers/generateFiles.ts +++ b/src/helpers/generateFiles.ts @@ -1,29 +1,43 @@ import path from "path"; -import type { TypeAliasDeclaration, VariableStatement } from "typescript"; +import type { TypeAliasDeclaration } from "typescript"; +import ts from "typescript"; import { generateFile } from "~/helpers/generateFile"; +import { capitalize } from "~/utils/camelCase"; + +import type { EnumType } from "./generateEnumType"; +import type { ModelType } from "./generateModel"; type File = { filepath: string; content: ReturnType }; export function generateFiles(opts: { typesOutfile: string; - enums: (VariableStatement | TypeAliasDeclaration)[]; + enums: EnumType[]; + models: ModelType[]; enumNames: string[]; enumsOutfile: string; databaseType: TypeAliasDeclaration; - modelDefinitions: TypeAliasDeclaration[]; + groupBySchema: boolean; }) { // Don't generate a separate file for enums if there are no enums if (opts.enumsOutfile === opts.typesOutfile || opts.enums.length === 0) { + let statements: Iterable; + + if (!opts.groupBySchema) { + statements = [ + ...opts.enums.flatMap((e) => [e.objectDeclaration, e.typeDeclaration]), + ...opts.models.map((m) => m.definition), + ]; + } else { + statements = groupModelsAndEnum(opts.enums, opts.models); + } + const typesFileWithEnums: File = { filepath: opts.typesOutfile, - content: generateFile( - [...opts.enums, ...opts.modelDefinitions, opts.databaseType], - { - withEnumImport: false, - withLeader: true, - } - ), + content: generateFile([...statements, opts.databaseType], { + withEnumImport: false, + withLeader: true, + }), }; return [typesFileWithEnums]; @@ -31,24 +45,80 @@ export function generateFiles(opts: { const typesFileWithoutEnums: File = { filepath: opts.typesOutfile, - content: generateFile([...opts.modelDefinitions, opts.databaseType], { - withEnumImport: { - importPath: `./${path.parse(opts.enumsOutfile).name}`, - names: opts.enumNames, - }, - withLeader: true, - }), + content: generateFile( + [...opts.models.map((m) => m.definition), opts.databaseType], + { + withEnumImport: { + importPath: `./${path.parse(opts.enumsOutfile).name}`, + names: opts.enumNames, + }, + withLeader: true, + } + ), }; if (opts.enums.length === 0) return [typesFileWithoutEnums]; const enumFile: File = { filepath: opts.enumsOutfile, - content: generateFile(opts.enums, { - withEnumImport: false, - withLeader: false, - }), + content: generateFile( + opts.enums.flatMap((e) => [e.objectDeclaration, e.typeDeclaration]), + { + withEnumImport: false, + withLeader: false, + } + ), }; return [typesFileWithoutEnums, enumFile]; } + +export function* groupModelsAndEnum( + enums: EnumType[], + models: ModelType[] +): Generator { + const groupsMap = new Map(); + + for (const enumType of enums) { + if (!enumType.schema) { + yield enumType.objectDeclaration; + yield enumType.typeDeclaration; + continue; + } + + const group = groupsMap.get(enumType.schema); + + if (!group) { + groupsMap.set(enumType.schema, [ + enumType.objectDeclaration, + enumType.typeDeclaration, + ]); + } else { + group.push(enumType.objectDeclaration, enumType.typeDeclaration); + } + } + + for (const model of models) { + if (!model.schema) { + yield model.definition; + continue; + } + + const group = groupsMap.get(model.schema); + + if (!group) { + groupsMap.set(model.schema, [model.definition]); + } else { + group.push(model.definition); + } + } + + for (const [schema, group] of groupsMap) { + yield ts.factory.createModuleDeclaration( + [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], + ts.factory.createIdentifier(capitalize(schema)), + ts.factory.createModuleBlock(group), + ts.NodeFlags.Namespace + ); + } +} diff --git a/src/helpers/generateModel.test.ts b/src/helpers/generateModel.test.ts index 5850cca..d2631fc 100644 --- a/src/helpers/generateModel.test.ts +++ b/src/helpers/generateModel.test.ts @@ -94,7 +94,9 @@ test("it generates a model!", () => { enumFileName: "", camelCase: false, readOnlyIds: false, - } + groupBySchema: false, + }, + false ); expect(model.tableName).toEqual("User"); @@ -152,7 +154,9 @@ test("it respects camelCase option", () => { enumFileName: "", camelCase: true, readOnlyIds: false, - } + groupBySchema: false, + }, + false ); expect(model.tableName).toEqual("User"); diff --git a/src/helpers/generateModel.ts b/src/helpers/generateModel.ts index 7ba2aa4..0c4046b 100644 --- a/src/helpers/generateModel.ts +++ b/src/helpers/generateModel.ts @@ -4,6 +4,7 @@ import ts from "typescript"; import { generateField } from "~/helpers/generateField"; import { generateFieldType } from "~/helpers/generateFieldType"; import { generateTypeOverrideFromDocumentation } from "~/helpers/generateTypeOverrideFromDocumentation"; +import { capitalize } from "~/utils/camelCase"; import { normalizeCase } from "~/utils/normalizeCase"; import type { Config } from "~/utils/validateConfig"; @@ -14,7 +15,19 @@ import type { Config } from "~/utils/validateConfig"; */ const defaultTypesImplementedInJS = ["cuid", "uuid"]; -export const generateModel = (model: DMMF.Model, config: Config) => { +export type ModelType = { + typeName: string; + tableName: string; + definition: ts.TypeAliasDeclaration; + schema?: string; +}; + +export const generateModel = ( + model: DMMF.Model, + config: Config, + groupBySchema: boolean, + multiSchemaMap?: Map +): ModelType => { const properties = model.fields.flatMap((field) => { const isGenerated = field.hasDefaultValue && @@ -32,12 +45,18 @@ export const generateModel = (model: DMMF.Model, config: Config) => { const dbName = typeof field.dbName === "string" ? field.dbName : null; + const schemaPrefix = groupBySchema && multiSchemaMap?.get(field.type); + if (field.kind === "enum") { return generateField({ isId: field.isId, name: normalizeCase(dbName || field.name, config), type: ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier(field.type), + ts.factory.createIdentifier( + schemaPrefix + ? `${capitalize(schemaPrefix)}.${field.type}` + : field.type + ), undefined ), nullable: !field.isRequired, diff --git a/src/helpers/generateTypedAliasDeclaration.test.ts b/src/helpers/generateTypedAliasDeclaration.test.ts deleted file mode 100644 index 53224de..0000000 --- a/src/helpers/generateTypedAliasDeclaration.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import ts from "typescript"; -import { expect, test } from "vitest"; - -import { generateTypedAliasDeclaration } from "~/helpers/generateTypedAliasDeclaration"; -import { stringifyTsNode } from "~/utils/testUtils"; - -test("it creates and exports a type alias :D", () => { - const node = generateTypedAliasDeclaration( - "typeOne", - ts.factory.createLiteralTypeNode(ts.factory.createNull()) - ); - const result = stringifyTsNode(node); - - expect(result).toEqual(`export type typeOne = null;`); -}); diff --git a/src/helpers/generateTypedAliasDeclaration.ts b/src/helpers/generateTypedAliasDeclaration.ts deleted file mode 100644 index 2c584ac..0000000 --- a/src/helpers/generateTypedAliasDeclaration.ts +++ /dev/null @@ -1,13 +0,0 @@ -import ts from "typescript"; - -export const generateTypedAliasDeclaration = ( - name: string, - type: ts.TypeNode -) => { - return ts.factory.createTypeAliasDeclaration( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - ts.factory.createIdentifier(name), - undefined, - type - ); -}; diff --git a/src/helpers/generatedEnumType.test.ts b/src/helpers/generatedEnumType.test.ts index 76335bd..5b972d3 100644 --- a/src/helpers/generatedEnumType.test.ts +++ b/src/helpers/generatedEnumType.test.ts @@ -4,10 +4,10 @@ import { expect, test } from "vitest"; import { generateEnumType } from "./generateEnumType"; test("it generates the enum type", () => { - const [objectDeclaration, typeDeclaration] = generateEnumType("Name", [ + const { objectDeclaration, typeDeclaration } = generateEnumType("Name", [ { name: "FOO", dbName: "FOO" }, { name: "BAR", dbName: "BAR" }, - ]); + ])!; const printer = createPrinter(); @@ -25,10 +25,10 @@ export type Name = (typeof Name)[keyof typeof Name];\n`); }); test("it generates the enum type when using Prisma's @map()", () => { - const [objectDeclaration, typeDeclaration] = generateEnumType("Name", [ + const { objectDeclaration, typeDeclaration } = generateEnumType("Name", [ { name: "FOO", dbName: "foo" }, { name: "BAR", dbName: "bar" }, - ]); + ])!; const printer = createPrinter(); diff --git a/src/helpers/multiSchemaHelpers.test.ts b/src/helpers/multiSchemaHelpers.test.ts index 484279e..a8c5564 100644 --- a/src/helpers/multiSchemaHelpers.test.ts +++ b/src/helpers/multiSchemaHelpers.test.ts @@ -1,6 +1,9 @@ import { expect, test } from "vitest"; -import { convertToMultiSchemaModels } from "./multiSchemaHelpers"; +import { + convertToMultiSchemaModels, + parseMultiSchemaMap, +} from "./multiSchemaHelpers"; const testDataModel = `generator kysely { provider = "node ./dist/bin.js" @@ -35,10 +38,33 @@ test("returns a list of models with schemas appended to the table name", () => { { typeName: "Eagle", tableName: "eagles" }, ]; - const result = convertToMultiSchemaModels(initialModels, testDataModel); + const result = convertToMultiSchemaModels( + initialModels, + false, + null, + parseMultiSchemaMap(testDataModel) + ); expect(result).toEqual([ { typeName: "Elephant", tableName: "mammals.elephants" }, { typeName: "Eagle", tableName: "birds.eagles" }, ]); }); + +test("returns a list of models with schemas appended to the table name filtered by schema", () => { + const initialModels = [ + { typeName: "Elephant", tableName: "elephants" }, + { typeName: "Eagle", tableName: "eagles" }, + ]; + + const result = convertToMultiSchemaModels( + initialModels, + false, + new Set(["mammals"]), + parseMultiSchemaMap(testDataModel) + ); + + expect(result).toEqual([ + { typeName: "Elephant", tableName: "mammals.elephants" }, + ]); +}); diff --git a/src/helpers/multiSchemaHelpers.ts b/src/helpers/multiSchemaHelpers.ts index 864842b..bbfbcb3 100644 --- a/src/helpers/multiSchemaHelpers.ts +++ b/src/helpers/multiSchemaHelpers.ts @@ -1,59 +1,112 @@ -import { - type BlockAttribute, - type Model, - getSchema, -} from "@mrleebo/prisma-ast"; +import { type BlockAttribute, getSchema } from "@mrleebo/prisma-ast"; +import ts from "typescript"; + +import { capitalize } from "~/utils/camelCase"; type ModelLike = { typeName: string; - tableName: string; + tableName?: string; + schema?: string; }; /** * Appends schema names to the table names of models. * + * @param models list of model names + * @param multiSchemaMap map of model names to schema names + * @param groupBySchema whether to group models by schema + * @param filterBySchema set of schema names to filter by. Use `null` to disable filtering. + * @returns list of models with schema names appended to the table names ("schema.table") + */ +export const convertToMultiSchemaModels = ( + models: T[], + groupBySchema: boolean, + filterBySchema: Set | null, + multiSchemaMap?: Map +): T[] => { + return models.flatMap((model) => { + const schemaName = multiSchemaMap?.get(model.typeName); + + if (!schemaName) { + return model; + } + + // Filter out models that don't match the schema filter + if (filterBySchema && !filterBySchema.has(schemaName)) { + return []; + } + + return [ + { + ...model, + typeName: + groupBySchema && schemaName + ? `${capitalize(schemaName)}.${model.typeName}` + : model.typeName, + tableName: model.tableName + ? `${schemaName}.${model.tableName}` + : undefined, + schema: groupBySchema ? schemaName : undefined, + }, + ]; + }); +}; + +// https://github.com/microsoft/TypeScript/blob/a53c37d59aa0c20f566dec7e5498f05afe45dc6b/src/compiler/scanner.ts#L985 +const isIdentifierText: ( + name: string, + languageVersion?: ts.ScriptTarget | undefined, + identifierVariant?: ts.LanguageVariant +) => boolean = + // @ts-expect-error - Internal TS API + ts.isIdentifierText; + +/** + * Parses a data model string and returns a map of model names to schema names. + * * Prisma supports multi-schema databases, but currently doens't include the schema name in the DMMT output. * As a workaround, this function parses the schema separately and matches the schema to the model name. * * TODO: Remove this when @prisma/generator-helper exposes schema names in the models by default. * See thread: https://github.com/prisma/prisma/issues/19987 * - * @param models list of model names * @param dataModelStr the full datamodel string (schema.prisma contents) - * @returns list of models with schema names appended to the table names ("schema.table") + * @returns a map of model names to schema names */ -export const convertToMultiSchemaModels = ( - models: T[], - dataModelStr: string -): T[] => { +export function parseMultiSchemaMap(dataModelStr: string) { const parsedSchema = getSchema(dataModelStr); + const multiSchemaMap = new Map(); - const multiSchemaMap = new Map( - parsedSchema.list - .filter((block): block is Model => block.type === "model") - .map((model) => { - const schemaProperty = model.properties.find( - (prop): prop is BlockAttribute => - prop.type === "attribute" && prop.name === "schema" - ); + for (const block of parsedSchema.list) { + if (block.type !== "model" && block.type !== "enum") { + continue; + } - const schemaName = schemaProperty?.args?.[0].value; + const properties = + block.type === "model" ? block.properties : block.enumerators; - if (typeof schemaName !== "string") { - return [model.name, ""]; - } + const schemaProperty = properties.find( + (prop): prop is BlockAttribute => + prop.type === "attribute" && prop.name === "schema" + ); - return [model.name, schemaName.replace(/"/g, "")]; - }) - ); + const schemaName = schemaProperty?.args?.[0].value; - return models.map((model) => { - const schemaName = multiSchemaMap.get(model.typeName); + if (typeof schemaName !== "string") { + multiSchemaMap.set(block.name, ""); + } else { + const schema: string = JSON.parse(schemaName).toString(); - if (!schemaName) { - return model; + if (isIdentifierText && !isIdentifierText(schema)) { + throw new Error( + `Cannot generate identifier for schema "${schema}" in model "${block.name}" because it is not a valid Identifier, please disable \`groupBySchema\` or rename it.` + ); + } + + // don't capitalize it here because the DB key is case-sensitive + multiSchemaMap.set(block.name, schema); } + } - return { ...model, tableName: `${schemaName}.${model.tableName}` }; - }); -}; + return multiSchemaMap; +} diff --git a/src/utils/camelCase.ts b/src/utils/camelCase.ts index 1847703..7704da4 100644 --- a/src/utils/camelCase.ts +++ b/src/utils/camelCase.ts @@ -133,3 +133,7 @@ function memoize(func: StringMapper): StringMapper { return mapped; }; } + +export function capitalize(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/src/utils/normalizeCase.test.ts b/src/utils/normalizeCase.test.ts index 73b48f3..f81fcae 100644 --- a/src/utils/normalizeCase.test.ts +++ b/src/utils/normalizeCase.test.ts @@ -10,6 +10,7 @@ test("converts names to camel case when config value is set", () => { fileName: "", enumFileName: "", readOnlyIds: false, + groupBySchema: false, }); expect(newName).toEqual("userId"); @@ -23,6 +24,7 @@ test("doesn't convert names to camel case when config value isn't set", () => { fileName: "", enumFileName: "", readOnlyIds: false, + groupBySchema: false, }); expect(newName).toEqual("user_id"); diff --git a/src/utils/validateConfig.ts b/src/utils/validateConfig.ts index 47bed45..e83983f 100644 --- a/src/utils/validateConfig.ts +++ b/src/utils/validateConfig.ts @@ -40,12 +40,25 @@ export const configValidator = z // Use GeneratedAlways for IDs instead of Generated readOnlyIds: booleanStringLiteral.default(false), + + // Group models in a namespace by their schema. Cannot be defined if enumFileName is defined. + groupBySchema: booleanStringLiteral.default(false), + + // Group models in a namespace by their schema. Cannot be defined if enumFileName is defined. + filterBySchema: z.array(z.string()).optional(), }) .strict() .transform((config) => { if (!config.enumFileName) { config.enumFileName = config.fileName; } + + if (config.groupBySchema && config.enumFileName !== config.fileName) { + // would require https://www.typescriptlang.org/docs/handbook/namespaces.html#splitting-across-files + // which is considered a bad practice + throw new Error("groupBySchema is not compatible with enumFileName"); + } + return config as Omit & Required>; }); @@ -64,6 +77,7 @@ export const validateConfig = (config: unknown) => { Object.values(parsed.error.flatten().formErrors).forEach((value) => { logger.error(`${value}`); }); + process.exit(1); } return parsed.data;