diff --git a/README.md b/README.md index 657ffd2..831bfc7 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,9 @@ generator zero { // When true, the generator will remap table names to camel case using Zero's `.from()` method. // You can read more about it here https://zero.rocicorp.dev/docs/zero-schema#name-mapping remapTablesToCamelCase = true + // When true, the generator will remap column names (Prisma fields) to camel case using Zero's `.from()` method. + // This follows the same logic as `remapTablesToCamelCase` but applies to columns. + remapColumnsToCamelCase = true // Optional list of Prisma Model names you want to exclude from the generated schema. // Helpful if you want to exclude Views (not supported by Zero) or other tables // you don't want Zero client to have access to diff --git a/src/__tests__/__snapshots__/generator.test.ts.snap b/src/__tests__/__snapshots__/generator.test.ts.snap index 5fb6679..d72aa95 100644 --- a/src/__tests__/__snapshots__/generator.test.ts.snap +++ b/src/__tests__/__snapshots__/generator.test.ts.snap @@ -195,6 +195,47 @@ export type User = Row; " `; +exports[`Generator > Schema Generation > should generate correct schema for model with @map attributes 1`] = ` +"// Generated by Zero Schema Generator + +import { + table, + string, + boolean, + number, + json, + enumeration, + relationships, + createSchema, + type Row, +} from "@rocicorp/zero"; + +// Define tables + +export const messageTable = table("message") + .columns({ + id: string(), + senderID: string().from('sender_id').optional(), + mediumID: string().from('medium_id').optional(), + }) + .primaryKey("id"); + +// Define schema + +export const schema = createSchema( + { + tables: [ + messageTable, + ], + } +); + +// Define types +export type Schema = typeof schema; +export type message = Row; +" +`; + exports[`Generator > Schema Generation > should handle enums as unions correctly 1`] = ` "// Generated by Zero Schema Generator @@ -286,6 +327,45 @@ export type User = Row; " `; +exports[`Generator > Schema Generation > should handle model @@map directive correctly 1`] = ` +"// Generated by Zero Schema Generator + +import { + table, + string, + boolean, + number, + json, + enumeration, + relationships, + createSchema, + type Row, +} from "@rocicorp/zero"; + +// Define tables + +export const cdrTable = table("cdr") + .columns({ + id: string(), + }) + .primaryKey("id"); + +// Define schema + +export const schema = createSchema( + { + tables: [ + cdrTable, + ], + } +); + +// Define types +export type Schema = typeof schema; +export type cdr = Row; +" +`; + exports[`Generator > Schema Generation > should handle relationships correctly 1`] = ` "// Generated by Zero Schema Generator diff --git a/src/__tests__/generator.test.ts b/src/__tests__/generator.test.ts index 63ef3fa..f5c2178 100644 --- a/src/__tests__/generator.test.ts +++ b/src/__tests__/generator.test.ts @@ -189,6 +189,27 @@ describe("Generator", () => { // Verify the output includes .from() calls for mapped columns expect(content).toContain("senderID: string().from('sender_id').optional()"); expect(content).toContain("mediumID: string().from('medium_id').optional()"); + expect(content).toMatchSnapshot(); // Add snapshot assertion for the @map test + }); + + it("should handle model @@map directive correctly", async () => { + // Define the model with @@map + const cdrModel = createModel( + "cdr", // Original Prisma model name + [createField("id", "String", { isId: true })], // Example field + { dbName: "xml_cdr" } // Simulates @@map("xml_cdr") + ); + + // Generate options and run generator + const options = createTestOptions(createMockDMMF([cdrModel])); + await onGenerate(options); + + // Get generated content + const [, contentBuffer] = vi.mocked(fs.writeFile).mock.calls[0]; + const content = contentBuffer.toString(); + + // Assert against snapshot + expect(content).toMatchSnapshot(); }); }); diff --git a/src/__tests__/schemaMapper.test.ts b/src/__tests__/schemaMapper.test.ts index 6358194..a4df375 100644 --- a/src/__tests__/schemaMapper.test.ts +++ b/src/__tests__/schemaMapper.test.ts @@ -9,6 +9,7 @@ describe("Schema Mapper", () => { prettier: false, resolvePrettierConfig: false, remapTablesToCamelCase: false, + remapColumnsToCamelCase: false, // Add default value }; describe("excludeTables", () => { @@ -337,4 +338,209 @@ describe("Schema Mapper", () => { expect(joinTable.columns.B.type).toBe("number()"); } }); +describe("remapColumnsToCamelCase", () => { + const configWithRemap: Config = { + ...baseConfig, + remapColumnsToCamelCase: true, + }; + + it("should remap column names to camel case", () => { + const model = createModel("TestModel", [ + createField("id", "String", { isId: true }), + createField("user_id", "String"), + createField("created_at", "DateTime"), + ]); + const dmmf = createMockDMMF([model]); + const result = transformSchema(dmmf, configWithRemap); + const transformedModel = result.models[0]; + + expect(transformedModel.columns).toHaveProperty("userId"); + expect(transformedModel.columns).toHaveProperty("createdAt"); + expect(transformedModel.columns).not.toHaveProperty("user_id"); + expect(transformedModel.columns).not.toHaveProperty("created_at"); + // Check mappedName is set correctly when remapping occurs without @map + expect(transformedModel.columns.userId.mappedName).toBe("user_id"); + expect(transformedModel.columns.createdAt.mappedName).toBe("created_at"); + }); + + it("should preserve column name if already in camel case", () => { + const model = createModel("TestModel", [ + createField("id", "String", { isId: true }), + createField("userId", "String"), + ]); + const dmmf = createMockDMMF([model]); + const result = transformSchema(dmmf, configWithRemap); + const transformedModel = result.models[0]; + + expect(transformedModel.columns).toHaveProperty("userId"); + // Check mappedName is undefined when no remapping or @map + expect(transformedModel.columns.userId.mappedName).toBeUndefined(); + }); + + it("should handle column names with leading/multiple underscores", () => { + const model = createModel("TestModel", [ + createField("id", "String", { isId: true }), + createField("_internal_field", "String"), + createField("__private_data", "String"), + ]); + const dmmf = createMockDMMF([model]); + const result = transformSchema(dmmf, configWithRemap); + const transformedModel = result.models[0]; + + expect(transformedModel.columns).toHaveProperty("_internalField"); + expect(transformedModel.columns).toHaveProperty("__privateData"); + expect(transformedModel.columns._internalField.mappedName).toBe("_internal_field"); + expect(transformedModel.columns.__privateData.mappedName).toBe("__private_data"); + }); + + it("should handle @map attribute correctly when remapping", () => { + const model = createModel("TestModel", [ + createField("id", "String", { isId: true }), + createField("user_identifier", "String", { dbName: "user_id_in_db" }), // Prisma name differs from DB name + ]); + const dmmf = createMockDMMF([model]); + const result = transformSchema(dmmf, configWithRemap); + const transformedModel = result.models[0]; + + // Key should be camelCase of Prisma name + expect(transformedModel.columns).toHaveProperty("userIdentifier"); + expect(transformedModel.columns).not.toHaveProperty("user_identifier"); + expect(transformedModel.columns).not.toHaveProperty("userIdInDb"); + // mappedName should be the dbName from @map + expect(transformedModel.columns.userIdentifier.mappedName).toBe("user_id_in_db"); + }); + + it("should handle @map attribute when Prisma name is already camelCase", () => { + const model = createModel("TestModel", [ + createField("id", "String", { isId: true }), + createField("userId", "String", { dbName: "user_uuid" }), // Prisma name already camelCase + ]); + const dmmf = createMockDMMF([model]); + const result = transformSchema(dmmf, configWithRemap); + const transformedModel = result.models[0]; + + expect(transformedModel.columns).toHaveProperty("userId"); + // mappedName should still be the dbName from @map + expect(transformedModel.columns.userId.mappedName).toBe("user_uuid"); + }); + + it("should remap primary key fields", () => { + const model = createModel( + "TestModel", + [createField("primary_key_part_1", "String"), createField("primary_key_part_2", "String")], + { primaryKey: { name: null, fields: ["primary_key_part_1", "primary_key_part_2"] } } + ); + const dmmf = createMockDMMF([model]); + const result = transformSchema(dmmf, configWithRemap); + const transformedModel = result.models[0]; + + // Expect the default change-case behavior with mergeAmbiguousCharacters: true + expect(transformedModel.primaryKey).toEqual(["primaryKeyPart_1", "primaryKeyPart_2"]); + expect(transformedModel.columns).toHaveProperty("primaryKeyPart_1"); + expect(transformedModel.columns).toHaveProperty("primaryKeyPart_2"); + }); + + it("should remap foreign key fields in relationships (1:N)", () => { + const userModel = createModel("User", [ + createField("user_id", "String", { isId: true }), + createField("posts", "Post", { isList: true, relationName: "UserPosts" }), + ]); + const postModel = createModel("Post", [ + createField("post_id", "String", { isId: true }), + createField("author_user_id", "String"), // Foreign key + createField("author", "User", { + relationName: "UserPosts", + relationFromFields: ["author_user_id"], + relationToFields: ["user_id"], + }), + ]); + const dmmf = createMockDMMF([userModel, postModel]); + const result = transformSchema(dmmf, configWithRemap); + + const transformedUser = result.models.find(m => m.modelName === "User"); + const transformedPost = result.models.find(m => m.modelName === "Post"); + + expect(transformedUser?.columns).toHaveProperty("userId"); + expect(transformedPost?.columns).toHaveProperty("postId"); + expect(transformedPost?.columns).toHaveProperty("authorUserId"); // FK column remapped + + // Check relationship on User side (many) + const userPostsRel = transformedUser?.relationships?.posts; + expect(userPostsRel?.type).toBe("many"); + if (userPostsRel && "sourceField" in userPostsRel) { + expect(userPostsRel.sourceField).toEqual(["userId"]); // Remapped PK + expect(userPostsRel.destField).toEqual(["authorUserId"]); // Remapped FK + } else { + throw new Error("Unexpected relationship structure for User.posts"); + } + + // Check relationship on Post side (one) + const postAuthorRel = transformedPost?.relationships?.author; + expect(postAuthorRel?.type).toBe("one"); + if (postAuthorRel && "sourceField" in postAuthorRel) { + expect(postAuthorRel.sourceField).toEqual(["authorUserId"]); // Remapped FK + expect(postAuthorRel.destField).toEqual(["userId"]); // Remapped PK + } else { + throw new Error("Unexpected relationship structure for Post.author"); + } + }); + + it("should remap fields in implicit M:N join table relationships", () => { + const postModel = createModel("Post", [ + createField("post_id", "Int", { isId: true }), // Remapped ID + createField("categories", "Category", { + isList: true, + relationName: "PostToCategory", + kind: "object", + }), + ]); + + const categoryModel = createModel("Category", [ + createField("category_id", "Int", { isId: true }), // Remapped ID + createField("posts", "Post", { + isList: true, + relationName: "PostToCategory", + kind: "object", + }), + ]); + + const dmmf = createMockDMMF([postModel, categoryModel]); + const result = transformSchema(dmmf, configWithRemap); + + const joinTable = result.models.find((m) => m.modelName === "_PostToCategory"); + expect(joinTable).toBeDefined(); + + // Check relationships within the join table model + const relA = joinTable?.relationships?.modelA; // Assuming Category comes first alphabetically + const relB = joinTable?.relationships?.modelB; // Assuming Post comes second + + expect(relA?.type).toBe("one"); + expect(relB?.type).toBe("one"); + + if (relA && "destField" in relA) { + expect(relA.destField).toEqual(["categoryId"]); // Should point to remapped ID + } else { + throw new Error("Unexpected relationship structure for joinTable.modelA"); + } + if (relB && "destField" in relB) { + expect(relB.destField).toEqual(["postId"]); // Should point to remapped ID + } else { + throw new Error("Unexpected relationship structure for joinTable.modelB"); + } + + // Also check the chained relationships on the original models + const postCategoriesRel = result.models.find(m => m.modelName === "Post")?.relationships?.categories; + expect(postCategoriesRel?.type).toBe("many"); + if (postCategoriesRel && "chain" in postCategoriesRel) { + expect(postCategoriesRel.chain[0].sourceField).toEqual(["postId"]); // Remapped Post ID + expect(postCategoriesRel.chain[0].destField).toEqual(["B"]); // Join table column + expect(postCategoriesRel.chain[1].sourceField).toEqual(["A"]); // Join table column + expect(postCategoriesRel.chain[1].destField).toEqual(["categoryId"]); // Remapped Category ID + } else { + throw new Error("Unexpected relationship structure for Post.categories"); + } + }); + + }); }); +// End of main describe block diff --git a/src/generator.ts b/src/generator.ts index b945f3d..46bddc9 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -20,6 +20,7 @@ export async function onGenerate(options: GeneratorOptions) { prettier: generator.config.prettier === "true", // Default false, resolvePrettierConfig: generator.config.resolvePrettierConfig !== "false", // Default true remapTablesToCamelCase: generator.config.remapTablesToCamelCase === "true", // Default false + remapColumnsToCamelCase: generator.config.remapColumnsToCamelCase === "true", // Default false excludeTables: loadExcludeTables(generator), enumAsUnion: generator.config.enumAsUnion === "true", } satisfies Config; diff --git a/src/generators/codeGenerator.ts b/src/generators/codeGenerator.ts index ee35a01..3c9b5a2 100644 --- a/src/generators/codeGenerator.ts +++ b/src/generators/codeGenerator.ts @@ -70,7 +70,7 @@ function generateColumnDefinition(name: string, mapping: ZeroTypeMapping): strin } function generateModelSchema(model: ZeroModel): string { - let output = `export const ${model.zeroTableName} = table("${model.tableName}")`; + let output = `export const ${model.zeroTableName} = table("${model.modelName}")`; // Add .from() if we have an original table name if (model.originalTableName) { @@ -176,7 +176,7 @@ function generateSchema(schema: TransformedSchema): string { output += "// Define types\n"; output += "export type Schema = typeof schema;\n"; schema.models.forEach((model) => { - output += `export type ${model.modelName} = Row;\n`; + output += `export type ${model.modelName} = Row;\n`; }); return output; diff --git a/src/mappers/schemaMapper.ts b/src/mappers/schemaMapper.ts index b962935..e90a0f5 100644 --- a/src/mappers/schemaMapper.ts +++ b/src/mappers/schemaMapper.ts @@ -33,13 +33,14 @@ function getImplicitManyToManyTableName( } /** - * Convert a string to camel case, preserving the `_` prefix - * Eg. _my_table -> _myTable + * Convert a string to camel case, preserving the `_` prefix. + * Uses default change-case behavior for ambiguous characters (e.g., _1 -> _1). */ function toCamelCase(str: string): string { const prefixMatch = str.match(/^_+/); const prefix = prefixMatch ? prefixMatch[0] : ""; const rest = str.slice(prefix.length); + // Use default camelCase behavior (no mergeAmbiguousCharacters option) return prefix + camelCase(rest); } @@ -48,58 +49,68 @@ function toCamelCase(str: string): string { * If remapTablesToCamelCase is true, convert the table name to camel case * Eg. issueLabel -> issueLabel */ -function getTableName(tableName: string, config?: Pick): string { - if (config?.remapTablesToCamelCase) { +function getTableName(tableName: string, config: Pick): string { + if (config.remapTablesToCamelCase) { return toCamelCase(tableName); } return tableName; } +/** + * Get the column name for the generated schema. + * If remapColumnsToCamelCase is true, convert the name to camel case. + */ +function getColumnName(fieldName: string, config: Pick): string { + if (config.remapColumnsToCamelCase) { + return toCamelCase(fieldName); + } + return fieldName; +} + +// Accepts the main fieldNameMaps now function createImplicitManyToManyModel( model1: DMMF.Model, model2: DMMF.Model, - relationName?: string, - config?: Config + relationName: string | undefined, + config: Config, + fieldNameMaps: Map> ): ZeroModel { const originalTableName = getImplicitManyToManyTableName(model1.name, model2.name, relationName); const [modelA, modelB] = [model1, model2].sort((a, b) => a.name.localeCompare(b.name)); const tableName = getTableName(originalTableName, config); - // Find the ID fields for modelA and modelB - const idFieldA = modelA.fields.find((f) => f.isId); - const idFieldB = modelB.fields.find((f) => f.isId); + const idFieldA = modelA.fields.find((f: DMMF.Field) => f.isId); + const idFieldB = modelB.fields.find((f: DMMF.Field) => f.isId); - if (!idFieldA) { - throw new Error(`Implicit relation ${relationName}: Model ${modelA.name} has no @id field.`); - } - if (!idFieldB) { - throw new Error(`Implicit relation ${relationName}: Model ${modelB.name} has no @id field.`); - } + if (!idFieldA) { throw new Error(`Implicit relation ${relationName}: Model ${modelA.name} has no @id field.`); } + if (!idFieldB) { throw new Error(`Implicit relation ${relationName}: Model ${modelB.name} has no @id field.`); } - // Map the Prisma types of the ID fields to Zero types const columnAType = mapPrismaTypeToZero(idFieldA); const columnBType = mapPrismaTypeToZero(idFieldB); + const mapA = fieldNameMaps.get(modelA.name) || new Map(); + const mapB = fieldNameMaps.get(modelB.name) || new Map(); + + const remappedIdFieldA = mapA.get(idFieldA.name) || idFieldA.name; + const remappedIdFieldB = mapB.get(idFieldB.name) || idFieldB.name; + return { tableName, originalTableName, modelName: originalTableName, zeroTableName: getZeroTableName(originalTableName), - columns: { - A: columnAType, - B: columnBType, - }, + columns: { A: columnAType, B: columnBType }, relationships: { modelA: { sourceField: ["A"], - destField: [idFieldA.name], + destField: [remappedIdFieldA], destSchema: getZeroTableName(modelA.name), type: "one", }, modelB: { sourceField: ["B"], - destField: [idFieldB.name], + destField: [remappedIdFieldB], destSchema: getZeroTableName(modelB.name), type: "one", }, @@ -108,93 +119,90 @@ function createImplicitManyToManyModel( }; } +// Accepts the main fieldNameMaps collection now function mapRelationships( model: DMMF.Model, dmmf: DMMF.Document, - config: Config + config: Config, + fieldNameMaps: Map> ): Record | undefined { const relationships: Record = {}; - model.fields - .filter((field) => field.relationName) - .forEach((field) => { - const targetModel = dmmf.datamodel.models.find((m) => m.name === field.type); - if (!targetModel) { - throw new Error(`Target model ${field.type} not found for relationship ${field.name}`); - } + const remapFields = (fields: string[], modelName: string): string[] => { + const map = fieldNameMaps.get(modelName) || new Map(); + return fields.map(f => map.get(f) || f); + }; - // Skip the field if the target model is excluded - if (config.excludeTables?.includes(targetModel.name)) { - return; - } + model.fields + .filter((field: DMMF.Field) => field.relationName) + .forEach((field: DMMF.Field) => { + const targetModel = dmmf.datamodel.models.find((m: DMMF.Model) => m.name === field.type); + if (!targetModel) { throw new Error(`Target model ${field.type} not found for relationship ${field.name}`); } + if (config.excludeTables?.includes(targetModel.name)) { return; } const backReference = targetModel.fields.find( - (f) => f.relationName === field.relationName && f.type === model.name + (f: DMMF.Field) => f.relationName === field.relationName && f.type === model.name ); - if (field.isList) { - // For "many" side relationships - if (backReference?.isList) { - // This is a many-to-many relationship - const joinTableName = getImplicitManyToManyTableName( - model.name, - targetModel.name, - field.relationName - ); + if (field.isList) { // MANY side + if (backReference?.isList) { // M:N + const joinTableName = getImplicitManyToManyTableName(model.name, targetModel.name, field.relationName); const [modelA] = [model, targetModel].sort((a, b) => a.name.localeCompare(b.name)); const isModelA = model.name === modelA.name; - // Create a chained relationship through the join table + const sourceIdField = model.fields.find((f: DMMF.Field) => f.isId)?.name || "id"; + const targetIdField = targetModel.fields.find((f: DMMF.Field) => f.isId)?.name || "id"; + + const remappedSourceId = (fieldNameMaps.get(model.name) || new Map()).get(sourceIdField) || sourceIdField; + const remappedTargetId = (fieldNameMaps.get(targetModel.name) || new Map()).get(targetIdField) || targetIdField; + relationships[field.name] = { type: "many", chain: [ { - sourceField: [model.fields.find((f) => f.isId)?.name || "id"], + sourceField: [remappedSourceId], destField: [isModelA ? "A" : "B"], destSchema: getZeroTableName(joinTableName), }, { sourceField: [isModelA ? "B" : "A"], - destField: [targetModel.fields.find((f) => f.isId)?.name || "id"], + destField: [remappedTargetId], destSchema: getZeroTableName(targetModel.name), }, ], }; - } else { - // Regular one-to-many relationship - // Use primaryKey fields first (for @@id), fallback to isId field (for @id) - const idField = model.fields.find((f) => f.isId)?.name; + } else { // 1:N (Current model is Parent, Target is Child) + const idField = model.fields.find((f: DMMF.Field) => f.isId)?.name; const primaryKeyFields = model.primaryKey?.fields || (idField ? [idField] : []); - const sourceFields = ensureStringArray(primaryKeyFields); - const destFields = backReference?.relationFromFields + const originalSourceFields = ensureStringArray(primaryKeyFields); + const originalDestFields = backReference?.relationFromFields ? ensureStringArray(backReference.relationFromFields) : []; relationships[field.name] = { - sourceField: sourceFields, - destField: destFields, + sourceField: remapFields(originalSourceFields, model.name), + destField: remapFields(originalDestFields, targetModel.name), destSchema: getZeroTableName(targetModel.name), type: "many", }; } - } else { - // For "one" side relationships - let sourceFields: string[] = []; - let destFields: string[] = []; + } else { // ONE side (Current model is Child, Target is Parent) + let originalSourceFields: string[] = []; + let originalDestFields: string[] = []; if (field.relationFromFields?.length) { - sourceFields = ensureStringArray(field.relationFromFields); - destFields = field.relationToFields ? ensureStringArray(field.relationToFields) : []; + originalSourceFields = ensureStringArray(field.relationFromFields); + originalDestFields = field.relationToFields ? ensureStringArray(field.relationToFields) : []; } else if (backReference?.relationFromFields?.length) { - sourceFields = backReference.relationToFields + originalSourceFields = backReference.relationToFields ? ensureStringArray(backReference.relationToFields) : []; - destFields = ensureStringArray(backReference.relationFromFields); + originalDestFields = ensureStringArray(backReference.relationFromFields); } relationships[field.name] = { - sourceField: sourceFields, - destField: destFields, + sourceField: remapFields(originalSourceFields, model.name), + destField: remapFields(originalDestFields, targetModel.name), destSchema: getZeroTableName(targetModel.name), type: "one", }; @@ -204,75 +212,95 @@ function mapRelationships( return Object.keys(relationships).length > 0 ? relationships : undefined; } -function mapModel(model: DMMF.Model, dmmf: DMMF.Document, config: Config): ZeroModel { +// Maps a single model and returns its Zero representation + its field name map +function mapModel(model: DMMF.Model, dmmf: DMMF.Document, config: Config): { zeroModel: ZeroModel, fieldNameMap: Map } { const columns: Record> = {}; + const fieldNameMap = new Map(); model.fields - .filter((field) => !field.relationName) - // Filter out list fields as Zero doesn't currently support arrays - // https://zero.rocicorp.dev/docs/postgres-support#column-types - .filter((field) => !field.isList) - .forEach((field) => { - columns[field.name] = mapPrismaTypeToZero(field); + .filter((field: DMMF.Field) => !field.relationName) + .filter((field: DMMF.Field) => !field.isList) + .forEach((field: DMMF.Field) => { + const originalPrismaName = field.name; + const databaseName = field.dbName || originalPrismaName; + const remappedName = getColumnName(originalPrismaName, config); + const mapping = mapPrismaTypeToZero(field); + + if (remappedName !== databaseName) { mapping.mappedName = databaseName; } + columns[remappedName] = mapping; + if (originalPrismaName !== remappedName) { fieldNameMap.set(originalPrismaName, remappedName); } }); - const idField = model.fields.find((f) => f.isId)?.name; - const primaryKey = model.primaryKey?.fields || (idField ? [idField] : []); - if (!primaryKey[0]) { - throw new Error(`No primary key found for ${model.name}`); - } + const originalIdField = model.fields.find((f: DMMF.Field) => f.isId)?.name; + const originalPrimaryKeyFields = model.primaryKey?.fields || (originalIdField ? [originalIdField] : []); - const tableName = getTableNameFromModel(model); - const camelCasedName = config?.remapTablesToCamelCase ? toCamelCase(tableName) : tableName; + const remappedPrimaryKey = ensureStringArray(originalPrimaryKeyFields).map( + (pk) => getColumnName(pk, config) + ); - const shouldRemap = config.remapTablesToCamelCase && camelCasedName !== tableName; + if (!remappedPrimaryKey[0]) { throw new Error(`No primary key found or mapped for ${model.name}`); } - return { - tableName: shouldRemap ? camelCasedName : tableName, - originalTableName: shouldRemap ? tableName : undefined, + const originalTableName = getTableNameFromModel(model); + const remappedTableName = getTableName(originalTableName, config); + const shouldRemapTable = config.remapTablesToCamelCase && remappedTableName !== originalTableName; + + const zeroModel: ZeroModel = { + tableName: remappedTableName, + originalTableName: shouldRemapTable ? originalTableName : undefined, modelName: model.name, zeroTableName: getZeroTableName(model.name), columns, - relationships: mapRelationships(model, dmmf, config), - primaryKey: ensureStringArray(primaryKey), + relationships: undefined, + primaryKey: remappedPrimaryKey, }; + + return { zeroModel, fieldNameMap }; } export function transformSchema( dmmf: DMMF.Document, config: Config ): TransformedSchema { - // Filter out excluded models - const filteredModels = dmmf.datamodel.models.filter(model => { + const filteredModels = dmmf.datamodel.models.filter((model: DMMF.Model) => { return !config.excludeTables?.includes(model.name); }); - const models = filteredModels.map((model) => mapModel(model, dmmf, config)); + // Step 1: Map models and collect fieldNameMaps + const mappedModelData = filteredModels.map((model: DMMF.Model) => mapModel(model, dmmf, config)); + const models: ZeroModel[] = mappedModelData.map((data: { zeroModel: ZeroModel; fieldNameMap: Map }) => data.zeroModel); + const fieldNameMaps = new Map>( + filteredModels.map((model: DMMF.Model, i: number) => [model.name, mappedModelData[i].fieldNameMap]) + ); + + // Step 2: Map relationships, passing the *entire* fieldNameMaps collection + models.forEach((zeroModel: ZeroModel) => { + const originalModel = dmmf.datamodel.models.find((m: DMMF.Model) => m.name === zeroModel.modelName); + if (originalModel) { + zeroModel.relationships = mapRelationships(originalModel, dmmf, config, fieldNameMaps); + } + }); - // Add implicit many-to-many join tables (but don't include them in the final schema) - const implicitJoinTables = filteredModels.flatMap((model) => { + // Step 3: Add implicit many-to-many join tables + const implicitJoinTables = filteredModels.flatMap((model: DMMF.Model) => { return model.fields - .filter((field) => field.relationName && field.isList) - .map((field) => { - const targetModel = dmmf.datamodel.models.find((m) => m.name === field.type); + .filter((field: DMMF.Field) => field.relationName && field.isList) + .map((field: DMMF.Field) => { + const targetModel = dmmf.datamodel.models.find((m: DMMF.Model) => m.name === field.type); if (!targetModel) return null; - - // Skip if either model is excluded if (config.excludeTables?.includes(targetModel.name)) return null; const backReference = targetModel.fields.find( - (f) => f.relationName === field.relationName && f.type === model.name + (f: DMMF.Field) => f.relationName === field.relationName && f.type === model.name ); if (backReference?.isList) { - // Only create the join table once for each relationship if (model.name.localeCompare(targetModel.name) < 0) { - return createImplicitManyToManyModel(model, targetModel, field.relationName, config); + return createImplicitManyToManyModel(model, targetModel, field.relationName, config, fieldNameMaps); } } return null; }) - .filter((table): table is ZeroModel => table !== null); + .filter((table: ZeroModel | null): table is ZeroModel => table !== null); }); return { diff --git a/src/types.ts b/src/types.ts index 8051dbe..919d67d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,7 @@ export type Config = { prettier: boolean; resolvePrettierConfig: boolean; remapTablesToCamelCase: boolean; + remapColumnsToCamelCase: boolean; // Added for column remapping excludeTables?: string[]; enumAsUnion?: boolean; };