Compare commits

...

3 Commits

Author SHA1 Message Date
599204d858 fix: use InvariantCulture for decimal/DateTime parsing in gRPC
The generated gRPC service code was using locale-dependent parsing
for decimal and DateTime values. On French locale systems, this
caused FormatException when parsing "0.95" because the system
expected a comma as decimal separator.

Now uses CultureInfo.InvariantCulture for all decimal.Parse() and
DateTime.Parse() calls to ensure consistent behavior across locales.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 11:47:56 -05:00
46e739eead fix: handle nullable numeric types in gRPC request mapping
When a C# property is nullable (int?, long?, etc.), protobuf3 defaults
to 0 when the field is not set. The generator now treats 0 as null
for nullable numeric properties, allowing proper optional field semantics.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 11:30:01 -05:00
179b06374d fix: improve gRPC source generator type mapping and property naming
- Add ProtoPropertyName to PropertyInfo for correct proto property naming
- Fix ToPascalCaseHelper to match Grpc.Tools naming (e.g., value_per100g → ValuePer100G)
- Add IsResultNullable and ResultTypeWithoutNullable to QueryInfo
- Fix IsPrimitiveType to correctly handle nullable complex types
- Add GetCollectionElementType helper (excludes strings from collection detection)
- Use AddRange pattern for repeated/collection fields in proto messages
- Add explicit Analyzer reference in props for reliable source generator loading
- Handle null cases in single complex type response mapping
- Fix collection properties in complex results with proper nested type mapping

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 10:47:05 -05:00
5 changed files with 532 additions and 38 deletions

View File

