diff --git a/lib/codegen/fromcto/csharp/csharpvisitor.js b/lib/codegen/fromcto/csharp/csharpvisitor.js index aa61300..5828d45 100644 --- a/lib/codegen/fromcto/csharp/csharpvisitor.js +++ b/lib/codegen/fromcto/csharp/csharpvisitor.js @@ -1,3 +1,4 @@ +/* eslint-disable eqeqeq */ /* eslint-disable no-unreachable */ /* * Licensed under the Apache License, Version 2.0 (the "License"); @@ -121,6 +122,10 @@ class CSharpVisitor { parameters.fileWriter.writeLine(0, `namespace ${dotNetNamespace};`); + if (modelFile.getAllDeclarations().some(d => d.isMapDeclaration?.())) { + parameters.fileWriter.writeLine(0, 'using System.Collections.Generic;'); + } + modelFile.getImports() .map(importString => ModelUtil.getNamespace(importString)) .filter(namespace => namespace !== modelFile.getNamespace()) // Skip own namespace. @@ -278,16 +283,110 @@ class CSharpVisitor { const csharpType = fqn === 'concerto.scalar.UUID' ? 'System.Guid' : this.toCSharpType(scalarDeclaration.getType()); - + const validatorLines = this.buildScalarValidatorLines(scalarDeclaration); + const converterName = `${identifier}JsonConverter`; + const useNewtonsoft = !!parameters.useNewtonsoftJson; + + const converterAttr = useNewtonsoft + ? `[Newtonsoft.Json.JsonConverter(typeof(${converterName}))]` + : `[System.Text.Json.Serialization.JsonConverter(typeof(${converterName}))]`; + const converterBase = useNewtonsoft + ? `Newtonsoft.Json.JsonConverter<${identifier}>` + : `System.Text.Json.Serialization.JsonConverter<${identifier}>`; + const readSig = useNewtonsoft + ? `public override ${identifier} ReadJson(Newtonsoft.Json.JsonReader r, System.Type t, ${identifier} existing, bool hasExisting, Newtonsoft.Json.JsonSerializer s)` + : `public override ${identifier} Read(ref System.Text.Json.Utf8JsonReader r, System.Type t, System.Text.Json.JsonSerializerOptions o)`; + const writeSig = useNewtonsoft + ? `public override void WriteJson(Newtonsoft.Json.JsonWriter w, ${identifier} v, Newtonsoft.Json.JsonSerializer s)` + : `public override void Write(System.Text.Json.Utf8JsonWriter w, ${identifier} v, System.Text.Json.JsonSerializerOptions o)`; + + parameters.fileWriter.writeLine(0, converterAttr); parameters.fileWriter.writeLine(0, `public readonly record struct ${identifier}(${csharpType} Value)`); parameters.fileWriter.writeLine(0, '{'); + if (validatorLines.length > 0) { + validatorLines.forEach(line => parameters.fileWriter.writeLine(1, line)); + parameters.fileWriter.writeLine(1, `public ${csharpType} Value { get; init; } = Value;`); + } parameters.fileWriter.writeLine(1, `public static implicit operator ${csharpType}(${identifier} s) => s.Value;`); parameters.fileWriter.writeLine(1, `public static implicit operator ${identifier}(${csharpType} v) => new(v);`); parameters.fileWriter.writeLine(1, 'public override string ToString() => Value.ToString();'); parameters.fileWriter.writeLine(0, '}'); + + // Companion converter — one per scalar, flavoured by the active serializer + let readExpr, writeExpr; + if (csharpType === 'System.Guid') { + readExpr = useNewtonsoft ? 'System.Guid.Parse((string)r.Value!)' : 'r.GetGuid()'; + writeExpr = useNewtonsoft ? 'w.WriteValue(v.Value.ToString())' : 'w.WriteStringValue(v.Value.ToString())'; + } else if (csharpType === 'string') { + readExpr = useNewtonsoft ? '(string)r.Value!' : 'r.GetString()!'; + writeExpr = useNewtonsoft ? 'w.WriteValue(v.Value)' : 'w.WriteStringValue(v.Value)'; + } else if (csharpType === 'bool') { + readExpr = useNewtonsoft ? '(bool)r.Value!' : 'r.GetBoolean()'; + writeExpr = useNewtonsoft ? 'w.WriteValue(v.Value)' : 'w.WriteBooleanValue(v.Value)'; + } else if (csharpType === 'int') { + readExpr = useNewtonsoft ? 'System.Convert.ToInt32(r.Value)' : 'r.GetInt32()'; + writeExpr = useNewtonsoft ? 'w.WriteValue(v.Value)' : 'w.WriteNumberValue(v.Value)'; + } else if (csharpType === 'long') { + readExpr = useNewtonsoft ? 'System.Convert.ToInt64(r.Value)' : 'r.GetInt64()'; + writeExpr = useNewtonsoft ? 'w.WriteValue(v.Value)' : 'w.WriteNumberValue(v.Value)'; + } else if (csharpType === 'double') { + readExpr = useNewtonsoft ? 'System.Convert.ToDouble(r.Value)' : 'r.GetDouble()'; + writeExpr = useNewtonsoft ? 'w.WriteValue(v.Value)' : 'w.WriteNumberValue(v.Value)'; + } else { + readExpr = useNewtonsoft + ? `(${csharpType})System.Convert.ChangeType((string)r.Value!, typeof(${csharpType}))` + : `(${csharpType})System.Convert.ChangeType(r.GetString()!, typeof(${csharpType}))`; + writeExpr = useNewtonsoft ? 'w.WriteValue(v.Value.ToString()!)' : 'w.WriteStringValue(v.Value.ToString()!)'; + } + + parameters.fileWriter.writeLine(0, `public class ${converterName} : ${converterBase}`); + parameters.fileWriter.writeLine(0, '{'); + parameters.fileWriter.writeLine(1, readSig); + parameters.fileWriter.writeLine(2, `=> new(${readExpr});`); + parameters.fileWriter.writeLine(1, writeSig); + parameters.fileWriter.writeLine(2, `=> ${writeExpr};`); + parameters.fileWriter.writeLine(0, '}'); + return null; } + /** + * Build the DataAnnotations attribute lines for a scalar declaration's validator. + * @param {ScalarDeclaration} scalarDeclaration - the scalar declaration + * @returns {string[]} attribute lines, empty if no validator + * @private + */ + buildScalarValidatorLines(scalarDeclaration) { + const validator = scalarDeclaration.getValidator(); + if (!validator) {return [];} + const lines = []; + const type = scalarDeclaration.getType(); + if (type === 'String') { + if (validator.getMinLength()) { + lines.push(`[System.ComponentModel.DataAnnotations.MinLength(${validator.getMinLength()})]`); + } + if (validator.getMaxLength()) { + lines.push(`[System.ComponentModel.DataAnnotations.MaxLength(${validator.getMaxLength()})]`); + } + if (validator.getRegex()) { + lines.push(`[System.ComponentModel.DataAnnotations.RegularExpression(@"${validator.getRegex().source}", ErrorMessage = "Invalid characters")]`); + } + } else if (['Integer', 'Long', 'Double'].includes(type)) { + 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[type]; + const lo = lower != null ? String(lower) : defaultMin[type]; + const hi = upper != null ? String(upper) : defaultMax[type]; + lines.push(`[System.ComponentModel.DataAnnotations.Range(typeof(${csType}), "${lo}", "${hi}")]`); + } + } + return lines; + } + /** * Visitor design pattern * @param {MapDeclaration} mapDeclaration - the object being visited @@ -297,7 +396,7 @@ class CSharpVisitor { */ visitMapDeclaration(mapDeclaration, parameters) { const identifier = this.toCSharpIdentifier(undefined, mapDeclaration.getName(), parameters); - const { keyType, valueType } = this.resolveMapTypes(mapDeclaration); + const { keyType, valueType } = this.resolveMapTypes(mapDeclaration, parameters); parameters.fileWriter.writeLine(0, `public class ${identifier} : Dictionary<${keyType}, ${valueType}> {}`); return null; } @@ -307,13 +406,14 @@ class CSharpVisitor { * Handles primitives, scalars (via global using aliases and special UUID mapping), * and concept types. * @param {MapDeclaration} mapDeclaration - the map declaration to resolve types for + * @param {Object} parameters - the visitor parameters (used for PascalCase conversion) * @returns {{ keyType: string, valueType: string }} the resolved C# key and value type strings * @private */ - resolveMapTypes(mapDeclaration) { + resolveMapTypes(mapDeclaration, parameters) { const modelFile = mapDeclaration.getModelFile(); - const keyType = this.resolveMapSide(mapDeclaration.getKey(), modelFile); - const valueType = this.resolveMapSide(mapDeclaration.getValue(), modelFile); + const keyType = this.resolveMapSide(mapDeclaration.getKey(), modelFile, parameters); + const valueType = this.resolveMapSide(mapDeclaration.getValue(), modelFile, parameters); return { keyType, valueType }; } @@ -321,10 +421,11 @@ class CSharpVisitor { * Resolve a single map key or value side to a C# type string. * @param {MapKeyType|MapValueType} side - key or value side of the map * @param {ModelFile} modelFile - the model file containing the map declaration - * @returns {string} C# type string + * @param {Object} parameters - the visitor parameters + * @returns {string} - the resolved type string for the map side * @private */ - resolveMapSide(side, modelFile) { + resolveMapSide(side, modelFile, parameters) { const typeName = side.getType(); if (ModelUtil.isPrimitiveType(typeName)) { return this.toCSharpType(typeName); @@ -332,9 +433,26 @@ class CSharpVisitor { if (ModelUtil.isScalar(side)) { const scalarDecl = modelFile.getType(typeName); const fqn = ModelUtil.removeNamespaceVersionFromFullyQualifiedName(scalarDecl.getFullyQualifiedName()); - return fqn === 'concerto.scalar.UUID' ? 'System.Guid' : scalarDecl.getName(); + 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 typeName; + return false; } /** @@ -349,7 +467,14 @@ class CSharpVisitor { // For concerto.scalar.UUID, the alias resolves to System.Guid; use 'UUID' (the alias name). // For all other scalars, use the scalar's short name — it's a global using alias. const scalarTypeName = field.getType(); - return this.writeField(field.getScalarField(), parameters, scalarTypeName, field.isOptional()); + // Field-level default takes precedence over the scalar declaration's default. + const rawDefault = field.getDefaultValue() ?? field.getScalarField().getDefaultValue(); + // UUID scalars wrap System.Guid — the struct constructor requires a Guid, not a string. + // Wrap the default in a pre-computed C# literal so formatDefaultLiteral emits the right code. + const defaultValue = (fqn === 'concerto.scalar.UUID' && rawDefault != null) + ? { __csharpLiteral: `new(System.Guid.Parse("${rawDefault}"))` } + : rawDefault; + return this.writeField(field.getScalarField(), parameters, scalarTypeName, field.isOptional(), defaultValue); } /** @@ -369,16 +494,19 @@ class CSharpVisitor { * @param {Object} parameters - the parameter * @param {string} [externalFieldType] - the external field type like UUID (optional) * @param {bool} [isOptional] - the bool value indicating if external field type like UUID is optional (optional) + * @param {*} [scalarDefaultValue] - pre-resolved default value for scalar-typed fields (optional) * @return {Object} the result of visiting or null * @private */ - writeField(field, parameters, externalFieldType, isOptional = false) { + writeField(field, parameters, externalFieldType, isOptional = false, scalarDefaultValue = undefined) { // write Map field if (ModelUtil.isMap?.(field)) { const mapDeclaration = field.getModelFile().getType(field.getType()); - const { keyType, valueType } = this.resolveMapTypes(mapDeclaration); + const { keyType, valueType } = this.resolveMapTypes(mapDeclaration, parameters); const nullable = field.isOptional() ? '?' : ''; - parameters.fileWriter.writeLine(1, `public Dictionary<${keyType}, ${valueType}>${nullable} ${field.getName()} { get; set; }`); + const resolvedType = `Dictionary<${keyType}, ${valueType}>`; + const lines = this.toCSharpProperty('public', field.getParent()?.getName(), field.getName(), null, '', nullable, '{ get; set; }', parameters, resolvedType); + lines.forEach(line => parameters.fileWriter.writeLine(1, line)); return null; } @@ -397,6 +525,7 @@ class CSharpVisitor { let isIdentifier = field.getName() === field.getParent()?.getIdentifierFieldName(); if (isIdentifier) { parameters.fileWriter.writeLine(1, '[AccordProject.Concerto.Identifier()]'); + parameters.fileWriter.writeLine(1, '[System.ComponentModel.DataAnnotations.Key]'); } let fieldType = externalFieldType ? externalFieldType : this.getFieldType(field); @@ -418,6 +547,21 @@ class CSharpVisitor { 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 (!externalFieldType && !field.isPrimitive()) { let fqn = this.getDotNetNamespaceOfType(field.getFullyQualifiedTypeName(), field.getParent(), parameters); const modelFile = field.getModelFile(); @@ -432,6 +576,10 @@ class CSharpVisitor { nullableType = '?'; } + const rawDefault = externalFieldType !== undefined ? scalarDefaultValue : field.getDefaultValue(); + const csDefault = this.formatDefaultLiteral(rawDefault, rawFieldType, !!externalFieldType); + const getset = csDefault != null ? `{ get; set; } = ${csDefault};` : '{ get; set; }'; + const lines = this.toCSharpProperty( 'public', field.getParent()?.getName(), @@ -439,7 +587,7 @@ class CSharpVisitor { fieldType, array, nullableType, - '{ get; set; }', + getset, parameters ); lines.forEach(line => parameters.fileWriter.writeLine(1, line)); @@ -519,6 +667,23 @@ class CSharpVisitor { return null; } + /** + * Format a Concerto default value as a C# literal suitable for a property initializer. + * String values are quoted; scalar-typed fields wrap the literal in `new(...)`. + * @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 + * @returns {string|null} C# literal string, or null if no default + * @private + */ + formatDefaultLiteral(value, concertoType, isScalar) { + 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;} + const rawLiteral = concertoType === 'String' ? `"${value}"` : String(value); + return isScalar ? `new(${rawLiteral})` : rawLiteral; + } + /** * Ensures that a concerto property name is valid in CSharp * @param {string} access the CSharp field access @@ -529,11 +694,12 @@ class CSharpVisitor { * @param {string} nullableType the nullable expression ? * @param {string} getset the getter and setter declaration * @param {Object} [parameters] - the parameter + * @param {string} [resolvedType] - pre-built C# type string; when provided, skips toCSharpType * @returns {string} the property declaration */ - toCSharpProperty(access, parentName, propertyName, propertyType, array, nullableType, getset, parameters) { + toCSharpProperty(access, parentName, propertyName, propertyType, array, nullableType, getset, parameters, resolvedType = undefined) { const identifier = this.toCSharpIdentifier(parentName, propertyName, parameters); - const type = this.toCSharpType(propertyType, parameters); + const type = resolvedType ?? this.toCSharpType(propertyType, parameters); let lines = []; diff --git a/test/codegen/__snapshots__/codegen.js.snap b/test/codegen/__snapshots__/codegen.js.snap index 8c59c6f..18f9f7f 100644 --- a/test/codegen/__snapshots__/codegen.js.snap +++ b/test/codegen/__snapshots__/codegen.js.snap @@ -657,6 +657,7 @@ public abstract class Asset : Concept { [System.Text.Json.Serialization.JsonPropertyName("$class")] public override string _class { get; } = "concerto@1.0.0.Asset"; [AccordProject.Concerto.Identifier()] + [System.ComponentModel.DataAnnotations.Key] [System.Text.Json.Serialization.JsonPropertyName("$identifier")] public string _identifier { get; set; } } @@ -666,6 +667,7 @@ public abstract class Participant : Concept { [System.Text.Json.Serialization.JsonPropertyName("$class")] public override string _class { get; } = "concerto@1.0.0.Participant"; [AccordProject.Concerto.Identifier()] + [System.ComponentModel.DataAnnotations.Key] [System.Text.Json.Serialization.JsonPropertyName("$identifier")] public string _identifier { get; set; } } @@ -693,6 +695,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 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))] @@ -736,18 +739,36 @@ public class Address : Concept { [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] public enum Level { } +[System.Text.Json.Serialization.JsonConverter(typeof(TimeJsonConverter))] public readonly record struct Time(System.DateTime Value) { public static implicit operator System.DateTime(Time s) => s.Value; public static implicit operator Time(System.DateTime v) => new(v); public override string ToString() => Value.ToString(); } +public class TimeJsonConverter : System.Text.Json.Serialization.JsonConverter