From 894b6416e5e1a03d9361fc9dfd0df0f7f0f93699 Mon Sep 17 00:00:00 2001 From: "ivan.wang" Date: Tue, 11 Aug 2020 01:28:26 +1000 Subject: [PATCH] Support abstract class codegen. The idea is to use the generated classes as abstract classes. Custom domain behaviors (i.e. methods, validations, and lifecycles) may be located in another class that inherits the generated model. This could benefit us from 1. avoiding polluting when running codegen after migration on existing codes. 1. have lean models without the boilerplates. 1. share common behaviors across multiple models. 3 new flags: - `extendAbstractClass` - string. Similar to `activeRecord` that `extends BaseEntity`. Now it supports custom abstract class inheritance by passing a relative path as string to `extendAbstractClass`. - `exportAbstractClass` - boolean. Export the generated models as an abstract class without `@Entity` - `skipRelationships` - boolean. Skip generating relationship columns. Because currently there is a [bug from TypeORM failing at foreign key embedded columns in abstract classes](https://github.com/typeorm/typeorm/issues/3132). Close: https://github.com/Kononnable/typeorm-model-generator/issues/288 --- src/IGenerationOptions.ts | 6 + src/ModelCustomization.ts | 12 ++ src/ModelGeneration.ts | 2 +- src/index.ts | 91 +++++++++++++- src/models/Entity.ts | 4 + src/templates/entity.mst | 13 +- .../modelCustomization.test.ts | 112 ++++++++++++++++++ 7 files changed, 232 insertions(+), 8 deletions(-) diff --git a/src/IGenerationOptions.ts b/src/IGenerationOptions.ts index 13bb2d80..fb687b60 100644 --- a/src/IGenerationOptions.ts +++ b/src/IGenerationOptions.ts @@ -16,6 +16,8 @@ export default interface IGenerationOptions { propertyVisibility: "public" | "protected" | "private" | "none"; lazy: boolean; activeRecord: boolean; + skipRelationships: boolean; + extendAbstractClass: string; generateConstructor: boolean; customNamingStrategyPath: string; relationIds: boolean; @@ -23,6 +25,7 @@ export default interface IGenerationOptions { skipSchema: boolean; indexFile: boolean; exportType: "named" | "default"; + exportAbstractClass: boolean; } export const eolConverter = { @@ -42,6 +45,8 @@ export function getDefaultGenerationOptions(): IGenerationOptions { propertyVisibility: "none", lazy: false, activeRecord: false, + skipRelationships: false, + extendAbstractClass: "", generateConstructor: false, customNamingStrategyPath: "", relationIds: false, @@ -49,6 +54,7 @@ export function getDefaultGenerationOptions(): IGenerationOptions { skipSchema: false, indexFile: false, exportType: "named", + exportAbstractClass: false, }; return generationOptions; } diff --git a/src/ModelCustomization.ts b/src/ModelCustomization.ts index 5973f022..cd99e7de 100644 --- a/src/ModelCustomization.ts +++ b/src/ModelCustomization.ts @@ -219,9 +219,21 @@ function addImportsAndGenerationOptions( if (generationOptions.activeRecord) { entity.activeRecord = true; } + if (generationOptions.skipRelationships) { + entity.skipRelationships = true; + } + if (generationOptions.extendAbstractClass) { + entity.extendAbstractClass = generationOptions.extendAbstractClass; + } if (generationOptions.generateConstructor) { entity.generateConstructor = true; } + if (generationOptions.exportAbstractClass) { + entity.exportAbstractClass = true; + } + entity.generateSuper = !!( + entity.activeRecord || entity.extendAbstractClass + ); }); return dbModel; } diff --git a/src/ModelGeneration.ts b/src/ModelGeneration.ts index beb96e66..80afbe37 100644 --- a/src/ModelGeneration.ts +++ b/src/ModelGeneration.ts @@ -231,7 +231,7 @@ function createHandlebarsHelpers(generationOptions: IGenerationOptions): void { } ); Handlebars.registerHelper("defaultExport", () => - generationOptions.exportType === "default" ? "default" : "" + generationOptions.exportType === "default" ? " default" : "" ); Handlebars.registerHelper("localImport", (entityName: string) => generationOptions.exportType === "default" diff --git a/src/index.ts b/src/index.ts index 573b6352..51299adb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,15 @@ function validateConfig(options: options): options { false ); options.generationOptions.relationIds = false; + } else if ( + options.generationOptions.activeRecord && + options.generationOptions.extendAbstractClass + ) { + TomgUtils.LogError( + "Typeorm cannot use ActiveRecord and extend-abstract-class at the same time.", + false + ); + options.generationOptions.activeRecord = false; } return options; } @@ -238,6 +247,24 @@ function checkYargsParameters(options: options): options { default: options.generationOptions.activeRecord, describe: "Use ActiveRecord syntax for generated models", }, + skipRelationships: { + alias: "skip-relationships", + boolean: true, + default: options.generationOptions.skipRelationships, + describe: "Skip relationship declarations", + }, + extendAbstractClass: { + alias: "extend-abstract-class", + string: true, + default: options.generationOptions.extendAbstractClass, + describe: "Make generated models extend a custom abstract class", + }, + exportAbstractClass: { + alias: "export-abstract-class", + boolean: true, + default: options.generationOptions.exportAbstractClass, + describe: "Export generated models as abstract classes", + }, namingStrategy: { describe: "Use custom naming strategy", default: options.generationOptions.customNamingStrategyPath, @@ -307,6 +334,8 @@ function checkYargsParameters(options: options): options { } options.connectionOptions.skipTables = skipTables; options.generationOptions.activeRecord = argv.a; + options.generationOptions.skipRelationships = argv.skipRelationships; + options.generationOptions.extendAbstractClass = argv.extendAbstractClass; options.generationOptions.generateConstructor = argv.generateConstructor; options.generationOptions.convertCaseEntity = argv.ce as IGenerationOptions["convertCaseEntity"]; options.generationOptions.convertCaseFile = argv.cf as IGenerationOptions["convertCaseFile"]; @@ -325,7 +354,7 @@ function checkYargsParameters(options: options): options { options.generationOptions.exportType = argv.defaultExport ? "default" : "named"; - + options.generationOptions.exportAbstractClass = argv.exportAbstractClass; return options; } @@ -499,6 +528,19 @@ async function useInquirer(options: options): Promise { value: "activeRecord", checked: options.generationOptions.activeRecord, }, + { + name: "Skip relationship declarations", + value: "skipRelationships", + checked: + options.generationOptions.skipRelationships, + }, + { + name: + "Generated models extend a custom abstract class", + value: "extendAbstractClass", + checked: + options.generationOptions.extendAbstractClass, + }, { name: "Use custom naming strategy", value: "namingStrategy", @@ -558,6 +600,12 @@ async function useInquirer(options: options): Promise { options.generationOptions.exportType === "default", }, + { + name: "Export generated models as abstract classes", + value: "exportAbstractClass", + checked: + options.generationOptions.exportAbstractClass, + }, ], message: "Available customizations", name: "selected", @@ -601,6 +649,44 @@ async function useInquirer(options: options): Promise { options.generationOptions.activeRecord = customizations.includes( "activeRecord" ); + options.generationOptions.skipRelationships = customizations.includes( + "skipRelationships" + ); + if (customizations.includes("extendAbstractClass")) { + const { extendAbstractClass } = await inquirer.prompt([ + { + default: options.generationOptions.extendAbstractClass, + message: "Relative path to custom abstract class file:", + name: "extendAbstractClass", + type: "input", + validate(value) { + const valid = value === "" || fs.existsSync(value); + return ( + valid || + "Please enter a valid relative path to custom abstract class file" + ); + }, + }, + ]); + + if (extendAbstractClass && extendAbstractClass !== "") { + const resultsAbsolutePath = path.join( + process.cwd(), + options.generationOptions.resultsPath + ); + const abstractClassAbsolutePath = path.join( + process.cwd(), + options.generationOptions.resultsPath + ); + const relativePath = path.relative( + abstractClassAbsolutePath, + resultsAbsolutePath + ); + options.generationOptions.extendAbstractClass = relativePath; + } else { + options.generationOptions.extendAbstractClass = ""; + } + } options.generationOptions.relationIds = customizations.includes( "relationId" ); @@ -616,6 +702,9 @@ async function useInquirer(options: options): Promise { ) ? "default" : "named"; + options.generationOptions.exportAbstractClass = customizations.includes( + "exportAbstractClass" + ); if (customizations.includes("namingStrategy")) { const namingStrategyPath = ( diff --git a/src/models/Entity.ts b/src/models/Entity.ts index 8c189f9b..e714d52d 100644 --- a/src/models/Entity.ts +++ b/src/models/Entity.ts @@ -17,5 +17,9 @@ export type Entity = { // TODO: move to sub-object or use handlebars helpers(?) fileImports: string[]; activeRecord?: true; + skipRelationships?: boolean; + extendAbstractClass?: string; + generateSuper?: boolean; generateConstructor?: true; + exportAbstractClass?: true; }; diff --git a/src/templates/entity.mst b/src/templates/entity.mst index c9b57e89..ddcc31da 100644 --- a/src/templates/entity.mst +++ b/src/templates/entity.mst @@ -26,22 +26,23 @@ import {{localImport (toEntityName .)}} from './{{toFileName .}}' {{/inline}} {{#*inline "Constructor"}} {{printPropertyVisibility}}constructor(init?: Partial<{{toEntityName entityName}}>) { - {{#activeRecord}}super(); - {{/activeRecord}}Object.assign(this, init); + {{#generateSuper}}super(); + {{/generateSuper}}Object.assign(this, init); } {{/inline}} {{#*inline "Entity"}} {{#indices}}{{> Index}}{{/indices~}} -@Entity("{{sqlName}}"{{#schema}} ,{schema:"{{.}}"{{#if ../database}}, database:"{{../database}}"{{/if}} } {{/schema}}) -export {{defaultExport}} class {{toEntityName tscName}}{{#activeRecord}} extends BaseEntity{{/activeRecord}} { +{{#unless exportAbstractClass}}@Entity("{{sqlName}}"{{#schema}} ,{schema:"{{.}}"{{#if ../database}}, database:"{{../database}}"{{/if}} } {{/schema}}){{/unless}} +export{{defaultExport}}{{#exportAbstractClass}} abstract{{/exportAbstractClass}} class {{toEntityName tscName}}{{#activeRecord}} extends BaseEntity{{/activeRecord}}{{#extendAbstractClass}} extends BaseClass{{/extendAbstractClass}} { {{#columns}}{{> Column}}{{/columns~}} -{{#relations}}{{> Relation}}{{/relations~}} +{{#unless skipRelationships}}{{#relations}}{{> Relation}}{{/relations~}}{{/unless}} {{#relationIds}}{{> RelationId entityName=../tscName}}{{/relationIds~}} {{#if generateConstructor}}{{>Constructor entityName=tscName}}{{/if~}} } {{/inline}} import {BaseEntity,Column,Entity,Index,JoinColumn,JoinTable,ManyToMany,ManyToOne,OneToMany,OneToOne,PrimaryColumn,PrimaryGeneratedColumn,RelationId} from "typeorm"; -{{#fileImports}}{{> Import}}{{/fileImports}} +{{#unless skipRelationships}}{{#fileImports}}{{> Import}}{{/fileImports}}{{/unless}} +{{#extendAbstractClass}}import BaseClass from "{{.}}";{{/extendAbstractClass}} {{> Entity}} diff --git a/test/modelCustomization/modelCustomization.test.ts b/test/modelCustomization/modelCustomization.test.ts index 8443f1f5..52cc9eeb 100644 --- a/test/modelCustomization/modelCustomization.test.ts +++ b/test/modelCustomization/modelCustomization.test.ts @@ -451,6 +451,118 @@ describe("Model customization phase", async () => { compileGeneratedModel(generationOptions.resultsPath, [""]); }); + it("skipRelationships", async () => { + const data = generateSampleData(); + const generationOptions = generateGenerationOptions(); + clearGenerationDir(); + + generationOptions.skipRelationships = true; + const customizedModel = modelCustomizationPhase( + data, + generationOptions, + {} + ); + modelGenerationPhase( + getDefaultConnectionOptions(), + generationOptions, + customizedModel + ); + const filesGenPath = path.resolve(resultsPath, "entities"); + const postContent = fs + .readFileSync(path.resolve(filesGenPath, "Post.ts")) + .toString(); + const postAuthorContent = fs + .readFileSync(path.resolve(filesGenPath, "PostAuthor.ts")) + .toString(); + expect(postContent).to.not.have.string( + `JoinColumn`, + ); + expect(postContent).to.not.have.string( + `import { PostAuthor } from "./PostAuthor";`, + ); + expect(postAuthorContent).to.not.have.string( + `OneToMany`, + ); + expect(postAuthorContent).to.not.have.string( + `import { Post } from "./Post";`, + ); + + compileGeneratedModel(generationOptions.resultsPath, [""]); + }); + it("extendAbstractClass", async () => { + const data = generateSampleData(); + const generationOptions = generateGenerationOptions(); + clearGenerationDir(); + + generationOptions.extendAbstractClass = '../../test/integration/examples/sample28-abstract-class-inheritance/BaseClass'; + const customizedModel = modelCustomizationPhase( + data, + generationOptions, + {} + ); + modelGenerationPhase( + getDefaultConnectionOptions(), + generationOptions, + customizedModel + ); + const filesGenPath = path.resolve(resultsPath, "entities"); + const postContent = fs + .readFileSync(path.resolve(filesGenPath, "Post.ts")) + .toString(); + const postAuthorContent = fs + .readFileSync(path.resolve(filesGenPath, "PostAuthor.ts")) + .toString(); + expect(postContent).to.have.string( + `export class Post extends BaseClass ` + ); + expect(postAuthorContent).to.have.string( + `export class PostAuthor extends BaseClass ` + ); + expect(postContent).to.have.string( + `import BaseClass from "../../test/integration/examples/sample28-abstract-class-inheritance/BaseClass"` + ); + expect(postAuthorContent).to.have.string( + `import BaseClass from "../../test/integration/examples/sample28-abstract-class-inheritance/BaseClass"` + ); + }); + it("exportAbstractClass", async () => { + const data = generateSampleData(); + const generationOptions = generateGenerationOptions(); + clearGenerationDir(); + + generationOptions.exportAbstractClass = true; + const customizedModel = modelCustomizationPhase( + data, + generationOptions, + {} + ); + modelGenerationPhase( + getDefaultConnectionOptions(), + generationOptions, + customizedModel + ); + const filesGenPath = path.resolve(resultsPath, "entities"); + const postContent = fs + .readFileSync(path.resolve(filesGenPath, "Post.ts")) + .toString(); + const postAuthorContent = fs + .readFileSync(path.resolve(filesGenPath, "PostAuthor.ts")) + .toString(); + expect(postContent).to.have.string( + `export abstract class Post ` + ); + expect(postAuthorContent).to.have.string( + `export abstract class PostAuthor ` + ); + expect(postContent).to.not.have.string( + `@Entity` + ); + expect(postAuthorContent).to.not.have.string( + `@Entity` + ); + + compileGeneratedModel(generationOptions.resultsPath, [""]); + }); it("skipSchema", async () => { const data = generateSampleData(); const generationOptions = generateGenerationOptions();