@ -350,6 +350,7 @@ namespace Svrnty.CQRS.Grpc.Generators
var propInfo = new PropertyInfo
{
Name = property.Name,
ProtoPropertyName = ToPascalCaseHelper(ToSnakeCaseHelper(property.Name)),
Type = propertyType,
FullyQualifiedType = propertyType,
ProtoType = protoType,
@ -507,6 +508,7 @@ namespace Svrnty.CQRS.Grpc.Generators
var propInfo = new PropertyInfo
{
Name = property.Name,
ProtoPropertyName = ToPascalCaseHelper(ToSnakeCaseHelper(property.Name)),
Type = propertyType,
FullyQualifiedType = propertyType,
ProtoType = string.Empty,
@ -536,6 +538,7 @@ namespace Svrnty.CQRS.Grpc.Generators
var propInfo = new PropertyInfo
{
Name = property.Name,
ProtoPropertyName = ToPascalCaseHelper(ToSnakeCaseHelper(property.Name)),
Type = propertyType,
FullyQualifiedType = propertyType,
ProtoType = string.Empty,
@ -577,26 +580,26 @@ namespace Svrnty.CQRS.Grpc.Generators
{
foreach (var prop in properties)
{
var sourcePropName = char.ToUpper(prop.Name[0]) + prop.Name.Substring(1);
// Use ProtoPropertyName to access the proto source property
if (prop.IsComplexType)
{
// Generate nested object mapping
sb.AppendLine($"{indent}{prop.Name} = {sourcePrefix}.{sourcePropName} != null ? new {prop.FullyQualifiedType}");
sb.AppendLine($"{indent}{prop.Name} = {sourcePrefix}.{prop.ProtoPropertyName} != null ? new {prop.FullyQualifiedType}");
sb.AppendLine($"{indent}{{");
GenerateNestedPropertyMapping(sb, prop.NestedProperties, $"{sourcePrefix}.{sourcePropName}", indent + " ");
GenerateNestedPropertyMapping(sb, prop.NestedProperties, $"{sourcePrefix}.{prop.ProtoPropertyName}", indent + " ");
sb.AppendLine($"{indent}}} : null!,");
}
else
{
sb.AppendLine($"{indent}{prop.Name} = {sourcePrefix}.{sourcePropName},");
sb.AppendLine($"{indent}{prop.Name} = {sourcePrefix}.{prop.ProtoPropertyName},");
}
}
}
private static string GeneratePropertyAssignment(PropertyInfo prop, string requestVar, string indent)
{
var requestPropName = char.ToUpper(prop.Name[0]) + prop.Name.Substring(1);
var source = $"{requestVar}.{requestPropName}";
// Use ProtoPropertyName to access the proto request property
var source = $"{requestVar}.{prop.ProtoPropertyName}";
// Handle lists
if (prop.IsList)
@ -624,11 +627,11 @@ namespace Svrnty.CQRS.Grpc.Generators
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = string.IsNullOrEmpty({source}) ? null : decimal.Parse({source}),";
return $"{indent}{prop.Name} = string.IsNullOrEmpty({source}) ? null : decimal.Parse({source}, System.Globalization.CultureInfo.InvariantCulture),";
}
else
{
return $"{indent}{prop.Name} = decimal.Parse({source}),";
return $"{indent}{prop.Name} = decimal.Parse({source}, System.Globalization.CultureInfo.InvariantCulture),";
}
}
@ -663,7 +666,7 @@ namespace Svrnty.CQRS.Grpc.Generators
foreach (var nestedProp in prop.ElementNestedProperties!)
{
var nestedSourcePropName = char.ToUpper(nestedProp.Name[0]) + nestedProp.Name.Substring(1);
// Use ProtoPropertyName for accessing proto source properties
var nestedAssignment = GenerateNestedPropertyAssignment(nestedProp, "x", indent + " ");
sb.AppendLine(nestedAssignment);
}
@ -690,8 +693,8 @@ namespace Svrnty.CQRS.Grpc.Generators
private static string GenerateNestedPropertyAssignment(PropertyInfo prop, string sourceVar, string indent)
{
var sourcePropName = char.ToUpper(prop.Name[0]) + prop.Name.Substring(1);
var source = $"{sourceVar}.{sourcePropName}";
// Use ProtoPropertyName to access the proto source property
var source = $"{sourceVar}.{prop.ProtoPropertyName}";
// Handle enums
if (prop.IsEnum)
@ -704,11 +707,11 @@ namespace Svrnty.CQRS.Grpc.Generators
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = string.IsNullOrEmpty({source}) ? null : decimal.Parse({source}),";
return $"{indent}{prop.Name} = string.IsNullOrEmpty({source}) ? null : decimal.Parse({source}, System.Globalization.CultureInfo.InvariantCulture),";
}
else
{
return $"{indent}{prop.Name} = decimal.Parse({source}),";
return $"{indent}{prop.Name} = decimal.Parse({source}, System.Globalization.CultureInfo.InvariantCulture),";
}
}
@ -750,27 +753,112 @@ namespace Svrnty.CQRS.Grpc.Generators
var resultTypeString = resultType.ToDisplayString();
queryInfo.IsResultPrimitiveType = IsPrimitiveType(resultTypeString);
// Extract result type properties if it's a complex type
if (!queryInfo.IsResultPrimitiveType)
// Check if result type is nullable and compute non-nullable name
var isNullable = resultType.NullableAnnotation == NullableAnnotation.Annotated ||
resultTypeString.EndsWith("?");
queryInfo.IsResultNullable = isNullable;
// Use the short type name (without namespace) for proto type mapping
// This matches how ResultElementType is used for collections
var shortTypeName = resultType.Name;
if (shortTypeName.EndsWith("?"))
{
var resultProperties = resultType.GetMembers().OfType<IPropertySymbol>()
queryInfo.ResultTypeWithoutNullable = shortTypeName.Substring(0, shortTypeName.Length - 1);
}
else
{
queryInfo.ResultTypeWithoutNullable = shortTypeName;
}
// Check if result type is a collection and extract element type
var (isCollection, elementType) = GetCollectionElementType(resultType);
queryInfo.IsResultCollection = isCollection;
// Determine which type to extract properties from
INamedTypeSymbol? typeToExtractFrom = null;
if (isCollection && elementType != null)
{
queryInfo.ResultElementType = elementType.Name;
queryInfo.ResultElementTypeFullyQualified = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
typeToExtractFrom = elementType;
}
else if (!queryInfo.IsResultPrimitiveType && resultType is INamedTypeSymbol namedType)
{
typeToExtractFrom = namedType;
}
// Extract result type properties if it's a complex type (or collection of complex type)
if (typeToExtractFrom != null)
{
var resultProperties = typeToExtractFrom.GetMembers().OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic)
.ToList();
foreach (var property in resultProperties)
{
var propType = property.Type;
var propTypeStr = propType.ToDisplayString();
// Check for type categories
var isEnum = propType.TypeKind == TypeKind.Enum;
var isDecimal = propTypeStr == "decimal" || propTypeStr == "System.Decimal" ||
propTypeStr == "decimal?" || propTypeStr == "System.Nullable<System.Decimal>";
var isDateTime = propTypeStr.Contains("DateTime") || propTypeStr.Contains("DateTimeOffset");
var isPropNullable = propType.NullableAnnotation == NullableAnnotation.Annotated ||
propTypeStr.EndsWith("?") ||
propTypeStr.StartsWith("System.Nullable<");
var isString = propTypeStr == "string" || propTypeStr == "System.String" ||
propTypeStr == "string?" || propTypeStr == "System.String?";
// Check if property is a collection
var (isPropCollection, propElementType) = GetCollectionElementType(propType);
var isElementComplexType = false;
List<PropertyInfo>? elementNestedProperties = null;
if (isPropCollection && propElementType != null)
{
isElementComplexType = propElementType.TypeKind != TypeKind.Enum &&
!IsPrimitiveType(propElementType.ToDisplayString());
if (isElementComplexType)
{
elementNestedProperties = new List<PropertyInfo>();
ExtractNestedPropertiesWithTypeInfo(propElementType, elementNestedProperties);
}
}
// Check if property itself is a complex type
var isComplexType = !isEnum && !isPropCollection &&
!IsPrimitiveType(propTypeStr.TrimEnd('?'));
List<PropertyInfo>? nestedProperties = null;
if (isComplexType && propType is INamedTypeSymbol namedPropType)
{
nestedProperties = new List<PropertyInfo>();
ExtractNestedPropertiesWithTypeInfo(namedPropType, nestedProperties);
}
queryInfo.ResultProperties.Add(new PropertyInfo
{
Name = property.Name,
Type = property.Type.ToDisplayString(),
FullyQualifiedType = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
ProtoPropertyName = ToPascalCaseHelper(ToSnakeCaseHelper(property.Name)),
Type = propTypeStr,
FullyQualifiedType = propType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
ProtoType = string.Empty, // Not needed for result mapping
FieldNumber = 0 // Not needed for result mapping
FieldNumber = 0, // Not needed for result mapping
IsEnum = isEnum,
IsDecimal = isDecimal,
IsDateTime = isDateTime,
IsNullable = isPropNullable || isString, // Treat strings as nullable for null-coalescing
IsList = isPropCollection,
IsComplexType = isComplexType,
NestedProperties = nestedProperties ?? new List<PropertyInfo>(),
ElementType = propElementType?.ToDisplayString(),
IsElementComplexType = isElementComplexType,
ElementNestedProperties = elementNestedProperties
});
}
}
// Extract properties
// Extract properties with type metadata for request mapping
var properties = queryType.GetMembers().OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic)
.ToList();
@ -779,14 +867,30 @@ namespace Svrnty.CQRS.Grpc.Generators
foreach (var property in properties)
{
var propertyType = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var propertyTypeShort = property.Type.ToDisplayString();
var protoType = ProtoTypeMapper.MapToProtoType(propertyType, out bool isRepeated, out bool isOptional);
// Determine type metadata for proper request mapping
var isEnum = property.Type.TypeKind == TypeKind.Enum;
var isDecimal = propertyTypeShort == "decimal" || propertyTypeShort == "System.Decimal" ||
propertyTypeShort == "decimal?" || propertyTypeShort.Contains("Nullable<System.Decimal>");
var isDateTime = propertyTypeShort.Contains("DateTime") || propertyTypeShort.Contains("DateTimeOffset");
var isRequestPropNullable = property.Type.NullableAnnotation == NullableAnnotation.Annotated ||
propertyTypeShort.EndsWith("?") ||
propertyTypeShort.StartsWith("System.Nullable<");
queryInfo.Properties.Add(new PropertyInfo
{
Name = property.Name,
ProtoPropertyName = ToPascalCaseHelper(ToSnakeCaseHelper(property.Name)),
Type = propertyType,
FullyQualifiedType = propertyType,
ProtoType = protoType,
FieldNumber = fieldNumber++
FieldNumber = fieldNumber++,
IsEnum = isEnum,
IsDecimal = isDecimal,
IsDateTime = isDateTime,
IsNullable = isRequestPropNullable
});
}
@ -1579,6 +1683,7 @@ namespace Svrnty.CQRS.Grpc.Generators
sb.AppendLine("#nullable enable");
sb.AppendLine("using Grpc.Core;");
sb.AppendLine("using System.Threading.Tasks;");
sb.AppendLine("using System.Linq;");
sb.AppendLine("using Microsoft.Extensions.DependencyInjection;");
sb.AppendLine($"using {rootNamespace}.Grpc;");
sb.AppendLine("using Svrnty.CQRS.Abstractions;");
@ -1617,29 +1722,130 @@ namespace Svrnty.CQRS.Grpc.Generators
sb.AppendLine(" {");
foreach (var prop in query.Properties)
{
sb.AppendLine($" {prop.Name} = request.{char.ToUpper(prop.Name[0]) + prop.Name.Substring(1)},");
var requestMapping = GenerateRequestPropertyMapping(prop);
sb.AppendLine($" {prop.Name} = {requestMapping},");
}
sb.AppendLine(" };");
sb.AppendLine(" var result = await handler.HandleAsync(query, context.CancellationToken);");
// Generate response with mapping if complex type
// Generate response with mapping based on result type
if (query.IsResultPrimitiveType)
{
sb.AppendLine($" return new {responseType} {{ Result = result }};");
}
else
else if (query.IsResultCollection)
{
// Complex type - need to map from C# type to proto type
sb.AppendLine($" return new {responseType}");
// Collection result - use AddRange with LINQ Select for type conversion
var elementTypeName = query.ResultElementType;
sb.AppendLine($" var response = new {responseType}();");
sb.AppendLine($" response.Result.AddRange(result.Select(item => new {elementTypeName}");
sb.AppendLine(" {");
sb.AppendLine($" Result = new {query.ResultType}");
sb.AppendLine(" {");
foreach (var prop in query.ResultProperties)
{
sb.AppendLine($" {prop.Name} = result.{prop.Name},");
// Skip collection properties (they need separate AddRange handling, not supported in collection results)
if (prop.IsList) continue;
var mapping = GeneratePropertyMapping("item", prop);
if (mapping != null)
{
sb.AppendLine($" {prop.ProtoPropertyName} = {mapping},");
}
}
sb.AppendLine(" }));");
sb.AppendLine(" return response;");
}
else
{
// Single complex object - map properties with type conversions
// Use the non-nullable type name for the proto message
var protoResultType = query.ResultTypeWithoutNullable;
if (query.IsResultNullable)
{
// Handle nullable result - check for null and return empty response
sb.AppendLine(" if (result == null)");
sb.AppendLine(" {");
sb.AppendLine($" return new {responseType}();");
sb.AppendLine(" }");
sb.AppendLine();
}
// Separate simple properties from collection properties
var simpleProps = query.ResultProperties.Where(p => !p.IsList).ToList();
var collectionProps = query.ResultProperties.Where(p => p.IsList).ToList();
if (collectionProps.Count == 0)
{
// No collection properties - simple case
sb.AppendLine($" return new {responseType}");
sb.AppendLine(" {");
sb.AppendLine($" Result = new {protoResultType}");
sb.AppendLine(" {");
foreach (var prop in simpleProps)
{
var mapping = GeneratePropertyMapping("result", prop);
sb.AppendLine($" {prop.ProtoPropertyName} = {mapping},");
}
sb.AppendLine(" }");
sb.AppendLine(" };");
}
else
{
// Has collection properties - need to use AddRange
sb.AppendLine($" var protoResult = new {protoResultType}");
sb.AppendLine(" {");
foreach (var prop in simpleProps)
{
var mapping = GeneratePropertyMapping("result", prop);
sb.AppendLine($" {prop.ProtoPropertyName} = {mapping},");
}
sb.AppendLine(" };");
sb.AppendLine();
// Add collection properties with AddRange
foreach (var prop in collectionProps)
{
var elementTypeName = prop.ElementType;
if (!string.IsNullOrEmpty(elementTypeName))
{
var lastDot = elementTypeName.LastIndexOf('.');
if (lastDot >= 0)
{
elementTypeName = elementTypeName.Substring(lastDot + 1);
}
}
if (prop.IsElementComplexType && prop.ElementNestedProperties != null && prop.ElementNestedProperties.Count > 0)
{
// Complex element type - map properties
sb.AppendLine($" if (result.{prop.Name} != null)");
sb.AppendLine(" {");
sb.AppendLine($" protoResult.{prop.ProtoPropertyName}.AddRange(result.{prop.Name}.Select(item => new {elementTypeName}");
sb.AppendLine(" {");
foreach (var nestedProp in prop.ElementNestedProperties)
{
var nestedMapping = GeneratePropertyMapping("item", nestedProp);
if (nestedMapping != null)
{
sb.AppendLine($" {nestedProp.ProtoPropertyName} = {nestedMapping},");
}
}
sb.AppendLine(" }));");
sb.AppendLine(" }");
}
else
{
// Simple element type or no nested properties - direct copy
sb.AppendLine($" if (result.{prop.Name} != null)");
sb.AppendLine(" {");
sb.AppendLine($" protoResult.{prop.ProtoPropertyName}.AddRange(result.{prop.Name});");
sb.AppendLine(" }");
}
sb.AppendLine();
}
sb.AppendLine($" return new {responseType} {{ Result = protoResult }};");
}
sb.AppendLine(" }");
sb.AppendLine(" };");
}
sb.AppendLine(" }");
@ -1672,9 +1878,231 @@ namespace Svrnty.CQRS.Grpc.Generators
"System.Guid"
};
return primitiveTypes.Contains(typeName) ||
typeName.StartsWith("System.Nullable<") ||
typeName.EndsWith("?");
// Handle nullable types - extract the underlying type first
var typeToCheck = typeName;
if (typeName.EndsWith("?"))
{
typeToCheck = typeName.Substring(0, typeName.Length - 1);
}
else if (typeName.StartsWith("System.Nullable<") && typeName.EndsWith(">"))
{
typeToCheck = typeName.Substring("System.Nullable<".Length, typeName.Length - "System.Nullable<".Length - 1);
}
return primitiveTypes.Contains(typeToCheck);
}
/// <summary>
/// Generates the mapping expression for a property from C# domain type to proto type
/// </summary>
private static string GeneratePropertyMapping(string sourceVar, PropertyInfo prop)
{
var accessor = $"{sourceVar}.{prop.Name}";
// Handle decimal → string conversion
if (prop.IsDecimal)
{
if (prop.IsNullable)
return $"{accessor}?.ToString() ?? \"\"";
return $"{accessor}.ToString()";
}
// Handle DateTime/DateTimeOffset → string conversion
if (prop.IsDateTime)
{
if (prop.IsNullable)
return $"{accessor}?.ToString(\"O\") ?? \"\"";
return $"{accessor}.ToString(\"O\")";
}
// Handle enum → proto enum conversion (cast with same underlying value)
if (prop.IsEnum)
{
// Extract just the enum name (without namespace) for the proto enum type
var enumTypeName = prop.Type;
var lastDot = enumTypeName.LastIndexOf('.');
if (lastDot >= 0)
{
enumTypeName = enumTypeName.Substring(lastDot + 1);
}
// Remove nullable suffix if present
if (enumTypeName.EndsWith("?"))
{
enumTypeName = enumTypeName.Substring(0, enumTypeName.Length - 1);
}
return $"({enumTypeName})(int){accessor}";
}
// Handle collection properties - these need special handling with AddRange
if (prop.IsList)
{
// Return a special marker indicating this needs AddRange treatment
return null;
}
// Handle nullable strings with null coalescing
if (prop.IsNullable && (prop.Type.Contains("string") || prop.Type == "string" || prop.Type == "string?"))
{
return $"{accessor} ?? \"\"";
}
// Handle collections (nested repeated fields)
if (prop.IsList && prop.IsElementComplexType && prop.ElementNestedProperties != null)
{
var sb = new StringBuilder();
sb.Append($"new global::Google.Protobuf.Collections.RepeatedField<{prop.ElementType}>()");
// Note: For nested collections, we'd need to generate AddRange call separately
// For now, return the accessor - caller should handle complex cases
return accessor;
}
// Handle complex nested types
if (prop.IsComplexType && prop.NestedProperties.Count > 0)
{
var sb = new StringBuilder();
sb.Append($"new {prop.Type}");
sb.AppendLine();
sb.Append(" {");
foreach (var nestedProp in prop.NestedProperties)
{
var nestedMapping = GeneratePropertyMapping(accessor, nestedProp);
sb.AppendLine();
sb.Append($" {nestedProp.ProtoPropertyName} = {nestedMapping},");
}
sb.AppendLine();
sb.Append(" }");
return sb.ToString();
}
// Default: direct assignment (works for primitives and simple types)
return accessor;
}
/// <summary>
/// Generates the mapping expression for a request property from proto type to C# domain type
/// </summary>
private static string GenerateRequestPropertyMapping(PropertyInfo prop)
{
// Use the proto property name (computed from snake_case conversion)
var accessor = $"request.{prop.ProtoPropertyName}";
// Handle string → decimal conversion (proto uses string for decimal)
if (prop.IsDecimal)
{
if (prop.IsNullable)
return $"string.IsNullOrEmpty({accessor}) ? null : decimal.Parse({accessor}, System.Globalization.CultureInfo.InvariantCulture)";
return $"decimal.Parse({accessor}, System.Globalization.CultureInfo.InvariantCulture)";
}
// Handle string → DateTime conversion (proto uses string for DateTime)
if (prop.IsDateTime)
{
if (prop.IsNullable)
return $"string.IsNullOrEmpty({accessor}) ? null : global::System.DateTime.Parse({accessor}, System.Globalization.CultureInfo.InvariantCulture)";
return $"global::System.DateTime.Parse({accessor}, System.Globalization.CultureInfo.InvariantCulture)";
}
// Handle proto enum → domain enum conversion
if (prop.IsEnum)
{
// Cast from proto enum (int) to domain enum
return $"({prop.Type})(int){accessor}";
}
// Handle nullable numeric types (int?, long?, etc.)
// In protobuf3, numeric fields default to 0 when not set.
// For nullable C# types, we treat 0 as null (unset).
if (prop.IsNullable && IsNullableNumericType(prop.Type))
{
return $"{accessor} == 0 ? null : {accessor}";
}
// Default: direct assignment (works for compatible types like int, long, string, bool)
return accessor;
}
/// <summary>
/// Checks if the type is a nullable numeric type (int?, long?, short?, etc.)
/// </summary>
private static bool IsNullableNumericType(string typeName)
{
// Check for common nullable numeric types
var nullableNumericTypes = new[]
{
"int?", "System.Int32?", "global::System.Int32?",
"long?", "System.Int64?", "global::System.Int64?",
"short?", "System.Int16?", "global::System.Int16?",
"byte?", "System.Byte?", "global::System.Byte?",
"uint?", "System.UInt32?", "global::System.UInt32?",
"ulong?", "System.UInt64?", "global::System.UInt64?",
"ushort?", "System.UInt16?", "global::System.UInt16?",
"sbyte?", "System.SByte?", "global::System.SByte?",
"float?", "System.Single?", "global::System.Single?",
"double?", "System.Double?", "global::System.Double?",
};
return nullableNumericTypes.Contains(typeName);
}
/// <summary>
/// Checks if a type is a collection type and returns the element type if so
/// </summary>
private static (bool IsCollection, INamedTypeSymbol? ElementType) GetCollectionElementType(ITypeSymbol type)
{
// Check if it's a named type
if (type is not INamedTypeSymbol namedType)
return (false, null);
// Explicitly exclude string (it implements IEnumerable<char> but we don't want to treat it as a collection)
var typeDisplayName = type.ToDisplayString();
if (typeDisplayName == "string" || typeDisplayName == "System.String" ||
typeDisplayName == "string?" || typeDisplayName == "System.String?")
{
return (false, null);
}
// Check for common collection types: List<T>, IList<T>, IEnumerable<T>, ICollection<T>, T[]
var typeFullName = namedType.OriginalDefinition.ToDisplayString();
// Check for array
if (type is IArrayTypeSymbol arrayType)
{
if (arrayType.ElementType is INamedTypeSymbol arrayElementType)
return (true, arrayElementType);
return (true, null);
}
// Collection type patterns to check
var collectionPatterns = new[]
{
"System.Collections.Generic.List<T>",
"System.Collections.Generic.IList<T>",
"System.Collections.Generic.IEnumerable<T>",
"System.Collections.Generic.ICollection<T>",
"System.Collections.Generic.IReadOnlyList<T>",
"System.Collections.Generic.IReadOnlyCollection<T>"
};
foreach (var pattern in collectionPatterns)
{
if (typeFullName == pattern && namedType.TypeArguments.Length > 0)
{
var elementType = namedType.TypeArguments[0] as INamedTypeSymbol;
return (true, elementType);
}
}
// Also check interfaces for types that implement IEnumerable<T>
foreach (var iface in namedType.AllInterfaces)
{
var ifaceFullName = iface.OriginalDefinition.ToDisplayString();
if (ifaceFullName == "System.Collections.Generic.IEnumerable<T>" && iface.TypeArguments.Length > 0)
{
var elementType = iface.TypeArguments[0] as INamedTypeSymbol;
return (true, elementType);
}
}
return (false, null);
}
private static string GenerateDynamicQueryMessages(List<DynamicQueryInfo> dynamicQueries, string rootNamespace)
@ -2232,6 +2660,7 @@ namespace Svrnty.CQRS.Grpc.Generators
properties.Add(new PropertyInfo
{
Name = prop.Name,
ProtoPropertyName = ToPascalCaseHelper(ToSnakeCaseHelper(prop.Name)),
Type = propType,
FullyQualifiedType = propType,
ProtoType = protoType,
@ -2382,8 +2811,27 @@ namespace Svrnty.CQRS.Grpc.Generators
{
if (string.IsNullOrEmpty(snakeCase)) return snakeCase;
var parts = snakeCase.Split('_');
return string.Join("", parts.Select(p =>
p.Length > 0 ? char.ToUpperInvariant(p[0]) + p.Substring(1).ToLowerInvariant() : ""));
var result = new StringBuilder();
foreach (var part in parts)
{
if (part.Length == 0) continue;
// Capitalize the first letter
result.Append(char.ToUpperInvariant(part[0]));
// Process remaining characters - capitalize letters that follow digits
for (int i = 1; i < part.Length; i++)
{
char c = part[i];
if (i > 0 && char.IsLetter(c) && char.IsDigit(part[i - 1]))
{
result.Append(char.ToUpperInvariant(c));
}
else
{
result.Append(char.ToLowerInvariant(c));
}
}
}
return result.ToString();
}
}
}

