From e481efaf50e4a007f39bcbf528c6706e45ee45ff Mon Sep 17 00:00:00 2001 From: elv1s Date: Tue, 23 Apr 2019 14:45:59 -0700 Subject: [PATCH] feat(cli): add `lb4 relation` command --- docs/site/Relation-generator.md | 109 +++ packages/cli/README.md | 31 +- .../relation/base-relation.generator.js | 209 +++++ .../relation/belongs-to-relation.generator.js | 161 ++++ .../relation/has-many-relation.generator.js | 205 +++++ packages/cli/generators/relation/index.js | 412 ++++++++++ ...roller-relation-template-belongs-to.ts.ejs | 37 + ...ntroller-relation-template-has-many.ts.ejs | 92 +++ .../generators/relation/utils.generator.js | 240 ++++++ packages/cli/lib/cli.js | 4 + packages/cli/package-lock.json | 77 ++ packages/cli/package.json | 1 + .../controllers/customer.controller.ts | 1 + .../fixtures/relation/controllers/index.ts | 1 + .../fixtures/relation/controllers/index4.ts | 1 + .../controllers/order-customer.controller.ts | 1 + packages/cli/test/fixtures/relation/index.js | 625 +++++++++++++++ .../models/customer-class-type.model.ts | 19 + .../relation/models/customer-class.model.ts | 19 + .../relation/models/customer.model.ts | 20 + .../relation/models/customer5.model.ts | 22 + .../fixtures/relation/models/nokey.model.ts | 19 + .../relation/models/order-class-type.model.ts | 19 + .../relation/models/order-class.model.ts | 19 + .../fixtures/relation/models/order.model.ts | 20 + .../customer-class-type.repository.ts | 13 + .../repositories/customer-class.repository.ts | 13 + .../repositories/customer.repository.ts | 13 + .../order-class-type.repository.ts | 13 + .../repositories/order-class.repository.ts | 13 + .../relation/repositories/order.repository.ts | 13 + .../test/integration/cli/cli.integration.js | 4 +- .../belongsto.relation.integration.js | 486 ++++++++++++ .../hasmany.relation.integration.js | 711 ++++++++++++++++++ .../generators/relation.integration.js | 282 +++++++ .../test/integration/lib/base-generator.js | 2 + 36 files changed, 3923 insertions(+), 4 deletions(-) create mode 100644 docs/site/Relation-generator.md create mode 100644 packages/cli/generators/relation/base-relation.generator.js create mode 100644 packages/cli/generators/relation/belongs-to-relation.generator.js create mode 100644 packages/cli/generators/relation/has-many-relation.generator.js create mode 100644 packages/cli/generators/relation/index.js create mode 100644 packages/cli/generators/relation/templates/controller-relation-template-belongs-to.ts.ejs create mode 100644 packages/cli/generators/relation/templates/controller-relation-template-has-many.ts.ejs create mode 100644 packages/cli/generators/relation/utils.generator.js create mode 100644 packages/cli/test/fixtures/relation/controllers/customer.controller.ts create mode 100644 packages/cli/test/fixtures/relation/controllers/index.ts create mode 100644 packages/cli/test/fixtures/relation/controllers/index4.ts create mode 100644 packages/cli/test/fixtures/relation/controllers/order-customer.controller.ts create mode 100644 packages/cli/test/fixtures/relation/index.js create mode 100644 packages/cli/test/fixtures/relation/models/customer-class-type.model.ts create mode 100644 packages/cli/test/fixtures/relation/models/customer-class.model.ts create mode 100644 packages/cli/test/fixtures/relation/models/customer.model.ts create mode 100644 packages/cli/test/fixtures/relation/models/customer5.model.ts create mode 100644 packages/cli/test/fixtures/relation/models/nokey.model.ts create mode 100644 packages/cli/test/fixtures/relation/models/order-class-type.model.ts create mode 100644 packages/cli/test/fixtures/relation/models/order-class.model.ts create mode 100644 packages/cli/test/fixtures/relation/models/order.model.ts create mode 100644 packages/cli/test/fixtures/relation/repositories/customer-class-type.repository.ts create mode 100644 packages/cli/test/fixtures/relation/repositories/customer-class.repository.ts create mode 100644 packages/cli/test/fixtures/relation/repositories/customer.repository.ts create mode 100644 packages/cli/test/fixtures/relation/repositories/order-class-type.repository.ts create mode 100644 packages/cli/test/fixtures/relation/repositories/order-class.repository.ts create mode 100644 packages/cli/test/fixtures/relation/repositories/order.repository.ts create mode 100644 packages/cli/test/integration/generators/belongsto.relation.integration.js create mode 100644 packages/cli/test/integration/generators/hasmany.relation.integration.js create mode 100644 packages/cli/test/integration/generators/relation.integration.js diff --git a/docs/site/Relation-generator.md b/docs/site/Relation-generator.md new file mode 100644 index 000000000000..e5c07ed9f091 --- /dev/null +++ b/docs/site/Relation-generator.md @@ -0,0 +1,109 @@ +--- +lang: en +title: 'Relation generator' +keywords: LoopBack 4.0, LoopBack 4 +sidebar: lb4_sidebar +permalink: /doc/en/lb4/Relation-generator.html +--- + +### Prerequisites + +Important: Before running this generator, make sure the models, datasource, and +repositories involved in this relation exist. Then, inside your LoopBack +application, run the command from the root directory. + +{% include content/generator-create-app.html lang=page.lang %} + +### Synopsis + +Adds a new `Relation` between existing source and target models in a LoopBack +application. + +```sh +lb4 relation [options] +``` + +### Options + +- `-h`, `--help`: Print the generator’s options and usage. +- `--skip-cache`: Do not remember prompt answers. Default: `false`. +- `--skip-install`: Do not automatically install dependencies. Default: `false`. +- `--force-install`: Fail on install dependencies error. Default: `false`. +- `--relationType`: Relation type. +- `--sourceModel`: Source model. +- `--destinationModel`: Destination model. +- `--foreignKeyName`: Destination model foreign key name. +- `--relationName`: Relation name. +- `-c`, `--config`: JSON file name or value to configure options. +- `-y`, `--yes`: Skip all confirmation prompts with default or provided value. +- `--format`: Format generated code using `npm run lint:fix`. + +### Arguments + +Defining lb4 relation in one command line interface (cli): + +```sh +lb4 relation --sourceModel= +--destinationModel= --foreignKeyName= +--relationType= [--relationName=] [--format] +``` + +- `` - Type of the relation that will be created between the + source and target models. + +- `` - Name of the model to create the relationship from. + +- `` - Name of the model to create a relationship with. + +- `` - Property that references the primary key property of the + destination model. + +- `` - Name of the relation that will be created. + +### Interactive Prompts + +The tool will prompt you for: + +- **Relation `type` between models.** _(relationBaseClass)_ Prompts a list of + available relations to choose from as the type of the relation between the + source model and the target model. Supported relation types: + + - [HasMany](HasMany-relation.md) + - [BelongsTo](BelongsTo-relation.md) + +- **Name of the `source` model.** _(sourceModel)_ Prompts a list of available + models to choose from as the source model of the relation. + +- **Name of the `target` model.** _(targetModel)_ Prompts a list of available + models to choose from as the target model of the relation. + +- **Name of the `Source property`.** _(relationName)_ Prompts for the Source + property name. Note: Leave blank to use the default. + + Default values: + + - `` for `belongsTo` relations, e.g. + `categoryId` + - plural form of `` for `hasMany` relations, e.g. `products` + +- **Name of Foreign key** _(foreignKeyName)_ to be created in target model. For + hasMany relation type only, default: ``. + Note: Leave blank to use the default. + +### Output + +Once all the prompts have been answered, the CLI will update or create source +files for the Entities involved in the relation. + +- Update source Model class as follows: + `/src/models/${sourceModel-Name}.model.ts` +- Update target Model class as follows: + `/src/models/${targetModel-Name}.model.ts` +- Update source Model Repository class as follows: + `/src/repositories/${sourceModel-Repository-Name}.repository.ts` +- Update target Model Repository class as follows: + `/src/repositories/${targetModel-Repository-Name}.repository.ts` +- Create a Controller for the new relation as follows: + `/src/controllers/{sourceModel-Name}-{targetModel-Name}.controller.ts` +- Update `/src/controllers/index.ts` to export the newly created Controller + class. diff --git a/packages/cli/README.md b/packages/cli/README.md index f9b3a040f281..6ca2aea37909 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -304,7 +304,33 @@ Run the following command to install the CLI. name # Name for the discover Type: String Required: false ``` -12. To list available commands +12. To generate relation into your application + + ```sh + cd + lb4 relation + ``` + + ```sh + Usage: + lb4 relation [options] + + Options: + -h, --help # Print the generator's options and usage + --skip-cache # Do not remember prompt answers Default: false + --skip-install # Do not automatically install dependencies Default: false + --force-install # Fail on install dependencies error Default: false + --relationType # Relation type + --sourceModel # Source model + --destinationModel # Destination model + --foreignKeyName # Destination model foreign key name + --relationName # Relation name + -c, --config # JSON file name or value to configure options + -y, --yes # Skip all confirmation prompts with default or provided value + --format # Format generated code using npm run lint:fix + ``` + +13. To list available commands `lb4 --commands` (or `lb4 -l`) @@ -319,11 +345,12 @@ Run the following command to install the CLI. lb4 service lb4 example lb4 openapi + lb4 relation ``` Please note `lb4 --help` also prints out available commands. -13. To print out version information +14. To print out version information `lb4 --version` (or `lb4 -v`) diff --git a/packages/cli/generators/relation/base-relation.generator.js b/packages/cli/generators/relation/base-relation.generator.js new file mode 100644 index 000000000000..0ef4f21ddbb3 --- /dev/null +++ b/packages/cli/generators/relation/base-relation.generator.js @@ -0,0 +1,209 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +const ast = require('ts-morph'); +const ArtifactGenerator = require('../../lib/artifact-generator'); +const path = require('path'); +const relationUtils = require('./utils.generator'); +const utils = require('../../lib/utils'); + +module.exports = class BaseRelationGenerator extends ArtifactGenerator { + constructor(args, opts) { + super(args, opts); + } + + _setupGenerator() { + this.artifactInfo = { + type: 'relation', + rootDir: utils.sourceRootDir, + }; + + this.artifactInfo.outDir = path.resolve( + this.artifactInfo.rootDir, + 'controllers', + ); + this.artifactInfo.modelDir = path.resolve( + this.artifactInfo.rootDir, + 'models', + ); + this.artifactInfo.repositoryDir = path.resolve( + this.artifactInfo.rootDir, + 'repositories', + ); + + super._setupGenerator(); + } + + async generateAll(options) { + this._setupGenerator(); + await this.generateControllers(options); + this._setupGenerator(); + await this.generateModels(options); + this._setupGenerator(); + await this.generateRepositories(options); + } + + generateControllers(options) { + /* istanbul ignore next */ + throw new Error('Not implemented'); + } + + generateModels(options) { + /* istanbul ignore next */ + throw new Error('Not implemented'); + } + + async generateRepositories(options) { + this._initializeProperties(options); + this._addImportsToRepository(options); + this._addPropertyToRepository(options); + const classDeclaration = relationUtils.getClassObj( + this.artifactInfo.srcRepositoryFileObj, + this.artifactInfo.srcRepositoryClassName, + ); + const classConstructor = relationUtils.getClassConstructor( + classDeclaration, + ); + this._addParametersToRepositoryConstructor(classConstructor); + this._addCreatorToRepositoryConstructor(classConstructor); + await this.artifactInfo.srcRepositoryFileObj.save(); + } + + _addImportsToRepository(options) { + const imports = this._getRepositoryRequiredImports( + options.destinationModel, + this.artifactInfo.dstRepositoryClassName, + ); + + relationUtils.addRequiredImports( + this.artifactInfo.srcRepositoryFileObj, + imports, + ); + } + + _addPropertyToRepository(options) { + const classDeclaration = this.artifactInfo.srcRepositoryFileObj.getClassOrThrow( + this.artifactInfo.srcRepositoryClassName, + ); + + const property = { + scope: ast.Scope.Public, + isReadonly: true, + name: this._getRepositoryRelationPropertyName(), + type: this._getRepositoryRelationPropertyType(), + }; + + if (relationUtils.doesPropertyExist(classDeclaration, property.name)) { + throw new Error( + 'property ' + property.name + ' already exist in the repository.', + ); + } else { + relationUtils.addProperty(classDeclaration, property); + } + } + + _addParametersToRepositoryConstructor(classConstructor) { + const parameterName = + utils.camelCase(this.artifactInfo.dstRepositoryClassName) + 'Getter'; + + if (relationUtils.doesParameterExist(classConstructor, parameterName)) { + throw new Error( + 'Parameter ' + parameterName + ' already exist in the constructor.', + ); + } + + classConstructor.addParameter({ + decorators: [ + { + name: 'repository.getter', + arguments: ["'" + this.artifactInfo.dstRepositoryClassName + "'"], + }, + ], + name: parameterName, + type: 'Getter<' + this.artifactInfo.dstRepositoryClassName + '>,', + scope: ast.Scope.Protected, + }); + } + + _addCreatorToRepositoryConstructor(classConstructor) { + /* istanbul ignore next */ + throw new Error('Not implemented'); + } + + _initializeProperties(options) { + // src configuration. + this.artifactInfo.srcModelPrimaryKey = options.sourceModelPrimaryKey; + this.artifactInfo.srcModelFile = path.resolve( + this.artifactInfo.modelDir, + utils.getModelFileName(options.sourceModel), + ); + + this.artifactInfo.srcModelClass = options.sourceModel; + + this.artifactInfo.srcRepositoryFile = path.resolve( + this.artifactInfo.repositoryDir, + utils.getRepositoryFileName(options.sourceModel), + ); + + this.artifactInfo.srcRepositoryClassName = + utils.toClassName(options.sourceModel) + 'Repository'; + + this.artifactInfo.srcRepositoryFileObj = new relationUtils.AstLoopBackProject().addExistingSourceFile( + this.artifactInfo.srcRepositoryFile, + ); + + // dst configuration + this.artifactInfo.dstModelFile = path.resolve( + this.artifactInfo.modelDir, + utils.getModelFileName(options.destinationModel), + ); + + this.artifactInfo.dstModelClass = options.destinationModel; + + this.artifactInfo.dstRepositoryFile = path.resolve( + this.artifactInfo.repositoryDir, + utils.getRepositoryFileName(options.destinationModel), + ); + + this.artifactInfo.dstRepositoryClassName = + utils.toClassName(options.destinationModel) + 'Repository'; + + // relation configuration + this.artifactInfo.relationName = options.relationName; + } + + _getRepositoryRequiredImports(dstModelClassName, dstRepositoryClassName) { + return [ + { + name: dstModelClassName, + module: '../models', + }, + { + name: 'repository', + module: '@loopback/repository', + }, + { + name: 'Getter', + module: '@loopback/core', + }, + { + name: dstRepositoryClassName, + module: `./${utils.kebabCase(dstModelClassName)}.repository`, + }, + ]; + } + + _getRepositoryRelationPropertyName() { + /* istanbul ignore next */ + throw new Error('Not implemented'); + } + + _getRepositoryRelationPropertyType() { + /* istanbul ignore next */ + throw new Error('Not implemented'); + } +}; diff --git a/packages/cli/generators/relation/belongs-to-relation.generator.js b/packages/cli/generators/relation/belongs-to-relation.generator.js new file mode 100644 index 000000000000..17c000f370d5 --- /dev/null +++ b/packages/cli/generators/relation/belongs-to-relation.generator.js @@ -0,0 +1,161 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +const path = require('path'); +const BaseRelationGenerator = require('./base-relation.generator'); +const utils = require('../../lib/utils'); +const relationUtils = require('./utils.generator'); + +const CONTROLLER_TEMPLATE_PATH_BELONGS_TO = + 'controller-relation-template-belongs-to.ts.ejs'; + +module.exports = class BelongsToRelationGenerator extends BaseRelationGenerator { + constructor(args, opts) { + super(args, opts); + } + + async generateControllers(options) { + this.artifactInfo.sourceModelPrimaryKey = options.sourceModelPrimaryKey; + this.artifactInfo.sourceModelPrimaryKeyType = + options.sourceModelPrimaryKeyType; + this.artifactInfo.sourceModelClassName = options.sourceModel; + this.artifactInfo.targetModelClassName = options.destinationModel; + this.artifactInfo.paramTargetModel = utils.camelCase( + options.destinationModel, + ); + this.artifactInfo.sourceRepositoryClassName = + this.artifactInfo.sourceModelClassName + 'Repository'; + this.artifactInfo.controllerClassName = + this.artifactInfo.sourceModelClassName + + this.artifactInfo.targetModelClassName + + 'Controller'; + + this.artifactInfo.paramSourceRepository = utils.camelCase( + this.artifactInfo.sourceModelClassName + 'Repository', + ); + + this.artifactInfo.sourceModelName = utils.kebabCase(options.sourceModel); + this.artifactInfo.sourceModelPath = utils.pluralize( + this.artifactInfo.sourceModelName, + ); + this.artifactInfo.targetModelName = utils.kebabCase( + options.destinationModel, + ); + + this.artifactInfo.relationPropertyName = options.relationName; + this.artifactInfo.targetModelPrimaryKey = + options.destinationModelPrimaryKey; + this.artifactInfo.targetModelPrimaryKeyType = + options.destinationModelPrimaryKeyType; + + const source = this.templatePath(CONTROLLER_TEMPLATE_PATH_BELONGS_TO); + + this.artifactInfo.name = + options.sourceModel + '-' + options.destinationModel; + this.artifactInfo.outFile = + utils.kebabCase(this.artifactInfo.name) + '.controller.ts'; + + const dest = this.destinationPath( + path.join(this.artifactInfo.outDir, this.artifactInfo.outFile), + ); + + this.copyTemplatedFiles(source, dest, this.artifactInfo); + await relationUtils.addExportController( + this, + path.resolve(this.artifactInfo.outDir, 'index.ts'), + this.artifactInfo.controllerClassName, + utils.kebabCase(this.artifactInfo.name) + '.controller', + ); + } + + async generateModels(options) { + const modelDir = this.artifactInfo.modelDir; + const sourceModel = options.sourceModel; + const sourceModelFile = path.resolve( + modelDir, + utils.getModelFileName(sourceModel), + ); + + const targetModel = options.destinationModel; + const relationType = options.relationType; + const relationName = options.relationName; + const fktype = options.destinationModelPrimaryKeyType; + + const project = new relationUtils.AstLoopBackProject(); + const sourceFile = relationUtils.addFileToProject( + project, + modelDir, + sourceModel, + ); + const sourceClass = relationUtils.getClassObj(sourceFile, sourceModel); + + relationUtils.doesRelationExist(sourceClass, relationName); + + const modelProperty = this.getBelongsTo(targetModel, relationName, fktype); + + relationUtils.addProperty(sourceClass, modelProperty); + const imports = relationUtils.getRequiredImports(targetModel, relationType); + relationUtils.addRequiredImports(sourceFile, imports); + + sourceClass.formatText(); + await sourceFile.save(); + } + + getBelongsTo(className, relationName, fktype) { + return { + decorators: [{name: 'belongsTo', arguments: ['() => ' + className]}], + name: relationName, + type: fktype, + }; + } + + _getRepositoryRequiredImports(dstModelClassName, dstRepositoryClassName) { + let importsArray = super._getRepositoryRequiredImports( + dstModelClassName, + dstRepositoryClassName, + ); + importsArray.push({ + name: 'BelongsToAccessor', + module: '@loopback/repository', + }); + return importsArray; + } + + _getRepositoryRelationPropertyName() { + return utils.camelCase(this.artifactInfo.dstModelClass); + } + + _initializeProperties(options) { + super._initializeProperties(options); + this.artifactInfo.dstModelPrimaryKey = options.destinationModelPrimaryKey; + } + + _getRepositoryRelationPropertyType() { + return ( + 'BelongsToAccessor<' + + utils.toClassName(this.artifactInfo.dstModelClass) + + ', typeof ' + + utils.toClassName(this.artifactInfo.srcModelClass) + + '.prototype.' + + this.artifactInfo.srcModelPrimaryKey + + '>' + ); + } + + _addCreatorToRepositoryConstructor(classConstructor) { + const statement = + 'this.' + + this._getRepositoryRelationPropertyName() + + ' = ' + + "this.createBelongsToAccessorFor('" + + this.artifactInfo.relationName.replace(/Id$/, '') + + "', " + + utils.camelCase(this.artifactInfo.dstRepositoryClassName) + + 'Getter,);'; + classConstructor.insertStatements(1, statement); + } +}; diff --git a/packages/cli/generators/relation/has-many-relation.generator.js b/packages/cli/generators/relation/has-many-relation.generator.js new file mode 100644 index 000000000000..7d4ce336c337 --- /dev/null +++ b/packages/cli/generators/relation/has-many-relation.generator.js @@ -0,0 +1,205 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +const path = require('path'); +const BaseRelationGenerator = require('./base-relation.generator'); +const relationUtils = require('./utils.generator'); +const utils = require('../../lib/utils'); + +const CONTROLLER_TEMPLATE_PATH_HAS_MANY = + 'controller-relation-template-has-many.ts.ejs'; + +module.exports = class HasManyRelationGenerator extends BaseRelationGenerator { + constructor(args, opts) { + super(args, opts); + } + + async generateControllers(options) { + this.artifactInfo.sourceModelClassName = options.sourceModel; + this.artifactInfo.targetModelClassName = options.destinationModel; + this.artifactInfo.sourceRepositoryClassName = + this.artifactInfo.sourceModelClassName + 'Repository'; + this.artifactInfo.controllerClassName = + this.artifactInfo.sourceModelClassName + + this.artifactInfo.targetModelClassName + + 'Controller'; + this.artifactInfo.paramSourceRepository = utils.camelCase( + this.artifactInfo.sourceModelClassName + 'Repository', + ); + + this.artifactInfo.sourceModelName = utils.kebabCase(options.sourceModel); + this.artifactInfo.sourceModelPath = utils.pluralize( + this.artifactInfo.sourceModelName, + ); + this.artifactInfo.targetModelName = utils.kebabCase( + options.destinationModel, + ); + this.artifactInfo.targetModelPath = utils.pluralize( + this.artifactInfo.targetModelName, + ); + this.artifactInfo.targetModelRequestBody = utils.camelCase( + this.artifactInfo.targetModelName, + ); + this.artifactInfo.relationPropertyName = utils.pluralize( + utils.camelCase(options.destinationModel), + ); + this.artifactInfo.sourceModelPrimaryKey = options.sourceModelPrimaryKey; + this.artifactInfo.sourceModelPrimaryKeyType = + options.sourceModelPrimaryKeyType; + + const source = this.templatePath(CONTROLLER_TEMPLATE_PATH_HAS_MANY); + + this.artifactInfo.name = + options.sourceModel + '-' + options.destinationModel; + this.artifactInfo.outFile = + utils.kebabCase(this.artifactInfo.name) + '.controller.ts'; + + const dest = this.destinationPath( + path.join(this.artifactInfo.outDir, this.artifactInfo.outFile), + ); + + this.copyTemplatedFiles(source, dest, this.artifactInfo); + await relationUtils.addExportController( + this, + path.resolve(this.artifactInfo.outDir, 'index.ts'), + this.artifactInfo.controllerClassName, + utils.kebabCase(this.artifactInfo.name) + '.controller', + ); + } + + async generateModels(options) { + const modelDir = this.artifactInfo.modelDir; + const sourceModel = options.sourceModel; + const sourceModelFile = path.resolve( + modelDir, + utils.getModelFileName(sourceModel), + ); + + const targetModel = options.destinationModel; + const targetModelFile = path.resolve( + modelDir, + utils.getModelFileName(targetModel), + ); + + const relationType = options.relationType; + const relationName = options.relationName; + const fktype = options.sourceModelPrimaryKeyType; + const isForeignKeyExist = options.doesForeignKeyExist; + const foreignKeyName = options.foreignKeyName; + + const isDefaultForeignKey = + foreignKeyName === utils.camelCase(options.sourceModel) + 'Id'; + + let modelProperty; + const project = new relationUtils.AstLoopBackProject(); + + const sourceFile = relationUtils.addFileToProject( + project, + modelDir, + sourceModel, + ); + const sourceClass = relationUtils.getClassObj(sourceFile, sourceModel); + relationUtils.doesRelationExist(sourceClass, relationName); + + modelProperty = this.getHasMany( + targetModel, + relationName, + isDefaultForeignKey, + foreignKeyName, + ); + + relationUtils.addProperty(sourceClass, modelProperty); + const imports = relationUtils.getRequiredImports(targetModel, relationType); + + relationUtils.addRequiredImports(sourceFile, imports); + await sourceFile.save(); + + const targetFile = relationUtils.addFileToProject( + project, + modelDir, + targetModel, + ); + const targetClass = relationUtils.getClassObj(targetFile, targetModel); + + if (isForeignKeyExist) { + if ( + !relationUtils.isValidPropertyType(targetClass, foreignKeyName, fktype) + ) { + throw new Error('foreignKey Type Error'); + } + } else { + modelProperty = relationUtils.addForeignKey(foreignKeyName, fktype); + relationUtils.addProperty(targetClass, modelProperty); + targetClass.formatText(); + await targetFile.save(); + } + } + + getHasMany(className, relationName, isDefaultForeignKey, foreignKeyName) { + let relationDecorator = [ + { + name: 'hasMany', + arguments: [ + '() => ' + className + " ,{keyTo: '" + foreignKeyName + "'}", + ], + }, + ]; + if (isDefaultForeignKey) { + relationDecorator = [ + {name: 'hasMany', arguments: ['() => ' + className]}, + ]; + } + + return { + decorators: relationDecorator, + name: relationName, + type: className + '[]', + }; + } + + _getRepositoryRequiredImports(dstModelClassName, dstRepositoryClassName) { + let importsArray = super._getRepositoryRequiredImports( + dstModelClassName, + dstRepositoryClassName, + ); + importsArray.push({ + name: 'HasManyRepositoryFactory', + module: '@loopback/repository', + }); + return importsArray; + } + + _getRepositoryRelationPropertyName() { + return utils.pluralize(utils.camelCase(this.artifactInfo.dstModelClass)); + } + + _getRepositoryRelationPropertyType() { + return ( + 'HasManyRepositoryFactory<' + + utils.toClassName(this.artifactInfo.dstModelClass) + + ', typeof ' + + utils.toClassName(this.artifactInfo.srcModelClass) + + '.prototype.' + + this.artifactInfo.srcModelPrimaryKey + + '>' + ); + } + + _addCreatorToRepositoryConstructor(classConstructor) { + const relationPropertyName = this._getRepositoryRelationPropertyName(); + const statement = + 'this.' + + relationPropertyName + + ' = ' + + "this.createHasManyRepositoryFactoryFor('" + + relationPropertyName + + "', " + + utils.camelCase(this.artifactInfo.dstRepositoryClassName) + + 'Getter,);'; + classConstructor.insertStatements(1, statement); + } +}; diff --git a/packages/cli/generators/relation/index.js b/packages/cli/generators/relation/index.js new file mode 100644 index 000000000000..c4915d921fd8 --- /dev/null +++ b/packages/cli/generators/relation/index.js @@ -0,0 +1,412 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +const _ = require('lodash'); +const ArtifactGenerator = require('../../lib/artifact-generator'); +const debug = require('../../lib/debug')('relation-generator'); +const inspect = require('util').inspect; +const path = require('path'); +const chalk = require('chalk'); +const utils = require('../../lib/utils'); +const relationUtils = require('./utils.generator'); + +const BelongsToRelationGenerator = require('./belongs-to-relation.generator'); +const HasManyRelationGenerator = require('./has-many-relation.generator'); + +const ERROR_INCORRECT_RELATION_TYPE = 'Incorrect relation type'; +const ERROR_MODEL_DOES_NOT_EXIST = 'model does not exist.'; +const ERROR_NO_MODELS_FOUND = 'No models found in'; +const ERROR_SOURCE_MODEL_PRIMARY_KEY_DOES_NOT_EXIST = + 'Source model primary key does not exist.'; +const ERROR_DESTINATION_MODEL_PRIMARY_KEY_DOES_NOT_EXIST = + 'Target model primary key does not exist.'; + +const PROMPT_BASE_RELATION_CLASS = 'Please select the relation type'; +const PROMPT_MESSAGE_SOURCE_MODEL = 'Please select source model'; +const PROMPT_MESSAGE_TARGET_MODEL = 'Please select target model'; +const PROMPT_MESSAGE_PROPERTY_NAME = + 'Source property name for the relation getter'; +const PROMPT_MESSAGE_FOREIGN_KEY_NAME = + 'Foreign key name to define on the target model'; + +module.exports = class RelationGenerator extends ArtifactGenerator { + constructor(args, opts) { + super(args, opts); + this.args = args; + this.opts = opts; + } + + setOptions() { + return super.setOptions(); + } + + _setupGenerator() { + this.option('relationType', { + type: String, + required: false, + description: 'Relation type', + }); + + this.option('sourceModel', { + type: String, + required: false, + description: 'Source model', + }); + + this.option('destinationModel', { + type: String, + required: false, + description: 'Destination model', + }); + + this.option('foreignKeyName', { + type: String, + required: false, + description: 'Destination model foreign key name', + }); + + this.option('relationName', { + type: String, + required: false, + description: 'Relation name', + }); + this.artifactInfo = { + type: 'relation', + rootDir: utils.sourceRootDir, + outDir: utils.sourceRootDir, + }; + this.artifactInfo.modelDir = path.resolve( + this.artifactInfo.rootDir, + utils.modelsDir, + ); + + super._setupGenerator(); + this._arguments = []; + + this.isChecked = { + relationType: false, + sourceModel: false, + destinationModel: false, + }; + } + + checkLoopBackProject() { + if (this.shouldExit()) return; + return super.checkLoopBackProject(); + } + + _getDefaultRelationName() { + let defaultRelationName; + switch (this.artifactInfo.relationType) { + case relationUtils.relationType.belongsTo: + defaultRelationName = + utils.camelCase(this.artifactInfo.destinationModel) + + utils.toClassName(this.artifactInfo.destinationModelPrimaryKey); + break; + case relationUtils.relationType.hasMany: + defaultRelationName = utils.pluralize( + utils.camelCase(this.artifactInfo.destinationModel), + ); + break; + } + + return defaultRelationName; + } + + async _promptModelList(message, parameter) { + let modelList; + try { + debug(`model list dir ${this.artifactInfo.modelDir}`); + modelList = await utils.getArtifactList( + this.artifactInfo.modelDir, + 'model', + ); + } catch (err) { + return this.exit(err); + } + + if (modelList.length === 0) { + return this.exit( + new Error( + `${ERROR_NO_MODELS_FOUND} ${this.artifactInfo.modelDir}. + ${chalk.yellow( + 'Please visit https://loopback.io/doc/en/lb4/Model-generator.html for information on how models are discovered', + )}`, + ), + ); + } + + if (this.options[parameter]) { + this.isChecked[parameter] = true; + if (!modelList.includes(this.options[parameter])) { + return this.exit( + new Error( + `"${this.options[parameter]}" ${ERROR_MODEL_DOES_NOT_EXIST}`, + ), + ); + } + + debug( + `${parameter} received from command line: ${this.options[parameter]}`, + ); + this.artifactInfo[parameter] = this.options[parameter]; + } + + // Prompt a user for model. + return this.prompt([ + { + type: 'list', + name: parameter, + message: message, + choices: modelList, + when: !this.artifactInfo[parameter], + default: modelList[0], + }, + ]).then(props => { + if (this.isChecked[parameter]) return; + if (!modelList.includes(props[parameter])) { + this.exit( + new Error(`"${props[parameter]}" ${ERROR_MODEL_DOES_NOT_EXIST}`), + ); + } + + debug(`props after ${parameter} prompt: ${inspect(props)}`); + Object.assign(this.artifactInfo, props); + return props; + }); + } + + // Prompt a user for Relation type + async promptRelationType() { + if (this.shouldExit()) return false; + const relationTypeChoices = Object.keys(relationUtils.relationType); + + if (this.options.relationType) { + this.isChecked.relationType = true; + debug( + `Relation type received from command line: ${ + this.options.relationType + }`, + ); + if (!relationTypeChoices.includes(this.options.relationType)) { + return this.exit(new Error(ERROR_INCORRECT_RELATION_TYPE)); + } + + this.artifactInfo.relationType = this.options.relationType; + } + + return this.prompt([ + { + type: 'list', + name: 'relationType', + message: PROMPT_BASE_RELATION_CLASS, + choices: relationTypeChoices, + when: !this.artifactInfo.relationType, + validate: utils.validateClassName, + default: relationTypeChoices[0], + }, + ]).then(props => { + if (this.isChecked.relationType) return; + if (!relationTypeChoices.includes(props.relationType)) { + this.exit(new Error(ERROR_INCORRECT_RELATION_TYPE)); + } + Object.assign(this.artifactInfo, props); + debug(`props after relation type prompt: ${inspect(props)}`); + return props; + }); + } + + // Get model list for source model. + async promptSourceModels() { + if (this.shouldExit()) return false; + + return await this._promptModelList( + PROMPT_MESSAGE_SOURCE_MODEL, + 'sourceModel', + ); + } + + // Get model list for target model. + async promptTargetModels() { + if (this.shouldExit()) return false; + + return await this._promptModelList( + PROMPT_MESSAGE_TARGET_MODEL, + 'destinationModel', + ); + } + + /** + * Prompt foreign key if not exist: + * 1. From source model get primary key. If primary key does not exist - + * error. + * 2. Get primary key type from source model. + * 3. Generate foreign key (camelCase source class Name + primary key name). + * 4. Check is foreign key exist in destination model. If not - prompt. + * Error - if type is not the same. + */ + async promptForeignKey() { + if (this.shouldExit()) return false; + + this.artifactInfo.sourceModelPrimaryKey = await relationUtils.getModelPrimaryKeyProperty( + this.fs, + this.artifactInfo.modelDir, + this.artifactInfo.sourceModel, + ); + + if (this.artifactInfo.sourceModelPrimaryKey) { + this.artifactInfo.sourceModelPrimaryKeyType = relationUtils.getModelPropertyType( + this.artifactInfo.modelDir, + this.artifactInfo.sourceModel, + this.artifactInfo.sourceModelPrimaryKey, + ); + } + + if ( + this.artifactInfo.relationType === relationUtils.relationType.belongsTo + ) { + return; + } + + if (this.artifactInfo.sourceModelPrimaryKey == null) { + return this.exit( + new Error(ERROR_SOURCE_MODEL_PRIMARY_KEY_DOES_NOT_EXIST), + ); + } + + this.artifactInfo.defaultForeignKeyName = + utils.camelCase(this.artifactInfo.sourceModel) + + utils.toClassName(this.artifactInfo.sourceModelPrimaryKey); + + const project = new relationUtils.AstLoopBackProject(); + + const destinationFile = path.join( + this.artifactInfo.modelDir, + utils.getModelFileName(this.artifactInfo.destinationModel), + ); + const df = project.addExistingSourceFile(destinationFile); + const cl = relationUtils.getClassObj( + df, + this.artifactInfo.destinationModel, + ); + this.artifactInfo.doesForeignKeyExist = relationUtils.doesPropertyExist( + cl, + this.artifactInfo.defaultForeignKeyName, + ); + + if (!this.artifactInfo.doesForeignKeyExist) { + if (this.options.foreignKeyName) { + debug( + `Foreign key name received from command line: ${ + this.options.foreignKeyName + }`, + ); + this.artifactInfo.foreignKeyName = this.options.foreignKeyName; + } + + return this.prompt([ + { + type: 'string', + name: 'foreignKeyName', + message: PROMPT_MESSAGE_FOREIGN_KEY_NAME, + default: this.artifactInfo.defaultForeignKeyName, + when: this.artifactInfo.foreignKeyName === undefined, + }, + ]).then(props => { + debug(`props after foreign key name prompt: ${inspect(props)}`); + Object.assign(this.artifactInfo, props); + this.artifactInfo.doesForeignKeyExist = relationUtils.doesPropertyExist( + cl, + this.artifactInfo.foreignKeyName, + ); + + return props; + }); + } else { + this.artifactInfo.foreignKeyName = this.artifactInfo.defaultForeignKeyName; + } + } + + async promptRelationName() { + if (this.shouldExit()) return false; + if ( + this.artifactInfo.relationType === relationUtils.relationType.belongsTo + ) { + this.artifactInfo.destinationModelPrimaryKey = await relationUtils.getModelPrimaryKeyProperty( + this.fs, + this.artifactInfo.modelDir, + this.artifactInfo.destinationModel, + ); + if (this.artifactInfo.destinationModelPrimaryKey == null) { + return this.exit( + new Error(ERROR_DESTINATION_MODEL_PRIMARY_KEY_DOES_NOT_EXIST), + ); + } + + this.artifactInfo.destinationModelPrimaryKeyType = relationUtils.getModelPropertyType( + this.artifactInfo.modelDir, + this.artifactInfo.destinationModel, + this.artifactInfo.destinationModelPrimaryKey, + ); + } + + if (this.options.relationName) { + debug( + `Relation name received from command line: ${ + this.options.relationName + }`, + ); + this.artifactInfo.relationName = this.options.relationName; + } + + return this.prompt([ + { + type: 'string', + name: 'relationName', + message: PROMPT_MESSAGE_PROPERTY_NAME, + when: this.artifactInfo.relationName === undefined, + default: this._getDefaultRelationName(), + }, + ]).then(props => { + debug(`props after relation name prompt: ${inspect(props)}`); + Object.assign(this.artifactInfo, props); + return props; + }); + } + + async scaffold() { + if (this.shouldExit()) return false; + + debug('Invoke generator...'); + + let relationGenerator; + + this.artifactInfo.name = this.artifactInfo.relationType; + + switch (this.artifactInfo.relationType) { + case relationUtils.relationType.belongsTo: + relationGenerator = new BelongsToRelationGenerator( + this.args, + this.opts, + ); + break; + case relationUtils.relationType.hasMany: + relationGenerator = new HasManyRelationGenerator(this.args, this.opts); + break; + } + + try { + await relationGenerator.generateAll(this.artifactInfo); + } catch (error) { + this.exit(error); + } + } + + async end() { + await super.end(); + } +}; diff --git a/packages/cli/generators/relation/templates/controller-relation-template-belongs-to.ts.ejs b/packages/cli/generators/relation/templates/controller-relation-template-belongs-to.ts.ejs new file mode 100644 index 000000000000..fd843be1a69d --- /dev/null +++ b/packages/cli/generators/relation/templates/controller-relation-template-belongs-to.ts.ejs @@ -0,0 +1,37 @@ +import { + repository, +} from '@loopback/repository'; +import { + param, + get, +} from '@loopback/rest'; +import { + <%= sourceModelClassName %>, + <%= targetModelClassName %>, +} from '../models'; +import {<%= sourceRepositoryClassName %>} from '../repositories'; + +export class <%= controllerClassName %> { + constructor( + @repository(<%= sourceRepositoryClassName %>) + public <%= paramSourceRepository %>: <%= sourceRepositoryClassName %>, + ) { } + + @get('/<%= sourceModelPath %>/{id}/<%= targetModelName %>', { + responses: { + '200': { + description: '<%= targetModelClassName %> belonging to <%= sourceModelClassName %>', + content: { + 'application/json': { + schema: { type: 'array', items: { 'x-ts-type': <%= targetModelClassName %> } }, + }, + }, + }, + }, + }) + async get<%= targetModelClassName %>( + @param.path.<%= sourceModelPrimaryKeyType %>('id') id: typeof <%= sourceModelClassName %>.prototype.<%= sourceModelPrimaryKey %>, + ): Promise<<%= targetModelClassName %>> { + return await this.<%= paramSourceRepository %>.<%= paramTargetModel %>(id); + } +} diff --git a/packages/cli/generators/relation/templates/controller-relation-template-has-many.ts.ejs b/packages/cli/generators/relation/templates/controller-relation-template-has-many.ts.ejs new file mode 100644 index 000000000000..8cf9235c9a63 --- /dev/null +++ b/packages/cli/generators/relation/templates/controller-relation-template-has-many.ts.ejs @@ -0,0 +1,92 @@ +import { + Count, + CountSchema, + Filter, + repository, + Where, +} from '@loopback/repository'; +import { + del, + get, + getWhereSchemaFor, + param, + patch, + post, + requestBody, +} from '@loopback/rest'; +import { + <%= sourceModelClassName %>, + <%= targetModelClassName %>, +} from '../models'; +import {<%= sourceRepositoryClassName %>} from '../repositories'; + +export class <%= controllerClassName %> { + constructor( + @repository(<%= sourceRepositoryClassName %>) protected <%= paramSourceRepository %>: <%= sourceRepositoryClassName %>, + ) { } + + @get('/<%= sourceModelPath %>/{id}/<%= targetModelPath %>', { + responses: { + '200': { + description: 'Array of <%= targetModelClassName %>\'s belonging to <%= sourceModelClassName %>', + content: { + 'application/json': { + schema: { type: 'array', items: { 'x-ts-type': <%= targetModelClassName %> } }, + }, + }, + }, + }, + }) + async find( + @param.path.<%= sourceModelPrimaryKeyType %>('id') id: <%= sourceModelPrimaryKeyType %>, + @param.query.object('filter') filter?: Filter, + ): Promise<<%= targetModelClassName %>[]> { + return await this.<%= paramSourceRepository %>.<%= relationPropertyName %>(id).find(filter); + } + + @post('/<%= sourceModelPath %>/{id}/<%= targetModelPath %>', { + responses: { + '200': { + description: '<%= sourceModelClassName %> model instance', + content: { 'application/json': { schema: { 'x-ts-type': <%= targetModelClassName %> } } }, + }, + }, + }) + async create( + @param.path.<%= sourceModelPrimaryKeyType %>('id') id: typeof <%= sourceModelClassName %>.prototype.<%= sourceModelPrimaryKey %>, + @requestBody() <%= targetModelRequestBody %>: <%= targetModelClassName %>, + ): Promise<<%= targetModelClassName %>> { + return await this.<%= paramSourceRepository %>.<%= relationPropertyName %>(id).create(<%= targetModelRequestBody %>); + } + + @patch('/<%= sourceModelPath %>/{id}/<%= targetModelPath %>', { + responses: { + '200': { + description: '<%= sourceModelClassName %>.<%= targetModelClassName %> PATCH success count', + content: { 'application/json': { schema: CountSchema } }, + }, + }, + }) + async patch( + @param.path.<%= sourceModelPrimaryKeyType %>('id') id: <%= sourceModelPrimaryKeyType %>, + @requestBody() <%= targetModelRequestBody %>: Partial<<%= targetModelClassName %>>, + @param.query.object('where', getWhereSchemaFor(<%= targetModelClassName %>)) where?: Where, + ): Promise { + return await this.<%= paramSourceRepository %>.<%= relationPropertyName %>(id).patch(<%= targetModelRequestBody %>, where); + } + + @del('/<%= sourceModelPath %>/{id}/<%= targetModelPath %>', { + responses: { + '200': { + description: '<%= sourceModelClassName %>.<%= targetModelClassName %> DELETE success count', + content: { 'application/json': { schema: CountSchema } }, + }, + }, + }) + async delete( + @param.path.<%= sourceModelPrimaryKeyType %>('id') id: <%= sourceModelPrimaryKeyType %>, + @param.query.object('where', getWhereSchemaFor(<%= targetModelClassName %>)) where?: Where, + ): Promise { + return await this.<%= paramSourceRepository %>.<%= relationPropertyName %>(id).delete(where); + } +} diff --git a/packages/cli/generators/relation/utils.generator.js b/packages/cli/generators/relation/utils.generator.js new file mode 100644 index 000000000000..02e300356222 --- /dev/null +++ b/packages/cli/generators/relation/utils.generator.js @@ -0,0 +1,240 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +const ast = require('ts-morph'); +const path = require('path'); +const tsquery = require('../../lib/ast-helper'); +const utils = require('../../lib/utils'); + +exports.relationType = { + belongsTo: 'belongsTo', + hasMany: 'hasMany', +}; + +class AstLoopBackProject extends ast.Project { + constructor() { + super({ + manipulationSettings: { + indentationText: ast.IndentationText.TwoSpaces, + insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces: false, + newLineKind: ast.NewLineKind.LineFeed, + quoteKind: ast.QuoteKind.Single, + }, + }); + } +} +exports.AstLoopBackProject = AstLoopBackProject; + +exports.getModelPrimaryKeyProperty = async function(fs, modelDir, modelName) { + const modelFile = path.join(modelDir, utils.getModelFileName(modelName)); + + const fileContent = await fs.read(modelFile, {}); + return tsquery.getIdFromModel(fileContent); +}; + +exports.getModelPropertyType = function(modelDir, modelName, propertyName) { + const project = new this.AstLoopBackProject(); + + const modelFile = path.join(modelDir, utils.getModelFileName(modelName)); + const sf = project.addExistingSourceFile(modelFile); + const co = this.getClassObj(sf, modelName); + return this.getPropertyType(co, propertyName); +}; + +exports.addFileToProject = function(project, dir, modelName) { + const fileName = path.resolve(dir, utils.getModelFileName(modelName)); + return project.addExistingSourceFile(fileName); +}; + +exports.getClassObj = function(fileName, modelName) { + return fileName.getClassOrThrow(modelName); +}; + +exports.getClassConstructor = function(classObj) { + return classObj.getConstructors()[0]; +}; + +exports.addExportController = async function( + generator, + fileName, + controllerClassName, + controllerFileName, +) { + const project = new this.AstLoopBackProject(); + let pFile; + const exportDeclaration = { + moduleSpecifier: './' + controllerFileName, + }; + + if (generator.fs.exists(fileName)) { + pFile = project.addExistingSourceFile(fileName); + for (const declaration of pFile.getExportedDeclarations()) { + if ( + ast.TypeGuards.isClassDeclaration(declaration) && + controllerClassName === declaration.getName() + ) { + return; + } + } + pFile.addExportDeclaration(exportDeclaration); + } else { + pFile = project.createSourceFile(fileName, { + exports: [exportDeclaration], + }); + } + + await pFile.save(); +}; + +/** + * Validate if property exist in class. + * + * @param {classObj} + * @param {propertyName} string + * + * @return bool true on success, false on failure. + */ + +exports.doesPropertyExist = function(classObj, propertyName) { + return classObj + .getProperties() + .map(x => x.getName()) + .includes(propertyName); +}; + +exports.doesRelationExist = function(classObj, propertyName) { + if (this.doesPropertyExist(classObj, propertyName)) { + throw new Error( + 'property ' + + propertyName + + ' already exist in the model ' + + classObj.getName(), + ); + } +}; + +/** + * Get property type in class. + * + * @param {classObj} + * @param {propertyName} string + * + * @return string + */ + +exports.getPropertyType = function(classObj, propertyName) { + return classObj + .getProperty(propertyName) + .getType() + .getText(); +}; + +/** + * Validate if property with specific type exist in class. + * + * @param {classObj} + * @param {propertyName} string + * @param {propertyType} string + * + * @return bool true on success, false on failure. + */ + +exports.isValidPropertyType = function(classObj, propertyName, propertyType) { + return this.getPropertyType(classObj, propertyName) == propertyType; +}; + +exports.doesParameterExist = function(classConstructor, parameterName) { + return classConstructor + .getParameters() + .map(x => x.getName()) + .includes(parameterName); +}; + +exports.addForeignKey = function(foreignKey, sourceModelPrimaryKeyType) { + return { + decorators: [ + { + name: 'property', + arguments: ["{\n type : '" + sourceModelPrimaryKeyType + "',\n}"], + }, + ], + name: foreignKey + '?', + type: sourceModelPrimaryKeyType, + }; +}; + +exports.addProperty = function(classOBj, property) { + classOBj.insertProperty(this.getPropertiesCount(classOBj), property); + classOBj.insertText(this.getPropertyStartPos(classOBj), '\n'); +}; + +exports.getPropertiesCount = function(classObj) { + return classObj.getProperties().length; +}; + +exports.getPropertyStartPos = function(classObj) { + return classObj + .getChildSyntaxList() + .getChildAtIndex(this.getPropertiesCount(classObj) - 1) + .getPos(); +}; + +exports.addRequiredImports = function(sourceFile, imports) { + for (let currentImport of imports) { + this.addCurrentImport(sourceFile, currentImport); + } +}; + +exports.getRequiredImports = function(targetModel, relationType) { + return [ + { + name: targetModel, + module: './' + utils.kebabCase(targetModel) + '.model', + }, + { + name: relationType, + module: '@loopback/repository', + }, + ]; +}; + +exports.addCurrentImport = function(sourceFile, currentImport) { + if (!this.doesModuleExists(sourceFile, currentImport.module)) { + sourceFile.addImportDeclaration({ + moduleSpecifier: currentImport.module, + }); + } + if (!this.doesImportExistInModule(sourceFile, currentImport)) { + sourceFile + .getImportDeclarationOrThrow(currentImport.module) + .addNamedImport(currentImport.name); + } +}; + +exports.doesModuleExists = function(sourceFile, moduleName) { + return sourceFile.getImportDeclaration(moduleName); +}; + +exports.doesImportExistInModule = function(sourceFile, currentImport) { + let identicalImport; + const relevantImports = this.getNamedImportsFromModule( + sourceFile, + currentImport.module, + ); + if (relevantImports.length > 0) { + identicalImport = relevantImports[0] + .getNamedImports() + .filter(imp => imp.getName() == currentImport.name); + } + + return identicalImport && identicalImport.length > 0; +}; + +exports.getNamedImportsFromModule = function(sourceFile, moduleName) { + const allImports = sourceFile.getImportDeclarations(); + return allImports.filter(imp => imp.getModuleSpecifierValue() == moduleName); +}; diff --git a/packages/cli/lib/cli.js b/packages/cli/lib/cli.js index b1acd5e7166e..5cb91875f565 100644 --- a/packages/cli/lib/cli.js +++ b/packages/cli/lib/cli.js @@ -87,6 +87,10 @@ function setupGenerators() { path.join(__dirname, '../generators/discover'), PREFIX + 'discover', ); + env.register( + path.join(__dirname, '../generators/relation'), + PREFIX + 'relation', + ); return env; } diff --git a/packages/cli/package-lock.json b/packages/cli/package-lock.json index b1e0e0248b8b..3c88808c2eac 100644 --- a/packages/cli/package-lock.json +++ b/packages/cli/package-lock.json @@ -30,6 +30,15 @@ "regenerator-runtime": "^0.13.2" } }, + "@dsherret/to-absolute-glob": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@dsherret/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz", + "integrity": "sha1-H2R13IvZdM6gei2vOGSzF7HdMyw=", + "requires": { + "is-absolute": "^1.0.0", + "is-negated-glob": "^1.0.0" + } + }, "@mrmlnc/readdir-enhanced": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz", @@ -804,6 +813,11 @@ "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=" }, + "code-block-writer": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-7.2.2.tgz", + "integrity": "sha512-8SyXM1bWsMDCzvCoTdnDBhnnUbHntxcba4ApBIO3S3QX0M2Iq0xZCzs6SYdBOGaSUi4drysvrAK15JoXhlpsvQ==" + }, "code-error-fragment": { "version": "0.0.230", "resolved": "https://registry.npmjs.org/code-error-fragment/-/code-error-fragment-0.0.230.tgz", @@ -2062,6 +2076,15 @@ "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", "integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=" }, + "is-absolute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", + "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", + "requires": { + "is-relative": "^1.0.0", + "is-windows": "^1.0.1" + } + }, "is-accessor-descriptor": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", @@ -2173,6 +2196,11 @@ "lower-case": "^1.1.0" } }, + "is-negated-glob": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-negated-glob/-/is-negated-glob-1.0.0.tgz", + "integrity": "sha1-aRC8pdqMleeEtXUbl2z1oQ/uNtI=" + }, "is-npm": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-3.0.0.tgz", @@ -2243,6 +2271,14 @@ "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", "integrity": "sha1-/S2INUXEa6xaYz57mgnof6LLUGk=" }, + "is-relative": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", + "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", + "requires": { + "is-unc-path": "^1.0.0" + } + }, "is-retry-allowed": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.1.0.tgz", @@ -2267,6 +2303,14 @@ "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", "dev": true }, + "is-unc-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", + "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", + "requires": { + "unc-path-regex": "^0.1.2" + } + }, "is-upper-case": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-upper-case/-/is-upper-case-1.1.2.tgz", @@ -4802,6 +4846,34 @@ "integrity": "sha1-y99WD9e5r2MlAv7UD5GMFX6pcTc=", "dev": true }, + "ts-morph": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-1.3.3.tgz", + "integrity": "sha512-TO4xmC4yKSoOSjuIGBlYOkPSQhY4dC6/8ksEH+1jlt7XUk6fmLshn97wwchMQxz1ejSd2DSxEk+pC5cqDYlUzg==", + "requires": { + "@dsherret/to-absolute-glob": "^2.0.2", + "code-block-writer": "7.2.2", + "fs-extra": "^7.0.0", + "glob-parent": "^3.1.0", + "globby": "^8.0.1", + "is-negated-glob": "^1.0.0", + "multimatch": "^2.1.0", + "tslib": "^1.9.0", + "typescript": ">=3.0.1 <3.5.0" + }, + "dependencies": { + "fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + } + } + }, "tslib": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz", @@ -4843,6 +4915,11 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.4.1.tgz", "integrity": "sha512-3NSMb2VzDQm8oBTLH6Nj55VVtUEpe/rgkIzMir0qVoLyjDZlnMBva0U6vDiV3IH+sl/Yu6oP5QwsAQtHPmDd2Q==" }, + "unc-path-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", + "integrity": "sha1-5z3T17DXxe2G+6xrCufYxqadUPo=" + }, "unicode-10.0.0": { "version": "0.7.5", "resolved": "https://registry.npmjs.org/unicode-10.0.0/-/unicode-10.0.0-0.7.5.tgz", diff --git a/packages/cli/package.json b/packages/cli/package.json index b79e8b48026d..507ea6203413 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -57,6 +57,7 @@ "stringify-object": "^3.3.0", "swagger-parser": "^6.0.5", "swagger2openapi": "^5.3.0", + "ts-morph": "^1.2.0", "typescript": "^3.1.1", "unicode-10.0.0": "^0.7.4", "update-notifier": "^3.0.0", diff --git a/packages/cli/test/fixtures/relation/controllers/customer.controller.ts b/packages/cli/test/fixtures/relation/controllers/customer.controller.ts new file mode 100644 index 000000000000..c91c5554aadc --- /dev/null +++ b/packages/cli/test/fixtures/relation/controllers/customer.controller.ts @@ -0,0 +1 @@ +export class CustomerController {} diff --git a/packages/cli/test/fixtures/relation/controllers/index.ts b/packages/cli/test/fixtures/relation/controllers/index.ts new file mode 100644 index 000000000000..26207a4b860f --- /dev/null +++ b/packages/cli/test/fixtures/relation/controllers/index.ts @@ -0,0 +1 @@ +export * from './customer.controller'; diff --git a/packages/cli/test/fixtures/relation/controllers/index4.ts b/packages/cli/test/fixtures/relation/controllers/index4.ts new file mode 100644 index 000000000000..35dd7b81fa75 --- /dev/null +++ b/packages/cli/test/fixtures/relation/controllers/index4.ts @@ -0,0 +1 @@ +export * from './order-customer.controller'; diff --git a/packages/cli/test/fixtures/relation/controllers/order-customer.controller.ts b/packages/cli/test/fixtures/relation/controllers/order-customer.controller.ts new file mode 100644 index 000000000000..a56f51e3dbd1 --- /dev/null +++ b/packages/cli/test/fixtures/relation/controllers/order-customer.controller.ts @@ -0,0 +1 @@ +export class OrderCustomerController {} diff --git a/packages/cli/test/fixtures/relation/index.js b/packages/cli/test/fixtures/relation/index.js new file mode 100644 index 000000000000..47a78916ee1c --- /dev/null +++ b/packages/cli/test/fixtures/relation/index.js @@ -0,0 +1,625 @@ +const DATASOURCE_APP_PATH = 'src/datasources'; +const MODEL_APP_PATH = 'src/models'; +const REPOSITORY_APP_PATH = 'src/repositories'; +const CONTROLLER_PATH = 'src/controllers'; +const CONFIG_PATH = '.'; +const DUMMY_CONTENT = '--DUMMY VALUE--'; +const fs = require('fs'); + +exports.SANDBOX_FILES = [ + { + path: CONFIG_PATH, + file: 'myconfig.json', + content: JSON.stringify({ + datasource: 'dbmem', + model: 'decoratordefined', + }), + }, + { + path: DATASOURCE_APP_PATH, + file: 'dbkv.datasource.json', + content: JSON.stringify({ + name: 'dbkv', + connector: 'kv-redis', + }), + }, + { + path: DATASOURCE_APP_PATH, + file: 'dbkv.datasource.ts', + content: DUMMY_CONTENT, + }, + { + path: DATASOURCE_APP_PATH, + file: 'dbmem.datasource.json', + content: JSON.stringify({ + name: 'dbmem', + connector: 'memory', + }), + }, + { + path: DATASOURCE_APP_PATH, + file: 'my-ds.datasource.json', + content: JSON.stringify({ + name: 'MyDS', + connector: 'memory', + }), + }, + { + path: DATASOURCE_APP_PATH, + file: 'dbmem.datasource.ts', + content: DUMMY_CONTENT, + }, + { + path: DATASOURCE_APP_PATH, + file: 'restdb.datasource.json', + content: JSON.stringify({ + name: 'restdb', + connector: 'rest', + }), + }, + { + path: DATASOURCE_APP_PATH, + file: 'sqlite-3.datasource.json', + content: JSON.stringify({ + name: 'sqlite3', + connector: 'loopback-connector-sqlite3', + }), + }, + { + path: DATASOURCE_APP_PATH, + file: 'sqlite-3.datasource.ts', + content: DUMMY_CONTENT, + }, + { + path: REPOSITORY_APP_PATH, + file: 'customer.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/customer.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: REPOSITORY_APP_PATH, + file: 'order.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/order.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: REPOSITORY_APP_PATH, + file: 'customer-class.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/customer-class.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: REPOSITORY_APP_PATH, + file: 'order-class.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/order-class.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, + + { + path: REPOSITORY_APP_PATH, + file: 'customer-class-type.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/customer-class-type.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: REPOSITORY_APP_PATH, + file: 'order-class-type.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/order-class-type.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: DATASOURCE_APP_PATH, + file: 'restdb.datasource.ts', + content: DUMMY_CONTENT, + }, + { + path: MODEL_APP_PATH, + file: 'customer.model.ts', + content: fs.readFileSync(require.resolve('./models/customer.model.ts'), { + encoding: 'utf-8', + }), + }, + { + path: MODEL_APP_PATH, + file: 'order.model.ts', + content: fs.readFileSync(require.resolve('./models/order.model.ts'), { + encoding: 'utf-8', + }), + }, + { + path: MODEL_APP_PATH, + file: 'nokey.model.ts', + content: fs.readFileSync(require.resolve('./models/nokey.model.ts'), { + encoding: 'utf-8', + }), + }, + { + path: MODEL_APP_PATH, + file: 'customer-class.model.ts', + content: fs.readFileSync( + require.resolve('./models/customer-class.model.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: MODEL_APP_PATH, + file: 'order-class.model.ts', + content: fs.readFileSync(require.resolve('./models/order-class.model.ts'), { + encoding: 'utf-8', + }), + }, + + { + path: MODEL_APP_PATH, + file: 'customer-class-type.model.ts', + content: fs.readFileSync( + require.resolve('./models/customer-class-type.model.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: MODEL_APP_PATH, + file: 'order-class-type.model.ts', + content: fs.readFileSync( + require.resolve('./models/order-class-type.model.ts'), + { + encoding: 'utf-8', + }, + ), + }, +]; +exports.SANDBOX_FILES2 = [ + { + path: CONTROLLER_PATH, + file: 'customer.controller.ts', + content: fs.readFileSync( + require.resolve('./controllers/customer.controller.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: REPOSITORY_APP_PATH, + file: 'customer.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/customer.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: REPOSITORY_APP_PATH, + file: 'order.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/order.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: REPOSITORY_APP_PATH, + file: 'customer-class.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/customer-class.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: REPOSITORY_APP_PATH, + file: 'order-class.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/order-class.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, + + { + path: REPOSITORY_APP_PATH, + file: 'customer-class-type.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/customer-class-type.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: REPOSITORY_APP_PATH, + file: 'order-class-type.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/order-class-type.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: DATASOURCE_APP_PATH, + file: 'restdb.datasource.ts', + content: DUMMY_CONTENT, + }, + { + path: MODEL_APP_PATH, + file: 'customer.model.ts', + content: fs.readFileSync(require.resolve('./models/customer.model.ts'), { + encoding: 'utf-8', + }), + }, + { + path: MODEL_APP_PATH, + file: 'order.model.ts', + content: fs.readFileSync(require.resolve('./models/order.model.ts'), { + encoding: 'utf-8', + }), + }, + { + path: MODEL_APP_PATH, + file: 'nokey.model.ts', + content: fs.readFileSync(require.resolve('./models/nokey.model.ts'), { + encoding: 'utf-8', + }), + }, + { + path: MODEL_APP_PATH, + file: 'customer-class.model.ts', + content: fs.readFileSync( + require.resolve('./models/customer-class.model.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: MODEL_APP_PATH, + file: 'order-class.model.ts', + content: fs.readFileSync(require.resolve('./models/order-class.model.ts'), { + encoding: 'utf-8', + }), + }, + + { + path: MODEL_APP_PATH, + file: 'customer-class-type.model.ts', + content: fs.readFileSync( + require.resolve('./models/customer-class-type.model.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: MODEL_APP_PATH, + file: 'order-class-type.model.ts', + content: fs.readFileSync( + require.resolve('./models/order-class-type.model.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: CONTROLLER_PATH, + file: 'index.ts', + content: fs.readFileSync(require.resolve('./controllers/index.ts'), { + encoding: 'utf-8', + }), + }, +]; + +exports.SANDBOX_FILES3 = []; + +exports.SANDBOX_FILES4 = [ + { + path: CONTROLLER_PATH, + file: 'order-customer.controller.ts', + content: fs.readFileSync( + require.resolve('./controllers/order-customer.controller.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: CONTROLLER_PATH, + file: 'index.ts', + content: fs.readFileSync(require.resolve('./controllers/index4.ts'), { + encoding: 'utf-8', + }), + }, + { + path: MODEL_APP_PATH, + file: 'customer.model.ts', + content: fs.readFileSync(require.resolve('./models/customer.model.ts'), { + encoding: 'utf-8', + }), + }, + { + path: MODEL_APP_PATH, + file: 'order.model.ts', + content: fs.readFileSync(require.resolve('./models/order.model.ts'), { + encoding: 'utf-8', + }), + }, + { + path: REPOSITORY_APP_PATH, + file: 'customer.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/customer.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: REPOSITORY_APP_PATH, + file: 'order.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/order.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, +]; + +exports.SANDBOX_FILES5 = [ + { + path: MODEL_APP_PATH, + file: 'customer.model.ts', + content: fs.readFileSync(require.resolve('./models/customer5.model.ts'), { + encoding: 'utf-8', + }), + }, + { + path: MODEL_APP_PATH, + file: 'order.model.ts', + content: fs.readFileSync(require.resolve('./models/order.model.ts'), { + encoding: 'utf-8', + }), + }, + { + path: REPOSITORY_APP_PATH, + file: 'customer.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/customer.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: REPOSITORY_APP_PATH, + file: 'order.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/order.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, +]; +exports.SANDBOX_FILES2 = [ + { + path: REPOSITORY_APP_PATH, + file: 'customer.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/customer.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: REPOSITORY_APP_PATH, + file: 'order.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/order.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: REPOSITORY_APP_PATH, + file: 'customer-class.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/customer-class.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: REPOSITORY_APP_PATH, + file: 'order-class.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/order-class.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, + + { + path: REPOSITORY_APP_PATH, + file: 'customer-class-type.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/customer-class-type.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: REPOSITORY_APP_PATH, + file: 'order-class-type.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/order-class-type.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: DATASOURCE_APP_PATH, + file: 'restdb.datasource.ts', + content: DUMMY_CONTENT, + }, + { + path: MODEL_APP_PATH, + file: 'customer.model.ts', + content: fs.readFileSync(require.resolve('./models/customer.model.ts'), { + encoding: 'utf-8', + }), + }, + { + path: MODEL_APP_PATH, + file: 'order.model.ts', + content: fs.readFileSync(require.resolve('./models/order.model.ts'), { + encoding: 'utf-8', + }), + }, + { + path: MODEL_APP_PATH, + file: 'nokey.model.ts', + content: fs.readFileSync(require.resolve('./models/nokey.model.ts'), { + encoding: 'utf-8', + }), + }, + { + path: MODEL_APP_PATH, + file: 'customer-class.model.ts', + content: fs.readFileSync( + require.resolve('./models/customer-class.model.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: MODEL_APP_PATH, + file: 'order-class.model.ts', + content: fs.readFileSync(require.resolve('./models/order-class.model.ts'), { + encoding: 'utf-8', + }), + }, + + { + path: MODEL_APP_PATH, + file: 'customer-class-type.model.ts', + content: fs.readFileSync( + require.resolve('./models/customer-class-type.model.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: MODEL_APP_PATH, + file: 'order-class-type.model.ts', + content: fs.readFileSync( + require.resolve('./models/order-class-type.model.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: CONTROLLER_PATH, + file: 'index.ts', + content: fs.readFileSync(require.resolve('./controllers/index.ts'), { + encoding: 'utf-8', + }), + }, +]; + +exports.SANDBOX_FILES3 = []; + +exports.SANDBOX_FILES4 = [ + { + path: CONTROLLER_PATH, + file: 'order-customer.controller.ts', + content: fs.readFileSync( + require.resolve('./controllers/order-customer.controller.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: CONTROLLER_PATH, + file: 'index.ts', + content: fs.readFileSync(require.resolve('./controllers/index4.ts'), { + encoding: 'utf-8', + }), + }, + { + path: MODEL_APP_PATH, + file: 'customer.model.ts', + content: fs.readFileSync(require.resolve('./models/customer.model.ts'), { + encoding: 'utf-8', + }), + }, + { + path: MODEL_APP_PATH, + file: 'order.model.ts', + content: fs.readFileSync(require.resolve('./models/order.model.ts'), { + encoding: 'utf-8', + }), + }, + { + path: REPOSITORY_APP_PATH, + file: 'customer.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/customer.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, + { + path: REPOSITORY_APP_PATH, + file: 'order.repository.ts', + content: fs.readFileSync( + require.resolve('./repositories/order.repository.ts'), + { + encoding: 'utf-8', + }, + ), + }, +]; diff --git a/packages/cli/test/fixtures/relation/models/customer-class-type.model.ts b/packages/cli/test/fixtures/relation/models/customer-class-type.model.ts new file mode 100644 index 000000000000..a6b645d90ebb --- /dev/null +++ b/packages/cli/test/fixtures/relation/models/customer-class-type.model.ts @@ -0,0 +1,19 @@ +import {Entity, model, property} from '@loopback/repository'; + +@model() +export class CustomerClassType extends Entity { + @property({ + type: 'number', + id: true, + }) + custNumber: number; + + @property({ + type: 'string', + }) + name?: string; + + constructor(data?: Partial) { + super(data); + } +} diff --git a/packages/cli/test/fixtures/relation/models/customer-class.model.ts b/packages/cli/test/fixtures/relation/models/customer-class.model.ts new file mode 100644 index 000000000000..06c062314bd1 --- /dev/null +++ b/packages/cli/test/fixtures/relation/models/customer-class.model.ts @@ -0,0 +1,19 @@ +import {Entity, model, property} from '@loopback/repository'; + +@model() +export class CustomerClass extends Entity { + @property({ + type: 'number', + id: true, + }) + custNumber?: number; + + @property({ + type: 'string', + }) + name?: string; + + constructor(data?: Partial) { + super(data); + } +} diff --git a/packages/cli/test/fixtures/relation/models/customer.model.ts b/packages/cli/test/fixtures/relation/models/customer.model.ts new file mode 100644 index 000000000000..06d0fc93ca4f --- /dev/null +++ b/packages/cli/test/fixtures/relation/models/customer.model.ts @@ -0,0 +1,20 @@ +import {Entity, model, property} from '@loopback/repository'; + +@model() +export class Customer extends Entity { + @property({ + type: 'number', + id: true, + default: 0, + }) + id?: number; + + @property({ + type: 'string', + }) + name?: string; + + constructor(data?: Partial) { + super(data); + } +} diff --git a/packages/cli/test/fixtures/relation/models/customer5.model.ts b/packages/cli/test/fixtures/relation/models/customer5.model.ts new file mode 100644 index 000000000000..caead0df379b --- /dev/null +++ b/packages/cli/test/fixtures/relation/models/customer5.model.ts @@ -0,0 +1,22 @@ +import {Entity, model, property} from '@loopback/repository'; + +@model() +export class Customer extends Entity { + @property({ + type: 'number', + id: true, + default: 0, + }) + id?: number; + + @property({ + type: 'string', + }) + name?: string; + + orders: string; + + constructor(data?: Partial) { + super(data); + } +} diff --git a/packages/cli/test/fixtures/relation/models/nokey.model.ts b/packages/cli/test/fixtures/relation/models/nokey.model.ts new file mode 100644 index 000000000000..b74f18c8634f --- /dev/null +++ b/packages/cli/test/fixtures/relation/models/nokey.model.ts @@ -0,0 +1,19 @@ +import {Entity, model, property} from '@loopback/repository'; + +@model() +export class Nokey extends Entity { + @property({ + type: 'number', + default: 0, + }) + id?: number; + + @property({ + type: 'string', + }) + name?: string; + + constructor(data?: Partial) { + super(data); + } +} diff --git a/packages/cli/test/fixtures/relation/models/order-class-type.model.ts b/packages/cli/test/fixtures/relation/models/order-class-type.model.ts new file mode 100644 index 000000000000..fe9d38b59b3b --- /dev/null +++ b/packages/cli/test/fixtures/relation/models/order-class-type.model.ts @@ -0,0 +1,19 @@ +import {Entity, model, property} from '@loopback/repository'; + +@model() +export class OrderClassType extends Entity { + @property({ + type: 'string', + id: true, + }) + orderString: string; + + @property({ + type: 'string', + }) + name?: string; + + constructor(data?: Partial) { + super(data); + } +} diff --git a/packages/cli/test/fixtures/relation/models/order-class.model.ts b/packages/cli/test/fixtures/relation/models/order-class.model.ts new file mode 100644 index 000000000000..1563eff11aaf --- /dev/null +++ b/packages/cli/test/fixtures/relation/models/order-class.model.ts @@ -0,0 +1,19 @@ +import {Entity, model, property} from '@loopback/repository'; + +@model() +export class OrderClass extends Entity { + @property({ + type: 'number', + id: true, + }) + orderNumber?: number; + + @property({ + type: 'string', + }) + name?: string; + + constructor(data?: Partial) { + super(data); + } +} diff --git a/packages/cli/test/fixtures/relation/models/order.model.ts b/packages/cli/test/fixtures/relation/models/order.model.ts new file mode 100644 index 000000000000..84f947dc2eb0 --- /dev/null +++ b/packages/cli/test/fixtures/relation/models/order.model.ts @@ -0,0 +1,20 @@ +import {Entity, model, property} from '@loopback/repository'; + +@model() +export class Order extends Entity { + @property({ + type: 'number', + id: true, + default: 0, + }) + id?: number; + + @property({ + type: 'string', + }) + name?: string; + + constructor(data?: Partial) { + super(data); + } +} diff --git a/packages/cli/test/fixtures/relation/repositories/customer-class-type.repository.ts b/packages/cli/test/fixtures/relation/repositories/customer-class-type.repository.ts new file mode 100644 index 000000000000..17c0f6c8cbb0 --- /dev/null +++ b/packages/cli/test/fixtures/relation/repositories/customer-class-type.repository.ts @@ -0,0 +1,13 @@ +import {DefaultCrudRepository} from '@loopback/repository'; +import {CustomerClassType} from '../models'; +import {MyDBDataSource} from '../datasources'; +import {inject} from '@loopback/core'; + +export class CustomerClassTypeRepository extends DefaultCrudRepository< + CustomerClassType, + typeof CustomerClassType.prototype.custNumber +> { + constructor(@inject('datasources.myDB') dataSource: MyDBDataSource) { + super(CustomerClassType, dataSource); + } +} diff --git a/packages/cli/test/fixtures/relation/repositories/customer-class.repository.ts b/packages/cli/test/fixtures/relation/repositories/customer-class.repository.ts new file mode 100644 index 000000000000..a5071b576ddd --- /dev/null +++ b/packages/cli/test/fixtures/relation/repositories/customer-class.repository.ts @@ -0,0 +1,13 @@ +import {DefaultCrudRepository} from '@loopback/repository'; +import {CustomerClass} from '../models'; +import {MyDBDataSource} from '../datasources'; +import {inject} from '@loopback/core'; + +export class CustomerClassRepository extends DefaultCrudRepository< + CustomerClass, + typeof CustomerClass.prototype.custNumber +> { + constructor(@inject('datasources.myDB') dataSource: MyDBDataSource) { + super(CustomerClass, dataSource); + } +} diff --git a/packages/cli/test/fixtures/relation/repositories/customer.repository.ts b/packages/cli/test/fixtures/relation/repositories/customer.repository.ts new file mode 100644 index 000000000000..817632e38973 --- /dev/null +++ b/packages/cli/test/fixtures/relation/repositories/customer.repository.ts @@ -0,0 +1,13 @@ +import {DefaultCrudRepository} from '@loopback/repository'; +import {Customer} from '../models'; +import {DbDataSource} from '../datasources'; +import {inject} from '@loopback/core'; + +export class CustomerRepository extends DefaultCrudRepository< + Customer, + typeof Customer.prototype.id +> { + constructor(@inject('datasources.db') dataSource: DbDataSource) { + super(Customer, dataSource); + } +} diff --git a/packages/cli/test/fixtures/relation/repositories/order-class-type.repository.ts b/packages/cli/test/fixtures/relation/repositories/order-class-type.repository.ts new file mode 100644 index 000000000000..a7a00984764d --- /dev/null +++ b/packages/cli/test/fixtures/relation/repositories/order-class-type.repository.ts @@ -0,0 +1,13 @@ +import {DefaultCrudRepository} from '@loopback/repository'; +import {OrderClassType} from '../models'; +import {MyDBDataSource} from '../datasources'; +import {inject} from '@loopback/core'; + +export class OrderClassTypeRepository extends DefaultCrudRepository< + OrderClassType, + typeof OrderClassType.prototype.orderString +> { + constructor(@inject('datasources.myDB') dataSource: MyDBDataSource) { + super(OrderClassType, dataSource); + } +} diff --git a/packages/cli/test/fixtures/relation/repositories/order-class.repository.ts b/packages/cli/test/fixtures/relation/repositories/order-class.repository.ts new file mode 100644 index 000000000000..68aac3b4162f --- /dev/null +++ b/packages/cli/test/fixtures/relation/repositories/order-class.repository.ts @@ -0,0 +1,13 @@ +import {DefaultCrudRepository} from '@loopback/repository'; +import {OrderClass} from '../models'; +import {MyDBDataSource} from '../datasources'; +import {inject} from '@loopback/core'; + +export class OrderClassRepository extends DefaultCrudRepository< + OrderClass, + typeof OrderClass.prototype.orderNumber +> { + constructor(@inject('datasources.myDB') dataSource: MyDBDataSource) { + super(OrderClass, dataSource); + } +} diff --git a/packages/cli/test/fixtures/relation/repositories/order.repository.ts b/packages/cli/test/fixtures/relation/repositories/order.repository.ts new file mode 100644 index 000000000000..9953a58c0cbc --- /dev/null +++ b/packages/cli/test/fixtures/relation/repositories/order.repository.ts @@ -0,0 +1,13 @@ +import {DefaultCrudRepository} from '@loopback/repository'; +import {Order} from '../models'; +import {DbDataSource} from '../datasources'; +import {inject} from '@loopback/core'; + +export class OrderRepository extends DefaultCrudRepository< + Order, + typeof Order.prototype.id +> { + constructor(@inject('datasources.db') dataSource: DbDataSource) { + super(Order, dataSource); + } +} diff --git a/packages/cli/test/integration/cli/cli.integration.js b/packages/cli/test/integration/cli/cli.integration.js index 27fbb691b10e..c1f5461ab478 100644 --- a/packages/cli/test/integration/cli/cli.integration.js +++ b/packages/cli/test/integration/cli/cli.integration.js @@ -25,7 +25,7 @@ describe('cli', () => { 'Available commands: ', ' lb4 app\n lb4 extension\n lb4 controller\n lb4 datasource\n ' + 'lb4 model\n lb4 repository\n lb4 service\n lb4 example\n ' + - 'lb4 openapi\n lb4 observer\n lb4 discover', + 'lb4 openapi\n lb4 observer\n lb4 discover\n lb4 relation', ]); }); @@ -44,7 +44,7 @@ describe('cli', () => { expect(entries).to.containEql( ' lb4 app\n lb4 extension\n lb4 controller\n lb4 datasource\n ' + 'lb4 model\n lb4 repository\n lb4 service\n lb4 example\n ' + - 'lb4 openapi\n lb4 observer\n lb4 discover', + 'lb4 openapi\n lb4 observer\n lb4 discover\n lb4 relation', ); }); diff --git a/packages/cli/test/integration/generators/belongsto.relation.integration.js b/packages/cli/test/integration/generators/belongsto.relation.integration.js new file mode 100644 index 000000000000..b4daece6e760 --- /dev/null +++ b/packages/cli/test/integration/generators/belongsto.relation.integration.js @@ -0,0 +1,486 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +const path = require('path'); +const assert = require('yeoman-assert'); +const testlab = require('@loopback/testlab'); +const fs = require('fs'); + +const expect = testlab.expect; +const TestSandbox = testlab.TestSandbox; + +const generator = path.join(__dirname, '../../../generators/relation'); +const SANDBOX_FILES = require('../../fixtures/relation').SANDBOX_FILES; +const testUtils = require('../../test-utils'); + +// Test Sandbox +const SANDBOX_PATH = path.resolve(__dirname, '..', '.sandbox'); +const MODEL_APP_PATH = 'src/models'; +const CONTROLLER_PATH = 'src/controllers'; +const REPOSITORY_APP_PATH = 'src/repositories'; +const sandbox = new TestSandbox(SANDBOX_PATH); + +const targetFileName = [ + 'customer.model.ts', + 'customer-class.model.ts', + 'customer-class-type.model.ts', +]; +const sourceFileName = [ + 'order.model.ts', + 'order-class.model.ts', + 'order-class-type.model.ts', +]; +const controllerFileName = [ + 'order-customer.controller.ts', + 'order-class-customer-class.controller.ts', + 'order-class-type-customer-class-type.controller.ts', +]; +const repositoryFileName = [ + 'order.repository.ts', + 'order-class.repository.ts', + 'order-class-type.repository.ts', +]; + +describe('lb4 relation', function() { + // tslint:disable-next-line:no-invalid-this + this.timeout(50000); + + beforeEach('reset sandbox', async () => { + await sandbox.reset(); + }); + + // special cases regardless of the repository type + context('generate model relation - ', () => { + const expectedImport = /import {Entity, model, property, belongsTo} from \'\@loopback\/repository\';\n/; + const expectedDecoretor = [ + /@belongsTo\(\(\) => Customer\)\n myCustomer: number;\n/, + /@belongsTo\(\(\) => CustomerClass\)\n myCustomer: number;\n/, + /@belongsTo\(\(\) => CustomerClassType\)\n myCustomer: number;\n/, + ]; + const promptArray = [ + { + relationType: 'belongsTo', + sourceModel: 'Order', + destinationModel: 'Customer', + relationName: 'myCustomer', + }, + { + relationType: 'belongsTo', + sourceModel: 'OrderClass', + destinationModel: 'CustomerClass', + relationName: 'myCustomer', + }, + { + relationType: 'belongsTo', + sourceModel: 'OrderClassType', + destinationModel: 'CustomerClassType', + relationName: 'myCustomer', + }, + ]; + + promptArray.forEach(function(multiItemPrompt, i) { + it('add import belongsTo, import for target model and belongsTo decorator ', async () => { + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts(multiItemPrompt); + + const expectedSourceFile = path.join( + SANDBOX_PATH, + MODEL_APP_PATH, + sourceFileName[i], + ); + + assert.file(expectedSourceFile); + assert.fileContent(expectedSourceFile, expectedImport); + assert.fileContent(expectedSourceFile, expectedDecoretor[i]); + }); + }); + }); + + context('generate model relation with custom relation name - ', () => { + const expectedDecoretor = [ + /@belongsTo\(\(\) => Customer\)\n customerPK: number;\n/, + /@belongsTo\(\(\) => CustomerClass\)\n customerPK: number;\n/, + /@belongsTo\(\(\) => CustomerClassType\)\n customerPK: number;\n/, + ]; + + const promptArray = [ + { + relationType: 'belongsTo', + sourceModel: 'Order', + destinationModel: 'Customer', + relationName: 'customerPK', + }, + { + relationType: 'belongsTo', + sourceModel: 'OrderClass', + destinationModel: 'CustomerClass', + relationName: 'customerPK', + }, + { + relationType: 'belongsTo', + sourceModel: 'OrderClassType', + destinationModel: 'CustomerClassType', + relationName: 'customerPK', + }, + ]; + promptArray.forEach(function(multiItemPrompt, i) { + it('relation name should be customerPK', async () => { + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts(multiItemPrompt); + + const expectedSourceFile = path.join( + SANDBOX_PATH, + MODEL_APP_PATH, + sourceFileName[i], + ); + + assert.fileContent(expectedSourceFile, expectedDecoretor[i]); + }); + }); + }); + + context('generate model relation with default relation name', () => { + const expectedDecoretor = [ + /@belongsTo\(\(\) => Customer\)\n customerId: number;\n/, + /@belongsTo\(\(\) => CustomerClass\)\n customerClassCustNumber: number;\n/, + /@belongsTo\(\(\) => CustomerClassType\)\n customerClassTypeCustNumber: number;\n/, + ]; + const defaultRelationName = [ + 'customerId', + 'customerClassCustNumber', + 'customerClassTypeCustNumber', + ]; + + const promptArray = [ + { + relationType: 'belongsTo', + sourceModel: 'Order', + destinationModel: 'Customer', + }, + { + relationType: 'belongsTo', + sourceModel: 'OrderClass', + destinationModel: 'CustomerClass', + }, + { + relationType: 'belongsTo', + sourceModel: 'OrderClassType', + destinationModel: 'CustomerClassType', + }, + ]; + promptArray.forEach(function(multiItemPrompt, i) { + it('relation name should be ' + defaultRelationName[i], async () => { + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts(multiItemPrompt); + + const expectedSourceFile = path.join( + SANDBOX_PATH, + MODEL_APP_PATH, + sourceFileName[i], + ); + + assert.fileContent(expectedSourceFile, expectedDecoretor[i]); + }); + }); + }); +}); +context('check if the controller file created ', () => { + const promptArray = [ + { + relationType: 'belongsTo', + sourceModel: 'Order', + destinationModel: 'Customer', + }, + { + relationType: 'belongsTo', + sourceModel: 'OrderClass', + destinationModel: 'CustomerClass', + }, + { + relationType: 'belongsTo', + sourceModel: 'OrderClassType', + destinationModel: 'CustomerClassType', + }, + ]; + const controllerClass = [ + /class OrderCustomerController/, + /class OrderClassCustomerClassController/, + /class OrderClassTypeCustomerClassTypeController/, + ]; + const controllerConstructor = [ + /constructor\(\n \@repository\(OrderRepository\)\n public orderRepository: OrderRepository,\n \) \{ \}\n/, + /constructor\(\n \@repository\(OrderClassRepository\)\n public orderClassRepository: OrderClassRepository,\n \) \{ \}\n/, + /constructor\(\n \@repository\(OrderClassTypeRepository\)\n public orderClassTypeRepository: OrderClassTypeRepository,\n \) \{ \}\n/, + ]; + const indexExport = [ + /export \* from '.\/order-customer.controller';/, + /export \* from '.\/order-class-customer-class.controller';/, + /export \* from '.\/order-class-type-customer-class-type.controller';/, + ]; + const sourceClassnames = ['Customer', 'CustomerClass', 'CustomerClassType']; + const targetClassnames = ['Order', 'OrderClass', 'OrderClassType']; + promptArray.forEach(function(multiItemPrompt, i) { + it('new controller file created', async () => { + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts(multiItemPrompt); + + const expectedControllerFile = path.join( + SANDBOX_PATH, + CONTROLLER_PATH, + controllerFileName[i], + ); + assert.file(expectedControllerFile); + }).timeout(10000); + it('controller with belongsTo class and constructor', async () => { + const expectedControllerFile = path.join( + SANDBOX_PATH, + CONTROLLER_PATH, + controllerFileName[i], + ); + assert.fileContent(expectedControllerFile, controllerClass[i]); + assert.fileContent(expectedControllerFile, controllerConstructor[i]); + }); + it('the new controller file added to index.ts file', async () => { + const expectedControllerIndexFile = path.join( + SANDBOX_PATH, + CONTROLLER_PATH, + 'index.ts', + ); + + assert.fileContent(expectedControllerIndexFile, indexExport[i]); + }); + it( + 'controller GET Array of ' + + targetClassnames[i] + + "'s belonging to " + + sourceClassnames[i], + async () => { + const getOrdersByCustomerIdRegEx = [ + /\@get\('\/orders\/{id}\/customer', \{\n responses: \{\n '200': \{\n/, + /content: \{\n 'application\/json': \{\n/, + /async getCustomer\(\n \@param\.path\.number\('id'\) id: typeof Order\.prototype\.id,\n/, + /\)\: Promise \{\n/, + /return await this\.orderRepository\.customer\(id\);\n \}\n/, + ]; + const getOrdersClassByCustomerClassIdRegEx = [ + /\@get\('\/order-classes\/{id}\/customer-class', \{\n responses: \{\n '200': \{\n/, + /content: \{\n 'application\/json': \{\n/, + /async getCustomerClass\(\n \@param\.path\.number\('id'\) id: typeof OrderClass\.prototype\.orderNumber,\n/, + /\)\: Promise \{\n/, + /return await this\.orderClassRepository\.customerClass\(id\);\n \}\n/, + ]; + + const getOrdersClassTypeByCustomerClassTypeIdRegEx = [ + /\@get\('\/order-class-types\/{id}\/customer-class-type', \{\n responses: \{\n '200': \{\n/, + /content: \{\n 'application\/json': \{\n/, + /async getCustomerClassType\(\n \@param\.path\.string\('id'\) id: typeof OrderClassType\.prototype\.orderString,\n/, + /\)\: Promise \{\n/, + /return await this\.orderClassTypeRepository\.customerClassType\(id\);\n \}\n/, + ]; + + const getRegEx = [ + getOrdersByCustomerIdRegEx, + getOrdersClassByCustomerClassIdRegEx, + getOrdersClassTypeByCustomerClassTypeIdRegEx, + ]; + + const expectedControllerFile = path.join( + SANDBOX_PATH, + CONTROLLER_PATH, + controllerFileName[i], + ); + getRegEx[i].forEach(regex => { + assert.fileContent(expectedControllerFile, regex); + }); + }, + ); + }); +}); + +context('check source class repository ', () => { + const promptArray = [ + { + relationType: 'belongsTo', + sourceModel: 'Order', + destinationModel: 'Customer', + }, + { + relationType: 'belongsTo', + sourceModel: 'OrderClass', + destinationModel: 'CustomerClass', + }, + { + relationType: 'belongsTo', + sourceModel: 'OrderClassType', + destinationModel: 'CustomerClassType', + }, + ]; + + const sourceClassnames = ['Order', 'OrderClass', 'OrderClassType']; + + promptArray.forEach(function(multiItemPrompt, i) { + it(sourceClassnames[i] + ' repostitory has all imports', async () => { + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts(multiItemPrompt); + + const repositoryBasicImports = [ + /import \{DefaultCrudRepository, repository, BelongsToAccessor\} from \'@loopback\/repository\';\n/, + /import \{inject, Getter\} from '\@loopback\/core';/, + ]; + + const repositoryClassImport = [ + /import \{CustomerRepository\} from '\.\/customer\.repository';/, + /import \{Order, Customer\} from '\.\.\/models';/, + ]; + const repositoryMultiWordClassImport = [ + /import \{CustomerClassRepository\} from '\.\/customer-class\.repository';/, + /import \{OrderClass, CustomerClass\} from '\.\.\/models';/, + ]; + + const repositoryTypeClassImport = [ + /import \{CustomerClassTypeRepository\} from '\.\/customer-class-type\.repository';/, + /import \{OrderClassType, CustomerClassType\} from '\.\.\/models';/, + ]; + + const sourceRepositoryFile = path.join( + SANDBOX_PATH, + REPOSITORY_APP_PATH, + repositoryFileName[i], + ); + + repositoryBasicImports.forEach(regex => { + assert.fileContent(sourceRepositoryFile, regex); + }); + + const importRegEx = [ + repositoryClassImport, + repositoryMultiWordClassImport, + repositoryTypeClassImport, + ]; + + importRegEx[i].forEach(regex => { + assert.fileContent(sourceRepositoryFile, regex); + }); + }).timeout(10000); + + it('repository has updated constructor', async () => { + const singleWordClassConstractor = [ + /public readonly customer: BelongsToAccessor;\n/, + /constructor\(@inject\('datasources\.db'\) dataSource: DbDataSource, @repository\.getter\('CustomerRepository'\) protected customerRepositoryGetter: Getter,\) \{\n/, + /super\(Order, dataSource\);\n this\.customer = this\.createBelongsToAccessorFor\('customer', customerRepositoryGetter,\);\n \}\n/, + ]; + + const multiWordClassConstractor = [ + /public readonly customerClass: BelongsToAccessor;\n/, + /constructor\(@inject\('datasources\.myDB'\) dataSource: MyDBDataSource, @repository\.getter\('CustomerClassRepository'\) protected customerClassRepositoryGetter: Getter,\) \{\n/, + /super\(OrderClass, dataSource\);\n this\.customerClass = this\.createBelongsToAccessorFor\('customerClassCustNumber', customerClassRepositoryGetter,\);\n \}\n/, + ]; + + const typeClassConstractor = [ + /public readonly customerClassType: BelongsToAccessor;\n/, + /constructor\(@inject\('datasources\.myDB'\) dataSource: MyDBDataSource, @repository\.getter\('CustomerClassTypeRepository'\) protected customerClassTypeRepositoryGetter: Getter,\) \{\n/, + /super\(OrderClassType, dataSource\);\n this\.customerClassType = this\.createBelongsToAccessorFor\('customerClassTypeCustNumber', customerClassTypeRepositoryGetter,\);\n \}\n/, + ]; + + const sourceRepositoryFile = path.join( + SANDBOX_PATH, + REPOSITORY_APP_PATH, + repositoryFileName[i], + ); + + const updateConstructorRegEx = [ + singleWordClassConstractor, + multiWordClassConstractor, + typeClassConstractor, + ]; + updateConstructorRegEx[i].forEach(regex => { + assert.fileContent(sourceRepositoryFile, regex); + }); + }); + }); + // Verify is property name that already exist will overwriting. + context('generate model relation - ', () => { + const expectedDecoretor = [ + /@belongsTo\(\(\) => Customer\)\n myCustomer: number;\n/, + /@belongsTo\(\(\) => CustomerClass\)\n myCustomer: number;\n/, + /@belongsTo\(\(\) => CustomerClassType\)\n myCustomer: number;\n/, + ]; + const promptArray = [ + { + relationType: 'belongsTo', + sourceModel: 'Order', + destinationModel: 'Customer', + relationName: 'myCustomer', + }, + ]; + + it('Verify is property name that already exist will overwriting ', async () => { + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts(promptArray[0]); + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts(promptArray[0]); + + const expectedSourceFile = path.join( + SANDBOX_PATH, + MODEL_APP_PATH, + sourceFileName[0], + ); + + assert.file(expectedSourceFile); + assert.fileContent(expectedSourceFile, expectedDecoretor[0]); + + fs.readFile(expectedSourceFile, (err, data) => { + if (err) throw err; + var indexOfFirstRelation = data.indexOf('@belongsTo'); + var lastIndexOfRelation = data.lastIndexOf('@belongsTo'); + assert.equal(indexOfFirstRelation, lastIndexOfRelation); + }); + }).timeout(20000); + }); +}); diff --git a/packages/cli/test/integration/generators/hasmany.relation.integration.js b/packages/cli/test/integration/generators/hasmany.relation.integration.js new file mode 100644 index 000000000000..ec0dd997af63 --- /dev/null +++ b/packages/cli/test/integration/generators/hasmany.relation.integration.js @@ -0,0 +1,711 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +const path = require('path'); +const assert = require('yeoman-assert'); +const testlab = require('@loopback/testlab'); +const fs = require('fs'); + +const expect = testlab.expect; +const TestSandbox = testlab.TestSandbox; + +const generator = path.join(__dirname, '../../../generators/relation'); +const SANDBOX_FILES = require('../../fixtures/relation').SANDBOX_FILES; +const testUtils = require('../../test-utils'); + +// Test Sandbox +const SANDBOX_PATH = path.resolve(__dirname, '..', '.sandbox'); +const MODEL_APP_PATH = 'src/models'; +const CONTROLLER_PATH = 'src/controllers'; +const REPOSITORY_APP_PATH = 'src/repositories'; + +const sandbox = new TestSandbox(SANDBOX_PATH); + +const hasManyrImportRegEx = /import \{Entity, model, property, hasMany\} from '@loopback\/repository';\n/; +const sourceFileName = [ + 'customer.model.ts', + 'customer-class.model.ts', + 'customer-class-type.model.ts', +]; +const targetFileName = [ + 'order.model.ts', + 'order-class.model.ts', + 'order-class-type.model.ts', +]; +const controllerFileName = [ + 'customer-order.controller.ts', + 'customer-class-order-class.controller.ts', + 'customer-class-type-order-class-type.controller.ts', +]; +const repositoryFileName = [ + 'customer.repository.ts', + 'customer-class.repository.ts', + 'customer-class-type.repository.ts', +]; + +describe('lb4 relation', function() { + // tslint:disable-next-line:no-invalid-this + this.timeout(30000); + + beforeEach('reset sandbox', async () => { + await sandbox.reset(); + }); + + // special cases regardless of the repository type + context('generate model relation', () => { + const promptArray = [ + { + relationType: 'hasMany', + sourceModel: 'Customer', + destinationModel: 'Order', + }, + { + relationType: 'hasMany', + sourceModel: 'CustomerClass', + destinationModel: 'OrderClass', + }, + { + relationType: 'hasMany', + sourceModel: 'CustomerClassType', + destinationModel: 'OrderClassType', + }, + ]; + + const expectedImport = [ + /import \{Order\} from '\.\/order\.model';\n/, + /import \{OrderClass\} from '\.\/order-class\.model';\n/, + /import \{OrderClassType\} from '\.\/order-class-type\.model';\n/, + ]; + const expectedDecoretor = [ + /\@hasMany\(\(\) => Order\)\n orders: Order\[\];\n/, + /\@hasMany\(\(\) => OrderClass ,\{keyTo: 'customerClassCustNumber'\}\)/, + /\@hasMany\(\(\) => OrderClassType ,\{keyTo: 'customerClassTypeCustNumber'\}\)/, + ]; + + promptArray.forEach(function(multiItemPrompt, i) { + it('add import hasMany, import for target model and hasMany decorator ', async () => { + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts(multiItemPrompt); + + const expectedSourceFile = path.join( + SANDBOX_PATH, + MODEL_APP_PATH, + sourceFileName[i], + ); + + assert.file(expectedSourceFile); + assert.fileContent(expectedSourceFile, hasManyrImportRegEx); + assert.fileContent(expectedSourceFile, expectedImport[i]); + + assert.fileContent(expectedSourceFile, expectedDecoretor[i]); + }); + }); + }); + + context('generate model relation with custom relation name', () => { + const promptArray = [ + { + relationType: 'hasMany', + sourceModel: 'Customer', + destinationModel: 'Order', + relationName: 'myOrders', + }, + { + relationType: 'hasMany', + sourceModel: 'CustomerClass', + destinationModel: 'OrderClass', + relationName: 'myOrders', + }, + { + relationType: 'hasMany', + sourceModel: 'CustomerClassType', + destinationModel: 'OrderClassType', + relationName: 'myOrders', + }, + ]; + + const expectedImport = [ + /import \{Order\} from '\.\/order\.model';\n/, + /import \{OrderClass\} from '\.\/order-class\.model';\n/, + /import \{OrderClassType\} from '\.\/order-class-type\.model';\n/, + ]; + const expectedDecoretor = [ + /\@hasMany\(\(\) => Order\)\n myOrders: Order\[\];\n/, + /\@hasMany\(\(\) => OrderClass ,\{keyTo: 'customerClassCustNumber'\}\)/, + /\@hasMany\(\(\) => OrderClassType ,\{keyTo: 'customerClassTypeCustNumber'\}\)/, + ]; + const expectedProperty = [ + /@property\(\{\n type: 'number',\n \}\)\n customerId\?\: number;\n/, + /@property\(\{\n type: 'number',\n \}\)\n customerClassCustNumber\?\: number;\n/, + /@property\(\{\n type: 'number',\n \}\)\n customerClassTypeCustNumber\?\: number;\n/, + ]; + + promptArray.forEach(function(multiItemPrompt, i) { + it('relation name should be myOrders', async () => { + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts(multiItemPrompt); + + const expectedSourceFile = path.join( + SANDBOX_PATH, + MODEL_APP_PATH, + sourceFileName[i], + ); + const expectedTargetFile = path.join( + SANDBOX_PATH, + MODEL_APP_PATH, + targetFileName[i], + ); + + assert.file(expectedSourceFile); + assert.fileContent(expectedSourceFile, hasManyrImportRegEx); + + assert.fileContent(expectedSourceFile, expectedImport[i]); + + assert.fileContent(expectedSourceFile, expectedDecoretor[i]); + + assert.fileContent(expectedTargetFile, expectedProperty[i]); + }); + }); + }); + + context('generate model relation with custom foreignKey', () => { + const promptArray = [ + { + relationType: 'hasMany', + sourceModel: 'Customer', + destinationModel: 'Order', + foreignKeyName: 'mykey', + }, + { + relationType: 'hasMany', + sourceModel: 'CustomerClass', + destinationModel: 'OrderClass', + foreignKeyName: 'mykey', + }, + { + relationType: 'hasMany', + sourceModel: 'CustomerClassType', + destinationModel: 'OrderClassType', + foreignKeyName: 'mykey', + }, + ]; + + const expectedImport = [ + /import \{Order\} from '\.\/order\.model';\n/, + /import \{OrderClass\} from '\.\/order-class\.model';\n/, + /import \{OrderClassType\} from '\.\/order-class-type\.model';\n/, + ]; + const expectedDecoretor = [ + /\@hasMany\(\(\) => Order ,\{keyTo: 'mykey'\}\)\n orders: Order\[\];\n/, + /\@hasMany\(\(\) => OrderClass ,\{keyTo: 'mykey'\}\)\n orderClasses: OrderClass\[\];\n/, + /\@hasMany\(\(\) => OrderClassType ,\{keyTo: 'mykey'\}\)\n orderClassTypes: OrderClassType\[\];\n/, + ]; + const expectedProperty = /@property\(\{\n type: 'number',\n \}\)\n mykey\?\: number;\n/; + + promptArray.forEach(function(multiItemPrompt, i) { + it('add the keyTo to the source model', async () => { + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts(multiItemPrompt); + + const expectedSourceFile = path.join( + SANDBOX_PATH, + MODEL_APP_PATH, + sourceFileName[i], + ); + const expectedTargetFile = path.join( + SANDBOX_PATH, + MODEL_APP_PATH, + targetFileName[i], + ); + + assert.file(expectedSourceFile); + assert.fileContent(expectedSourceFile, hasManyrImportRegEx); + + assert.fileContent(expectedSourceFile, expectedImport[i]); + + assert.fileContent(expectedSourceFile, expectedDecoretor[i]); + + assert.fileContent(expectedTargetFile, expectedProperty); + }); + }); + }); + + context('generate model relation with default relation name', () => { + const promptArray = [ + { + relationType: 'hasMany', + sourceModel: 'Customer', + destinationModel: 'Order', + }, + { + relationType: 'hasMany', + sourceModel: 'CustomerClass', + destinationModel: 'OrderClass', + }, + { + relationType: 'hasMany', + sourceModel: 'CustomerClassType', + destinationModel: 'OrderClassType', + }, + ]; + + const expectedDecoretor = [ + /\@hasMany\(\(\) => Order\)\n orders: Order\[\];\n/, + /\@hasMany\(\(\) => OrderClass ,\{keyTo: 'customerClassCustNumber'\}\)\n/, + /\@hasMany\(\(\) => OrderClassType ,\{keyTo: 'customerClassTypeCustNumber'\}\)\n/, + ]; + const defaultRelationName = ['orders', 'orderClasses', 'orderClassTypes']; + promptArray.forEach(function(multiItemPrompt, i) { + it('relation name should be ' + defaultRelationName[i], async () => { + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts(multiItemPrompt); + + const expectedSourceFile = path.join( + SANDBOX_PATH, + MODEL_APP_PATH, + sourceFileName[i], + ); + + assert.fileContent(expectedSourceFile, expectedDecoretor[i]); + }); + }); + }); +}); + +context('check if the controller file created ', () => { + const promptArray = [ + { + relationType: 'hasMany', + sourceModel: 'Customer', + destinationModel: 'Order', + }, + { + relationType: 'hasMany', + sourceModel: 'CustomerClass', + destinationModel: 'OrderClass', + }, + { + relationType: 'hasMany', + sourceModel: 'CustomerClassType', + destinationModel: 'OrderClassType', + }, + ]; + const controllerClass = [ + /class CustomerOrderController/, + /class CustomerClassOrderClassController/, + /class CustomerClassTypeOrderClassTypeController/, + ]; + const controllerConstructor = [ + /constructor\(\n \@repository\(CustomerRepository\) protected customerRepository: CustomerRepository,\n \) \{ \}\n/, + /constructor\(\n \@repository\(CustomerClassRepository\) protected customerClassRepository: CustomerClassRepository,\n \) \{ \}\n/, + /constructor\(\n \@repository\(CustomerClassTypeRepository\) protected customerClassTypeRepository: CustomerClassTypeRepository,\n \) \{ \}\n/, + ]; + + const indexExport = [ + /export \* from '\.\/customer-order\.controller';/, + /export \* from '\.\/customer-class-order-class\.controller';/, + /export \* from '\.\/customer-class-type-order-class-type\.controller';/, + ]; + const sourceClassnames = ['Customer', 'CustomerClass', 'CustomerClassType']; + const targetClassnames = ['Order', 'OrderClass', 'OrderClassType']; + + promptArray.forEach(function(multiItemPrompt, i) { + it('new controller file created', async () => { + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts(multiItemPrompt); + + const expectedControllerFile = path.join( + SANDBOX_PATH, + CONTROLLER_PATH, + controllerFileName[i], + ); + assert.file(expectedControllerFile); + }).timeout(10000); + + it('controller with hasMany class and constractor', async () => { + const expectedControllerFile = path.join( + SANDBOX_PATH, + CONTROLLER_PATH, + controllerFileName[i], + ); + assert.fileContent(expectedControllerFile, controllerClass[i]); + assert.fileContent(expectedControllerFile, controllerConstructor[i]); + }); + + it('the new controller file added to index.ts file', async () => { + const expectedControllerIndexFile = path.join( + SANDBOX_PATH, + CONTROLLER_PATH, + 'index.ts', + ); + + assert.fileContent(expectedControllerIndexFile, indexExport[i]); + }); + + it( + 'controller GET Array of ' + + targetClassnames[i] + + "'s belonging to " + + sourceClassnames[i], + async () => { + const getOrdersByCustomerIdRegEx = [ + /\@get\('\/customers\/{id}\/orders', {\n responses: {\n '200': {\n/, + /description: 'Array of Order\\'s belonging to Customer',\n/, + /content: {\n 'application\/json': {\n/, + /schema: { type: 'array', items: { 'x-ts-type': Order } },/, + /},\n . },\n . },\n . },\n }\)\n/, + /async find\(\n . \@param.path.number\('id'\) id: number,\n/, + /\@param.query.object\('filter'\) filter\?: Filter,\n/, + /\)\: Promise {\n/, + /return await this\.customerRepository\.orders\(id\)\.find\(filter\);\n }\n/, + ]; + const getOrdersClassByCustomerClassIdRegEx = [ + /\@get\('\/customer-classes\/{id}\/order-classes', {\n responses: {\n '200': {\n/, + /description: 'Array of OrderClass\\'s belonging to CustomerClass',\n/, + /content: {\n 'application\/json': {\n/, + /schema: { type: 'array', items: { 'x-ts-type': OrderClass } },/, + /},\n . },\n . },\n . },\n }\)\n/, + /async find\(\n . \@param.path.number\('id'\) id: number,\n/, + /\@param.query.object\('filter'\) filter\?: Filter,\n/, + /\)\: Promise {\n/, + /return await this\.customerClassRepository\.orderClasses\(id\)\.find\(filter\);\n }\n/, + ]; + const getOrdersClassTypeByCustomerClassTypeIdRegEx = [ + /\@get\('\/customer-class-types\/{id}\/order-class-types', {\n responses: {\n '200': {\n/, + /description: 'Array of OrderClassType\\'s belonging to CustomerClassType',\n/, + /content: {\n 'application\/json': {\n/, + /schema: { type: 'array', items: { 'x-ts-type': OrderClassType } },/, + /},\n . },\n . },\n . },\n }\)\n/, + /async find\(\n . \@param.path.number\('id'\) id: number,\n/, + /\@param.query.object\('filter'\) filter\?: Filter,\n/, + /\)\: Promise {\n/, + /return await this\.customerClassTypeRepository\.orderClassTypes\(id\).find\(filter\);\n }\n/, + ]; + + const getRegEx = [ + getOrdersByCustomerIdRegEx, + getOrdersClassByCustomerClassIdRegEx, + getOrdersClassTypeByCustomerClassTypeIdRegEx, + ]; + + const expectedControllerFile = path.join( + SANDBOX_PATH, + CONTROLLER_PATH, + controllerFileName[i], + ); + getRegEx[i].forEach(regex => { + assert.fileContent(expectedControllerFile, regex); + }); + }, + ); + + it( + 'controller POST ' + targetClassnames[i] + ' to ' + sourceClassnames[i], + async () => { + const postClassCreateRegEx = [ + /\@post\('\/customers\/{id}\/orders', {\n responses: {\n '200': {\n/, + /description: 'Customer model instance',\n/, + /content: { 'application\/json': { schema: { 'x-ts-type': Order } } },\n/, + /},\n . },\n .}\)\n async create\(\n/, + /\@param\.path\.number\('id'\) id: typeof Customer\.prototype\.id,\n/, + /\@requestBody\(\) order: Order,\n/, + /\): Promise {\n/, + /return await this\.customerRepository\.orders\(id\)\.create\(order\);\n }\n/, + ]; + const postMultiWordClassCreateRegEx = [ + /\@post\('\/customer-classes\/{id}\/order-classes', {\n responses: {\n '200': {\n/, + /description: 'CustomerClass model instance',\n/, + /content: { 'application\/json': { schema: { 'x-ts-type': OrderClass } } },\n/, + /},\n . },\n .}\)\n async create\(\n/, + /\@param\.path\.number\('id'\) id: typeof CustomerClass\.prototype\.custNumber,\n/, + /\@requestBody\(\) orderClass: OrderClass,\n/, + /\): Promise {\n/, + /return await this\.customerClassRepository\.orderClasses\(id\)\.create\(orderClass\);\n }\n/, + ]; + const postTypeClassCreateRegEx = [ + /\@post\('\/customer-class-types\/{id}\/order-class-types', {\n responses: {\n '200': {\n/, + /description: 'CustomerClassType model instance',\n/, + /content: { 'application\/json': { schema: { 'x-ts-type': OrderClassType } } },\n/, + /},\n . },\n .}\)\n async create\(\n/, + /\@param\.path\.number\('id'\) id: typeof CustomerClassType\.prototype\.custNumber,\n/, + /\@requestBody\(\) orderClassType: OrderClassType,\n/, + /\): Promise {\n/, + /return await this\.customerClassTypeRepository\.orderClassTypes\(id\)\.create\(orderClassType\);\n }\n/, + ]; + + const expectedControllerFile = path.join( + SANDBOX_PATH, + CONTROLLER_PATH, + controllerFileName[i], + ); + + const postRegEx = [ + postClassCreateRegEx, + postMultiWordClassCreateRegEx, + postTypeClassCreateRegEx, + ]; + + postRegEx[i].forEach(regex => { + assert.fileContent(expectedControllerFile, regex); + }); + }, + ); + + it( + 'controller ' + + targetClassnames[i] + + ' PATCH by ' + + sourceClassnames[i] + + ' id', + async () => { + const updateOrderByCustomerIdRegEx = [ + /\@patch\('\/customers\/{id}\/orders', {\n responses: {\n '200': {\n/, + /description: 'Customer.Order PATCH success count',\n/, + /content: { 'application\/json': { schema: CountSchema } },\n/, + /},\n },\n }\)\n async patch\(\n/, + /\@param\.path\.number\('id'\) id: number,\n \@requestBody\(\) order: Partial,\n/, + /\@param\.query\.object\('where', getWhereSchemaFor\(Order\)\) where\?: Where,\n/, + /\): Promise {\n/, + /return await this\.customerRepository\.orders\(id\).patch\(order, where\);\n }\n/, + ]; + + const updateOrderClassByCustomerClassIdRegEx = [ + /\@patch\('\/customer-classes\/{id}\/order-classes', {\n responses: {\n '200': {\n/, + /description: 'CustomerClass.OrderClass PATCH success count',\n/, + /content: { 'application\/json': { schema: CountSchema } },\n/, + /},\n },\n }\)\n async patch\(\n/, + /\@param\.path\.number\('id'\) id: number,\n \@requestBody\(\) orderClass: Partial,\n/, + /\@param\.query\.object\('where', getWhereSchemaFor\(OrderClass\)\) where\?: Where,\n/, + /\): Promise {\n/, + /return await this\.customerClassRepository\.orderClasses\(id\)\.patch\(orderClass, where\);\n }\n/, + ]; + + const updateOrderClassByCustomerClassTypeIdRegEx = [ + /\@patch\('\/customer-class-types\/{id}\/order-class-types', {\n responses: {\n '200': {\n/, + /description: 'CustomerClassType.OrderClassType PATCH success count',\n/, + /content: { 'application\/json': { schema: CountSchema } },\n/, + /},\n },\n }\)\n async patch\(\n/, + /\@param\.path\.number\('id'\) id: number,\n \@requestBody\(\) orderClassType: Partial,\n/, + /\@param\.query\.object\('where', getWhereSchemaFor\(OrderClassType\)\) where\?: Where,\n/, + /\): Promise {\n/, + /return await this\.customerClassTypeRepository\.orderClassTypes\(id\).patch\(orderClassType, where\);\n }\n/, + ]; + + const expectedControllerFile = path.join( + SANDBOX_PATH, + CONTROLLER_PATH, + controllerFileName[i], + ); + + const updateRegEx = [ + updateOrderByCustomerIdRegEx, + updateOrderClassByCustomerClassIdRegEx, + updateOrderClassByCustomerClassTypeIdRegEx, + ]; + + updateRegEx[i].forEach(regex => { + assert.fileContent(expectedControllerFile, regex); + }); + }, + ); + + it( + 'controller ' + + targetClassnames[i] + + ' DELETE by ' + + sourceClassnames[i] + + ' id', + async () => { + const deleteOrderByCustomerIdRegEx = [ + /\@del\('\/customers\/{id}\/orders', {\n responses: {\n '200': {\n/, + /description: 'Customer.Order DELETE success count',\n/, + /content: { 'application\/json': { schema: CountSchema } },\n/, + /},\n },\n }\)\n async delete\(\n/, + /\@param\.path\.number\('id'\) id: number,\n /, + /\@param\.query\.object\('where', getWhereSchemaFor\(Order\)\) where\?: Where,\n/, + /\): Promise {\n/, + /return await this\.customerRepository\.orders\(id\)\.delete\(where\);\n }\n}\n/, + ]; + + const deleteOrderClassByCustomerClassIdRegEx = [ + /\@del\('\/customer-classes\/{id}\/order-classes', {\n responses: {\n '200': {\n/, + /description: 'CustomerClass.OrderClass DELETE success count',\n/, + /content: { 'application\/json': { schema: CountSchema } },\n/, + /},\n },\n }\)\n async delete\(\n/, + /\@param\.path\.number\('id'\) id: number,\n /, + /\@param\.query\.object\('where', getWhereSchemaFor\(OrderClass\)\) where\?: Where,\n/, + /\): Promise {\n/, + /return await this\.customerClassRepository\.orderClasses\(id\)\.delete\(where\);\n }\n}\n/, + ]; + + const deleteOrderClassTypeByCustomerClassTypeIdRegEx = [ + /\@del\('\/customer-class-types\/{id}\/order-class-types', {\n responses: {\n '200': {\n/, + /description: 'CustomerClassType.OrderClassType DELETE success count',\n/, + /content: { 'application\/json': { schema: CountSchema } },\n/, + /},\n },\n }\)\n async delete\(\n/, + /\@param\.path\.number\('id'\) id: number,\n /, + /\@param\.query\.object\('where', getWhereSchemaFor\(OrderClassType\)\) where\?: Where,\n/, + /\): Promise {\n/, + /return await this\.customerClassTypeRepository\.orderClassTypes\(id\)\.delete\(where\);\n }\n}\n/, + ]; + + const expectedControllerFile = path.join( + SANDBOX_PATH, + CONTROLLER_PATH, + controllerFileName[i], + ); + + const deleteRegEx = [ + deleteOrderByCustomerIdRegEx, + deleteOrderClassByCustomerClassIdRegEx, + deleteOrderClassTypeByCustomerClassTypeIdRegEx, + ]; + + deleteRegEx[i].forEach(regex => { + assert.fileContent(expectedControllerFile, regex); + }); + }, + ); + }); +}); + +context('check source class repository ', () => { + const promptArray = [ + { + relationType: 'hasMany', + sourceModel: 'Customer', + destinationModel: 'Order', + }, + { + relationType: 'hasMany', + sourceModel: 'CustomerClass', + destinationModel: 'OrderClass', + }, + { + relationType: 'hasMany', + sourceModel: 'CustomerClassType', + destinationModel: 'OrderClassType', + }, + ]; + + const sourceClassnames = ['Customer', 'CustomerClass', 'CustomerClassType']; + + promptArray.forEach(function(multiItemPrompt, i) { + it(sourceClassnames[i] + ' repostitory has all imports', async () => { + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts(multiItemPrompt); + + const repositoryBasicImports = [ + /repository, HasManyRepositoryFactory} from '\@loopback\/repository';\n/, + /import \{inject, Getter\} from '\@loopback\/core';/, + ]; + + const repositoryClassImport = [ + /import \{OrderRepository\} from '\.\/order\.repository';/, + /import \{Customer, Order\} from '\.\.\/models';/, + ]; + const repositoryMultiWordClassImport = [ + /import \{OrderClassRepository\} from '\.\/order-class\.repository';/, + /import \{CustomerClass, OrderClass\} from '\.\.\/models';/, + ]; + const repositoryTypeClassImport = [ + /import \{OrderClassTypeRepository\} from '\.\/order-class-type\.repository';/, + /import \{CustomerClassType, OrderClassType\} from '\.\.\/models';/, + ]; + + const sourceRepositoryFile = path.join( + SANDBOX_PATH, + REPOSITORY_APP_PATH, + repositoryFileName[i], + ); + + repositoryBasicImports.forEach(regex => { + assert.fileContent(sourceRepositoryFile, regex); + }); + + const importRegEx = [ + repositoryClassImport, + repositoryMultiWordClassImport, + repositoryTypeClassImport, + ]; + + importRegEx[i].forEach(regex => { + assert.fileContent(sourceRepositoryFile, regex); + }); + }).timeout(10000); + + it('repository has updated constructor', async () => { + const singleWordClassConstractor = [ + /public readonly orders: HasManyRepositoryFactory;\n/, + /constructor\(\@inject\('datasources\.db'\) dataSource: DbDataSource, \@repository\.getter\('OrderRepository'\) protected orderRepositoryGetter: Getter,\) \{\n/, + /super\(Customer, dataSource\);\n this.orders = this.createHasManyRepositoryFactoryFor\('orders', orderRepositoryGetter,\);\n \}\n/, + ]; + + const multiWordClassConstractor = [ + /public readonly orderClasses: HasManyRepositoryFactory;\n/, + /constructor\(\@inject\('datasources\.myDB'\) dataSource: MyDBDataSource, \@repository\.getter\('OrderClassRepository'\) protected orderClassRepositoryGetter: Getter,\) \{\n/, + /super\(CustomerClass, dataSource\);\n this\.orderClasses = this\.createHasManyRepositoryFactoryFor\('orderClasses', orderClassRepositoryGetter,\);\n \}\n/, + ]; + const typeClassConstractor = [ + /public readonly orderClassTypes: HasManyRepositoryFactory;\n/, + /constructor\(@inject\('datasources\.myDB'\) dataSource: MyDBDataSource, @repository\.getter\('OrderClassTypeRepository'\) protected orderClassTypeRepositoryGetter: Getter,\) \{\n/, + /super\(CustomerClassType, dataSource\);\n this\.orderClassTypes = this\.createHasManyRepositoryFactoryFor\('orderClassTypes', orderClassTypeRepositoryGetter,\);\n \}/, + ]; + + const sourceRepositoryFile = path.join( + SANDBOX_PATH, + REPOSITORY_APP_PATH, + repositoryFileName[i], + ); + + const updateConstructorRegEx = [ + singleWordClassConstractor, + multiWordClassConstractor, + typeClassConstractor, + ]; + updateConstructorRegEx[i].forEach(regex => { + assert.fileContent(sourceRepositoryFile, regex); + }); + }); + }); +}); diff --git a/packages/cli/test/integration/generators/relation.integration.js b/packages/cli/test/integration/generators/relation.integration.js new file mode 100644 index 000000000000..7e5cc5a2bc99 --- /dev/null +++ b/packages/cli/test/integration/generators/relation.integration.js @@ -0,0 +1,282 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +const path = require('path'); +const assert = require('yeoman-assert'); +const testlab = require('@loopback/testlab'); +const fs = require('fs'); +const expect = testlab.expect; +const TestSandbox = testlab.TestSandbox; +const generator = path.join(__dirname, '../../../generators/relation'); +const SANDBOX_FILES = require('../../fixtures/relation').SANDBOX_FILES2; +const SANDBOX_FILES3 = require('../../fixtures/relation').SANDBOX_FILES3; +const SANDBOX_FILES4 = require('../../fixtures/relation').SANDBOX_FILES4; +const SANDBOX_FILES5 = require('../../fixtures/relation').SANDBOX_FILES5; +const testUtils = require('../../test-utils'); + +// Test Sandbox +const SANDBOX_PATH = path.resolve(__dirname, '..', '.sandbox'); +const MODEL_APP_PATH = 'src/models'; +const CONTROLLER_PATH = 'src/controllers'; +const REPOSITORY_APP_PATH = 'src/repositories'; +const sandbox = new TestSandbox(SANDBOX_PATH); + +describe('lb4 relation', function() { + // tslint:disable-next-line:no-invalid-this + + this.timeout(30000); + + beforeEach('reset sandbox', async () => { + await sandbox.reset(); + }); // special cases regardless of the repository type + + context('Execute relation with wrong relation type', () => { + it('rejects invalid relation type provided via CLI arguments', () => { + return expect( + testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withArguments('--relationType foo'), + ).to.be.rejectedWith(/Incorrect relation type/); + }); + it('rejects invalid relation type provided via prompt', () => { + const prompt = { + relationType: 'foo', + }; + + return expect( + testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts(prompt), + ).to.be.rejectedWith(/Incorrect relation type/); + }); + }); + + context('Execute relation with wrong source model', () => { + it('rejects unknown source model set via CLI arguments', () => { + return expect( + testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withArguments('--relationType hasMany --sourceModel=blabla'), + ).to.be.rejectedWith(/\"blabla\" model does not exist\./); + }); + it('rejects unknown source model set via prompt', () => { + const prompt = { + relationType: 'hasMany', + sourceModel: 'blabla', + }; + + return expect( + testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts(prompt), + ).to.be.rejectedWith(/\"blabla\" model does not exist\./); + }); + }); + + context('Execute relation with wrong destination model', () => { + it('rejects unknown destination model set via CLI arguments', () => { + return expect( + testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withArguments( + '--relationType hasMany --sourceModel=Customer --destinationModel=blabla', + ), + ).to.be.rejectedWith(/\"blabla\" model does not exist\./); + }); + it('rejects unknown destination model set via prompt', () => { + const prompt = { + relationType: 'hasMany', + sourceModel: 'Customer', + destinationModel: 'blabla', + }; + + return expect( + testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts(prompt), + ).to.be.rejectedWith(/\"blabla\" model does not exist\./); + }); + }); + context( + 'Execute hasMany relation with missing primary key in source model', + () => { + it("rejects relation when source model doesn't have primary Key", () => { + const prompt = { + relationType: 'hasMany', + sourceModel: 'Nokey', + destinationModel: 'Customer', + }; + + return expect( + testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts(prompt), + ).to.be.rejectedWith(/Source model primary key does not exist\./); + }); + }, + ); + + context( + 'Execute belongsTo relation with missing primary key in destination model', + () => { + it("rejects relation when destination model doesn't have primary Key", () => { + const prompt = { + relationType: 'belongsTo', + sourceModel: 'Customer', + destinationModel: 'Nokey', + }; + + return expect( + testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts(prompt), + ).to.be.rejectedWith(/Target model primary key does not exist/); + }); + }, + ); + context('add new controller to exisiting index file', () => { + it('check if the controller exported to index file ', async () => { + const prompt = { + relationType: 'hasMany', + sourceModel: 'Customer', + destinationModel: 'Order', + }; + + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts(prompt); + const expectedControllerIndexFile = path.join( + SANDBOX_PATH, + CONTROLLER_PATH, + 'index.ts', + ); + + assert.equalsFileContent( + expectedControllerIndexFile, + "export * from './customer.controller';\nexport * from './customer-order.controller';\n", + ); + }); + }); + + context('add controller to existing index file only once', () => { + it('check if the controller exported to index file only once', async () => { + const prompt = { + relationType: 'belongsTo', + sourceModel: 'Order', + destinationModel: 'Customer', + }; + + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES4, + }), + ) + .withPrompts(prompt); + const expectedControllerIndexFile = path.join( + SANDBOX_PATH, + CONTROLLER_PATH, + 'index.ts', + ); + + assert.equalsFileContent( + expectedControllerIndexFile, + "export * from './order-customer.controller';\n", + ); + }); + }); + + context('Execute relation when models does not exist', () => { + it('rejects relation when models does not exist', () => { + const prompt = { + relationType: 'belongsTo', + sourceModel: 'Customer', + destinationModel: 'Nokey', + }; + + return expect( + testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES3, + }), + ) + .withPrompts(prompt), + ).to.be.rejectedWith(/No models found/); + }); + }); + + context('Execute relation when property already exist in the model', () => { + it('rejects relation when property already exist in the model', () => { + const prompt = { + relationType: 'hasMany', + sourceModel: 'Customer', + destinationModel: 'Order', + }; + + return expect( + testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES5, + }), + ) + .withPrompts(prompt), + ).to.be.rejectedWith( + /property orders already exist in the model Customer/, + ); + }); + }); +}); diff --git a/packages/cli/test/integration/lib/base-generator.js b/packages/cli/test/integration/lib/base-generator.js index d6c9608e875d..4a76e18ff2aa 100644 --- a/packages/cli/test/integration/lib/base-generator.js +++ b/packages/cli/test/integration/lib/base-generator.js @@ -9,11 +9,13 @@ const assert = require('yeoman-assert'); const testUtils = require('../../test-utils'); const path = require('path'); const mockStdin = require('mock-stdin'); +const process = require('process'); module.exports = function(generator) { return function() { describe('usage', () => { it('prints lb4', () => { + process.chdir(path.resolve(__dirname, '..', '..', '..')); const gen = testUtils.testSetUpGen(generator); const helpText = gen.help(); assert(helpText.match(/lb4 /));