Compare commits

...

4 Commits

Author SHA1 Message Date
bd43bc9bde
Fix gRPC source generator for complex nested types
- Add DateTime/Timestamp conversion in nested property mapping
- Add IsReadOnly property detection to skip computed properties
- Extract ElementNestedProperties for complex list element types
- Skip read-only properties in GenerateComplexObjectMapping and GenerateComplexListMapping

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:25:01 -05:00
661f5b4b1c
Fix GrpcGenerator type mapping for commands and nullable primitives
- Add proper complex type mapping for command results (same as queries already had)
- Handle nullable primitives (long?, int?, etc.) with default value fallback
- Fixes CS0029 and CS0266 compilation errors in generated gRPC service implementations

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 11:29:32 -05:00
99aebcf314
Fix proto generation for collection types (NpgsqlPolygon, etc.)
- Add IsCollectionTypeByInterface() to detect types implementing IList<T>, ICollection<T>, IEnumerable<T>
- Add GetCollectionElementTypeByInterface() to extract element type from collection interfaces
- Add IsCollectionInternalProperty() to filter out Count, Capacity, IsReadOnly, etc.
- Update GenerateComplexTypeMessage to generate `repeated T items` for collection types
- Filter out indexers (!p.IsIndexer) and collection-internal properties from all property extraction

This fixes the invalid proto syntax where C# indexers (this[]) were being generated
as proto fields, causing proto compilation errors.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 10:33:55 -05:00
Mathias Beaulieu-Duncan
f76dbb1a97 fix: add Guid to string conversion in gRPC source generator
The MapToProtoModel function was silently failing when mapping Guid
properties to proto string fields, causing IDs to be empty in gRPC
responses. Added explicit Guid → string conversion handling.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:06:18 -05:00
5 changed files with 995 additions and 245 deletions

View File