View File

@ -31,6 +31,11 @@ namespace Svrnty.CQRS.Grpc.Generators.Models
public class PropertyInfo
{
public string Name { get; set; }
/// <summary>
/// The property name as generated by Grpc.Tools from the proto field name.
/// This may differ from the C# property name due to casing differences.
/// </summary>
public string ProtoPropertyName { get; set; }
public string Type { get; set; }
public string FullyQualifiedType { get; set; }
public string ProtoType { get; set; }
@ -51,6 +56,7 @@ namespace Svrnty.CQRS.Grpc.Generators.Models
public PropertyInfo()
{
Name = string.Empty;
ProtoPropertyName = string.Empty;
Type = string.Empty;
FullyQualifiedType = string.Empty;
ProtoType = string.Empty;

View File

@ -13,6 +13,26 @@ namespace Svrnty.CQRS.Grpc.Generators.Models
public string HandlerInterfaceName { get; set; }
public List<PropertyInfo> ResultProperties { get; set; }
public bool IsResultPrimitiveType { get; set; }
/// <summary>
/// True if the result type is a collection (List, IEnumerable, etc.)
/// </summary>
public bool IsResultCollection { get; set; }
/// <summary>
/// The element type name if IsResultCollection is true
/// </summary>
public string ResultElementType { get; set; }
/// <summary>
/// The fully qualified element type name if IsResultCollection is true
/// </summary>
public string ResultElementTypeFullyQualified { get; set; }
/// <summary>
/// True if the result type is nullable (ends with ? or is Nullable<T>)
/// </summary>
public bool IsResultNullable { get; set; }
/// <summary>
/// The result type name without the nullable annotation (e.g., "CnfFoodDetailItem" instead of "CnfFoodDetailItem?")
/// </summary>
public string ResultTypeWithoutNullable { get; set; }
public QueryInfo()
{
@ -25,6 +45,11 @@ namespace Svrnty.CQRS.Grpc.Generators.Models
HandlerInterfaceName = string.Empty;
ResultProperties = new List<PropertyInfo>();
IsResultPrimitiveType = false;
IsResultCollection = false;
ResultElementType = string.Empty;
ResultElementTypeFullyQualified = string.Empty;
IsResultNullable = false;
ResultTypeWithoutNullable = string.Empty;
}
}
}

