diff --git a/packages/cli/lib/ast-helper.js b/packages/cli/lib/ast-helper.js index 258204bdd850..930cd46e0134 100644 --- a/packages/cli/lib/ast-helper.js +++ b/packages/cli/lib/ast-helper.js @@ -4,145 +4,140 @@ // 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', - 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, - ], -]; +const {tsquery} = require('@phenomnomnominal/tsquery'); +const {syntaxKindName} = tsquery; +const debug = require('./debug')('ast-query'); /** * 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; - +exports.getIdFromModel = function(fileContent) { const ast = tsquery.ast(fileContent); - for (const rootNodes of RootNodesFindID) { - const propertyArr = []; - const stRootNode = rootNodes.join('>'); - const nodes = tsquery(ast, stRootNode); + for (const queryName in QUERIES) { + debug('Trying %s', queryName); + const {query, getModelPropertyDeclaration} = QUERIES[queryName]; - debug(`rootNode ${stRootNode}`); + const idFieldAssignments = tsquery(ast, query); - for (const a of nodes) { - propertyArr.push(a.escapedText); - } + for (const node of idFieldAssignments) { + const fieldName = node.name.escapedText; + /* istanbul ignore if */ + if (debug.enabled) { + debug( + ' trying prop metadata field "%s" with value `%s`', + fieldName, + getNodeSource(node), + ); + } - const stChildNode = ChildNodesFindID[nodePos].join('>'); - const subnodes = tsquery(ast, stChildNode); + if (!isPrimaryKeyFlag(node.initializer)) continue; - let i = 0; - for (const 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; + const propDeclarationNode = getModelPropertyDeclaration(node); + const modelPropertyName = propDeclarationNode.name.escapedText; + /* istanbul ignore if */ + if (debug.enabled) { + debug( + 'Found primary key `%s` with id flag set to `%s`', + modelPropertyName, + getNodeSource(node), + ); } - i++; - } - if (retVal !== null) { - break; + return modelPropertyName; } + } - nodePos++; + // no primary key was found + return null; + + function getNodeSource(node) { + return fileContent.slice(node.pos, node.end).trim(); } +}; - return retVal; +const QUERIES = { + 'default format generated by lb4 model': { + // @property({id: true|1}) + // id: number + query: + // Find all class properties decorated with `@property()` + 'ClassDeclaration>PropertyDeclaration>Decorator:has([name="id"])>' + + // Find object-literal argument passed to `@property` decorator + 'CallExpression>ObjectLiteralExpression>' + + // Find all assignments to `id` property (metadata field) + 'PropertyAssignment:has([name="id"])', + + getModelPropertyDeclaration(node) { + return node.parent.parent.parent.parent; + }, + }, + + 'model JSON definition inside the @model decorator': { + // @model({properties: {id: {type:number, id:true|1}}}) + query: + // Find all classes decorated with `@model()` + 'ClassDeclaration>Decorator:has([name="model"])>' + + // Find object-literal argument passed to `@model` decorator + 'CallExpression>ObjectLiteralExpression>' + + // Find {properties:{...}} initializer + 'PropertyAssignment:has([name="properties"])>ObjectLiteralExpression>' + + // Find all model properties, e.g. {name: {required: true}} + 'PropertyAssignment>ObjectLiteralExpression>' + + // Find all assignments to `id` property (metadata field) + 'PropertyAssignment:has([name="id"])', + getModelPropertyDeclaration(node) { + return node.parent.parent; + }, + }, + + 'model JSON definition inside a static model property "definition"': { + // static definition = {properties: {id: {type:number, id:true|1}}} + query: + // Find all classes with static property `definition` + // TODO: check for "static" modifier + 'ClassDeclaration>PropertyDeclaration:has([name="definition"])>' + + // Find object-literal argument used to initialize `definition` + 'ObjectLiteralExpression>' + + // Find {properties:{...}} initializer + 'PropertyAssignment:has([name="properties"])>ObjectLiteralExpression>' + + // Find all model properties, e.g. {name: {required: true}} + 'PropertyAssignment>ObjectLiteralExpression>' + + // Find all assignments to `id` property (metadata field) + 'PropertyAssignment:has([name="id"])', + + getModelPropertyDeclaration(node) { + return node.parent.parent; + }, + }, }; + +function isPrimaryKeyFlag(idInitializer) { + const kindName = syntaxKindName(idInitializer.kind); + + /* istanbul ignore if */ + if (debug.enabled) { + debug( + 'Checking primary key flag initializer, kind: %s node:', + kindName, + require('util').inspect( + {...idInitializer, parent: '[removed for brevity]'}, + {depth: null}, + ), + ); + } + + // {id: true} + if (kindName === 'TrueKeyword') return true; + + // {id: number} + if (kindName === 'NumericLiteral') { + const ix = +idInitializer.text; + // the value must be a non-zero number, e.g. {id: 1} + return ix !== 0 && !isNaN(ix); + } + + return false; +} diff --git a/packages/cli/test/unit/ast-helper/get-id-from-model.unit.js b/packages/cli/test/unit/ast-helper/get-id-from-model.unit.js new file mode 100644 index 000000000000..6f50367a2505 --- /dev/null +++ b/packages/cli/test/unit/ast-helper/get-id-from-model.unit.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 {getIdFromModel} = require('../../../lib/ast-helper'); +const {expect} = require('@loopback/testlab'); + +describe('getIdFromModel', () => { + it('returns null when no id property was found', () => { + const modelCode = ` + @model() + class Product extends Entity {} + `; + const id = getIdFromModel(modelCode); + + expect(id).to.equal(null); + }); + + context('@property() decorator', () => { + it('detects `{id: true}`', () => { + const modelCode = ` + @model() + class Product extends Entity { + @property({required: true}) + name: string; + + @property({id: true}) + primaryKey: number; + } + `; + const id = getIdFromModel(modelCode); + + expect(id).to.equal('primaryKey'); + }); + + it('ignores `{id: false}`', () => { + const modelCode = ` + @model() + class Product extends Entity { + @property({id: false}) + name: string; + } + `; + const id = getIdFromModel(modelCode); + + expect(id).to.equal(null); + }); + + it('detects `{id: 1}`', () => { + const modelCode = ` + @model() + class Product extends Entity { + @property({required: true}) + name: string; + + @property({id: 1}) + primaryKey: number; + } + `; + const id = getIdFromModel(modelCode); + + expect(id).to.equal('primaryKey'); + }); + + it('ignores `{id: 0}`', () => { + const modelCode = ` + @model() + class Product extends Entity { + @property({id: 0}) + name: string; + } + `; + const id = getIdFromModel(modelCode); + + expect(id).to.equal(null); + }); + }); + + context('@model({properties:{}}) decorator', () => { + it('detects `{id: true}`', () => { + const modelCode = ` + @model({ + properties: { + name: {type: string, required: true} + primaryKey: {type: number, id: true} + }, + }) + class Product extends Entity {} + `; + const id = getIdFromModel(modelCode); + + expect(id).to.equal('primaryKey'); + }); + + it('ignores `{id: false}`', () => { + const modelCode = ` + @model({ + properties: { + name: {type: string, id: false} + }, + }) + class Product extends Entity {} + `; + const id = getIdFromModel(modelCode); + + expect(id).to.equal(null); + }); + + it('detects `{id: 1}`', () => { + const modelCode = ` + @model({ + properties: { + name: {type: string, required: true} + primaryKey: {type: number, id: 1} + }, + }) + class Product extends Entity {} + `; + const id = getIdFromModel(modelCode); + + expect(id).to.equal('primaryKey'); + }); + + it('ignores `{id: 0}`', () => { + const modelCode = ` + @model({ + properties: { + name: {type: string, id: 0} + }, + }) + class Product extends Entity {} + `; + const id = getIdFromModel(modelCode); + + expect(id).to.equal(null); + }); + }); + + context('static model property `definition`', () => { + it('detects `{id: true}`', () => { + const modelCode = ` + class Product extends Entity { + static definition = { + properties: { + name: {type: string, required: true} + primaryKey: {type: number, id: true} + }, + }; + } + `; + const id = getIdFromModel(modelCode); + + expect(id).to.equal('primaryKey'); + }); + + it('ignores `{id: false}`', () => { + const modelCode = ` + class Product extends Entity { + static definition = { + properties: { + name: {type: string, id: false} + }, + }; + } + `; + const id = getIdFromModel(modelCode); + + expect(id).to.equal(null); + }); + + it('detects `{id: 1}`', () => { + const modelCode = ` + class Product extends Entity { + static definition = { + properties: { + name: {type: string, required: true} + primaryKey: {type: number, id: 1} + }, + }; + } + `; + const id = getIdFromModel(modelCode); + + expect(id).to.equal('primaryKey'); + }); + + it('ignores `{id: 0}`', () => { + const modelCode = ` + class Product extends Entity { + static definition = { + properties: { + name: {type: string, id: 0} + }, + }; + } + `; + const id = getIdFromModel(modelCode); + + expect(id).to.equal(null); + }); + }); +});