@ -360,7 +360,11 @@ namespace Svrnty.CQRS.Grpc.Generators
IsEnum = IsEnumType(property.Type),
IsDecimal = IsDecimalType(property.Type),
IsDateTime = IsDateTimeType(property.Type),
IsGuid = IsGuidType(property.Type),
IsJsonElement = IsJsonElementType(property.Type),
IsList = IsListOrCollection(property.Type),
IsBinaryType = IsBinaryType(property.Type),
IsStream = IsStreamType(property.Type),
};
// If it's a list, extract element type info
@ -371,6 +375,7 @@ namespace Svrnty.CQRS.Grpc.Generators
{
propInfo.ElementType = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
propInfo.IsElementComplexType = IsUserDefinedComplexType(elementType);
propInfo.IsElementGuid = IsGuidType(elementType);
// If element is complex, extract nested properties
if (propInfo.IsElementComplexType)
@ -467,6 +472,36 @@ namespace Svrnty.CQRS.Grpc.Generators
return unwrapped.TypeKind == TypeKind.Enum;
}
private static bool IsGuidType(ITypeSymbol type)
{
var unwrapped = UnwrapNullableType(type);
return unwrapped.ToDisplayString() == "System.Guid";
}
private static bool IsJsonElementType(ITypeSymbol type)
{
var unwrapped = UnwrapNullableType(type);
return unwrapped.ToDisplayString() == "System.Text.Json.JsonElement";
}
private static bool IsBinaryType(ITypeSymbol type)
{
return ProtoFileTypeMapper.IsBinaryType(type);
}
private static bool IsStreamType(ITypeSymbol type)
{
var fullTypeName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
if (fullTypeName.Contains("System.IO.Stream") ||
fullTypeName.Contains("System.IO.MemoryStream") ||
fullTypeName.Contains("System.IO.FileStream"))
{
return true;
}
var typeName = type.Name;
return typeName == "Stream" || typeName == "MemoryStream" || typeName == "FileStream";
}
private static bool IsListOrCollection(ITypeSymbol type)
{
if (type is IArrayTypeSymbol)
@ -495,6 +530,41 @@ namespace Svrnty.CQRS.Grpc.Generators
return null;
}
/// <summary>
/// Generates the value expression for converting from proto type to C# type
/// </summary>
private static string GetProtoToCSharpConversion(PropertyInfo prop, string sourceExpr)
{
if (prop.IsGuid)
{
if (prop.IsNullable)
return $"string.IsNullOrEmpty({sourceExpr}) ? null : System.Guid.Parse({sourceExpr})";
return $"System.Guid.Parse({sourceExpr})";
}
if (prop.IsEnum)
{
// Enum is already handled correctly in proto - values match
return $"{sourceExpr}";
}
// Default: direct assignment
return $"{sourceExpr}!";
}
/// <summary>
/// Generates the value expression for converting from C# type to proto type
/// </summary>
private static string GetCSharpToProtoConversion(PropertyInfo prop, string sourceExpr)
{
if (prop.IsGuid)
{
if (prop.IsNullable)
return $"{sourceExpr}?.ToString() ?? \"\"";
return $"{sourceExpr}.ToString()";
}
// Default: direct assignment
return sourceExpr;
}
private static void ExtractNestedProperties(INamedTypeSymbol type, List<PropertyInfo> nestedProperties)
{
var properties = type.GetMembers().OfType<IPropertySymbol>()
@ -532,6 +602,9 @@ namespace Svrnty.CQRS.Grpc.Generators
foreach (var property in properties)
{
// Skip read-only properties (no setter) - they are computed and can't be set
var isReadOnly = property.IsReadOnly || property.SetMethod == null;
var propertyType = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var propInfo = new PropertyInfo
{
@ -541,12 +614,17 @@ namespace Svrnty.CQRS.Grpc.Generators
ProtoType = string.Empty,
FieldNumber = 0,
IsComplexType = IsUserDefinedComplexType(property.Type),
IsReadOnly = isReadOnly,
// Type metadata
IsNullable = IsNullableType(property.Type),
IsEnum = IsEnumType(property.Type),
IsDecimal = IsDecimalType(property.Type),
IsDateTime = IsDateTimeType(property.Type),
IsGuid = IsGuidType(property.Type),
IsJsonElement = IsJsonElementType(property.Type),
IsList = IsListOrCollection(property.Type),
IsBinaryType = IsBinaryType(property.Type),
IsStream = IsStreamType(property.Type),
};
// If it's a list, extract element type info
@ -557,6 +635,14 @@ namespace Svrnty.CQRS.Grpc.Generators
{
propInfo.ElementType = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
propInfo.IsElementComplexType = IsUserDefinedComplexType(elementType);
propInfo.IsElementGuid = IsGuidType(elementType);
// Extract nested properties for complex element types
if (propInfo.IsElementComplexType && elementType is INamedTypeSymbol namedElementType)
{
propInfo.ElementNestedProperties = new List<PropertyInfo>();
ExtractNestedPropertiesWithTypeInfo(namedElementType, propInfo.ElementNestedProperties);
}
}
}
// Recursively extract nested properties for complex types
@ -606,6 +692,11 @@ namespace Svrnty.CQRS.Grpc.Generators
// Complex list: map each element
return GenerateComplexListMapping(prop, source, indent);
}
else if (prop.IsElementGuid)
{
// List<string> from proto -> List<Guid> in C#
return $"{indent}{prop.Name} = {source}?.Select(x => System.Guid.Parse(x)).ToList(),";
}
else
{
// Primitive list: just ToList()
@ -645,6 +736,42 @@ namespace Svrnty.CQRS.Grpc.Generators
}
}
// Handle Guid (proto string -> C# Guid)
if (prop.IsGuid)
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = string.IsNullOrEmpty({source}) ? null : System.Guid.Parse({source}),";
}
else
{
return $"{indent}{prop.Name} = System.Guid.Parse({source}),";
}
}
// Handle binary types (proto ByteString -> C# byte[]/Stream)
if (prop.IsBinaryType)
{
if (prop.IsStream)
{
// ByteString -> MemoryStream
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source}?.IsEmpty == false ? new System.IO.MemoryStream({source}.ToByteArray()) : null,";
}
return $"{indent}{prop.Name} = new System.IO.MemoryStream({source}.ToByteArray()),";
}
else
{
// ByteString -> byte[]
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source}?.IsEmpty == false ? {source}.ToByteArray() : null,";
}
return $"{indent}{prop.Name} = {source}.ToByteArray(),";
}
}
// Handle complex types (single objects)
if (prop.IsComplexType)
{
@ -663,6 +790,9 @@ namespace Svrnty.CQRS.Grpc.Generators
foreach (var nestedProp in prop.ElementNestedProperties!)
{
// Skip read-only properties - they can't be assigned
if (nestedProp.IsReadOnly) continue;
var nestedSourcePropName = char.ToUpper(nestedProp.Name[0]) + nestedProp.Name.Substring(1);
var nestedAssignment = GenerateNestedPropertyAssignment(nestedProp, "x", indent + " ");
sb.AppendLine(nestedAssignment);
@ -680,6 +810,9 @@ namespace Svrnty.CQRS.Grpc.Generators
foreach (var nestedProp in prop.NestedProperties)
{
// Skip read-only properties - they can't be assigned
if (nestedProp.IsReadOnly) continue;
var nestedAssignment = GenerateNestedPropertyAssignment(nestedProp, source, indent + " ");
sb.AppendLine(nestedAssignment);
}
@ -712,9 +845,39 @@ namespace Svrnty.CQRS.Grpc.Generators
}
}
// Handle lists
// Handle DateTime (proto Timestamp -> C# DateTime)
if (prop.IsDateTime)
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source} == null ? (System.DateTime?)null : {source}.ToDateTime(),";
}
else
{
return $"{indent}{prop.Name} = {source}.ToDateTime(),";
}
}
// Handle Guid
if (prop.IsGuid)
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = string.IsNullOrEmpty({source}) ? null : System.Guid.Parse({source}),";
}
else
{
return $"{indent}{prop.Name} = System.Guid.Parse({source}),";
}
}
// Handle lists with complex element types
if (prop.IsList)
{
if (prop.IsElementComplexType && prop.ElementNestedProperties != null && prop.ElementNestedProperties.Any())
{
return GenerateComplexListMapping(prop, source, indent);
}
return $"{indent}{prop.Name} = {source}?.ToList(),";
}
@ -728,6 +891,319 @@ namespace Svrnty.CQRS.Grpc.Generators
return $"{indent}{prop.Name} = {source},";
}
/// <summary>
/// Generates C# to proto property mapping (reverse of GeneratePropertyAssignment)
/// </summary>
private static string GenerateResultPropertyMapping(PropertyInfo prop, string sourceVar, string indent)
{
var source = $"{sourceVar}.{prop.Name}";
// Handle lists
if (prop.IsList)
{
if (prop.IsElementComplexType)
{
// Complex list: map each element to proto type
return GenerateResultComplexListMapping(prop, source, indent);
}
else if (prop.IsElementGuid)
{
// List<Guid> -> repeated string
return $"{indent}{prop.Name} = {{ {source}?.Select(x => x.ToString()) ?? Enumerable.Empty<string>() }},";
}
else
{
// Primitive list: just copy
return $"{indent}{prop.Name} = {{ {source} ?? Enumerable.Empty<{prop.Type.Replace("System.Collections.Generic.List<", "").Replace(">", "").Replace("?", "")}>() }},";
}
}
// Handle Guid (C# Guid -> proto string)
if (prop.IsGuid)
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source}?.ToString() ?? string.Empty,";
}
else
{
return $"{indent}{prop.Name} = {source}.ToString(),";
}
}
// Handle decimals (C# decimal -> proto string)
if (prop.IsDecimal)
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source}?.ToString() ?? string.Empty,";
}
else
{
return $"{indent}{prop.Name} = {source}.ToString(),";
}
}
// Handle DateTime (C# DateTime -> proto Timestamp)
if (prop.IsDateTime)
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source}.HasValue ? Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(System.DateTime.SpecifyKind({source}.Value, System.DateTimeKind.Utc)) : null,";
}
else
{
return $"{indent}{prop.Name} = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(System.DateTime.SpecifyKind({source}, System.DateTimeKind.Utc)),";
}
}
// Handle TimeSpan (C# TimeSpan -> proto Duration)
if (prop.FullyQualifiedType.Contains("System.TimeSpan"))
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source}.HasValue ? Google.Protobuf.WellKnownTypes.Duration.FromTimeSpan({source}.Value) : null,";
}
else
{
return $"{indent}{prop.Name} = Google.Protobuf.WellKnownTypes.Duration.FromTimeSpan({source}),";
}
}
// Handle enums (C# enum -> proto int32)
if (prop.IsEnum)
{
return $"{indent}{prop.Name} = (int){source},";
}
// Handle binary types (byte[], Stream -> ByteString)
if (prop.IsBinaryType)
{
if (prop.IsStream)
{
// Stream -> ByteString: read stream to bytes first
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source} != null ? Google.Protobuf.ByteString.FromStream({source}) : Google.Protobuf.ByteString.Empty,";
}
return $"{indent}{prop.Name} = Google.Protobuf.ByteString.FromStream({source}),";
}
else
{
// byte[] -> ByteString
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source} != null ? Google.Protobuf.ByteString.CopyFrom({source}) : Google.Protobuf.ByteString.Empty,";
}
return $"{indent}{prop.Name} = Google.Protobuf.ByteString.CopyFrom({source}),";
}
}
// Handle complex types (single objects)
if (prop.IsComplexType)
{
return GenerateResultComplexObjectMapping(prop, source, indent);
}
// Default: direct assignment (strings, ints, bools, etc.)
if (prop.IsNullable)
{
if (prop.Type.Contains("string"))
{
return $"{indent}{prop.Name} = {source} ?? string.Empty,";
}
// Handle nullable primitives (long?, int?, etc.) - use default value
return $"{indent}{prop.Name} = {source} ?? default,";
}
return $"{indent}{prop.Name} = {source},";
}
private static string GenerateResultComplexListMapping(PropertyInfo prop, string source, string indent)
{
var sb = new StringBuilder();
var protoElementType = prop.ElementType?.Split('.').Last() ?? prop.Type;
sb.AppendLine($"{indent}{prop.Name} = {{");
sb.AppendLine($"{indent} {source}?.Select(x => new {protoElementType}");
sb.AppendLine($"{indent} {{");
if (prop.ElementNestedProperties != null)
{
foreach (var nestedProp in prop.ElementNestedProperties)
{
var nestedAssignment = GenerateResultNestedPropertyMapping(nestedProp, "x", indent + " ");
sb.AppendLine(nestedAssignment);
}
}
sb.AppendLine($"{indent} }}) ?? Enumerable.Empty<{protoElementType}>()");
sb.Append($"{indent}}},");
return sb.ToString();
}
private static string GenerateResultComplexObjectMapping(PropertyInfo prop, string source, string indent)
{
var sb = new StringBuilder();
var protoType = prop.Type.Split('.').Last().Replace("?", "");
sb.AppendLine($"{indent}{prop.Name} = {source} != null ? new {protoType}");
sb.AppendLine($"{indent}{{");
foreach (var nestedProp in prop.NestedProperties)
{
var nestedAssignment = GenerateResultNestedPropertyMapping(nestedProp, source, indent + " ");
sb.AppendLine(nestedAssignment);
}
sb.Append($"{indent}}} : null,");
return sb.ToString();
}
private static string GenerateResultNestedPropertyMapping(PropertyInfo prop, string sourceVar, string indent)
{
var source = $"{sourceVar}.{prop.Name}";
// Handle DateTime (C# DateTime -> proto Timestamp)
if (prop.IsDateTime)
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source} != null ? Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(System.DateTime.SpecifyKind({source}.Value, System.DateTimeKind.Utc)) : null,";
}
else
{
return $"{indent}{prop.Name} = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(System.DateTime.SpecifyKind({source}, System.DateTimeKind.Utc)),";
}
}
// Handle DateOnly (C# DateOnly -> proto string)
if (prop.Type.Contains("DateOnly"))
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source}?.ToString(\"yyyy-MM-dd\") ?? string.Empty,";
}
else
{
return $"{indent}{prop.Name} = {source}.ToString(\"yyyy-MM-dd\"),";
}
}
// Handle Guid
if (prop.IsGuid)
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source}?.ToString() ?? string.Empty,";
}
else
{
return $"{indent}{prop.Name} = {source}.ToString(),";
}
}
// Handle decimals
if (prop.IsDecimal)
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source}?.ToString() ?? string.Empty,";
}
else
{
return $"{indent}{prop.Name} = {source}.ToString(),";
}
}
// Handle enums
if (prop.IsEnum)
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = (int)({source} ?? default),";
}
return $"{indent}{prop.Name} = (int){source},";
}
// Handle binary types (C# byte[]/Stream -> proto ByteString)
if (prop.IsBinaryType)
{
if (prop.IsStream)
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source} != null ? Google.Protobuf.ByteString.FromStream({source}) : Google.Protobuf.ByteString.Empty,";
}
return $"{indent}{prop.Name} = Google.Protobuf.ByteString.FromStream({source}),";
}
else
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source} != null ? Google.Protobuf.ByteString.CopyFrom({source}) : Google.Protobuf.ByteString.Empty,";
}
return $"{indent}{prop.Name} = Google.Protobuf.ByteString.CopyFrom({source}),";
}
}
// Handle lists
if (prop.IsList)
{
if (prop.IsElementGuid)
{
return $"{indent}{prop.Name} = {{ {source}?.Select(x => x.ToString()) ?? Enumerable.Empty<string>() }},";
}
else if (prop.IsElementComplexType)
{
// Complex list elements need mapping
if (prop.ElementNestedProperties != null && prop.ElementNestedProperties.Any())
{
// Use recursive mapping for nested properties
return GenerateResultComplexListMapping(prop, source, indent);
}
else
{
// Fall back to creating empty proto objects (the user needs to ensure types are compatible)
var elementTypeName = prop.ElementType?.Split('.').Last() ?? "object";
return $"{indent}{prop.Name} = {{ {source}?.Select(x => new {elementTypeName}()) ?? Enumerable.Empty<{elementTypeName}>() }},";
}
}
return $"{indent}{prop.Name} = {{ {source} }},";
}
// Handle complex types (non-list)
if (prop.IsComplexType)
{
var typeName = prop.Type.Split('.').Last().Replace("?", "");
if (prop.NestedProperties != null && prop.NestedProperties.Any())
{
// Use recursive mapping for nested properties
return GenerateResultComplexObjectMapping(prop, source, indent);
}
else if (prop.IsNullable || prop.Type.EndsWith("?"))
{
return $"{indent}{prop.Name} = {source} != null ? new {typeName}() : null,";
}
else
{
return $"{indent}{prop.Name} = new {typeName}(),";
}
}
// Handle nullable strings
if (prop.IsNullable && prop.Type.Contains("string"))
{
return $"{indent}{prop.Name} = {source} ?? string.Empty,";
}
// Handle nullable value types (int?, long?, double?, etc.)
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source} ?? default,";
}
// Default: direct assignment
return $"{indent}{prop.Name} = {source},";
}
private static QueryInfo? ExtractQueryInfo(INamedTypeSymbol queryType, INamedTypeSymbol resultType)
{
var queryInfo = new QueryInfo
@ -759,14 +1235,55 @@ namespace Svrnty.CQRS.Grpc.Generators
foreach (var property in resultProperties)
{
queryInfo.ResultProperties.Add(new PropertyInfo
var propInfo = new PropertyInfo
{
Name = property.Name,
Type = property.Type.ToDisplayString(),
FullyQualifiedType = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
ProtoType = string.Empty, // Not needed for result mapping
FieldNumber = 0 // Not needed for result mapping
});
ProtoType = string.Empty,
FieldNumber = 0,
IsNullable = property.Type.NullableAnnotation == NullableAnnotation.Annotated ||
(property.Type is INamedTypeSymbol nt && nt.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T),
IsEnum = IsEnumType(property.Type),
IsDecimal = IsDecimalType(property.Type),
IsDateTime = IsDateTimeType(property.Type),
IsGuid = IsGuidType(property.Type),
IsJsonElement = IsJsonElementType(property.Type),
IsList = IsListOrCollection(property.Type),
IsComplexType = IsUserDefinedComplexType(property.Type),
IsBinaryType = IsBinaryType(property.Type),
IsStream = IsStreamType(property.Type),
};
// If it's a list, extract element type info
if (propInfo.IsList)
{
var elementType = GetListElementType(property.Type);
if (elementType != null)
{
propInfo.ElementType = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
propInfo.IsElementComplexType = IsUserDefinedComplexType(elementType);
propInfo.IsElementGuid = IsGuidType(elementType);
// Extract nested properties for complex element types
if (propInfo.IsElementComplexType && elementType is INamedTypeSymbol namedElementType)
{
propInfo.ElementNestedProperties = new List<PropertyInfo>();
ExtractNestedPropertiesWithTypeInfo(namedElementType, propInfo.ElementNestedProperties);
}
}
}
// If it's a complex type (not list), extract nested properties
else if (propInfo.IsComplexType)
{
var unwrapped = UnwrapNullableType(property.Type);
if (unwrapped is INamedTypeSymbol namedType)
{
ExtractNestedPropertiesWithTypeInfo(namedType, propInfo.NestedProperties);
}
}
queryInfo.ResultProperties.Add(propInfo);
}
}
@ -781,13 +1298,48 @@ namespace Svrnty.CQRS.Grpc.Generators
var propertyType = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var protoType = ProtoTypeMapper.MapToProtoType(propertyType, out bool isRepeated, out bool isOptional);
queryInfo.Properties.Add(new PropertyInfo
var propInfo = new PropertyInfo
{
Name = property.Name,
Type = propertyType,
FullyQualifiedType = propertyType,
ProtoType = protoType,
FieldNumber = fieldNumber++
});
FieldNumber = fieldNumber++,
IsNullable = property.Type.NullableAnnotation == NullableAnnotation.Annotated ||
(property.Type is INamedTypeSymbol nt && nt.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T),
IsEnum = IsEnumType(property.Type),
IsDecimal = IsDecimalType(property.Type),
IsDateTime = IsDateTimeType(property.Type),
IsGuid = IsGuidType(property.Type),
IsJsonElement = IsJsonElementType(property.Type),
IsList = IsListOrCollection(property.Type),
IsComplexType = IsUserDefinedComplexType(property.Type),
IsBinaryType = IsBinaryType(property.Type),
IsStream = IsStreamType(property.Type),
};
// If it's a list, extract element type info
if (propInfo.IsList)
{
var elementType = GetListElementType(property.Type);
if (elementType != null)
{
propInfo.ElementType = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
propInfo.IsElementComplexType = IsUserDefinedComplexType(elementType);
propInfo.IsElementGuid = IsGuidType(elementType);
}
}
// If it's a complex type (not list), extract nested properties
else if (propInfo.IsComplexType)
{
var unwrapped = UnwrapNullableType(property.Type);
if (unwrapped is INamedTypeSymbol namedType)
{
ExtractNestedPropertiesWithTypeInfo(namedType, propInfo.NestedProperties);
}
}
queryInfo.Properties.Add(propInfo);
}
return queryInfo;
@ -1032,7 +1584,8 @@ namespace Svrnty.CQRS.Grpc.Generators
sb.AppendLine(" {");
foreach (var prop in command.Properties)
{
sb.AppendLine($" {prop.Name} = request.{prop.Name}!,");
var conversion = GetProtoToCSharpConversion(prop, $"request.{prop.Name}");
sb.AppendLine($" {prop.Name} = {conversion},");
}
sb.AppendLine(" };");
sb.AppendLine(" var result = await handler.HandleAsync(command, context.CancellationToken);");
@ -1048,7 +1601,8 @@ namespace Svrnty.CQRS.Grpc.Generators
sb.AppendLine(" {");
foreach (var prop in command.Properties)
{
sb.AppendLine($" {prop.Name} = request.{prop.Name}!,");
var conversion = GetProtoToCSharpConversion(prop, $"request.{prop.Name}");
sb.AppendLine($" {prop.Name} = {conversion},");
}
sb.AppendLine(" };");
sb.AppendLine(" await handler.HandleAsync(command, context.CancellationToken);");
@ -1114,7 +1668,8 @@ namespace Svrnty.CQRS.Grpc.Generators
sb.AppendLine(" {");
foreach (var prop in query.Properties)
{
sb.AppendLine($" {prop.Name} = request.{prop.Name}!,");
var conversion = GetProtoToCSharpConversion(prop, $"request.{prop.Name}");
sb.AppendLine($" {prop.Name} = {conversion},");
}
sb.AppendLine(" };");
sb.AppendLine(" var result = await handler.HandleAsync(query, context.CancellationToken);");
@ -1554,7 +2109,39 @@ namespace Svrnty.CQRS.Grpc.Generators
if (command.HasResult)
{
sb.AppendLine(" var result = await handler.HandleAsync(command, context.CancellationToken);");
sb.AppendLine($" return new {responseType} {{ Result = result }};");
// Generate response with mapping if complex type
if (command.IsResultPrimitiveType)
{
// Handle primitive type result conversion (e.g., Guid.ToString())
if (command.ResultFullyQualifiedName?.Contains("System.Guid") == true)
{
sb.AppendLine($" return new {responseType} {{ Result = result.ToString() }};");
}
else
{
sb.AppendLine($" return new {responseType} {{ Result = result }};");
}
}
else
{
// Complex type - need to map from C# type to proto type
sb.AppendLine($" if (result == null)");
sb.AppendLine($" {{");
sb.AppendLine($" return new {responseType}();");
sb.AppendLine($" }}");
sb.AppendLine($" return new {responseType}");
sb.AppendLine(" {");
sb.AppendLine($" Result = new {command.ResultType}");
sb.AppendLine(" {");
foreach (var prop in command.ResultProperties)
{
var assignment = GenerateResultPropertyMapping(prop, "result", " ");
sb.AppendLine(assignment);
}
sb.AppendLine(" }");
sb.AppendLine(" };");
}
}
else
{
@ -1617,7 +2204,8 @@ 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 assignment = GeneratePropertyAssignment(prop, "request", " ");
sb.AppendLine(assignment);
}
sb.AppendLine(" };");
sb.AppendLine(" var result = await handler.HandleAsync(query, context.CancellationToken);");
@ -1625,18 +2213,31 @@ namespace Svrnty.CQRS.Grpc.Generators
// Generate response with mapping if complex type
if (query.IsResultPrimitiveType)
{
sb.AppendLine($" return new {responseType} {{ Result = result }};");
// Handle primitive type result conversion (e.g., Guid.ToString())
if (query.ResultFullyQualifiedName?.Contains("System.Guid") == true)
{
sb.AppendLine($" return new {responseType} {{ Result = result.ToString() }};");
}
else
{
sb.AppendLine($" return new {responseType} {{ Result = result }};");
}
}
else
{
// Complex type - need to map from C# type to proto type
sb.AppendLine($" if (result == null)");
sb.AppendLine($" {{");
sb.AppendLine($" return new {responseType}();");
sb.AppendLine($" }}");
sb.AppendLine($" return new {responseType}");
sb.AppendLine(" {");
sb.AppendLine($" Result = new {query.ResultType}");
sb.AppendLine(" {");
foreach (var prop in query.ResultProperties)
{
sb.AppendLine($" {prop.Name} = result.{prop.Name},");
var assignment = GenerateResultPropertyMapping(prop, "result", " ");
sb.AppendLine(assignment);
}
sb.AppendLine(" }");
sb.AppendLine(" };");
@ -1672,9 +2273,23 @@ namespace Svrnty.CQRS.Grpc.Generators
"System.Guid"
};
return primitiveTypes.Contains(typeName) ||
typeName.StartsWith("System.Nullable<") ||
typeName.EndsWith("?");
if (primitiveTypes.Contains(typeName))
return true;
// Handle nullable types - check if the underlying type is primitive
if (typeName.EndsWith("?"))
{
var underlyingType = typeName.Substring(0, typeName.Length - 1);
return IsPrimitiveType(underlyingType);
}
if (typeName.StartsWith("System.Nullable<") && typeName.EndsWith(">"))
{
var underlyingType = typeName.Substring("System.Nullable<".Length, typeName.Length - "System.Nullable<".Length - 1);
return IsPrimitiveType(underlyingType);
}
return false;
}
private static string GenerateDynamicQueryMessages(List<DynamicQueryInfo> dynamicQueries, string rootNamespace)
@ -1910,10 +2525,10 @@ namespace Svrnty.CQRS.Grpc.Generators
sb.AppendLine(" {");
sb.AppendLine(" Page = request.Page > 0 ? request.Page : null,");
sb.AppendLine(" PageSize = request.PageSize > 0 ? request.PageSize : null,");
sb.AppendLine(" Filters = ConvertFilters(request.Filters),");
sb.AppendLine(" Sorts = ConvertSorts(request.Sorts),");
sb.AppendLine(" Groups = ConvertGroups(request.Groups),");
sb.AppendLine(" Aggregates = ConvertAggregates(request.Aggregates)");
sb.AppendLine(" Filters = ConvertFilters(request.Filters) ?? new(),");
sb.AppendLine(" Sorts = ConvertSorts(request.Sorts) ?? new(),");
sb.AppendLine(" Groups = ConvertGroups(request.Groups) ?? new(),");
sb.AppendLine(" Aggregates = ConvertAggregates(request.Aggregates) ?? new()");
sb.AppendLine(" };");
sb.AppendLine();
@ -2096,7 +2711,8 @@ namespace Svrnty.CQRS.Grpc.Generators
sb.AppendLine(" var domainElementType = domainProp.PropertyType.IsArray");
sb.AppendLine(" ? domainProp.PropertyType.GetElementType()");
sb.AppendLine(" : domainProp.PropertyType.IsGenericType ? domainProp.PropertyType.GetGenericArguments()[0] : null;");
sb.AppendLine(" var protoElementType = protoField.MessageType?.ClrType;");
sb.AppendLine(" // Only access MessageType for message fields (throws for primitives)");
sb.AppendLine(" var protoElementType = protoField.FieldType == Google.Protobuf.Reflection.FieldType.Message ? protoField.MessageType?.ClrType : null;");
sb.AppendLine();
sb.AppendLine(" foreach (var item in enumerable)");
sb.AppendLine(" {");
@ -2120,14 +2736,66 @@ namespace Svrnty.CQRS.Grpc.Generators
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" // Handle enumerable value types that map to proto messages with repeated fields (e.g., NpgsqlPolygon -> proto message with items)");
sb.AppendLine(" else if (domainProp.PropertyType.IsValueType && ");
sb.AppendLine(" domainValue is System.Collections.IEnumerable valueTypeEnumerable &&");
sb.AppendLine(" protoField.FieldType == Google.Protobuf.Reflection.FieldType.Message)");
sb.AppendLine(" {");
sb.AppendLine(" // Create the proto message and look for its 'items' repeated field");
sb.AppendLine(" var protoFieldType = protoField.MessageType?.ClrType;");
sb.AppendLine(" if (protoFieldType != null && typeof(Google.Protobuf.IMessage).IsAssignableFrom(protoFieldType))");
sb.AppendLine(" {");
sb.AppendLine(" var nestedProto = System.Activator.CreateInstance(protoFieldType) as Google.Protobuf.IMessage;");
sb.AppendLine(" if (nestedProto != null)");
sb.AppendLine(" {");
sb.AppendLine(" // Find the 'items' field in the proto message");
sb.AppendLine(" var itemsField = nestedProto.Descriptor.FindFieldByName(\"items\");");
sb.AppendLine(" if (itemsField != null && itemsField.IsRepeated)");
sb.AppendLine(" {");
sb.AppendLine(" var repeatedField = itemsField.Accessor.GetValue(nestedProto);");
sb.AppendLine(" var repeatedFieldType = repeatedField?.GetType();");
sb.AppendLine(" var protoElementType = itemsField.MessageType?.ClrType;");
sb.AppendLine(" ");
sb.AppendLine(" if (repeatedFieldType != null && protoElementType != null)");
sb.AppendLine(" {");
sb.AppendLine(" var addMethod = repeatedFieldType.GetMethod(\"Add\", new[] { protoElementType });");
sb.AppendLine(" if (addMethod != null)");
sb.AppendLine(" {");
sb.AppendLine(" // Get element type from the enumerable");
sb.AppendLine(" var enumerableInterface = domainProp.PropertyType.GetInterfaces()");
sb.AppendLine(" .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(System.Collections.Generic.IEnumerable<>));");
sb.AppendLine(" var domainElementType = enumerableInterface?.GetGenericArguments()[0];");
sb.AppendLine(" ");
sb.AppendLine(" foreach (var item in valueTypeEnumerable)");
sb.AppendLine(" {");
sb.AppendLine(" if (item == null) continue;");
sb.AppendLine(" ");
sb.AppendLine(" // Map each item to the proto element type");
sb.AppendLine(" if (domainElementType != null && typeof(Google.Protobuf.IMessage).IsAssignableFrom(protoElementType))");
sb.AppendLine(" {");
sb.AppendLine(" var mapMethod = typeof(DynamicQueryServiceImpl).GetMethod(\"MapToProtoModel\",");
sb.AppendLine(" System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!");
sb.AppendLine(" .MakeGenericMethod(domainElementType, protoElementType);");
sb.AppendLine(" var mappedItem = mapMethod.Invoke(null, new[] { item });");
sb.AppendLine(" if (mappedItem != null)");
sb.AppendLine(" addMethod.Invoke(repeatedField, new[] { mappedItem });");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" protoAccessor.SetValue(proto, nestedProto);");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" // Handle nested complex types (non-primitive, non-enum, non-string, non-collection)");
sb.AppendLine(" else if (!domainProp.PropertyType.IsPrimitive && ");
sb.AppendLine(" domainProp.PropertyType != typeof(string) && ");
sb.AppendLine(" !domainProp.PropertyType.IsEnum &&");
sb.AppendLine(" !domainProp.PropertyType.IsValueType)");
sb.AppendLine(" {");
sb.AppendLine(" // Get the proto field type and recursively map");
sb.AppendLine(" var protoFieldType = protoAccessor.GetValue(proto)?.GetType() ?? protoField.MessageType?.ClrType;");
sb.AppendLine(" // Get the proto field type and recursively map (only access MessageType for message fields)");
sb.AppendLine(" var protoFieldType = protoAccessor.GetValue(proto)?.GetType() ?? (protoField.FieldType == Google.Protobuf.Reflection.FieldType.Message ? protoField.MessageType?.ClrType : null);");
sb.AppendLine(" if (protoFieldType != null && typeof(Google.Protobuf.IMessage).IsAssignableFrom(protoFieldType))");
sb.AppendLine(" {");
sb.AppendLine(" var mapMethod = typeof(DynamicQueryServiceImpl).GetMethod(\"MapToProtoModel\", ");
@ -2143,6 +2811,11 @@ namespace Svrnty.CQRS.Grpc.Generators
sb.AppendLine(" {");
sb.AppendLine(" protoAccessor.SetValue(proto, ((decimal)domainValue).ToString(System.Globalization.CultureInfo.InvariantCulture));");
sb.AppendLine(" }");
sb.AppendLine(" // Handle Guid -> string conversion");
sb.AppendLine(" else if (domainProp.PropertyType == typeof(Guid) || domainProp.PropertyType == typeof(Guid?))");
sb.AppendLine(" {");
sb.AppendLine(" protoAccessor.SetValue(proto, ((Guid)domainValue).ToString());");
sb.AppendLine(" }");
sb.AppendLine(" else");
sb.AppendLine(" {");
sb.AppendLine(" // Direct assignment for primitives, strings, enums");
@ -2252,7 +2925,7 @@ namespace Svrnty.CQRS.Grpc.Generators
Name = type.Name,
FullyQualifiedName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
Namespace = type.ContainingNamespace?.ToDisplayString() ?? "",
SubscriptionKeyProperty = subscriptionKeyProp,
SubscriptionKeyProperty = subscriptionKeyProp!, // Already validated as non-null above
SubscriptionKeyInfo = keyPropInfo,
Properties = properties
});
@ -2343,6 +3016,10 @@ namespace Svrnty.CQRS.Grpc.Generators
{
sb.AppendLine($" {protoFieldName} = domain.{prop.Name}.ToString(),");
}
else if (prop.IsGuid)
{
sb.AppendLine($" {protoFieldName} = domain.{prop.Name}.ToString(),");
}
else if (prop.IsEnum)
{
// Map domain enum to proto enum - get simple type name

View File

@ -44,8 +44,14 @@ namespace Svrnty.CQRS.Grpc.Generators.Models
public bool IsNullable { get; set; }
public bool IsDecimal { get; set; }
public bool IsDateTime { get; set; }
public bool IsGuid { get; set; }
public bool IsJsonElement { get; set; }
public bool IsBinaryType { get; set; } // Stream, byte[], MemoryStream
public bool IsStream { get; set; } // Specifically Stream types (not byte[])
public bool IsReadOnly { get; set; } // Read-only/computed properties should be skipped
public string? ElementType { get; set; }
public bool IsElementComplexType { get; set; }
public bool IsElementGuid { get; set; }
public List<PropertyInfo>? ElementNestedProperties { get; set; }
public PropertyInfo()
@ -61,7 +67,13 @@ namespace Svrnty.CQRS.Grpc.Generators.Models
IsNullable = false;
IsDecimal = false;
IsDateTime = false;
IsGuid = false;
IsJsonElement = false;
IsBinaryType = false;
IsStream = false;
IsReadOnly = false;
IsElementComplexType = false;
IsElementGuid = false;
}
}
}

