diff --git a/Svrnty.CQRS.Grpc.Generators/GenerateProtoFileTask.cs b/Svrnty.CQRS.Grpc.Generators/GenerateProtoFileTask.cs
new file mode 100644
index 0000000..2a22459
--- /dev/null
+++ b/Svrnty.CQRS.Grpc.Generators/GenerateProtoFileTask.cs
@@ -0,0 +1,256 @@
+#pragma warning disable RS1035 // Do not use APIs banned for analyzers - This is an MSBuild task, not an analyzer
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace Svrnty.CQRS.Grpc.Generators;
+
+///
+/// MSBuild task that generates .proto files by creating its own Roslyn compilation.
+/// This runs BEFORE CoreCompile to solve the source generator timing issue.
+///
+public class GenerateProtoFileTask : Task
+{
+ ///
+ /// The project directory
+ ///
+ [Required]
+ public string ProjectDirectory { get; set; } = string.Empty;
+
+ ///
+ /// The output directory where the proto file should be written (typically Protos/)
+ ///
+ [Required]
+ public string OutputDirectory { get; set; } = string.Empty;
+
+ ///
+ /// The name of the proto file to generate (typically cqrs_services.proto)
+ ///
+ [Required]
+ public string ProtoFileName { get; set; } = string.Empty;
+
+ ///
+ /// The C# source files to compile (from @(Compile) ItemGroup)
+ ///
+ [Required]
+ public ITaskItem[] SourceFiles { get; set; } = Array.Empty();
+
+ ///
+ /// The assembly references (from @(ReferencePath) ItemGroup)
+ ///
+ [Required]
+ public ITaskItem[] References { get; set; } = Array.Empty();
+
+ ///
+ /// The root namespace of the project
+ ///
+ public string RootNamespace { get; set; } = string.Empty;
+
+ ///
+ /// The assembly name of the project
+ ///
+ public string AssemblyName { get; set; } = string.Empty;
+
+ public override bool Execute()
+ {
+ try
+ {
+ Log.LogMessage(MessageImportance.High,
+ "Svrnty.CQRS.Grpc: Generating proto file via MSBuild task...");
+
+ // Determine the namespace for the proto file
+ var projectNamespace = !string.IsNullOrEmpty(RootNamespace) ? RootNamespace
+ : !string.IsNullOrEmpty(AssemblyName) ? AssemblyName
+ : "Generated";
+ var grpcNamespace = $"{projectNamespace}.Grpc";
+ var packageName = "cqrs";
+
+ // Create the compilation
+ var compilation = CreateCompilation();
+ if (compilation == null)
+ {
+ Log.LogWarning("Svrnty.CQRS.Grpc: Could not create compilation. Writing placeholder proto file.");
+ WritePlaceholderProto(grpcNamespace);
+ return true;
+ }
+
+ // Check for compilation errors that would prevent proper analysis
+ var diagnostics = compilation.GetDiagnostics()
+ .Where(d => d.Severity == DiagnosticSeverity.Error)
+ .ToList();
+
+ if (diagnostics.Count > 0)
+ {
+ Log.LogMessage(MessageImportance.Normal,
+ $"Svrnty.CQRS.Grpc: Compilation has {diagnostics.Count} errors. Attempting to generate proto anyway...");
+
+ // Log first few errors for debugging
+ foreach (var diag in diagnostics.Take(5))
+ {
+ Log.LogMessage(MessageImportance.Low, $" {diag.GetMessage()}");
+ }
+ }
+
+ // Use the ProtoFileGenerator to generate content
+ var generator = new ProtoFileGenerator(compilation);
+ var protoContent = generator.Generate(packageName, grpcNamespace);
+
+ // Check if we got meaningful content
+ if (string.IsNullOrWhiteSpace(protoContent) || !protoContent.Contains("rpc "))
+ {
+ Log.LogMessage(MessageImportance.High,
+ "Svrnty.CQRS.Grpc: No commands/queries/notifications found. Writing minimal proto file.");
+ WritePlaceholderProto(grpcNamespace);
+ return true;
+ }
+
+ // Ensure output directory exists
+ var fullOutputPath = Path.IsPathRooted(OutputDirectory)
+ ? OutputDirectory
+ : Path.Combine(ProjectDirectory, OutputDirectory);
+ Directory.CreateDirectory(fullOutputPath);
+
+ // Write the proto file
+ var protoFilePath = Path.Combine(fullOutputPath, ProtoFileName);
+ File.WriteAllText(protoFilePath, protoContent);
+
+ Log.LogMessage(MessageImportance.High,
+ $"Svrnty.CQRS.Grpc: Successfully generated proto file at {protoFilePath}");
+
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Log.LogErrorFromException(ex, true);
+ return false;
+ }
+ }
+
+ private CSharpCompilation? CreateCompilation()
+ {
+ try
+ {
+ // Parse all source files into syntax trees
+ var syntaxTrees = new List();
+ foreach (var sourceFile in SourceFiles)
+ {
+ var filePath = sourceFile.ItemSpec;
+ if (!Path.IsPathRooted(filePath))
+ {
+ filePath = Path.Combine(ProjectDirectory, filePath);
+ }
+
+ if (!File.Exists(filePath))
+ {
+ Log.LogMessage(MessageImportance.Low, $"Source file not found: {filePath}");
+ continue;
+ }
+
+ try
+ {
+ var sourceText = File.ReadAllText(filePath);
+ var syntaxTree = CSharpSyntaxTree.ParseText(
+ sourceText,
+ CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest),
+ path: filePath);
+ syntaxTrees.Add(syntaxTree);
+ }
+ catch (Exception ex)
+ {
+ Log.LogMessage(MessageImportance.Low, $"Failed to parse {filePath}: {ex.Message}");
+ }
+ }
+
+ if (syntaxTrees.Count == 0)
+ {
+ Log.LogWarning("Svrnty.CQRS.Grpc: No source files could be parsed.");
+ return null;
+ }
+
+ Log.LogMessage(MessageImportance.Normal,
+ $"Svrnty.CQRS.Grpc: Parsed {syntaxTrees.Count} source files");
+
+ // Create metadata references from the References
+ var metadataReferences = new List();
+ foreach (var reference in References)
+ {
+ var refPath = reference.ItemSpec;
+ if (!File.Exists(refPath))
+ {
+ Log.LogMessage(MessageImportance.Low, $"Reference not found: {refPath}");
+ continue;
+ }
+
+ try
+ {
+ var metadataRef = MetadataReference.CreateFromFile(refPath);
+ metadataReferences.Add(metadataRef);
+ }
+ catch (Exception ex)
+ {
+ Log.LogMessage(MessageImportance.Low, $"Failed to load reference {refPath}: {ex.Message}");
+ }
+ }
+
+ Log.LogMessage(MessageImportance.Normal,
+ $"Svrnty.CQRS.Grpc: Loaded {metadataReferences.Count} references");
+
+ // Create the compilation
+ var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
+ .WithNullableContextOptions(NullableContextOptions.Enable);
+
+ var assemblyName = !string.IsNullOrEmpty(AssemblyName) ? AssemblyName : "TempCompilation";
+ var compilation = CSharpCompilation.Create(
+ assemblyName,
+ syntaxTrees,
+ metadataReferences,
+ compilationOptions);
+
+ return compilation;
+ }
+ catch (Exception ex)
+ {
+ Log.LogMessage(MessageImportance.High,
+ $"Svrnty.CQRS.Grpc: Failed to create compilation: {ex.Message}");
+ return null;
+ }
+ }
+
+ private void WritePlaceholderProto(string grpcNamespace)
+ {
+ var placeholderProto = $@"syntax = ""proto3"";
+
+option csharp_namespace = ""{grpcNamespace}"";
+
+package cqrs;
+
+// Placeholder proto file - will be regenerated when commands/queries are available
+// Using namespace: {grpcNamespace}
+
+// Empty service definitions so Grpc.Tools generates base classes
+service CommandService {{
+}}
+
+service QueryService {{
+}}
+
+service DynamicQueryService {{
+}}
+";
+ var fullOutputPath = Path.IsPathRooted(OutputDirectory)
+ ? OutputDirectory
+ : Path.Combine(ProjectDirectory, OutputDirectory);
+ Directory.CreateDirectory(fullOutputPath);
+ var protoFilePath = Path.Combine(fullOutputPath, ProtoFileName);
+ File.WriteAllText(protoFilePath, placeholderProto);
+
+ Log.LogMessage(MessageImportance.High,
+ $"Svrnty.CQRS.Grpc: Wrote placeholder proto file at {protoFilePath}");
+ }
+}
diff --git a/Svrnty.CQRS.Grpc.Generators/ProtoFileSourceGenerator.cs b/Svrnty.CQRS.Grpc.Generators/ProtoFileSourceGenerator.cs
deleted file mode 100644
index 0e065db..0000000
--- a/Svrnty.CQRS.Grpc.Generators/ProtoFileSourceGenerator.cs
+++ /dev/null
@@ -1,124 +0,0 @@
-using System;
-using System.Collections.Immutable;
-using System.Linq;
-using Microsoft.CodeAnalysis;
-using Microsoft.CodeAnalysis.CSharp.Syntax;
-
-namespace Svrnty.CQRS.Grpc.Generators;
-
-///
-/// Incremental source generator that generates .proto files from C# commands and queries
-///
-[Generator]
-public class ProtoFileSourceGenerator : IIncrementalGenerator
-{
- public void Initialize(IncrementalGeneratorInitializationContext context)
- {
- // Register a post-initialization output to generate the proto file
- context.RegisterPostInitializationOutput(ctx =>
- {
- // Generate a placeholder - the actual proto will be generated in the source output
- });
-
- // Collect type declarations to trigger generation
- // We use any type declaration as a trigger since ProtoFileGenerator scans all assemblies
- var typeDeclarations = context.SyntaxProvider
- .CreateSyntaxProvider(
- predicate: static (s, _) => s is TypeDeclarationSyntax,
- transform: static (ctx, _) => GetTypeSymbol(ctx))
- .Where(static m => m is not null)
- .Collect();
-
- // Combine with compilation to have access to it
- var compilationAndTypes = context.CompilationProvider.Combine(typeDeclarations);
-
- // Generate proto file when commands/queries change
- context.RegisterSourceOutput(compilationAndTypes, (spc, source) =>
- {
- var (compilation, types) = source;
-
- // Note: We no longer bail out early since ProtoFileGenerator now scans all referenced assemblies
- // The types from source are just a trigger - the generator will find types from all assemblies
-
- try
- {
- // Get the root namespace from the compilation - this matches what GrpcGenerator does
- var rootNamespace = compilation.AssemblyName ?? "Generated";
- var packageName = "cqrs";
- var csharpNamespace = $"{rootNamespace}.Grpc";
-
- // Generate the proto file content
- var generator = new ProtoFileGenerator(compilation);
- var protoContent = generator.Generate(packageName, csharpNamespace);
-
- // Output as an embedded resource that can be extracted
- var protoFileName = "cqrs_services.proto";
-
- // Generate a C# class that contains the proto content
- // This allows build tools to extract it if needed
- var csContent = $$"""
- //
- #nullable enable
-
- namespace Svrnty.CQRS.Grpc.Generated
- {
- ///
- /// Contains the auto-generated Protocol Buffer definition
- ///
- public static class GeneratedProtoFile
- {
- public const string FileName = "{{protoFileName}}";
-
- public const string Content = @"{{protoContent.Replace("\"", "\"\"")}}";
- }
- }
- """;
-
- spc.AddSource("GeneratedProtoFile.g.cs", csContent);
-
- // Report that we generated the proto content
- var descriptor = new DiagnosticDescriptor(
- "CQRSGRPC002",
- "Proto file generated",
- "Generated proto file content in GeneratedProtoFile class",
- "Svrnty.CQRS.Grpc",
- DiagnosticSeverity.Info,
- isEnabledByDefault: true);
-
- spc.ReportDiagnostic(Diagnostic.Create(descriptor, Location.None));
- }
- catch (Exception ex)
- {
- // Report diagnostic if generation fails
- var descriptor = new DiagnosticDescriptor(
- "CQRSGRPC001",
- "Proto file generation failed",
- "Failed to generate proto file: {0}",
- "Svrnty.CQRS.Grpc",
- DiagnosticSeverity.Warning,
- isEnabledByDefault: true);
-
- spc.ReportDiagnostic(Diagnostic.Create(descriptor, Location.None, ex.Message));
- }
- });
- }
-
- private static INamedTypeSymbol? GetTypeSymbol(GeneratorSyntaxContext context)
- {
- var typeDecl = (TypeDeclarationSyntax)context.Node;
- var symbol = context.SemanticModel.GetDeclaredSymbol(typeDecl) as INamedTypeSymbol;
-
- // Skip if it has GrpcIgnore attribute
- if (symbol?.GetAttributes().Any(a => a.AttributeClass?.Name == "GrpcIgnoreAttribute") == true)
- return null;
-
- return symbol;
- }
-
- private static string? GetBuildProperty(SourceProductionContext context, string propertyName)
- {
- // Try to get build properties from the compilation options
- // This is a simplified approach - in practice, you might need analyzer config
- return null; // Will use defaults
- }
-}
diff --git a/Svrnty.CQRS.Grpc.Generators/WriteProtoFileTask.cs b/Svrnty.CQRS.Grpc.Generators/WriteProtoFileTask.cs
deleted file mode 100644
index 894b1c6..0000000
--- a/Svrnty.CQRS.Grpc.Generators/WriteProtoFileTask.cs
+++ /dev/null
@@ -1,187 +0,0 @@
-#pragma warning disable RS1035 // Do not use APIs banned for analyzers - This is an MSBuild task, not an analyzer
-
-using System;
-using System.IO;
-using System.Linq;
-using System.Text.RegularExpressions;
-using Microsoft.Build.Framework;
-using Microsoft.Build.Utilities;
-
-namespace Svrnty.CQRS.Grpc.Generators;
-
-///
-/// MSBuild task that extracts the auto-generated proto file content from the source generator
-/// output and writes it to disk so Grpc.Tools can process it
-///
-public class WriteProtoFileTask : Task
-{
- ///
- /// The project directory where we should look for generated files
- ///
- [Required]
- public string ProjectDirectory { get; set; } = string.Empty;
-
- ///
- /// The intermediate output path (typically obj/Debug/net10.0)
- ///
- [Required]
- public string IntermediateOutputPath { get; set; } = string.Empty;
-
- ///
- /// The output directory where the proto file should be written (typically Protos/)
- ///
- [Required]
- public string OutputDirectory { get; set; } = string.Empty;
-
- ///
- /// The name of the proto file to generate (typically cqrs_services.proto)
- ///
- [Required]
- public string ProtoFileName { get; set; } = string.Empty;
-
- ///
- /// The root namespace of the project (optional, falls back to AssemblyName)
- ///
- public string RootNamespace { get; set; } = string.Empty;
-
- ///
- /// The assembly name of the project (used for proto namespace if RootNamespace not set)
- ///
- public string AssemblyName { get; set; } = string.Empty;
-
- public override bool Execute()
- {
- try
- {
- Log.LogMessage(MessageImportance.High,
- "Svrnty.CQRS.Grpc: Extracting auto-generated proto file...");
-
- // Look for the generated C# file containing the proto content
- // Source generators output to obj/Generated, not IntermediateOutputPath/Generated
- var generatedFilePath = Path.Combine(
- ProjectDirectory,
- "obj",
- "Generated",
- "Svrnty.CQRS.Grpc.Generators",
- "Svrnty.CQRS.Grpc.Generators.ProtoFileSourceGenerator",
- "GeneratedProtoFile.g.cs"
- );
-
- // Check if proto file already exists (committed to repo or from previous build)
- var existingProtoPath = Path.Combine(ProjectDirectory, OutputDirectory, ProtoFileName);
- if (File.Exists(existingProtoPath) && !File.Exists(generatedFilePath))
- {
- Log.LogMessage(MessageImportance.High,
- $"Svrnty.CQRS.Grpc: Using existing proto file at {existingProtoPath}. " +
- "To regenerate, delete the file and build twice.");
- return true;
- }
-
- if (!File.Exists(generatedFilePath))
- {
- Log.LogWarning(
- $"Generated proto file not found at {generatedFilePath}. " +
- "The proto file may not have been generated yet. This is normal on first build.");
-
- // Write a minimal placeholder proto file so Grpc.Tools doesn't fail
- // The real content will be generated on the next build
- // Use project's namespace so GrpcGenerator can find the service base types
- var projectNamespace = !string.IsNullOrEmpty(RootNamespace) ? RootNamespace
- : !string.IsNullOrEmpty(AssemblyName) ? AssemblyName
- : "Generated";
- var grpcNamespace = $"{projectNamespace}.Grpc";
-
- var placeholderProto = $@"syntax = ""proto3"";
-
-option csharp_namespace = ""{grpcNamespace}"";
-
-package cqrs;
-
-// Placeholder proto file - will be regenerated on next build with actual services
-// Using namespace: {grpcNamespace}
-
-// Empty service definitions so Grpc.Tools generates base classes
-service CommandService {{
-}}
-
-service QueryService {{
-}}
-
-service DynamicQueryService {{
-}}
-";
- var placeholderOutputPath = Path.Combine(ProjectDirectory, OutputDirectory);
- Directory.CreateDirectory(placeholderOutputPath);
- var placeholderProtoFilePath = Path.Combine(placeholderOutputPath, ProtoFileName);
- File.WriteAllText(placeholderProtoFilePath, placeholderProto);
-
- Log.LogMessage(MessageImportance.High,
- $"Svrnty.CQRS.Grpc: Wrote placeholder proto file at {placeholderProtoFilePath}. " +
- "Run build again to generate the actual proto content.");
-
- return true;
- }
-
- // Read the generated C# file
- var csContent = File.ReadAllText(generatedFilePath);
-
- // Extract the proto content using a more robust approach
- // Looking for: public const string Content = @"...";
- var startMarker = "public const string Content = @\"";
- var startIndex = csContent.IndexOf(startMarker);
-
- if (startIndex < 0)
- {
- Log.LogError($"Could not find Content property in {generatedFilePath}");
- return false;
- }
-
- startIndex += startMarker.Length;
-
- // Find the closing "; - We need the LAST occurrence because the content contains escaped quotes
- // The pattern is: Content = @"...content...";
- // where content has "" for literal quotes
- var endMarker = "\";";
-
- // Find where the next field starts or class ends to limit our search
- var nextFieldOrEnd = csContent.IndexOf("\n }", startIndex); // End of class
- if (nextFieldOrEnd < 0)
- {
- nextFieldOrEnd = csContent.Length;
- }
-
- var endIndex = csContent.LastIndexOf(endMarker, nextFieldOrEnd, nextFieldOrEnd - startIndex);
-
- if (endIndex < 0 || endIndex < startIndex)
- {
- Log.LogError($"Could not find end of Content property in {generatedFilePath}");
- return false;
- }
-
- // Extract and unescape doubled quotes
- var protoContent = csContent.Substring(startIndex, endIndex - startIndex);
- protoContent = protoContent.Replace("\"\"", "\"");
-
- Log.LogMessage(MessageImportance.High,
- $"Extracted proto content length: {protoContent.Length} characters");
-
- // Ensure output directory exists
- var fullOutputPath = Path.Combine(ProjectDirectory, OutputDirectory);
- Directory.CreateDirectory(fullOutputPath);
-
- // Write the proto file
- var protoFilePath = Path.Combine(fullOutputPath, ProtoFileName);
- File.WriteAllText(protoFilePath, protoContent);
-
- Log.LogMessage(MessageImportance.High,
- $"Svrnty.CQRS.Grpc: Successfully generated proto file at {protoFilePath}");
-
- return true;
- }
- catch (Exception ex)
- {
- Log.LogErrorFromException(ex, true);
- return false;
- }
- }
-}
diff --git a/Svrnty.CQRS.Grpc.Generators/build/Svrnty.CQRS.Grpc.Generators.targets b/Svrnty.CQRS.Grpc.Generators/build/Svrnty.CQRS.Grpc.Generators.targets
index e4584c1..d977f97 100644
--- a/Svrnty.CQRS.Grpc.Generators/build/Svrnty.CQRS.Grpc.Generators.targets
+++ b/Svrnty.CQRS.Grpc.Generators/build/Svrnty.CQRS.Grpc.Generators.targets
@@ -9,30 +9,47 @@
<_GeneratorsAssemblyPath Condition="Exists('$(MSBuildThisFileDirectory)\..\analyzers\dotnet\cs\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)\..\analyzers\dotnet\cs\Svrnty.CQRS.Grpc.Generators.dll
+ <_GeneratorsAssemblyPath Condition="'$(_GeneratorsAssemblyPath)' == '' AND Exists('$(MSBuildThisFileDirectory)Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)Svrnty.CQRS.Grpc.Generators.dll
<_GeneratorsAssemblyPath Condition="'$(_GeneratorsAssemblyPath)' == '' AND Exists('$(MSBuildThisFileDirectory)\..\bin\Debug\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)\..\bin\Debug\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll
<_GeneratorsAssemblyPath Condition="'$(_GeneratorsAssemblyPath)' == '' AND Exists('$(MSBuildThisFileDirectory)\..\bin\Release\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll')">$(MSBuildThisFileDirectory)\..\bin\Release\netstandard2.0\Svrnty.CQRS.Grpc.Generators.dll
-
-
+
-
-
+
+
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/Svrnty.Sample/Protos/cqrs_services.proto b/Svrnty.Sample/Protos/cqrs_services.proto
index 10bad1c..b84bd32 100644
--- a/Svrnty.Sample/Protos/cqrs_services.proto
+++ b/Svrnty.Sample/Protos/cqrs_services.proto
@@ -21,13 +21,6 @@ service QueryService {
}
-// 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;
@@ -66,46 +59,3 @@ message User {
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;
-}
-