From fb4b1dfbb5a9b1ea16ccc908a083bb17679e16c1 Mon Sep 17 00:00:00 2001 From: muhammed-abdulkadir Date: Fri, 22 May 2026 12:08:47 +0100 Subject: [PATCH 1/6] feat(*): generate range when provided Signed-off-by: muhammed-abdulkadir --- lib/codegen/fromcto/csharp/csharpvisitor.js | 15 ++++++ test/codegen/__snapshots__/codegen.js.snap | 1 + test/codegen/fromcto/csharp/csharpvisitor.js | 56 ++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/lib/codegen/fromcto/csharp/csharpvisitor.js b/lib/codegen/fromcto/csharp/csharpvisitor.js index aa61300..943cb28 100644 --- a/lib/codegen/fromcto/csharp/csharpvisitor.js +++ b/lib/codegen/fromcto/csharp/csharpvisitor.js @@ -418,6 +418,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(); diff --git a/test/codegen/__snapshots__/codegen.js.snap b/test/codegen/__snapshots__/codegen.js.snap index 8c59c6f..bed903e 100644 --- a/test/codegen/__snapshots__/codegen.js.snap +++ b/test/codegen/__snapshots__/codegen.js.snap @@ -853,6 +853,7 @@ public class Employee : Person { [System.Text.Json.Serialization.JsonPropertyName("$class")] public override string _class { get; } = "org.acme.hr@1.0.0.Employee"; public string employeeId { get; set; } + [System.ComponentModel.DataAnnotations.Range(typeof(long), "1", "10")] public long salary { get; set; } public int numDependents { get; set; } public bool retired { get; set; } diff --git a/test/codegen/fromcto/csharp/csharpvisitor.js b/test/codegen/fromcto/csharp/csharpvisitor.js index 1302449..3c08d96 100644 --- a/test/codegen/fromcto/csharp/csharpvisitor.js +++ b/test/codegen/fromcto/csharp/csharpvisitor.js @@ -581,6 +581,62 @@ public class SampleModel : Concept { file1.should.match(/public ScalarStringWithSameMinMaxLength scalarStringWithSameMinMaxLength/); }); + it('should emit [Range] attribute for Integer, Long, and Double fields with range validators', () => { + const modelManager = new ModelManager({ strict: true }); + modelManager.addCTOModel(` + namespace org.acme@1.2.3 + + concept RangeModel { + o Integer intBothBounds range=[1,100] + o Long longBothBounds range=[0,9999999999] + o Double doubleBothBounds range=[0.5,99.9] + o Integer intLowerOnly range=[5,] + o Double doubleUpperOnly range=[,1.0] + } + `); + csharpVisitor.visit(modelManager, { fileWriter }); + const files = fileWriter.getFilesInMemory(); + const file1 = files.get('org.acme@1.2.3.cs'); + file1.should.match(/\[System\.ComponentModel\.DataAnnotations\.Range\(typeof\(int\), "1", "100"\)\]/); + file1.should.match(/public int intBothBounds \{ get; set; \}/); + file1.should.match(/\[System\.ComponentModel\.DataAnnotations\.Range\(typeof\(long\), "0", "9999999999"\)\]/); + file1.should.match(/public long longBothBounds \{ get; set; \}/); + file1.should.match(/\[System\.ComponentModel\.DataAnnotations\.Range\(typeof\(double\), "0\.5", "99\.9"\)\]/); + file1.should.match(/public double doubleBothBounds \{ get; set; \}/); + // lower-only: upper defaults to type max + file1.should.match(/\[System\.ComponentModel\.DataAnnotations\.Range\(typeof\(int\), "5", "2147483647"\)\]/); + file1.should.match(/public int intLowerOnly \{ get; set; \}/); + // upper-only: lower defaults to type min + file1.should.match(/\[System\.ComponentModel\.DataAnnotations\.Range\(typeof\(double\), "-1\.7976931348623157E\+308", "1"\)\]/); + file1.should.match(/public double doubleUpperOnly \{ get; set; \}/); + }); + + it('should emit [Range] attribute for scalar fields backed by numeric types with range validators', () => { + const modelManager = new ModelManager({ strict: true }); + modelManager.addCTOModel(` + namespace org.acme@1.2.3 + + scalar Age extends Integer range=[0,150] + scalar Salary extends Long range=[1,] + scalar Ratio extends Double range=[0.0,1.0] + + concept Person { + o Age age + o Salary salary + o Ratio ratio optional + } + `); + csharpVisitor.visit(modelManager, { fileWriter }); + const files = fileWriter.getFilesInMemory(); + const file1 = files.get('org.acme@1.2.3.cs'); + file1.should.match(/\[System\.ComponentModel\.DataAnnotations\.Range\(typeof\(int\), "0", "150"\)\]/); + file1.should.match(/public Age age \{ get; set; \}/); + file1.should.match(/\[System\.ComponentModel\.DataAnnotations\.Range\(typeof\(long\), "1", "9223372036854775807"\)\]/); + file1.should.match(/public Salary salary \{ get; set; \}/); + file1.should.match(/\[System\.ComponentModel\.DataAnnotations\.Range\(typeof\(double\), "0", "1"\)\]/); + file1.should.match(/public Ratio\? ratio \{ get; set; \}/); + }); + it('should use UUID alias for scalar type UUID with different namespace than concerto.scalar', () => { const modelManager = new ModelManager({ strict: true }); modelManager.addCTOModel(` From 0c80c3eced22483f7c5d1420a1f60c7ba338ff1b Mon Sep 17 00:00:00 2001 From: muhammed-abdulkadir Date: Fri, 22 May 2026 12:33:15 +0100 Subject: [PATCH 2/6] chore(*): support scalar validation Signed-off-by: muhammed-abdulkadir --- lib/codegen/fromcto/csharp/csharpvisitor.js | 78 ++++++++++++++++++-- test/codegen/__snapshots__/codegen.js.snap | 4 +- test/codegen/fromcto/csharp/csharpvisitor.js | 47 ++++++++++++ 3 files changed, 122 insertions(+), 7 deletions(-) diff --git a/lib/codegen/fromcto/csharp/csharpvisitor.js b/lib/codegen/fromcto/csharp/csharpvisitor.js index 943cb28..a920c3f 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"); @@ -279,8 +280,14 @@ class CSharpVisitor { ? 'System.Guid' : this.toCSharpType(scalarDeclaration.getType()); + const validatorLines = this.buildScalarValidatorLines(scalarDeclaration); + 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();'); @@ -288,6 +295,43 @@ class CSharpVisitor { 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 @@ -349,7 +393,9 @@ 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 defaultValue = field.getDefaultValue() ?? field.getScalarField().getDefaultValue(); + return this.writeField(field.getScalarField(), parameters, scalarTypeName, field.isOptional(), defaultValue); } /** @@ -369,10 +415,11 @@ 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()); @@ -423,13 +470,13 @@ class CSharpVisitor { if (validator) { const lower = validator.getLowerBound(); const upper = validator.getUpperBound(); - if (lower !== null || upper !== null) { + 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]; + 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}")]`); } } @@ -447,6 +494,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(), @@ -454,7 +505,7 @@ class CSharpVisitor { fieldType, array, nullableType, - '{ get; set; }', + getset, parameters ); lines.forEach(line => parameters.fileWriter.writeLine(1, line)); @@ -534,6 +585,21 @@ 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;} + 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 diff --git a/test/codegen/__snapshots__/codegen.js.snap b/test/codegen/__snapshots__/codegen.js.snap index bed903e..f121ae7 100644 --- a/test/codegen/__snapshots__/codegen.js.snap +++ b/test/codegen/__snapshots__/codegen.js.snap @@ -744,6 +744,8 @@ public readonly record struct Time(System.DateTime Value) } public readonly record struct SSN(string Value) { + [System.ComponentModel.DataAnnotations.RegularExpression(@"(\\d{3}-\\d{2}-\\d{4})+", ErrorMessage = "Invalid characters")] + public string Value { get; init; } = Value; public static implicit operator string(SSN s) => s.Value; public static implicit operator SSN(string v) => new(v); public override string ToString() => Value.ToString(); @@ -842,7 +844,7 @@ public abstract class Person : Participant { 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; } + public SSN ssn { get; set; } = new("000-00-0000"); public double height { get; set; } public System.DateTime dob { get; set; } public Dictionary nextOfKin { get; set; } diff --git a/test/codegen/fromcto/csharp/csharpvisitor.js b/test/codegen/fromcto/csharp/csharpvisitor.js index 3c08d96..960764a 100644 --- a/test/codegen/fromcto/csharp/csharpvisitor.js +++ b/test/codegen/fromcto/csharp/csharpvisitor.js @@ -637,6 +637,53 @@ public class SampleModel : Concept { file1.should.match(/public Ratio\? ratio \{ get; set; \}/); }); + it('should emit property initializers for default values on primitive fields', () => { + const modelManager = new ModelManager({ strict: true }); + modelManager.addCTOModel(` + namespace org.acme@1.2.3 + + concept Defaults { + o String name default="Alice" + o Integer count default=0 + o Long big default=9999999999 + o Double rate default=3.14 + o Boolean active default=true + } + `); + csharpVisitor.visit(modelManager, { fileWriter }); + const files = fileWriter.getFilesInMemory(); + const file1 = files.get('org.acme@1.2.3.cs'); + file1.should.match(/public string name \{ get; set; \} = "Alice";/); + file1.should.match(/public int count \{ get; set; \} = 0;/); + file1.should.match(/public long big \{ get; set; \} = 9999999999;/); + file1.should.match(/public double rate \{ get; set; \} = 3\.14;/); + file1.should.match(/public bool active \{ get; set; \} = true;/); + }); + + it('should emit property initializers for default values on scalar fields', () => { + const modelManager = new ModelManager({ strict: true }); + modelManager.addCTOModel(` + namespace org.acme@1.2.3 + + scalar SSN extends String default="000-00-0000" + scalar Score extends Integer default=100 + + concept Person { + o SSN ssn + o SSN customSsn default="123-45-6789" + o Score score + } + `); + csharpVisitor.visit(modelManager, { fileWriter }); + const files = fileWriter.getFilesInMemory(); + const file1 = files.get('org.acme@1.2.3.cs'); + // uses scalar declaration default + file1.should.match(/public SSN ssn \{ get; set; \} = new\("000-00-0000"\);/); + // field-level default overrides scalar declaration default + file1.should.match(/public SSN customSsn \{ get; set; \} = new\("123-45-6789"\);/); + file1.should.match(/public Score score \{ get; set; \} = new\(100\);/); + }); + it('should use UUID alias for scalar type UUID with different namespace than concerto.scalar', () => { const modelManager = new ModelManager({ strict: true }); modelManager.addCTOModel(` From f403f83b68e541416e1719cbb5d0a368d25da2f9 Mon Sep 17 00:00:00 2001 From: muhammed-abdulkadir Date: Fri, 22 May 2026 13:48:11 +0100 Subject: [PATCH 3/6] chore(*): enforce scalar type validation of map declaration Signed-off-by: muhammed-abdulkadir --- lib/codegen/fromcto/csharp/csharpvisitor.js | 21 +++++++++++++++++ test/codegen/__snapshots__/codegen.js.snap | 3 +++ test/codegen/fromcto/csharp/csharpvisitor.js | 24 ++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/lib/codegen/fromcto/csharp/csharpvisitor.js b/lib/codegen/fromcto/csharp/csharpvisitor.js index a920c3f..cb8a01a 100644 --- a/lib/codegen/fromcto/csharp/csharpvisitor.js +++ b/lib/codegen/fromcto/csharp/csharpvisitor.js @@ -381,6 +381,23 @@ class CSharpVisitor { return typeName; } + /** + * 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; + } + /** * Visitor design pattern * @param {Field} field - the object being visited @@ -425,6 +442,10 @@ class CSharpVisitor { const mapDeclaration = field.getModelFile().getType(field.getType()); const { keyType, valueType } = this.resolveMapTypes(mapDeclaration); const nullable = field.isOptional() ? '?' : ''; + if (this.mapSideHasValidators(mapDeclaration.getKey(), mapDeclaration.getModelFile()) || + this.mapSideHasValidators(mapDeclaration.getValue(), mapDeclaration.getModelFile())) { + parameters.fileWriter.writeLine(1, '[System.ComponentModel.DataAnnotations.ValidateComplexType]'); + } parameters.fileWriter.writeLine(1, `public Dictionary<${keyType}, ${valueType}>${nullable} ${field.getName()} { get; set; }`); return null; } diff --git a/test/codegen/__snapshots__/codegen.js.snap b/test/codegen/__snapshots__/codegen.js.snap index f121ae7..54238d0 100644 --- a/test/codegen/__snapshots__/codegen.js.snap +++ b/test/codegen/__snapshots__/codegen.js.snap @@ -797,9 +797,12 @@ public class Company : Concept { public string name { get; set; } public org.acme.hr.base.Address headquarters { get; set; } public Dictionary? companyProperties { get; set; } + [System.ComponentModel.DataAnnotations.ValidateComplexType] public Dictionary? employeeDirectory { get; set; } + [System.ComponentModel.DataAnnotations.ValidateComplexType] public Dictionary? employeeTShirtSizes { get; set; } public Dictionary? employeeProfiles { get; set; } + [System.ComponentModel.DataAnnotations.ValidateComplexType] public Dictionary? employeeSocialSecurityNumbers { get; set; } } [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] diff --git a/test/codegen/fromcto/csharp/csharpvisitor.js b/test/codegen/fromcto/csharp/csharpvisitor.js index 960764a..415bfe2 100644 --- a/test/codegen/fromcto/csharp/csharpvisitor.js +++ b/test/codegen/fromcto/csharp/csharpvisitor.js @@ -684,6 +684,30 @@ public class SampleModel : Concept { file1.should.match(/public Score score \{ get; set; \} = new\(100\);/); }); + it('should emit [ValidateComplexType] on a map field whose value type is a scalar with validators', () => { + sandbox.restore(); // allow ModelUtil.isMap to work normally + const modelManager = new ModelManager({ strict: true }); + modelManager.addCTOModel(` + namespace org.acme@1.2.3 + + scalar SSN extends String regex=/\\d{3}-\\d{2}-\\d{4}/ + + map PhoneBook { + o String + o SSN + } + + concept Person { + o PhoneBook phoneBook + } + `); + csharpVisitor.visit(modelManager, { fileWriter }); + const files = fileWriter.getFilesInMemory(); + const file1 = files.get('org.acme@1.2.3.cs'); + file1.should.match(/\[System\.ComponentModel\.DataAnnotations\.ValidateComplexType\]/); + file1.should.match(/public Dictionary.*phoneBook \{ get; set; \}/); + }); + it('should use UUID alias for scalar type UUID with different namespace than concerto.scalar', () => { const modelManager = new ModelManager({ strict: true }); modelManager.addCTOModel(` From cbb88b112ad728e6036386babc79a38189cb7a1f Mon Sep 17 00:00:00 2001 From: muhammed-abdulkadir Date: Fri, 22 May 2026 15:33:24 +0100 Subject: [PATCH 4/6] chore(*): add key annotation for identifier and json converter support for serialisation Signed-off-by: muhammed-abdulkadir --- lib/codegen/fromcto/csharp/csharpvisitor.js | 47 ++++++- test/codegen/__snapshots__/codegen.js.snap | 36 ++++++ test/codegen/fromcto/csharp/csharpvisitor.js | 121 +++++++++++++++++++ 3 files changed, 203 insertions(+), 1 deletion(-) diff --git a/lib/codegen/fromcto/csharp/csharpvisitor.js b/lib/codegen/fromcto/csharp/csharpvisitor.js index cb8a01a..02f7fa5 100644 --- a/lib/codegen/fromcto/csharp/csharpvisitor.js +++ b/lib/codegen/fromcto/csharp/csharpvisitor.js @@ -281,7 +281,9 @@ class CSharpVisitor { : this.toCSharpType(scalarDeclaration.getType()); const validatorLines = this.buildScalarValidatorLines(scalarDeclaration); + const converterName = `${identifier}JsonConverter`; + parameters.fileWriter.writeLine(0, `[System.Text.Json.Serialization.JsonConverter(typeof(${converterName}))]`); parameters.fileWriter.writeLine(0, `public readonly record struct ${identifier}(${csharpType} Value)`); parameters.fileWriter.writeLine(0, '{'); if (validatorLines.length > 0) { @@ -292,6 +294,41 @@ class CSharpVisitor { 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 JsonConverter so System.Text.Json reads/writes the struct as a bare scalar value + parameters.fileWriter.writeLine(0, `public class ${converterName} : System.Text.Json.Serialization.JsonConverter<${identifier}>`); + parameters.fileWriter.writeLine(0, '{'); + if (csharpType === 'System.Guid') { + parameters.fileWriter.writeLine(1, `public override ${identifier} Read(ref System.Text.Json.Utf8JsonReader r, System.Type t, System.Text.Json.JsonSerializerOptions o)`); + parameters.fileWriter.writeLine(2, '=> new(r.GetGuid());'); + parameters.fileWriter.writeLine(1, `public override void Write(System.Text.Json.Utf8JsonWriter w, ${identifier} v, System.Text.Json.JsonSerializerOptions o)`); + parameters.fileWriter.writeLine(2, '=> w.WriteStringValue(v.Value.ToString());'); + } else if (csharpType === 'string') { + parameters.fileWriter.writeLine(1, `public override ${identifier} Read(ref System.Text.Json.Utf8JsonReader r, System.Type t, System.Text.Json.JsonSerializerOptions o)`); + parameters.fileWriter.writeLine(2, '=> new(r.GetString()!);'); + parameters.fileWriter.writeLine(1, `public override void Write(System.Text.Json.Utf8JsonWriter w, ${identifier} v, System.Text.Json.JsonSerializerOptions o)`); + parameters.fileWriter.writeLine(2, '=> w.WriteStringValue(v.Value);'); + } else if (csharpType === 'bool') { + parameters.fileWriter.writeLine(1, `public override ${identifier} Read(ref System.Text.Json.Utf8JsonReader r, System.Type t, System.Text.Json.JsonSerializerOptions o)`); + parameters.fileWriter.writeLine(2, '=> new(r.GetBoolean());'); + parameters.fileWriter.writeLine(1, `public override void Write(System.Text.Json.Utf8JsonWriter w, ${identifier} v, System.Text.Json.JsonSerializerOptions o)`); + parameters.fileWriter.writeLine(2, '=> w.WriteBooleanValue(v.Value);'); + } else if (['int', 'long', 'double'].includes(csharpType)) { + const getMethod = { int: 'GetInt32', long: 'GetInt64', double: 'GetDouble' }[csharpType]; + const writeMethod = { int: 'WriteNumberValue', long: 'WriteNumberValue', double: 'WriteNumberValue' }[csharpType]; + parameters.fileWriter.writeLine(1, `public override ${identifier} Read(ref System.Text.Json.Utf8JsonReader r, System.Type t, System.Text.Json.JsonSerializerOptions o)`); + parameters.fileWriter.writeLine(2, `=> new(r.${getMethod}());`); + parameters.fileWriter.writeLine(1, `public override void Write(System.Text.Json.Utf8JsonWriter w, ${identifier} v, System.Text.Json.JsonSerializerOptions o)`); + parameters.fileWriter.writeLine(2, `=> w.${writeMethod}(v.Value);`); + } else { + // fallback: serialize as string via ToString + parameters.fileWriter.writeLine(1, `public override ${identifier} Read(ref System.Text.Json.Utf8JsonReader r, System.Type t, System.Text.Json.JsonSerializerOptions o)`); + parameters.fileWriter.writeLine(2, `=> new((${csharpType})System.Convert.ChangeType(r.GetString()!, typeof(${csharpType})));`); + parameters.fileWriter.writeLine(1, `public override void Write(System.Text.Json.Utf8JsonWriter w, ${identifier} v, System.Text.Json.JsonSerializerOptions o)`); + parameters.fileWriter.writeLine(2, '=> w.WriteStringValue(v.Value.ToString()!);'); + } + parameters.fileWriter.writeLine(0, '}'); + return null; } @@ -411,7 +448,12 @@ class CSharpVisitor { // For all other scalars, use the scalar's short name — it's a global using alias. const scalarTypeName = field.getType(); // Field-level default takes precedence over the scalar declaration's default. - const defaultValue = field.getDefaultValue() ?? field.getScalarField().getDefaultValue(); + 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); } @@ -465,6 +507,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); @@ -617,6 +660,8 @@ class CSharpVisitor { */ 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; } diff --git a/test/codegen/__snapshots__/codegen.js.snap b/test/codegen/__snapshots__/codegen.js.snap index 54238d0..b53bd14 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; } } @@ -736,12 +738,21 @@ 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