diff --git a/Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs b/Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs index 8edbf8b..983d305 100644 --- a/Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs +++ b/Svrnty.CQRS.DynamicQuery.MinimalApi/EndpointRouteBuilderExtensions.cs @@ -23,7 +23,6 @@ public static class EndpointRouteBuilderExtensions public static IEndpointRouteBuilder MapSvrntyDynamicQueries(this IEndpointRouteBuilder endpoints, string routePrefix = "api/query") { var queryDiscovery = endpoints.ServiceProvider.GetRequiredService(); - var authorizationService = endpoints.ServiceProvider.GetService(); foreach (var queryMeta in queryDiscovery.GetQueries()) { @@ -43,14 +42,14 @@ public static class EndpointRouteBuilderExtensions if (dynamicQueryMeta.ParamsType == null) { // DynamicQuery - MapDynamicQueryPost(endpoints, route, dynamicQueryMeta, authorizationService); - MapDynamicQueryGet(endpoints, route, dynamicQueryMeta, authorizationService); + MapDynamicQueryPost(endpoints, route, dynamicQueryMeta); + MapDynamicQueryGet(endpoints, route, dynamicQueryMeta); } else { // DynamicQuery - MapDynamicQueryWithParamsPost(endpoints, route, dynamicQueryMeta, authorizationService); - MapDynamicQueryWithParamsGet(endpoints, route, dynamicQueryMeta, authorizationService); + MapDynamicQueryWithParamsPost(endpoints, route, dynamicQueryMeta); + MapDynamicQueryWithParamsGet(endpoints, route, dynamicQueryMeta); } } @@ -60,8 +59,7 @@ public static class EndpointRouteBuilderExtensions private static void MapDynamicQueryPost( IEndpointRouteBuilder endpoints, string route, - DynamicQueryMeta dynamicQueryMeta, - IQueryAuthorizationService? authorizationService) + DynamicQueryMeta dynamicQueryMeta) { var sourceType = dynamicQueryMeta.SourceType; var destinationType = dynamicQueryMeta.DestinationType; @@ -75,7 +73,7 @@ public static class EndpointRouteBuilderExtensions .GetMethod(nameof(MapDynamicQueryPostTyped), BindingFlags.NonPublic | BindingFlags.Static)! .MakeGenericMethod(sourceType, destinationType); - var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType, authorizationService])!; + var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType])!; endpoint .WithName($"DynamicQuery_{dynamicQueryMeta.LowerCamelCaseName}_Post") @@ -91,8 +89,7 @@ public static class EndpointRouteBuilderExtensions IEndpointRouteBuilder endpoints, string route, Type queryType, - Type handlerType, - IQueryAuthorizationService? authorizationService) + Type handlerType) where TSource : class where TDestination : class { @@ -102,6 +99,7 @@ public static class EndpointRouteBuilderExtensions IServiceProvider serviceProvider, CancellationToken cancellationToken) => { + var authorizationService = serviceProvider.GetService(); if (authorizationService != null) { var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken); @@ -129,8 +127,7 @@ public static class EndpointRouteBuilderExtensions private static void MapDynamicQueryGet( IEndpointRouteBuilder endpoints, string route, - DynamicQueryMeta dynamicQueryMeta, - IQueryAuthorizationService? authorizationService) + DynamicQueryMeta dynamicQueryMeta) { var sourceType = dynamicQueryMeta.SourceType; var destinationType = dynamicQueryMeta.DestinationType; @@ -141,6 +138,7 @@ public static class EndpointRouteBuilderExtensions endpoints.MapGet(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) => { + var authorizationService = serviceProvider.GetService(); if (authorizationService != null) { var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken); @@ -199,8 +197,7 @@ public static class EndpointRouteBuilderExtensions private static void MapDynamicQueryWithParamsPost( IEndpointRouteBuilder endpoints, string route, - DynamicQueryMeta dynamicQueryMeta, - IQueryAuthorizationService? authorizationService) + DynamicQueryMeta dynamicQueryMeta) { var sourceType = dynamicQueryMeta.SourceType; var destinationType = dynamicQueryMeta.DestinationType; @@ -214,7 +211,7 @@ public static class EndpointRouteBuilderExtensions .GetMethod(nameof(MapDynamicQueryWithParamsPostTyped), BindingFlags.NonPublic | BindingFlags.Static)! .MakeGenericMethod(sourceType, destinationType, paramsType); - var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType, authorizationService])!; + var endpoint = (RouteHandlerBuilder)mapPostMethod.Invoke(null, [endpoints, route, queryType, handlerType])!; endpoint .WithName($"DynamicQuery_{dynamicQueryMeta.LowerCamelCaseName}_WithParams_Post") @@ -230,8 +227,7 @@ public static class EndpointRouteBuilderExtensions IEndpointRouteBuilder endpoints, string route, Type queryType, - Type handlerType, - IQueryAuthorizationService? authorizationService) + Type handlerType) where TSource : class where TDestination : class where TParams : class @@ -242,6 +238,7 @@ public static class EndpointRouteBuilderExtensions IServiceProvider serviceProvider, CancellationToken cancellationToken) => { + var authorizationService = serviceProvider.GetService(); if (authorizationService != null) { var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken); @@ -269,8 +266,7 @@ public static class EndpointRouteBuilderExtensions private static void MapDynamicQueryWithParamsGet( IEndpointRouteBuilder endpoints, string route, - DynamicQueryMeta dynamicQueryMeta, - IQueryAuthorizationService? authorizationService) + DynamicQueryMeta dynamicQueryMeta) { var sourceType = dynamicQueryMeta.SourceType; var destinationType = dynamicQueryMeta.DestinationType; @@ -282,6 +278,7 @@ public static class EndpointRouteBuilderExtensions endpoints.MapGet(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) => { + var authorizationService = serviceProvider.GetService(); if (authorizationService != null) { var authorizationResult = await authorizationService.IsAllowedAsync(queryType, cancellationToken); diff --git a/Svrnty.CQRS.DynamicQuery/DynamicQueryHandlerBase.cs b/Svrnty.CQRS.DynamicQuery/DynamicQueryHandlerBase.cs index 3dbde90..ac844ee 100644 --- a/Svrnty.CQRS.DynamicQuery/DynamicQueryHandlerBase.cs +++ b/Svrnty.CQRS.DynamicQuery/DynamicQueryHandlerBase.cs @@ -35,7 +35,11 @@ public abstract class DynamicQueryHandlerBase protected virtual Task> GetQueryableAsync(IDynamicQuery query, CancellationToken cancellationToken = default) { if (_queryableProviders.Any()) - return _queryableProviders.ElementAt(0).GetQueryableAsync(query, cancellationToken); + { + // Use Last() to prefer closed generic registrations (overrides) over open generic (default) + // Registration order: open generic first, closed generic (override) last + return _queryableProviders.Last().GetQueryableAsync(query, cancellationToken); + } throw new Exception($"You must provide a QueryableProvider for {typeof(TSource).Name}"); } diff --git a/Svrnty.CQRS.Grpc.Generators/GrpcGenerator.cs b/Svrnty.CQRS.Grpc.Generators/GrpcGenerator.cs index 8f45d62..ae87ba7 100644 --- a/Svrnty.CQRS.Grpc.Generators/GrpcGenerator.cs +++ b/Svrnty.CQRS.Grpc.Generators/GrpcGenerator.cs @@ -329,6 +329,60 @@ namespace Svrnty.CQRS.Grpc.Generators var commandTypeFullyQualified = commandType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var resultTypeFullyQualified = resultType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); commandInfo.HandlerInterfaceName = $"ICommandHandler<{commandTypeFullyQualified}, {resultTypeFullyQualified}>"; + + // Check if result type is primitive + var resultTypeString = resultType.ToDisplayString(); + commandInfo.IsResultPrimitiveType = IsPrimitiveType(resultTypeString); + + // Extract result type properties if it's a complex type + if (!commandInfo.IsResultPrimitiveType) + { + var resultProperties = resultType.GetMembers().OfType() + .Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic) + .ToList(); + + foreach (var property in resultProperties) + { + var propertyType = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var isList = IsListOrCollection(property.Type); + var isComplexType = IsUserDefinedComplexType(property.Type); + + var propInfo = new PropertyInfo + { + Name = property.Name, + Type = property.Type.ToDisplayString(), + FullyQualifiedType = propertyType, + ProtoType = string.Empty, // Not needed for result mapping + FieldNumber = 0, // Not needed for result mapping + IsList = isList, + IsComplexType = isComplexType, + IsNullable = IsNullableType(property.Type), + IsEnum = IsEnumType(property.Type), + IsDecimal = IsDecimalType(property.Type), + IsDateTime = IsDateTimeType(property.Type), + IsDateTimeOffset = IsDateTimeOffsetType(property.Type), + IsGuid = IsGuidType(property.Type), + IsJsonElement = IsJsonElementType(property.Type), + IsValueTypeCollection = IsValueTypeCollection(property.Type), + IsBinaryType = IsBinaryType(property.Type), + IsStream = IsStreamType(property.Type), + }; + + // If it's a list, extract element type info + if (isList) + { + var elementType = GetListElementType(property.Type); + if (elementType != null) + { + propInfo.ElementType = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + propInfo.IsElementComplexType = IsUserDefinedComplexType(elementType); + propInfo.IsElementGuid = IsGuidType(elementType); + } + } + + commandInfo.ResultProperties.Add(propInfo); + } + } } else { @@ -360,9 +414,11 @@ namespace Svrnty.CQRS.Grpc.Generators IsEnum = IsEnumType(property.Type), IsDecimal = IsDecimalType(property.Type), IsDateTime = IsDateTimeType(property.Type), + IsDateTimeOffset = IsDateTimeOffsetType(property.Type), IsGuid = IsGuidType(property.Type), IsJsonElement = IsJsonElementType(property.Type), IsList = IsListOrCollection(property.Type), + IsValueTypeCollection = IsValueTypeCollection(property.Type), IsBinaryType = IsBinaryType(property.Type), IsStream = IsStreamType(property.Type), }; @@ -466,6 +522,12 @@ namespace Svrnty.CQRS.Grpc.Generators return unwrapped.SpecialType == SpecialType.System_DateTime; } + private static bool IsDateTimeOffsetType(ITypeSymbol type) + { + var unwrapped = UnwrapNullableType(type); + return unwrapped.ToDisplayString() == "System.DateTimeOffset"; + } + private static bool IsEnumType(ITypeSymbol type) { var unwrapped = UnwrapNullableType(type); @@ -504,28 +566,119 @@ namespace Svrnty.CQRS.Grpc.Generators private static bool IsListOrCollection(ITypeSymbol type) { - if (type is IArrayTypeSymbol) - return true; + // Unwrap nullable first to handle NpgsqlPolygon? + var unwrapped = UnwrapNullableType(type); - if (type is INamedTypeSymbol namedType && namedType.IsGenericType) + if (unwrapped is IArrayTypeSymbol arrayType) { - 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<"); + // byte[] is binary data, not a collection + if (arrayType.ElementType.SpecialType == SpecialType.System_Byte) + return false; + return true; + } + + if (unwrapped is INamedTypeSymbol namedType) + { + // Check if it's a generic collection type + if (namedType.IsGenericType) + { + var typeName = namedType.OriginalDefinition.ToDisplayString(); + if (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 true; + } + } + + // Check if it implements IList, ICollection, or IEnumerable (handles types like NpgsqlPolygon) + // Skip string which implements IEnumerable + if (namedType.SpecialType == SpecialType.System_String) + return false; + + foreach (var iface in namedType.AllInterfaces) + { + if (iface.IsGenericType && iface.TypeArguments.Length == 1) + { + var ifaceName = iface.OriginalDefinition.ToDisplayString(); + if (ifaceName == "System.Collections.Generic.IList" || + ifaceName == "System.Collections.Generic.ICollection" || + ifaceName == "System.Collections.Generic.IReadOnlyList" || + ifaceName == "System.Collections.Generic.IReadOnlyCollection") + { + return true; + } + } + } } return false; } + /// + /// Checks if a type is a value type that implements a collection interface (like NpgsqlPolygon) + /// These require special construction syntax + /// + private static bool IsValueTypeCollection(ITypeSymbol type) + { + // Unwrap nullable first to handle NpgsqlPolygon? + var unwrapped = UnwrapNullableType(type); + + if (unwrapped is not INamedTypeSymbol namedType) + return false; + + // Must be a value type (struct) + if (!namedType.IsValueType) + return false; + + // Must implement a collection interface (pass unwrapped since IsListOrCollection also unwraps) + return IsListOrCollection(unwrapped); + } + private static ITypeSymbol? GetListElementType(ITypeSymbol type) { - if (type is IArrayTypeSymbol arrayType) + // Unwrap nullable first to handle NpgsqlPolygon? + var unwrapped = UnwrapNullableType(type); + + if (unwrapped is IArrayTypeSymbol arrayType) return arrayType.ElementType; - if (type is INamedTypeSymbol namedType && namedType.IsGenericType && namedType.TypeArguments.Length > 0) + if (unwrapped is INamedTypeSymbol namedType) { - return namedType.TypeArguments[0]; + // First try generic type arguments + if (namedType.IsGenericType && namedType.TypeArguments.Length > 0) + { + return namedType.TypeArguments[0]; + } + + // Fall back to checking interfaces (for types like NpgsqlPolygon that implement IList) + foreach (var iface in namedType.AllInterfaces) + { + if (iface.IsGenericType && iface.TypeArguments.Length == 1) + { + var ifaceName = iface.OriginalDefinition.ToDisplayString(); + if (ifaceName == "System.Collections.Generic.IList" || + ifaceName == "System.Collections.Generic.IReadOnlyList") + { + return iface.TypeArguments[0]; + } + } + } + + // Try ICollection or IEnumerable as fallback + foreach (var iface in namedType.AllInterfaces) + { + if (iface.IsGenericType && iface.TypeArguments.Length == 1) + { + var ifaceName = iface.OriginalDefinition.ToDisplayString(); + if (ifaceName == "System.Collections.Generic.ICollection" || + ifaceName == "System.Collections.Generic.IReadOnlyCollection" || + ifaceName == "System.Collections.Generic.IEnumerable") + { + return iface.TypeArguments[0]; + } + } + } } return null; } @@ -582,6 +735,8 @@ namespace Svrnty.CQRS.Grpc.Generators ProtoType = string.Empty, FieldNumber = 0, IsComplexType = IsUserDefinedComplexType(property.Type), + IsList = IsListOrCollection(property.Type), + IsValueTypeCollection = IsValueTypeCollection(property.Type), }; // Recursively extract nested properties for complex types @@ -620,9 +775,11 @@ namespace Svrnty.CQRS.Grpc.Generators IsEnum = IsEnumType(property.Type), IsDecimal = IsDecimalType(property.Type), IsDateTime = IsDateTimeType(property.Type), + IsDateTimeOffset = IsDateTimeOffsetType(property.Type), IsGuid = IsGuidType(property.Type), IsJsonElement = IsJsonElementType(property.Type), IsList = IsListOrCollection(property.Type), + IsValueTypeCollection = IsValueTypeCollection(property.Type), IsBinaryType = IsBinaryType(property.Type), IsStream = IsStreamType(property.Type), }; @@ -695,8 +852,20 @@ namespace Svrnty.CQRS.Grpc.Generators else if (prop.IsElementGuid) { // List from proto -> List in C# + if (prop.IsValueTypeCollection) + { + // Value type collection proto message has Items field + var constructorType = prop.FullyQualifiedType.TrimEnd('?'); + return $"{indent}{prop.Name} = new {constructorType}({source}?.Items?.Select(x => System.Guid.Parse(x)).ToArray() ?? System.Array.Empty()),"; + } return $"{indent}{prop.Name} = {source}?.Select(x => System.Guid.Parse(x)).ToList(),"; } + else if (prop.IsValueTypeCollection) + { + // Value type collection (like NpgsqlPolygon): proto message has Items field + var constructorType = prop.FullyQualifiedType.TrimEnd('?'); + return $"{indent}{prop.Name} = new {constructorType}({source}?.Items?.Select(x => new {prop.ElementType} {{ X = x.X, Y = x.Y }}).ToArray() ?? System.Array.Empty<{prop.ElementType ?? "object"}>()),"; + } else { // Primitive list: just ToList() @@ -736,6 +905,19 @@ namespace Svrnty.CQRS.Grpc.Generators } } + // Handle DateTimeOffset (proto Timestamp -> C# DateTimeOffset) + if (prop.IsDateTimeOffset) + { + if (prop.IsNullable) + { + return $"{indent}{prop.Name} = {source} == null ? (System.DateTimeOffset?)null : {source}.ToDateTimeOffset(),"; + } + else + { + return $"{indent}{prop.Name} = {source}.ToDateTimeOffset(),"; + } + } + // Handle Guid (proto string -> C# Guid) if (prop.IsGuid) { @@ -785,7 +967,9 @@ namespace Svrnty.CQRS.Grpc.Generators 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}"); + // For value type collections, the proto message has an Items field containing the repeated elements + var itemsSource = prop.IsValueTypeCollection ? $"{source}?.Items" : source; + sb.AppendLine($"{indent}{prop.Name} = {itemsSource}?.Select(x => new {prop.ElementType}"); sb.AppendLine($"{indent}{{"); foreach (var nestedProp in prop.ElementNestedProperties!) @@ -798,7 +982,17 @@ namespace Svrnty.CQRS.Grpc.Generators sb.AppendLine(nestedAssignment); } - sb.Append($"{indent}}}).ToList(),"); + if (prop.IsValueTypeCollection) + { + // Value type collection: wrap in constructor with array + // Use non-nullable type for constructor (remove trailing ? if present) + var constructorType = prop.FullyQualifiedType.TrimEnd('?'); + sb.Append($"{indent}}}).ToArray() is {{ }} arr ? new {constructorType}(arr) : default,"); + } + else + { + sb.Append($"{indent}}}).ToList(),"); + } return sb.ToString(); } @@ -878,6 +1072,12 @@ namespace Svrnty.CQRS.Grpc.Generators { return GenerateComplexListMapping(prop, source, indent); } + if (prop.IsValueTypeCollection) + { + // Value type collection (like NpgsqlPolygon): use constructor with array + var constructorType = prop.FullyQualifiedType.TrimEnd('?'); + return $"{indent}{prop.Name} = new {constructorType}({source}?.ToArray() ?? System.Array.Empty<{prop.ElementType ?? "object"}>()),"; + } return $"{indent}{prop.Name} = {source}?.ToList(),"; } @@ -901,7 +1101,22 @@ namespace Svrnty.CQRS.Grpc.Generators // Handle lists if (prop.IsList) { - if (prop.IsElementComplexType) + // Value type collections (like NpgsqlPolygon) map to a wrapper message with Items field + if (prop.IsValueTypeCollection) + { + var protoWrapperType = prop.Type.Split('.').Last().Replace("?", ""); + var protoElementType = prop.ElementType?.Split('.').Last() ?? "object"; + if (prop.IsElementComplexType && prop.ElementNestedProperties != null && prop.ElementNestedProperties.Any()) + { + return GenerateResultValueTypeCollectionWithComplexElements(prop, source, indent, protoWrapperType, protoElementType); + } + else + { + // Simple element type + return $"{indent}{prop.Name} = new {protoWrapperType} {{ Items = {{ {source}.Select(x => new {protoElementType} {{ X = x.X, Y = x.Y }}) }} }},"; + } + } + else if (prop.IsElementComplexType) { // Complex list: map each element to proto type return GenerateResultComplexListMapping(prop, source, indent); @@ -1040,6 +1255,27 @@ namespace Svrnty.CQRS.Grpc.Generators return sb.ToString(); } + private static string GenerateResultValueTypeCollectionWithComplexElements(PropertyInfo prop, string source, string indent, string protoWrapperType, string protoElementType) + { + var sb = new StringBuilder(); + sb.AppendLine($"{indent}{prop.Name} = new {protoWrapperType} {{ Items = {{"); + sb.AppendLine($"{indent} {source}.Select(x => new {protoElementType}"); + sb.AppendLine($"{indent} {{"); + + if (prop.ElementNestedProperties != null) + { + foreach (var nestedProp in prop.ElementNestedProperties) + { + var nestedAssignment = GenerateResultNestedPropertyMapping(nestedProp, "x", indent + " "); + sb.AppendLine(nestedAssignment); + } + } + + sb.AppendLine($"{indent} }})"); + sb.Append($"{indent}}} }},"); + return sb.ToString(); + } + private static string GenerateResultComplexObjectMapping(PropertyInfo prop, string source, string indent) { var sb = new StringBuilder(); @@ -1147,7 +1383,22 @@ namespace Svrnty.CQRS.Grpc.Generators // Handle lists if (prop.IsList) { - if (prop.IsElementGuid) + // Value type collections (like NpgsqlPolygon) map to a wrapper message with Items field + if (prop.IsValueTypeCollection) + { + var protoWrapperType = prop.Type.Split('.').Last().Replace("?", ""); + var protoElementType = prop.ElementType?.Split('.').Last() ?? "object"; + if (prop.IsElementComplexType && prop.ElementNestedProperties != null && prop.ElementNestedProperties.Any()) + { + return GenerateResultValueTypeCollectionWithComplexElements(prop, source, indent, protoWrapperType, protoElementType); + } + else + { + // Simple element type (like NpgsqlPoint) + return $"{indent}{prop.Name} = new {protoWrapperType} {{ Items = {{ {source}.Select(x => new {protoElementType} {{ X = x.X, Y = x.Y }}) }} }},"; + } + } + else if (prop.IsElementGuid) { return $"{indent}{prop.Name} = {{ {source}?.Select(x => x.ToString()) ?? Enumerable.Empty() }},"; } @@ -1247,9 +1498,11 @@ namespace Svrnty.CQRS.Grpc.Generators IsEnum = IsEnumType(property.Type), IsDecimal = IsDecimalType(property.Type), IsDateTime = IsDateTimeType(property.Type), + IsDateTimeOffset = IsDateTimeOffsetType(property.Type), IsGuid = IsGuidType(property.Type), IsJsonElement = IsJsonElementType(property.Type), IsList = IsListOrCollection(property.Type), + IsValueTypeCollection = IsValueTypeCollection(property.Type), IsComplexType = IsUserDefinedComplexType(property.Type), IsBinaryType = IsBinaryType(property.Type), IsStream = IsStreamType(property.Type), @@ -1310,9 +1563,11 @@ namespace Svrnty.CQRS.Grpc.Generators IsEnum = IsEnumType(property.Type), IsDecimal = IsDecimalType(property.Type), IsDateTime = IsDateTimeType(property.Type), + IsDateTimeOffset = IsDateTimeOffsetType(property.Type), IsGuid = IsGuidType(property.Type), IsJsonElement = IsJsonElementType(property.Type), IsList = IsListOrCollection(property.Type), + IsValueTypeCollection = IsValueTypeCollection(property.Type), IsComplexType = IsUserDefinedComplexType(property.Type), IsBinaryType = IsBinaryType(property.Type), IsStream = IsStreamType(property.Type), @@ -2166,6 +2421,7 @@ namespace Svrnty.CQRS.Grpc.Generators sb.AppendLine("#nullable enable"); sb.AppendLine("using Grpc.Core;"); sb.AppendLine("using System.Threading.Tasks;"); + sb.AppendLine("using System.Linq;"); sb.AppendLine("using Microsoft.Extensions.DependencyInjection;"); sb.AppendLine($"using {rootNamespace}.Grpc;"); sb.AppendLine("using Svrnty.CQRS.Abstractions;"); @@ -2911,7 +3167,9 @@ namespace Svrnty.CQRS.Grpc.Generators FieldNumber = fieldNumber++, IsEnum = prop.Type.TypeKind == TypeKind.Enum, IsDecimal = propType.Contains("decimal") || propType.Contains("Decimal"), - IsDateTime = propType.Contains("DateTime") + IsDateTime = propType.Contains("DateTime"), + IsList = IsListOrCollection(prop.Type), + IsValueTypeCollection = IsValueTypeCollection(prop.Type), }); } diff --git a/Svrnty.CQRS.Grpc.Generators/Helpers/ProtoTypeMapper.cs b/Svrnty.CQRS.Grpc.Generators/Helpers/ProtoTypeMapper.cs index a3fb640..f31eb42 100644 --- a/Svrnty.CQRS.Grpc.Generators/Helpers/ProtoTypeMapper.cs +++ b/Svrnty.CQRS.Grpc.Generators/Helpers/ProtoTypeMapper.cs @@ -49,6 +49,12 @@ namespace Svrnty.CQRS.Grpc.Generators.Helpers isRepeated = false; isOptional = false; + // Handle byte[] as bytes proto type (NOT repeated uint32) + if (csharpType == "System.Byte[]" || csharpType == "byte[]" || csharpType == "Byte[]") + { + return "bytes"; + } + // Handle arrays if (csharpType.EndsWith("[]")) { diff --git a/Svrnty.CQRS.Grpc.Generators/Models/CommandInfo.cs b/Svrnty.CQRS.Grpc.Generators/Models/CommandInfo.cs index 9a11ec4..b4d21aa 100644 --- a/Svrnty.CQRS.Grpc.Generators/Models/CommandInfo.cs +++ b/Svrnty.CQRS.Grpc.Generators/Models/CommandInfo.cs @@ -44,11 +44,13 @@ namespace Svrnty.CQRS.Grpc.Generators.Models public bool IsNullable { get; set; } public bool IsDecimal { get; set; } public bool IsDateTime { get; set; } + public bool IsDateTimeOffset { get; set; } public bool IsGuid { get; set; } public bool IsJsonElement { get; set; } public bool IsBinaryType { get; set; } // Stream, byte[], MemoryStream public bool IsStream { get; set; } // Specifically Stream types (not byte[]) public bool IsReadOnly { get; set; } // Read-only/computed properties should be skipped + public bool IsValueTypeCollection { get; set; } // Value types that implement IList (like NpgsqlPolygon) public string? ElementType { get; set; } public bool IsElementComplexType { get; set; } public bool IsElementGuid { get; set; } @@ -67,11 +69,13 @@ namespace Svrnty.CQRS.Grpc.Generators.Models IsNullable = false; IsDecimal = false; IsDateTime = false; + IsDateTimeOffset = false; IsGuid = false; IsJsonElement = false; IsBinaryType = false; IsStream = false; IsReadOnly = false; + IsValueTypeCollection = false; IsElementComplexType = false; IsElementGuid = false; } diff --git a/Svrnty.CQRS.Grpc.Generators/ProtoTypeMapper.cs b/Svrnty.CQRS.Grpc.Generators/ProtoTypeMapper.cs index cdce5cf..d4a7f58 100644 --- a/Svrnty.CQRS.Grpc.Generators/ProtoTypeMapper.cs +++ b/Svrnty.CQRS.Grpc.Generators/ProtoTypeMapper.cs @@ -159,7 +159,10 @@ internal static class ProtoFileTypeMapper } /// - /// Converts C# PascalCase property name to proto snake_case field name + /// Converts C# PascalCase property name to proto snake_case field name. + /// Uses simple conversion: add underscore before each uppercase letter (except first). + /// This matches protobuf's C# codegen expectations for PascalCase conversion. + /// Example: TotalADeduire -> total_a_deduire -> TotalADeduire (in generated C#) /// public static string ToSnakeCase(string pascalCase) { @@ -174,16 +177,8 @@ internal static class ProtoFileTypeMapper var c = pascalCase[i]; if (char.IsUpper(c)) { - // Handle sequences of uppercase letters (e.g., "APIKey" -> "api_key") - if (i + 1 < pascalCase.Length && char.IsUpper(pascalCase[i + 1])) - { - result.Append(char.ToLowerInvariant(c)); - } - else - { - result.Append('_'); - result.Append(char.ToLowerInvariant(c)); - } + result.Append('_'); + result.Append(char.ToLowerInvariant(c)); } else { diff --git a/Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs b/Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs index 8aef222..fe5e3f0 100644 --- a/Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs +++ b/Svrnty.CQRS.MinimalApi/EndpointRouteBuilderExtensions.cs @@ -19,7 +19,6 @@ public static class EndpointRouteBuilderExtensions public static IEndpointRouteBuilder MapSvrntyQueries(this IEndpointRouteBuilder endpoints, string routePrefix = "api/query") { var queryDiscovery = endpoints.ServiceProvider.GetRequiredService(); - var authorizationService = endpoints.ServiceProvider.GetService(); foreach (var queryMeta in queryDiscovery.GetQueries()) { @@ -33,8 +32,8 @@ public static class EndpointRouteBuilderExtensions var route = $"{routePrefix}/{queryMeta.LowerCamelCaseName}"; - MapQueryPost(endpoints, route, queryMeta, authorizationService); - MapQueryGet(endpoints, route, queryMeta, authorizationService); + MapQueryPost(endpoints, route, queryMeta); + MapQueryGet(endpoints, route, queryMeta); } return endpoints; @@ -43,13 +42,13 @@ public static class EndpointRouteBuilderExtensions private static void MapQueryPost( IEndpointRouteBuilder endpoints, string route, - IQueryMeta queryMeta, - IQueryAuthorizationService? authorizationService) + IQueryMeta queryMeta) { var handlerType = typeof(IQueryHandler<,>).MakeGenericType(queryMeta.QueryType, queryMeta.QueryResultType); endpoints.MapPost(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) => { + var authorizationService = serviceProvider.GetService(); if (authorizationService != null) { var authorizationResult = await authorizationService.IsAllowedAsync(queryMeta.QueryType, cancellationToken); @@ -90,13 +89,13 @@ public static class EndpointRouteBuilderExtensions private static void MapQueryGet( IEndpointRouteBuilder endpoints, string route, - IQueryMeta queryMeta, - IQueryAuthorizationService? authorizationService) + IQueryMeta queryMeta) { var handlerType = typeof(IQueryHandler<,>).MakeGenericType(queryMeta.QueryType, queryMeta.QueryResultType); endpoints.MapGet(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) => { + var authorizationService = serviceProvider.GetService(); if (authorizationService != null) { var authorizationResult = await authorizationService.IsAllowedAsync(queryMeta.QueryType, cancellationToken); @@ -153,7 +152,6 @@ public static class EndpointRouteBuilderExtensions public static IEndpointRouteBuilder MapSvrntyCommands(this IEndpointRouteBuilder endpoints, string routePrefix = "api/command") { var commandDiscovery = endpoints.ServiceProvider.GetRequiredService(); - var authorizationService = endpoints.ServiceProvider.GetService(); foreach (var commandMeta in commandDiscovery.GetCommands()) { @@ -165,11 +163,11 @@ public static class EndpointRouteBuilderExtensions if (commandMeta.CommandResultType == null) { - MapCommandWithoutResult(endpoints, route, commandMeta, authorizationService); + MapCommandWithoutResult(endpoints, route, commandMeta); } else { - MapCommandWithResult(endpoints, route, commandMeta, authorizationService); + MapCommandWithResult(endpoints, route, commandMeta); } } @@ -179,13 +177,13 @@ public static class EndpointRouteBuilderExtensions private static void MapCommandWithoutResult( IEndpointRouteBuilder endpoints, string route, - ICommandMeta commandMeta, - ICommandAuthorizationService? authorizationService) + ICommandMeta commandMeta) { var handlerType = typeof(ICommandHandler<>).MakeGenericType(commandMeta.CommandType); endpoints.MapPost(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) => { + var authorizationService = serviceProvider.GetService(); if (authorizationService != null) { var authorizationResult = await authorizationService.IsAllowedAsync(commandMeta.CommandType, cancellationToken); @@ -221,13 +219,13 @@ public static class EndpointRouteBuilderExtensions private static void MapCommandWithResult( IEndpointRouteBuilder endpoints, string route, - ICommandMeta commandMeta, - ICommandAuthorizationService? authorizationService) + ICommandMeta commandMeta) { var handlerType = typeof(ICommandHandler<,>).MakeGenericType(commandMeta.CommandType, commandMeta.CommandResultType!); endpoints.MapPost(route, async (HttpContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken) => { + var authorizationService = serviceProvider.GetService(); if (authorizationService != null) { var authorizationResult = await authorizationService.IsAllowedAsync(commandMeta.CommandType, cancellationToken); diff --git a/Svrnty.Sample/Protos/cqrs_services.proto b/Svrnty.Sample/Protos/cqrs_services.proto index f5eaed2..10bad1c 100644 --- a/Svrnty.Sample/Protos/cqrs_services.proto +++ b/Svrnty.Sample/Protos/cqrs_services.proto @@ -1,7 +1,111 @@ syntax = "proto3"; -option csharp_namespace = "Generated.Grpc"; +option csharp_namespace = "Svrnty.Sample.Grpc"; package cqrs; -// Placeholder proto file - will be regenerated on next build +// Command service for CQRS operations +service CommandService { + // AddUserCommand operation + rpc AddUser (AddUserCommandRequest) returns (AddUserCommandResponse); + + // RemoveUserCommand operation + rpc RemoveUser (RemoveUserCommandRequest) returns (RemoveUserCommandResponse); + +} + +// Query service for CQRS operations +service QueryService { + // FetchUserQuery operation + rpc FetchUser (FetchUserQueryRequest) returns (FetchUserQueryResponse); + +} + +// DynamicQuery service for CQRS operations +service DynamicQueryService { + // Dynamic query for User + rpc QueryUsers (DynamicQueryUsersRequest) returns (DynamicQueryUsersResponse); + +} + +// Request message for AddUserCommand +message AddUserCommandRequest { + string name = 1; + string email = 2; + int32 age = 3; +} + +// Response message for AddUserCommand +message AddUserCommandResponse { + int32 result = 1; +} + +// Request message for RemoveUserCommand +message RemoveUserCommandRequest { + int32 user_id = 1; +} + +// Response message for RemoveUserCommand +message RemoveUserCommandResponse { +} + +// Request message for FetchUserQuery +message FetchUserQueryRequest { + int32 user_id = 1; +} + +// Response message for FetchUserQuery +message FetchUserQueryResponse { + User result = 1; +} + +// User entity +message User { + int32 id = 1; + string name = 2; + string email = 3; +} + +// Dynamic query filter with AND/OR support +message DynamicQueryFilter { + string path = 1; + int32 type = 2; // PoweredSoft.DynamicQuery.Core.FilterType + string value = 3; + repeated DynamicQueryFilter and = 4; + repeated DynamicQueryFilter or = 5; +} + +// Dynamic query sort +message DynamicQuerySort { + string path = 1; + bool ascending = 2; +} + +// Dynamic query group +message DynamicQueryGroup { + string path = 1; +} + +// Dynamic query aggregate +message DynamicQueryAggregate { + string path = 1; + int32 type = 2; // PoweredSoft.DynamicQuery.Core.AggregateType +} + +// Dynamic query request for User +message DynamicQueryUsersRequest { + int32 page = 1; + int32 page_size = 2; + repeated DynamicQueryFilter filters = 3; + repeated DynamicQuerySort sorts = 4; + repeated DynamicQueryGroup groups = 5; + repeated DynamicQueryAggregate aggregates = 6; +} + +// Dynamic query response for User +message DynamicQueryUsersResponse { + repeated User data = 1; + int64 total_records = 2; + int32 number_of_pages = 3; +} +