View File

@ -320,7 +320,9 @@ internal class ProtoFileGenerator
var properties = type.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public)
.Where(p => p.DeclaredAccessibility == Accessibility.Public &&
!p.IsIndexer &&
!ProtoFileTypeMapper.IsCollectionInternalProperty(p.Name))
.ToList();
// Collect nested complex types to generate after closing this message
@ -423,48 +425,73 @@ internal class ProtoFileGenerator
_messagesBuilder.AppendLine($"// {type.Name} entity");
_messagesBuilder.AppendLine($"message {type.Name} {{");
var properties = type.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public)
.ToList();
// Collect nested complex types to generate after closing this message
var nestedComplexTypes = new List<INamedTypeSymbol>();
int fieldNumber = 1;
foreach (var prop in properties)
// Check if this type is a collection (implements IList<T>, ICollection<T>, etc.)
var collectionElementType = ProtoFileTypeMapper.GetCollectionElementTypeByInterface(type);
if (collectionElementType != null)
{
if (ProtoFileTypeMapper.IsUnsupportedType(prop.Type))
{
_messagesBuilder.AppendLine($" // Skipped: {prop.Name} - unsupported type {prop.Type.Name}");
continue;
}
var protoType = ProtoFileTypeMapper.MapType(prop.Type, out var needsImport, out var importPath);
// This type is a collection - generate a single repeated field for items
var protoElementType = ProtoFileTypeMapper.MapType(collectionElementType, out var needsImport, out var importPath);
if (needsImport && importPath != null)
{
_requiredImports.Add(importPath);
}
var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name);
_messagesBuilder.AppendLine($" {protoType} {fieldName} = {fieldNumber};");
_messagesBuilder.AppendLine($" repeated {protoElementType} items = 1;");
// Track enums for later generation
var enumType = ProtoFileTypeMapper.GetEnumType(prop.Type);
if (enumType != null)
// Track the element type for nested generation
if (IsComplexType(collectionElementType) && collectionElementType is INamedTypeSymbol elementNamedType)
{
TrackEnumType(enumType);
nestedComplexTypes.Add(elementNamedType);
}
}
else
{
// Not a collection - generate properties as usual
var properties = type.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public &&
!p.IsIndexer &&
!ProtoFileTypeMapper.IsCollectionInternalProperty(p.Name))
.ToList();
// Collect complex types to generate after this message is closed
// Use GetElementOrUnderlyingType to extract element type from collections
var underlyingType = ProtoFileTypeMapper.GetElementOrUnderlyingType(prop.Type);
if (IsComplexType(underlyingType) && underlyingType is INamedTypeSymbol namedType)
int fieldNumber = 1;
foreach (var prop in properties)
{
nestedComplexTypes.Add(namedType);
}
if (ProtoFileTypeMapper.IsUnsupportedType(prop.Type))
{
_messagesBuilder.AppendLine($" // Skipped: {prop.Name} - unsupported type {prop.Type.Name}");
continue;
}
fieldNumber++;
var protoType = ProtoFileTypeMapper.MapType(prop.Type, out var needsImport, out var importPath);
if (needsImport && importPath != null)
{
_requiredImports.Add(importPath);
}
var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name);
_messagesBuilder.AppendLine($" {protoType} {fieldName} = {fieldNumber};");
// Track enums for later generation
var enumType = ProtoFileTypeMapper.GetEnumType(prop.Type);
if (enumType != null)
{
TrackEnumType(enumType);
}
// Collect complex types to generate after this message is closed
// Use GetElementOrUnderlyingType to extract element type from collections
var underlyingType = ProtoFileTypeMapper.GetElementOrUnderlyingType(prop.Type);
if (IsComplexType(underlyingType) && underlyingType is INamedTypeSymbol namedType)
{
nestedComplexTypes.Add(namedType);
}
fieldNumber++;
}
}
_messagesBuilder.AppendLine("}");
@ -737,7 +764,7 @@ internal class ProtoFileGenerator
FullyQualifiedName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
.Replace("global::", ""),
Namespace = type.ContainingNamespace?.ToDisplayString() ?? "",
SubscriptionKeyProperty = subscriptionKeyProp,
SubscriptionKeyProperty = subscriptionKeyProp!, // Already validated as non-null above
SubscriptionKeyInfo = keyPropInfo,
Properties = properties
});
@ -755,7 +782,9 @@ internal class ProtoFileGenerator
int fieldNumber = 1;
foreach (var prop in type.GetMembers().OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public))
.Where(p => p.DeclaredAccessibility == Accessibility.Public &&
!p.IsIndexer &&
!ProtoFileTypeMapper.IsCollectionInternalProperty(p.Name)))
{
if (ProtoFileTypeMapper.IsUnsupportedType(prop.Type))
continue;
@ -817,14 +846,16 @@ internal class ProtoFileGenerator
foreach (var prop in notification.Properties)
{
var protoType = ProtoFileTypeMapper.MapType(
_compilation.GetTypeByMetadataName(prop.FullyQualifiedType) ??
GetTypeFromName(prop.FullyQualifiedType),
out var needsImport, out var importPath);
var typeSymbol = _compilation.GetTypeByMetadataName(prop.FullyQualifiedType) ??
GetTypeFromName(prop.FullyQualifiedType);
if (needsImport && importPath != null)
if (typeSymbol != null)
{
_requiredImports.Add(importPath);
ProtoFileTypeMapper.MapType(typeSymbol, out var needsImport, out var importPath);
if (needsImport && importPath != null)
{
_requiredImports.Add(importPath);
}
}
var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name);

View File

@ -20,6 +20,68 @@ internal static class ProtoFileTypeMapper
// Note: NullableAnnotation.Annotated is for reference type nullability (List<T>?, string?, etc.)
// We don't unwrap these - just use the underlying type. Nullable<T> value types are handled later.
// Handle Nullable<T> value types (e.g., int?, decimal?, enum?) FIRST
if (typeSymbol is INamedTypeSymbol nullableType &&
nullableType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T &&
nullableType.TypeArguments.Length == 1)
{
// Unwrap the nullable and map the inner type
return MapType(nullableType.TypeArguments[0], out needsImport, out importPath);
}
// Handle collections BEFORE basic type checks (to avoid matching List<Guid> as Guid)
if (typeSymbol is INamedTypeSymbol collectionType)
{
// List, IEnumerable, Array, ICollection etc. (but not Nullable<T>)
var collectionTypeName = collectionType.Name;
if (collectionType.TypeArguments.Length == 1 &&
(collectionTypeName.Contains("List") || collectionTypeName.Contains("Collection") ||
collectionTypeName.Contains("Enumerable") || collectionTypeName.Contains("Array") ||
collectionTypeName.Contains("Set") || collectionTypeName.Contains("IList") ||
collectionTypeName.Contains("ICollection") || collectionTypeName.Contains("IEnumerable")))
{
var elementType = collectionType.TypeArguments[0];
var protoElementType = MapType(elementType, out needsImport, out importPath);
return $"repeated {protoElementType}";
}
// Dictionary<K, V>
if (collectionType.TypeArguments.Length == 2 &&
(collectionTypeName.Contains("Dictionary") || collectionTypeName.Contains("IDictionary")))
{
var keyType = MapType(collectionType.TypeArguments[0], out var keyNeedsImport, out var keyImportPath);
var valueType = MapType(collectionType.TypeArguments[1], out var valueNeedsImport, out var valueImportPath);
// Set import flags if either key or value needs imports
if (keyNeedsImport)
{
needsImport = true;
importPath = keyImportPath;
}
if (valueNeedsImport)
{
needsImport = true;
importPath = valueImportPath; // Note: This only captures last import, may need improvement
}
return $"map<{keyType}, {valueType}>";
}
}
// Handle byte[] array type (check before switch since it's an array)
if (typeSymbol is IArrayTypeSymbol arrayType && arrayType.ElementType.SpecialType == SpecialType.System_Byte)
{
return "bytes";
}
// Handle Stream types -> bytes
if (fullTypeName.Contains("System.IO.Stream") ||
fullTypeName.Contains("System.IO.MemoryStream") ||
fullTypeName.Contains("System.IO.FileStream"))
{
return "bytes";
}
// Basic types
switch (typeName)
{
@ -49,81 +111,35 @@ internal static class ProtoFileTypeMapper
return "double";
case "Byte[]":
return "bytes";
}
// Special types that need imports
if (fullTypeName.Contains("System.DateTime"))
{
needsImport = true;
importPath = "google/protobuf/timestamp.proto";
return "google.protobuf.Timestamp";
}
if (fullTypeName.Contains("System.TimeSpan"))
{
needsImport = true;
importPath = "google/protobuf/duration.proto";
return "google.protobuf.Duration";
}
if (fullTypeName.Contains("System.Guid"))
{
// Guid serialized as string
return "string";
}
if (fullTypeName.Contains("System.Decimal") || typeName == "Decimal" || fullTypeName == "decimal")
{
// Decimal serialized as string (no native decimal in proto)
return "string";
}
// Handle Nullable<T> value types (e.g., int?, decimal?, enum?)
if (typeSymbol is INamedTypeSymbol nullableType &&
nullableType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T &&
nullableType.TypeArguments.Length == 1)
{
// Unwrap the nullable and map the inner type
return MapType(nullableType.TypeArguments[0], out needsImport, out importPath);
}
// Collections
if (typeSymbol is INamedTypeSymbol collectionType)
{
// List, IEnumerable, Array, ICollection etc. (but not Nullable<T>)
var typeName2 = collectionType.Name;
if (collectionType.TypeArguments.Length == 1 &&
(typeName2.Contains("List") || typeName2.Contains("Collection") ||
typeName2.Contains("Enumerable") || typeName2.Contains("Array") ||
typeName2.Contains("Set") || typeName2.Contains("IList") ||
typeName2.Contains("ICollection") || typeName2.Contains("IEnumerable")))
{
var elementType = collectionType.TypeArguments[0];
var protoElementType = MapType(elementType, out needsImport, out importPath);
return $"repeated {protoElementType}";
}
// Dictionary<K, V>
if (collectionType.TypeArguments.Length == 2 &&
(typeName.Contains("Dictionary") || typeName.Contains("IDictionary")))
{
var keyType = MapType(collectionType.TypeArguments[0], out var keyNeedsImport, out var keyImportPath);
var valueType = MapType(collectionType.TypeArguments[1], out var valueNeedsImport, out var valueImportPath);
// Set import flags if either key or value needs imports
if (keyNeedsImport)
{
needsImport = true;
importPath = keyImportPath;
}
if (valueNeedsImport)
{
needsImport = true;
importPath = valueImportPath; // Note: This only captures last import, may need improvement
}
return $"map<{keyType}, {valueType}>";
}
case "Stream":
case "MemoryStream":
case "FileStream":
return "bytes";
case "Guid":
// Guid serialized as string
return "string";
case "Decimal":
// Decimal serialized as string (no native decimal in proto)
return "string";
case "DateTime":
case "DateTimeOffset":
needsImport = true;
importPath = "google/protobuf/timestamp.proto";
return "google.protobuf.Timestamp";
case "DateOnly":
// DateOnly serialized as string (YYYY-MM-DD format)
return "string";
case "TimeOnly":
// TimeOnly serialized as string (HH:mm:ss format)
return "string";
case "TimeSpan":
needsImport = true;
importPath = "google/protobuf/duration.proto";
return "google.protobuf.Duration";
case "JsonElement":
needsImport = true;
importPath = "google/protobuf/struct.proto";
return "google.protobuf.Struct";
}
// Enums
@ -186,8 +202,8 @@ internal static class ProtoFileTypeMapper
var fullTypeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// Skip these types - they should trigger a warning/error
if (fullTypeName.Contains("System.IO.Stream") ||
fullTypeName.Contains("System.Threading.CancellationToken") ||
// Note: Stream types are now supported (mapped to bytes)
if (fullTypeName.Contains("System.Threading.CancellationToken") ||
fullTypeName.Contains("System.Threading.Tasks.Task") ||
fullTypeName.Contains("System.Collections.Generic.IAsyncEnumerable") ||
fullTypeName.Contains("System.Func") ||
@ -200,6 +216,31 @@ internal static class ProtoFileTypeMapper
return false;
}
/// <summary>
/// Checks if a type is a Stream or byte array type (for special ByteString handling)
/// </summary>
public static bool IsBinaryType(ITypeSymbol typeSymbol)
{
var fullTypeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// Check for byte[]
if (typeSymbol is IArrayTypeSymbol arrayType && arrayType.ElementType.SpecialType == SpecialType.System_Byte)
{
return true;
}
// Check for Stream types
if (fullTypeName.Contains("System.IO.Stream") ||
fullTypeName.Contains("System.IO.MemoryStream") ||
fullTypeName.Contains("System.IO.FileStream"))
{
return true;
}
var typeName = typeSymbol.Name;
return typeName == "Stream" || typeName == "MemoryStream" || typeName == "FileStream";
}
/// <summary>
/// Gets the element type from a collection type, or returns the type itself if not a collection.
/// Also unwraps Nullable types.
@ -251,4 +292,97 @@ internal static class ProtoFileTypeMapper
}
return null;
}
/// <summary>
/// Checks if a type is a collection by checking if it implements IList{T}, ICollection{T}, or IEnumerable{T}
/// This handles types like NpgsqlPolygon that implement IList{NpgsqlPoint} but aren't named "List"
/// </summary>
public static bool IsCollectionTypeByInterface(ITypeSymbol typeSymbol)
{
if (typeSymbol is not INamedTypeSymbol namedType)
return false;
// Skip string (implements IEnumerable<char>)
if (namedType.SpecialType == SpecialType.System_String)
return false;
// Check all interfaces for IList<T>, ICollection<T>, or IEnumerable<T>
foreach (var iface in namedType.AllInterfaces)
{
if (iface.IsGenericType && iface.TypeArguments.Length == 1)
{
var ifaceName = iface.OriginalDefinition.ToDisplayString();
if (ifaceName == "System.Collections.Generic.IList<T>" ||
ifaceName == "System.Collections.Generic.ICollection<T>" ||
ifaceName == "System.Collections.Generic.IEnumerable<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyList<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyCollection<T>")
{
return true;
}
}
}
return false;
}
/// <summary>
/// Gets the element type from a collection that implements IList{T}, ICollection{T}, or IEnumerable{T}
/// Returns null if the type is not a collection
/// </summary>
public static ITypeSymbol? GetCollectionElementTypeByInterface(ITypeSymbol typeSymbol)
{
if (typeSymbol is not INamedTypeSymbol namedType)
return null;
// Skip string
if (namedType.SpecialType == SpecialType.System_String)
return null;
// Prefer IList<T> over ICollection<T> over IEnumerable<T>
ITypeSymbol? elementType = null;
int priority = 0;
foreach (var iface in namedType.AllInterfaces)
{
if (iface.IsGenericType && iface.TypeArguments.Length == 1)
{
var ifaceName = iface.OriginalDefinition.ToDisplayString();
int currentPriority = 0;
if (ifaceName == "System.Collections.Generic.IList<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyList<T>")
currentPriority = 3;
else if (ifaceName == "System.Collections.Generic.ICollection<T>" ||
ifaceName == "System.Collections.Generic.IReadOnlyCollection<T>")
currentPriority = 2;
else if (ifaceName == "System.Collections.Generic.IEnumerable<T>")
currentPriority = 1;
if (currentPriority > priority)
{
priority = currentPriority;
elementType = iface.TypeArguments[0];
}
}
}
return elementType;
}
/// <summary>
/// Collection-internal properties that should be skipped when generating proto messages
/// </summary>
private static readonly System.Collections.Generic.HashSet<string> CollectionInternalProperties = new()
{
"Count", "Capacity", "IsReadOnly", "IsSynchronized", "SyncRoot", "Keys", "Values"
};
/// <summary>
/// Checks if a property name is a collection-internal property that should be skipped
/// </summary>
public static bool IsCollectionInternalProperty(string propertyName)
{
return CollectionInternalProperties.Contains(propertyName);
}
}

