added domain events, fix IQueryalbeProvider abstraction, added support for sagas and RabbitMQ

This commit is contained in:
Mathias Beaulieu-Duncan
2025-12-20 15:13:05 -05:00
parent 4051800934
commit 9b9e2cbdbe
57 changed files with 4951 additions and 91 deletions
+405 -30
View File
@@ -2,29 +2,90 @@ using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Svrnty.CQRS.Grpc.Generators.Models;
namespace Svrnty.CQRS.Grpc.Generators;
/// <summary>
/// Generates Protocol Buffer (.proto) files from C# Command and Query types
/// Generates Protocol Buffer (.proto) files from C# Command, Query, and Notification types
/// </summary>
internal class ProtoFileGenerator
{
private readonly Compilation _compilation;
private readonly HashSet<string> _requiredImports = new HashSet<string>();
private readonly HashSet<string> _generatedMessages = new HashSet<string>();
private readonly HashSet<string> _generatedEnums = new HashSet<string>();
private readonly List<INamedTypeSymbol> _pendingEnums = new List<INamedTypeSymbol>();
private readonly StringBuilder _messagesBuilder = new StringBuilder();
private readonly StringBuilder _enumsBuilder = new StringBuilder();
private List<INamedTypeSymbol>? _allTypesCache;
/// <summary>
/// Gets the discovered notifications after Generate() is called.
/// </summary>
public List<NotificationInfo> DiscoveredNotifications { get; private set; } = new List<NotificationInfo>();
public ProtoFileGenerator(Compilation compilation)
{
_compilation = compilation;
}
/// <summary>
/// Gets all types from the compilation and all referenced assemblies
/// </summary>
private IEnumerable<INamedTypeSymbol> GetAllTypes()
{
if (_allTypesCache != null)
return _allTypesCache;
_allTypesCache = new List<INamedTypeSymbol>();
// Get types from the current assembly
CollectTypesFromNamespace(_compilation.Assembly.GlobalNamespace, _allTypesCache);
// 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, _allTypesCache);
}
}
return _allTypesCache;
}
private static void CollectTypesFromNamespace(INamespaceSymbol ns, List<INamedTypeSymbol> types)
{
foreach (var type in ns.GetTypeMembers())
{
types.Add(type);
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);
}
}
public string Generate(string packageName, string csharpNamespace)
{
var commands = DiscoverCommands();
var queries = DiscoverQueries();
var dynamicQueries = DiscoverDynamicQueries();
var notifications = DiscoverNotifications();
DiscoveredNotifications = notifications;
var sb = new StringBuilder();
@@ -98,6 +159,24 @@ internal class ProtoFileGenerator
sb.AppendLine();
}
// Notification Service (server streaming)
if (notifications.Any())
{
sb.AppendLine("// NotificationService for real-time streaming notifications");
sb.AppendLine("service NotificationService {");
foreach (var notification in notifications)
{
var methodName = $"SubscribeTo{notification.Name}";
var requestType = $"SubscribeTo{notification.Name}Request";
sb.AppendLine($" // Subscribe to {notification.Name} notifications");
sb.AppendLine($" rpc {methodName} ({requestType}) returns (stream {notification.Name});");
sb.AppendLine();
}
sb.AppendLine("}");
sb.AppendLine();
}
// Generate messages for commands
foreach (var command in commands)
{
@@ -118,7 +197,17 @@ internal class ProtoFileGenerator
GenerateDynamicQueryMessages(dq);
}
// Append all generated messages
// Generate messages for notifications
foreach (var notification in notifications)
{
GenerateNotificationMessages(notification);
}
// Generate any pending enum definitions
GeneratePendingEnums();
// Append all generated enums first, then messages
sb.Append(_enumsBuilder);
sb.Append(_messagesBuilder);
// Insert imports if any were needed
@@ -138,24 +227,78 @@ internal class ProtoFileGenerator
private List<INamedTypeSymbol> DiscoverCommands()
{
return _compilation.GetSymbolsWithName(
name => name.EndsWith("Command"),
SymbolFilter.Type)
.OfType<INamedTypeSymbol>()
.Where(t => !HasGrpcIgnoreAttribute(t))
.Where(t => t.TypeKind == TypeKind.Class || t.TypeKind == TypeKind.Struct)
.ToList();
// First, find all command handlers to know which commands are actually registered
var commandHandlerInterface = _compilation.GetTypeByMetadataName("Svrnty.CQRS.Abstractions.ICommandHandler`1");
var commandHandlerWithResultInterface = _compilation.GetTypeByMetadataName("Svrnty.CQRS.Abstractions.ICommandHandler`2");
if (commandHandlerInterface == null && commandHandlerWithResultInterface == null)
return new List<INamedTypeSymbol>();
var registeredCommands = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
foreach (var type in GetAllTypes())
{
if (type.IsAbstract || type.IsStatic)
continue;
foreach (var iface in type.AllInterfaces)
{
if (iface.IsGenericType)
{
if ((commandHandlerInterface != null && SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, commandHandlerInterface)) ||
(commandHandlerWithResultInterface != null && SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, commandHandlerWithResultInterface)))
{
var commandType = iface.TypeArguments[0] as INamedTypeSymbol;
if (commandType != null && !HasGrpcIgnoreAttribute(commandType))
{
registeredCommands.Add(commandType);
}
}
}
}
}
return registeredCommands.ToList();
}
private List<INamedTypeSymbol> DiscoverQueries()
{
return _compilation.GetSymbolsWithName(
name => name.EndsWith("Query"),
SymbolFilter.Type)
.OfType<INamedTypeSymbol>()
.Where(t => !HasGrpcIgnoreAttribute(t))
.Where(t => t.TypeKind == TypeKind.Class || t.TypeKind == TypeKind.Struct)
.ToList();
// First, find all query handlers to know which queries are actually registered
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");
if (queryHandlerInterface == null)
return new List<INamedTypeSymbol>();
var registeredQueries = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
foreach (var type in GetAllTypes())
{
if (type.IsAbstract || type.IsStatic)
continue;
foreach (var iface in type.AllInterfaces)
{
if (iface.IsGenericType && SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, queryHandlerInterface))
{
var queryType = iface.TypeArguments[0] as INamedTypeSymbol;
if (queryType != null && !HasGrpcIgnoreAttribute(queryType))
{
// Skip dynamic queries - they're handled separately
if (queryType.IsGenericType &&
((dynamicQueryInterface2 != null && SymbolEqualityComparer.Default.Equals(queryType.OriginalDefinition, dynamicQueryInterface2)) ||
(dynamicQueryInterface3 != null && SymbolEqualityComparer.Default.Equals(queryType.OriginalDefinition, dynamicQueryInterface3))))
{
continue;
}
registeredQueries.Add(queryType);
}
}
}
}
return registeredQueries.ToList();
}
private bool HasGrpcIgnoreAttribute(INamedTypeSymbol type)
@@ -180,6 +323,9 @@ internal class ProtoFileGenerator
.Where(p => p.DeclaredAccessibility == Accessibility.Public)
.ToList();
// Collect nested complex types to generate after closing this message
var nestedComplexTypes = new List<INamedTypeSymbol>();
int fieldNumber = 1;
foreach (var prop in properties)
{
@@ -199,10 +345,19 @@ internal class ProtoFileGenerator
var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name);
_messagesBuilder.AppendLine($" {protoType} {fieldName} = {fieldNumber};");
// If this is a complex type, generate its message too
if (IsComplexType(prop.Type))
// Track enums for later generation
var enumType = ProtoFileTypeMapper.GetEnumType(prop.Type);
if (enumType != null)
{
GenerateComplexTypeMessage(prop.Type as INamedTypeSymbol);
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++;
@@ -210,6 +365,12 @@ internal class ProtoFileGenerator
_messagesBuilder.AppendLine("}");
_messagesBuilder.AppendLine();
// Now generate nested complex type messages
foreach (var nestedType in nestedComplexTypes)
{
GenerateComplexTypeMessage(nestedType);
}
}
private void GenerateResponseMessage(INamedTypeSymbol type)
@@ -267,6 +428,9 @@ internal class ProtoFileGenerator
.Where(p => p.DeclaredAccessibility == Accessibility.Public)
.ToList();
// Collect nested complex types to generate after closing this message
var nestedComplexTypes = new List<INamedTypeSymbol>();
int fieldNumber = 1;
foreach (var prop in properties)
{
@@ -285,10 +449,19 @@ internal class ProtoFileGenerator
var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name);
_messagesBuilder.AppendLine($" {protoType} {fieldName} = {fieldNumber};");
// Recursively generate nested complex types
if (IsComplexType(prop.Type))
// Track enums for later generation
var enumType = ProtoFileTypeMapper.GetEnumType(prop.Type);
if (enumType != null)
{
GenerateComplexTypeMessage(prop.Type as INamedTypeSymbol);
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++;
@@ -296,6 +469,12 @@ internal class ProtoFileGenerator
_messagesBuilder.AppendLine("}");
_messagesBuilder.AppendLine();
// Now generate nested complex type messages
foreach (var nestedType in nestedComplexTypes)
{
GenerateComplexTypeMessage(nestedType);
}
}
private ITypeSymbol? GetResultType(INamedTypeSymbol commandOrQueryType)
@@ -305,11 +484,8 @@ internal class ProtoFileGenerator
? "ICommandHandler"
: "IQueryHandler";
// Find all types in the compilation
var allTypes = _compilation.GetSymbolsWithName(_ => true, SymbolFilter.Type)
.OfType<INamedTypeSymbol>();
foreach (var type in allTypes)
// Find all types in the compilation and referenced assemblies
foreach (var type in GetAllTypes())
{
// Check if this type implements the handler interface
foreach (var @interface in type.AllInterfaces)
@@ -372,10 +548,8 @@ internal class ProtoFileGenerator
return new List<INamedTypeSymbol>();
var dynamicQueryTypes = new List<INamedTypeSymbol>();
var allTypes = _compilation.GetSymbolsWithName(_ => true, SymbolFilter.Type)
.OfType<INamedTypeSymbol>();
foreach (var type in allTypes)
foreach (var type in GetAllTypes())
{
if (type.IsAbstract || type.IsStatic)
continue;
@@ -471,4 +645,205 @@ internal class ProtoFileGenerator
return word + "es";
return word + "s";
}
/// <summary>
/// Tracks an enum type for later generation
/// </summary>
private void TrackEnumType(INamedTypeSymbol enumType)
{
if (!_generatedEnums.Contains(enumType.Name) && !_pendingEnums.Any(e => e.Name == enumType.Name))
{
_pendingEnums.Add(enumType);
}
}
/// <summary>
/// Generates all pending enum definitions
/// </summary>
private void GeneratePendingEnums()
{
foreach (var enumType in _pendingEnums)
{
if (_generatedEnums.Contains(enumType.Name))
continue;
_generatedEnums.Add(enumType.Name);
_enumsBuilder.AppendLine($"// {enumType.Name} enum");
_enumsBuilder.AppendLine($"enum {enumType.Name} {{");
// Get all enum members
var members = enumType.GetMembers()
.OfType<IFieldSymbol>()
.Where(f => f.HasConstantValue)
.ToList();
foreach (var member in members)
{
var protoFieldName = $"{ProtoFileTypeMapper.ToSnakeCase(enumType.Name).ToUpperInvariant()}_{ProtoFileTypeMapper.ToSnakeCase(member.Name).ToUpperInvariant()}";
var value = member.ConstantValue;
_enumsBuilder.AppendLine($" {protoFieldName} = {value};");
}
_enumsBuilder.AppendLine("}");
_enumsBuilder.AppendLine();
}
}
/// <summary>
/// Discovers types marked with [StreamingNotification] attribute
/// </summary>
private List<NotificationInfo> DiscoverNotifications()
{
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 GetAllTypes())
{
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 = ExtractNotificationProperties(type);
// 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)
.Replace("global::", ""),
Namespace = type.ContainingNamespace?.ToDisplayString() ?? "",
SubscriptionKeyProperty = subscriptionKeyProp,
SubscriptionKeyInfo = keyPropInfo,
Properties = properties
});
}
return notifications;
}
/// <summary>
/// Extracts property information from a notification type
/// </summary>
private List<Models.PropertyInfo> ExtractNotificationProperties(INamedTypeSymbol type)
{
var properties = new List<Models.PropertyInfo>();
int fieldNumber = 1;
foreach (var prop in type.GetMembers().OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public))
{
if (ProtoFileTypeMapper.IsUnsupportedType(prop.Type))
continue;
var protoType = ProtoFileTypeMapper.MapType(prop.Type, out _, out _);
var enumType = ProtoFileTypeMapper.GetEnumType(prop.Type);
properties.Add(new Models.PropertyInfo
{
Name = prop.Name,
Type = prop.Type.Name,
FullyQualifiedType = prop.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
.Replace("global::", ""),
ProtoType = protoType,
FieldNumber = fieldNumber++,
IsEnum = enumType != null,
IsDecimal = prop.Type.SpecialType == SpecialType.System_Decimal ||
prop.Type.ToDisplayString().Contains("decimal"),
IsDateTime = prop.Type.ToDisplayString().Contains("DateTime"),
IsNullable = prop.Type.NullableAnnotation == NullableAnnotation.Annotated ||
(prop.Type is INamedTypeSymbol namedType &&
namedType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T)
});
if (enumType != null)
{
TrackEnumType(enumType);
}
}
return properties;
}
/// <summary>
/// Generates proto messages for a notification type
/// </summary>
private void GenerateNotificationMessages(NotificationInfo notification)
{
// Generate subscription request message (contains only the subscription key)
var requestMessageName = $"SubscribeTo{notification.Name}Request";
if (!_generatedMessages.Contains(requestMessageName))
{
_generatedMessages.Add(requestMessageName);
_messagesBuilder.AppendLine($"// Subscription request for {notification.Name}");
_messagesBuilder.AppendLine($"message {requestMessageName} {{");
_messagesBuilder.AppendLine($" {notification.SubscriptionKeyInfo.ProtoType} {ProtoFileTypeMapper.ToSnakeCase(notification.SubscriptionKeyProperty)} = 1;");
_messagesBuilder.AppendLine("}");
_messagesBuilder.AppendLine();
}
// Generate the notification message itself
if (!_generatedMessages.Contains(notification.Name))
{
_generatedMessages.Add(notification.Name);
_messagesBuilder.AppendLine($"// {notification.Name} streaming notification");
_messagesBuilder.AppendLine($"message {notification.Name} {{");
foreach (var prop in notification.Properties)
{
var protoType = ProtoFileTypeMapper.MapType(
_compilation.GetTypeByMetadataName(prop.FullyQualifiedType) ??
GetTypeFromName(prop.FullyQualifiedType),
out var needsImport, out var importPath);
if (needsImport && importPath != null)
{
_requiredImports.Add(importPath);
}
var fieldName = ProtoFileTypeMapper.ToSnakeCase(prop.Name);
_messagesBuilder.AppendLine($" {prop.ProtoType} {fieldName} = {prop.FieldNumber};");
}
_messagesBuilder.AppendLine("}");
_messagesBuilder.AppendLine();
}
}
/// <summary>
/// Gets a type symbol from a type name by searching all types
/// </summary>
private ITypeSymbol? GetTypeFromName(string fullTypeName)
{
// Try to find the type in all types
return GetAllTypes().FirstOrDefault(t =>
t.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Replace("global::", "") == fullTypeName ||
t.ToDisplayString() == fullTypeName);
}
}