diff --git a/packages/concerto-core/src/basemodelmanager.ts b/packages/concerto-core/src/basemodelmanager.ts index 994d98bd5..7b07800ca 100644 --- a/packages/concerto-core/src/basemodelmanager.ts +++ b/packages/concerto-core/src/basemodelmanager.ts @@ -30,7 +30,11 @@ import decoratorModelModule from './decoratormodelhelper'; import type { ModelFileSource, ModelManagerOptions } from './types'; import type { AstNode } from './introspect/decorated'; type ModelFileInstance = InstanceType; -type ModelFileInput = string | ModelFileInstance; +type ModelFileInput = string | ModelFileInstance | Record; + +function isModelFile(obj: unknown): obj is ModelFileInstance { + return Boolean(obj) && (obj instanceof ModelFile || (typeof (obj as any).isModelFile === 'function' && (obj as any).isModelFile() === true)); +} function getFileNameFromIdentifier(fileIdentifier) { const normalizedIdentifier = fileIdentifier.replace(/[\\/]+$/, ''); @@ -179,17 +183,19 @@ class BaseModelManager { * Note that if there are dependencies between multiple files the files * must be added in dependency order, or the addModelFiles method can be * used to add a set of files irrespective of dependencies. - * @param {string|ModelFile} modelFile - The Concerto file as a string + * Note: AST objects require an AST-based model manager (such as AstModelManager). + * @param {string|ModelFile|object} modelFile - The Concerto file as a string, AST object (requires an AST-based model manager), or ModelFile * @param {string} [fileName] - a file name to associate with the model file * @throws {IllegalModelException} */ validateModelFile(modelFile, fileName?) { - if (typeof modelFile === 'string') { + if (isModelFile(modelFile)) { + modelFile.validate(); + } else { const { ast } = this.processFile(fileName, modelFile); - let m = new ModelFile(this, ast as AstNode, modelFile, fileName); + const definitions = typeof modelFile === 'string' ? modelFile : null; + let m = new ModelFile(this, ast as AstNode, definitions, fileName); m.validate(); - } else { - modelFile.validate(); } } @@ -334,7 +340,8 @@ class BaseModelManager { * Concerto files have a single namespace. If a Concerto file with the * same namespace has already been added to the ModelManager then it * will be replaced. - * @param {string|ModelFile} modelFile - Model as a string or object + * Note: AST objects require an AST-based model manager (such as AstModelManager). + * @param {string|ModelFile|object} modelFile - Model as a string, AST object (requires an AST-based model manager), or ModelFile * @param {string} [fileName] - a file name to associate with the model file * @param {boolean} [disableValidation] - If true then the model files are not validated * @throws {IllegalModelException} @@ -343,10 +350,11 @@ class BaseModelManager { updateModelFile(modelFile, fileName?, disableValidation?) { const NAME = 'updateModelFile'; debug(NAME, 'updateModelFile', modelFile, fileName); - if (typeof modelFile === 'string') { + if (!isModelFile(modelFile)) { const { ast } = this.processFile(fileName, modelFile); - let m = new ModelFile(this, ast as AstNode, modelFile, fileName); - return this.updateModelFile(m,fileName,disableValidation); + const definitions = typeof modelFile === 'string' ? modelFile : null; + let m = new ModelFile(this, ast as AstNode, definitions, fileName); + return this.updateModelFile(m, fileName, disableValidation); } else { let existing = this.modelFiles[modelFile.getNamespace()]; if (!existing) { @@ -377,7 +385,8 @@ class BaseModelManager { /** * Add a set of Concerto files to the model manager. - * @param {string[]|ModelFile[]} modelFiles - An array of models as strings or ModelFile objects. + * Note: AST objects require an AST-based model manager (such as AstModelManager). + * @param {string[]|ModelFile[]|object[]} modelFiles - An array of models as strings, AST objects (requires an AST-based model manager), or ModelFile objects. * @param {string[]} [fileNames] - A array of file names to associate with the model files * @param {boolean} [disableValidation] - If true then the model files are not validated * @returns {Object[]} The newly added model files (internal). @@ -400,11 +409,12 @@ class BaseModelManager { } let m: ModelFileInstance; - if (typeof modelFile === 'string') { - const { ast } = this.processFile(fileName, modelFile); - m = new ModelFile(this, ast as AstNode, modelFile, fileName); - } else { + if (isModelFile(modelFile)) { m = modelFile; + } else { + const { ast } = this.processFile(fileName, modelFile); + const definitions = typeof modelFile === 'string' ? modelFile : null; + m = new ModelFile(this, ast as AstNode, definitions, fileName); } if (!m.getVersion()) { throw new Error(`Cannot add an unversioned namespace: ${m.getNamespace()}`); diff --git a/packages/concerto-core/src/modelmanager.ts b/packages/concerto-core/src/modelmanager.ts index 55e2869c2..4cd7f8851 100644 --- a/packages/concerto-core/src/modelmanager.ts +++ b/packages/concerto-core/src/modelmanager.ts @@ -27,9 +27,12 @@ import type ModelFile from './introspect/modelfile'; // How to create a modelfile from a cto file const ctoProcessFile = (options: ModelManagerOptions = {}): ((name: string | null, data: unknown) => ModelFileSource) => (name: string | null, data: unknown) => { + if (typeof data !== 'string') { + throw new Error('ModelManager expects CTO model content as a string. For AST objects, use AstModelManager.'); + } // Clone individual properties to avoid options injection to Peggy. const parserOptions = { skipLocationNodes: options?.skipLocationNodes }; - const content = typeof data === 'string' ? data : String(data); + const content = data; const fileName = name ?? 'UNKNOWN'; return { ast: Parser.parse(content, fileName, parserOptions), diff --git a/packages/concerto-core/test/astmodelmanager.js b/packages/concerto-core/test/astmodelmanager.js index 9f72e11ea..03641e524 100644 --- a/packages/concerto-core/test/astmodelmanager.js +++ b/packages/concerto-core/test/astmodelmanager.js @@ -16,6 +16,7 @@ const { AstModelManager } = require('../src/astmodelmanager'); const { IllegalModelException } = require('../src/introspect/illegalmodelexception'); +const { TypeNotFoundException } = require('../src/typenotfoundexception'); const chai = require('chai'); chai.should(); @@ -110,4 +111,193 @@ describe('AstModelManager', () => { }); }); + describe('#addModelFiles', () => { + it('should add multiple model files from AST objects', () => { + const modelContent1 = { + '$class': 'concerto.metamodel@1.0.0.Model', + 'namespace': 'org.example.test1@1.0.0', + 'imports': [], + 'declarations': [ + { + '$class': 'concerto.metamodel@1.0.0.ConceptDeclaration', + 'name': 'Concept1', + 'isAbstract': false, + 'properties': [], + 'decorators': [] + } + ] + }; + const modelContent2 = { + '$class': 'concerto.metamodel@1.0.0.Model', + 'namespace': 'org.example.test2@1.0.0', + 'imports': [], + 'declarations': [ + { + '$class': 'concerto.metamodel@1.0.0.ConceptDeclaration', + 'name': 'Concept2', + 'isAbstract': false, + 'properties': [], + 'decorators': [] + } + ] + }; + + const added = modelManager.addModelFiles([modelContent1, modelContent2]); + added.should.have.lengthOf(2); + modelManager.getModelFile('org.example.test1@1.0.0').should.not.be.undefined; + modelManager.getModelFile('org.example.test2@1.0.0').should.not.be.undefined; + modelManager.getType('org.example.test1@1.0.0.Concept1').getName().should.equal('Concept1'); + modelManager.getType('org.example.test2@1.0.0.Concept2').getName().should.equal('Concept2'); + }); + + it('should rollback added models if a later AST is invalid', () => { + const validModel = { + '$class': 'concerto.metamodel@1.0.0.Model', + 'namespace': 'org.example.valid@1.0.0', + 'imports': [], + 'declarations': [ + { + '$class': 'concerto.metamodel@1.0.0.ConceptDeclaration', + 'name': 'ValidConcept', + 'isAbstract': false, + 'properties': [], + 'decorators': [] + } + ] + }; + const invalidModel = { + '$class': 'concerto.metamodel@1.0.0.Model', + 'namespace': 'org.example.invalid@1.0.0', + 'imports': [], + 'declarations': [ + { + '$class': 'concerto.metamodel@1.0.0.ConceptDeclaration', + 'name': 'InvalidConcept', + 'isAbstract': false, + 'properties': [ + { + '$class': 'concerto.metamodel@1.0.0.RelationshipProperty', + 'name': 'target', + 'type': { + '$class': 'concerto.metamodel@1.0.0.TypeIdentifier', + 'name': 'NonExistentType' + }, + 'isArray': false, + 'isOptional': false, + 'decorators': [] + } + ], + 'decorators': [] + } + ] + }; + + (() => { + modelManager.addModelFiles([validModel, invalidModel]); + }).should.throw(IllegalModelException, /NonExistentType/); + + chai.expect(modelManager.getModelFile('org.example.valid@1.0.0')).to.be.undefined; + chai.expect(modelManager.getModelFile('org.example.invalid@1.0.0')).to.be.undefined; + }); + }); + + describe('#updateModelFile', () => { + it('should update a model file from an AST object', () => { + const initialContent = { + '$class': 'concerto.metamodel@1.0.0.Model', + 'namespace': 'org.example.update@1.0.0', + 'imports': [], + 'declarations': [ + { + '$class': 'concerto.metamodel@1.0.0.ConceptDeclaration', + 'name': 'InitialConcept', + 'isAbstract': false, + 'properties': [], + 'decorators': [] + } + ] + }; + + modelManager.addModel(initialContent); + modelManager.getType('org.example.update@1.0.0.InitialConcept').getName().should.equal('InitialConcept'); + + const updatedContent = { + '$class': 'concerto.metamodel@1.0.0.Model', + 'namespace': 'org.example.update@1.0.0', + 'imports': [], + 'declarations': [ + { + '$class': 'concerto.metamodel@1.0.0.ConceptDeclaration', + 'name': 'UpdatedConcept', + 'isAbstract': false, + 'properties': [], + 'decorators': [] + } + ] + }; + + modelManager.updateModelFile(updatedContent); + modelManager.getType('org.example.update@1.0.0.UpdatedConcept').getName().should.equal('UpdatedConcept'); + (() => { + modelManager.getType('org.example.update@1.0.0.InitialConcept'); + }).should.throw(TypeNotFoundException, /InitialConcept/); + }); + }); + + describe('#validateModelFile', () => { + it('should validate an AST object model', () => { + const modelContent = { + '$class': 'concerto.metamodel@1.0.0.Model', + 'namespace': 'org.example.validate@1.0.0', + 'imports': [], + 'declarations': [ + { + '$class': 'concerto.metamodel@1.0.0.ConceptDeclaration', + 'name': 'ValidConcept', + 'isAbstract': false, + 'properties': [], + 'decorators': [] + } + ] + }; + + (() => { + modelManager.validateModelFile(modelContent); + }).should.not.throw(); + }); + + it('should throw IllegalModelException for an invalid AST object model', () => { + const invalidContent = { + '$class': 'concerto.metamodel@1.0.0.Model', + 'namespace': 'org.example.validate.invalid@1.0.0', + 'imports': [], + 'declarations': [ + { + '$class': 'concerto.metamodel@1.0.0.ConceptDeclaration', + 'name': 'InvalidConcept', + 'isAbstract': false, + 'properties': [ + { + '$class': 'concerto.metamodel@1.0.0.RelationshipProperty', + 'name': 'target', + 'type': { + '$class': 'concerto.metamodel@1.0.0.TypeIdentifier', + 'name': 'NonExistentType' + }, + 'isArray': false, + 'isOptional': false, + 'decorators': [] + } + ], + 'decorators': [] + } + ] + }; + + (() => { + modelManager.validateModelFile(invalidContent); + }).should.throw(IllegalModelException, /NonExistentType/); + }); + }); + });