View File

@ -1,111 +1,7 @@
syntax = "proto3";
option csharp_namespace = "Svrnty.Sample.Grpc";
option csharp_namespace = "Generated.Grpc";
package cqrs;
// Command service for CQRS operations
service CommandService {
// AddUserCommand operation
rpc AddUser (AddUserCommandRequest) returns (AddUserCommandResponse);
// RemoveUserCommand operation
rpc RemoveUser (RemoveUserCommandRequest) returns (RemoveUserCommandResponse);
}
// Query service for CQRS operations
service QueryService {
// FetchUserQuery operation
rpc FetchUser (FetchUserQueryRequest) returns (FetchUserQueryResponse);
}
// DynamicQuery service for CQRS operations
service DynamicQueryService {
// Dynamic query for User
rpc QueryUsers (DynamicQueryUsersRequest) returns (DynamicQueryUsersResponse);
}
// Request message for AddUserCommand
message AddUserCommandRequest {
string name = 1;
string email = 2;
int32 age = 3;
}
// Response message for AddUserCommand
message AddUserCommandResponse {
int32 result = 1;
}
// Request message for RemoveUserCommand
message RemoveUserCommandRequest {
int32 user_id = 1;
}
// Response message for RemoveUserCommand
message RemoveUserCommandResponse {
}
// Request message for FetchUserQuery
message FetchUserQueryRequest {
int32 user_id = 1;
}
// Response message for FetchUserQuery
message FetchUserQueryResponse {
User result = 1;
}
// User entity
message User {
int32 id = 1;
string name = 2;
string email = 3;
}
// Dynamic query filter with AND/OR support
message DynamicQueryFilter {
string path = 1;
int32 type = 2; // PoweredSoft.DynamicQuery.Core.FilterType
string value = 3;
repeated DynamicQueryFilter and = 4;
repeated DynamicQueryFilter or = 5;
}
// Dynamic query sort
message DynamicQuerySort {
string path = 1;
bool ascending = 2;
}
// Dynamic query group
message DynamicQueryGroup {
string path = 1;
}
// Dynamic query aggregate
message DynamicQueryAggregate {
string path = 1;
int32 type = 2; // PoweredSoft.DynamicQuery.Core.AggregateType
}
// Dynamic query request for User
message DynamicQueryUsersRequest {
int32 page = 1;
int32 page_size = 2;
repeated DynamicQueryFilter filters = 3;
repeated DynamicQuerySort sorts = 4;
repeated DynamicQueryGroup groups = 5;
repeated DynamicQueryAggregate aggregates = 6;
}
// Dynamic query response for User
message DynamicQueryUsersResponse {
repeated User data = 1;
int64 total_records = 2;
int32 number_of_pages = 3;
}
// Placeholder proto file - will be regenerated on next build