From b8e4d7ea3f3fd8ba61e2d3c565c0cfa30fc7c7f8 Mon Sep 17 00:00:00 2001 From: Siddharth-Dangi Date: Sun, 25 Oct 2026 02:22:03 +0530 Subject: [PATCH] fix(concerto-cto): safely handle null default values and validator bounds in Printer Metamodel AST instances can represent optional or unbounded properties using explicit null values (e.g. deserialized from JSON metamodels). Previously, Printer.toCTO only checked !== undefined in several places, causing multiple defects: - defaultValue: null on IntegerProperty or LongProperty crashed with TypeError (calling null.toString()) - defaultValue: null on DoubleProperty emitted default=0.0 - defaultValue: null on StringProperty, DateTimeProperty, and ObjectProperty emitted default="null" - lower: null or upper: null on IntegerDomainValidator or LongDomainValidator emitted invalid CTO syntax range=[null, ...] - lower: null on DoubleDomainValidator emitted range=[0.0, ...] - minLength: null or maxLength: null on StringLengthValidator emitted length=[null, ...] - minSize: null or maxSize: null on CollectionSizeValidator emitted size=[null, ...] - flags: null on StringRegexValidator threw in RegExp constructor, bypassing forward slash escaping - pattern: null on StringRegexValidator emitted regex=/null/ - all null/omitted bounds on StringLengthValidator emitted length=[,], which causes a syntax error in Parser.parse - all null/omitted bounds on domain and size validators emitted range=[,] and size=[,], which fail model introspection validation - ObjectProperty default values containing quotes were interpolated without escaping, breaking parser syntax - empty decorators arrays emitted spurious newlines and whitespace indentation Use NullUtil.isNull to safely ignore null bounds and defaults while preserving falsy non-null values (0, false, ""). Guard validators to only emit clauses when at least one bound exists. Escape ObjectProperty default values with toStringLiteral. Safely handle empty decorators and clean regex flags so pattern normalization succeeds. Signed-off-by: Siddharth-Dangi --- packages/concerto-cto/src/printer.ts | 66 ++-- packages/concerto-cto/test/printer.js | 527 +++++++++++++++++++++++++- 2 files changed, 564 insertions(+), 29 deletions(-) diff --git a/packages/concerto-cto/src/printer.ts b/packages/concerto-cto/src/printer.ts index f8c08b26f8..a85a332ada 100644 --- a/packages/concerto-cto/src/printer.ts +++ b/packages/concerto-cto/src/printer.ts @@ -30,6 +30,10 @@ import { IDecoratorBoolean, IDecoratorNumber } from '@accordproject/concerto-metamodel'; +import { NullUtil } from '@accordproject/concerto-util'; + +const { isNull } = NullUtil; + /** * Returns true if the metamodel is a MapDeclaration @@ -102,6 +106,9 @@ function decoratorFromMetaModel(mm: IDecorator): string { * @return {string} the string for the decorators */ function decoratorsFromMetaModel(mm: IDecorator[], prefix: string): string { + if (!mm || mm.length === 0) { + return ''; + } let result = ''; result += mm.map(decoratorFromMetaModel).join(`\n${prefix}`); result += `\n${prefix}`; @@ -190,68 +197,68 @@ function modifiersFromMetaModel(mm: any): string { break; case `${MetaModelNamespace}.DateTimeProperty`: case `${MetaModelNamespace}.DateTimeScalar`: - if (mm.defaultValue !== undefined) { + if (!isNull(mm.defaultValue)) { defaultString += ` default=${toStringLiteral(mm.defaultValue)}`; } break; case `${MetaModelNamespace}.DoubleProperty`: case `${MetaModelNamespace}.DoubleScalar`: - if (mm.defaultValue !== undefined) { + if (!isNull(mm.defaultValue)) { const doubleString = toDoubleString(mm.defaultValue); defaultString += ` default=${doubleString}`; } - if (mm.validator) { - const lowerString = mm.validator.lower !== undefined ? toDoubleString(mm.validator.lower) : ''; - const upperString = mm.validator.upper !== undefined ? toDoubleString(mm.validator.upper) : ''; + if (mm.validator && (!isNull(mm.validator.lower) || !isNull(mm.validator.upper))) { + const lowerString = !isNull(mm.validator.lower) ? toDoubleString(mm.validator.lower) : ''; + const upperString = !isNull(mm.validator.upper) ? toDoubleString(mm.validator.upper) : ''; validatorString += ` range=[${lowerString},${upperString}]`; } break; case `${MetaModelNamespace}.IntegerProperty`: case `${MetaModelNamespace}.IntegerScalar`: - if (mm.defaultValue !== undefined) { + if (!isNull(mm.defaultValue)) { defaultString += ` default=${mm.defaultValue.toString()}`; } - if (mm.validator) { - const lowerString = mm.validator.lower !== undefined ? mm.validator.lower : ''; - const upperString = mm.validator.upper !== undefined ? mm.validator.upper : ''; + if (mm.validator && (!isNull(mm.validator.lower) || !isNull(mm.validator.upper))) { + const lowerString = !isNull(mm.validator.lower) ? mm.validator.lower : ''; + const upperString = !isNull(mm.validator.upper) ? mm.validator.upper : ''; validatorString += ` range=[${lowerString},${upperString}]`; } break; case `${MetaModelNamespace}.LongProperty`: case `${MetaModelNamespace}.LongScalar`: - if (mm.defaultValue !== undefined) { + if (!isNull(mm.defaultValue)) { defaultString += ` default=${mm.defaultValue.toString()}`; } - if (mm.validator) { - const lowerString = mm.validator.lower !== undefined ? mm.validator.lower : ''; - const upperString = mm.validator.upper !== undefined ? mm.validator.upper : ''; + if (mm.validator && (!isNull(mm.validator.lower) || !isNull(mm.validator.upper))) { + const lowerString = !isNull(mm.validator.lower) ? mm.validator.lower : ''; + const upperString = !isNull(mm.validator.upper) ? mm.validator.upper : ''; validatorString += ` range=[${lowerString},${upperString}]`; } break; case `${MetaModelNamespace}.StringProperty`: case `${MetaModelNamespace}.StringScalar`: - if (mm.defaultValue !== undefined) { + if (!isNull(mm.defaultValue)) { defaultString += ` default=${toStringLiteral(mm.defaultValue)}`; } - if (mm.validator) { + if (mm.validator && !isNull(mm.validator.pattern)) { validatorString += ` regex=${toRegexLiteral(mm.validator.pattern, mm.validator.flags)}`; } - if (mm.lengthValidator) { - const minLength = mm.lengthValidator.minLength !== undefined ? mm.lengthValidator.minLength : ''; - const maxLength = mm.lengthValidator.maxLength !== undefined ? mm.lengthValidator.maxLength : ''; + if (mm.lengthValidator && (!isNull(mm.lengthValidator.minLength) || !isNull(mm.lengthValidator.maxLength))) { + const minLength = !isNull(mm.lengthValidator.minLength) ? mm.lengthValidator.minLength : ''; + const maxLength = !isNull(mm.lengthValidator.maxLength) ? mm.lengthValidator.maxLength : ''; validatorString += ` length=[${minLength},${maxLength}]`; } break; case `${MetaModelNamespace}.ObjectProperty`: - if (mm.defaultValue !== undefined) { - defaultString += ` default="${mm.defaultValue}"`; + if (!isNull(mm.defaultValue)) { + defaultString += ` default=${toStringLiteral(mm.defaultValue)}`; } break; } - if (mm.sizeValidator) { - const minSize = mm.sizeValidator.minSize !== undefined ? mm.sizeValidator.minSize : ''; - const maxSize = mm.sizeValidator.maxSize !== undefined ? mm.sizeValidator.maxSize : ''; + if (mm.sizeValidator && (!isNull(mm.sizeValidator.minSize) || !isNull(mm.sizeValidator.maxSize))) { + const minSize = !isNull(mm.sizeValidator.minSize) ? mm.sizeValidator.minSize : ''; + const maxSize = !isNull(mm.sizeValidator.maxSize) ? mm.sizeValidator.maxSize : ''; validatorString += ` size=[${minSize},${maxSize}]`; } @@ -280,12 +287,13 @@ function toStringLiteral(value: string): string { */ function toRegexLiteral(pattern: string, flags?: string): string { let source = pattern; + const cleanFlags = flags || undefined; try { - source = new RegExp(pattern, flags).source; + source = new RegExp(pattern, cleanFlags).source; } catch { // Leave invalid patterns untouched rather than throwing while printing } - return `/${source}/${flags || ''}`; + return `/${source}/${cleanFlags || ''}`; } /** @@ -427,9 +435,11 @@ function declFromMetaModel(mm: IDeclaration): string { result += '{'; // Handle properties - conceptDecl.properties.forEach((property: IProperty) => { - result += `\n ${propertyFromMetaModel(property)}`; - }); + if (conceptDecl.properties) { + conceptDecl.properties.forEach((property: IProperty) => { + result += `\n ${propertyFromMetaModel(property)}`; + }); + } result += '\n}'; } diff --git a/packages/concerto-cto/test/printer.js b/packages/concerto-cto/test/printer.js index 2592a590dc..16133a5fb6 100644 --- a/packages/concerto-cto/test/printer.js +++ b/packages/concerto-cto/test/printer.js @@ -336,4 +336,529 @@ describe('parser', () => { result.should.include('o Double value range=[0.0000001,1.0]'); }); -}); \ No newline at end of file + + it('Should not print default value when defaultValue is null', () => { + const result = Printer.toCTO({ + $class: 'concerto.metamodel@1.0.0.Model', + namespace: 'org.acme@1.0.0', + declarations: [ + { + $class: 'concerto.metamodel@1.0.0.ConceptDeclaration', + name: 'Sample', + isAbstract: false, + properties: [ + { + $class: 'concerto.metamodel@1.0.0.IntegerProperty', + name: 'intProp', + isArray: false, + isOptional: false, + defaultValue: null + }, + { + $class: 'concerto.metamodel@1.0.0.LongProperty', + name: 'longProp', + isArray: false, + isOptional: false, + defaultValue: null + }, + { + $class: 'concerto.metamodel@1.0.0.DoubleProperty', + name: 'doubleProp', + isArray: false, + isOptional: false, + defaultValue: null + }, + { + $class: 'concerto.metamodel@1.0.0.StringProperty', + name: 'stringProp', + isArray: false, + isOptional: false, + defaultValue: null + }, + { + $class: 'concerto.metamodel@1.0.0.DateTimeProperty', + name: 'dateTimeProp', + isArray: false, + isOptional: false, + defaultValue: null + }, + { + $class: 'concerto.metamodel@1.0.0.ObjectProperty', + name: 'objProp', + type: { + $class: 'concerto.metamodel@1.0.0.TypeIdentifier', + name: 'Sample' + }, + isArray: false, + isOptional: false, + defaultValue: null + } + ] + }, + { + $class: 'concerto.metamodel@1.0.0.IntegerScalar', + name: 'IntScalar', + defaultValue: null + }, + { + $class: 'concerto.metamodel@1.0.0.DoubleScalar', + name: 'DoubleScalar', + defaultValue: null + }, + { + $class: 'concerto.metamodel@1.0.0.StringScalar', + name: 'StringScalar', + defaultValue: null + } + ] + }); + + result.should.not.include('default='); + const reparsed = Parser.parse(result); + reparsed.declarations[0].properties.forEach((p) => { + should.not.exist(p.defaultValue); + }); + }); + + it('Should print falsy default values correctly', () => { + const result = Printer.toCTO({ + $class: 'concerto.metamodel@1.0.0.Model', + namespace: 'org.acme@1.0.0', + declarations: [ + { + $class: 'concerto.metamodel@1.0.0.ConceptDeclaration', + name: 'Sample', + isAbstract: false, + properties: [ + { + $class: 'concerto.metamodel@1.0.0.IntegerProperty', + name: 'zeroInt', + isArray: false, + isOptional: false, + defaultValue: 0 + }, + { + $class: 'concerto.metamodel@1.0.0.DoubleProperty', + name: 'zeroDouble', + isArray: false, + isOptional: false, + defaultValue: 0 + }, + { + $class: 'concerto.metamodel@1.0.0.StringProperty', + name: 'emptyStr', + isArray: false, + isOptional: false, + defaultValue: '' + }, + { + $class: 'concerto.metamodel@1.0.0.BooleanProperty', + name: 'falseBool', + isArray: false, + isOptional: false, + defaultValue: false + } + ] + } + ] + }); + + result.should.include('o Integer zeroInt default=0'); + result.should.include('o Double zeroDouble default=0.0'); + result.should.include('o String emptyStr default=""'); + result.should.include('o Boolean falseBool default=false'); + const reparsed = Parser.parse(result); + reparsed.declarations[0].properties[0].defaultValue.should.equal(0); + reparsed.declarations[0].properties[1].defaultValue.should.equal(0); + reparsed.declarations[0].properties[2].defaultValue.should.equal(''); + reparsed.declarations[0].properties[3].defaultValue.should.equal(false); + }); + + it('Should print single-bounded validator ranges when other bound is null', () => { + const result = Printer.toCTO({ + $class: 'concerto.metamodel@1.0.0.Model', + namespace: 'org.acme@1.0.0', + declarations: [ + { + $class: 'concerto.metamodel@1.0.0.ConceptDeclaration', + name: 'Sample', + isAbstract: false, + properties: [ + { + $class: 'concerto.metamodel@1.0.0.IntegerProperty', + name: 'intLowerOnly', + isArray: false, + isOptional: false, + validator: { + $class: 'concerto.metamodel@1.0.0.IntegerDomainValidator', + lower: 10, + upper: null + } + }, + { + $class: 'concerto.metamodel@1.0.0.IntegerProperty', + name: 'intUpperOnly', + isArray: false, + isOptional: false, + validator: { + $class: 'concerto.metamodel@1.0.0.IntegerDomainValidator', + lower: null, + upper: 10 + } + }, + { + $class: 'concerto.metamodel@1.0.0.LongProperty', + name: 'longLowerOnly', + isArray: false, + isOptional: false, + validator: { + $class: 'concerto.metamodel@1.0.0.LongDomainValidator', + lower: 100, + upper: null + } + }, + { + $class: 'concerto.metamodel@1.0.0.LongProperty', + name: 'longUpperOnly', + isArray: false, + isOptional: false, + validator: { + $class: 'concerto.metamodel@1.0.0.LongDomainValidator', + lower: null, + upper: 100 + } + }, + { + $class: 'concerto.metamodel@1.0.0.DoubleProperty', + name: 'doubleLowerOnly', + isArray: false, + isOptional: false, + validator: { + $class: 'concerto.metamodel@1.0.0.DoubleDomainValidator', + lower: 5.5, + upper: null + } + }, + { + $class: 'concerto.metamodel@1.0.0.DoubleProperty', + name: 'doubleUpperOnly', + isArray: false, + isOptional: false, + validator: { + $class: 'concerto.metamodel@1.0.0.DoubleDomainValidator', + lower: null, + upper: 5.5 + } + }, + { + $class: 'concerto.metamodel@1.0.0.StringProperty', + name: 'strMinOnly', + isArray: false, + isOptional: false, + lengthValidator: { + $class: 'concerto.metamodel@1.0.0.StringLengthValidator', + minLength: 3, + maxLength: null + } + }, + { + $class: 'concerto.metamodel@1.0.0.StringProperty', + name: 'strMaxOnly', + isArray: false, + isOptional: false, + lengthValidator: { + $class: 'concerto.metamodel@1.0.0.StringLengthValidator', + minLength: null, + maxLength: 10 + } + }, + { + $class: 'concerto.metamodel@1.0.0.StringProperty', + name: 'arraySizeMinOnly', + isArray: true, + isOptional: false, + sizeValidator: { + $class: 'concerto.metamodel@1.0.0.CollectionSizeValidator', + minSize: 1, + maxSize: null + } + }, + { + $class: 'concerto.metamodel@1.0.0.StringProperty', + name: 'arraySizeMaxOnly', + isArray: true, + isOptional: false, + sizeValidator: { + $class: 'concerto.metamodel@1.0.0.CollectionSizeValidator', + minSize: null, + maxSize: 5 + } + } + ] + } + ] + }); + + result.should.include('o Integer intLowerOnly range=[10,]'); + result.should.include('o Integer intUpperOnly range=[,10]'); + result.should.include('o Long longLowerOnly range=[100,]'); + result.should.include('o Long longUpperOnly range=[,100]'); + result.should.include('o Double doubleLowerOnly range=[5.5,]'); + result.should.include('o Double doubleUpperOnly range=[,5.5]'); + result.should.include('o String strMinOnly length=[3,]'); + result.should.include('o String strMaxOnly length=[,10]'); + result.should.include('o String[] arraySizeMinOnly size=[1,]'); + result.should.include('o String[] arraySizeMaxOnly size=[,5]'); + + const reparsed = Parser.parse(result); + reparsed.declarations[0].properties[0].validator.lower.should.equal(10); + should.not.exist(reparsed.declarations[0].properties[0].validator.upper); + reparsed.declarations[0].properties[1].validator.upper.should.equal(10); + should.not.exist(reparsed.declarations[0].properties[1].validator.lower); + reparsed.declarations[0].properties[4].validator.lower.should.equal(5.5); + should.not.exist(reparsed.declarations[0].properties[4].validator.upper); + reparsed.declarations[0].properties[5].validator.upper.should.equal(5.5); + should.not.exist(reparsed.declarations[0].properties[5].validator.lower); + }); + + it('Should normalize and escape regex pattern when flags is null', () => { + const result = Printer.toCTO({ + $class: 'concerto.metamodel@1.0.0.Model', + namespace: 'org.acme@1.0.0', + declarations: [ + { + $class: 'concerto.metamodel@1.0.0.ConceptDeclaration', + name: 'Sample', + isAbstract: false, + properties: [ + { + $class: 'concerto.metamodel@1.0.0.StringProperty', + name: 'path', + isArray: false, + isOptional: false, + validator: { + $class: 'concerto.metamodel@1.0.0.StringRegexValidator', + pattern: 'foo/bar', + flags: null + } + } + ] + } + ] + }); + + result.should.include('regex=/foo\\/bar/'); + const reparsed = Parser.parse(result); + const pattern = reparsed.declarations[0].properties[0].validator.pattern; + new RegExp(pattern).test('foo/bar').should.equal(true); + }); + + it('Should not print validators when all bounds or pattern are null or omitted', () => { + const result = Printer.toCTO({ + $class: 'concerto.metamodel@1.0.0.Model', + namespace: 'org.acme@1.0.0', + declarations: [ + { + $class: 'concerto.metamodel@1.0.0.ConceptDeclaration', + name: 'Sample', + isAbstract: false, + properties: [ + { + $class: 'concerto.metamodel@1.0.0.DoubleProperty', + name: 'doubleProp', + isArray: false, + isOptional: false, + validator: { + $class: 'concerto.metamodel@1.0.0.DoubleDomainValidator', + lower: null, + upper: null + } + }, + { + $class: 'concerto.metamodel@1.0.0.IntegerProperty', + name: 'intProp', + isArray: false, + isOptional: false, + validator: { + $class: 'concerto.metamodel@1.0.0.IntegerDomainValidator', + lower: null, + upper: null + } + }, + { + $class: 'concerto.metamodel@1.0.0.LongProperty', + name: 'longProp', + isArray: false, + isOptional: false, + validator: { + $class: 'concerto.metamodel@1.0.0.LongDomainValidator', + lower: null, + upper: null + } + }, + { + $class: 'concerto.metamodel@1.0.0.StringProperty', + name: 'strProp', + isArray: false, + isOptional: false, + validator: { + $class: 'concerto.metamodel@1.0.0.StringRegexValidator', + pattern: null, + flags: null + }, + lengthValidator: { + $class: 'concerto.metamodel@1.0.0.StringLengthValidator', + minLength: null, + maxLength: null + } + }, + { + $class: 'concerto.metamodel@1.0.0.StringProperty', + name: 'arrayProp', + isArray: true, + isOptional: false, + sizeValidator: { + $class: 'concerto.metamodel@1.0.0.CollectionSizeValidator', + minSize: null, + maxSize: null + } + } + ] + }, + { + $class: 'concerto.metamodel@1.0.0.DoubleScalar', + name: 'DoubleScalar', + validator: { + $class: 'concerto.metamodel@1.0.0.DoubleDomainValidator', + lower: null, + upper: null + } + }, + { + $class: 'concerto.metamodel@1.0.0.IntegerScalar', + name: 'IntScalar', + validator: { + $class: 'concerto.metamodel@1.0.0.IntegerDomainValidator', + lower: null, + upper: null + } + }, + { + $class: 'concerto.metamodel@1.0.0.LongScalar', + name: 'LongScalar', + validator: { + $class: 'concerto.metamodel@1.0.0.LongDomainValidator', + lower: null, + upper: null + } + }, + { + $class: 'concerto.metamodel@1.0.0.StringScalar', + name: 'StringScalar', + lengthValidator: { + $class: 'concerto.metamodel@1.0.0.StringLengthValidator', + minLength: null, + maxLength: null + } + } + ] + }); + + result.should.not.include('range='); + result.should.not.include('length='); + result.should.not.include('size='); + result.should.not.include('regex='); + const reparsed = Parser.parse(result); + should.exist(reparsed); + }); + + it('Should escape quotes and backslashes in ObjectProperty default values', () => { + const result = Printer.toCTO({ + $class: 'concerto.metamodel@1.0.0.Model', + namespace: 'org.acme@1.0.0', + declarations: [ + { + $class: 'concerto.metamodel@1.0.0.ConceptDeclaration', + name: 'Sample', + isAbstract: false, + properties: [ + { + $class: 'concerto.metamodel@1.0.0.ObjectProperty', + name: 'objWithQuote', + type: { + $class: 'concerto.metamodel@1.0.0.TypeIdentifier', + name: 'Sample' + }, + isArray: false, + isOptional: false, + defaultValue: 'hello"world' + } + ] + } + ] + }); + + result.should.include('default="hello\\"world"'); + const reparsed = Parser.parse(result); + reparsed.declarations[0].properties[0].defaultValue.should.equal('hello"world'); + }); + + it('Should handle empty decorators array without inserting spurious newlines or indentation', () => { + const result = Printer.toCTO({ + $class: 'concerto.metamodel@1.0.0.Model', + namespace: 'org.acme@1.0.0', + declarations: [ + { + $class: 'concerto.metamodel@1.0.0.ConceptDeclaration', + name: 'Sample', + isAbstract: false, + decorators: [], + properties: [ + { + $class: 'concerto.metamodel@1.0.0.StringProperty', + name: 'field', + isArray: false, + isOptional: false, + decorators: [] + } + ] + }, + { + $class: 'concerto.metamodel@1.0.0.MapDeclaration', + name: 'SampleMap', + decorators: [], + key: { + $class: 'concerto.metamodel@1.0.0.StringMapKeyType', + decorators: [] + }, + value: { + $class: 'concerto.metamodel@1.0.0.StringMapValueType', + decorators: [] + } + } + ] + }); + + result.should.equal('namespace org.acme@1.0.0\n\nconcept Sample {\n o String field\n}\n\nmap SampleMap {\n o String\n o String\n}'); + const reparsed = Parser.parse(result); + should.exist(reparsed); + }); + + it('Should handle concept declaration without properties array', () => { + const result = Printer.toCTO({ + $class: 'concerto.metamodel@1.0.0.Model', + namespace: 'org.acme@1.0.0', + declarations: [ + { + $class: 'concerto.metamodel@1.0.0.ConceptDeclaration', + name: 'EmptyConcept', + isAbstract: false + } + ] + }); + + result.should.include('concept EmptyConcept {\n}'); + const reparsed = Parser.parse(result); + reparsed.declarations[0].name.should.equal('EmptyConcept'); + }); +});