diff --git a/docs/site/Relation-generator.md b/docs/site/Relation-generator.md new file mode 100644 index 000000000000..359c7b354cde --- /dev/null +++ b/docs/site/Relation-generator.md @@ -0,0 +1,72 @@ +--- +lang: en +title: 'Relation generator' +keywords: LoopBack 4.0, LoopBack 4 +sidebar: lb4_sidebar +permalink: /doc/en/lb4/Relation-generator.html +--- + +{% include content/generator-create-app.html lang=page.lang %} + +The models involved in the relation must exist before running this generator. + +### Synopsis + +Adds a new `Relation` between existing source and target models in a LoopBack +application. + +```sh +lb4 relation [options] [] +``` + +### Options + +TBD + +### Arguments + +`` - Name of the ID property that will be created in the source +model. If not provided, the tool will use `id` as the default name when it +prompts for the ID property name. + +### 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 + - hasOne + - belongsTo + +- **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. Note: The selected + `source` model will not appear in the `target` model list. + +- **Name of the `ID property` in the source model.** _(Optional, default: `id`)_ + Prompts for the ID property name (serves as the foreign key) in the source + model. Note: Leave blank to use the default. + +- **Name for the `property relation` in the source model.** TBD + +### Output + +Once all the prompts have been answered, the CLI will do the following: + +- 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}-{targetModel}.controller.ts` +- Update `/src/controllers/index.ts` to export the newly created Controller + class. diff --git a/packages/cli/generators/relation/index.js b/packages/cli/generators/relation/index.js new file mode 100644 index 000000000000..f55002919aa3 --- /dev/null +++ b/packages/cli/generators/relation/index.js @@ -0,0 +1,442 @@ +// Copyright IBM Corp. 2017,2018. 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 tsquery = require('../../lib/ast-helper'); +const ast = require('ts-simple-ast'); +const relationUtils = require('./relationutils'); + +const RelationBelongsTo = require('./relationBelongsTo'); +const RelationHasMany = require('./relationHasMany'); +const RelationHasOne = require('./relationHasOne'); + +const ERROR_INCORRECT_RELATION_TYPE = 'Incorrect Relation Type'; +const ERROR_MODEL_DOES_NOT_EXIST = 'model does not exist.'; +const ERROR_NO_DESTINATION_MODEL_SELECTED = 'No destination model selected'; +const ERROR_NO_MODELS_FOUND = 'No models found in'; +const ERROR_NO_SOURCE_MODEL_SELECTED = 'No source model selected'; +const ERROR_RELATION_TYPE_PARAMETER_SHOULD_BE_SPECIFIED = + "'relationType' parameter should be specified."; +const ERROR_SOURCE_MODEL_PRIMARY_KEY_DOES_NOT_EXIST = + 'Source 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 = 'Property name for the relation'; +const PROMPT_MESSAGE_FOREIGN_KEY_NAME = 'Foreign key name for the relation'; + +const relPathControllersFolder = '/controllers'; +const relPathModelsFolder = '/models'; +const relPathRepositoriesFolder = '/repositories'; + +module.exports = class RelationGenerator extends ArtifactGenerator { + constructor(args, opts) { + super(args, opts); + this.args = args; + this.opts = opts; + + 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', + }); + } + + /** + * get the property name for the id field + * @param {string} modelName + */ + async _getModelPrimaryKeyProperty(modelName) { + const modelFile = path.join( + this.artifactInfo.modelDir, + utils.getModelFileName(modelName), + ); + const fileContent = this.fs.read(modelFile, {}); + + return tsquery.getIdFromModel(fileContent); + } + + _getKeyType(sourceFile, propertyName) { + const classObj = this._getClassObj(sourceFile); + if ( + classObj + .getProperties() + .map(x => x.getName()) + .includes(propertyName) + ) { + return classObj + .getProperty(propertyName) + .getType() + .getText(); + } + } + + _getClassObj(fileName) { + const className = fileName.getClasses()[0].getNameOrThrow(); + return fileName.getClassOrThrow(className); + } + + async _calcSourceModelPrimaryKey() { + this.artifactInfo.sourceModelPrimaryKey = await this._getModelPrimaryKeyProperty( + this.artifactInfo.sourceModel, + ); + + if (this.artifactInfo.sourceModelPrimaryKey === null) { + throw new Error(ERROR_SOURCE_MODEL_PRIMARY_KEY_DOES_NOT_EXIST); + } + } + + /** + * Read source model file and get type of the primary key. + * + * @return string + */ + _calcSourceModelPrimaryKeyType() { + let project = new ast.Project(); + + const sourceFile = path.join( + this.artifactInfo.modelDir, + utils.getModelFileName(this.artifactInfo.sourceModel), + ); + const sf = project.addExistingSourceFile(sourceFile); + this.artifactInfo.sourceModelPrimaryKeyType = this._getKeyType( + sf, + this.artifactInfo.sourceModelPrimaryKey, + ); + } + + /** + * Generate default foreign key name. Foreign key name use in target model. + */ + _calcDefaultForeignKey() { + this.artifactInfo.defaultForeignKeyName = + utils.camelCase(this.artifactInfo.sourceModel) + + utils.toClassName(this.artifactInfo.sourceModelPrimaryKey); + } + + setOptions() { + return super.setOptions(); + } + + _setupGenerator() { + this.artifactInfo = { + type: 'relation', + rootDir: utils.sourceRootDir, + outDir: utils.sourceRootDir, + }; + this.artifactInfo.modelDir = path.resolve( + this.artifactInfo.rootDir, + utils.modelsDir, + ); + } + + _getDefaultRelationName() { + var defaultRelationName; + switch (this.artifactInfo.relationType) { + case relationUtils.relationType.belongsTo: + defaultRelationName = + utils.camelCase(this.artifactInfo.destinationModel) + + utils.toClassName(this.artifactInfo.sourceModelPrimaryKey); + break; + case relationUtils.relationType.hasMany: + defaultRelationName = utils.pluralize( + utils.camelCase(this.artifactInfo.destinationModel), + ); + break; + case relationUtils.relationType.hasOne: + defaultRelationName = utils.camelCase( + this.artifactInfo.destinationModel, + ); + break; + default: + throw new Error(ERROR_INCORRECT_RELATION_TYPE); + } + + 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) { + throw 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]) { + if (!modelList.includes(this.options[parameter])) { + throw 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] === undefined, + default: modelList[0], + }, + ]) + .then(props => { + debug(`props after ${parameter} prompt: ${inspect(props)}`); + Object.assign(this.artifactInfo, props); + return props; + }) + .catch(err => { + debug(`Error during ${parameter} prompt: ${err.stack}`); + return this.exit(err); + }); + } + + // Prompt a user for Relation type + async promptRelationType() { + if (this.shouldExit()) return false; + + if (this.options.relationType) { + debug( + `Relation type received from command line: ${ + this.options.relationType + }`, + ); + this.artifactInfo.relationType = this.options.relationType; + } + + const relationTypeChoices = Object.keys(relationUtils.relationType); + return this.prompt([ + { + type: 'list', + name: 'relationType', + message: PROMPT_BASE_RELATION_CLASS, + choices: relationTypeChoices, + when: this.artifactInfo.relationType === undefined, + validate: utils.validateClassName, + default: relationTypeChoices[0], + }, + ]) + .then(props => { + debug(`props after relation type prompt: ${inspect(props)}`); + Object.assign(this.artifactInfo, props); + return props; + }) + .catch(err => { + debug(`Error during relation type prompt: ${err.stack}`); + return this.exit(err); + }); + } + + // 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; + + if (_.isEmpty(this.artifactInfo.sourceModel)) { + return this.exit(new Error(`${ERROR_NO_SOURCE_MODEL_SELECTED}`)); + } + + if (_.isEmpty(this.artifactInfo.destinationModel)) { + return this.exit(new Error(`${ERROR_NO_DESTINATION_MODEL_SELECTED}`)); + } + + await this._calcSourceModelPrimaryKey(); + this._calcSourceModelPrimaryKeyType(); + this._calcDefaultForeignKey(); + + if ( + this.artifactInfo.relationType === relationUtils.relationType.belongsTo + ) { + return; + } + let project = new ast.Project(); + + const destinationFile = path.join( + this.artifactInfo.modelDir, + utils.getModelFileName(this.artifactInfo.destinationModel), + ); + const df = project.addExistingSourceFile(destinationFile); + const cl = this._getClassObj(df); + this.artifactInfo.destinationModelForeignKeyExist = cl + .getProperties() + .map(x => x.getName()) + .includes(this.artifactInfo.defaultForeignKeyName); + + if (!this.artifactInfo.destinationModelForeignKeyExist) { + 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); + return props; + }) + .catch(err => { + debug(`Error during foreign key name prompt: ${err.stack}`); + return this.exit(err); + }); + } else { + this.artifactInfo.foreignKeyName = this.artifactInfo.defaultForeignKeyName; + } + } + + async promptRelationName() { + if (this.shouldExit()) return false; + + 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; + }) + .catch(err => { + debug(`Error during relation name prompt: ${err.stack}`); + return this.exit(err); + }); + } + + async scaffold() { + if (!this.artifactInfo.relationType) { + throw new Error(ERROR_RELATION_TYPE_PARAMETER_SHOULD_BE_SPECIFIED); + } + debug('Invoke generator...'); + + var relation; + + this.artifactInfo.name = this.artifactInfo.relationType; + + switch (this.artifactInfo.relationType) { + case relationUtils.relationType.belongsTo: + relation = new RelationBelongsTo(this.args, this.opts); + break; + case relationUtils.relationType.hasMany: + relation = new RelationHasMany(this.args, this.opts); + break; + case relationUtils.relationType.hasOne: + relation = new RelationHasOne(this.args, this.opts); + break; + default: + throw new Error(ERROR_INCORRECT_RELATION_TYPE); + } + + await relation.generateAll(this.artifactInfo); + } + + async end() { + await super.end(); + } +}; diff --git a/packages/cli/generators/relation/relation.js b/packages/cli/generators/relation/relation.js new file mode 100644 index 000000000000..0597ac34d3ab --- /dev/null +++ b/packages/cli/generators/relation/relation.js @@ -0,0 +1,49 @@ +'use strict'; + +const ArtifactGenerator = require('../../lib/artifact-generator'); +const path = require('path'); +const utils = require('../../lib/utils'); + +module.exports = class RelationGenerator extends ArtifactGenerator { + constructor(args, opts) { + super(args, opts); + } + + _setupGenerator() { + super._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', + ); + } + + async generateAll(options) { + this.generateControllers(options); + await this.generateModels(options); + } + + generateControllers(options) { + throw new Error('Not implemented'); + } + + generateModels(options) { + throw new Error('Not implemented'); + } + + generateRepositories(options) { + throw new Error('Not implemented'); + } +}; diff --git a/packages/cli/generators/relation/relationBelongsTo.js b/packages/cli/generators/relation/relationBelongsTo.js new file mode 100644 index 000000000000..d610067d96cf --- /dev/null +++ b/packages/cli/generators/relation/relationBelongsTo.js @@ -0,0 +1,119 @@ +'use strict'; + +const ast = require('ts-simple-ast'); +const path = require('path'); +const RelationGenerator = require('./relation'); +const utils = require('../../lib/utils'); +const relationUtils = require('./relationutils'); + +const CONTROLLER_TEMPLATE_PATH_BELONGS_TO = + 'controller-relation-template-belongs-to.ts.ejs'; + +module.exports = class RelationBelongsTo extends RelationGenerator { + constructor(args, opts) { + super(args, opts); + } + + generateControllers(options) { + 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.targetModelName = utils.kebabCase( + options.destinationModel, + ); + + this.artifactInfo.relationPropertyName = options.relationName; + this.artifactInfo.sourceModelPrimaryKey = options.sourceModelPrimaryKey; + this.artifactInfo.sourceModelPrimaryKeyType = + options.sourceModelPrimaryKeyType; + + 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); + return; + } + + async generateModels(options) { + let path = this.artifactInfo.modelDir; + let sourceModel = options.sourceModel; + let targetModel = options.destinationModel; + let relationType = options.relationType; + let relationName = options.relationName; + let fktype = options.sourceModelPrimaryKeyType; + + // add keyTo when needed in both hasMany and belongsTo relation + + let project = new ast.Project(); + + const sourceFile = relationUtils.addFileToProject( + project, + path, + utils.kebabCase(sourceModel), + ); + + const targetFile = relationUtils.addFileToProject( + project, + path, + utils.kebabCase(targetModel), + ); + + const sourceClass = relationUtils.getClassObj(sourceFile, sourceModel); + const targetClass = relationUtils.getClassObj(targetFile, targetModel); + + let modelProperty; + + relationUtils.isRelationExist(sourceClass, relationName); + modelProperty = this.getBelongsTo( + targetModel, + relationName, + utils.toClassName(fktype), + ); + + relationUtils.addPropertyToModel(sourceClass, modelProperty); + relationUtils.addRequiredImports( + sourceFile, + targetModel, + relationType, + targetModel, + ); + sourceClass.formatText(); + await sourceFile.save(); + } + + generateRepositories(options) { + throw new Error('Not implemented'); + } + + getBelongsTo(className, relationName, fktype) { + let relationProperty; + relationProperty = { + decorators: [{name: 'belongsTo', arguments: ['() => ' + className]}], + name: relationName, + type: fktype, + }; + return relationProperty; + } +}; diff --git a/packages/cli/generators/relation/relationHasMany.js b/packages/cli/generators/relation/relationHasMany.js new file mode 100644 index 000000000000..97e73e748ce6 --- /dev/null +++ b/packages/cli/generators/relation/relationHasMany.js @@ -0,0 +1,139 @@ +'use strict'; + +const ast = require('ts-simple-ast'); +const path = require('path'); +const RelationGenerator = require('./relation'); +const relationUtils = require('./relationutils'); +const utils = require('../../lib/utils'); + +const CONTROLLER_TEMPLATE_PATH_HAS_MANY = + 'controller-relation-template-has-many.ts.ejs'; + +module.exports = class RelationHasMany extends RelationGenerator { + constructor(args, opts) { + super(args, opts); + } + + 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.targetModelName = utils.kebabCase( + options.destinationModel, + ); + this.artifactInfo.relationPropertyName = 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); + return; + } + + async generateModels(options) { + let path = this.artifactInfo.modelDir; + let sourceModel = options.sourceModel; + let targetModel = options.destinationModel; + let relationType = options.relationType; + let relationName = options.relationName; + let fktype = options.sourceModelPrimaryKeyType; + let foreignKey = options.defaultForeignKeyName; + let isForeignKeyExist = options.destinationModelForeignKeyExist; + + if (!isForeignKeyExist) { + foreignKey = options.foreignKeyName; + } + let isDefaultForeignKey = + options.defaultForeignKeyName == options.destinationModelForeignKeyName; + + let project = new ast.Project(); + + const sourceFile = relationUtils.addFileToProject( + project, + path, + utils.kebabCase(sourceModel), + ); + + const targetFile = relationUtils.addFileToProject( + project, + path, + utils.kebabCase(targetModel), + ); + + const sourceClass = relationUtils.getClassObj(sourceFile, sourceModel); + const targetClass = relationUtils.getClassObj(targetFile, targetModel); + + let modelProperty; + + relationUtils.isRelationExist(sourceClass, relationName); + if (isForeignKeyExist) { + relationUtils.vlidateType(targetClass, foreignKey, fktype); + } else { + modelProperty = relationUtils.addForeginKey(foreignKey, fktype); + relationUtils.addPropertyToModel(targetClass, modelProperty); + targetClass.formatText(); + await targetFile.save(); + } + modelProperty = this.getHasMany( + targetModel, + relationName, + isDefaultForeignKey, + foreignKey, + ); + + relationUtils.addPropertyToModel(sourceClass, modelProperty); + relationUtils.addRequiredImports( + sourceFile, + targetModel, + relationType, + targetModel, + ); + sourceClass.formatText(); + await sourceFile.save(); + } + + generateRepositories(options) { + throw new Error('Not implemented'); + } + + getHasMany(className, relationName, isDefaultForeignKey, foreignKey) { + let relationDecoretor = [ + { + name: 'hasMany', + arguments: ['() => ' + className + " ,{keyTo: '" + foreignKey + "'}"], + }, + ]; + if (isDefaultForeignKey) { + relationDecoretor = [ + {name: 'hasMany', arguments: ['() => ' + className]}, + ]; + } + let relationProperty = { + decorators: relationDecoretor, + name: relationName, + type: className + '[]', + }; + return relationProperty; + } +}; diff --git a/packages/cli/generators/relation/relationHasOne.js b/packages/cli/generators/relation/relationHasOne.js new file mode 100644 index 000000000000..0de64223378f --- /dev/null +++ b/packages/cli/generators/relation/relationHasOne.js @@ -0,0 +1,21 @@ +'use strict'; + +const RelationGenerator = require('./relation'); + +module.exports = class RelationHasOne extends RelationGenerator { + constructor(args, opts) { + super(args, opts); + } + + generateControllers(options) { + throw new Error('Not implemented'); + } + + generateModels(options) { + throw new Error('Not implemented'); + } + + generateRepositories(options) { + throw new Error('Not implemented'); + } +}; diff --git a/packages/cli/generators/relation/relationutils.js b/packages/cli/generators/relation/relationutils.js new file mode 100644 index 000000000000..d75e9e85f695 --- /dev/null +++ b/packages/cli/generators/relation/relationutils.js @@ -0,0 +1,158 @@ +'use strict'; + +const utils = require('../../lib/utils'); + +exports.relationType = { + belongsTo: 'belongsTo', + hasMany: 'hasMany', + hasOne: 'hasOne', +}; + +exports.addFileToProject = function(project, path, modelName) { + const fileName = path + '/' + modelName + '.model.ts'; + return project.addExistingSourceFile(fileName); +}; + +exports.getClassObj = function(fileName, modelName) { + return fileName.getClassOrThrow(modelName); +}; + +exports.getClassesCount = function(fileName) { + return fileName.getClasses().length; +}; + +exports.isPropertyExist = function(classObj, propertyName) { + return classObj + .getProperties() + .map(x => x.getName()) + .includes(propertyName); +}; + +exports.isRelationExist = function(classObj, propertyName) { + if (this.isPropertyExist(classObj, propertyName)) { + console.log('property ' + propertyName + ' exsist in the model'); + throw new Error(' Property exsists'); + } + return; +}; + +exports.vlidateType = function(classObj, foriegnKeyName, foriegnKeyType) { + if ( + utils.lowerCase( + classObj + .getProperty(foriegnKeyName) + .getType() + .getText(), + ) != foriegnKeyType + ) { + console.log(' foreignKey type has wrong Type '); + throw new Error('foreginKey Type Error'); + } + return; +}; + +exports.addForeginKey = function(foreginKey, sourceModelPrimaryKeyType) { + let fkProperty = { + decorators: [ + { + name: 'property', + arguments: ["{\n type : '" + sourceModelPrimaryKeyType + "',\n}"], + }, + ], + name: foreginKey + '?', + type: sourceModelPrimaryKeyType, + }; + return fkProperty; +}; + +exports.addPropertyToModel = function(classOBj, modelProperty) { + classOBj.insertProperty(this.getPropertiesCount(classOBj), modelProperty); + 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, + targetModel, + relationType, + targetClassName, +) { + let importsArray = this.getRequiredImports( + targetModel, + relationType, + targetClassName, + ); + while (importsArray.length > 0) { + let currentImport = importsArray.pop(); + this.addCurrentImport(sourceFile, currentImport); + } +}; + +exports.getRequiredImports = function( + targetModel, + relationType, + targetClassName, +) { + let importsArray = [ + { + name: targetClassName, + module: './' + utils.kebabCase(targetModel) + '.model', + }, + { + name: relationType, + module: '@loopback/repository', + }, + ]; + + return importsArray; +}; + +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; + let 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) { + let allImports = sourceFile.getImportDeclarations(); + let relevantImports = allImports.filter( + imp => imp.getModuleSpecifierValue() == moduleName, + ); + return relevantImports; +}; 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..09cd9a1932e2 --- /dev/null +++ b/packages/cli/generators/relation/templates/controller-relation-template-belongs-to.ts.ejs @@ -0,0 +1,23 @@ +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('/<%= sourceModelName %>s/{id}/<%= targetModelName %>') + async get<%= targetModelClassName %>( + @param.path.<%= sourceModelPrimaryKeyType %>('id') <%= relationPropertyName %>: typeof <%= sourceModelClassName %>.prototype.<%= sourceModelPrimaryKey %>, + ): Promise<<%= targetModelClassName %>> { + return await this.<%= paramSourceRepository %>.<%= paramTargetModel %>(<%= relationPropertyName %>); + } +} 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..c7e0df2976cc --- /dev/null +++ b/packages/cli/generators/relation/templates/controller-relation-template-has-many.ts.ejs @@ -0,0 +1,89 @@ +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('/<%= sourceModelName %>s/{id}/<%= targetModelName %>s', { + 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 %>.<%= targetModelName %>s(id).find(filter); + } + + @post('/<%= sourceModelName %>s/{id}/<%= targetModelName %>s', { + 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() <%= targetModelName %>: <%= targetModelClassName %>, + ): Promise<<%= targetModelClassName %>> { + return await this.<%= paramSourceRepository %>.<%= targetModelName %>s(id).create(<%= targetModelName %>); + } + + @patch('/<%= sourceModelName %>s/{id}/<%= targetModelName %>s', { + responses: { + '200': { + description: '<%= sourceModelClassName %>.<%= targetModelClassName %> PATCH success count', + content: { 'application/json': { schema: CountSchema } }, + }, + }, + }) + async patch( + @param.path.<%= sourceModelPrimaryKeyType %>('id') id: <%= sourceModelPrimaryKeyType %>, + @requestBody() <%= targetModelName %>: Partial<<%= targetModelClassName %>>, + @param.query.object('where', getWhereSchemaFor(<%= targetModelClassName %>)) where?: Where, + ): Promise { + return await this.<%= paramSourceRepository %>.<%= targetModelName %>s(id).patch(<%= targetModelName %>, where); + } + + @del('/<%= sourceModelName %>s/{id}/<%= targetModelName %>s', { + 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 %>.<%= targetModelName %>s(id).delete(where); + } +} diff --git a/packages/cli/lib/cli.js b/packages/cli/lib/cli.js index 9925d79c4a8c..1d948659c575 100644 --- a/packages/cli/lib/cli.js +++ b/packages/cli/lib/cli.js @@ -79,6 +79,10 @@ function setupGenerators() { path.join(__dirname, '../generators/openapi'), PREFIX + 'openapi', ); + env.register( + path.join(__dirname, '../generators/relation'), + PREFIX + 'relation', + ); return env; } diff --git a/packages/cli/package.json b/packages/cli/package.json index fd61b2224008..07280d833128 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -55,6 +55,7 @@ "semver": "^5.5.0", "swagger-parser": "^6.0.5", "swagger2openapi": "^5.1.0", + "ts-simple-ast": "^21.0.2", "typescript": "^3.1.1", "unicode-10.0.0": "^0.7.4", "update-notifier": "^2.5.0", diff --git a/packages/cli/test/fixtures/relation/index.js b/packages/cli/test/fixtures/relation/index.js new file mode 100644 index 000000000000..8106aca04bc3 --- /dev/null +++ b/packages/cli/test/fixtures/relation/index.js @@ -0,0 +1,91 @@ +const DATASOURCE_APP_PATH = 'src/datasources'; +const MODEL_APP_PATH = 'src/models'; +const REPOSITORY_APP_PATH = 'src/repositories'; +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: 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', + }), + }, +]; 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/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/integration/cli/cli.integration.js b/packages/cli/test/integration/cli/cli.integration.js index d0272d17c61b..13005af50227 100644 --- a/packages/cli/test/integration/cli/cli.integration.js +++ b/packages/cli/test/integration/cli/cli.integration.js @@ -24,7 +24,8 @@ describe('cli', () => { expect(entries).to.eql([ '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', + 'lb4 model\n lb4 repository\n lb4 service\n lb4 example\n ' + + 'lb4 openapi\n lb4 relation', ]); }); @@ -42,7 +43,8 @@ describe('cli', () => { expect(entries).to.containEql('Available commands: '); 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', + 'lb4 model\n lb4 repository\n lb4 service\n lb4 example\n ' + + 'lb4 openapi\n lb4 relation', ); }); diff --git a/packages/cli/test/integration/generators/model.relation.integration.js b/packages/cli/test/integration/generators/model.relation.integration.js new file mode 100644 index 000000000000..f851eb08f367 --- /dev/null +++ b/packages/cli/test/integration/generators/model.relation.integration.js @@ -0,0 +1,77 @@ +// Copyright IBM Corp. 2018. 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 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 + describe('generate model relation', () => { + it('generates lb4 relation', async () => { + const multiItemPrompt = { + relationType: 'hasMany', + sourceModel: 'Customer', + destinationModel: 'Order', + relationName: 'orders', + }; + + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts(multiItemPrompt); + + const expectedFile = path.join( + SANDBOX_PATH, + MODEL_APP_PATH, + 'customer.model.ts', + ); + + assert.file(expectedFile); + assert.fileContent( + expectedFile, + /import \{Entity, model, property, hasMany \} from '@loopback\/repository';/, + ); + + assert.fileContent( + expectedFile, + /import \{ Order \} from "\.\/order\.model";/, + ); + + assert.fileContent( + expectedFile, + /@hasMany\(\(\) => Order, \{ keyTo: 'customerId' \}\)/, + ); + + assert.fileContent(expectedFile, /orders: Order\[\];/); + }); + }); +}); 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..dab2dbd14f8b --- /dev/null +++ b/packages/cli/test/integration/generators/relation.integration.js @@ -0,0 +1 @@ +('use strict');