diff --git a/lib/codegen/fromcto/csharp/csharpvisitor.js b/lib/codegen/fromcto/csharp/csharpvisitor.js index 5828d455..ad27915a 100644 --- a/lib/codegen/fromcto/csharp/csharpvisitor.js +++ b/lib/codegen/fromcto/csharp/csharpvisitor.js @@ -122,9 +122,8 @@ class CSharpVisitor { parameters.fileWriter.writeLine(0, `namespace ${dotNetNamespace};`); - if (modelFile.getAllDeclarations().some(d => d.isMapDeclaration?.())) { - parameters.fileWriter.writeLine(0, 'using System.Collections.Generic;'); - } + parameters.fileWriter.writeLine(0, 'using System.Collections.Generic;'); + parameters.fileWriter.writeLine(0, 'using System.ComponentModel.DataAnnotations;'); modelFile.getImports() .map(importString => ModelUtil.getNamespace(importString)) @@ -435,24 +434,12 @@ class CSharpVisitor { const fqn = ModelUtil.removeNamespaceVersionFromFullyQualifiedName(scalarDecl.getFullyQualifiedName()); return fqn === 'concerto.scalar.UUID' ? 'System.Guid' : this.toCSharpIdentifier(undefined, scalarDecl.getName(), parameters); } - return this.toCSharpIdentifier(undefined, typeName, parameters); - } - - /** - * Returns true if the map key/value side is a scalar that declares validators. - * Used to decide whether to emit [ValidateComplexType] on the map property. - * @param {MapKeyType|MapValueType} side - key or value side of the map - * @param {ModelFile} modelFile - the model file containing the map declaration - * @returns {boolean} - true if the map side is a scalar with validators, false otherwise - * @private - */ - mapSideHasValidators(side, modelFile) { - const typeName = side.getType(); - if (!ModelUtil.isPrimitiveType(typeName) && ModelUtil.isScalar(side)) { - const scalarDecl = modelFile.getType(typeName); - return !!scalarDecl?.getValidator?.(); - } - return false; + // concept or relationship — reuse getDotNetNamespaceOfType which returns '' for + // same-namespace types and 'Some.Ns.' for cross-namespace types. + const typeFqn = modelFile.getType(typeName)?.getFullyQualifiedName(); + const canonicalName = typeFqn ? ModelUtil.getShortName(typeFqn) : typeName; + const ns = this.getDotNetNamespaceOfType(typeFqn, side.getParent(), parameters); + return `${ns}${this.toCSharpIdentifier(undefined, canonicalName, parameters)}`; } /** @@ -532,43 +519,45 @@ class CSharpVisitor { // Check the underlying field type for validator applicability (handles scalar aliases) const rawFieldType = this.getFieldType(field); - if (rawFieldType === 'String') { - const validator = field.getValidator(); - - if(validator) { - if(validator.getMinLength()) { - parameters.fileWriter.writeLine(1, `[System.ComponentModel.DataAnnotations.MinLength(${validator.getMinLength()})]`); - } - if(validator.getMaxLength()) { - parameters.fileWriter.writeLine(1, `[System.ComponentModel.DataAnnotations.MaxLength(${validator.getMaxLength()})]`); - } - if (validator.getRegex()) { - let regexVal = validator.getRegex().source; - parameters.fileWriter.writeLine(1, `[System.ComponentModel.DataAnnotations.RegularExpression(@"${regexVal}", ErrorMessage = "Invalid characters")]`); + // Only emit DataAnnotation validators for raw primitive-typed properties. + // Scalar-typed properties (externalFieldType set) already carry their validators + // on the scalar struct's Value property — emitting them here would produce + // attributes like [MinLength] on a struct type, which throws at runtime. + if (!externalFieldType) { + if (rawFieldType === 'String') { + const validator = field.getValidator(); + + if(validator) { + if(validator.getMinLength()) { + parameters.fileWriter.writeLine(1, `[System.ComponentModel.DataAnnotations.MinLength(${validator.getMinLength()})]`); + } + if(validator.getMaxLength()) { + parameters.fileWriter.writeLine(1, `[System.ComponentModel.DataAnnotations.MaxLength(${validator.getMaxLength()})]`); + } + if (validator.getRegex()) { + let regexVal = validator.getRegex().source; + parameters.fileWriter.writeLine(1, `[System.ComponentModel.DataAnnotations.RegularExpression(@"${regexVal}", ErrorMessage = "Invalid characters")]`); + } } - } - } else if (['Integer', 'Long', 'Double'].includes(rawFieldType)) { - const validator = field.getValidator(); - if (validator) { - const lower = validator.getLowerBound(); - const upper = validator.getUpperBound(); - if (lower != null || upper != null) { - const csTypeMap = { Integer: 'int', Long: 'long', Double: 'double' }; - const defaultMin = { Integer: '-2147483648', Long: '-9223372036854775808', Double: '-1.7976931348623157E+308' }; - const defaultMax = { Integer: '2147483647', Long: '9223372036854775807', Double: '1.7976931348623157E+308' }; - const csType = csTypeMap[rawFieldType]; - const lo = lower != null ? String(lower) : defaultMin[rawFieldType]; - const hi = upper != null ? String(upper) : defaultMax[rawFieldType]; - parameters.fileWriter.writeLine(1, `[System.ComponentModel.DataAnnotations.Range(typeof(${csType}), "${lo}", "${hi}")]`); + } else if (['Integer', 'Long', 'Double'].includes(rawFieldType)) { + const validator = field.getValidator(); + if (validator) { + const lower = validator.getLowerBound(); + const upper = validator.getUpperBound(); + if (lower != null || upper != null) { + const csTypeMap = { Integer: 'int', Long: 'long', Double: 'double' }; + const defaultMin = { Integer: '-2147483648', Long: '-9223372036854775808', Double: '-1.7976931348623157E+308' }; + const defaultMax = { Integer: '2147483647', Long: '9223372036854775807', Double: '1.7976931348623157E+308' }; + const csType = csTypeMap[rawFieldType]; + const lo = lower != null ? String(lower) : defaultMin[rawFieldType]; + const hi = upper != null ? String(upper) : defaultMax[rawFieldType]; + parameters.fileWriter.writeLine(1, `[System.ComponentModel.DataAnnotations.Range(typeof(${csType}), "${lo}", "${hi}")]`); + } } + } else if (!field.isPrimitive()) { + const fqn = this.getDotNetNamespaceOfType(field.getFullyQualifiedTypeName(), field.getParent(), parameters); + fieldType = `${fqn}${ModelUtil.getShortName(field.getFullyQualifiedTypeName())}`; } - } else if (!externalFieldType && !field.isPrimitive()) { - let fqn = this.getDotNetNamespaceOfType(field.getFullyQualifiedTypeName(), field.getParent(), parameters); - const modelFile = field.getModelFile(); - if (modelFile?.isImportedType(fieldType)) { - fieldType = modelFile.getImportedType(fieldType); - } - fieldType = `${fqn}${fieldType}`; } let nullableType = ''; @@ -576,8 +565,11 @@ class CSharpVisitor { nullableType = '?'; } - const rawDefault = externalFieldType !== undefined ? scalarDefaultValue : field.getDefaultValue(); - const csDefault = this.formatDefaultLiteral(rawDefault, rawFieldType, !!externalFieldType); + // Arrays have no per-element default initializer; scalar defaults must not bleed into T[] = new(x). + const rawDefault = field.isArray() + ? null + : (externalFieldType !== undefined ? scalarDefaultValue : field.getDefaultValue()); + const csDefault = this.formatDefaultLiteral(rawDefault, rawFieldType, !!externalFieldType, field, fieldType); const getset = csDefault != null ? `{ get; set; } = ${csDefault};` : '{ get; set; }'; const lines = this.toCSharpProperty( @@ -650,6 +642,14 @@ class CSharpVisitor { // if it's scalar type, remove namespace and version from fqn type = ModelUtil.removeNamespaceVersionFromFullyQualifiedName(qualifiedType); } + } else { + // Qualify the type when the relationship target is in a different namespace + const fqn = this.getDotNetNamespaceOfType( + relationship.getFullyQualifiedTypeName(), + relationship.getParent(), + parameters + ); + type = `${fqn}${ModelUtil.getShortName(relationship.getFullyQualifiedTypeName())}`; } // we export all relationships @@ -673,13 +673,32 @@ class CSharpVisitor { * @param {*} value - the raw default value from getDefaultValue() * @param {string} concertoType - the underlying Concerto primitive type * @param {boolean} isScalar - true when the property type is a scalar struct + * @param {Field} [field] - the field for context when handling enum defaults (optional) + * @param {string} [resolvedFieldType] - resolved C# property type used for enum qualification * @returns {string|null} C# literal string, or null if no default * @private */ - formatDefaultLiteral(value, concertoType, isScalar) { + formatDefaultLiteral(value, concertoType, isScalar, field, resolvedFieldType) { if (value == null) {return null;} // Pre-computed C# literal (e.g. UUID default needs System.Guid.Parse, not a bare string) if (value?.__csharpLiteral) {return value.__csharpLiteral;} + + // Handle enum types: emit the enum member access expression (e.g. PartySide.External) rather than a raw literal (e.g. "EXTERNAL") + if (field && !isScalar) { + try { + const fieldType = field.getModelFile().getType(field.getType()); + if (fieldType?.isEnum?.()) { + // Enum value found; emit Type.Member + // e.g., "EXTERNAL" -> "PartySide.External" + const enumMemberName = camelCase(String(value), { pascalCase: true }); + const enumTypeName = resolvedFieldType || this.toCSharpIdentifier(undefined, field.getType()); + return `${enumTypeName}.${enumMemberName}`; + } + } catch (e) { + // Type resolution failed; fall through to default handling + } + } + const rawLiteral = concertoType === 'String' ? `"${value}"` : String(value); return isScalar ? `new(${rawLiteral})` : rawLiteral; } diff --git a/package-lock.json b/package-lock.json index f17c32e6..bbd25355 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "pluralize": "8.0.0" }, "devDependencies": { - "@accordproject/concerto-cto": "4.1.2", + "@accordproject/concerto-cto": "4.1.3", "@babel/preset-env": "7.16.11", "@types/webgl-ext": "0.0.37", "babel-loader": "8.2.3", @@ -53,21 +53,21 @@ "npm": ">=6" }, "peerDependencies": { - "@accordproject/concerto-core": "^4.1.2", - "@accordproject/concerto-util": "^4.1.2", - "@accordproject/concerto-vocabulary": "^4.1.2" + "@accordproject/concerto-core": "^4.1.3", + "@accordproject/concerto-util": "^4.1.3", + "@accordproject/concerto-vocabulary": "^4.1.3" } }, "node_modules/@accordproject/concerto-core": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@accordproject/concerto-core/-/concerto-core-4.1.2.tgz", - "integrity": "sha512-2k5J4Rwb50DZQwdMS1hQMNwHMqtETTjkqSjVXYopemzNjYdlGAF+6FrRl2cC7zfxIffiQbd0FJPbE0bDJ0mf+A==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@accordproject/concerto-core/-/concerto-core-4.1.3.tgz", + "integrity": "sha512-fQj8Q7/jW4XnNI7oz1xZ6BgPVkgZeTVwtkk2+MotWLMX2L8UVn0bMJGR+DBELSyxiTT1f6ELPjU+XclF9bWcUw==", "license": "Apache-2.0", "peer": true, "dependencies": { - "@accordproject/concerto-cto": "4.1.2", + "@accordproject/concerto-cto": "4.1.3", "@accordproject/concerto-metamodel": "^3.13.0", - "@accordproject/concerto-util": "4.1.2", + "@accordproject/concerto-util": "4.1.3", "dayjs": "1.11.13", "debug": "4.3.7", "lorem-ipsum": "2.0.8", @@ -109,13 +109,13 @@ "peer": true }, "node_modules/@accordproject/concerto-cto": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@accordproject/concerto-cto/-/concerto-cto-4.1.2.tgz", - "integrity": "sha512-oidSD2YC26YRiuEUctgYhIN2HMnNRA4TH/rTvMdmLUJasfD947TYCh4XwtJ4YNcjpRy80TRmCwcup7O/CIQ0vA==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@accordproject/concerto-cto/-/concerto-cto-4.1.3.tgz", + "integrity": "sha512-GNQMBwHiubNP1w4u0eE3Zu97fUTQHJ2ahMAj3F3/X7gLPbXGYYFQGag4hk9Z58UW/bRks4cgxtUJsWhaFQrFQw==", "license": "Apache-2.0", "dependencies": { "@accordproject/concerto-metamodel": "^3.13.0", - "@accordproject/concerto-util": "4.1.2", + "@accordproject/concerto-util": "4.1.3", "acorn": "^8.15.0", "path-browserify": "^1.0.1", "schema-utils": "^4.3.3" @@ -172,9 +172,9 @@ } }, "node_modules/@accordproject/concerto-util": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@accordproject/concerto-util/-/concerto-util-4.1.2.tgz", - "integrity": "sha512-pHtZuWDrLXuNLrpyGaoRKcSW5fBZyrnz9IQ27aNnQt+JLSTovQzkw/JHuPUSi9EWZBC1/Ak/yUCiDpLjHfV1Dg==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@accordproject/concerto-util/-/concerto-util-4.1.3.tgz", + "integrity": "sha512-5Y1fSTqvjB7QOUpy1ivR+Ih7TtrYCGomyrRruWzIP4Xq2he86u2YhKm4aBjrN3SpQRHX6BnGl/GGX0yTTRwVNg==", "license": "Apache-2.0", "dependencies": { "@supercharge/promise-pool": "1.7.0", @@ -188,9 +188,9 @@ } }, "node_modules/@accordproject/concerto-vocabulary": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@accordproject/concerto-vocabulary/-/concerto-vocabulary-4.1.2.tgz", - "integrity": "sha512-B4rqccN6kssdtj36jZN1J4f75tQEa3vtRcGtIdvkGnmTmrz9GPrJZvSs8au1iLK8c+TB8z2Jb/GvVO5zc7jiyw==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@accordproject/concerto-vocabulary/-/concerto-vocabulary-4.1.3.tgz", + "integrity": "sha512-yn0bPtwKZ0F2IHx68PSwDttXV6NPoO+mFNmOpIb1HT433t4tAYaAulN7x5rSugBgYlZDW61WQVA4kfEPFIh43Q==", "license": "Apache-2.0", "peer": true, "dependencies": { diff --git a/package.json b/package.json index e1790cfd..d4473cb6 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "author": "accordproject.org", "license": "Apache-2.0", "devDependencies": { - "@accordproject/concerto-cto": "4.1.2", + "@accordproject/concerto-cto": "4.1.3", "@babel/preset-env": "7.16.11", "@types/webgl-ext": "0.0.37", "babel-loader": "8.2.3", @@ -88,9 +88,9 @@ "pluralize": "8.0.0" }, "peerDependencies": { - "@accordproject/concerto-core": "^4.1.2", - "@accordproject/concerto-util": "^4.1.2", - "@accordproject/concerto-vocabulary": "^4.1.2" + "@accordproject/concerto-core": "^4.1.3", + "@accordproject/concerto-util": "^4.1.3", + "@accordproject/concerto-vocabulary": "^4.1.3" }, "license-check-and-add-config": { "folder": "./lib", diff --git a/test/codegen/__snapshots__/codegen.js.snap b/test/codegen/__snapshots__/codegen.js.snap index 18f9f7f8..5587f3a7 100644 --- a/test/codegen/__snapshots__/codegen.js.snap +++ b/test/codegen/__snapshots__/codegen.js.snap @@ -621,6 +621,8 @@ exports[`codegen #formats check we can convert all formats from namespace versio { "key": "concerto.decorator@1.0.0.cs", "value": "namespace concerto.decorator; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using AccordProject.Concerto; [AccordProject.Concerto.Type(Namespace = "concerto.decorator", Version = "1.0.0", Name = "Decorator")] [System.Text.Json.Serialization.JsonConverter(typeof(AccordProject.Concerto.ConcertoConverterFactorySystem))] @@ -644,6 +646,8 @@ exports[`codegen #formats check we can convert all formats from namespace versio { "key": "concerto@1.0.0.cs", "value": "namespace AccordProject.Concerto; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using concerto.decorator; [AccordProject.Concerto.Type(Namespace = "concerto", Version = "1.0.0", Name = "Concept")] [System.Text.Json.Serialization.JsonConverter(typeof(AccordProject.Concerto.ConcertoConverterFactorySystem))] @@ -696,6 +700,7 @@ exports[`codegen #formats check we can convert all formats from namespace versio "key": "org.acme.hr.base@1.0.0.cs", "value": "namespace org.acme.hr.base; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using AccordProject.Concerto; [AccordProject.Concerto.Type(Namespace = "org.acme.hr.base", Version = "1.0.0", Name = "Category")] [System.Text.Json.Serialization.JsonConverter(typeof(AccordProject.Concerto.ConcertoConverterFactorySystem))] @@ -778,6 +783,7 @@ exports[`codegen #formats check we can convert all formats from namespace versio "key": "org.acme.hr@1.0.0.cs", "value": "namespace org.acme.hr; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using org.acme.hr.base; using concerto.decorator; using AccordProject.Concerto; @@ -865,7 +871,6 @@ public abstract class Person : Participant { public string lastName { get; set; } public string? middleNames { get; set; } public org.acme.hr.base.Address homeAddress { get; set; } - [System.ComponentModel.DataAnnotations.RegularExpression(@"(\\d{3}-\\d{2}-\\d{4})+", ErrorMessage = "Invalid characters")] public SSN ssn { get; set; } = new("000-00-0000"); public double height { get; set; } public System.DateTime dob { get; set; } diff --git a/test/codegen/fromJsonSchema/cto/data/concertoModel.cto b/test/codegen/fromJsonSchema/cto/data/concertoModel.cto index 9b753591..23d5cacd 100644 --- a/test/codegen/fromJsonSchema/cto/data/concertoModel.cto +++ b/test/codegen/fromJsonSchema/cto/data/concertoModel.cto @@ -6,7 +6,7 @@ concept Root { o DateTime dob o DateTime graduationDate optional o Integer age - o Double height range=[50,] + o Double height range=[50.0,] o String favouriteFood optional o Root$_properties$_children[] children o Root$_properties$_company company @@ -97,7 +97,7 @@ concept Pet { concept Stuff { o String sku - o Double price range=[,99999999] + o Double price range=[,99999999.0] o Pet product } diff --git a/test/codegen/fromJsonSchema/cto/jsonSchemaVisitor.js b/test/codegen/fromJsonSchema/cto/jsonSchemaVisitor.js index 639cd7e2..804aed7b 100644 --- a/test/codegen/fromJsonSchema/cto/jsonSchemaVisitor.js +++ b/test/codegen/fromJsonSchema/cto/jsonSchemaVisitor.js @@ -254,8 +254,8 @@ concept veggie { concept geographical_location { o String name default="home" regex=/[\\w\\s]+/ optional o Double latitude - o Double longitude range=[-180,180] - o Double elevation range=[-11034,] optional + o Double longitude range=[-180.0,180.0] + o Double elevation range=[-11034.0,] optional o Integer yearDiscovered range=[,2022] optional }`); }); diff --git a/test/codegen/fromcto/csharp/csharpvisitor.js b/test/codegen/fromcto/csharp/csharpvisitor.js index 70408c01..f9faf533 100644 --- a/test/codegen/fromcto/csharp/csharpvisitor.js +++ b/test/codegen/fromcto/csharp/csharpvisitor.js @@ -148,6 +148,19 @@ describe('CSharpVisitor', function () { file.should.match(/namespace Org.Acme.Models;/); }); + it('should throw when @DotNetNamespace has wrong number of arguments', () => { + const modelManager = new ModelManager({ strict: true }); + modelManager.addCTOModel(` + @DotNetNamespace("Org.Acme", "Extra") + namespace org.acme@1.2.3 + + concept Thing {} + `); + (() => { + csharpVisitor.visit(modelManager, { fileWriter }); + }).should.throw('Malformed @DotNetNamespace decorator'); + }); + it('should use the imported @DotNetNamespace decorator if present', () => { const modelManager = new ModelManager({ strict: true }); modelManager.addCTOModel(` @@ -613,143 +626,27 @@ describe('CSharpVisitor', function () { file.should.match(/w\.WriteBooleanValue\(v\.Value\)/); }); - it('should emit a Newtonsoft JsonConverter for a String-backed scalar with useNewtonsoftJson flag', () => { + it('should emit a Newtonsoft JsonConverter for a String-backed scalar', () => { const modelManager = new ModelManager({ strict: true }); modelManager.addCTOModel(` namespace org.acme@1.0.0 - scalar SSN extends String regex=/\\d{3}-\\d{2}-\\d{4}/ + scalar SSN extends String - concept Person identified by ssn { + concept Person { o SSN ssn - o String givenName } `); csharpVisitor.visit(modelManager, { fileWriter, useNewtonsoftJson: true }); const file = fileWriter.getFilesInMemory().get('org.acme@1.0.0.cs'); - file.should.match(/\[Newtonsoft\.Json\.JsonConverter\(typeof\(SSNJsonConverter\)\)\]/); file.should.match(/public readonly record struct SSN\(string Value\)/); file.should.match(/public class SSNJsonConverter : Newtonsoft\.Json\.JsonConverter/); + file.should.match(/public override SSN ReadJson/); file.should.match(/\(string\)r\.Value!/); file.should.match(/w\.WriteValue\(v\.Value\)/); }); - it('should emit a Newtonsoft JsonConverter for a UUID scalar with useNewtonsoftJson flag', () => { - const modelManager = new ModelManager({ strict: true }); - modelManager.addCTOModel(` - namespace concerto.scalar@1.0.0 - - scalar UUID extends String default="00000000-0000-0000-0000-000000000000" - `); - modelManager.addCTOModel(` - namespace org.acme@1.0.0 - - import concerto.scalar@1.0.0.{ UUID } - - concept Thing { - o UUID id - } - `); - csharpVisitor.visit(modelManager, { fileWriter, useNewtonsoftJson: true }); - const scalarFile = fileWriter.getFilesInMemory().get('concerto.scalar@1.0.0.cs'); - - // struct is Guid-backed - scalarFile.should.match(/public readonly record struct UUID\(System\.Guid Value\)/); - // converter attribute uses Newtonsoft - scalarFile.should.match(/\[Newtonsoft\.Json\.JsonConverter\(typeof\(UUIDJsonConverter\)\)\]/); - // converter extends Newtonsoft base - scalarFile.should.match(/public class UUIDJsonConverter : Newtonsoft\.Json\.JsonConverter/); - // Newtonsoft-style read/write - scalarFile.should.match(/public override UUID ReadJson\(/); - scalarFile.should.match(/public override void WriteJson\(/); - scalarFile.should.match(/System\.Guid\.Parse\(\(string\)r\.Value!\)/); - }); - - it('should emit a Newtonsoft JsonConverter for an Integer scalar with useNewtonsoftJson flag', () => { - const modelManager = new ModelManager({ strict: true }); - modelManager.addCTOModel(` - namespace org.acme@1.0.0 - - scalar Age extends Integer range=[0,150] - - concept Person { - o Age age - } - `); - csharpVisitor.visit(modelManager, { fileWriter, useNewtonsoftJson: true }); - const file = fileWriter.getFilesInMemory().get('org.acme@1.0.0.cs'); - - file.should.match(/\[Newtonsoft\.Json\.JsonConverter\(typeof\(AgeJsonConverter\)\)\]/); - file.should.match(/public readonly record struct Age\(int Value\)/); - file.should.match(/public class AgeJsonConverter : Newtonsoft\.Json\.JsonConverter/); - file.should.match(/System\.Convert\.ToInt32\(r\.Value\)/); - file.should.match(/w\.WriteValue\(v\.Value\)/); - }); - - it('should emit a Newtonsoft JsonConverter for a Long scalar with useNewtonsoftJson flag', () => { - const modelManager = new ModelManager({ strict: true }); - modelManager.addCTOModel(` - namespace org.acme@1.0.0 - - scalar BigNumber extends Long - - concept Item { - o BigNumber count - } - `); - csharpVisitor.visit(modelManager, { fileWriter, useNewtonsoftJson: true }); - const file = fileWriter.getFilesInMemory().get('org.acme@1.0.0.cs'); - - file.should.match(/\[Newtonsoft\.Json\.JsonConverter\(typeof\(BigNumberJsonConverter\)\)\]/); - file.should.match(/public readonly record struct BigNumber\(long Value\)/); - file.should.match(/public class BigNumberJsonConverter : Newtonsoft\.Json\.JsonConverter/); - file.should.match(/System\.Convert\.ToInt64\(r\.Value\)/); - file.should.match(/w\.WriteValue\(v\.Value\)/); - }); - - it('should emit a Newtonsoft JsonConverter for a Double scalar with useNewtonsoftJson flag', () => { - const modelManager = new ModelManager({ strict: true }); - modelManager.addCTOModel(` - namespace org.acme@1.0.0 - - scalar Weight extends Double range=[0.0,500.0] - - concept Item { - o Weight weight - } - `); - csharpVisitor.visit(modelManager, { fileWriter, useNewtonsoftJson: true }); - const file = fileWriter.getFilesInMemory().get('org.acme@1.0.0.cs'); - - file.should.match(/\[Newtonsoft\.Json\.JsonConverter\(typeof\(WeightJsonConverter\)\)\]/); - file.should.match(/public readonly record struct Weight\(double Value\)/); - file.should.match(/public class WeightJsonConverter : Newtonsoft\.Json\.JsonConverter/); - file.should.match(/System\.Convert\.ToDouble\(r\.Value\)/); - file.should.match(/w\.WriteValue\(v\.Value\)/); - }); - - it('should emit a Newtonsoft JsonConverter for a Boolean scalar with useNewtonsoftJson flag', () => { - const modelManager = new ModelManager({ strict: true }); - modelManager.addCTOModel(` - namespace org.acme@1.0.0 - - scalar Flag extends Boolean - - concept Config { - o Flag enabled - } - `); - csharpVisitor.visit(modelManager, { fileWriter, useNewtonsoftJson: true }); - const file = fileWriter.getFilesInMemory().get('org.acme@1.0.0.cs'); - - file.should.match(/\[Newtonsoft\.Json\.JsonConverter\(typeof\(FlagJsonConverter\)\)\]/); - file.should.match(/public readonly record struct Flag\(bool Value\)/); - file.should.match(/public class FlagJsonConverter : Newtonsoft\.Json\.JsonConverter/); - file.should.match(/\(bool\)r\.Value!/); - file.should.match(/w\.WriteValue\(v\.Value\)/); - }); - it('should use regex annotation when regex pattern provided to a field', () => { const modelManager = new ModelManager({ strict: true }); modelManager.addCTOModel(fs.readFileSync(path.resolve(__dirname, '../data/model/agreement.cto'), 'utf8'), 'agreement.cto'); @@ -757,6 +654,8 @@ describe('CSharpVisitor', function () { const files = fileWriter.getFilesInMemory(); const file1 = files.get('org.acme@1.2.3.cs'); file1.should.equal(`namespace org.acme; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using AccordProject.Concerto; [AccordProject.Concerto.Type(Namespace = "org.acme", Version = "1.2.3", Name = "AgreementBase")] [System.Text.Json.Serialization.JsonConverter(typeof(AccordProject.Concerto.ConcertoConverterFactorySystem))] @@ -784,6 +683,8 @@ public class AgreementBase : Concept { const files = fileWriter.getFilesInMemory(); const file1 = files.get('org.acme@1.2.3.cs'); file1.should.equal(`namespace org.acme; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using AccordProject.Concerto; [AccordProject.Concerto.Type(Namespace = "org.acme", Version = "1.2.3", Name = "SampleModel")] [System.Text.Json.Serialization.JsonConverter(typeof(AccordProject.Concerto.ConcertoConverterFactorySystem))] @@ -895,6 +796,22 @@ public class SampleModel : Concept { file1.should.match(/public Ratio\? ratio \{ get; set; \}/); }); + it('should use type min when only upper bound is provided in a scalar range validator', () => { + const modelManager = new ModelManager({ strict: true }); + modelManager.addCTOModel(` + namespace org.acme@1.2.3 + + scalar Score extends Integer range=[,100] + + concept Result { + o Score score + } + `); + csharpVisitor.visit(modelManager, { fileWriter }); + const file1 = fileWriter.getFilesInMemory().get('org.acme@1.2.3.cs'); + file1.should.match(/\[System\.ComponentModel\.DataAnnotations\.Range\(typeof\(int\), "-2147483648", "100"\)\]/); + }); + it('should emit property initializers for default values on primitive fields', () => { const modelManager = new ModelManager({ strict: true }); modelManager.addCTOModel(` @@ -942,6 +859,27 @@ public class SampleModel : Concept { file1.should.match(/public Score score \{ get; set; \} = new\(100\);/); }); + it('should emit property initializers for default enum values', () => { + const modelManager = new ModelManager({ strict: true }); + modelManager.addCTOModel(` + namespace org.acme@1.2.3 + + enum Status { + o ACTIVE + o INACTIVE + } + + concept Task { + o Status status default="ACTIVE" + } + `); + csharpVisitor.visit(modelManager, { fileWriter }); + const files = fileWriter.getFilesInMemory(); + const file1 = files.get('org.acme@1.2.3.cs'); + // Enum default values should be emitted as qualified C# enum members + file1.should.match(/public Status status \{ get; set; \} = Status.Active;/); + }); + it('should use UUID alias for scalar type UUID with different namespace than concerto.scalar', () => { const modelManager = new ModelManager({ strict: true }); modelManager.addCTOModel(` @@ -1098,6 +1036,26 @@ public class SampleModel : Concept { file1.should.match(/public string someOtherThingId/); }); + it('should use relationship type name when enableReferenceType is set but target has no identifier', () => { + let mockRelationship = sinon.createStubInstance(RelationshipDeclaration); + mockRelationship.isRelationship.returns(true); + mockRelationship.getName.returns('thing'); + mockRelationship.getType.returns('PlainThing'); + mockRelationship.getFullyQualifiedTypeName.returns('org.acme@1.2.3.PlainThing'); + mockRelationship.isArray.returns(false); + mockRelationship.isOptional.returns(false); + mockRelationship.getParent.returns({ getModelFile: () => null, getName: () => undefined }); + + const mockTypeDecl = { getIdentifierFieldName: () => null }; + const mockModelManager = { getType: () => mockTypeDecl }; + const mockModelFile = { getModelManager: () => mockModelManager }; + mockRelationship.getModelFile.returns(mockModelFile); + + const param = { fileWriter: mockFileWriter, enableReferenceType: true }; + csharpVisitor.visitRelationship(mockRelationship, param); + param.fileWriter.writeLine.withArgs(1, 'public PlainThing thing { get; set; }').calledOnce.should.be.ok; + }); + it('should not use relationship id if enableReferenceType param is not set', () => { const modelManager = new ModelManager({ strict: true }); modelManager.addCTOModel(` @@ -1127,8 +1085,8 @@ public class SampleModel : Concept { const files = fileWriter.getFilesInMemory(); const file1 = files.get('org.acme@1.2.3.cs'); file1.should.match(/namespace org.acme;/); - file1.should.match(/public OtherThing otherThingId/); - file1.should.match(/public SomeOtherThing someOtherThingId/); + file1.should.match(/public org.acme.other.OtherThing otherThingId/); + file1.should.match(/public org.acme.other.SomeOtherThing someOtherThingId/); }); it('should use relationship id (System.Guid) if enableReferenceType param is set to true', () => { @@ -1422,11 +1380,13 @@ public class SampleModel : Concept { csharpVisitor.visitModelFile(mockModelFile, myParams); param.fileWriter.openFile.withArgs('org.acme.cs').calledOnce.should.be.ok; - param.fileWriter.writeLine.callCount.should.equal(4); + param.fileWriter.writeLine.callCount.should.equal(6); param.fileWriter.writeLine.getCall(0).args.should.deep.equal([0, 'namespace Concerto.Models.org.acme;']); - param.fileWriter.writeLine.getCall(1).args.should.deep.equal([0, 'using Concerto.Models.org.org1;']); - param.fileWriter.writeLine.getCall(2).args.should.deep.equal([0, 'using Concerto.Models.org.org2;']); - param.fileWriter.writeLine.getCall(3).args.should.deep.equal([0, 'using Concerto.Models.super;']); + param.fileWriter.writeLine.getCall(1).args.should.deep.equal([0, 'using System.Collections.Generic;']); + param.fileWriter.writeLine.getCall(2).args.should.deep.equal([0, 'using System.ComponentModel.DataAnnotations;']); + param.fileWriter.writeLine.getCall(3).args.should.deep.equal([0, 'using Concerto.Models.org.org1;']); + param.fileWriter.writeLine.getCall(4).args.should.deep.equal([0, 'using Concerto.Models.org.org2;']); + param.fileWriter.writeLine.getCall(5).args.should.deep.equal([0, 'using Concerto.Models.super;']); param.fileWriter.closeFile.calledOnce.should.be.ok; acceptSpy.withArgs(csharpVisitor, myParams).calledThrice.should.be.ok; }); @@ -1499,11 +1459,13 @@ public class SampleModel : Concept { csharpVisitor.visitModelFile(mockModelFile, newtonsoftParams); param.fileWriter.openFile.withArgs('org.acme.cs').calledOnce.should.be.ok; - param.fileWriter.writeLine.callCount.should.equal(4); + param.fileWriter.writeLine.callCount.should.equal(6); param.fileWriter.writeLine.getCall(0).args.should.deep.equal([0, 'namespace Concerto.Models.org.acme;']); - param.fileWriter.writeLine.getCall(1).args.should.deep.equal([0, 'using Concerto.Models.org.org1;']); - param.fileWriter.writeLine.getCall(2).args.should.deep.equal([0, 'using Concerto.Models.org.org2;']); - param.fileWriter.writeLine.getCall(3).args.should.deep.equal([0, 'using Concerto.Models.super;']); + param.fileWriter.writeLine.getCall(1).args.should.deep.equal([0, 'using System.Collections.Generic;']); + param.fileWriter.writeLine.getCall(2).args.should.deep.equal([0, 'using System.ComponentModel.DataAnnotations;']); + param.fileWriter.writeLine.getCall(3).args.should.deep.equal([0, 'using Concerto.Models.org.org1;']); + param.fileWriter.writeLine.getCall(4).args.should.deep.equal([0, 'using Concerto.Models.org.org2;']); + param.fileWriter.writeLine.getCall(5).args.should.deep.equal([0, 'using Concerto.Models.super;']); param.fileWriter.closeFile.calledOnce.should.be.ok; acceptSpy.withArgs(csharpVisitor, newtonsoftParams).calledThrice.should.be.ok; }); @@ -1571,10 +1533,12 @@ public class SampleModel : Concept { csharpVisitor.visitModelFile(mockModelFile, myParams); param.fileWriter.openFile.withArgs('org.acme.cs').calledOnce.should.be.ok; - param.fileWriter.writeLine.callCount.should.equal(3); + param.fileWriter.writeLine.callCount.should.equal(5); param.fileWriter.writeLine.getCall(0).args.should.deep.equal([0, 'namespace Concerto.Models.org.acme;']); - param.fileWriter.writeLine.getCall(1).args.should.deep.equal([0, 'using Concerto.Models.org.org1;']); - param.fileWriter.writeLine.getCall(2).args.should.deep.equal([0, 'using Concerto.Models.org.org2;']); + param.fileWriter.writeLine.getCall(1).args.should.deep.equal([0, 'using System.Collections.Generic;']); + param.fileWriter.writeLine.getCall(2).args.should.deep.equal([0, 'using System.ComponentModel.DataAnnotations;']); + param.fileWriter.writeLine.getCall(3).args.should.deep.equal([0, 'using Concerto.Models.org.org1;']); + param.fileWriter.writeLine.getCall(4).args.should.deep.equal([0, 'using Concerto.Models.org.org2;']); param.fileWriter.closeFile.calledOnce.should.be.ok; acceptSpy.withArgs(csharpVisitor, myParams).calledTwice.should.be.ok; }); @@ -1607,6 +1571,17 @@ public class SampleModel : Concept { acceptSpy.withArgs(csharpVisitor, param).calledTwice.should.be.ok; }); + + it('should write Newtonsoft JsonConverter for enum when useNewtonsoftJson is set', () => { + let mockEnumDeclaration = sinon.createStubInstance(EnumDeclaration); + mockEnumDeclaration.isEnum.returns(true); + mockEnumDeclaration.getName.returns('Status'); + mockEnumDeclaration.getOwnProperties.returns([]); + const newtonsoftParam = { fileWriter: mockFileWriter, useNewtonsoftJson: true }; + csharpVisitor.visitEnumDeclaration(mockEnumDeclaration, newtonsoftParam); + mockFileWriter.writeLine.withArgs(0, '[Newtonsoft.Json.JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]').calledOnce.should.be.ok; + mockFileWriter.writeLine.withArgs(0, '[System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))]').called.should.not.be.ok; + }); }); describe('visitClassDeclaration', () => { @@ -1923,6 +1898,8 @@ public class SampleModel : Concept { mockField.getType.returns('Enum'); mockField.isOptional.returns(true); mockField.isTypeEnum.returns(true); + mockField.getFullyQualifiedTypeName.returns('org.acme@1.0.0.Enum'); + mockField.getParent.returns({ getModelFile: () => null, getIdentifierFieldName: () => undefined, getName: () => undefined }); csharpVisitor.visitField(mockField, param); param.fileWriter.writeLine.withArgs(1, 'public Enum? myEnum { get; set; }').calledOnce.should.be.ok; }); @@ -1943,6 +1920,7 @@ public class SampleModel : Concept { mockModelFile.getModelManager.returns(mockModelManager); mockClassDeclaration.getModelFile.returns(mockModelFile); mockField.getParent.returns(mockClassDeclaration); + mockField.getFullyQualifiedTypeName.returns('org.acme@1.0.0.Person'); csharpVisitor.visitField(mockField, param); param.fileWriter.writeLine.withArgs(1, 'public Person[] Bob { get; set; }').calledOnce.should.be.ok; }); @@ -2006,7 +1984,8 @@ public class SampleModel : Concept { mockMapDeclaration.getName.returns('Map1'); mockMapDeclaration.isMapDeclaration.returns(true); mockMapDeclaration.getKey.returns({ getType: getKeyType }); - mockMapDeclaration.getValue.returns({ getType: getValueType }); + mockMapDeclaration.getValue.returns({ getType: getValueType, getParent: () => null }); + mockMapDeclaration.getModelFile.returns(modelFile); csharpVisitor.visitField(mockField, param); param.fileWriter.writeLine.withArgs(1, 'public Dictionary Map1 { get; set; }').calledOnce.should.be.ok; @@ -2040,35 +2019,6 @@ public class SampleModel : Concept { csharpVisitor.visitField(mockField, param); param.fileWriter.writeLine.withArgs(1, 'public Dictionary Map1 { get; set; }').calledOnce.should.be.ok; }); - - it('should write a line for field name and type thats a map of ', () => { - const mockField = sinon.createStubInstance(Field); - const getType = sinon.stub(); - - mockField.dummy = 'Dummy Value'; - mockField.getModelFile.returns({ getType: getType }); - - sandbox.restore(); - sandbox.stub(ModelUtil, 'isMap').callsFake(() => { - return true; - }); - - let mockMapDeclaration = sinon.createStubInstance(MapDeclaration); - const getKeyType = sinon.stub(); - const getValueType = sinon.stub(); - - getType.returns(mockMapDeclaration); - getKeyType.returns('String'); - getValueType.returns('DateTime'); - mockField.getName.returns('Map1'); - mockMapDeclaration.getName.returns('Map1'); - mockMapDeclaration.isMapDeclaration.returns(true); - mockMapDeclaration.getKey.returns({ getType: getKeyType }); - mockMapDeclaration.getValue.returns({ getType: getValueType }); - - csharpVisitor.visitField(mockField, param); - param.fileWriter.writeLine.withArgs(1, 'public Dictionary Map1 { get; set; }').calledOnce.should.be.ok; - }); }); describe('visitEnumValueDeclaration', () => { @@ -2126,6 +2076,8 @@ public class SampleModel : Concept { mockRelationship.isRelationship.returns(true); mockRelationship.getName.returns('Bob'); mockRelationship.getType.returns('Person'); + mockRelationship.getFullyQualifiedTypeName.returns('org.acme@1.0.0.Person'); + mockRelationship.getParent.returns({ getModelFile: () => null, getName: () => undefined }); csharpVisitor.visitRelationship(mockRelationship, param); param.fileWriter.writeLine.withArgs(1, 'public Person Bob { get; set; }').calledOnce.should.be.ok; @@ -2137,6 +2089,8 @@ public class SampleModel : Concept { mockField.getName.returns('Bob'); mockField.getType.returns('Person'); mockField.isArray.returns(true); + mockField.getFullyQualifiedTypeName.returns('org.acme@1.0.0.Person'); + mockField.getParent.returns({ getModelFile: () => null, getName: () => undefined }); csharpVisitor.visitRelationship(mockField, param); param.fileWriter.writeLine.withArgs(1, 'public Person[] Bob { get; set; }').calledOnce.should.be.ok;