View File

@ -403,9 +403,14 @@ internal class ProtoFileGenerator
_messagesBuilder.AppendLine();
// Generate complex type message after closing the response message
if (resultType != null && IsComplexType(resultType))
// Use GetElementOrUnderlyingType to extract element type from collections (e.g., List<CnfFoodItem> -> CnfFoodItem)
if (resultType != null)
{
GenerateComplexTypeMessage(resultType as INamedTypeSymbol);
var underlyingType = ProtoFileTypeMapper.GetElementOrUnderlyingType(resultType);
if (IsComplexType(underlyingType) && underlyingType is INamedTypeSymbol namedType)
{
GenerateComplexTypeMessage(namedType);
}
}
}

View File

@ -2,5 +2,15 @@
<PropertyGroup>
<!-- Marker to indicate Svrnty.CQRS.Grpc.Generators is referenced -->
<SvrntyCqrsGrpcGeneratorsVersion>$(SvrntyCqrsGrpcGeneratorsVersion)</SvrntyCqrsGrpcGeneratorsVersion>
<!-- Path resolution for both NuGet package and project reference -->
<_SvrntyCqrsGrpcGeneratorsPath Condition="Exists('$(MSBuildThisFileDirectory)..\analyzers\dotnet\cs\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)..\analyzers\dotnet\cs\Svrnty.CQRS.Grpc.Generators.dll</_SvrntyCqrsGrpcGeneratorsPath>
<_SvrntyCqrsGrpcGeneratorsPath Condition="'$(_SvrntyCqrsGrpcGeneratorsPath)' == '' AND Exists('$(MSBuildThisFileDirectory)..\bin\Debug\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)..\bin\Debug\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll</_SvrntyCqrsGrpcGeneratorsPath>
<_SvrntyCqrsGrpcGeneratorsPath Condition="'$(_SvrntyCqrsGrpcGeneratorsPath)' == '' AND Exists('$(MSBuildThisFileDirectory)..\bin\Release\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)..\bin\Release\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll</_SvrntyCqrsGrpcGeneratorsPath>
</PropertyGroup>
<!-- Explicitly add the generator to the Analyzer ItemGroup -->
<ItemGroup Condition="'$(_SvrntyCqrsGrpcGeneratorsPath)' != ''">
<Analyzer Include="$(_SvrntyCqrsGrpcGeneratorsPath)" />
</ItemGroup>
</Project>