Fix gRPC source generator for complex nested types

- Add DateTime/Timestamp conversion in nested property mapping
- Add IsReadOnly property detection to skip computed properties
- Extract ElementNestedProperties for complex list element types
- Skip read-only properties in GenerateComplexObjectMapping and GenerateComplexListMapping

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
David Nguyen 2026-01-06 23:25:01 -05:00
parent 661f5b4b1c
commit bd43bc9bde
Signed by: david.nguyen
GPG Key ID: D5FB5A5715829326
4 changed files with 298 additions and 117 deletions

View File

@ -363,6 +363,8 @@ namespace Svrnty.CQRS.Grpc.Generators
IsGuid = IsGuidType(property.Type),
IsJsonElement = IsJsonElementType(property.Type),
IsList = IsListOrCollection(property.Type),
IsBinaryType = IsBinaryType(property.Type),
IsStream = IsStreamType(property.Type),
};
// If it's a list, extract element type info
@ -482,6 +484,24 @@ namespace Svrnty.CQRS.Grpc.Generators
return unwrapped.ToDisplayString() == "System.Text.Json.JsonElement";
}
private static bool IsBinaryType(ITypeSymbol type)
{
return ProtoFileTypeMapper.IsBinaryType(type);
}
private static bool IsStreamType(ITypeSymbol type)
{
var fullTypeName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
if (fullTypeName.Contains("System.IO.Stream") ||
fullTypeName.Contains("System.IO.MemoryStream") ||
fullTypeName.Contains("System.IO.FileStream"))
{
return true;
}
var typeName = type.Name;
return typeName == "Stream" || typeName == "MemoryStream" || typeName == "FileStream";
}
private static bool IsListOrCollection(ITypeSymbol type)
{
if (type is IArrayTypeSymbol)
@ -582,6 +602,9 @@ namespace Svrnty.CQRS.Grpc.Generators
foreach (var property in properties)
{
// Skip read-only properties (no setter) - they are computed and can't be set
var isReadOnly = property.IsReadOnly || property.SetMethod == null;
var propertyType = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var propInfo = new PropertyInfo
{
@ -591,6 +614,7 @@ namespace Svrnty.CQRS.Grpc.Generators
ProtoType = string.Empty,
FieldNumber = 0,
IsComplexType = IsUserDefinedComplexType(property.Type),
IsReadOnly = isReadOnly,
// Type metadata
IsNullable = IsNullableType(property.Type),
IsEnum = IsEnumType(property.Type),
@ -599,6 +623,8 @@ namespace Svrnty.CQRS.Grpc.Generators
IsGuid = IsGuidType(property.Type),
IsJsonElement = IsJsonElementType(property.Type),
IsList = IsListOrCollection(property.Type),
IsBinaryType = IsBinaryType(property.Type),
IsStream = IsStreamType(property.Type),
};
// If it's a list, extract element type info
@ -610,6 +636,13 @@ namespace Svrnty.CQRS.Grpc.Generators
propInfo.ElementType = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
propInfo.IsElementComplexType = IsUserDefinedComplexType(elementType);
propInfo.IsElementGuid = IsGuidType(elementType);
// Extract nested properties for complex element types
if (propInfo.IsElementComplexType && elementType is INamedTypeSymbol namedElementType)
{
propInfo.ElementNestedProperties = new List<PropertyInfo>();
ExtractNestedPropertiesWithTypeInfo(namedElementType, propInfo.ElementNestedProperties);
}
}
}
// Recursively extract nested properties for complex types
@ -716,6 +749,29 @@ namespace Svrnty.CQRS.Grpc.Generators
}
}
// Handle binary types (proto ByteString -> C# byte[]/Stream)
if (prop.IsBinaryType)
{
if (prop.IsStream)
{
// ByteString -> MemoryStream
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source}?.IsEmpty == false ? new System.IO.MemoryStream({source}.ToByteArray()) : null,";
}
return $"{indent}{prop.Name} = new System.IO.MemoryStream({source}.ToByteArray()),";
}
else
{
// ByteString -> byte[]
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source}?.IsEmpty == false ? {source}.ToByteArray() : null,";
}
return $"{indent}{prop.Name} = {source}.ToByteArray(),";
}
}
// Handle complex types (single objects)
if (prop.IsComplexType)
{
@ -734,6 +790,9 @@ namespace Svrnty.CQRS.Grpc.Generators
foreach (var nestedProp in prop.ElementNestedProperties!)
{
// Skip read-only properties - they can't be assigned
if (nestedProp.IsReadOnly) continue;
var nestedSourcePropName = char.ToUpper(nestedProp.Name[0]) + nestedProp.Name.Substring(1);
var nestedAssignment = GenerateNestedPropertyAssignment(nestedProp, "x", indent + " ");
sb.AppendLine(nestedAssignment);
@ -751,6 +810,9 @@ namespace Svrnty.CQRS.Grpc.Generators
foreach (var nestedProp in prop.NestedProperties)
{
// Skip read-only properties - they can't be assigned
if (nestedProp.IsReadOnly) continue;
var nestedAssignment = GenerateNestedPropertyAssignment(nestedProp, source, indent + " ");
sb.AppendLine(nestedAssignment);
}
@ -783,6 +845,19 @@ namespace Svrnty.CQRS.Grpc.Generators
}
}
// Handle DateTime (proto Timestamp -> C# DateTime)
if (prop.IsDateTime)
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source} == null ? (System.DateTime?)null : {source}.ToDateTime(),";
}
else
{
return $"{indent}{prop.Name} = {source}.ToDateTime(),";
}
}
// Handle Guid
if (prop.IsGuid)
{
@ -796,9 +871,13 @@ namespace Svrnty.CQRS.Grpc.Generators
}
}
// Handle lists
// Handle lists with complex element types
if (prop.IsList)
{
if (prop.IsElementComplexType && prop.ElementNestedProperties != null && prop.ElementNestedProperties.Any())
{
return GenerateComplexListMapping(prop, source, indent);
}
return $"{indent}{prop.Name} = {source}?.ToList(),";
}
@ -897,6 +976,29 @@ namespace Svrnty.CQRS.Grpc.Generators
return $"{indent}{prop.Name} = (int){source},";
}
// Handle binary types (byte[], Stream -> ByteString)
if (prop.IsBinaryType)
{
if (prop.IsStream)
{
// Stream -> ByteString: read stream to bytes first
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source} != null ? Google.Protobuf.ByteString.FromStream({source}) : Google.Protobuf.ByteString.Empty,";
}
return $"{indent}{prop.Name} = Google.Protobuf.ByteString.FromStream({source}),";
}
else
{
// byte[] -> ByteString
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source} != null ? Google.Protobuf.ByteString.CopyFrom({source}) : Google.Protobuf.ByteString.Empty,";
}
return $"{indent}{prop.Name} = Google.Protobuf.ByteString.CopyFrom({source}),";
}
}
// Handle complex types (single objects)
if (prop.IsComplexType)
{
@ -959,6 +1061,32 @@ namespace Svrnty.CQRS.Grpc.Generators
{
var source = $"{sourceVar}.{prop.Name}";
// Handle DateTime (C# DateTime -> proto Timestamp)
if (prop.IsDateTime)
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source} != null ? Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(System.DateTime.SpecifyKind({source}.Value, System.DateTimeKind.Utc)) : null,";
}
else
{
return $"{indent}{prop.Name} = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(System.DateTime.SpecifyKind({source}, System.DateTimeKind.Utc)),";
}
}
// Handle DateOnly (C# DateOnly -> proto string)
if (prop.Type.Contains("DateOnly"))
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source}?.ToString(\"yyyy-MM-dd\") ?? string.Empty,";
}
else
{
return $"{indent}{prop.Name} = {source}.ToString(\"yyyy-MM-dd\"),";
}
}
// Handle Guid
if (prop.IsGuid)
{
@ -988,9 +1116,34 @@ namespace Svrnty.CQRS.Grpc.Generators
// Handle enums
if (prop.IsEnum)
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = (int)({source} ?? default),";
}
return $"{indent}{prop.Name} = (int){source},";
}
// Handle binary types (C# byte[]/Stream -> proto ByteString)
if (prop.IsBinaryType)
{
if (prop.IsStream)
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source} != null ? Google.Protobuf.ByteString.FromStream({source}) : Google.Protobuf.ByteString.Empty,";
}
return $"{indent}{prop.Name} = Google.Protobuf.ByteString.FromStream({source}),";
}
else
{
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source} != null ? Google.Protobuf.ByteString.CopyFrom({source}) : Google.Protobuf.ByteString.Empty,";
}
return $"{indent}{prop.Name} = Google.Protobuf.ByteString.CopyFrom({source}),";
}
}
// Handle lists
if (prop.IsList)
{
@ -1000,10 +1153,18 @@ namespace Svrnty.CQRS.Grpc.Generators
}
else if (prop.IsElementComplexType)
{
// Complex list elements need mapping - but we don't have nested property info here
// Fall back to creating empty proto objects (the user needs to ensure types are compatible)
var elementTypeName = prop.ElementType?.Split('.').Last() ?? "object";
return $"{indent}{prop.Name} = {{ {source}?.Select(x => new {elementTypeName}()) ?? Enumerable.Empty<{elementTypeName}>() }},";
// Complex list elements need mapping
if (prop.ElementNestedProperties != null && prop.ElementNestedProperties.Any())
{
// Use recursive mapping for nested properties
return GenerateResultComplexListMapping(prop, source, indent);
}
else
{
// Fall back to creating empty proto objects (the user needs to ensure types are compatible)
var elementTypeName = prop.ElementType?.Split('.').Last() ?? "object";
return $"{indent}{prop.Name} = {{ {source}?.Select(x => new {elementTypeName}()) ?? Enumerable.Empty<{elementTypeName}>() }},";
}
}
return $"{indent}{prop.Name} = {{ {source} }},";
}
@ -1012,7 +1173,12 @@ namespace Svrnty.CQRS.Grpc.Generators
if (prop.IsComplexType)
{
var typeName = prop.Type.Split('.').Last().Replace("?", "");
if (prop.IsNullable || prop.Type.EndsWith("?"))
if (prop.NestedProperties != null && prop.NestedProperties.Any())
{
// Use recursive mapping for nested properties
return GenerateResultComplexObjectMapping(prop, source, indent);
}
else if (prop.IsNullable || prop.Type.EndsWith("?"))
{
return $"{indent}{prop.Name} = {source} != null ? new {typeName}() : null,";
}
@ -1028,6 +1194,12 @@ namespace Svrnty.CQRS.Grpc.Generators
return $"{indent}{prop.Name} = {source} ?? string.Empty,";
}
// Handle nullable value types (int?, long?, double?, etc.)
if (prop.IsNullable)
{
return $"{indent}{prop.Name} = {source} ?? default,";
}
// Default: direct assignment
return $"{indent}{prop.Name} = {source},";
}
@ -1079,6 +1251,8 @@ namespace Svrnty.CQRS.Grpc.Generators
IsJsonElement = IsJsonElementType(property.Type),
IsList = IsListOrCollection(property.Type),
IsComplexType = IsUserDefinedComplexType(property.Type),
IsBinaryType = IsBinaryType(property.Type),
IsStream = IsStreamType(property.Type),
};
// If it's a list, extract element type info
@ -1090,6 +1264,13 @@ namespace Svrnty.CQRS.Grpc.Generators
propInfo.ElementType = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
propInfo.IsElementComplexType = IsUserDefinedComplexType(elementType);
propInfo.IsElementGuid = IsGuidType(elementType);
// Extract nested properties for complex element types
if (propInfo.IsElementComplexType && elementType is INamedTypeSymbol namedElementType)
{
propInfo.ElementNestedProperties = new List<PropertyInfo>();
ExtractNestedPropertiesWithTypeInfo(namedElementType, propInfo.ElementNestedProperties);
}
}
}
// If it's a complex type (not list), extract nested properties
@ -1133,6 +1314,8 @@ namespace Svrnty.CQRS.Grpc.Generators
IsJsonElement = IsJsonElementType(property.Type),
IsList = IsListOrCollection(property.Type),
IsComplexType = IsUserDefinedComplexType(property.Type),
IsBinaryType = IsBinaryType(property.Type),
IsStream = IsStreamType(property.Type),
};
// If it's a list, extract element type info
@ -2528,7 +2711,8 @@ namespace Svrnty.CQRS.Grpc.Generators
sb.AppendLine(" var domainElementType = domainProp.PropertyType.IsArray");
sb.AppendLine(" ? domainProp.PropertyType.GetElementType()");
sb.AppendLine(" : domainProp.PropertyType.IsGenericType ? domainProp.PropertyType.GetGenericArguments()[0] : null;");
sb.AppendLine(" var protoElementType = protoField.MessageType?.ClrType;");
sb.AppendLine(" // Only access MessageType for message fields (throws for primitives)");
sb.AppendLine(" var protoElementType = protoField.FieldType == Google.Protobuf.Reflection.FieldType.Message ? protoField.MessageType?.ClrType : null;");
sb.AppendLine();
sb.AppendLine(" foreach (var item in enumerable)");
sb.AppendLine(" {");
@ -2552,14 +2736,66 @@ namespace Svrnty.CQRS.Grpc.Generators
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" // Handle enumerable value types that map to proto messages with repeated fields (e.g., NpgsqlPolygon -> proto message with items)");
sb.AppendLine(" else if (domainProp.PropertyType.IsValueType && ");
sb.AppendLine(" domainValue is System.Collections.IEnumerable valueTypeEnumerable &&");
sb.AppendLine(" protoField.FieldType == Google.Protobuf.Reflection.FieldType.Message)");
sb.AppendLine(" {");
sb.AppendLine(" // Create the proto message and look for its 'items' repeated field");
sb.AppendLine(" var protoFieldType = protoField.MessageType?.ClrType;");
sb.AppendLine(" if (protoFieldType != null && typeof(Google.Protobuf.IMessage).IsAssignableFrom(protoFieldType))");
sb.AppendLine(" {");
sb.AppendLine(" var nestedProto = System.Activator.CreateInstance(protoFieldType) as Google.Protobuf.IMessage;");
sb.AppendLine(" if (nestedProto != null)");
sb.AppendLine(" {");
sb.AppendLine(" // Find the 'items' field in the proto message");
sb.AppendLine(" var itemsField = nestedProto.Descriptor.FindFieldByName(\"items\");");
sb.AppendLine(" if (itemsField != null && itemsField.IsRepeated)");
sb.AppendLine(" {");
sb.AppendLine(" var repeatedField = itemsField.Accessor.GetValue(nestedProto);");
sb.AppendLine(" var repeatedFieldType = repeatedField?.GetType();");
sb.AppendLine(" var protoElementType = itemsField.MessageType?.ClrType;");
sb.AppendLine(" ");
sb.AppendLine(" if (repeatedFieldType != null && protoElementType != null)");
sb.AppendLine(" {");
sb.AppendLine(" var addMethod = repeatedFieldType.GetMethod(\"Add\", new[] { protoElementType });");
sb.AppendLine(" if (addMethod != null)");
sb.AppendLine(" {");
sb.AppendLine(" // Get element type from the enumerable");
sb.AppendLine(" var enumerableInterface = domainProp.PropertyType.GetInterfaces()");
sb.AppendLine(" .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(System.Collections.Generic.IEnumerable<>));");
sb.AppendLine(" var domainElementType = enumerableInterface?.GetGenericArguments()[0];");
sb.AppendLine(" ");
sb.AppendLine(" foreach (var item in valueTypeEnumerable)");
sb.AppendLine(" {");
sb.AppendLine(" if (item == null) continue;");
sb.AppendLine(" ");
sb.AppendLine(" // Map each item to the proto element type");
sb.AppendLine(" if (domainElementType != null && typeof(Google.Protobuf.IMessage).IsAssignableFrom(protoElementType))");
sb.AppendLine(" {");
sb.AppendLine(" var mapMethod = typeof(DynamicQueryServiceImpl).GetMethod(\"MapToProtoModel\",");
sb.AppendLine(" System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!");
sb.AppendLine(" .MakeGenericMethod(domainElementType, protoElementType);");
sb.AppendLine(" var mappedItem = mapMethod.Invoke(null, new[] { item });");
sb.AppendLine(" if (mappedItem != null)");
sb.AppendLine(" addMethod.Invoke(repeatedField, new[] { mappedItem });");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" protoAccessor.SetValue(proto, nestedProto);");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" // Handle nested complex types (non-primitive, non-enum, non-string, non-collection)");
sb.AppendLine(" else if (!domainProp.PropertyType.IsPrimitive && ");
sb.AppendLine(" domainProp.PropertyType != typeof(string) && ");
sb.AppendLine(" !domainProp.PropertyType.IsEnum &&");
sb.AppendLine(" !domainProp.PropertyType.IsValueType)");
sb.AppendLine(" {");
sb.AppendLine(" // Get the proto field type and recursively map");
sb.AppendLine(" var protoFieldType = protoAccessor.GetValue(proto)?.GetType() ?? protoField.MessageType?.ClrType;");
sb.AppendLine(" // Get the proto field type and recursively map (only access MessageType for message fields)");
sb.AppendLine(" var protoFieldType = protoAccessor.GetValue(proto)?.GetType() ?? (protoField.FieldType == Google.Protobuf.Reflection.FieldType.Message ? protoField.MessageType?.ClrType : null);");
sb.AppendLine(" if (protoFieldType != null && typeof(Google.Protobuf.IMessage).IsAssignableFrom(protoFieldType))");
sb.AppendLine(" {");
sb.AppendLine(" var mapMethod = typeof(DynamicQueryServiceImpl).GetMethod(\"MapToProtoModel\", ");

