dotnet-cqrs/Svrnty.CQRS.Grpc.Generators/GrpcGenerator.cs
Jean-Philippe Brule 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

2838 lines
144 KiB
C#

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Svrnty.CQRS.Grpc.Generators.Helpers;
using Svrnty.CQRS.Grpc.Generators.Models;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Svrnty.CQRS.Grpc.Generators
{
[Generator]
public class GrpcGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Find all types that might be commands or queries from source
var typeDeclarations = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: static (node, _) => node is TypeDeclarationSyntax,
transform: static (ctx, _) => GetTypeSymbol(ctx))
.Where(static symbol => symbol is not null);
// Combine with compilation
var compilationAndTypes = context.CompilationProvider.Combine(typeDeclarations.Collect());
// Register source output
context.RegisterSourceOutput(compilationAndTypes, static (spc, source) => Execute(source.Left, source.Right!, spc));
}
private static INamedTypeSymbol? GetTypeSymbol(GeneratorSyntaxContext context)
{
var typeDeclaration = (TypeDeclarationSyntax)context.Node;
var symbol = context.SemanticModel.GetDeclaredSymbol(typeDeclaration);
return symbol as INamedTypeSymbol;
}
/// <summary>
/// Collects all types from the compilation and all referenced assemblies
/// </summary>
private static IEnumerable<INamedTypeSymbol> GetAllTypesFromCompilation(Compilation compilation)
{
var types = new List<INamedTypeSymbol>();
// Get types from the current assembly
CollectTypesFromNamespace(compilation.Assembly.GlobalNamespace, types);
// Get types from all referenced assemblies
foreach (var reference in compilation.References)
{
var assemblySymbol = compilation.GetAssemblyOrModuleSymbol(reference) as IAssemblySymbol;
if (assemblySymbol != null)
{
CollectTypesFromNamespace(assemblySymbol.GlobalNamespace, types);
}
}
return types;
}
private static void CollectTypesFromNamespace(INamespaceSymbol ns, List<INamedTypeSymbol> types)
{
foreach (var type in ns.GetTypeMembers())
{
types.Add(type);
// Also collect nested types
CollectNestedTypes(type, types);
}
foreach (var nestedNs in ns.GetNamespaceMembers())
{
CollectTypesFromNamespace(nestedNs, types);
}
}
private static void CollectNestedTypes(INamedTypeSymbol type, List<INamedTypeSymbol> types)
{
foreach (var nestedType in type.GetTypeMembers())
{
types.Add(nestedType);
CollectNestedTypes(nestedType, types);
}
}
private static void Execute(Compilation compilation, IEnumerable<INamedTypeSymbol?> sourceTypes, SourceProductionContext context)
{
// Get the expected namespace for proto-generated types
var rootNamespace = compilation.AssemblyName ?? "Generated";
var grpcNamespace = $"{rootNamespace}.Grpc";
// Check if proto types are available (from Grpc.Tools compilation of .proto file)
// If not, skip generation - this happens on first build before proto file is compiled
var commandServiceBase = compilation.GetTypeByMetadataName($"{grpcNamespace}.CommandService+CommandServiceBase");
var queryServiceBase = compilation.GetTypeByMetadataName($"{grpcNamespace}.QueryService+QueryServiceBase");
var dynamicQueryServiceBase = compilation.GetTypeByMetadataName($"{grpcNamespace}.DynamicQueryService+DynamicQueryServiceBase");
var notificationServiceBase = compilation.GetTypeByMetadataName($"{grpcNamespace}.NotificationService+NotificationServiceBase");
// If none of the service bases exist, the proto hasn't been compiled yet - skip generation
if (commandServiceBase == null && queryServiceBase == null && dynamicQueryServiceBase == null && notificationServiceBase == null)
{
// Report diagnostic for first build
var descriptor = new DiagnosticDescriptor(
"CQRSGRPC003",
"Proto types not yet available",
"gRPC service implementations will be generated on second build after proto file is compiled",
"Svrnty.CQRS.Grpc",
DiagnosticSeverity.Info,
isEnabledByDefault: true);
context.ReportDiagnostic(Diagnostic.Create(descriptor, Location.None));
return;
}
var grpcIgnoreAttribute = compilation.GetTypeByMetadataName("Svrnty.CQRS.Grpc.Abstractions.Attributes.GrpcIgnoreAttribute");
var commandHandlerInterface = compilation.GetTypeByMetadataName("Svrnty.CQRS.Abstractions.ICommandHandler`1");
var commandHandlerWithResultInterface = compilation.GetTypeByMetadataName("Svrnty.CQRS.Abstractions.ICommandHandler`2");
var queryHandlerInterface = compilation.GetTypeByMetadataName("Svrnty.CQRS.Abstractions.IQueryHandler`2");
var dynamicQueryInterface2 = compilation.GetTypeByMetadataName("Svrnty.CQRS.DynamicQuery.Abstractions.IDynamicQuery`2");
var dynamicQueryInterface3 = compilation.GetTypeByMetadataName("Svrnty.CQRS.DynamicQuery.Abstractions.IDynamicQuery`3");
var queryableProviderInterface = compilation.GetTypeByMetadataName("Svrnty.CQRS.DynamicQuery.Abstractions.IQueryableProvider`1");
if (commandHandlerInterface == null || queryHandlerInterface == null)
{
return; // Handler interfaces not found
}
var commandMap = new Dictionary<INamedTypeSymbol, INamedTypeSymbol?>(SymbolEqualityComparer.Default); // Command -> Result type (null if no result)
var queryMap = new Dictionary<INamedTypeSymbol, INamedTypeSymbol>(SymbolEqualityComparer.Default); // Query -> Result type
var dynamicQueryMap = new List<(INamedTypeSymbol SourceType, INamedTypeSymbol DestinationType, INamedTypeSymbol? ParamsType)>(); // List of (Source, Destination, Params?)
// Get all types from the compilation and referenced assemblies
var allTypes = GetAllTypesFromCompilation(compilation);
// Find all command and query types by looking at handler implementations
foreach (var typeSymbol in allTypes)
{
if (typeSymbol.IsAbstract || typeSymbol.IsStatic)
continue;
// Check if this type implements ICommandHandler<T> or ICommandHandler<T, TResult>
foreach (var iface in typeSymbol.AllInterfaces)
{
if (iface.IsGenericType)
{
// Check for ICommandHandler<TCommand>
if (SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, commandHandlerInterface) && iface.TypeArguments.Length == 1)
{
var commandType = iface.TypeArguments[0] as INamedTypeSymbol;
if (commandType != null && !commandMap.ContainsKey(commandType))
commandMap[commandType] = null; // No result type
}
// Check for ICommandHandler<TCommand, TResult>
else if (SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, commandHandlerWithResultInterface) && iface.TypeArguments.Length == 2)
{
var commandType = iface.TypeArguments[0] as INamedTypeSymbol;
var resultType = iface.TypeArguments[1] as INamedTypeSymbol;
if (commandType != null && resultType != null)
commandMap[commandType] = resultType;
}
// Check for IQueryHandler<TQuery, TResult>
else if (SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, queryHandlerInterface) && iface.TypeArguments.Length == 2)
{
var queryType = iface.TypeArguments[0] as INamedTypeSymbol;
var resultType = iface.TypeArguments[1] as INamedTypeSymbol;
if (queryType != null && resultType != null)
{
// Check if this is a dynamic query handler
if (queryType.IsGenericType &&
(SymbolEqualityComparer.Default.Equals(queryType.OriginalDefinition, dynamicQueryInterface2) ||
SymbolEqualityComparer.Default.Equals(queryType.OriginalDefinition, dynamicQueryInterface3)))
{
// Extract source, destination, and optional params types
var sourceType = queryType.TypeArguments[0] as INamedTypeSymbol;
var destinationType = queryType.TypeArguments[1] as INamedTypeSymbol;
INamedTypeSymbol? paramsType = null;
if (queryType.TypeArguments.Length == 3)
{
paramsType = queryType.TypeArguments[2] as INamedTypeSymbol;
}
if (sourceType != null && destinationType != null)
{
// Check if already added (avoid duplicates)
var exists = dynamicQueryMap.Any(dq =>
SymbolEqualityComparer.Default.Equals(dq.SourceType, sourceType) &&
SymbolEqualityComparer.Default.Equals(dq.DestinationType, destinationType) &&
(dq.ParamsType == null && paramsType == null ||
dq.ParamsType != null && paramsType != null && SymbolEqualityComparer.Default.Equals(dq.ParamsType, paramsType)));
if (!exists)
{
dynamicQueryMap.Add((sourceType, destinationType, paramsType));
}
}
}
else
{
queryMap[queryType] = resultType;
}
}
}
}
}
// Check if this type implements IQueryableProvider<T> - this indicates a dynamic query
if (queryableProviderInterface != null)
{
foreach (var iface in typeSymbol.AllInterfaces)
{
if (iface.IsGenericType && SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, queryableProviderInterface))
{
// Extract source type from IQueryableProvider<TSource>
var sourceType = iface.TypeArguments[0] as INamedTypeSymbol;
if (sourceType != null)
{
// For IQueryableProvider, we assume TSource = TDestination (no params)
var exists = dynamicQueryMap.Any(dq =>
SymbolEqualityComparer.Default.Equals(dq.SourceType, sourceType) &&
SymbolEqualityComparer.Default.Equals(dq.DestinationType, sourceType) &&
dq.ParamsType == null);
if (!exists)
{
dynamicQueryMap.Add((sourceType, sourceType, null));
}
}
}
}
}
}
var commands = new List<CommandInfo>();
var queries = new List<QueryInfo>();
// Process discovered command types
foreach (var kvp in commandMap)
{
var commandType = kvp.Key;
var resultType = kvp.Value;
// Skip if marked with [GrpcIgnore]
if (HasGrpcIgnoreAttribute(commandType))
continue;
var commandInfo = ExtractCommandInfo(commandType, resultType);
if (commandInfo != null)
commands.Add(commandInfo);
}
// Process discovered query types
foreach (var kvp in queryMap)
{
var queryType = kvp.Key;
var resultType = kvp.Value;
// Skip if marked with [GrpcIgnore]
if (HasGrpcIgnoreAttribute(queryType))
continue;
var queryInfo = ExtractQueryInfo(queryType, resultType);
if (queryInfo != null)
queries.Add(queryInfo);
}
// Process discovered dynamic query types
var dynamicQueries = new List<DynamicQueryInfo>();
foreach (var (sourceType, destinationType, paramsType) in dynamicQueryMap)
{
var dynamicQueryInfo = ExtractDynamicQueryInfo(sourceType, destinationType, paramsType);
if (dynamicQueryInfo != null)
dynamicQueries.Add(dynamicQueryInfo);
}
// Process discovered notification types (marked with [StreamingNotification])
var notifications = DiscoverNotifications(allTypes, compilation);
// Generate services if we found any commands, queries, dynamic queries, or notifications
if (commands.Any() || queries.Any() || dynamicQueries.Any() || notifications.Any())
{
GenerateProtoAndServices(context, commands, queries, dynamicQueries, notifications, compilation);
}
}
private static bool HasAttribute(INamedTypeSymbol typeSymbol, INamedTypeSymbol attributeSymbol)
{
return typeSymbol.GetAttributes().Any(attr =>
SymbolEqualityComparer.Default.Equals(attr.AttributeClass, attributeSymbol));
}
private static bool HasGrpcIgnoreAttribute(INamedTypeSymbol typeSymbol)
{
return typeSymbol.GetAttributes().Any(attr =>
attr.AttributeClass?.Name == "GrpcIgnoreAttribute");
}
private static bool ImplementsInterface(INamedTypeSymbol typeSymbol, INamedTypeSymbol? interfaceSymbol)
{
if (interfaceSymbol == null)
return false;
return typeSymbol.AllInterfaces.Any(i =>
SymbolEqualityComparer.Default.Equals(i, interfaceSymbol));
}
private static bool ImplementsGenericInterface(INamedTypeSymbol typeSymbol, INamedTypeSymbol? genericInterfaceSymbol)
{
if (genericInterfaceSymbol == null)
return false;
return typeSymbol.AllInterfaces.Any(i =>
i.IsGenericType && SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, genericInterfaceSymbol));
}
private static CommandInfo? ExtractCommandInfo(INamedTypeSymbol commandType, INamedTypeSymbol? resultType)
{
var commandInfo = new CommandInfo
{
Name = commandType.Name,
FullyQualifiedName = commandType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
Namespace = commandType.ContainingNamespace.ToDisplayString(),
Properties = new List<PropertyInfo>()
};
// Set result type if provided
if (resultType != null)
{
commandInfo.ResultType = resultType.Name;
commandInfo.ResultFullyQualifiedName = resultType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// Use fully qualified names to avoid ambiguity with proto-generated types
var commandTypeFullyQualified = commandType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var resultTypeFullyQualified = resultType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
commandInfo.HandlerInterfaceName = $"ICommandHandler<{commandTypeFullyQualified}, {resultTypeFullyQualified}>";
}
else
{
var commandTypeFullyQualified = commandType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
commandInfo.HandlerInterfaceName = $"ICommandHandler<{commandTypeFullyQualified}>";
}
// Extract properties
var properties = commandType.GetMembers().OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic)
.ToList();
int fieldNumber = 1;
foreach (var property in properties)
{
var propertyType = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var protoType = ProtoTypeMapper.MapToProtoType(propertyType, out bool isRepeated, out bool isOptional);
var propInfo = new PropertyInfo
{
Name = property.Name,
ProtoPropertyName = ToPascalCaseHelper(ToSnakeCaseHelper(property.Name)),
Type = propertyType,
FullyQualifiedType = propertyType,
ProtoType = protoType,
FieldNumber = fieldNumber++,
IsComplexType = IsUserDefinedComplexType(property.Type),
// New type metadata fields
IsNullable = IsNullableType(property.Type),
IsEnum = IsEnumType(property.Type),
IsDecimal = IsDecimalType(property.Type),
IsDateTime = IsDateTimeType(property.Type),
IsList = IsListOrCollection(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);
// If element is complex, extract nested properties
if (propInfo.IsElementComplexType)
{
var unwrappedElement = UnwrapNullableType(elementType);
if (unwrappedElement 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);
}
}
commandInfo.Properties.Add(propInfo);
}
return commandInfo;
}
private static bool IsUserDefinedComplexType(ITypeSymbol type)
{
if (type == null)
return false;
// Unwrap nullable first
var unwrapped = UnwrapNullableType(type);
if (unwrapped.TypeKind != TypeKind.Class && unwrapped.TypeKind != TypeKind.Struct)
return false;
var fullName = unwrapped.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// Exclude system types and primitives
if (fullName.StartsWith("global::System."))
return false;
if (IsPrimitiveType(fullName))
return false;
return true;
}
private static ITypeSymbol UnwrapNullableType(ITypeSymbol type)
{
// Handle Nullable<T> (value type nullability)
if (type is INamedTypeSymbol namedType &&
namedType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T &&
namedType.TypeArguments.Length == 1)
{
return namedType.TypeArguments[0];
}
return type;
}
private static bool IsNullableType(ITypeSymbol type)
{
// Check for Nullable<T> (value type nullability)
if (type is INamedTypeSymbol namedType &&
namedType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T)
{
return true;
}
// Check for reference type nullability (C# 8.0+)
if (type.NullableAnnotation == NullableAnnotation.Annotated)
{
return true;
}
return false;
}
private static bool IsDecimalType(ITypeSymbol type)
{
var unwrapped = UnwrapNullableType(type);
return unwrapped.SpecialType == SpecialType.System_Decimal;
}
private static bool IsDateTimeType(ITypeSymbol type)
{
var unwrapped = UnwrapNullableType(type);
return unwrapped.SpecialType == SpecialType.System_DateTime;
}
private static bool IsEnumType(ITypeSymbol type)
{
var unwrapped = UnwrapNullableType(type);
return unwrapped.TypeKind == TypeKind.Enum;
}
private static bool IsListOrCollection(ITypeSymbol type)
{
if (type is IArrayTypeSymbol)
return true;
if (type is INamedTypeSymbol namedType && namedType.IsGenericType)
{
var typeName = namedType.OriginalDefinition.ToDisplayString();
return typeName.StartsWith("System.Collections.Generic.List<") ||
typeName.StartsWith("System.Collections.Generic.IList<") ||
typeName.StartsWith("System.Collections.Generic.ICollection<") ||
typeName.StartsWith("System.Collections.Generic.IEnumerable<");
}
return false;
}
private static ITypeSymbol? GetListElementType(ITypeSymbol type)
{
if (type is IArrayTypeSymbol arrayType)
return arrayType.ElementType;
if (type is INamedTypeSymbol namedType && namedType.IsGenericType && namedType.TypeArguments.Length > 0)
{
return namedType.TypeArguments[0];
}
return null;
}
private static void ExtractNestedProperties(INamedTypeSymbol type, List<PropertyInfo> nestedProperties)
{
var properties = type.GetMembers().OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic)
.ToList();
foreach (var property in properties)
{
var propertyType = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var propInfo = new PropertyInfo
{
Name = property.Name,
ProtoPropertyName = ToPascalCaseHelper(ToSnakeCaseHelper(property.Name)),
Type = propertyType,
FullyQualifiedType = propertyType,
ProtoType = string.Empty,
FieldNumber = 0,
IsComplexType = IsUserDefinedComplexType(property.Type),
};
// Recursively extract nested properties for complex types
if (propInfo.IsComplexType && property.Type is INamedTypeSymbol namedType)
{
ExtractNestedProperties(namedType, propInfo.NestedProperties);
}
nestedProperties.Add(propInfo);
}
}
private static void ExtractNestedPropertiesWithTypeInfo(INamedTypeSymbol type, List<PropertyInfo> nestedProperties)
{
var properties = type.GetMembers().OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic)
.ToList();
foreach (var property in properties)
{
var propertyType = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var propInfo = new PropertyInfo
{
Name = property.Name,
ProtoPropertyName = ToPascalCaseHelper(ToSnakeCaseHelper(property.Name)),
Type = propertyType,
FullyQualifiedType = propertyType,
ProtoType = string.Empty,
FieldNumber = 0,
IsComplexType = IsUserDefinedComplexType(property.Type),
// Type metadata
IsNullable = IsNullableType(property.Type),
IsEnum = IsEnumType(property.Type),
IsDecimal = IsDecimalType(property.Type),
IsDateTime = IsDateTimeType(property.Type),
IsList = IsListOrCollection(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);
}
}
// Recursively extract nested properties for complex types
else if (propInfo.IsComplexType)
{
var unwrapped = UnwrapNullableType(property.Type);
if (unwrapped is INamedTypeSymbol namedType)
{
ExtractNestedPropertiesWithTypeInfo(namedType, propInfo.NestedProperties);
}
}
nestedProperties.Add(propInfo);
}
}
private static void GenerateNestedPropertyMapping(StringBuilder sb, List<PropertyInfo> properties, string sourcePrefix, string indent)
{
foreach (var prop in properties)
{
// Use ProtoPropertyName to access the proto source property
if (prop.IsComplexType)
{
// Generate nested object mapping
sb.AppendLine($"{indent}{prop.Name} = {sourcePrefix}.{prop.ProtoPropertyName} != null ? new {prop.FullyQualifiedType}");
sb.AppendLine($"{indent}{{");
GenerateNestedPropertyMapping(sb, prop.NestedProperties, $"{sourcePrefix}.{prop.ProtoPropertyName}", indent + " ");
sb.AppendLine($"{indent}}} : null!,");
}
else
{
sb.AppendLine($"{indent}{prop.Name} = {sourcePrefix}.{prop.ProtoPropertyName},");
}
}
}
private static string GeneratePropertyAssignment(PropertyInfo prop, string requestVar, string indent)
{
// Use ProtoPropertyName to access the proto request property
var source = $"{requestVar}.{prop.ProtoPropertyName}";
// Handle lists
if (prop.IsList)
{
if (prop.IsElementComplexType && prop.ElementNestedProperties != null && prop.ElementNestedProperties.Any())
{
// Complex list: map each element
return GenerateComplexListMapping(prop, source, indent);
}
else
{
// Primitive list: just ToList()
return $"{indent}{prop.Name} = {source}?.ToList(),";
}
}
// Handle enums (proto int32 -> C# enum)
if (prop.IsEnum)
{
return $"{indent}{prop.Name} = ({prop.FullyQualifiedType}){source},";
}
// Handle decimals (proto string -> C# decimal)
if (prop.IsDecimal)
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = string.IsNullOrEmpty({source}) ? null : decimal.Parse({source}, System.Globalization.CultureInfo.InvariantCulture),";
}
else
{
return $"{indent}{prop.Name} = decimal.Parse({source}, System.Globalization.CultureInfo.InvariantCulture),";
}
}
// 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 complex types (single objects)
if (prop.IsComplexType)
{
return GenerateComplexObjectMapping(prop, source, indent);
}
// Default: direct assignment
return $"{indent}{prop.Name} = {source},";
}
private static string GenerateComplexListMapping(PropertyInfo prop, string source, string indent)
{
var sb = new StringBuilder();
sb.AppendLine($"{indent}{prop.Name} = {source}?.Select(x => new {prop.ElementType}");
sb.AppendLine($"{indent}{{");
foreach (var nestedProp in prop.ElementNestedProperties!)
{
// Use ProtoPropertyName for accessing proto source properties
var nestedAssignment = GenerateNestedPropertyAssignment(nestedProp, "x", indent + " ");
sb.AppendLine(nestedAssignment);
}
sb.Append($"{indent}}}).ToList(),");
return sb.ToString();
}
private static string GenerateComplexObjectMapping(PropertyInfo prop, string source, string indent)
{
var sb = new StringBuilder();
sb.AppendLine($"{indent}{prop.Name} = {source} != null ? new {prop.FullyQualifiedType}");
sb.AppendLine($"{indent}{{");
foreach (var nestedProp in prop.NestedProperties)
{
var nestedAssignment = GenerateNestedPropertyAssignment(nestedProp, source, indent + " ");
sb.AppendLine(nestedAssignment);
}
sb.Append($"{indent}}} : null!,");
return sb.ToString();
}
private static string GenerateNestedPropertyAssignment(PropertyInfo prop, string sourceVar, string indent)
{
// Use ProtoPropertyName to access the proto source property
var source = $"{sourceVar}.{prop.ProtoPropertyName}";
// Handle enums
if (prop.IsEnum)
{
return $"{indent}{prop.Name} = ({prop.FullyQualifiedType}){source},";
}
// Handle decimals
if (prop.IsDecimal)
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = string.IsNullOrEmpty({source}) ? null : decimal.Parse({source}, System.Globalization.CultureInfo.InvariantCulture),";
}
else
{
return $"{indent}{prop.Name} = decimal.Parse({source}, System.Globalization.CultureInfo.InvariantCulture),";
}
}
// Handle lists
if (prop.IsList)
{
return $"{indent}{prop.Name} = {source}?.ToList(),";
}
// Handle complex types
if (prop.IsComplexType && prop.NestedProperties.Any())
{
return GenerateComplexObjectMapping(prop, source, indent);
}
// Default: direct assignment
return $"{indent}{prop.Name} = {source},";
}
private static QueryInfo? ExtractQueryInfo(INamedTypeSymbol queryType, INamedTypeSymbol resultType)
{
var queryInfo = new QueryInfo
{
Name = queryType.Name,
FullyQualifiedName = queryType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
Namespace = queryType.ContainingNamespace.ToDisplayString(),
Properties = new List<PropertyInfo>()
};
// Set result type
queryInfo.ResultType = resultType.Name;
queryInfo.ResultFullyQualifiedName = resultType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// Use fully qualified names to avoid ambiguity with proto-generated types
var queryTypeFullyQualified = queryType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var resultTypeFullyQualified = resultType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
queryInfo.HandlerInterfaceName = $"IQueryHandler<{queryTypeFullyQualified}, {resultTypeFullyQualified}>";
// Check if result type is primitive
var resultTypeString = resultType.ToDisplayString();
queryInfo.IsResultPrimitiveType = IsPrimitiveType(resultTypeString);
// 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("?"))
{
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,
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
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 with type metadata for request mapping
var properties = queryType.GetMembers().OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic)
.ToList();
int fieldNumber = 1;
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++,
IsEnum = isEnum,
IsDecimal = isDecimal,
IsDateTime = isDateTime,
IsNullable = isRequestPropNullable
});
}
return queryInfo;
}
private static DynamicQueryInfo? ExtractDynamicQueryInfo(INamedTypeSymbol sourceType, INamedTypeSymbol destinationType, INamedTypeSymbol? paramsType)
{
var dynamicQueryInfo = new DynamicQueryInfo
{
SourceType = sourceType.Name,
SourceTypeFullyQualified = sourceType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
DestinationType = destinationType.Name,
DestinationTypeFullyQualified = destinationType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
HasParams = paramsType != null
};
if (paramsType != null)
{
dynamicQueryInfo.ParamsType = paramsType.Name;
dynamicQueryInfo.ParamsTypeFullyQualified = paramsType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
}
// Pluralize destination type for naming (e.g., User -> Users)
dynamicQueryInfo.Name = Pluralize(destinationType.Name);
// Build interface names
if (paramsType != null)
{
dynamicQueryInfo.QueryInterfaceName = $"IDynamicQuery<{dynamicQueryInfo.SourceTypeFullyQualified}, {dynamicQueryInfo.DestinationTypeFullyQualified}, {dynamicQueryInfo.ParamsTypeFullyQualified}>";
dynamicQueryInfo.HandlerInterfaceName = $"IQueryHandler<{dynamicQueryInfo.QueryInterfaceName}, IQueryExecutionResult<{dynamicQueryInfo.DestinationTypeFullyQualified}>>";
}
else
{
dynamicQueryInfo.QueryInterfaceName = $"IDynamicQuery<{dynamicQueryInfo.SourceTypeFullyQualified}, {dynamicQueryInfo.DestinationTypeFullyQualified}>";
dynamicQueryInfo.HandlerInterfaceName = $"IQueryHandler<{dynamicQueryInfo.QueryInterfaceName}, IQueryExecutionResult<{dynamicQueryInfo.DestinationTypeFullyQualified}>>";
}
return dynamicQueryInfo;
}
private static string Pluralize(string word)
{
// Simple pluralization logic - can be enhanced with Pluralize.NET if needed
if (word.EndsWith("y") && word.Length > 1 && !"aeiou".Contains(word[word.Length - 2]))
return word.Substring(0, word.Length - 1) + "ies";
if (word.EndsWith("s") || word.EndsWith("x") || word.EndsWith("z") || word.EndsWith("ch") || word.EndsWith("sh"))
return word + "es";
return word + "s";
}
private static void GenerateProtoAndServices(SourceProductionContext context, List<CommandInfo> commands, List<QueryInfo> queries, List<DynamicQueryInfo> dynamicQueries, List<NotificationInfo> notifications, Compilation compilation)
{
// Get root namespace from compilation
var rootNamespace = compilation.AssemblyName ?? "Application";
// Generate service implementations for commands
if (commands.Any())
{
var commandService = GenerateCommandServiceImpl(commands, rootNamespace);
context.AddSource("CommandServiceImpl.g.cs", commandService);
}
// Generate service implementations for queries
if (queries.Any())
{
var queryService = GenerateQueryServiceImpl(queries, rootNamespace);
context.AddSource("QueryServiceImpl.g.cs", queryService);
}
// Generate service implementations for dynamic queries
if (dynamicQueries.Any())
{
var dynamicQueryService = GenerateDynamicQueryServiceImpl(dynamicQueries, rootNamespace);
context.AddSource("DynamicQueryServiceImpl.g.cs", dynamicQueryService);
}
// Generate service implementations for notifications (streaming)
if (notifications.Any())
{
var notificationService = GenerateNotificationServiceImpl(notifications, rootNamespace);
context.AddSource("NotificationServiceImpl.g.cs", notificationService);
}
// Generate registration extensions
var registrationExtensions = GenerateRegistrationExtensions(commands.Any(), queries.Any(), dynamicQueries.Any(), notifications.Any(), rootNamespace);
context.AddSource("GrpcServiceRegistration.g.cs", registrationExtensions);
}
private static string GenerateCommandMessages(List<CommandInfo> commands, string rootNamespace)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");
sb.AppendLine("#nullable enable");
sb.AppendLine("using System.Runtime.Serialization;");
sb.AppendLine("using ProtoBuf;");
sb.AppendLine();
sb.AppendLine($"namespace {rootNamespace}.Grpc.Messages");
sb.AppendLine("{");
foreach (var command in commands)
{
// Generate command DTO
sb.AppendLine(" [ProtoContract]");
sb.AppendLine(" [DataContract]");
sb.AppendLine($" public sealed class {command.Name}Dto");
sb.AppendLine(" {");
foreach (var prop in command.Properties)
{
sb.AppendLine($" [ProtoMember({prop.FieldNumber})]");
sb.AppendLine(" [DataMember(Order = " + prop.FieldNumber + ")]");
sb.AppendLine($" public {prop.Type} {prop.Name} {{ get; set; }}");
sb.AppendLine();
}
sb.AppendLine(" }");
sb.AppendLine();
// Generate result DTO if command has a result
if (command.HasResult)
{
sb.AppendLine(" [ProtoContract]");
sb.AppendLine(" [DataContract]");
sb.AppendLine($" public sealed class {command.Name}ResultDto");
sb.AppendLine(" {");
sb.AppendLine(" [ProtoMember(1)]");
sb.AppendLine(" [DataMember(Order = 1)]");
sb.AppendLine($" public {command.ResultFullyQualifiedName} Result {{ get; set; }}");
sb.AppendLine(" }");
sb.AppendLine();
}
}
sb.AppendLine("}");
return sb.ToString();
}
private static string GenerateQueryMessages(List<QueryInfo> queries, string rootNamespace)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");
sb.AppendLine("#nullable enable");
sb.AppendLine("using System.Runtime.Serialization;");
sb.AppendLine("using ProtoBuf;");
sb.AppendLine();
sb.AppendLine($"namespace {rootNamespace}.Grpc.Messages");
sb.AppendLine("{");
foreach (var query in queries)
{
// Generate query DTO
sb.AppendLine(" [ProtoContract]");
sb.AppendLine(" [DataContract]");
sb.AppendLine($" public sealed class {query.Name}Dto");
sb.AppendLine(" {");
foreach (var prop in query.Properties)
{
sb.AppendLine($" [ProtoMember({prop.FieldNumber})]");
sb.AppendLine(" [DataMember(Order = " + prop.FieldNumber + ")]");
sb.AppendLine($" public {prop.Type} {prop.Name} {{ get; set; }}");
sb.AppendLine();
}
sb.AppendLine(" }");
sb.AppendLine();
// Generate result DTO
sb.AppendLine(" [ProtoContract]");
sb.AppendLine(" [DataContract]");
sb.AppendLine($" public sealed class {query.Name}ResultDto");
sb.AppendLine(" {");
sb.AppendLine(" [ProtoMember(1)]");
sb.AppendLine(" [DataMember(Order = 1)]");
sb.AppendLine($" public {query.ResultFullyQualifiedName} Result {{ get; set; }}");
sb.AppendLine(" }");
sb.AppendLine();
}
sb.AppendLine("}");
return sb.ToString();
}
private static string GenerateCommandService(List<CommandInfo> commands, string rootNamespace)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");
sb.AppendLine("#nullable enable");
sb.AppendLine("using System.ServiceModel;");
sb.AppendLine("using System.Threading;");
sb.AppendLine("using System.Threading.Tasks;");
sb.AppendLine("using Microsoft.Extensions.DependencyInjection;");
sb.AppendLine($"using {rootNamespace}.Grpc.Messages;");
sb.AppendLine("using Svrnty.CQRS.Abstractions;");
sb.AppendLine("using ProtoBuf.Grpc;");
sb.AppendLine();
// Generate service interface
sb.AppendLine($"namespace {rootNamespace}.Grpc.Services");
sb.AppendLine("{");
sb.AppendLine(" [ServiceContract]");
sb.AppendLine(" public interface ICommandService");
sb.AppendLine(" {");
foreach (var command in commands)
{
if (command.HasResult)
{
sb.AppendLine($" [OperationContract]");
sb.AppendLine($" Task<{command.Name}ResultDto> Execute{command.Name}Async({command.Name}Dto request, CallContext context = default);");
}
else
{
sb.AppendLine($" [OperationContract]");
sb.AppendLine($" Task Execute{command.Name}Async({command.Name}Dto request, CallContext context = default);");
}
sb.AppendLine();
}
sb.AppendLine(" }");
sb.AppendLine();
// Generate service implementation
sb.AppendLine(" public sealed class CommandService : ICommandService");
sb.AppendLine(" {");
sb.AppendLine(" private readonly IServiceProvider _serviceProvider;");
sb.AppendLine();
sb.AppendLine(" public CommandService(IServiceProvider serviceProvider)");
sb.AppendLine(" {");
sb.AppendLine(" _serviceProvider = serviceProvider;");
sb.AppendLine(" }");
sb.AppendLine();
foreach (var command in commands)
{
if (command.HasResult)
{
sb.AppendLine($" public async Task<{command.Name}ResultDto> Execute{command.Name}Async({command.Name}Dto request, CallContext context = default)");
sb.AppendLine(" {");
sb.AppendLine($" var handler = _serviceProvider.GetRequiredService<{command.HandlerInterfaceName}>();");
sb.AppendLine($" var command = new {command.FullyQualifiedName}");
sb.AppendLine(" {");
foreach (var prop in command.Properties)
{
sb.AppendLine($" {prop.Name} = request.{prop.Name}!,");
}
sb.AppendLine(" };");
sb.AppendLine(" var result = await handler.HandleAsync(command, context.CancellationToken);");
sb.AppendLine($" return new {command.Name}ResultDto {{ Result = result }};");
sb.AppendLine(" }");
}
else
{
sb.AppendLine($" public async Task Execute{command.Name}Async({command.Name}Dto request, CallContext context = default)");
sb.AppendLine(" {");
sb.AppendLine($" var handler = _serviceProvider.GetRequiredService<{command.HandlerInterfaceName}>();");
sb.AppendLine($" var command = new {command.FullyQualifiedName}");
sb.AppendLine(" {");
foreach (var prop in command.Properties)
{
sb.AppendLine($" {prop.Name} = request.{prop.Name}!,");
}
sb.AppendLine(" };");
sb.AppendLine(" await handler.HandleAsync(command, context.CancellationToken);");
sb.AppendLine(" }");
}
sb.AppendLine();
}
sb.AppendLine(" }");
sb.AppendLine("}");
return sb.ToString();
}
private static string GenerateQueryService(List<QueryInfo> queries, string rootNamespace)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");
sb.AppendLine("#nullable enable");
sb.AppendLine("using System.ServiceModel;");
sb.AppendLine("using System.Threading;");
sb.AppendLine("using System.Threading.Tasks;");
sb.AppendLine("using Microsoft.Extensions.DependencyInjection;");
sb.AppendLine($"using {rootNamespace}.Grpc.Messages;");
sb.AppendLine("using Svrnty.CQRS.Abstractions;");
sb.AppendLine("using ProtoBuf.Grpc;");
sb.AppendLine();
// Generate service interface
sb.AppendLine($"namespace {rootNamespace}.Grpc.Services");
sb.AppendLine("{");
sb.AppendLine(" [ServiceContract]");
sb.AppendLine(" public interface IQueryService");
sb.AppendLine(" {");
foreach (var query in queries)
{
sb.AppendLine($" [OperationContract]");
sb.AppendLine($" Task<{query.Name}ResultDto> Execute{query.Name}Async({query.Name}Dto request, CallContext context = default);");
sb.AppendLine();
}
sb.AppendLine(" }");
sb.AppendLine();
// Generate service implementation
sb.AppendLine(" public sealed class QueryService : IQueryService");
sb.AppendLine(" {");
sb.AppendLine(" private readonly IServiceProvider _serviceProvider;");
sb.AppendLine();
sb.AppendLine(" public QueryService(IServiceProvider serviceProvider)");
sb.AppendLine(" {");
sb.AppendLine(" _serviceProvider = serviceProvider;");
sb.AppendLine(" }");
sb.AppendLine();
foreach (var query in queries)
{
sb.AppendLine($" public async Task<{query.Name}ResultDto> Execute{query.Name}Async({query.Name}Dto request, CallContext context = default)");
sb.AppendLine(" {");
sb.AppendLine($" var handler = _serviceProvider.GetRequiredService<{query.HandlerInterfaceName}>();");
sb.AppendLine($" var query = new {query.FullyQualifiedName}");
sb.AppendLine(" {");
foreach (var prop in query.Properties)
{
sb.AppendLine($" {prop.Name} = request.{prop.Name}!,");
}
sb.AppendLine(" };");
sb.AppendLine(" var result = await handler.HandleAsync(query, context.CancellationToken);");
sb.AppendLine($" return new {query.Name}ResultDto {{ Result = result }};");
sb.AppendLine(" }");
sb.AppendLine();
}
sb.AppendLine(" }");
sb.AppendLine("}");
return sb.ToString();
}
private static string GenerateRegistrationExtensions(bool hasCommands, bool hasQueries, bool hasDynamicQueries, bool hasNotifications, string rootNamespace)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");
sb.AppendLine("using Microsoft.AspNetCore.Builder;");
sb.AppendLine("using Microsoft.AspNetCore.Routing;");
sb.AppendLine("using Microsoft.Extensions.DependencyInjection;");
sb.AppendLine($"using {rootNamespace}.Grpc.Services;");
if (hasNotifications)
{
sb.AppendLine("using Svrnty.CQRS.Notifications.Grpc;");
}
sb.AppendLine();
sb.AppendLine($"namespace {rootNamespace}.Grpc.Extensions");
sb.AppendLine("{");
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Auto-generated extension methods for registering and mapping gRPC services");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static class GrpcServiceRegistrationExtensions");
sb.AppendLine(" {");
if (hasCommands)
{
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Registers the auto-generated Command gRPC service");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static IServiceCollection AddGrpcCommandService(this IServiceCollection services)");
sb.AppendLine(" {");
sb.AppendLine(" services.AddGrpc();");
sb.AppendLine(" services.AddSingleton<CommandServiceImpl>();");
sb.AppendLine(" return services;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Maps the auto-generated Command gRPC service endpoints");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static IEndpointRouteBuilder MapGrpcCommands(this IEndpointRouteBuilder endpoints)");
sb.AppendLine(" {");
sb.AppendLine(" endpoints.MapGrpcService<CommandServiceImpl>();");
sb.AppendLine(" return endpoints;");
sb.AppendLine(" }");
sb.AppendLine();
}
if (hasQueries)
{
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Registers the auto-generated Query gRPC service");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static IServiceCollection AddGrpcQueryService(this IServiceCollection services)");
sb.AppendLine(" {");
sb.AppendLine(" services.AddGrpc();");
sb.AppendLine(" services.AddSingleton<QueryServiceImpl>();");
sb.AppendLine(" return services;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Maps the auto-generated Query gRPC service endpoints");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static IEndpointRouteBuilder MapGrpcQueries(this IEndpointRouteBuilder endpoints)");
sb.AppendLine(" {");
sb.AppendLine(" endpoints.MapGrpcService<QueryServiceImpl>();");
sb.AppendLine(" return endpoints;");
sb.AppendLine(" }");
sb.AppendLine();
}
if (hasDynamicQueries)
{
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Registers the auto-generated DynamicQuery gRPC service");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static IServiceCollection AddGrpcDynamicQueryService(this IServiceCollection services)");
sb.AppendLine(" {");
sb.AppendLine(" services.AddGrpc();");
sb.AppendLine(" services.AddSingleton<DynamicQueryServiceImpl>();");
sb.AppendLine(" return services;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Maps the auto-generated DynamicQuery gRPC service endpoints");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static IEndpointRouteBuilder MapGrpcDynamicQueries(this IEndpointRouteBuilder endpoints)");
sb.AppendLine(" {");
sb.AppendLine(" endpoints.MapGrpcService<DynamicQueryServiceImpl>();");
sb.AppendLine(" return endpoints;");
sb.AppendLine(" }");
sb.AppendLine();
}
if (hasNotifications)
{
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Registers the auto-generated Notification streaming gRPC service");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static IServiceCollection AddGrpcNotificationService(this IServiceCollection services)");
sb.AppendLine(" {");
sb.AppendLine(" services.AddGrpc();");
sb.AppendLine(" services.AddStreamingNotifications();");
sb.AppendLine(" services.AddSingleton<NotificationServiceImpl>();");
sb.AppendLine(" return services;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Maps the auto-generated Notification streaming gRPC service endpoints");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static IEndpointRouteBuilder MapGrpcNotifications(this IEndpointRouteBuilder endpoints)");
sb.AppendLine(" {");
sb.AppendLine(" endpoints.MapGrpcService<NotificationServiceImpl>();");
sb.AppendLine(" return endpoints;");
sb.AppendLine(" }");
sb.AppendLine();
}
if (hasCommands || hasQueries || hasDynamicQueries || hasNotifications)
{
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Registers all auto-generated gRPC services (Commands, Queries, DynamicQueries, and Notifications)");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static IServiceCollection AddGrpcCommandsAndQueries(this IServiceCollection services)");
sb.AppendLine(" {");
sb.AppendLine(" services.AddGrpc();");
sb.AppendLine(" services.AddGrpcReflection();");
if (hasCommands)
sb.AppendLine(" services.AddSingleton<CommandServiceImpl>();");
if (hasQueries)
sb.AppendLine(" services.AddSingleton<QueryServiceImpl>();");
if (hasDynamicQueries)
sb.AppendLine(" services.AddSingleton<DynamicQueryServiceImpl>();");
if (hasNotifications)
{
sb.AppendLine(" services.AddStreamingNotifications();");
sb.AppendLine(" services.AddSingleton<NotificationServiceImpl>();");
}
sb.AppendLine(" return services;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Maps all auto-generated gRPC service endpoints (Commands, Queries, DynamicQueries, and Notifications)");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static IEndpointRouteBuilder MapGrpcCommandsAndQueries(this IEndpointRouteBuilder endpoints)");
sb.AppendLine(" {");
if (hasCommands)
sb.AppendLine(" endpoints.MapGrpcService<CommandServiceImpl>();");
if (hasQueries)
sb.AppendLine(" endpoints.MapGrpcService<QueryServiceImpl>();");
if (hasDynamicQueries)
sb.AppendLine(" endpoints.MapGrpcService<DynamicQueryServiceImpl>();");
if (hasNotifications)
sb.AppendLine(" endpoints.MapGrpcService<NotificationServiceImpl>();");
sb.AppendLine(" return endpoints;");
sb.AppendLine(" }");
sb.AppendLine();
// Add configuration-based methods
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Registers gRPC services based on configuration");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static IServiceCollection AddGrpcFromConfiguration(this IServiceCollection services)");
sb.AppendLine(" {");
sb.AppendLine(" var config = services.BuildServiceProvider().GetService<Svrnty.CQRS.Configuration.CqrsConfiguration>();");
sb.AppendLine(" var grpcOptions = config?.GetConfiguration<Svrnty.CQRS.Grpc.GrpcCqrsOptions>();");
sb.AppendLine(" if (grpcOptions != null)");
sb.AppendLine(" {");
sb.AppendLine(" services.AddGrpc();");
sb.AppendLine(" if (grpcOptions.ShouldEnableReflection)");
sb.AppendLine(" services.AddGrpcReflection();");
sb.AppendLine();
if (hasCommands)
{
sb.AppendLine(" if (grpcOptions.GetShouldMapCommands())");
sb.AppendLine(" services.AddSingleton<CommandServiceImpl>();");
}
if (hasQueries)
{
sb.AppendLine(" if (grpcOptions.GetShouldMapQueries())");
sb.AppendLine(" services.AddSingleton<QueryServiceImpl>();");
}
if (hasDynamicQueries)
{
sb.AppendLine(" if (grpcOptions.GetShouldMapQueries())");
sb.AppendLine(" services.AddSingleton<DynamicQueryServiceImpl>();");
}
if (hasNotifications)
{
sb.AppendLine(" // Always register notification service if it exists");
sb.AppendLine(" services.AddStreamingNotifications();");
sb.AppendLine(" services.AddSingleton<NotificationServiceImpl>();");
}
sb.AppendLine(" }");
sb.AppendLine(" return services;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Maps gRPC service endpoints based on configuration");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static IEndpointRouteBuilder MapGrpcFromConfiguration(this IEndpointRouteBuilder endpoints)");
sb.AppendLine(" {");
sb.AppendLine(" var config = endpoints.ServiceProvider.GetService<Svrnty.CQRS.Configuration.CqrsConfiguration>();");
sb.AppendLine(" var grpcOptions = config?.GetConfiguration<Svrnty.CQRS.Grpc.GrpcCqrsOptions>();");
sb.AppendLine(" if (grpcOptions != null)");
sb.AppendLine(" {");
if (hasCommands)
{
sb.AppendLine(" if (grpcOptions.GetShouldMapCommands())");
sb.AppendLine(" endpoints.MapGrpcService<CommandServiceImpl>();");
}
if (hasQueries)
{
sb.AppendLine(" if (grpcOptions.GetShouldMapQueries())");
sb.AppendLine(" endpoints.MapGrpcService<QueryServiceImpl>();");
}
if (hasDynamicQueries)
{
sb.AppendLine(" if (grpcOptions.GetShouldMapQueries())");
sb.AppendLine(" endpoints.MapGrpcService<DynamicQueryServiceImpl>();");
}
if (hasNotifications)
{
sb.AppendLine(" // Always map notification service if it exists");
sb.AppendLine(" endpoints.MapGrpcService<NotificationServiceImpl>();");
}
sb.AppendLine();
sb.AppendLine(" if (grpcOptions.ShouldEnableReflection)");
sb.AppendLine(" endpoints.MapGrpcReflectionService();");
sb.AppendLine(" }");
sb.AppendLine(" return endpoints;");
sb.AppendLine(" }");
}
sb.AppendLine(" }");
sb.AppendLine("}");
return sb.ToString();
}
private static string ToCamelCase(string str)
{
if (string.IsNullOrEmpty(str) || char.IsLower(str[0]))
return str;
return char.ToLowerInvariant(str[0]) + str.Substring(1);
}
// New methods for standard gRPC generation
private static string GenerateCommandsProto(List<CommandInfo> commands, string rootNamespace)
{
var sb = new StringBuilder();
sb.AppendLine("syntax = \"proto3\";");
sb.AppendLine();
sb.AppendLine($"option csharp_namespace = \"{rootNamespace}.Grpc\";");
sb.AppendLine();
sb.AppendLine("package cqrs;");
sb.AppendLine();
sb.AppendLine("// Command service for CQRS operations");
sb.AppendLine("service CommandService {");
foreach (var command in commands)
{
var methodName = command.Name.Replace("Command", "");
sb.AppendLine($" // {command.Name}");
sb.AppendLine($" rpc {methodName} ({command.Name}Request) returns ({command.Name}Response);");
}
sb.AppendLine("}");
sb.AppendLine();
// Generate message types
foreach (var command in commands)
{
// Request message
sb.AppendLine($"message {command.Name}Request {{");
foreach (var prop in command.Properties)
{
sb.AppendLine($" {prop.ProtoType} {ToCamelCase(prop.Name)} = {prop.FieldNumber};");
}
sb.AppendLine("}");
sb.AppendLine();
// Response message
sb.AppendLine($"message {command.Name}Response {{");
if (command.HasResult)
{
sb.AppendLine($" {ProtoTypeMapper.MapToProtoType(command.ResultFullyQualifiedName!, out _, out _)} result = 1;");
}
sb.AppendLine("}");
sb.AppendLine();
}
return sb.ToString();
}
private static string GenerateQueriesProto(List<QueryInfo> queries, string rootNamespace)
{
var sb = new StringBuilder();
sb.AppendLine("syntax = \"proto3\";");
sb.AppendLine();
sb.AppendLine($"option csharp_namespace = \"{rootNamespace}.Grpc\";");
sb.AppendLine();
sb.AppendLine("package cqrs;");
sb.AppendLine();
sb.AppendLine("// Query service for CQRS operations");
sb.AppendLine("service QueryService {");
foreach (var query in queries)
{
var methodName = query.Name.Replace("Query", "");
sb.AppendLine($" // {query.Name}");
sb.AppendLine($" rpc {methodName} ({query.Name}Request) returns ({query.Name}Response);");
}
sb.AppendLine("}");
sb.AppendLine();
// Generate message types
foreach (var query in queries)
{
// Request message
sb.AppendLine($"message {query.Name}Request {{");
foreach (var prop in query.Properties)
{
sb.AppendLine($" {prop.ProtoType} {ToCamelCase(prop.Name)} = {prop.FieldNumber};");
}
sb.AppendLine("}");
sb.AppendLine();
// Response message
sb.AppendLine($"message {query.Name}Response {{");
sb.AppendLine($" {ProtoTypeMapper.MapToProtoType(query.ResultFullyQualifiedName, out _, out _)} result = 1;");
sb.AppendLine("}");
sb.AppendLine();
}
return sb.ToString();
}
private static string GenerateCommandServiceImpl(List<CommandInfo> commands, string rootNamespace)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");
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 FluentValidation;");
sb.AppendLine("using Google.Rpc;");
sb.AppendLine("using Google.Protobuf.WellKnownTypes;");
sb.AppendLine($"using {rootNamespace}.Grpc;");
sb.AppendLine("using Svrnty.CQRS.Abstractions;");
sb.AppendLine();
sb.AppendLine($"namespace {rootNamespace}.Grpc.Services");
sb.AppendLine("{");
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Auto-generated gRPC service implementation for Commands");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public sealed class CommandServiceImpl : CommandService.CommandServiceBase");
sb.AppendLine(" {");
sb.AppendLine(" private readonly IServiceScopeFactory _scopeFactory;");
sb.AppendLine();
sb.AppendLine(" public CommandServiceImpl(IServiceScopeFactory scopeFactory)");
sb.AppendLine(" {");
sb.AppendLine(" _scopeFactory = scopeFactory;");
sb.AppendLine(" }");
sb.AppendLine();
foreach (var command in commands)
{
var methodName = command.Name.Replace("Command", "");
var requestType = $"{command.Name}Request";
var responseType = $"{command.Name}Response";
sb.AppendLine($" public override async Task<{responseType}> {methodName}(");
sb.AppendLine($" {requestType} request,");
sb.AppendLine(" ServerCallContext context)");
sb.AppendLine(" {");
sb.AppendLine(" using var scope = _scopeFactory.CreateScope();");
sb.AppendLine(" var serviceProvider = scope.ServiceProvider;");
sb.AppendLine();
sb.AppendLine($" var command = new {command.FullyQualifiedName}");
sb.AppendLine(" {");
foreach (var prop in command.Properties)
{
var assignment = GeneratePropertyAssignment(prop, "request", " ");
sb.AppendLine(assignment);
}
sb.AppendLine(" };");
sb.AppendLine();
sb.AppendLine(" // Validate command if validator is registered");
sb.AppendLine($" var validator = serviceProvider.GetService<IValidator<{command.FullyQualifiedName}>>();");
sb.AppendLine(" if (validator != null)");
sb.AppendLine(" {");
sb.AppendLine(" var validationResult = await validator.ValidateAsync(command, context.CancellationToken);");
sb.AppendLine(" if (!validationResult.IsValid)");
sb.AppendLine(" {");
sb.AppendLine(" // Create Rich Error Model with structured field violations");
sb.AppendLine(" var badRequest = new BadRequest();");
sb.AppendLine(" foreach (var error in validationResult.Errors)");
sb.AppendLine(" {");
sb.AppendLine(" badRequest.FieldViolations.Add(new BadRequest.Types.FieldViolation");
sb.AppendLine(" {");
sb.AppendLine(" Field = error.PropertyName,");
sb.AppendLine(" Description = error.ErrorMessage");
sb.AppendLine(" });");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" var status = new Google.Rpc.Status");
sb.AppendLine(" {");
sb.AppendLine(" Code = (int)Code.InvalidArgument,");
sb.AppendLine(" Message = \"Validation failed\",");
sb.AppendLine(" Details = { Any.Pack(badRequest) }");
sb.AppendLine(" };");
sb.AppendLine();
sb.AppendLine(" throw status.ToRpcException();");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine($" var handler = serviceProvider.GetRequiredService<{command.HandlerInterfaceName}>();");
if (command.HasResult)
{
sb.AppendLine(" var result = await handler.HandleAsync(command, context.CancellationToken);");
sb.AppendLine($" return new {responseType} {{ Result = result }};");
}
else
{
sb.AppendLine(" await handler.HandleAsync(command, context.CancellationToken);");
sb.AppendLine($" return new {responseType}();");
}
sb.AppendLine(" }");
sb.AppendLine();
}
sb.AppendLine(" }");
sb.AppendLine("}");
return sb.ToString();
}
private static string GenerateQueryServiceImpl(List<QueryInfo> queries, string rootNamespace)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");
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;");
sb.AppendLine();
sb.AppendLine($"namespace {rootNamespace}.Grpc.Services");
sb.AppendLine("{");
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Auto-generated gRPC service implementation for Queries");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public sealed class QueryServiceImpl : QueryService.QueryServiceBase");
sb.AppendLine(" {");
sb.AppendLine(" private readonly IServiceScopeFactory _scopeFactory;");
sb.AppendLine();
sb.AppendLine(" public QueryServiceImpl(IServiceScopeFactory scopeFactory)");
sb.AppendLine(" {");
sb.AppendLine(" _scopeFactory = scopeFactory;");
sb.AppendLine(" }");
sb.AppendLine();
foreach (var query in queries)
{
var methodName = query.Name.Replace("Query", "");
var requestType = $"{query.Name}Request";
var responseType = $"{query.Name}Response";
sb.AppendLine($" public override async Task<{responseType}> {methodName}(");
sb.AppendLine($" {requestType} request,");
sb.AppendLine(" ServerCallContext context)");
sb.AppendLine(" {");
sb.AppendLine(" using var scope = _scopeFactory.CreateScope();");
sb.AppendLine(" var serviceProvider = scope.ServiceProvider;");
sb.AppendLine();
sb.AppendLine($" var handler = serviceProvider.GetRequiredService<{query.HandlerInterfaceName}>();");
sb.AppendLine($" var query = new {query.FullyQualifiedName}");
sb.AppendLine(" {");
foreach (var prop in query.Properties)
{
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 based on result type
if (query.IsResultPrimitiveType)
{
sb.AppendLine($" return new {responseType} {{ Result = result }};");
}
else if (query.IsResultCollection)
{
// 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(" {");
foreach (var prop in query.ResultProperties)
{
// 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(" }");
sb.AppendLine("}");
return sb.ToString();
}
private static bool IsPrimitiveType(string typeName)
{
// Check for common primitive and built-in types
var primitiveTypes = new[]
{
"int", "System.Int32",
"long", "System.Int64",
"short", "System.Int16",
"byte", "System.Byte",
"bool", "System.Boolean",
"float", "System.Single",
"double", "System.Double",
"decimal", "System.Decimal",
"string", "System.String",
"System.DateTime",
"System.DateTimeOffset",
"System.TimeSpan",
"System.Guid"
};
// 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)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");
sb.AppendLine("#nullable enable");
sb.AppendLine("using System.Collections.Generic;");
sb.AppendLine("using System.ServiceModel;");
sb.AppendLine("using System.Runtime.Serialization;");
sb.AppendLine("using ProtoBuf;");
sb.AppendLine("using ProtoBuf.Grpc;");
sb.AppendLine();
sb.AppendLine($"namespace {rootNamespace}.Grpc.DynamicQuery");
sb.AppendLine("{");
// Common message types
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Dynamic query filter with support for nested AND/OR logic");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" [ProtoContract]");
sb.AppendLine(" [DataContract]");
sb.AppendLine(" public sealed class DynamicQueryFilter");
sb.AppendLine(" {");
sb.AppendLine(" [ProtoMember(1)]");
sb.AppendLine(" [DataMember(Order = 1)]");
sb.AppendLine(" public string Path { get; set; } = string.Empty;");
sb.AppendLine();
sb.AppendLine(" [ProtoMember(2)]");
sb.AppendLine(" [DataMember(Order = 2)]");
sb.AppendLine(" public int Type { get; set; } // Maps to PoweredSoft.DynamicQuery.Core.FilterType");
sb.AppendLine();
sb.AppendLine(" [ProtoMember(3)]");
sb.AppendLine(" [DataMember(Order = 3)]");
sb.AppendLine(" public string Value { get; set; } = string.Empty;");
sb.AppendLine();
sb.AppendLine(" [ProtoMember(4)]");
sb.AppendLine(" [DataMember(Order = 4)]");
sb.AppendLine(" public List<DynamicQueryFilter>? And { get; set; }");
sb.AppendLine();
sb.AppendLine(" [ProtoMember(5)]");
sb.AppendLine(" [DataMember(Order = 5)]");
sb.AppendLine(" public List<DynamicQueryFilter>? Or { get; set; }");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Dynamic query sort");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" [ProtoContract]");
sb.AppendLine(" [DataContract]");
sb.AppendLine(" public sealed class DynamicQuerySort");
sb.AppendLine(" {");
sb.AppendLine(" [ProtoMember(1)]");
sb.AppendLine(" [DataMember(Order = 1)]");
sb.AppendLine(" public string Path { get; set; } = string.Empty;");
sb.AppendLine();
sb.AppendLine(" [ProtoMember(2)]");
sb.AppendLine(" [DataMember(Order = 2)]");
sb.AppendLine(" public bool Ascending { get; set; } = true;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Dynamic query group");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" [ProtoContract]");
sb.AppendLine(" [DataContract]");
sb.AppendLine(" public sealed class DynamicQueryGroup");
sb.AppendLine(" {");
sb.AppendLine(" [ProtoMember(1)]");
sb.AppendLine(" [DataMember(Order = 1)]");
sb.AppendLine(" public string Path { get; set; } = string.Empty;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Dynamic query aggregate");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" [ProtoContract]");
sb.AppendLine(" [DataContract]");
sb.AppendLine(" public sealed class DynamicQueryAggregate");
sb.AppendLine(" {");
sb.AppendLine(" [ProtoMember(1)]");
sb.AppendLine(" [DataMember(Order = 1)]");
sb.AppendLine(" public string Path { get; set; } = string.Empty;");
sb.AppendLine();
sb.AppendLine(" [ProtoMember(2)]");
sb.AppendLine(" [DataMember(Order = 2)]");
sb.AppendLine(" public int Type { get; set; } // Maps to PoweredSoft.DynamicQuery.Core.AggregateType");
sb.AppendLine(" }");
sb.AppendLine();
// Generate request/response messages for each dynamic query
foreach (var dynamicQuery in dynamicQueries)
{
// Request message
sb.AppendLine($" /// <summary>");
sb.AppendLine($" /// Request message for dynamic query on {dynamicQuery.Name}");
sb.AppendLine($" /// </summary>");
sb.AppendLine(" [ProtoContract]");
sb.AppendLine(" [DataContract]");
sb.AppendLine($" public sealed class DynamicQuery{dynamicQuery.Name}Request");
sb.AppendLine(" {");
sb.AppendLine(" [ProtoMember(1)]");
sb.AppendLine(" [DataMember(Order = 1)]");
sb.AppendLine(" public int Page { get; set; }");
sb.AppendLine();
sb.AppendLine(" [ProtoMember(2)]");
sb.AppendLine(" [DataMember(Order = 2)]");
sb.AppendLine(" public int PageSize { get; set; }");
sb.AppendLine();
sb.AppendLine(" [ProtoMember(3)]");
sb.AppendLine(" [DataMember(Order = 3)]");
sb.AppendLine(" public List<DynamicQueryFilter> Filters { get; set; } = new();");
sb.AppendLine();
sb.AppendLine(" [ProtoMember(4)]");
sb.AppendLine(" [DataMember(Order = 4)]");
sb.AppendLine(" public List<DynamicQuerySort> Sorts { get; set; } = new();");
sb.AppendLine();
sb.AppendLine(" [ProtoMember(5)]");
sb.AppendLine(" [DataMember(Order = 5)]");
sb.AppendLine(" public List<DynamicQueryGroup> Groups { get; set; } = new();");
sb.AppendLine();
sb.AppendLine(" [ProtoMember(6)]");
sb.AppendLine(" [DataMember(Order = 6)]");
sb.AppendLine(" public List<DynamicQueryAggregate> Aggregates { get; set; } = new();");
sb.AppendLine(" }");
sb.AppendLine();
// Response message
sb.AppendLine($" /// <summary>");
sb.AppendLine($" /// Response message for dynamic query on {dynamicQuery.Name}");
sb.AppendLine($" /// </summary>");
sb.AppendLine(" [ProtoContract]");
sb.AppendLine(" [DataContract]");
sb.AppendLine($" public sealed class DynamicQuery{dynamicQuery.Name}Response");
sb.AppendLine(" {");
sb.AppendLine(" [ProtoMember(1)]");
sb.AppendLine(" [DataMember(Order = 1)]");
sb.AppendLine($" public List<{dynamicQuery.DestinationTypeFullyQualified}> Data {{ get; set; }} = new();");
sb.AppendLine();
sb.AppendLine(" [ProtoMember(2)]");
sb.AppendLine(" [DataMember(Order = 2)]");
sb.AppendLine(" public long TotalCount { get; set; }");
sb.AppendLine();
sb.AppendLine(" [ProtoMember(3)]");
sb.AppendLine(" [DataMember(Order = 3)]");
sb.AppendLine(" public int NumberOfPages { get; set; }");
sb.AppendLine(" }");
sb.AppendLine();
}
// Generate service interface
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// gRPC service interface for DynamicQueries");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" [ServiceContract]");
sb.AppendLine(" public interface IDynamicQueryService");
sb.AppendLine(" {");
foreach (var dynamicQuery in dynamicQueries)
{
var methodName = $"Query{dynamicQuery.Name}";
sb.AppendLine($" /// <summary>");
sb.AppendLine($" /// Execute dynamic query on {dynamicQuery.Name}");
sb.AppendLine($" /// </summary>");
sb.AppendLine(" [OperationContract]");
sb.AppendLine($" System.Threading.Tasks.Task<DynamicQuery{dynamicQuery.Name}Response> {methodName}Async(DynamicQuery{dynamicQuery.Name}Request request, CallContext context = default);");
sb.AppendLine();
}
sb.AppendLine(" }");
sb.AppendLine("}");
return sb.ToString();
}
private static string GenerateDynamicQueryServiceImpl(List<DynamicQueryInfo> dynamicQueries, string rootNamespace)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");
sb.AppendLine("#nullable enable");
sb.AppendLine("using Grpc.Core;");
sb.AppendLine("using System.Threading.Tasks;");
sb.AppendLine("using System.Collections.Generic;");
sb.AppendLine("using System.Linq;");
sb.AppendLine("using Microsoft.Extensions.DependencyInjection;");
sb.AppendLine($"using {rootNamespace}.Grpc;");
sb.AppendLine("using Svrnty.CQRS.Abstractions;");
sb.AppendLine("using Svrnty.CQRS.DynamicQuery.Abstractions;");
sb.AppendLine("using PoweredSoft.DynamicQuery.Core;");
sb.AppendLine();
sb.AppendLine($"namespace {rootNamespace}.Grpc.Services");
sb.AppendLine("{");
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Auto-generated gRPC service implementation for DynamicQueries");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public sealed class DynamicQueryServiceImpl : DynamicQueryService.DynamicQueryServiceBase");
sb.AppendLine(" {");
sb.AppendLine(" private readonly IServiceScopeFactory _scopeFactory;");
sb.AppendLine();
sb.AppendLine(" public DynamicQueryServiceImpl(IServiceScopeFactory scopeFactory)");
sb.AppendLine(" {");
sb.AppendLine(" _scopeFactory = scopeFactory;");
sb.AppendLine(" }");
sb.AppendLine();
foreach (var dynamicQuery in dynamicQueries)
{
var methodName = $"Query{dynamicQuery.Name}";
var requestType = $"DynamicQuery{dynamicQuery.Name}Request";
var responseType = $"DynamicQuery{dynamicQuery.Name}Response";
sb.AppendLine($" public override async Task<{responseType}> {methodName}(");
sb.AppendLine($" {requestType} request,");
sb.AppendLine(" ServerCallContext context)");
sb.AppendLine(" {");
sb.AppendLine(" using var scope = _scopeFactory.CreateScope();");
sb.AppendLine(" var serviceProvider = scope.ServiceProvider;");
sb.AppendLine();
// Build the dynamic query object
if (dynamicQuery.HasParams)
{
sb.AppendLine($" var query = new Svrnty.CQRS.DynamicQuery.DynamicQuery<{dynamicQuery.SourceTypeFullyQualified}, {dynamicQuery.DestinationTypeFullyQualified}, {dynamicQuery.ParamsTypeFullyQualified}>");
}
else
{
sb.AppendLine($" var query = new Svrnty.CQRS.DynamicQuery.DynamicQuery<{dynamicQuery.SourceTypeFullyQualified}, {dynamicQuery.DestinationTypeFullyQualified}>");
}
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(" };");
sb.AppendLine();
// Get the handler and execute
sb.AppendLine($" var handler = serviceProvider.GetRequiredService<IQueryHandler<{dynamicQuery.QueryInterfaceName}, IQueryExecutionResult<{dynamicQuery.DestinationTypeFullyQualified}>>>();");
sb.AppendLine(" var result = await handler.HandleAsync(query, context.CancellationToken);");
sb.AppendLine();
// Build response
sb.AppendLine($" var response = new {responseType}");
sb.AppendLine(" {");
sb.AppendLine(" TotalRecords = result.TotalRecords,");
sb.AppendLine(" NumberOfPages = (int)(result.NumberOfPages ?? 0)");
sb.AppendLine(" };");
sb.AppendLine();
sb.AppendLine(" if (result.Data != null)");
sb.AppendLine(" {");
sb.AppendLine(" foreach (var item in result.Data)");
sb.AppendLine(" {");
sb.AppendLine($" response.Data.Add(MapTo{dynamicQuery.Name}ProtoModel(item));");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" // TODO: Add aggregates and groups to response if needed");
sb.AppendLine();
sb.AppendLine(" return response;");
sb.AppendLine(" }");
sb.AppendLine();
}
// Add helper methods for converting proto messages to AspNetCore types
sb.AppendLine(" private static List<Svrnty.CQRS.DynamicQuery.DynamicQueryFilter>? ConvertFilters(Google.Protobuf.Collections.RepeatedField<DynamicQueryFilter> protoFilters)");
sb.AppendLine(" {");
sb.AppendLine(" if (protoFilters == null || protoFilters.Count == 0)");
sb.AppendLine(" return null;");
sb.AppendLine();
sb.AppendLine(" var filters = new List<Svrnty.CQRS.DynamicQuery.DynamicQueryFilter>();");
sb.AppendLine(" foreach (var protoFilter in protoFilters)");
sb.AppendLine(" {");
sb.AppendLine(" var filter = new Svrnty.CQRS.DynamicQuery.DynamicQueryFilter");
sb.AppendLine(" {");
sb.AppendLine(" Path = protoFilter.Path,");
sb.AppendLine(" Type = ((PoweredSoft.DynamicQuery.Core.FilterType)protoFilter.Type).ToString(),");
sb.AppendLine(" Value = protoFilter.Value");
sb.AppendLine(" };");
sb.AppendLine();
sb.AppendLine(" // Handle nested AND filters");
sb.AppendLine(" if (protoFilter.And != null && protoFilter.And.Count > 0)");
sb.AppendLine(" {");
sb.AppendLine(" filter.Filters = ConvertProtoFiltersToList(protoFilter.And);");
sb.AppendLine(" filter.And = true;");
sb.AppendLine(" }");
sb.AppendLine(" // Handle nested OR filters");
sb.AppendLine(" else if (protoFilter.Or != null && protoFilter.Or.Count > 0)");
sb.AppendLine(" {");
sb.AppendLine(" filter.Filters = ConvertProtoFiltersToList(protoFilter.Or);");
sb.AppendLine(" filter.And = false;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" filters.Add(filter);");
sb.AppendLine(" }");
sb.AppendLine(" return filters;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" private static List<Svrnty.CQRS.DynamicQuery.DynamicQueryFilter> ConvertProtoFiltersToList(Google.Protobuf.Collections.RepeatedField<DynamicQueryFilter> protoFilters)");
sb.AppendLine(" {");
sb.AppendLine(" var result = new List<Svrnty.CQRS.DynamicQuery.DynamicQueryFilter>();");
sb.AppendLine(" foreach (var pf in protoFilters)");
sb.AppendLine(" {");
sb.AppendLine(" var filter = new Svrnty.CQRS.DynamicQuery.DynamicQueryFilter");
sb.AppendLine(" {");
sb.AppendLine(" Path = pf.Path,");
sb.AppendLine(" Type = ((PoweredSoft.DynamicQuery.Core.FilterType)pf.Type).ToString(),");
sb.AppendLine(" Value = pf.Value");
sb.AppendLine(" };");
sb.AppendLine(" if (pf.And != null && pf.And.Count > 0)");
sb.AppendLine(" {");
sb.AppendLine(" filter.Filters = ConvertProtoFiltersToList(pf.And);");
sb.AppendLine(" filter.And = true;");
sb.AppendLine(" }");
sb.AppendLine(" else if (pf.Or != null && pf.Or.Count > 0)");
sb.AppendLine(" {");
sb.AppendLine(" filter.Filters = ConvertProtoFiltersToList(pf.Or);");
sb.AppendLine(" filter.And = false;");
sb.AppendLine(" }");
sb.AppendLine(" result.Add(filter);");
sb.AppendLine(" }");
sb.AppendLine(" return result;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" private static List<PoweredSoft.DynamicQuery.Sort>? ConvertSorts(Google.Protobuf.Collections.RepeatedField<DynamicQuerySort> protoSorts)");
sb.AppendLine(" {");
sb.AppendLine(" if (protoSorts == null || protoSorts.Count == 0)");
sb.AppendLine(" return null;");
sb.AppendLine();
sb.AppendLine(" return protoSorts.Select(s => new PoweredSoft.DynamicQuery.Sort");
sb.AppendLine(" {");
sb.AppendLine(" Path = s.Path,");
sb.AppendLine(" Ascending = s.Ascending");
sb.AppendLine(" }).ToList();");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" private static List<PoweredSoft.DynamicQuery.Group>? ConvertGroups(Google.Protobuf.Collections.RepeatedField<DynamicQueryGroup> protoGroups)");
sb.AppendLine(" {");
sb.AppendLine(" if (protoGroups == null || protoGroups.Count == 0)");
sb.AppendLine(" return null;");
sb.AppendLine();
sb.AppendLine(" return protoGroups.Select(g => new PoweredSoft.DynamicQuery.Group");
sb.AppendLine(" {");
sb.AppendLine(" Path = g.Path");
sb.AppendLine(" }).ToList();");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" private static List<Svrnty.CQRS.DynamicQuery.DynamicQueryAggregate>? ConvertAggregates(Google.Protobuf.Collections.RepeatedField<DynamicQueryAggregate> protoAggregates)");
sb.AppendLine(" {");
sb.AppendLine(" if (protoAggregates == null || protoAggregates.Count == 0)");
sb.AppendLine(" return null;");
sb.AppendLine();
sb.AppendLine(" return protoAggregates.Select(a => new Svrnty.CQRS.DynamicQuery.DynamicQueryAggregate");
sb.AppendLine(" {");
sb.AppendLine(" Path = a.Path,");
sb.AppendLine(" Type = ((PoweredSoft.DynamicQuery.Core.AggregateType)a.Type).ToString()");
sb.AppendLine(" }).ToList();");
sb.AppendLine(" }");
sb.AppendLine();
// Add generic reflection-based mapper helper
sb.AppendLine(" private static TProto MapToProtoModel<TDomain, TProto>(TDomain domainModel) where TProto : Google.Protobuf.IMessage, new()");
sb.AppendLine(" {");
sb.AppendLine(" if (domainModel == null) return new TProto();");
sb.AppendLine(" var proto = new TProto();");
sb.AppendLine(" var domainProps = typeof(TDomain).GetProperties();");
sb.AppendLine(" var protoDesc = proto.Descriptor;");
sb.AppendLine();
sb.AppendLine(" foreach (var domainProp in domainProps)");
sb.AppendLine(" {");
sb.AppendLine(" // Convert property name to proto field name (PascalCase to snake_case)");
sb.AppendLine(" var protoFieldName = ToSnakeCase(domainProp.Name);");
sb.AppendLine(" var protoField = protoDesc.FindFieldByName(protoFieldName);");
sb.AppendLine(" if (protoField == null) continue;");
sb.AppendLine();
sb.AppendLine(" var domainValue = domainProp.GetValue(domainModel);");
sb.AppendLine(" if (domainValue == null) continue;");
sb.AppendLine();
sb.AppendLine(" var protoAccessor = protoField.Accessor;");
sb.AppendLine();
sb.AppendLine(" // Handle DateTime -> Timestamp conversion");
sb.AppendLine(" if (domainProp.PropertyType == typeof(DateTime) || domainProp.PropertyType == typeof(DateTime?))");
sb.AppendLine(" {");
sb.AppendLine(" var dateTime = (DateTime)domainValue;");
sb.AppendLine(" // Ensure UTC for Timestamp conversion");
sb.AppendLine(" if (dateTime.Kind != DateTimeKind.Utc)");
sb.AppendLine(" dateTime = dateTime.ToUniversalTime();");
sb.AppendLine(" protoAccessor.SetValue(proto, Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(dateTime));");
sb.AppendLine(" }");
sb.AppendLine(" else if (domainProp.PropertyType == typeof(DateTimeOffset) || domainProp.PropertyType == typeof(DateTimeOffset?))");
sb.AppendLine(" {");
sb.AppendLine(" var dateTimeOffset = (DateTimeOffset)domainValue;");
sb.AppendLine(" protoAccessor.SetValue(proto, Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(dateTimeOffset));");
sb.AppendLine(" }");
sb.AppendLine(" // Handle collections (List<T>, IList<T>, etc.) - must check before complex types");
sb.AppendLine(" else if (protoField.IsRepeated && domainValue is System.Collections.IEnumerable enumerable && domainProp.PropertyType != typeof(string))");
sb.AppendLine(" {");
sb.AppendLine(" var repeatedField = protoAccessor.GetValue(proto);");
sb.AppendLine(" if (repeatedField == null) continue;");
sb.AppendLine();
sb.AppendLine(" // Get the element type of the RepeatedField<T>");
sb.AppendLine(" var repeatedFieldType = repeatedField.GetType();");
sb.AppendLine(" var repeatedElementType = repeatedFieldType.IsGenericType ? repeatedFieldType.GetGenericArguments()[0] : null;");
sb.AppendLine(" if (repeatedElementType == null) continue;");
sb.AppendLine();
sb.AppendLine(" // Get Add(T) method with specific parameter type to avoid ambiguity");
sb.AppendLine(" var addMethod = repeatedFieldType.GetMethod(\"Add\", new[] { repeatedElementType });");
sb.AppendLine(" if (addMethod == null) continue;");
sb.AppendLine();
sb.AppendLine(" // Get element types");
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();
sb.AppendLine(" foreach (var item in enumerable)");
sb.AppendLine(" {");
sb.AppendLine(" if (item == null) continue;");
sb.AppendLine();
sb.AppendLine(" // Check if elements need mapping (complex types)");
sb.AppendLine(" if (protoElementType != null && typeof(Google.Protobuf.IMessage).IsAssignableFrom(protoElementType) && domainElementType != null)");
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(" else");
sb.AppendLine(" {");
sb.AppendLine(" // Primitive types, enums, strings - add directly");
sb.AppendLine(" try { addMethod.Invoke(repeatedField, new[] { item }); }");
sb.AppendLine(" catch { /* Type mismatch, skip */ }");
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(" if (protoFieldType != null && typeof(Google.Protobuf.IMessage).IsAssignableFrom(protoFieldType))");
sb.AppendLine(" {");
sb.AppendLine(" var mapMethod = typeof(DynamicQueryServiceImpl).GetMethod(\"MapToProtoModel\", ");
sb.AppendLine(" System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!");
sb.AppendLine(" .MakeGenericMethod(domainProp.PropertyType, protoFieldType);");
sb.AppendLine(" var nestedProto = mapMethod.Invoke(null, new[] { domainValue });");
sb.AppendLine(" if (nestedProto != null)");
sb.AppendLine(" protoAccessor.SetValue(proto, nestedProto);");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" // Handle decimal -> string conversion");
sb.AppendLine(" else if (domainProp.PropertyType == typeof(decimal) || domainProp.PropertyType == typeof(decimal?))");
sb.AppendLine(" {");
sb.AppendLine(" protoAccessor.SetValue(proto, ((decimal)domainValue).ToString(System.Globalization.CultureInfo.InvariantCulture));");
sb.AppendLine(" }");
sb.AppendLine(" else");
sb.AppendLine(" {");
sb.AppendLine(" // Direct assignment for primitives, strings, enums");
sb.AppendLine(" try { protoAccessor.SetValue(proto, domainValue); }");
sb.AppendLine(" catch { /* Type mismatch, skip */ }");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" return proto;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" private static string ToSnakeCase(string str)");
sb.AppendLine(" {");
sb.AppendLine(" if (string.IsNullOrEmpty(str)) return str;");
sb.AppendLine(" var result = new System.Text.StringBuilder();");
sb.AppendLine(" for (int i = 0; i < str.Length; i++)");
sb.AppendLine(" {");
sb.AppendLine(" if (i > 0 && char.IsUpper(str[i]))");
sb.AppendLine(" result.Append('_');");
sb.AppendLine(" result.Append(char.ToLowerInvariant(str[i]));");
sb.AppendLine(" }");
sb.AppendLine(" return result.ToString();");
sb.AppendLine(" }");
sb.AppendLine();
// Add mapper methods for each entity type
foreach (var dynamicQuery in dynamicQueries)
{
var entityName = dynamicQuery.Name;
var protoTypeName = $"{entityName.TrimEnd('s')}"; // User from Users
sb.AppendLine($" private static {protoTypeName} MapTo{entityName}ProtoModel({dynamicQuery.DestinationTypeFullyQualified} domainModel)");
sb.AppendLine(" {");
sb.AppendLine($" return MapToProtoModel<{dynamicQuery.DestinationTypeFullyQualified}, {protoTypeName}>(domainModel);");
sb.AppendLine(" }");
sb.AppendLine();
}
sb.AppendLine(" }");
sb.AppendLine("}");
return sb.ToString();
}
/// <summary>
/// Discovers types marked with [StreamingNotification] attribute
/// </summary>
private static List<NotificationInfo> DiscoverNotifications(IEnumerable<INamedTypeSymbol> allTypes, Compilation compilation)
{
var streamingNotificationAttribute = compilation.GetTypeByMetadataName(
"Svrnty.CQRS.Notifications.Abstractions.StreamingNotificationAttribute");
if (streamingNotificationAttribute == null)
return new List<NotificationInfo>();
var notifications = new List<NotificationInfo>();
foreach (var type in allTypes)
{
if (type.IsAbstract || type.IsStatic)
continue;
var attr = type.GetAttributes()
.FirstOrDefault(a => SymbolEqualityComparer.Default.Equals(
a.AttributeClass, streamingNotificationAttribute));
if (attr == null)
continue;
// Extract SubscriptionKey from attribute
var subscriptionKeyArg = attr.NamedArguments
.FirstOrDefault(a => a.Key == "SubscriptionKey");
var subscriptionKeyProp = subscriptionKeyArg.Value.Value as string;
if (string.IsNullOrEmpty(subscriptionKeyProp))
continue;
// Get all properties of the notification type
var properties = new List<PropertyInfo>();
int fieldNumber = 1;
foreach (var prop in type.GetMembers().OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public))
{
var propType = prop.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var protoType = ProtoTypeMapper.MapToProtoType(propType, out _, out _);
properties.Add(new PropertyInfo
{
Name = prop.Name,
ProtoPropertyName = ToPascalCaseHelper(ToSnakeCaseHelper(prop.Name)),
Type = propType,
FullyQualifiedType = propType,
ProtoType = protoType,
FieldNumber = fieldNumber++,
IsEnum = prop.Type.TypeKind == TypeKind.Enum,
IsDecimal = propType.Contains("decimal") || propType.Contains("Decimal"),
IsDateTime = propType.Contains("DateTime")
});
}
// Find the subscription key property info
var keyPropInfo = properties.FirstOrDefault(p => p.Name == subscriptionKeyProp);
if (keyPropInfo == null)
continue;
notifications.Add(new NotificationInfo
{
Name = type.Name,
FullyQualifiedName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
Namespace = type.ContainingNamespace?.ToDisplayString() ?? "",
SubscriptionKeyProperty = subscriptionKeyProp,
SubscriptionKeyInfo = keyPropInfo,
Properties = properties
});
}
return notifications;
}
/// <summary>
/// Generates the NotificationServiceImpl class for streaming notifications
/// </summary>
private static string GenerateNotificationServiceImpl(List<NotificationInfo> notifications, string rootNamespace)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");
sb.AppendLine("#nullable enable");
sb.AppendLine("using Grpc.Core;");
sb.AppendLine("using System.Threading.Tasks;");
sb.AppendLine("using System.Threading;");
sb.AppendLine("using Google.Protobuf.WellKnownTypes;");
sb.AppendLine($"using {rootNamespace}.Grpc;");
sb.AppendLine("using Svrnty.CQRS.Notifications.Grpc;");
sb.AppendLine();
sb.AppendLine($"namespace {rootNamespace}.Grpc.Services");
sb.AppendLine("{");
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Auto-generated gRPC service implementation for streaming Notifications");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public sealed class NotificationServiceImpl : NotificationService.NotificationServiceBase");
sb.AppendLine(" {");
sb.AppendLine(" private readonly NotificationSubscriptionManager _subscriptionManager;");
sb.AppendLine();
sb.AppendLine(" public NotificationServiceImpl(NotificationSubscriptionManager subscriptionManager)");
sb.AppendLine(" {");
sb.AppendLine(" _subscriptionManager = subscriptionManager;");
sb.AppendLine(" }");
foreach (var notification in notifications)
{
var methodName = $"SubscribeTo{notification.Name}";
var requestType = $"SubscribeTo{notification.Name}Request";
var keyPropName = notification.SubscriptionKeyProperty;
// Proto uses PascalCase for C# properties
var keyPropPascal = ToPascalCaseHelper(ToSnakeCaseHelper(keyPropName));
sb.AppendLine();
sb.AppendLine($" public override async Task {methodName}(");
sb.AppendLine($" {requestType} request,");
sb.AppendLine($" IServerStreamWriter<{notification.Name}> responseStream,");
sb.AppendLine(" ServerCallContext context)");
sb.AppendLine(" {");
sb.AppendLine($" // Subscribe with mapper from domain notification to proto message");
sb.AppendLine($" using var subscription = _subscriptionManager.Subscribe<{notification.FullyQualifiedName}, {notification.Name}>(");
sb.AppendLine($" request.{keyPropPascal},");
sb.AppendLine($" responseStream,");
sb.AppendLine($" domainNotification => Map{notification.Name}(domainNotification));");
sb.AppendLine();
sb.AppendLine(" // Keep the stream alive until client disconnects");
sb.AppendLine(" try");
sb.AppendLine(" {");
sb.AppendLine(" await Task.Delay(Timeout.Infinite, context.CancellationToken);");
sb.AppendLine(" }");
sb.AppendLine(" catch (OperationCanceledException)");
sb.AppendLine(" {");
sb.AppendLine(" // Client disconnected - normal behavior");
sb.AppendLine(" }");
sb.AppendLine(" }");
}
// Generate mapper methods
foreach (var notification in notifications)
{
sb.AppendLine();
sb.AppendLine($" private static {notification.Name} Map{notification.Name}({notification.FullyQualifiedName} domain)");
sb.AppendLine(" {");
sb.AppendLine($" return new {notification.Name}");
sb.AppendLine(" {");
foreach (var prop in notification.Properties)
{
var protoFieldName = ToPascalCaseHelper(ToSnakeCaseHelper(prop.Name));
if (prop.IsDateTime)
{
sb.AppendLine($" {protoFieldName} = Timestamp.FromDateTime(domain.{prop.Name}.ToUniversalTime()),");
}
else if (prop.IsDecimal)
{
sb.AppendLine($" {protoFieldName} = domain.{prop.Name}.ToString(),");
}
else if (prop.IsEnum)
{
// Map domain enum to proto enum - get simple type name
var simpleTypeName = prop.Type.Replace("global::", "").Split('.').Last();
sb.AppendLine($" {protoFieldName} = ({simpleTypeName})((int)domain.{prop.Name}),");
}
else
{
sb.AppendLine($" {protoFieldName} = domain.{prop.Name},");
}
}
sb.AppendLine(" };");
sb.AppendLine(" }");
}
sb.AppendLine(" }");
sb.AppendLine("}");
return sb.ToString();
}
private static string ToSnakeCaseHelper(string str)
{
if (string.IsNullOrEmpty(str)) return str;
var result = new StringBuilder();
for (int i = 0; i < str.Length; i++)
{
if (i > 0 && char.IsUpper(str[i]))
result.Append('_');
result.Append(char.ToLowerInvariant(str[i]));
}
return result.ToString();
}
private static string ToPascalCaseHelper(string snakeCase)
{
if (string.IsNullOrEmpty(snakeCase)) return snakeCase;
var parts = snakeCase.Split('_');
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();
}
}
}