Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 182 additions & 16 deletions lib/codegen/fromcto/csharp/csharpvisitor.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable eqeqeq */
/* eslint-disable no-unreachable */
/*
* Licensed under the Apache License, Version 2.0 (the "License");
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
Expand All @@ -307,34 +406,53 @@ 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 };
}

/**
* 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);
}
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;
}

/**
Expand All @@ -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);
}

/**
Expand All @@ -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;
}

Expand All @@ -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);
Expand All @@ -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();
Expand All @@ -432,14 +576,18 @@ 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(),
field.getName(),
fieldType,
array,
nullableType,
'{ get; set; }',
getset,
parameters
);
lines.forEach(line => parameters.fileWriter.writeLine(1, line));
Expand Down Expand Up @@ -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
Expand All @@ -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 = [];

Expand Down
Loading
Loading