From 99aebcf314770002a83624e673eb50b5cd2700cf Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 6 Jan 2026 10:33:55 -0500 Subject: [PATCH] Fix proto generation for collection types (NpgsqlPolygon, etc.) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add IsCollectionTypeByInterface() to detect types implementing IList, ICollection, IEnumerable - Add GetCollectionElementTypeByInterface() to extract element type from collection interfaces - Add IsCollectionInternalProperty() to filter out Count, Capacity, IsReadOnly, etc. - Update GenerateComplexTypeMessage to generate `repeated T items` for collection types - Filter out indexers (!p.IsIndexer) and collection-internal properties from all property extraction This fixes the invalid proto syntax where C# indexers (this[]) were being generated as proto fields, causing proto compilation errors. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../ProtoFileGenerator.cs | 87 +++++++++++------ .../ProtoTypeMapper.cs | 93 +++++++++++++++++++ 2 files changed, 151 insertions(+), 29 deletions(-) diff --git a/Svrnty.CQRS.Grpc.Generators/ProtoFileGenerator.cs b/Svrnty.CQRS.Grpc.Generators/ProtoFileGenerator.cs index e6bdd73..100bc3a 100644 --- a/Svrnty.CQRS.Grpc.Generators/ProtoFileGenerator.cs +++ b/Svrnty.CQRS.Grpc.Generators/ProtoFileGenerator.cs @@ -320,7 +320,9 @@ internal class ProtoFileGenerator var properties = type.GetMembers() .OfType() - .Where(p => p.DeclaredAccessibility == Accessibility.Public) + .Where(p => p.DeclaredAccessibility == Accessibility.Public && + !p.IsIndexer && + !ProtoFileTypeMapper.IsCollectionInternalProperty(p.Name)) .ToList(); // Collect nested complex types to generate after closing this message @@ -423,48 +425,73 @@ internal class ProtoFileGenerator _messagesBuilder.AppendLine($"// {type.Name} entity"); _messagesBuilder.AppendLine($"message {type.Name} {{"); - var properties = type.GetMembers() - .OfType() - .Where(p => p.DeclaredAccessibility == Accessibility.Public) - .ToList(); - // Collect nested complex types to generate after closing this message var nestedComplexTypes = new List(); - int fieldNumber = 1; - foreach (var prop in properties) + // Check if this type is a collection (implements IList, ICollection, etc.) + var collectionElementType = ProtoFileTypeMapper.GetCollectionElementTypeByInterface(type); + if (collectionElementType != null) { - if (ProtoFileTypeMapper.IsUnsupportedType(prop.Type)) - { - _messagesBuilder.AppendLine($" // Skipped: {prop.Name} - unsupported type {prop.Type.Name}"); - continue; - } - - var protoType = ProtoFileTypeMapper.MapType(prop.Type, out var needsImport, out var importPath); + // This type is a collection - generate a single repeated field for items + var protoElementType = ProtoFileTypeMapper.MapType(collectionElementType, out var needsImport, out var importPath); if (needsImport && importPath != null) { _requiredImports.Add(importPath); } - var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name); - _messagesBuilder.AppendLine($" {protoType} {fieldName} = {fieldNumber};"); + _messagesBuilder.AppendLine($" repeated {protoElementType} items = 1;"); - // Track enums for later generation - var enumType = ProtoFileTypeMapper.GetEnumType(prop.Type); - if (enumType != null) + // Track the element type for nested generation + if (IsComplexType(collectionElementType) && collectionElementType is INamedTypeSymbol elementNamedType) { - TrackEnumType(enumType); + nestedComplexTypes.Add(elementNamedType); } + } + else + { + // Not a collection - generate properties as usual + var properties = type.GetMembers() + .OfType() + .Where(p => p.DeclaredAccessibility == Accessibility.Public && + !p.IsIndexer && + !ProtoFileTypeMapper.IsCollectionInternalProperty(p.Name)) + .ToList(); - // Collect complex types to generate after this message is closed - // Use GetElementOrUnderlyingType to extract element type from collections - var underlyingType = ProtoFileTypeMapper.GetElementOrUnderlyingType(prop.Type); - if (IsComplexType(underlyingType) && underlyingType is INamedTypeSymbol namedType) + int fieldNumber = 1; + foreach (var prop in properties) { - nestedComplexTypes.Add(namedType); - } + if (ProtoFileTypeMapper.IsUnsupportedType(prop.Type)) + { + _messagesBuilder.AppendLine($" // Skipped: {prop.Name} - unsupported type {prop.Type.Name}"); + continue; + } - fieldNumber++; + var protoType = ProtoFileTypeMapper.MapType(prop.Type, out var needsImport, out var importPath); + if (needsImport && importPath != null) + { + _requiredImports.Add(importPath); + } + + var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name); + _messagesBuilder.AppendLine($" {protoType} {fieldName} = {fieldNumber};"); + + // Track enums for later generation + var enumType = ProtoFileTypeMapper.GetEnumType(prop.Type); + if (enumType != null) + { + TrackEnumType(enumType); + } + + // Collect complex types to generate after this message is closed + // Use GetElementOrUnderlyingType to extract element type from collections + var underlyingType = ProtoFileTypeMapper.GetElementOrUnderlyingType(prop.Type); + if (IsComplexType(underlyingType) && underlyingType is INamedTypeSymbol namedType) + { + nestedComplexTypes.Add(namedType); + } + + fieldNumber++; + } } _messagesBuilder.AppendLine("}"); @@ -755,7 +782,9 @@ internal class ProtoFileGenerator int fieldNumber = 1; foreach (var prop in type.GetMembers().OfType() - .Where(p => p.DeclaredAccessibility == Accessibility.Public)) + .Where(p => p.DeclaredAccessibility == Accessibility.Public && + !p.IsIndexer && + !ProtoFileTypeMapper.IsCollectionInternalProperty(p.Name))) { if (ProtoFileTypeMapper.IsUnsupportedType(prop.Type)) continue; diff --git a/Svrnty.CQRS.Grpc.Generators/ProtoTypeMapper.cs b/Svrnty.CQRS.Grpc.Generators/ProtoTypeMapper.cs index a0d26f8..9d01d16 100644 --- a/Svrnty.CQRS.Grpc.Generators/ProtoTypeMapper.cs +++ b/Svrnty.CQRS.Grpc.Generators/ProtoTypeMapper.cs @@ -243,4 +243,97 @@ internal static class ProtoFileTypeMapper } return null; } + + /// + /// Checks if a type is a collection by checking if it implements IList{T}, ICollection{T}, or IEnumerable{T} + /// This handles types like NpgsqlPolygon that implement IList{NpgsqlPoint} but aren't named "List" + /// + public static bool IsCollectionTypeByInterface(ITypeSymbol typeSymbol) + { + if (typeSymbol is not INamedTypeSymbol namedType) + return false; + + // Skip string (implements IEnumerable) + if (namedType.SpecialType == SpecialType.System_String) + return false; + + // Check all interfaces for IList, ICollection, or IEnumerable + 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.IEnumerable" || + ifaceName == "System.Collections.Generic.IReadOnlyList" || + ifaceName == "System.Collections.Generic.IReadOnlyCollection") + { + return true; + } + } + } + + return false; + } + + /// + /// Gets the element type from a collection that implements IList{T}, ICollection{T}, or IEnumerable{T} + /// Returns null if the type is not a collection + /// + public static ITypeSymbol? GetCollectionElementTypeByInterface(ITypeSymbol typeSymbol) + { + if (typeSymbol is not INamedTypeSymbol namedType) + return null; + + // Skip string + if (namedType.SpecialType == SpecialType.System_String) + return null; + + // Prefer IList over ICollection over IEnumerable + ITypeSymbol? elementType = null; + int priority = 0; + + foreach (var iface in namedType.AllInterfaces) + { + if (iface.IsGenericType && iface.TypeArguments.Length == 1) + { + var ifaceName = iface.OriginalDefinition.ToDisplayString(); + int currentPriority = 0; + + if (ifaceName == "System.Collections.Generic.IList" || + ifaceName == "System.Collections.Generic.IReadOnlyList") + currentPriority = 3; + else if (ifaceName == "System.Collections.Generic.ICollection" || + ifaceName == "System.Collections.Generic.IReadOnlyCollection") + currentPriority = 2; + else if (ifaceName == "System.Collections.Generic.IEnumerable") + currentPriority = 1; + + if (currentPriority > priority) + { + priority = currentPriority; + elementType = iface.TypeArguments[0]; + } + } + } + + return elementType; + } + + /// + /// Collection-internal properties that should be skipped when generating proto messages + /// + private static readonly System.Collections.Generic.HashSet CollectionInternalProperties = new() + { + "Count", "Capacity", "IsReadOnly", "IsSynchronized", "SyncRoot", "Keys", "Values" + }; + + /// + /// Checks if a property name is a collection-internal property that should be skipped + /// + public static bool IsCollectionInternalProperty(string propertyName) + { + return CollectionInternalProperties.Contains(propertyName); + } }