View File

@ -46,6 +46,9 @@ namespace Svrnty.CQRS.Grpc.Generators.Models
public bool IsDateTime { get; set; }
public bool IsGuid { get; set; }
public bool IsJsonElement { get; set; }
public bool IsBinaryType { get; set; } // Stream, byte[], MemoryStream
public bool IsStream { get; set; } // Specifically Stream types (not byte[])
public bool IsReadOnly { get; set; } // Read-only/computed properties should be skipped
public string? ElementType { get; set; }
public bool IsElementComplexType { get; set; }
public bool IsElementGuid { get; set; }
@ -66,6 +69,9 @@ namespace Svrnty.CQRS.Grpc.Generators.Models
IsDateTime = false;
IsGuid = false;
IsJsonElement = false;
IsBinaryType = false;
IsStream = false;
IsReadOnly = false;
IsElementComplexType = false;
IsElementGuid = false;
}

View File

@ -68,6 +68,20 @@ internal static class ProtoFileTypeMapper
}
}
// Handle byte[] array type (check before switch since it's an array)
if (typeSymbol is IArrayTypeSymbol arrayType && arrayType.ElementType.SpecialType == SpecialType.System_Byte)
{
return "bytes";
}
// Handle Stream types -> bytes
if (fullTypeName.Contains("System.IO.Stream") ||
fullTypeName.Contains("System.IO.MemoryStream") ||
fullTypeName.Contains("System.IO.FileStream"))
{
return "bytes";
}
// Basic types
switch (typeName)
{
@ -97,6 +111,10 @@ internal static class ProtoFileTypeMapper
return "double";
case "Byte[]":
return "bytes";
case "Stream":
case "MemoryStream":
case "FileStream":
return "bytes";
case "Guid":
// Guid serialized as string
return "string";
@ -184,8 +202,8 @@ internal static class ProtoFileTypeMapper
var fullTypeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// Skip these types - they should trigger a warning/error
if (fullTypeName.Contains("System.IO.Stream") ||
fullTypeName.Contains("System.Threading.CancellationToken") ||
// Note: Stream types are now supported (mapped to bytes)
if (fullTypeName.Contains("System.Threading.CancellationToken") ||
fullTypeName.Contains("System.Threading.Tasks.Task") ||
fullTypeName.Contains("System.Collections.Generic.IAsyncEnumerable") ||
fullTypeName.Contains("System.Func") ||
@ -198,6 +216,31 @@ internal static class ProtoFileTypeMapper
return false;
}
/// <summary>
/// Checks if a type is a Stream or byte array type (for special ByteString handling)
/// </summary>
public static bool IsBinaryType(ITypeSymbol typeSymbol)
{
var fullTypeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// Check for byte[]
if (typeSymbol is IArrayTypeSymbol arrayType && arrayType.ElementType.SpecialType == SpecialType.System_Byte)
{
return true;
}
// Check for Stream types
if (fullTypeName.Contains("System.IO.Stream") ||
fullTypeName.Contains("System.IO.MemoryStream") ||
fullTypeName.Contains("System.IO.FileStream"))
{
return true;
}
var typeName = typeSymbol.Name;
return typeName == "Stream" || typeName == "MemoryStream" || typeName == "FileStream";
}
/// <summary>
/// Gets the element type from a collection type, or returns the type itself if not a collection.
/// Also unwraps Nullable types.

View File

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