diff --git a/docs/site/Repository-generator.md b/docs/site/Repository-generator.md new file mode 100644 index 000000000000..eb9c0efb9e61 --- /dev/null +++ b/docs/site/Repository-generator.md @@ -0,0 +1,98 @@ +--- +lang: en +title: 'Repository generator' +keywords: LoopBack 4.0, LoopBack 4 +sidebar: lb4_sidebar +permalink: /doc/en/lb4/Repository-generator.html +--- + +{% include content/generator-create-app.html lang=page.lang %} + +### Synopsis + +Adds a new +[Repository class (or multiple backed by the same datasource)](Repositories.md) +to a LoopBack application with one single command. + +```sh +lb4 repository [options] [] +``` + +### Options + +`--datasource` : _(Optional)_ name of a valid datasource already created in +src/datasources + +`--model` : _(Optional)_ name of a valid model already created in src/models + +`--id` : _(Optional)_ name of the property serving as **ID** in the selected +model. If you supply this value, the CLI will not try to infer this value from +the selected model file. + +### Configuration file + +This generator supports a config file with the following format, see the +Standard options below to see different ways you can supply this configuration +file. + +```ts +{ + "name": "repositoryNameToBeGenerated", + "datasource": "validDataSourceName", + "model": "validDModelName", + "id": "anOptionalNameForID" +} +``` + +### Notes + +Service oriented datasources such as REST or SOAP are not considered valid in +this context and will not be presented to you in the selection list. + +There should be at least one valid _(KeyValue or Persisted)_ data source and one +model already created in their respective directories. + +{% include_relative includes/CLI-std-options.md %} + +### Arguments + +`` - Optional argument specifyng the respository name to be generated. In +case you select multiple models, the first model will take this argument for its +repository file name. + +### Interactive Prompts + +The tool will prompt you for: + +- **Please select the datasource.** _(name)_ If the name of the datasource had + been supplied from the command line, the prompt is skipped, otherwise it will + present you the list of available datasources to select one. It will use this + datasource to check what kind of repository it will generate. + +- **Select the model(s) you want to generate a repository.** _(model)_ If the + name of the model had been supplied from the command line with `--model` + option and it is a valid model, then the prompt is skipped, otherwise it will + present the error `Error: No models found` in the console. + + If no `--model` is supplied, then the it will present you with a valid list of + models from `src/models` directory and you will be able to select one or + multiple models. The tool will generate a repository for each of the selected + models. + + **NOTE:** The tool will inspect each of the selected models and try to find + the name of the property serving as **ID** for the model. + +- **Please enter the name of the ID property for _modelName_.** _(id)_ If the + CLI cannot find the corresponding ID property name for the model, it will + prompt you to enter a name here. If you don't specify any name, it will use + _id_ as the default one. + +### Output + +Once all the prompts have been answered, the CLI will do the following for each +of the selected models. + +- Create a Repository class as follows: + `/src/repositories/${modelName}.repository.ts` +- Update `/src/repositories/index.ts` to export the newly created Repository + class. diff --git a/docs/site/sidebars/lb4_sidebar.yml b/docs/site/sidebars/lb4_sidebar.yml index a6404836bbf1..a2cb158f55a6 100644 --- a/docs/site/sidebars/lb4_sidebar.yml +++ b/docs/site/sidebars/lb4_sidebar.yml @@ -223,6 +223,10 @@ children: url: Model-generator.html output: 'web, pdf' + - title: 'Repository generator' + url: Repository-generator.html + output: 'web, pdf' + - title: 'OpenAPI generator' url: OpenAPI-generator.html output: 'web, pdf' diff --git a/docs/site/tables/lb4-artifact-commands.html b/docs/site/tables/lb4-artifact-commands.html index 0454b758fb78..ee56b14b6492 100644 --- a/docs/site/tables/lb4-artifact-commands.html +++ b/docs/site/tables/lb4-artifact-commands.html @@ -29,6 +29,12 @@ Model generator + + lb4 repository + Add new repositories for selected model(s) to a LoopBack 4 application + Repository generator + + lb4 openapi Generate controllers and models from OpenAPI specs diff --git a/packages/cli/generators/model/index.js b/packages/cli/generators/model/index.js index 844a07deb729..8fff3babaa0c 100644 --- a/packages/cli/generators/model/index.js +++ b/packages/cli/generators/model/index.js @@ -30,12 +30,12 @@ module.exports = class ModelGenerator extends ArtifactGenerator { _setupGenerator() { this.artifactInfo = { type: 'model', - rootDir: 'src', + rootDir: utils.sourceRootDir, }; this.artifactInfo.outDir = path.resolve( this.artifactInfo.rootDir, - 'models', + utils.modelsDir, ); // Model Property Types @@ -190,8 +190,7 @@ module.exports = class ModelGenerator extends ArtifactGenerator { debug('scaffolding'); // Data for templates - this.artifactInfo.fileName = utils.kebabCase(this.artifactInfo.name); - this.artifactInfo.outFile = `${this.artifactInfo.fileName}.model.ts`; + this.artifactInfo.outFile = utils.getModelFileName(this.artifactInfo.name); // Resolved Output Path const tsPath = this.destinationPath( diff --git a/packages/cli/generators/repository/index.js b/packages/cli/generators/repository/index.js new file mode 100644 index 000000000000..39fcd95ee623 --- /dev/null +++ b/packages/cli/generators/repository/index.js @@ -0,0 +1,469 @@ +// 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 fs = require('fs'); +const debug = require('../../lib/debug')('repository-generator'); +const inspect = require('util').inspect; +const path = require('path'); +const chalk = require('chalk'); +const utils = require('../../lib/utils'); +const connectors = require('../datasource/connectors.json'); +const tsquery = require('../../lib/ast-helper'); + +const VALID_CONNECTORS_FOR_REPOSITORY = ['KeyValueModel', 'PersistedModel']; +const KEY_VALUE_CONNECTOR = ['KeyValueModel']; + +const DEFAULT_CRUD_REPOSITORY = 'DefaultCrudRepository'; +const KEY_VALUE_REPOSITORY = 'DefaultKeyValueRepository'; + +const REPOSITORY_KV_TEMPLATE = 'repository-kv-template.ts.ejs'; +const REPOSITORY_CRUD_TEMPLATE = 'repository-crud-default-template.ts.ejs'; + +const PROMPT_MESSAGE_MODEL = + 'Select the model(s) you want to generate a repository'; +const PROMPT_MESSAGE_DATA_SOURCE = 'Please select the datasource'; +const ERROR_READING_FILE = 'Error reading file'; +const ERROR_NO_DATA_SOURCES_FOUND = 'No datasources found at'; +const ERROR_NO_MODELS_FOUND = 'No models found at'; +const ERROR_NO_MODEL_SELECTED = 'You did not select a valid model'; +const ERROR_NO_DIRECTORY = 'The directory was not found'; + +module.exports = class RepositoryGenerator extends ArtifactGenerator { + // Note: arguments and options should be defined in the constructor. + constructor(args, opts) { + super(args, opts); + } + + /** + * get the property name for the id field + * @param {string} modelName + */ + async _getModelIdProperty(modelName) { + let fileContent = ''; + let modelFile = path.join( + this.artifactInfo.modelDir, + utils.getModelFileName(modelName), + ); + try { + fileContent = this.fs.read(modelFile, {}); + } catch (err) { + debug(`${ERROR_READING_FILE} ${modelFile}: ${err.message}`); + return this.exit(err); + } + + return tsquery.getIdFromModel(fileContent); + } + + /** + * helper method to inspect and validate a repository type + */ + async _inferRepositoryType() { + if (!this.artifactInfo.dataSourceClass) { + return; + } + let result = this._isConnectorOfType( + KEY_VALUE_CONNECTOR, + this.artifactInfo.dataSourceClass, + ); + debug(`KeyValue Connector: ${result}`); + + if (result) { + this.artifactInfo.repositoryTypeClass = KEY_VALUE_REPOSITORY; + this.artifactInfo.defaultTemplate = REPOSITORY_KV_TEMPLATE; + } else { + this.artifactInfo.repositoryTypeClass = DEFAULT_CRUD_REPOSITORY; + this.artifactInfo.defaultTemplate = REPOSITORY_CRUD_TEMPLATE; + } + + // assign the data source name to the information artifact + let dataSourceName = this.artifactInfo.dataSourceClass + .replace('Datasource', '') + .toLowerCase(); + + let dataSourceClassName = this.artifactInfo.dataSourceClass.replace( + 'Datasource', + 'DataSource', + ); + + Object.assign(this.artifactInfo, { + dataSourceClassName: dataSourceClassName, + }); + + Object.assign(this.artifactInfo, { + dataSourceName: dataSourceName, + }); + } + + /** + * load the connectors available and check if the basedModel matches any + * connectorType supplied for the given connector name + * @param {string} connectorType single or a comma separated string array + */ + _isConnectorOfType(connectorType, dataSourceClass) { + debug(`calling isConnectorType ${connectorType}`); + let jsonFileContent = ''; + let result = false; + + if (!dataSourceClass) { + return false; + } + let datasourceJSONFile = path.join( + this.artifactInfo.datasourcesDir, + dataSourceClass.replace('Datasource', '.datasource.json').toLowerCase(), + ); + + try { + jsonFileContent = this.fs.readJSON(datasourceJSONFile, {}); + } catch (err) { + debug(`${ERROR_READING_FILE} ${datasourceJSONFile}: ${err.message}`); + return this.exit(err); + } + + for (let connector of Object.values(connectors)) { + const matchedConnector = + jsonFileContent.connector === connector.name || + jsonFileContent.connector === `loopback-connector-${connector.name}`; + + if (matchedConnector && connectorType.includes(connector.baseModel)) { + result = true; + break; + } + } + + return result; + } + + _setupGenerator() { + this.artifactInfo = { + type: 'repository ', + rootDir: utils.sourceRootDir, + }; + + this.artifactInfo.outDir = path.resolve( + this.artifactInfo.rootDir, + utils.repositoriesDir, + ); + this.artifactInfo.datasourcesDir = path.resolve( + this.artifactInfo.rootDir, + utils.datasourcesDir, + ); + this.artifactInfo.modelDir = path.resolve( + this.artifactInfo.rootDir, + utils.modelsDir, + ); + + // to be able to write multiple files to the index.ts + this.artifactInfo.indexesToBeUpdated = []; + + this.artifactInfo.defaultTemplate = REPOSITORY_CRUD_TEMPLATE; + + this.option('model', { + type: String, + required: false, + description: 'A valid model name', + }); + + this.option('id', { + type: String, + required: false, + description: 'A valid ID property name for the specified model', + }); + + this.option('datasource', { + type: String, + required: false, + description: 'A valid datasource name', + }); + + return super._setupGenerator(); + } + + setOptions() { + return super.setOptions(); + } + + async checkPaths() { + // check for datasources + if (!fs.existsSync(this.artifactInfo.datasourcesDir)) { + return this.exit( + new Error( + `${ERROR_NO_DATA_SOURCES_FOUND} ${this.artifactInfo.datasourcesDir}. + ${chalk.yellow( + 'Please visit https://loopback.io/doc/en/lb4/DataSource-generator.html for information on how datasources are discovered', + )}`, + ), + ); + } + + // check for models + if (!fs.existsSync(this.artifactInfo.modelDir)) { + 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', + )}`, + ), + ); + } + } + + async promptDataSourceName() { + if (this.shouldExit()) return false; + + debug('Prompting for a datasource '); + let cmdDatasourceName; + let datasourcesList; + + // grab the datasourcename from the command line + cmdDatasourceName = this.options.datasource + ? utils.toClassName(this.options.datasource) + 'Datasource' + : ''; + + debug(`command line datasource is ${cmdDatasourceName}`); + + try { + datasourcesList = await utils.getArtifactList( + this.artifactInfo.datasourcesDir, + 'datasource', + true, + ); + debug( + `datasourcesList from ${utils.sourceRootDir}/${ + utils.datasourcesDir + } : ${datasourcesList}`, + ); + } catch (err) { + return this.exit(err); + } + + const availableDatasources = datasourcesList.filter(item => { + debug(`data source unfiltered list: ${item}`); + const result = this._isConnectorOfType( + VALID_CONNECTORS_FOR_REPOSITORY, + item, + ); + return result; + }); + + debug(`artifactInfo.dataSourceClass ${this.artifactInfo.dataSourceClass}`); + + if (availableDatasources.length === 0) { + return this.exit( + new Error( + `${ERROR_NO_DATA_SOURCES_FOUND} ${this.artifactInfo.datasourcesDir}. + ${chalk.yellow( + 'Please visit https://loopback.io/doc/en/lb4/DataSource-generator.html for information on how datasources are discovered', + )}`, + ), + ); + } + + if (availableDatasources.includes(cmdDatasourceName)) { + Object.assign(this.artifactInfo, { + dataSourceClass: cmdDatasourceName, + }); + } + + return this.prompt([ + { + type: 'list', + name: 'dataSourceClass', + message: PROMPT_MESSAGE_DATA_SOURCE, + choices: availableDatasources, + when: !this.artifactInfo.dataSourceClass, + default: availableDatasources[0], + validate: utils.validateClassName, + }, + ]) + .then(props => { + Object.assign(this.artifactInfo, props); + debug(`props after datasource prompt: ${inspect(props)}`); + return props; + }) + .catch(err => { + debug(`Error during datasource prompt: ${err}`); + return this.exit(err); + }); + } + + async promptModels() { + if (this.shouldExit()) return false; + + await this._inferRepositoryType(); + + 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 (this.options.model) { + debug(`Model name received from command line: ${this.options.model}`); + + this.options.model = utils.toClassName(this.options.model); + // assign the model name from the command line only if it is valid + if ( + modelList && + modelList.length > 0 && + modelList.includes(this.options.model) + ) { + Object.assign(this.artifactInfo, {modelNameList: [this.options.model]}); + } else { + modelList = []; + } + } + + 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', + )}`, + ), + ); + } + + return this.prompt([ + { + type: 'checkbox', + name: 'modelNameList', + message: PROMPT_MESSAGE_MODEL, + choices: modelList, + when: this.artifactInfo.modelNameList === undefined, + }, + ]) + .then(props => { + Object.assign(this.artifactInfo, props); + debug(`props after model list prompt: ${inspect(props)}`); + return props; + }) + .catch(err => { + debug(`Error during model list prompt: ${err}`); + return this.exit(err); + }); + } + + async promptModelId() { + if (this.shouldExit()) return false; + let idProperty; + + debug(`Model ID property name from command line: ${this.options.id}`); + debug(`Selected Models: ${this.artifactInfo.modelNameList}`); + + if (_.isEmpty(this.artifactInfo.modelNameList)) { + return this.exit(new Error(`${ERROR_NO_MODEL_SELECTED}`)); + } else { + // iterate thru each selected model, infer or ask for the ID type + for (let item of this.artifactInfo.modelNameList) { + this.artifactInfo.modelName = item; + + const prompts = [ + { + type: 'input', + name: 'propertyName', + message: `Please enter the name of the ID property for ${item}:`, + default: 'id', + }, + ]; + + // user supplied the id from the command line + if (this.options.id) { + debug(`passing thru this.options.id with value : ${this.options.id}`); + + idProperty = this.options.id; + /** make sure it is only used once, in case user selected more + * than one model. + */ + delete this.options.id; + } else { + idProperty = await this._getModelIdProperty(item); + if (idProperty === null) { + const answer = await this.prompt(prompts); + idProperty = answer.propertyName; + } + } + this.artifactInfo.idProperty = idProperty; + // Generate this repository + await this._scaffold(); + } + } + } + + async _scaffold() { + if (this.shouldExit()) return false; + + if (this.options.name) { + this.artifactInfo.className = utils.toClassName(this.options.name); + this.artifactInfo.outFile = utils.getRepositoryFileName( + this.options.name, + ); + + // make sure the name supplied from cmd line is only used once + delete this.options.name; + } else { + this.artifactInfo.className = utils.toClassName( + this.artifactInfo.modelName, + ); + + this.artifactInfo.outFile = utils.getRepositoryFileName( + this.artifactInfo.modelName, + ); + + this.artifactInfo.indexesToBeUpdated.push({ + dir: this.artifactInfo.outDir, + file: this.artifactInfo.outFile, + }); + } + + const source = this.templatePath( + path.join( + utils.sourceRootDir, + utils.repositoriesDir, + this.artifactInfo.defaultTemplate, + ), + ); + + const dest = this.destinationPath( + path.join(this.artifactInfo.outDir, this.artifactInfo.outFile), + ); + + if (debug.enabled) { + debug(`artifactInfo: ${inspect(this.artifactInfo)}`); + debug(`Copying artifact to: ${dest}`); + } + this.fs.copyTpl( + source, + dest, + this.artifactInfo, + {}, + {globOptions: {dot: true}}, + ); + return; + } + + async end() { + this.artifactInfo.type = + this.artifactInfo.modelNameList && + this.artifactInfo.modelNameList.length > 1 + ? 'Repositories' + : 'Repository'; + + this.artifactInfo.name = this.artifactInfo.modelNameList + ? this.artifactInfo.modelNameList.join() + : this.artifactInfo.modelName; + + await super.end(); + } +}; diff --git a/packages/cli/generators/repository/templates/src/repositories/repository-crud-default-template.ts.ejs b/packages/cli/generators/repository/templates/src/repositories/repository-crud-default-template.ts.ejs new file mode 100644 index 000000000000..d08a59e0579b --- /dev/null +++ b/packages/cli/generators/repository/templates/src/repositories/repository-crud-default-template.ts.ejs @@ -0,0 +1,15 @@ +import {<%= repositoryTypeClass %>, juggler} from '@loopback/repository'; +import {<%= modelName %>} from '../models'; +import {<%= dataSourceClassName %>} from '../datasources'; +import {inject} from '@loopback/core'; + +export class <%= className %>Repository extends <%= repositoryTypeClass %>< + <%= modelName %>, + typeof <%= modelName %>.prototype.<%= idProperty %> +> { + constructor( + @inject('datasources.<%= dataSourceName %>') protected datasource: <%= dataSourceClassName %>, + ) { + super(<%= modelName %>, datasource); + } +} diff --git a/packages/cli/generators/repository/templates/src/repositories/repository-kv-template.ts.ejs b/packages/cli/generators/repository/templates/src/repositories/repository-kv-template.ts.ejs new file mode 100644 index 000000000000..4fad4863a068 --- /dev/null +++ b/packages/cli/generators/repository/templates/src/repositories/repository-kv-template.ts.ejs @@ -0,0 +1,14 @@ +import {<%= repositoryTypeClass %>} from '@loopback/repository'; +import {<%= modelName %>} from '../models'; +import {<%= dataSourceClassName %>} from '../datasources'; +import {inject} from '@loopback/core'; + +export class <%= className %>Repository extends <%= repositoryTypeClass %>< + <%= modelName %> + > { + constructor( + @inject('datasources.<%= dataSourceName %>') datasource: <%= dataSourceClassName %>, + ) { + super(<%= modelName %>, datasource); + } +} diff --git a/packages/cli/lib/artifact-generator.js b/packages/cli/lib/artifact-generator.js index 3e5cd0b0f687..f843660f73be 100644 --- a/packages/cli/lib/artifact-generator.js +++ b/packages/cli/lib/artifact-generator.js @@ -129,17 +129,19 @@ module.exports = class ArtifactGenerator extends BaseGenerator { // Index Update Disabled if (this.artifactInfo.disableIndexUpdate) return; + if (!this.artifactInfo.indexesToBeUpdated) { + this.artifactInfo.indexesToBeUpdated = []; + } + // No Array given for Index Update, Create default array if ( - !this.artifactInfo.indexesToBeUpdated && this.artifactInfo.outDir && - this.artifactInfo.outFile + this.artifactInfo.outFile && + this.artifactInfo.indexesToBeUpdated.length === 0 ) { this.artifactInfo.indexesToBeUpdated = [ {dir: this.artifactInfo.outDir, file: this.artifactInfo.outFile}, ]; - } else { - this.artifactInfo.indexesToBeUpdated = []; } for (const idx of this.artifactInfo.indexesToBeUpdated) { diff --git a/packages/cli/lib/ast-helper.js b/packages/cli/lib/ast-helper.js new file mode 100644 index 000000000000..6cda7d20054d --- /dev/null +++ b/packages/cli/lib/ast-helper.js @@ -0,0 +1,149 @@ +// 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 tsquery = require('@phenomnomnominal/tsquery').tsquery; +const debug = require('./debug')('ast-query'); + +const tsArtifact = { + ClassDeclaration: 'ClassDeclaration', + PropertyDeclaration: 'PropertyDeclaration', + Identifier: 'Identifier', + Decorator: 'Decorator', + CallExpression: 'CallExpression', + ObjectLiteralExpression: 'ObjectLiteralExpression', + PropertyAssignment: 'PropertyAssignment', + PropertyDeclaration: 'PropertyDeclaration', + TrueKeywordTrue: 'TrueKeyword[value="true"]', + // the following are placed in case it is needed to explore more artifacts + IfStatement: 'IfStatement', + ForStatement: 'ForStatement', + WhileStatement: 'WhileStatement', + DoStatement: 'DoStatement', + VariableStatement: 'VariableStatement', + FunctionDeclaration: 'FunctionDeclaration', + ArrowFunction: 'ArrowFunction', + ImportDeclaration: 'ImportDeclaration', + StringLiteral: 'StringLiteral', + FalseKeyword: 'FalseKeyword', + NullKeyword: 'NullKeyword', + AnyKeyword: 'AnyKeyword', + NumericLiteral: 'NumericLiteral', + NoSubstitutionTemplateLiteral: 'NoSubstitutionTemplateLiteral', + TemplateExpression: 'TemplateExpression', +}; + +const RootNodesFindID = [ + // Defaul format generated by lb4 model + [ + tsArtifact.ClassDeclaration, + tsArtifact.PropertyDeclaration, + tsArtifact.Identifier, + ], + // Model JSON definition inside the @model decorator + [ + tsArtifact.ClassDeclaration, + tsArtifact.Decorator, + tsArtifact.CallExpression, + tsArtifact.ObjectLiteralExpression, + tsArtifact.PropertyAssignment, + tsArtifact.ObjectLiteralExpression, + tsArtifact.PropertyAssignment, + tsArtifact.Identifier, + ], + // Model JSON static definition inside the Class + [ + tsArtifact.ClassDeclaration, + tsArtifact.PropertyDeclaration, + tsArtifact.ObjectLiteralExpression, + tsArtifact.PropertyAssignment, + tsArtifact.ObjectLiteralExpression, + tsArtifact.PropertyAssignment, + tsArtifact.Identifier, + ], +]; +const ChildNodesFindID = [ + // Defaul format generated by lb4 model + [ + tsArtifact.ClassDeclaration, + tsArtifact.PropertyDeclaration, + tsArtifact.Decorator, + tsArtifact.CallExpression, + tsArtifact.ObjectLiteralExpression, + tsArtifact.PropertyAssignment, + tsArtifact.TrueKeywordTrue, + ], + + // Model JSON definition inside the @model decorator + [ + tsArtifact.ClassDeclaration, + tsArtifact.Decorator, + tsArtifact.CallExpression, + tsArtifact.ObjectLiteralExpression, + tsArtifact.PropertyAssignment, + tsArtifact.ObjectLiteralExpression, + tsArtifact.PropertyAssignment, + tsArtifact.ObjectLiteralExpression, + tsArtifact.PropertyAssignment, + tsArtifact.TrueKeywordTrue, + ], + // Model JSON static definition inside the Class + [ + tsArtifact.ClassDeclaration, + tsArtifact.PropertyDeclaration, + tsArtifact.ObjectLiteralExpression, + tsArtifact.PropertyAssignment, + tsArtifact.ObjectLiteralExpression, + tsArtifact.PropertyAssignment, + tsArtifact.ObjectLiteralExpression, + tsArtifact.PropertyAssignment, + tsArtifact.TrueKeywordTrue, + ], +]; + +/** + * Parse the file using the possible formats specified in the arrays + * rootNodesFindID and childNodesFindID + * @param {string} fileContent with a model.ts class + */ +exports.getIdFromModel = async function(fileContent) { + let nodePos = 0; + let retVal = null; + + const ast = tsquery.ast(fileContent); + for (let rootNodes of RootNodesFindID) { + const propertyArr = []; + const stRootNode = rootNodes.join('>'); + const nodes = tsquery(ast, stRootNode); + + debug(`rootNode ${stRootNode}`); + + for (let a of nodes) { + propertyArr.push(a.escapedText); + } + + const stChildNode = ChildNodesFindID[nodePos].join('>'); + const subnodes = tsquery(ast, stChildNode); + + let i = 0; + for (let a of subnodes) { + if (a.parent.name.escapedText === 'id') { + // we found the primary key for the model + retVal = propertyArr[i]; + debug(`found key: ${retVal}`); + break; + } + i++; + } + + if (retVal !== null) { + break; + } + + nodePos++; + } + + return retVal; +}; diff --git a/packages/cli/lib/cli.js b/packages/cli/lib/cli.js index d92b600791c8..54273489b0ed 100644 --- a/packages/cli/lib/cli.js +++ b/packages/cli/lib/cli.js @@ -63,6 +63,10 @@ function setupGenerators() { PREFIX + 'datasource', ); env.register(path.join(__dirname, '../generators/model'), PREFIX + 'model'); + env.register( + path.join(__dirname, '../generators/repository'), + PREFIX + 'repository', + ); env.register( path.join(__dirname, '../generators/example'), PREFIX + 'example', diff --git a/packages/cli/lib/utils.js b/packages/cli/lib/utils.js index 7e0357bb79ae..2b34f2a14a7a 100644 --- a/packages/cli/lib/utils.js +++ b/packages/cli/lib/utils.js @@ -370,3 +370,24 @@ function validateValue(name, unallowedCharacters) { } return true; } +/** + * Returns the modelName in the directory file format for the model + * @param {string} modelName + */ +exports.getModelFileName = function(modelName) { + return `${_.kebabCase(modelName)}.model.ts`; +}; + +/** + * Returns the repositoryName in the directory file format for the repository + * @param {string} repositoryName + */ +exports.getRepositoryFileName = function(repositoryName) { + return `${_.kebabCase(repositoryName)}.repository.ts`; +}; + +// literal strings with artifacts directory locations +exports.repositoriesDir = 'repositories'; +exports.datasourcesDir = 'datasources'; +exports.modelsDir = 'models'; +exports.sourceRootDir = 'src'; diff --git a/packages/cli/package.json b/packages/cli/package.json index 76034da7bae7..c02d5fd0fea1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -36,12 +36,14 @@ "request-promise-native": "^1.0.5", "rimraf": "^2.6.2", "sinon": "^4.5.0", + "typescript": "^2.9.2", "yeoman-assert": "^3.1.1", "yeoman-environment": "^2.0.6", "yeoman-test": "^1.7.0" }, "dependencies": { "@loopback/dist-util": "^0.3.7", + "@phenomnomnominal/tsquery": "^2.0.1", "camelcase-keys": "^4.2.0", "chalk": "^2.3.2", "change-case": "^3.0.2", diff --git a/packages/cli/test/integration/cli/cli.integration.js b/packages/cli/test/integration/cli/cli.integration.js index 1f9347bd578d..ee6fa01ea1ab 100644 --- a/packages/cli/test/integration/cli/cli.integration.js +++ b/packages/cli/test/integration/cli/cli.integration.js @@ -24,7 +24,7 @@ describe('cli', () => { expect(entries).to.eql([ 'Available commands: ', ' lb4 app\n lb4 extension\n lb4 controller\n lb4 datasource\n ' + - 'lb4 model\n lb4 example\n lb4 openapi', + 'lb4 model\n lb4 repository\n lb4 example\n lb4 openapi', ]); }); @@ -42,7 +42,7 @@ 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 example\n lb4 openapi', + 'lb4 model\n lb4 repository\n lb4 example\n lb4 openapi', ); }); diff --git a/packages/cli/test/integration/generators/repository.integration.js b/packages/cli/test/integration/generators/repository.integration.js new file mode 100644 index 000000000000..f8d1bf5c6a6e --- /dev/null +++ b/packages/cli/test/integration/generators/repository.integration.js @@ -0,0 +1,586 @@ +// 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/repository'); + +const testUtils = require('../../test-utils'); + +// Test Sandbox +const SANDBOX_PATH = path.resolve(__dirname, '..', '.sandbox'); +const sandbox = new TestSandbox(SANDBOX_PATH); + +describe('lb4 repository', () => { + beforeEach('reset sandbox', async () => { + await sandbox.reset(); + }); + + // special cases regardless of the repository type + describe('generate repositories on special conditions', () => { + it('generates multipe crud repositories', async () => { + const multiItemPrompt = { + dataSourceClass: 'DbmemDatasource', + modelNameList: ['MultiWord', 'Defaultmodel'], + }; + + await testUtils + .executeGenerator(generator) + .inDir( + SANDBOX_PATH, + async () => await prepareGeneratorForRepository(SANDBOX_PATH), + ) + .withPrompts(multiItemPrompt); + + const expectedMultiWordFile = path.join( + SANDBOX_PATH, + REPOSITORY_APP_PATH, + 'multi-word.repository.ts', + ); + const expectedDefaultModelFile = path.join( + SANDBOX_PATH, + REPOSITORY_APP_PATH, + 'defaultmodel.repository.ts', + ); + + assert.file(expectedMultiWordFile); + assert.file(expectedDefaultModelFile); + + assert.fileContent( + expectedMultiWordFile, + /export class MultiWordRepository extends DefaultCrudRepository { + const multiItemPrompt = { + dataSourceClass: 'DbmemDatasource', + modelNameList: ['MultiWord'], + }; + + await testUtils + .executeGenerator(generator) + .inDir( + SANDBOX_PATH, + async () => await prepareGeneratorForRepository(SANDBOX_PATH), + ) + .withPrompts(multiItemPrompt); + + const expectedFile = path.join( + SANDBOX_PATH, + REPOSITORY_APP_PATH, + 'multi-word.repository.ts', + ); + + assert.file(expectedFile); + assert.fileContent( + expectedFile, + /export class MultiWordRepository extends DefaultCrudRepository { + await testUtils + .executeGenerator(generator) + .inDir( + SANDBOX_PATH, + async () => await prepareGeneratorForRepository(SANDBOX_PATH), + ) + .withArguments('myrepo --datasource dbmem --model MultiWord'); + const expectedFile = path.join( + SANDBOX_PATH, + REPOSITORY_APP_PATH, + 'myrepo.repository.ts', + ); + + assert.file(expectedFile); + assert.fileContent( + expectedFile, + /export class MyrepoRepository extends DefaultCrudRepository { + await testUtils + .executeGenerator(generator) + .inDir( + SANDBOX_PATH, + async () => await prepareGeneratorForRepository(SANDBOX_PATH), + ) + .withArguments('--config myconfig.json'); + const expectedFile = path.join( + SANDBOX_PATH, + REPOSITORY_APP_PATH, + 'decoratordefined.repository.ts', + ); + assert.file(expectedFile); + assert.fileContent( + expectedFile, + /export class DecoratordefinedRepository extends DefaultCrudRepository\ { + const multiItemPrompt = { + dataSourceClass: 'DbmemDatasource', + modelNameList: ['InvalidId'], + propertyName: 'myid', + }; + + await testUtils + .executeGenerator(generator) + .inDir( + SANDBOX_PATH, + async () => await prepareGeneratorForRepository(SANDBOX_PATH), + ) + .withPrompts(multiItemPrompt); + + const expectedFile = path.join( + SANDBOX_PATH, + REPOSITORY_APP_PATH, + 'invalid-id.repository.ts', + ); + + assert.file(expectedFile); + assert.fileContent( + expectedFile, + /export class InvalidIdRepository extends DefaultCrudRepository { + it('does not run with an invalid model name', async () => { + const basicPrompt = { + dataSourceClass: 'DbmemDatasource', + }; + return expect( + testUtils + .executeGenerator(generator) + .inDir( + SANDBOX_PATH, + async () => await prepareGeneratorForRepository(SANDBOX_PATH), + ) + .withPrompts(basicPrompt) + .withArguments(' --model InvalidModel'), + ).to.be.rejectedWith(/No models found/); + }); + + it("does not run when user doesn't select a model", async () => { + const basicPrompt = { + dataSourceClass: 'DbmemDatasource', + }; + return expect( + testUtils + .executeGenerator(generator) + .inDir( + SANDBOX_PATH, + async () => await prepareGeneratorForRepository(SANDBOX_PATH), + ) + .withPrompts(basicPrompt), + ).to.be.rejectedWith(/You did not select a valid model/); + }); + + it('does not run with empty datasource list', async () => { + return expect( + testUtils.executeGenerator(generator).inDir( + SANDBOX_PATH, + async () => + await prepareGeneratorForRepository(SANDBOX_PATH, { + noFixtures: true, + }), + ), + ).to.be.rejectedWith(/No datasources found/); + }); + }); + + describe('valid generation of crud repositories', () => { + it('generates a crud repository from default model', async () => { + const basicPrompt = { + dataSourceClass: 'DbmemDatasource', + }; + await testUtils + .executeGenerator(generator) + .inDir( + SANDBOX_PATH, + async () => await prepareGeneratorForRepository(SANDBOX_PATH), + ) + .withPrompts(basicPrompt) + .withArguments(' --model Defaultmodel'); + const expectedFile = path.join( + SANDBOX_PATH, + REPOSITORY_APP_PATH, + 'defaultmodel.repository.ts', + ); + assert.file(expectedFile); + assert.fileContent( + expectedFile, + /export class DefaultmodelRepository extends DefaultCrudRepository\ { + await testUtils + .executeGenerator(generator) + .inDir( + SANDBOX_PATH, + async () => await prepareGeneratorForRepository(SANDBOX_PATH), + ) + .withArguments('--datasource dbmem --model decoratordefined'); + const expectedFile = path.join( + SANDBOX_PATH, + REPOSITORY_APP_PATH, + 'decoratordefined.repository.ts', + ); + assert.file(expectedFile); + assert.fileContent( + expectedFile, + /export class DecoratordefinedRepository extends DefaultCrudRepository\ { + it('generates a kv repository from default model', async () => { + await testUtils + .executeGenerator(generator) + .inDir( + SANDBOX_PATH, + async () => await prepareGeneratorForRepository(SANDBOX_PATH), + ) + .withArguments('--datasource dbkv --model Defaultmodel'); + const expectedFile = path.join( + SANDBOX_PATH, + REPOSITORY_APP_PATH, + 'defaultmodel.repository.ts', + ); + assert.file(expectedFile); + assert.fileContent( + expectedFile, + /DefaultmodelRepository extends DefaultKeyValueRepository { + const basicPrompt = { + dataSourceClass: 'DbkvDatasource', + }; + await testUtils + .executeGenerator(generator) + .inDir( + SANDBOX_PATH, + async () => await prepareGeneratorForRepository(SANDBOX_PATH), + ) + .withPrompts(basicPrompt) + .withArguments('--model decoratordefined'); + const expectedFile = path.join( + SANDBOX_PATH, + REPOSITORY_APP_PATH, + 'decoratordefined.repository.ts', + ); + + assert.file(expectedFile); + assert.fileContent( + expectedFile, + /DecoratordefinedRepository extends DefaultKeyValueRepository) { + super(data); + } + } + `, + }, + { + path: MODEL_APP_PATH, + file: 'defaultmodel.model.ts', + content: ` + import {Entity, model, property} from '@loopback/repository'; + + @model() + export class DefaultModel extends Entity { + @property({ + type: 'number', + id: true, + default: 0, + }) + id?: number; + + @property({ + type: 'string', + }) + desc?: string; + + @property({ + type: 'number', + default: 0, + }) + balance?: number; + + constructor(data?: Partial) { + super(data); + } + } + `, + }, + { + path: MODEL_APP_PATH, + file: 'multi-word.model.ts', + content: ` + import {Entity, model, property} from '@loopback/repository'; + + @model() + export class MultiWord extends Entity { + @property({ + type: 'string', + id: true, + default: 0, + }) + pk?: string; + + @property({ + type: 'string', + }) + desc?: string; + + constructor(data?: Partial) { + super(data); + } + } + `, + }, + { + path: MODEL_APP_PATH, + file: 'invalid-id.model.ts', + content: ` + import {Entity, model, property} from '@loopback/repository'; + + @model() + export class InvalidID extends Entity { + @property({ + type: 'string', + required: true, + default: 0, + }) + id: string; + + @property({ + type: 'string', + }) + desc?: string; + + constructor(data?: Partial) { + super(data); + } + } + `, + }, +]; + +async function prepareGeneratorForRepository(rootDir, options) { + options = options || {}; + const content = {}; + if (!options.excludeKeyword) { + content.keywords = ['loopback']; + } + + if (!options.excludePackageJSON) { + fs.writeFileSync( + path.join(rootDir, 'package.json'), + JSON.stringify(content), + ); + } + + if (!options.excludeYoRcJSON) { + fs.writeFileSync(path.join(rootDir, '.yo-rc.json'), JSON.stringify({})); + } + + fs.mkdirSync(path.join(rootDir, 'src')); + + if (!options.excludeControllersDir) { + fs.mkdirSync(path.join(rootDir, 'src', 'controllers')); + } + + if (!options.excludeModelsDir) { + fs.mkdirSync(path.join(rootDir, 'src', 'models')); + } + + if (!options.excludeRepositoriesDir) { + fs.mkdirSync(path.join(rootDir, 'src', 'repositories')); + } + + if (!options.excludeDataSourcesDir) { + fs.mkdirSync(path.join(rootDir, 'src', 'datasources')); + } + + if (!options.noFixtures) { + copyFixtures(); + } +} + +function copyFixtures() { + for (let theFile of SANDBOX_FILES) { + const fullPath = path.join(SANDBOX_PATH, theFile.path, theFile.file); + if (!fs.existsSync(fullPath)) { + fs.writeFileSync(fullPath, theFile.content); + } + } +}