diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f1ed06..e01062b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## softaware.Cqs.DependencyInjection.SourceGenerated + +### 1.0.0 + +A **compile-time** alternative to `softaware.Cqs.DependencyInjection` that uses a Roslyn source generator instead of Scrutor-based runtime reflection to register CQS handlers and decorators. + +See [the package-specific README](./src/softaware.Cqs.DependencyInjection.SourceGenerated/README.md) for details. + ## All packages ### 4.0.0 diff --git a/README.md b/README.md index 8be22cb..ab9e3cc 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,18 @@ This project provides a library for the command-query separation pattern. Commands and queries will be separated on class-level and will be represented by the `ICommand` and `IQuery` interfaces. +## Table of Contents + +- [Usage](#usage) + - [Queries and Commands](#queries-and-commands) + - [Dependency Injection](#dependency-injection) + - [Microsoft.Extensions.DependencyInjection](#microsoftextensionsdependencyinjection) + - [Microsoft.Extensions.DependencyInjection (Source Generator)](#microsoftextensionsdependencyinjection-source-generator) + - [SimpleInjector](#simpleinjector) + - [Executing Commands/Queries](#executing-commandsqueries) +- [Packages](#packages) +- [Breaking changes in version 4.0](#breaking-changes-in-version-40) + ## Usage ### Queries and Commands @@ -68,7 +80,7 @@ It is possible to define decorators for specific query or command types (`IReque ### Dependency Injection -The software CQS packages support two dependency injection frameworks: +The softaware CQS packages support two dependency injection frameworks: 1. [Dependency injection in .NET](https://docs.microsoft.com/en-us/dotnet/core/extensions/dependency-injection) from the `Microsoft.Extensions.DependencyInjection` package (recommended). 2. [Simple Injector](https://simpleinjector.org/). @@ -107,6 +119,21 @@ services The decorators wrap the handler in the order they are added here (so they are called in opposite order). In this case, the `FluentValidationRequestHandlerDecorator` is the first decorator to be called. The `CommandLoggingDecorator` is the last one and calls the actual handler. +#### Microsoft.Extensions.DependencyInjection (Source Generator) + +`softaware.CQS.DependencyInjection.SourceGenerated` is a compile-time alternative to the runtime package above. Instead of using Scrutor's assembly scanning at startup, a Roslyn source generator discovers all handlers at compile time and emits explicit `IServiceCollection` registrations. This eliminates the startup scanning cost and resolves handlers without any runtime reflection. + +The API is intentionally identical, with one key difference: **`IncludeTypesFrom` requires a `typeof()` expression** (not an `Assembly`): + +```csharp +services + .AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(MyMarkerType))) + .AddDecorators(b => b + .AddRequestHandlerDecorator(typeof(CommandLoggingDecorator<,>))); +``` + +See the [package README](src/softaware.Cqs.DependencyInjection.SourceGenerated/README.md) for the full migration guide from the runtime package, a list of limitations, and debugging tips. + #### SimpleInjector When using the `softaware.CQS.SimpleInjector` library, use `AddRequestHandlerDecorator` method to register decorators in the Simple Injector container: @@ -169,6 +196,7 @@ The project consists of several separate packages, which allows flexible usage o | [`softaware.CQS.Analyzers`](src/softaware.Cqs.Analyzers) | | Roslyn analyzers that ensure correct usage of the library. (Shipped with core library.) | | [`softaware.CQS.SimpleInjector`](src/softaware.Cqs.SimpleInjector) | [![NuGet](https://img.shields.io/nuget/v/softaware.CQS.SimpleInjector.svg?style=flat-square)](https://www.nuget.org/packages/softaware.CQS.SimpleInjector/) | Adds support for dynamic resolving of commands handlers and query handlers via SimpleInjector. | | [`softaware.CQS.DependencyInjection`](src/softaware.Cqs.DependencyInjection) | [![NuGet](https://img.shields.io/nuget/v/softaware.CQS.DependencyInjection.svg?style=flat-square)](https://www.nuget.org/packages/softaware.CQS.DependencyInjection/) | Adds support for dynamic resolving of commands handlers and query handlers via `Microsoft.Extensions.DependencyInjection`. | +| [`softaware.CQS.DependencyInjection.SourceGenerated`](src/softaware.Cqs.DependencyInjection.SourceGenerated) | [![NuGet](https://img.shields.io/nuget/v/softaware.CQS.DependencyInjection.SourceGenerated.svg?style=flat-square)](https://www.nuget.org/packages/softaware.CQS.DependencyInjection.SourceGenerated/) | Compile-time alternative to `softaware.CQS.DependencyInjection` using a Roslyn source generator. No runtime assembly scanning. | | [`softaware.CQS.Decorators.Transaction`](src/softaware.Cqs.Decorators.Transaction) | [![NuGet](https://img.shields.io/nuget/v/softaware.CQS.Decorators.Transaction.svg?style=flat-square)](https://www.nuget.org/packages/softaware.CQS.Decorators.Transaction/) | A decorator for command-query architecture, which supports transactions. | | [`softaware.CQS.Decorators.Transaction.DependencyInjection`](src/softaware.Cqs.Decorators.Transaction.DependencyInjection) | [![NuGet](https://img.shields.io/nuget/v/softaware.CQS.Decorators.Transaction.DependencyInjection.svg?style=flat-square)](https://www.nuget.org/packages/softaware.CQS.Decorators.Transaction.DependencyInjection/) | Builder extensions for adding decorators to Microsoft's DI. | | [`softaware.CQS.Decorators.Validation`](src/softaware.Cqs.Decorators.Validation) | [![NuGet](https://img.shields.io/nuget/v/softaware.CQS.Decorators.Validation.svg?style=flat-square)](https://www.nuget.org/packages/softaware.CQS.Decorators.Validation/) | A decorator for command-query architecture, which supports validation of data annotations. | diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 0c72aa6..bd0f0e9 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -15,9 +15,9 @@ variables: steps: - task: UseDotNet@2 - displayName: 'Use .NET 6' + displayName: 'Use .NET 10' inputs: - version: 6.0.x + version: 10.0.x - script: dotnet build $(solutionFile) --configuration $(buildConfiguration) displayName: 'dotnet build $(buildConfiguration)' diff --git a/src/softaware.Cqs.Analyzers.Tests/softaware.Cqs.Analyzers.Tests.csproj b/src/softaware.Cqs.Analyzers.Tests/softaware.Cqs.Analyzers.Tests.csproj index 23e0b16..5e3db39 100644 --- a/src/softaware.Cqs.Analyzers.Tests/softaware.Cqs.Analyzers.Tests.csproj +++ b/src/softaware.Cqs.Analyzers.Tests/softaware.Cqs.Analyzers.Tests.csproj @@ -1,7 +1,7 @@ - net6.0 + net10.0 enable enable latest @@ -11,8 +11,8 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/softaware.Cqs.Analyzers/IRequestShouldNotBeImplementedDirectlyAnalyzer.cs b/src/softaware.Cqs.Analyzers/IRequestShouldNotBeImplementedDirectlyAnalyzer.cs index 481f552..48d8531 100644 --- a/src/softaware.Cqs.Analyzers/IRequestShouldNotBeImplementedDirectlyAnalyzer.cs +++ b/src/softaware.Cqs.Analyzers/IRequestShouldNotBeImplementedDirectlyAnalyzer.cs @@ -19,9 +19,7 @@ public class IRequestShouldNotBeImplementedDirectlyAnalyzer : DiagnosticAnalyzer private static readonly LocalizableString Description = new LocalizableResourceString(nameof(Resources.AnalyzerDescription), Resources.ResourceManager, typeof(Resources)); private const string Category = "Usage"; -#pragma warning disable IDE0090 // Use 'new(...)' https://github.com/dotnet/roslyn-analyzers/issues/5828 - private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Error, isEnabledByDefault: true, description: Description); -#pragma warning restore IDE0090 // Use 'new(...)' + private static readonly DiagnosticDescriptor Rule = new(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Error, isEnabledByDefault: true, description: Description); public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); diff --git a/src/softaware.Cqs.Analyzers/softaware.Cqs.Analyzers.csproj b/src/softaware.Cqs.Analyzers/softaware.Cqs.Analyzers.csproj index 348ed8f..376b8f8 100644 --- a/src/softaware.Cqs.Analyzers/softaware.Cqs.Analyzers.csproj +++ b/src/softaware.Cqs.Analyzers/softaware.Cqs.Analyzers.csproj @@ -7,7 +7,7 @@ true true softaware.CQS.Analyzers - 4.0.0 + 4.0.1 softaware gmbh softaware gmbh package-icon.png @@ -19,17 +19,18 @@ softaware, CQS, command-query-separation true true - 4.0.0.0 - 4.0.0.0 + 4.0.1.0 + 4.0.1.0 + true $(TargetsForTfmSpecificContentInPackage);_AddAnalyzersToOutput - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/softaware.Cqs.Benchmarks/Contracts/Commands/Commands.cs b/src/softaware.Cqs.Benchmarks/Contracts/Commands/Commands.cs new file mode 100644 index 0000000..75b7a74 --- /dev/null +++ b/src/softaware.Cqs.Benchmarks/Contracts/Commands/Commands.cs @@ -0,0 +1,21 @@ +namespace softaware.Cqs.Benchmarks.Contracts.Commands; + +public class SimpleCommand : ICommand +{ + public int Value { get; set; } +} + +public class AccessCheckedCommand : ICommand, IAccessChecked +{ + public bool AccessCheckEvaluated { get; set; } +} + +public class HighPriorityCommand : ICommand, IPrioritized +{ + public int Priority { get; set; } = 1; +} + +public class CommandWithResult : ICommand +{ + public int Input { get; set; } +} diff --git a/src/softaware.Cqs.Benchmarks/Contracts/Interfaces.cs b/src/softaware.Cqs.Benchmarks/Contracts/Interfaces.cs new file mode 100644 index 0000000..62168f0 --- /dev/null +++ b/src/softaware.Cqs.Benchmarks/Contracts/Interfaces.cs @@ -0,0 +1,17 @@ +namespace softaware.Cqs.Benchmarks.Contracts; + +/// +/// Marker interface for access-checked requests (command or query). +/// +public interface IAccessChecked +{ + bool AccessCheckEvaluated { get; set; } +} + +/// +/// Marker interface for prioritized requests. +/// +public interface IPrioritized +{ + int Priority { get; set; } +} diff --git a/src/softaware.Cqs.Benchmarks/Contracts/Queries/Queries.cs b/src/softaware.Cqs.Benchmarks/Contracts/Queries/Queries.cs new file mode 100644 index 0000000..56b5fce --- /dev/null +++ b/src/softaware.Cqs.Benchmarks/Contracts/Queries/Queries.cs @@ -0,0 +1,16 @@ +namespace softaware.Cqs.Benchmarks.Contracts.Queries; + +public class GetSquare : IQuery +{ + public int Value { get; set; } +} + +public class GetGreeting : IQuery +{ + public string Name { get; set; } = ""; +} + +public class AccessCheckedQuery : IQuery, IAccessChecked +{ + public bool AccessCheckEvaluated { get; set; } +} diff --git a/src/softaware.Cqs.Benchmarks/CqsBenchmarks.cs b/src/softaware.Cqs.Benchmarks/CqsBenchmarks.cs new file mode 100644 index 0000000..22910b0 --- /dev/null +++ b/src/softaware.Cqs.Benchmarks/CqsBenchmarks.cs @@ -0,0 +1,78 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Order; +using softaware.Cqs.Benchmarks.Contracts.Commands; +using softaware.Cqs.Benchmarks.Contracts.Queries; + +namespace softaware.Cqs.Benchmarks; + +[MemoryDiagnoser] +[Orderer(SummaryOrderPolicy.FastestToSlowest)] +public class CqsBenchmarks +{ + private IRequestProcessor runtimeProcessor = null!; + private IRequestProcessor generatedProcessor = null!; + private IServiceProvider runtimeServiceProvider = null!; + private IServiceProvider generatedServiceProvider = null!; + + [GlobalSetup] + public void Setup() + { + // Setup runtime (Scrutor-based) approach + this.runtimeServiceProvider = RuntimeSetup.CreateServiceProvider(); + this.runtimeProcessor = this.runtimeServiceProvider.GetRequiredService(); + + // Setup compile-time (source-generated) approach + this.generatedServiceProvider = SourceGeneratedSetup.CreateServiceProvider(); + this.generatedProcessor = this.generatedServiceProvider.GetRequiredService(); + } + + // --- Startup benchmarks --- + + [Benchmark(Description = "Runtime: DI Container Build")] + public static IServiceProvider Runtime_ContainerBuild() + => RuntimeSetup.CreateServiceProvider(); + + [Benchmark(Description = "Generated: DI Container Build")] + public static IServiceProvider Generated_ContainerBuild() + => SourceGeneratedSetup.CreateServiceProvider(); + + // --- Command execution benchmarks --- + + [Benchmark(Description = "Runtime: Execute SimpleCommand")] + public async Task Runtime_SimpleCommand() + => await this.runtimeProcessor.HandleAsync(new SimpleCommand { Value = 1 }, default); + + [Benchmark(Description = "Generated: Execute SimpleCommand")] + public async Task Generated_SimpleCommand() + => await this.generatedProcessor.HandleAsync(new SimpleCommand { Value = 1 }, default); + + // --- Query execution benchmarks --- + + [Benchmark(Description = "Runtime: Execute GetSquare")] + public async Task Runtime_GetSquare() + => await this.runtimeProcessor.HandleAsync(new GetSquare { Value = 5 }, default); + + [Benchmark(Description = "Generated: Execute GetSquare")] + public async Task Generated_GetSquare() + => await this.generatedProcessor.HandleAsync(new GetSquare { Value = 5 }, default); + + // --- Access-checked command (multiple decorator constraints) --- + + [Benchmark(Description = "Runtime: Execute AccessCheckedCommand")] + public async Task Runtime_AccessCheckedCommand() + => await this.runtimeProcessor.HandleAsync(new AccessCheckedCommand(), default); + + [Benchmark(Description = "Generated: Execute AccessCheckedCommand")] + public async Task Generated_AccessCheckedCommand() + => await this.generatedProcessor.HandleAsync(new AccessCheckedCommand(), default); + + // --- Query with caching + logging decorators --- + + [Benchmark(Description = "Runtime: Execute GetGreeting")] + public async Task Runtime_GetGreeting() + => await this.runtimeProcessor.HandleAsync(new GetGreeting { Name = "World" }, default); + + [Benchmark(Description = "Generated: Execute GetGreeting")] + public async Task Generated_GetGreeting() + => await this.generatedProcessor.HandleAsync(new GetGreeting { Name = "World" }, default); +} diff --git a/src/softaware.Cqs.Benchmarks/Decorators/Decorators.cs b/src/softaware.Cqs.Benchmarks/Decorators/Decorators.cs new file mode 100644 index 0000000..2b69ccc --- /dev/null +++ b/src/softaware.Cqs.Benchmarks/Decorators/Decorators.cs @@ -0,0 +1,84 @@ +using softaware.Cqs.Benchmarks.Contracts; + +namespace softaware.Cqs.Benchmarks.Decorators; + +/// +/// Applies to ALL requests (where TRequest : IRequest<TResult>). +/// Simulates a logging/telemetry decorator. +/// +public class LoggingDecorator : IRequestHandler + where TRequest : IRequest +{ + private readonly IRequestHandler decoratee; + + public LoggingDecorator(IRequestHandler decoratee) + => this.decoratee = decoratee; + + public Task HandleAsync(TRequest request, CancellationToken ct) + => this.decoratee.HandleAsync(request, ct); +} + +/// +/// Applies only to COMMANDS (where TRequest : ICommand<TResult>). +/// Simulates a transaction decorator. +/// +public class TransactionDecorator : IRequestHandler + where TRequest : ICommand +{ + private readonly IRequestHandler decoratee; + + public TransactionDecorator(IRequestHandler decoratee) + => this.decoratee = decoratee; + + public Task HandleAsync(TRequest request, CancellationToken ct) + => this.decoratee.HandleAsync(request, ct); +} + +/// +/// Applies only to QUERIES (where TRequest : IQuery<TResult>). +/// Simulates a caching decorator. +/// +public class CachingDecorator : IRequestHandler + where TRequest : IQuery +{ + private readonly IRequestHandler decoratee; + + public CachingDecorator(IRequestHandler decoratee) + => this.decoratee = decoratee; + + public Task HandleAsync(TRequest request, CancellationToken ct) + => this.decoratee.HandleAsync(request, ct); +} + +/// +/// Applies only to requests implementing IAccessChecked (multiple interface constraints). +/// +public class AccessCheckDecorator : IRequestHandler + where TRequest : IRequest, IAccessChecked +{ + private readonly IRequestHandler decoratee; + + public AccessCheckDecorator(IRequestHandler decoratee) + => this.decoratee = decoratee; + + public Task HandleAsync(TRequest request, CancellationToken ct) + { + request.AccessCheckEvaluated = true; + return this.decoratee.HandleAsync(request, ct); + } +} + +/// +/// Applies only to prioritized commands (two constraints: ICommand + IPrioritized). +/// +public class PriorityDecorator : IRequestHandler + where TRequest : ICommand, IPrioritized +{ + private readonly IRequestHandler decoratee; + + public PriorityDecorator(IRequestHandler decoratee) + => this.decoratee = decoratee; + + public Task HandleAsync(TRequest request, CancellationToken ct) + => this.decoratee.HandleAsync(request, ct); +} diff --git a/src/softaware.Cqs.Benchmarks/GlobalSuppressions.cs b/src/softaware.Cqs.Benchmarks/GlobalSuppressions.cs new file mode 100644 index 0000000..fa7aa79 --- /dev/null +++ b/src/softaware.Cqs.Benchmarks/GlobalSuppressions.cs @@ -0,0 +1,8 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "For benchmarking purposes", Scope = "namespaceanddescendants", Target = "~N:softaware.Cqs.Benchmarks")] diff --git a/src/softaware.Cqs.Benchmarks/GlobalUsings.cs b/src/softaware.Cqs.Benchmarks/GlobalUsings.cs new file mode 100644 index 0000000..be9ca72 --- /dev/null +++ b/src/softaware.Cqs.Benchmarks/GlobalUsings.cs @@ -0,0 +1 @@ +global using Microsoft.Extensions.DependencyInjection; diff --git a/src/softaware.Cqs.Benchmarks/Handlers/Handlers.cs b/src/softaware.Cqs.Benchmarks/Handlers/Handlers.cs new file mode 100644 index 0000000..83559a3 --- /dev/null +++ b/src/softaware.Cqs.Benchmarks/Handlers/Handlers.cs @@ -0,0 +1,49 @@ +using softaware.Cqs.Benchmarks.Contracts.Commands; +using softaware.Cqs.Benchmarks.Contracts.Queries; + +namespace softaware.Cqs.Benchmarks.Handlers; + +internal class SimpleCommandHandler : IRequestHandler +{ + public Task HandleAsync(SimpleCommand command, CancellationToken ct) + { + command.Value += 1; + return NoResult.CompletedTask; + } +} + +internal class AccessCheckedCommandHandler : IRequestHandler +{ + public Task HandleAsync(AccessCheckedCommand command, CancellationToken ct) + => NoResult.CompletedTask; +} + +internal class HighPriorityCommandHandler : IRequestHandler +{ + public Task HandleAsync(HighPriorityCommand command, CancellationToken ct) + => NoResult.CompletedTask; +} + +internal class CommandWithResultHandler : IRequestHandler +{ + public Task HandleAsync(CommandWithResult command, CancellationToken ct) + => Task.FromResult(command.Input * 2); +} + +internal class GetSquareHandler : IRequestHandler +{ + public Task HandleAsync(GetSquare query, CancellationToken ct) + => Task.FromResult(query.Value * query.Value); +} + +internal class GetGreetingHandler : IRequestHandler +{ + public Task HandleAsync(GetGreeting query, CancellationToken ct) + => Task.FromResult($"Hello, {query.Name}!"); +} + +internal class AccessCheckedQueryHandler : IRequestHandler +{ + public Task HandleAsync(AccessCheckedQuery query, CancellationToken ct) + => Task.FromResult(true); +} diff --git a/src/softaware.Cqs.Benchmarks/Program.cs b/src/softaware.Cqs.Benchmarks/Program.cs new file mode 100644 index 0000000..6482c8a --- /dev/null +++ b/src/softaware.Cqs.Benchmarks/Program.cs @@ -0,0 +1,26 @@ +using BenchmarkDotNet.Running; +using softaware.Cqs; +using softaware.Cqs.Benchmarks; +using softaware.Cqs.Benchmarks.Contracts.Commands; + +if (args.Length > 0 && args[0] == "--validate") +{ + Console.WriteLine("Validating DI setups..."); + + // Validate runtime (Scrutor-based) approach + var runtimeSp = RuntimeSetup.CreateServiceProvider(); + var runtimeProcessor = runtimeSp.GetRequiredService(); + await runtimeProcessor.HandleAsync(new SimpleCommand { Value = 1 }, default); + Console.WriteLine(" Runtime (Scrutor): OK - IRequestProcessor resolved and handled command."); + + // Validate source-generated approach + var generatedSp = SourceGeneratedSetup.CreateServiceProvider(); + var generatedProcessor = generatedSp.GetRequiredService(); + await generatedProcessor.HandleAsync(new SimpleCommand { Value = 1 }, default); + Console.WriteLine(" Generated: OK - IRequestProcessor resolved and handled command."); + + Console.WriteLine("All validations passed!"); + return; +} + +BenchmarkRunner.Run(args: args); diff --git a/src/softaware.Cqs.Benchmarks/RuntimeSetup.cs b/src/softaware.Cqs.Benchmarks/RuntimeSetup.cs new file mode 100644 index 0000000..6d26b06 --- /dev/null +++ b/src/softaware.Cqs.Benchmarks/RuntimeSetup.cs @@ -0,0 +1,29 @@ +extern alias RuntimeDI; +using RuntimeDI::Microsoft.Extensions.DependencyInjection; +using softaware.Cqs.Benchmarks.Decorators; + +namespace softaware.Cqs.Benchmarks; + +/// +/// Sets up the runtime (Scrutor-based) DI container for benchmarking. +/// +internal static class RuntimeSetup +{ + public static IServiceProvider CreateServiceProvider() + { + var services = new ServiceCollection(); + +#pragma warning disable CQ0003, CQ0008 // This file uses the runtime (Scrutor-based) DI package, not the source generator + services + .AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(RuntimeSetup).Assembly)) + .AddDecorators(b => b + .AddRequestHandlerDecorator(typeof(LoggingDecorator<,>)) + .AddRequestHandlerDecorator(typeof(TransactionDecorator<,>)) + .AddRequestHandlerDecorator(typeof(CachingDecorator<,>)) + .AddRequestHandlerDecorator(typeof(AccessCheckDecorator<,>)) + .AddRequestHandlerDecorator(typeof(PriorityDecorator<,>))); +#pragma warning restore CQ0003, CQ0008 + + return services.BuildServiceProvider(); + } +} diff --git a/src/softaware.Cqs.Benchmarks/SourceGeneratedSetup.cs b/src/softaware.Cqs.Benchmarks/SourceGeneratedSetup.cs new file mode 100644 index 0000000..a5f2bde --- /dev/null +++ b/src/softaware.Cqs.Benchmarks/SourceGeneratedSetup.cs @@ -0,0 +1,27 @@ +extern alias GeneratedDI; +using GeneratedDI::Microsoft.Extensions.DependencyInjection; +using softaware.Cqs.Benchmarks.Decorators; + +namespace softaware.Cqs.Benchmarks; + +/// +/// Sets up the source-generated DI container for benchmarking. +/// +internal static class SourceGeneratedSetup +{ + public static IServiceProvider CreateServiceProvider() + { + var services = new ServiceCollection(); + + services + .AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(SourceGeneratedSetup))) + .AddDecorators(b => b + .AddRequestHandlerDecorator(typeof(LoggingDecorator<,>)) + .AddRequestHandlerDecorator(typeof(TransactionDecorator<,>)) + .AddRequestHandlerDecorator(typeof(CachingDecorator<,>)) + .AddRequestHandlerDecorator(typeof(AccessCheckDecorator<,>)) + .AddRequestHandlerDecorator(typeof(PriorityDecorator<,>))); + + return services.BuildServiceProvider(); + } +} diff --git a/src/softaware.Cqs.Benchmarks/softaware.Cqs.Benchmarks.csproj b/src/softaware.Cqs.Benchmarks/softaware.Cqs.Benchmarks.csproj new file mode 100644 index 0000000..4b938e9 --- /dev/null +++ b/src/softaware.Cqs.Benchmarks/softaware.Cqs.Benchmarks.csproj @@ -0,0 +1,28 @@ + + + + Exe + net10.0 + enable + enable + latest + false + + + + + + + + + + + RuntimeDI + + + GeneratedDI + + + + + diff --git a/src/softaware.Cqs.DependencyInjection.SourceGenerated/CqsDecoratorRegistry.cs b/src/softaware.Cqs.DependencyInjection.SourceGenerated/CqsDecoratorRegistry.cs new file mode 100644 index 0000000..a6f62cd --- /dev/null +++ b/src/softaware.Cqs.DependencyInjection.SourceGenerated/CqsDecoratorRegistry.cs @@ -0,0 +1,31 @@ +namespace softaware.Cqs.DependencyInjection; + +/// +/// Tracks which decorator types were registered at runtime via AddDecorators. +/// Used by the source-generated factory lambdas to evaluate conditional decorator registrations +/// (i.e. decorators inside if/switch blocks). +/// +public sealed class CqsDecoratorRegistry +{ + private readonly HashSet enabledDecorators; + + /// + /// Creates an empty registry (no decorators enabled). + /// + public CqsDecoratorRegistry() => this.enabledDecorators = []; + + /// + /// Creates a registry with the specified decorator types enabled. + /// + /// The set of open generic decorator types that were registered at runtime. + public CqsDecoratorRegistry(HashSet enabledDecorators) => this.enabledDecorators = enabledDecorators ?? throw new ArgumentNullException(nameof(enabledDecorators)); + + /// + /// Returns true if the given open generic decorator type was registered at runtime. + /// + /// The open generic decorator type (e.g. typeof(MyDecorator<,>)). + public bool IsEnabled(Type openGenericDecoratorType) + { + return this.enabledDecorators.Contains(openGenericDecoratorType); + } +} diff --git a/src/softaware.Cqs.DependencyInjection.SourceGenerated/README.md b/src/softaware.Cqs.DependencyInjection.SourceGenerated/README.md new file mode 100644 index 0000000..a69d880 --- /dev/null +++ b/src/softaware.Cqs.DependencyInjection.SourceGenerated/README.md @@ -0,0 +1,318 @@ +# softaware.Cqs.DependencyInjection.SourceGenerated + +A **compile-time** alternative to `softaware.Cqs.DependencyInjection` that uses a Roslyn source generator instead of Scrutor-based runtime reflection to register CQS handlers and decorators. + +## Quick Start + +```csharp +services + .AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(MyMarkerType))) + .AddDecorators(b => b + .AddRequestHandlerDecorator(typeof(LoggingDecorator<,>)) + .AddRequestHandlerDecorator(typeof(ValidationDecorator<,>))); +``` + +> **Important:** All arguments to `IncludeTypesFrom` and `AddRequestHandlerDecorator` **must** be `typeof()` expressions. +> Variables, method calls, or other expressions will produce a compile error (`CQ0008`). + +## Migration Guide: from `softaware.Cqs.DependencyInjection` + +### Step 1 — Replace the NuGet package + +```xml + + + + + +``` + +### Step 2 — Change `IncludeTypesFrom` to use `typeof()` + +The runtime package accepts an `Assembly` directly. The source generator can only work with `typeof()` expressions — a marker type from each assembly is enough. + +```csharp +// Before +services.AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(MyHandler).Assembly)); + +// After +services.AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(MyHandler))); +``` + +If you were passing multiple assemblies, pass a marker type from each: + +```csharp +// Before +services.AddSoftawareCqs(b => b + .IncludeTypesFrom(typeof(SomeHandler).Assembly) + .IncludeTypesFrom(typeof(OtherHandler).Assembly)); + +// After +services.AddSoftawareCqs(b => b + .IncludeTypesFrom(typeof(SomeHandler)) + .IncludeTypesFrom(typeof(OtherHandler))); +``` + +### Step 3 — Replace convenience decorator methods + +The source generator cannot trace through extension methods. Replace all convenience methods with explicit `AddRequestHandlerDecorator(typeof(...))` calls: + +| Convenience method (remove) | Replace with | +|---|---| +| `.AddTransactionCommandHandlerDecorator()` | `.AddRequestHandlerDecorator(typeof(TransactionAwareCommandHandlerDecorator<,>))` | +| `.AddTransactionQueryHandlerDecorator()` | `.AddRequestHandlerDecorator(typeof(TransactionAwareQueryHandlerDecorator<,>))` | +| `.AddDataAnnotationsValidationDecorators()` | `.AddRequestHandlerDecorator(typeof(ValidationRequestHandlerDecorator<,>))` | +| `.AddFluentValidationDecorators()` | `.AddRequestHandlerDecorator(typeof(FluentValidationRequestHandlerDecorator<,>))` | +| `.AddUsageAwareDecorators()` | `.AddRequestHandlerDecorator(typeof(UsageAwareRequestHandlerDecorator<,>))` | +| `.AddApplicationInsightsDependencyTelemetryDecorator()` | `.AddRequestHandlerDecorator(typeof(DependencyTelemetryRequestHandlerDecorator<,>))` | + +A build warning (`CQ0004`) is emitted for every detected convenience method. + +```csharp +// Before +.AddDecorators(b => b + .AddTransactionCommandHandlerDecorator() + .AddUsageAwareDecorators()); + +// After +.AddDecorators(b => b + .AddRequestHandlerDecorator(typeof(TransactionAwareCommandHandlerDecorator<,>)) + .AddRequestHandlerDecorator(typeof(UsageAwareRequestHandlerDecorator<,>))); +``` + +### Step 4 — Rebuild + +Build the project. If the generator is working correctly you will see: + +``` +info CQ0007: softaware.Cqs source generator: Registered 12 handler(s) with 5 decorator(s). IRequestProcessor → GeneratedRequestProcessor +``` + +--- + +## What is NOT supported + +### Open generic request types + +Request types with type parameters cannot be registered by the source generator, because it generates one explicit registration per concrete request type. + +```csharp +// ❌ NOT supported — causes CQ0009 (error) +public class GetNextLogicalId : IQuery { } +public class GetNextLogicalIdHandler : IRequestHandler, int> { } +``` + +**Workaround:** Keep this handler registered via the runtime package, or refactor to a non-generic design (e.g. pass a `Type` parameter or use a separate handler per entity type). + +### Convenience decorator extension methods + +The generator cannot statically trace extension method calls. + +```csharp +// ❌ NOT supported — causes CQ0004 (warning, handler skipped) +.AddDecorators(b => b.AddTransactionCommandHandlerDecorator()) +``` + +**Fix:** Use explicit `typeof()` calls as shown in Step 3 above. + +### Custom helper methods inside `AddDecorators` + +Any method call other than `AddRequestHandlerDecorator` inside the `AddDecorators` lambda is silently ignored by the source generator — even if that method internally calls `AddRequestHandlerDecorator`. The generator cannot trace through arbitrary method calls. + +```csharp +// ❌ NOT supported — causes CQ0012 (warning, decorator silently skipped) +.AddDecorators(b => +{ + b.AddRequestHandlerDecorator(typeof(LoggingDecorator<,>)); // ✅ recognized + MyHelper.RegisterAllDecorators(b); // ❌ ignored +}) +``` + +**Fix:** Register each decorator directly with `AddRequestHandlerDecorator(typeof(...))`: + +```csharp +.AddDecorators(b => b + .AddRequestHandlerDecorator(typeof(LoggingDecorator<,>)) + .AddRequestHandlerDecorator(typeof(ValidationDecorator<,>))) +``` + +### `IncludeTypesFrom` with anything other than `typeof()` + +The generator reads types from the syntax tree — expressions that produce an `Assembly` or `Type` at runtime cannot be evaluated at compile time. + +```csharp +// ❌ NOT supported — causes CQ0008 (error) +var marker = typeof(MyHandler); +services.AddSoftawareCqs(b => b.IncludeTypesFrom(marker)); + +// ❌ NOT supported — causes CQ0008 (error) +services.AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(MyHandler).Assembly)); + +// ✅ OK +services.AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(MyHandler))); +``` + +### Partially closed decorators + +Decorators with only one type parameter (partially closed generics) are not supported. The runtime Scrutor package throws a `NotSupportedException` for these too, and the same restriction applies here. + +```csharp +// ❌ NOT supported by either package +class Decorator : IRequestHandler { } + +// ✅ Refactor to fully generic with type constraint +class Decorator : IRequestHandler + where TRequest : SomeRequest { } +``` + +--- + +## What the Source Generator Does + +At compile time, the generator: + +1. Reads `AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(...)))` from the syntax tree +2. Discovers all `IRequestHandler` implementations in the referenced assemblies +3. Evaluates generic type constraints to determine which decorators apply to which handlers +4. Generates explicit `IServiceCollection` registrations with decorator chains +5. Generates a `GeneratedRequestProcessor` for static dispatch (registered as `IRequestProcessor`) + +At runtime, the `AddSoftawareCqs()` call locates the generated `CqsServiceRegistration` class via reflection and invokes `RegisterAll(IServiceCollection)`. +The `AddDecorators()` lambda is **executed at runtime** to capture conditional registrations — see [Conditional Decorator Registration](#conditional-decorator-registration) below. + +## Diagnostics + +| ID | Severity | Description | +|---|---|---| +| `CQ0004` | Warning | Convenience method (e.g. `AddTransactionCommandHandlerDecorator`) detected. Use `AddRequestHandlerDecorator(typeof(...))` instead. | +| `CQ0005` | Warning | No `AddSoftawareCqs` call found. The generator has nothing to generate. | +| `CQ0006` | Warning | Core CQS types (`IRequestHandler`, `IRequest`, `IRequestProcessor`) could not be resolved. Ensure `softaware.CQS` is referenced. | +| `CQ0007` | Info | Generation succeeded. Shows handler and decorator counts. Only visible in IDE Error List (with Info filter) or `dotnet build -v detailed`. | +| `CQ0008` | Error | Argument to `IncludeTypesFrom` or `AddRequestHandlerDecorator` is not a `typeof()` expression. | +| `CQ0009` | Error | Handler uses an open generic request type (e.g. `MyRequest`). Not supported in this version. | +| `CQ0010` | Info | `AddRequestHandlerDecorator` inside a conditional block — will use runtime registry check. | +| `CQ0011` | Error | Decorator has generic type parameters that could not be mapped to `TRequest`/`TResult` from `IRequestHandler`. | +| `CQ0012` | Warning | A method other than `AddRequestHandlerDecorator` was called inside `AddDecorators`. The call is ignored by the source generator. | + +## Conditional Decorator Registration + +Unlike the runtime (Scrutor-based) package, the source generator reads **all** `AddRequestHandlerDecorator` calls from the syntax tree at compile time — including those inside `if`/`switch` blocks. + +To handle this correctly, when the generator detects a decorator inside a conditional block, it generates an `if (registry.IsEnabled(...))` guard in the factory lambda. At runtime, the `AddDecorators` lambda is **actually executed**, and the `SoftawareCqsDecoratorBuilder` records which decorators were called. This information is stored in a `CqsDecoratorRegistry` singleton. + +```csharp +services + .AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(MyMarker))) + .AddDecorators(b => + { + if (useLogging) // ✅ This works — evaluated at runtime + { + b.AddRequestHandlerDecorator(typeof(LoggingDecorator<,>)); + } + + b.AddRequestHandlerDecorator(typeof(ValidationDecorator<,>)); // Always applied + }); +``` + +The generated code for a handler would look like: + +```csharp +services.AddTransient>(sp => +{ + var __decoratorRegistry = sp.GetRequiredService(); + IRequestHandler current = ActivatorUtilities.CreateInstance(sp); + + if (__decoratorRegistry.IsEnabled(typeof(LoggingDecorator<,>))) + { + current = ActivatorUtilities.CreateInstance>(sp, current); + } + + current = ActivatorUtilities.CreateInstance>(sp, current); + return current; +}); +``` + +> **Note:** When no decorators are conditional (the common case), the registry is not used and there is zero runtime overhead. + +## Debugging + +### Is the generator running? + +Build the project and look for warning `CQ0005` and `CQ0006`, or info `CQ0007` in the build output: + +``` +info CQ0007: softaware.Cqs source generator: Registered 5 handler(s) with 3 decorator(s). IRequestProcessor → GeneratedRequestProcessor +``` + +If you see **no CQ warnings at all**, the generator is not running. Check: + +- The NuGet package is properly installed (`dotnet list package`) +- Clear the NuGet cache: `dotnet nuget locals all --clear` +- Rebuild from scratch: `dotnet clean && dotnet build` + +> **Note:** `CQ0007` is an Info-level diagnostic that only appears in Visual Studio's Error List (enable the "Messages" filter) or when building with `dotnet build -v detailed`. The warning-level diagnostics (`CQ0005`, `CQ0006`) always appear in normal build output. + +### Inspect generated files + +You should find the two files in Visual Studio Search (Ctrl + T shortcut): +- `CqsServiceRegistration.g.cs` — handler and decorator registrations +- `GeneratedRequestProcessor.g.cs` — static request dispatch + +If not, add this to your `.csproj`: + +```xml + + true + +``` + +Then build and check the generated files at: + +``` +obj/Debug/{TFM}/generated/softaware.Cqs.DependencyInjection.SourceGenerator/softaware.Cqs.DependencyInjection.SourceGenerator.CqsSourceGenerator/ +``` + +You should see: +- `CqsServiceRegistration.g.cs` — handler and decorator registrations +- `GeneratedRequestProcessor.g.cs` — static request dispatch + +### Attach a debugger + +Add the MSBuild property `CqsDebugSourceGenerator` to your project for the duration of the debugging session: + +```xml + + true + +``` + +Then build the project. This triggers `Debugger.Launch()` and lets you step through the generator in Visual Studio. + +> **Note:** Remove this property once you are done debugging — it causes a debugger prompt on every build. + +### Common pitfalls + +| Symptom | Cause | Fix | +|---|---|---| +| No warnings, no generated files | Generator not running | Clear NuGet cache, rebuild | +| `CQ0005` — "No AddSoftawareCqs call found" | Missing or incorrect `AddSoftawareCqs` call | Ensure you call `services.AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(...)))` | +| `CQ0006` — "Core CQS types not resolved" | Missing `softaware.CQS` package reference | Add `` | +| `CQ0008` — "must be a typeof() expression" | Using a variable or `.Assembly` instead of `typeof()` | Replace `IncludeTypesFrom(myVariable)` with `IncludeTypesFrom(typeof(MyType))` | +| `InvalidOperationException` at runtime | Generated class not found | Ensure the NuGet package is installed and project was rebuilt | +| `CQ0009` — "open generic request type" | Handler like `MyHandler : IRequestHandler, int>` | Not supported. Use the runtime (Scrutor-based) package or refactor to closed generic types | +| `CQ0011` — "unsupported decorator generic shape" | Decorator has extra generic parameters beyond `TRequest`/`TResult` | Ensure all generic parameters are used by the `IRequestHandler` implementation | +| `CQ0012` — "unsupported method call in AddDecorators" | A helper or extension method was called inside `AddDecorators` | Register decorators directly via `AddRequestHandlerDecorator(typeof(...))` | + +## Decorator Order + +Decorators are applied in **registration order**: +- **First registered** = closest to the handler (innermost) +- **Last registered** = outermost (executed first) + +```csharp +.AddDecorators(b => b + .AddRequestHandlerDecorator(typeof(InnerDecorator<,>)) // wraps handler directly + .AddRequestHandlerDecorator(typeof(OuterDecorator<,>))); // wraps InnerDecorator +``` + +Execution order: `OuterDecorator → InnerDecorator → Handler` diff --git a/src/softaware.Cqs.DependencyInjection.SourceGenerated/SoftawareCqsBuilder.cs b/src/softaware.Cqs.DependencyInjection.SourceGenerated/SoftawareCqsBuilder.cs new file mode 100644 index 0000000..af7a9f1 --- /dev/null +++ b/src/softaware.Cqs.DependencyInjection.SourceGenerated/SoftawareCqsBuilder.cs @@ -0,0 +1,48 @@ +using softaware.Cqs.DependencyInjection; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Provides methods for configuring the softaware CQS infrastructure with source-generated registrations. +/// +/// +/// Initializes a new instance of the class. +/// +/// The service collection. +public class SoftawareCqsBuilder(IServiceCollection services) +{ + /// + /// The service collection. + /// + public IServiceCollection Services { get; } = services ?? throw new ArgumentNullException(nameof(services)); + + /// + /// Configures decorators for the softaware CQS infrastructure. + /// + /// + /// Decorators are applied in reverse order. This means decorators which are registered last will be executed first. + /// Decorators which are registered earlier will be executed "closer" to the actual handler. + /// + /// The source generator reads all decorator types from the syntax tree at compile time. + /// At runtime, this method executes the lambda to record which decorators were actually requested, + /// enabling conditional registration (decorators inside if blocks). + /// + /// Provides an action to configure decorators. + /// The CQS builder. + public SoftawareCqsBuilder AddDecorators(Action softawareCqsDecoratorBuilderAction) + { + var builder = new SoftawareCqsDecoratorBuilder(); + softawareCqsDecoratorBuilderAction(builder); + + // Replace the default empty registry with one containing the actually-enabled decorators. + // This allows conditional decorators (inside if/switch blocks) to work correctly: + // the generated factory lambdas check registry.IsEnabled() at resolution time. + var descriptor = new ServiceDescriptor( + typeof(CqsDecoratorRegistry), + new CqsDecoratorRegistry(builder.EnabledDecorators)); + Extensions.ServiceCollectionDescriptorExtensions.RemoveAll(this.Services); + this.Services.Add(descriptor); + + return this; + } +} diff --git a/src/softaware.Cqs.DependencyInjection.SourceGenerated/SoftawareCqsDecoratorBuilder.cs b/src/softaware.Cqs.DependencyInjection.SourceGenerated/SoftawareCqsDecoratorBuilder.cs new file mode 100644 index 0000000..8e010bd --- /dev/null +++ b/src/softaware.Cqs.DependencyInjection.SourceGenerated/SoftawareCqsDecoratorBuilder.cs @@ -0,0 +1,38 @@ +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Provides methods for configuring decorators for the softaware CQS infrastructure with source generation. +/// +public class SoftawareCqsDecoratorBuilder +{ + internal HashSet EnabledDecorators { get; } = []; + + /// + /// Adds a request handler decorator. + /// + /// + /// The source generator reads the typeof() argument from the syntax tree at compile time + /// and generates explicit decorator chains for each handler. + /// At runtime, the type is recorded so that conditional registrations (inside if blocks) + /// can be evaluated correctly. + /// + /// + /// The type of the decorator. The decorator must implement + /// IRequestHandler<TRequest, TResult>. + /// + /// The decorator builder for chaining. + public SoftawareCqsDecoratorBuilder AddRequestHandlerDecorator(Type decoratorType) + { + if (decoratorType is null) + { + throw new ArgumentNullException(nameof(decoratorType)); + } + + var normalizedDecoratorType = decoratorType.IsConstructedGenericType + ? decoratorType.GetGenericTypeDefinition() + : decoratorType; + + this.EnabledDecorators.Add(normalizedDecoratorType); + return this; + } +} diff --git a/src/softaware.Cqs.DependencyInjection.SourceGenerated/SoftawareCqsExtensions.cs b/src/softaware.Cqs.DependencyInjection.SourceGenerated/SoftawareCqsExtensions.cs new file mode 100644 index 0000000..81d7c45 --- /dev/null +++ b/src/softaware.Cqs.DependencyInjection.SourceGenerated/SoftawareCqsExtensions.cs @@ -0,0 +1,63 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using softaware.Cqs; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Provides extension methods for to configure the softaware CQS infrastructure +/// using compile-time source generation. +/// +public static class SoftawareCqsExtensions +{ + /// + /// Adds the softaware CQS infrastructure using compile-time generated registrations. + /// + /// + /// This method locates the source-generated CqsServiceRegistration class in the calling assembly + /// and invokes its RegisterAll method to register all handlers and decorators. + /// The is read syntactically by the source generator + /// at compile time; at runtime it is not executed. + /// + /// The service collection. + /// + /// The types builder for specifying assemblies via IncludeTypesFrom(typeof(...)). + /// Only typeof() expressions are supported; Assembly.GetExecutingAssembly() is not. + /// + /// The softaware CQS builder. + [MethodImpl(MethodImplOptions.NoInlining)] + public static SoftawareCqsBuilder AddSoftawareCqs( + this IServiceCollection services, + Action softawareCqsTypesBuilderAction) + { + if (softawareCqsTypesBuilderAction is null) + { + throw new ArgumentNullException(nameof(softawareCqsTypesBuilderAction)); + } + + var callingAssembly = Assembly.GetCallingAssembly(); + var registrationType = callingAssembly.GetType("softaware.Cqs.Generated.CqsServiceRegistration") ?? throw new InvalidOperationException( + $"Source-generated CQS registration class not found in assembly '{callingAssembly.GetName().Name}'. " + + "Ensure the softaware.Cqs.DependencyInjection.SourceGenerated NuGet package is referenced " + + "(which includes the source generator), and that the project has been rebuilt."); + + var method = registrationType.GetMethod( + "RegisterAll", + BindingFlags.Public | BindingFlags.Static, + binder: null, + types: [typeof(IServiceCollection)], + modifiers: null); + + if (method is null || method.ReturnType != typeof(void)) + { + throw new InvalidOperationException( + $"Source-generated CQS registration method 'public static void RegisterAll(IServiceCollection)' " + + $"was not found on type '{registrationType.FullName}' in assembly '{callingAssembly.GetName().Name}'. " + + "Ensure the generated registration code is up to date and that the project has been rebuilt."); + } + + method.Invoke(null, [services]); + + return new SoftawareCqsBuilder(services); + } +} diff --git a/src/softaware.Cqs.DependencyInjection.SourceGenerated/build/softaware.CQS.DependencyInjection.SourceGenerated.targets b/src/softaware.Cqs.DependencyInjection.SourceGenerated/build/softaware.CQS.DependencyInjection.SourceGenerated.targets new file mode 100644 index 0000000..cf653bb --- /dev/null +++ b/src/softaware.Cqs.DependencyInjection.SourceGenerated/build/softaware.CQS.DependencyInjection.SourceGenerated.targets @@ -0,0 +1,5 @@ + + + + + diff --git a/src/softaware.Cqs.DependencyInjection.SourceGenerated/softaware.Cqs.DependencyInjection.SourceGenerated.csproj b/src/softaware.Cqs.DependencyInjection.SourceGenerated/softaware.Cqs.DependencyInjection.SourceGenerated.csproj new file mode 100644 index 0000000..5863fe0 --- /dev/null +++ b/src/softaware.Cqs.DependencyInjection.SourceGenerated/softaware.Cqs.DependencyInjection.SourceGenerated.csproj @@ -0,0 +1,54 @@ + + + + netstandard2.0 + latest + enable + enable + $(NoWarn);CQ0005 + true + softaware gmbh + softaware gmbh + Compile-time source generator for softaware CQS dependency injection. Replaces Scrutor-based runtime discovery with explicit, generated handler and decorator registrations. + softaware, command-query-separation, source-generator + git + https://github.com/softawaregmbh/library-cqs + https://github.com/softawaregmbh/library-cqs + 1.0.0-beta1 + softaware.CQS.DependencyInjection.SourceGenerated + package-icon.png + MIT + true + true + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + 1.0.0.0 + 1.0.0.0 + True + softaware.Cqs.DependencyInjection.SourceGenerated + $(TargetsForTfmSpecificContentInPackage);_AddAnalyzersToOutput + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/src/softaware.Cqs.DependencyInjection.SourceGenerator.Tests/CqsSourceGeneratorTests.cs b/src/softaware.Cqs.DependencyInjection.SourceGenerator.Tests/CqsSourceGeneratorTests.cs new file mode 100644 index 0000000..4baa024 --- /dev/null +++ b/src/softaware.Cqs.DependencyInjection.SourceGenerator.Tests/CqsSourceGeneratorTests.cs @@ -0,0 +1,892 @@ +using System.Globalization; +using Xunit; + +namespace softaware.Cqs.DependencyInjection.SourceGenerator.Tests; + +public class CqsSourceGeneratorTests +{ + [Fact] + public void BasicHandler_GeneratesRegistration() + { + var source = @" +using softaware.Cqs; +using Microsoft.Extensions.DependencyInjection; + +namespace TestApp; + +public class GetSquare : IQuery +{ + public int Value { get; set; } +} + +public class GetSquareHandler : IRequestHandler +{ + public System.Threading.Tasks.Task HandleAsync(GetSquare query, System.Threading.CancellationToken ct) + => System.Threading.Tasks.Task.FromResult(query.Value * query.Value); +} + +public class Startup +{ + public void Configure(IServiceCollection services) + { + services.AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(GetSquare))); + } +} +"; + + var (outputCompilation, diagnostics, runResult) = TestHelper.RunGeneratorWithCompilation(source); + + var registrationSource = TestHelper.GetGeneratedSource(runResult, "CqsServiceRegistration.g.cs"); + var processorSource = TestHelper.GetGeneratedSource(runResult, "GeneratedRequestProcessor.g.cs"); + + Assert.NotNull(registrationSource); + Assert.NotNull(processorSource); + Assert.Contains("GetSquareHandler", registrationSource); + Assert.Contains("GetSquare", processorSource); + } + + [Fact] + public void CommandHandler_GeneratesRegistration() + { + var source = @" +using softaware.Cqs; +using Microsoft.Extensions.DependencyInjection; + +namespace TestApp; + +public class SaveThing : ICommand +{ + public string Name { get; set; } +} + +public class SaveThingHandler : IRequestHandler +{ + public System.Threading.Tasks.Task HandleAsync(SaveThing command, System.Threading.CancellationToken ct) + => NoResult.CompletedTask; +} + +public class Startup +{ + public void Configure(IServiceCollection services) + { + services.AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(SaveThing))); + } +} +"; + + var (outputCompilation, diagnostics, runResult) = TestHelper.RunGeneratorWithCompilation(source); + + var registrationSource = TestHelper.GetGeneratedSource(runResult, "CqsServiceRegistration.g.cs"); + Assert.NotNull(registrationSource); + Assert.Contains("SaveThingHandler", registrationSource); + Assert.Contains("NoResult", registrationSource); + } + + [Fact] + public void DecoratorWithCommandConstraint_AppliesOnlyToCommands() + { + var source = @" +using softaware.Cqs; +using Microsoft.Extensions.DependencyInjection; + +namespace TestApp; + +public class MyCommand : ICommand { } +public class MyQuery : IQuery { } + +public class MyCommandHandler : IRequestHandler +{ + public System.Threading.Tasks.Task HandleAsync(MyCommand c, System.Threading.CancellationToken ct) + => NoResult.CompletedTask; +} + +public class MyQueryHandler : IRequestHandler +{ + public System.Threading.Tasks.Task HandleAsync(MyQuery q, System.Threading.CancellationToken ct) + => System.Threading.Tasks.Task.FromResult(42); +} + +public class CommandOnlyDecorator : IRequestHandler + where TRequest : ICommand +{ + private readonly IRequestHandler decoratee; + public CommandOnlyDecorator(IRequestHandler decoratee) => this.decoratee = decoratee; + public System.Threading.Tasks.Task HandleAsync(TRequest r, System.Threading.CancellationToken ct) + => this.decoratee.HandleAsync(r, ct); +} + +public class Startup +{ + public void Configure(IServiceCollection services) + { + services + .AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(MyCommand))) + .AddDecorators(b => b.AddRequestHandlerDecorator(typeof(CommandOnlyDecorator<,>))); + } +} +"; + + var (outputCompilation, diagnostics, runResult) = TestHelper.RunGeneratorWithCompilation(source); + + var registrationSource = TestHelper.GetGeneratedSource(runResult, "CqsServiceRegistration.g.cs"); + Assert.NotNull(registrationSource); + + // CommandOnlyDecorator should appear in MyCommand registration + Assert.Contains("CommandOnlyDecorator", registrationSource); + + // But the query handler should NOT have the decorator + // Split by handler registrations and check + var lines = registrationSource.Split('\n'); + var querySection = false; + var commandSection = false; + var decoratorInQuery = false; + var decoratorInCommand = false; + + foreach (var line in lines) + { + if (line.Contains("MyQueryHandler")) + { + querySection = true; + } + + if (line.Contains("MyCommandHandler")) + { + commandSection = true; + } + + if (line.Contains("return current;") || (line.Contains("AddTransient") && !line.Contains("IRequestProcessor"))) + { + querySection = false; + commandSection = false; + } + if (querySection && line.Contains("CommandOnlyDecorator")) + { + decoratorInQuery = true; + } + + if (commandSection && line.Contains("CommandOnlyDecorator")) + { + decoratorInCommand = true; + } + } + + Assert.True(decoratorInCommand, "CommandOnlyDecorator should be applied to command handler"); + Assert.False(decoratorInQuery, "CommandOnlyDecorator should NOT be applied to query handler"); + } + + [Fact] + public void MultipleDecorators_AppliedInCorrectOrder() + { + var source = @" +using softaware.Cqs; +using Microsoft.Extensions.DependencyInjection; + +namespace TestApp; + +public class MyCommand : ICommand { } + +public class MyCommandHandler : IRequestHandler +{ + public System.Threading.Tasks.Task HandleAsync(MyCommand c, System.Threading.CancellationToken ct) + => NoResult.CompletedTask; +} + +public class DecoratorA : IRequestHandler + where TRequest : IRequest +{ + private readonly IRequestHandler decoratee; + public DecoratorA(IRequestHandler decoratee) => this.decoratee = decoratee; + public System.Threading.Tasks.Task HandleAsync(TRequest r, System.Threading.CancellationToken ct) + => this.decoratee.HandleAsync(r, ct); +} + +public class DecoratorB : IRequestHandler + where TRequest : IRequest +{ + private readonly IRequestHandler decoratee; + public DecoratorB(IRequestHandler decoratee) => this.decoratee = decoratee; + public System.Threading.Tasks.Task HandleAsync(TRequest r, System.Threading.CancellationToken ct) + => this.decoratee.HandleAsync(r, ct); +} + +public class Startup +{ + public void Configure(IServiceCollection services) + { + services + .AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(MyCommand))) + .AddDecorators(b => b + .AddRequestHandlerDecorator(typeof(DecoratorA<,>)) + .AddRequestHandlerDecorator(typeof(DecoratorB<,>))); + } +} +"; + + var (outputCompilation, diagnostics, runResult) = TestHelper.RunGeneratorWithCompilation(source); + + var registrationSource = TestHelper.GetGeneratedSource(runResult, "CqsServiceRegistration.g.cs"); + Assert.NotNull(registrationSource); + + // DecoratorA should appear BEFORE DecoratorB (A is closer to handler, B wraps A) + var indexA = registrationSource.IndexOf("DecoratorA", StringComparison.Ordinal); + var indexB = registrationSource.IndexOf("DecoratorB", StringComparison.Ordinal); + Assert.True(indexA > 0, "DecoratorA should be in the generated code"); + Assert.True(indexB > 0, "DecoratorB should be in the generated code"); + Assert.True(indexA < indexB, "DecoratorA (closest to handler) should appear before DecoratorB (outermost)"); + } + + [Fact] + public void DecoratorWithInterfaceConstraint_OnlyAppliesWhenRequestImplementsInterface() + { + var source = @" +using softaware.Cqs; +using Microsoft.Extensions.DependencyInjection; + +namespace TestApp; + +public interface IAccessChecked +{ + bool Checked { get; set; } +} + +public class CheckedCommand : ICommand, IAccessChecked +{ + public bool Checked { get; set; } +} + +public class UncheckedCommand : ICommand { } + +public class CheckedCommandHandler : IRequestHandler +{ + public System.Threading.Tasks.Task HandleAsync(CheckedCommand c, System.Threading.CancellationToken ct) + => NoResult.CompletedTask; +} + +public class UncheckedCommandHandler : IRequestHandler +{ + public System.Threading.Tasks.Task HandleAsync(UncheckedCommand c, System.Threading.CancellationToken ct) + => NoResult.CompletedTask; +} + +public class AccessCheckDecorator : IRequestHandler + where TRequest : IRequest, IAccessChecked +{ + private readonly IRequestHandler decoratee; + public AccessCheckDecorator(IRequestHandler decoratee) => this.decoratee = decoratee; + public System.Threading.Tasks.Task HandleAsync(TRequest r, System.Threading.CancellationToken ct) + => this.decoratee.HandleAsync(r, ct); +} + +public class Startup +{ + public void Configure(IServiceCollection services) + { + services + .AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(CheckedCommand))) + .AddDecorators(b => b.AddRequestHandlerDecorator(typeof(AccessCheckDecorator<,>))); + } +} +"; + + var (outputCompilation, diagnostics, runResult) = TestHelper.RunGeneratorWithCompilation(source); + + var registrationSource = TestHelper.GetGeneratedSource(runResult, "CqsServiceRegistration.g.cs"); + Assert.NotNull(registrationSource); + + // AccessCheckDecorator should apply to CheckedCommand but not UncheckedCommand + Assert.Contains("AccessCheckDecorator { } + +public class Cmd1Handler : IRequestHandler +{ + public System.Threading.Tasks.Task HandleAsync(Cmd1 c, System.Threading.CancellationToken ct) + => NoResult.CompletedTask; +} + +public class Cmd2Handler : IRequestHandler +{ + public System.Threading.Tasks.Task HandleAsync(Cmd2 c, System.Threading.CancellationToken ct) + => NoResult.CompletedTask; +} + +public class Query1Handler : IRequestHandler +{ + public System.Threading.Tasks.Task HandleAsync(Query1 q, System.Threading.CancellationToken ct) + => System.Threading.Tasks.Task.FromResult(""hello""); +} + +public class Startup +{ + public void Configure(IServiceCollection services) + { + services.AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(Cmd1))); + } +} +"; + + var (outputCompilation, diagnostics, runResult) = TestHelper.RunGeneratorWithCompilation(source); + + var processorSource = TestHelper.GetGeneratedSource(runResult, "GeneratedRequestProcessor.g.cs"); + Assert.NotNull(processorSource); + Assert.Contains("Cmd1", processorSource); + Assert.Contains("Cmd2", processorSource); + Assert.Contains("Query1", processorSource); + Assert.Contains("GeneratedRequestProcessor", processorSource); + Assert.Contains("IRequestProcessor", processorSource); + } + + [Fact] + public void NoConfiguration_GeneratesNothing() + { + var source = @" +namespace TestApp; + +public class Foo { } +"; + + var runResult = TestHelper.RunGenerator(source); + + Assert.Empty(runResult.Results.SelectMany(r => r.GeneratedSources)); + } + + [Fact] + public void ConvenienceMethod_GeneratesWarning() + { + var source = @" +using softaware.Cqs; +using Microsoft.Extensions.DependencyInjection; + +namespace TestApp; + +public class Startup +{ + public void Configure(IServiceCollection services) + { + services + .AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(Startup))) + .AddDecorators(b => b.AddTransactionCommandHandlerDecorator()); + } +} + +public static class FakeExtensions +{ + public static SoftawareCqsDecoratorBuilder AddTransactionCommandHandlerDecorator(this SoftawareCqsDecoratorBuilder b) => b; +} +"; + + var (outputCompilation, diagnostics, runResult) = TestHelper.RunGeneratorWithCompilation(source); + + var generatorDiagnostics = runResult.Results + .SelectMany(r => r.Diagnostics) + .Where(d => d.Id == "CQ0004") + .ToList(); + + Assert.Single(generatorDiagnostics); + Assert.Contains("AddTransactionCommandHandlerDecorator", generatorDiagnostics[0].GetMessage(CultureInfo.InvariantCulture)); + } + + [Fact] + public void SnapshotTest_BasicRegistration() + { + var source = @" +using softaware.Cqs; +using Microsoft.Extensions.DependencyInjection; + +namespace TestApp; + +public class GetValue : IQuery { } + +public class GetValueHandler : IRequestHandler +{ + public System.Threading.Tasks.Task HandleAsync(GetValue q, System.Threading.CancellationToken ct) + => System.Threading.Tasks.Task.FromResult(42); +} + +public class Startup +{ + public void Configure(IServiceCollection services) + { + services.AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(GetValue))); + } +} +"; + + var (_, _, runResult) = TestHelper.RunGeneratorWithCompilation(source); + var registrationSource = TestHelper.GetGeneratedSource(runResult, "CqsServiceRegistration.g.cs"); + var processorSource = TestHelper.GetGeneratedSource(runResult, "GeneratedRequestProcessor.g.cs"); + + // Snapshot assertions: verify exact structure + Assert.Contains("namespace softaware.Cqs.Generated;", registrationSource); + Assert.Contains("internal static class CqsServiceRegistration", registrationSource); + Assert.Contains("public static void RegisterAll", registrationSource); + Assert.Contains("ActivatorUtilities.CreateInstance", registrationSource); + Assert.Contains("GeneratedRequestProcessor", registrationSource); + + Assert.Contains("namespace softaware.Cqs.Generated;", processorSource); + Assert.Contains("internal sealed class GeneratedRequestProcessor", processorSource); + Assert.Contains("global::softaware.Cqs.IRequestProcessor", processorSource); + Assert.Contains("global::TestApp.GetValue", processorSource); + } + + [Fact] + public void SnapshotTest_WithDecoratorChain() + { + var source = @" +using softaware.Cqs; +using Microsoft.Extensions.DependencyInjection; + +namespace TestApp; + +public class DoWork : ICommand { } + +public class DoWorkHandler : IRequestHandler +{ + public System.Threading.Tasks.Task HandleAsync(DoWork c, System.Threading.CancellationToken ct) + => NoResult.CompletedTask; +} + +public class AllRequestDecorator : IRequestHandler + where TRequest : IRequest +{ + private readonly IRequestHandler d; + public AllRequestDecorator(IRequestHandler d) => this.d = d; + public System.Threading.Tasks.Task HandleAsync(TRequest r, System.Threading.CancellationToken ct) => d.HandleAsync(r, ct); +} + +public class CommandDecorator : IRequestHandler + where TRequest : ICommand +{ + private readonly IRequestHandler d; + public CommandDecorator(IRequestHandler d) => this.d = d; + public System.Threading.Tasks.Task HandleAsync(TRequest r, System.Threading.CancellationToken ct) => d.HandleAsync(r, ct); +} + +public class Startup +{ + public void Configure(IServiceCollection services) + { + services + .AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(DoWork))) + .AddDecorators(b => b + .AddRequestHandlerDecorator(typeof(AllRequestDecorator<,>)) + .AddRequestHandlerDecorator(typeof(CommandDecorator<,>))); + } +} +"; + + var (_, _, runResult) = TestHelper.RunGeneratorWithCompilation(source); + var registrationSource = TestHelper.GetGeneratedSource(runResult, "CqsServiceRegistration.g.cs")!; + + // Both decorators should apply to DoWork (it's a command, so both IRequest and ICommand constraints are met) + Assert.Contains("AllRequestDecorator { public int Value { get; set; } } +public class MyQueryHandler : IRequestHandler +{ + public System.Threading.Tasks.Task HandleAsync(MyQuery q, System.Threading.CancellationToken ct) + => System.Threading.Tasks.Task.FromResult(q.Value); +} + +public class Startup +{ + public void Configure(IServiceCollection services) + { + services.AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(MyQuery))); + } +} +"; + + var (_, _, runResult) = TestHelper.RunGeneratorWithCompilation(source); + var registrationSource = TestHelper.GetGeneratedSource(runResult, "CqsServiceRegistration.g.cs"); + + // The generated RegisterAll method must register IRequestProcessor → GeneratedRequestProcessor + Assert.Contains("IRequestProcessor", registrationSource); + Assert.Contains("GeneratedRequestProcessor", registrationSource); + Assert.Contains("AddTransient", registrationSource); + } + + [Fact] + public void IncludeTypesFrom_WithVariable_GeneratesError() + { + var source = @" +using System; +using softaware.Cqs; +using Microsoft.Extensions.DependencyInjection; + +namespace TestApp; + +public class MyQuery : IQuery { } +public class MyQueryHandler : IRequestHandler +{ + public System.Threading.Tasks.Task HandleAsync(MyQuery q, System.Threading.CancellationToken ct) + => System.Threading.Tasks.Task.FromResult(42); +} + +public class Startup +{ + public void Configure(IServiceCollection services) + { + var markerType = typeof(MyQuery); + services.AddSoftawareCqs(b => b.IncludeTypesFrom(markerType)); + } +} +"; + + var (_, _, runResult) = TestHelper.RunGeneratorWithCompilation(source); + + var errors = runResult.Results + .SelectMany(r => r.Diagnostics) + .Where(d => d.Id == "CQ0008") + .ToList(); + + Assert.Single(errors); + Assert.Contains("IncludeTypesFrom", errors[0].GetMessage(CultureInfo.InvariantCulture)); + Assert.Equal(Microsoft.CodeAnalysis.DiagnosticSeverity.Error, errors[0].Severity); + + // Should NOT generate any source files when there are errors + Assert.Empty(runResult.Results.SelectMany(r => r.GeneratedSources)); + } + + [Fact] + public void AddRequestHandlerDecorator_WithVariable_GeneratesError() + { + var source = @" +using System; +using softaware.Cqs; +using Microsoft.Extensions.DependencyInjection; + +namespace TestApp; + +public class MyCommand : ICommand { } +public class MyCommandHandler : IRequestHandler +{ + public System.Threading.Tasks.Task HandleAsync(MyCommand c, System.Threading.CancellationToken ct) + => NoResult.CompletedTask; +} + +public class MyDecorator : IRequestHandler + where TRequest : IRequest +{ + private readonly IRequestHandler d; + public MyDecorator(IRequestHandler d) => this.d = d; + public System.Threading.Tasks.Task HandleAsync(TRequest r, System.Threading.CancellationToken ct) => d.HandleAsync(r, ct); +} + +public class Startup +{ + public void Configure(IServiceCollection services) + { + var decoratorType = typeof(MyDecorator<,>); + services + .AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(MyCommand))) + .AddDecorators(b => b.AddRequestHandlerDecorator(decoratorType)); + } +} +"; + + var (_, _, runResult) = TestHelper.RunGeneratorWithCompilation(source); + + var errors = runResult.Results + .SelectMany(r => r.Diagnostics) + .Where(d => d.Id == "CQ0008") + .ToList(); + + Assert.Single(errors); + Assert.Contains("AddRequestHandlerDecorator", errors[0].GetMessage(CultureInfo.InvariantCulture)); + } + + [Fact] + public void OpenGenericRequest_GeneratesError() + { + var source = @" +using softaware.Cqs; +using Microsoft.Extensions.DependencyInjection; + +namespace TestApp; + +public class GetNextLogicalId : IQuery { } + +public class GetNextLogicalIdHandler : IRequestHandler, int> +{ + public System.Threading.Tasks.Task HandleAsync(GetNextLogicalId q, System.Threading.CancellationToken ct) + => System.Threading.Tasks.Task.FromResult(1); +} + +public class SimpleQuery : IQuery { } +public class SimpleQueryHandler : IRequestHandler +{ + public System.Threading.Tasks.Task HandleAsync(SimpleQuery q, System.Threading.CancellationToken ct) + => System.Threading.Tasks.Task.FromResult(""ok""); +} + +public class Startup +{ + public void Configure(IServiceCollection services) + { + services.AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(SimpleQuery))); + } +} +"; + + var (_, _, runResult) = TestHelper.RunGeneratorWithCompilation(source); + + var errors = runResult.Results + .SelectMany(r => r.Diagnostics) + .Where(d => d.Id == "CQ0009") + .ToList(); + + Assert.Single(errors); + Assert.Contains("GetNextLogicalIdHandler", errors[0].GetMessage(CultureInfo.InvariantCulture)); + Assert.Contains("GetNextLogicalId", errors[0].GetMessage(CultureInfo.InvariantCulture)); + + // The non-generic handler should still be generated + var registrationSource = TestHelper.GetGeneratedSource(runResult, "CqsServiceRegistration.g.cs"); + Assert.Contains("SimpleQueryHandler", registrationSource); + Assert.DoesNotContain("GetNextLogicalIdHandler", registrationSource); + } + + [Fact] + public void ConditionalDecoratorRegistration_GeneratesRegistryCheck() + { + var source = @" +using softaware.Cqs; +using Microsoft.Extensions.DependencyInjection; + +namespace TestApp; + +public class MyCommand : ICommand { } +public class MyCommandHandler : IRequestHandler +{ + public System.Threading.Tasks.Task HandleAsync(MyCommand c, System.Threading.CancellationToken ct) + => NoResult.CompletedTask; +} + +public class DecoratorA : IRequestHandler + where TRequest : IRequest +{ + private readonly IRequestHandler d; + public DecoratorA(IRequestHandler d) => this.d = d; + public System.Threading.Tasks.Task HandleAsync(TRequest r, System.Threading.CancellationToken ct) => d.HandleAsync(r, ct); +} + +public class DecoratorB : IRequestHandler + where TRequest : IRequest +{ + private readonly IRequestHandler d; + public DecoratorB(IRequestHandler d) => this.d = d; + public System.Threading.Tasks.Task HandleAsync(TRequest r, System.Threading.CancellationToken ct) => d.HandleAsync(r, ct); +} + +public class Startup +{ + public void Configure(IServiceCollection services, bool useDecoratorA) + { + services + .AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(MyCommand))) + .AddDecorators(b => + { + if (useDecoratorA) + { + b.AddRequestHandlerDecorator(typeof(DecoratorA<,>)); + } + + b.AddRequestHandlerDecorator(typeof(DecoratorB<,>)); + }); + } +} +"; + + var (_, _, runResult) = TestHelper.RunGeneratorWithCompilation(source); + + var registrationSource = TestHelper.GetGeneratedSource(runResult, "CqsServiceRegistration.g.cs"); + Assert.NotNull(registrationSource); + + // DecoratorA (conditional) should be wrapped in a registry check + Assert.Contains("__decoratorRegistry.IsEnabled(typeof(global::TestApp.DecoratorA<,>))", registrationSource); + + // DecoratorB (unconditional) should be applied directly without a registry check + Assert.Contains("DecoratorB", registrationSource); + // Count occurrences of IsEnabled — should only appear once (for DecoratorA) + var isEnabledCount = registrationSource.Split(["IsEnabled"], StringSplitOptions.None).Length - 1; + Assert.Equal(1, isEnabledCount); + + // Should register a default CqsDecoratorRegistry + Assert.Contains("CqsDecoratorRegistry", registrationSource); + + // CQ0010 info diagnostic should be reported for the conditional decorator + var conditionalDiagnostics = runResult.Results + .SelectMany(r => r.Diagnostics) + .Where(d => d.Id == "CQ0010") + .ToList(); + + Assert.Single(conditionalDiagnostics); + Assert.Equal(Microsoft.CodeAnalysis.DiagnosticSeverity.Info, conditionalDiagnostics[0].Severity); + } + + [Fact] + public void IncludeTypesFrom_WithAssemblyAccess_GeneratesUnsupportedAssemblyOverloadError() + { + var source = @" +using System; +using System.Reflection; +using softaware.Cqs; +using Microsoft.Extensions.DependencyInjection; + +namespace TestApp; + +public class MyQuery : IQuery { } +public class MyQueryHandler : IRequestHandler +{ + public System.Threading.Tasks.Task HandleAsync(MyQuery q, System.Threading.CancellationToken ct) + => System.Threading.Tasks.Task.FromResult(42); +} + +public class Startup +{ + public void Configure(IServiceCollection services) + { + services.AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(MyQuery).Assembly)); + } +} +"; + + var (_, _, runResult) = TestHelper.RunGeneratorWithCompilation(source); + + var errors = runResult.Results + .SelectMany(r => r.Diagnostics) + .Where(d => d.Id == "CQ0003") + .ToList(); + + Assert.Single(errors); + Assert.Equal(Microsoft.CodeAnalysis.DiagnosticSeverity.Error, errors[0].Severity); + Assert.Contains("IncludeTypesFrom", errors[0].GetMessage(CultureInfo.InvariantCulture)); + + // Should NOT generate any source files when there are errors + Assert.Empty(runResult.Results.SelectMany(r => r.GeneratedSources)); + } + + [Fact] + public void RequestTypeWithoutHandler_GeneratesMissingHandlerError() + { + var source = @" +using softaware.Cqs; +using Microsoft.Extensions.DependencyInjection; + +namespace TestApp; + +public class HandledQuery : IQuery { } +public class HandledQueryHandler : IRequestHandler +{ + public System.Threading.Tasks.Task HandleAsync(HandledQuery q, System.Threading.CancellationToken ct) + => System.Threading.Tasks.Task.FromResult(42); +} + +public class OrphanCommand : ICommand { } + +public class Startup +{ + public void Configure(IServiceCollection services) + { + services.AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(HandledQuery))); + } +} +"; + + var (_, _, runResult) = TestHelper.RunGeneratorWithCompilation(source); + + var errors = runResult.Results + .SelectMany(r => r.Diagnostics) + .Where(d => d.Id == "CQ0002") + .ToList(); + + Assert.Single(errors); + Assert.Contains("OrphanCommand", errors[0].GetMessage(CultureInfo.InvariantCulture)); + Assert.Equal(Microsoft.CodeAnalysis.DiagnosticSeverity.Error, errors[0].Severity); + + // The handled query should still be generated + var registrationSource = TestHelper.GetGeneratedSource(runResult, "CqsServiceRegistration.g.cs"); + Assert.Contains("HandledQueryHandler", registrationSource); + Assert.DoesNotContain("OrphanCommand", registrationSource); + } + + [Fact] + public void UnsupportedMethodInAddDecorators_GeneratesWarning() + { + var source = @" +using softaware.Cqs; +using Microsoft.Extensions.DependencyInjection; + +namespace TestApp; + +public class MyCommand : ICommand { } +public class MyCommandHandler : IRequestHandler +{ + public System.Threading.Tasks.Task HandleAsync(MyCommand c, System.Threading.CancellationToken ct) + => NoResult.CompletedTask; +} + +public class MyDecorator : IRequestHandler + where TRequest : IRequest +{ + private readonly IRequestHandler d; + public MyDecorator(IRequestHandler d) => this.d = d; + public System.Threading.Tasks.Task HandleAsync(TRequest r, System.Threading.CancellationToken ct) => d.HandleAsync(r, ct); +} + +public class Startup +{ + public void Configure(IServiceCollection services) + { + services + .AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(MyCommand))) + .AddDecorators(b => + { + b.AddRequestHandlerDecorator(typeof(MyDecorator<,>)); + b.SomeCustomMethod(); + }); + } +} +"; + + var (_, _, runResult) = TestHelper.RunGeneratorWithCompilation(source); + + var warnings = runResult.Results + .SelectMany(r => r.Diagnostics) + .Where(d => d.Id == "CQ0012") + .ToList(); + + Assert.Single(warnings); + Assert.Contains("SomeCustomMethod", warnings[0].GetMessage(CultureInfo.InvariantCulture)); + Assert.Equal(Microsoft.CodeAnalysis.DiagnosticSeverity.Warning, warnings[0].Severity); + } +} diff --git a/src/softaware.Cqs.DependencyInjection.SourceGenerator.Tests/GlobalSuppressions.cs b/src/softaware.Cqs.DependencyInjection.SourceGenerator.Tests/GlobalSuppressions.cs new file mode 100644 index 0000000..b778c28 --- /dev/null +++ b/src/softaware.Cqs.DependencyInjection.SourceGenerator.Tests/GlobalSuppressions.cs @@ -0,0 +1,8 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "tests", Scope = "namespaceanddescendants", Target = "~N:softaware.Cqs.DependencyInjection.SourceGenerator.Tests")] diff --git a/src/softaware.Cqs.DependencyInjection.SourceGenerator.Tests/TestHelper.cs b/src/softaware.Cqs.DependencyInjection.SourceGenerator.Tests/TestHelper.cs new file mode 100644 index 0000000..51aa870 --- /dev/null +++ b/src/softaware.Cqs.DependencyInjection.SourceGenerator.Tests/TestHelper.cs @@ -0,0 +1,128 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace softaware.Cqs.DependencyInjection.SourceGenerator.Tests; + +internal static class TestHelper +{ + /// + /// Runs the CQS source generator against the given source code and returns the result. + /// + public static GeneratorDriverRunResult RunGenerator(string source, params string[] additionalSources) + { + var syntaxTrees = new List + { + CSharpSyntaxTree.ParseText(source) + }; + + foreach (var additional in additionalSources) + { + syntaxTrees.Add(CSharpSyntaxTree.ParseText(additional)); + } + + var references = GetMetadataReferences(); + + var compilation = CSharpCompilation.Create( + assemblyName: "TestAssembly", + syntaxTrees: syntaxTrees, + references: references, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var generator = new CqsSourceGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); + + return driver.GetRunResult(); + } + + /// + /// Runs the generator and returns the output compilation and diagnostics. + /// + public static (Compilation OutputCompilation, ImmutableArray Diagnostics, GeneratorDriverRunResult RunResult) + RunGeneratorWithCompilation(string source, params string[] additionalSources) + { + var syntaxTrees = new List + { + CSharpSyntaxTree.ParseText(source) + }; + + foreach (var additional in additionalSources) + { + syntaxTrees.Add(CSharpSyntaxTree.ParseText(additional)); + } + + var references = GetMetadataReferences(); + + var compilation = CSharpCompilation.Create( + assemblyName: "TestAssembly", + syntaxTrees: syntaxTrees, + references: references, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var generator = new CqsSourceGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver.Create(generator); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); + + return (outputCompilation, diagnostics, driver.GetRunResult()); + } + + private static List GetMetadataReferences() + { + var references = new List(); + + // Add core .NET references + var runtimeDir = Path.GetDirectoryName(typeof(object).Assembly.Location)!; + references.Add(MetadataReference.CreateFromFile(Path.Combine(runtimeDir, "System.Runtime.dll"))); + references.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)); + references.Add(MetadataReference.CreateFromFile(typeof(Attribute).Assembly.Location)); + + // Add System.Collections (for List<>, etc.) + var collectionsAssembly = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetName().Name == "System.Collections"); + if (collectionsAssembly != null) + { + references.Add(MetadataReference.CreateFromFile(collectionsAssembly.Location)); + } + + // Add softaware.Cqs core (IRequest, ICommand, IQuery, IRequestHandler, IRequestProcessor, NoResult) + references.Add(MetadataReference.CreateFromFile(typeof(IRequestProcessor).Assembly.Location)); + + // Add Microsoft.Extensions.DependencyInjection.Abstractions + var diAbstractionsAssembly = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetName().Name == "Microsoft.Extensions.DependencyInjection.Abstractions"); + if (diAbstractionsAssembly != null) + { + references.Add(MetadataReference.CreateFromFile(diAbstractionsAssembly.Location)); + } + + // Add system threading + var threadingAssembly = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetName().Name == "System.Threading"); + if (threadingAssembly != null) + { + references.Add(MetadataReference.CreateFromFile(threadingAssembly.Location)); + } + + // Add netstandard + var netstandardAssembly = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetName().Name == "netstandard"); + if (netstandardAssembly != null) + { + references.Add(MetadataReference.CreateFromFile(netstandardAssembly.Location)); + } + + return references; + } + + /// + /// Gets the generated source text for a specific hint name from the run result. + /// + public static string? GetGeneratedSource(GeneratorDriverRunResult result, string hintName) + { + return result.Results + .SelectMany(r => r.GeneratedSources) + .FirstOrDefault(s => s.HintName == hintName) + .SourceText?.ToString(); + } +} diff --git a/src/softaware.Cqs.DependencyInjection.SourceGenerator.Tests/softaware.Cqs.DependencyInjection.SourceGenerator.Tests.csproj b/src/softaware.Cqs.DependencyInjection.SourceGenerator.Tests/softaware.Cqs.DependencyInjection.SourceGenerator.Tests.csproj new file mode 100644 index 0000000..1683b8b --- /dev/null +++ b/src/softaware.Cqs.DependencyInjection.SourceGenerator.Tests/softaware.Cqs.DependencyInjection.SourceGenerator.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + latest + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/src/softaware.Cqs.DependencyInjection.SourceGenerator/AnalyzerReleases.Shipped.md b/src/softaware.Cqs.DependencyInjection.SourceGenerator/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..f50bb1f --- /dev/null +++ b/src/softaware.Cqs.DependencyInjection.SourceGenerator/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/src/softaware.Cqs.DependencyInjection.SourceGenerator/AnalyzerReleases.Unshipped.md b/src/softaware.Cqs.DependencyInjection.SourceGenerator/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..254c6bb --- /dev/null +++ b/src/softaware.Cqs.DependencyInjection.SourceGenerator/AnalyzerReleases.Unshipped.md @@ -0,0 +1,18 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|-------------------- +CQ0002 | Usage | Error | MissingHandler +CQ0003 | Usage | Error | UnsupportedAssemblyOverload +CQ0004 | Usage | Warning | ConvenienceMethodDetected +CQ0005 | Usage | Warning | NoConfigurationFound +CQ0006 | Usage | Warning | CoreTypesNotFound +CQ0007 | Usage | Info | GenerationSucceeded +CQ0008 | Usage | Error | TypeofExpressionRequired +CQ0009 | Usage | Error | OpenGenericRequestNotSupported +CQ0010 | Usage | Info | ConditionalDecoratorRegistration +CQ0011 | Usage | Error | UnsupportedDecoratorGenericShape +CQ0012 | Usage | Warning | UnsupportedMethodInAddDecorators diff --git a/src/softaware.Cqs.DependencyInjection.SourceGenerator/CqsConfiguration.cs b/src/softaware.Cqs.DependencyInjection.SourceGenerator/CqsConfiguration.cs new file mode 100644 index 0000000..965db97 --- /dev/null +++ b/src/softaware.Cqs.DependencyInjection.SourceGenerator/CqsConfiguration.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis; + +namespace softaware.Cqs.DependencyInjection.SourceGenerator; + +/// +/// Parsed CQS configuration extracted from the syntax tree. +/// +internal sealed class CqsConfiguration +{ + /// + /// Marker types from IncludeTypesFrom(typeof(...)). Used to identify assemblies containing handlers. + /// + public List MarkerTypes { get; } = []; + + /// + /// Decorator registrations from AddRequestHandlerDecorator(typeof(...)), in registration order. + /// First registered = closest to handler, last registered = outermost. + /// + public List DecoratorTypes { get; } = []; + + /// + /// Location of the AddSoftawareCqs invocation (for diagnostics). + /// + public Location? InvocationLocation { get; set; } + + /// + /// Diagnostics collected during syntax extraction that must be reported in the source output phase. + /// + public List PendingDiagnostics { get; } = []; +} + +/// +/// A decorator type extracted from the syntax tree, with metadata about whether it appeared +/// inside a conditional block (if/switch). +/// +internal sealed class DecoratorRegistration +{ + public INamedTypeSymbol Type { get; set; } = null!; + + /// + /// True if this AddRequestHandlerDecorator call was inside an if/switch block. + /// Conditional decorators are wrapped in a runtime registry check in the generated code. + /// + public bool IsConditional { get; set; } +} + +/// +/// A diagnostic that was detected during syntax extraction but must be reported later. +/// +internal sealed class PendingDiagnostic +{ + public DiagnosticDescriptor Descriptor { get; set; } = null!; + public Location Location { get; set; } = Location.None; + public object[] MessageArgs { get; set; } = []; +} + +/// +/// A discovered handler with its request type, result type, and applicable decorator chain. +/// +internal sealed class HandlerInfo +{ + public INamedTypeSymbol HandlerType { get; set; } = null!; + public INamedTypeSymbol RequestType { get; set; } = null!; + public INamedTypeSymbol ResultType { get; set; } = null!; + + /// + /// Decorators applicable to this handler, in registration order (first = closest to handler). + /// + public List ApplicableDecorators { get; } = []; +} diff --git a/src/softaware.Cqs.DependencyInjection.SourceGenerator/CqsSourceGenerator.cs b/src/softaware.Cqs.DependencyInjection.SourceGenerator/CqsSourceGenerator.cs new file mode 100644 index 0000000..dac1966 --- /dev/null +++ b/src/softaware.Cqs.DependencyInjection.SourceGenerator/CqsSourceGenerator.cs @@ -0,0 +1,1011 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace softaware.Cqs.DependencyInjection.SourceGenerator; + +[Generator] +public class CqsSourceGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var debugEnabled = context.AnalyzerConfigOptionsProvider + .Select(static (options, _) => + options.GlobalOptions.TryGetValue("build_property.CqsDebugSourceGenerator", out var value) + && string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)); + + // Find all InvocationExpression nodes that could be AddSoftawareCqs calls + var cqsInvocations = context.SyntaxProvider.CreateSyntaxProvider( + predicate: static (node, _) => IsAddSoftawareCqsCandidate(node), + transform: static (ctx, ct) => ExtractConfiguration(ctx, ct)) + .Where(static c => c is not null); + + // Find convenience method calls (for warning diagnostic) + var convenienceMethodCalls = context.SyntaxProvider.CreateSyntaxProvider( + predicate: static (node, _) => IsConvenienceMethodCandidate(node), + transform: static (ctx, ct) => ExtractConvenienceMethodInfo(ctx)) + .Where(static info => info is not null); + + // Combine configurations with compilation + var compilationAndConfigs = context.CompilationProvider + .Combine(cqsInvocations.Collect()) + .Combine(convenienceMethodCalls.Collect()); + + context.RegisterSourceOutput(compilationAndConfigs.Combine(debugEnabled), static (spc, source) => + { + var (data, shouldDebug) = source; + if (shouldDebug && !Debugger.IsAttached) + { + Debugger.Launch(); + } + var ((compilation, configurations), convenienceMethods) = data; + Execute(compilation, configurations, convenienceMethods, spc); + }); + } + + private static bool IsAddSoftawareCqsCandidate(SyntaxNode node) + { + if (node is not InvocationExpressionSyntax invocation) + { + return false; + } + + var name = GetMethodName(invocation); + return name == "AddSoftawareCqs"; + } + + private static bool IsConvenienceMethodCandidate(SyntaxNode node) + { + if (node is not InvocationExpressionSyntax invocation) + { + return false; + } + + var name = GetMethodName(invocation); + return name is "AddTransactionCommandHandlerDecorator" + or "AddTransactionQueryHandlerDecorator" + or "AddDataAnnotationsValidationDecorators" + or "AddFluentValidationDecorators" + or "AddUsageAwareDecorators" + or "AddApplicationInsightsDependencyTelemetryDecorator"; + } + + private static string? GetMethodName(InvocationExpressionSyntax invocation) + { + return invocation.Expression switch + { + MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.Text, + IdentifierNameSyntax identifier => identifier.Identifier.Text, + _ => null + }; + } + + private static bool IsInsideConditional(SyntaxNode node, SyntaxNode boundary) + { + var current = node.Parent; + while (current != null && current != boundary) + { + if (current is IfStatementSyntax or SwitchStatementSyntax or SwitchExpressionSyntax or ConditionalExpressionSyntax) + { + return true; + } + + current = current.Parent; + } + return false; + } + + private static CqsConfiguration? ExtractConfiguration(GeneratorSyntaxContext context, CancellationToken ct) + { + var invocation = (InvocationExpressionSyntax)context.Node; + var config = new CqsConfiguration + { + InvocationLocation = invocation.GetLocation() + }; + + // Extract IncludeTypesFrom(typeof(...)) from the lambda argument + if (invocation.ArgumentList.Arguments.Count > 0) + { + var lambdaArg = invocation.ArgumentList.Arguments[0].Expression; + ExtractMarkerTypes(lambdaArg, context.SemanticModel, config, ct); + } + + // Walk up the syntax tree to find chained .AddDecorators(...) calls + ExtractDecoratorTypes(invocation, context.SemanticModel, config, ct); + + if (config.MarkerTypes.Count == 0 && config.PendingDiagnostics.Count == 0) + { + return null; + } + + return config; + } + + private static void ExtractMarkerTypes( + ExpressionSyntax lambdaExpression, + SemanticModel semanticModel, + CqsConfiguration config, + CancellationToken ct) + { + // Find all typeof expressions within IncludeTypesFrom calls + foreach (var invocation in lambdaExpression.DescendantNodesAndSelf().OfType()) + { + var name = GetMethodName(invocation); + + if (name == "IncludeTypesFrom") + { + foreach (var arg in invocation.ArgumentList.Arguments) + { + if (arg.Expression is TypeOfExpressionSyntax typeOfExpr) + { + var typeInfo = semanticModel.GetTypeInfo(typeOfExpr.Type, ct); + if (typeInfo.Type is INamedTypeSymbol namedType) + { + config.MarkerTypes.Add(namedType); + } + } + else if (arg.Expression is MemberAccessExpressionSyntax memberAccess + && memberAccess.Name.Identifier.Text == "Assembly" + && memberAccess.Expression is TypeOfExpressionSyntax) + { + config.PendingDiagnostics.Add(new PendingDiagnostic + { + Descriptor = DiagnosticDescriptors.UnsupportedAssemblyOverload, + Location = arg.GetLocation(), + MessageArgs = [] + }); + } + else + { + config.PendingDiagnostics.Add(new PendingDiagnostic + { + Descriptor = DiagnosticDescriptors.TypeofExpressionRequired, + Location = arg.GetLocation(), + MessageArgs = ["IncludeTypesFrom"] + }); + } + } + } + } + } + + private static void ExtractDecoratorTypes( + InvocationExpressionSyntax addSoftawareCqsInvocation, + SemanticModel semanticModel, + CqsConfiguration config, + CancellationToken ct) + { + // Walk up: the AddSoftawareCqs invocation might be the expression of a MemberAccess for .AddDecorators(...) + // Pattern: services.AddSoftawareCqs(...).AddDecorators(...) + var current = addSoftawareCqsInvocation.Parent; + + while (current != null) + { + if (current is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == "AddDecorators" && + memberAccess.Parent is InvocationExpressionSyntax addDecoratorsInvocation) + { + // Extract typeof(...) from the AddDecorators lambda + if (addDecoratorsInvocation.ArgumentList.Arguments.Count > 0) + { + var lambdaArg = addDecoratorsInvocation.ArgumentList.Arguments[0].Expression; + ExtractDecoratorTypesFromLambda(lambdaArg, semanticModel, config, ct); + } + + // Continue walking up for more chained AddDecorators calls + current = addDecoratorsInvocation.Parent; + continue; + } + + current = current.Parent; + + // Stop at statement level + if (current is StatementSyntax or MemberDeclarationSyntax) + { + break; + } + } + } + + private static void ExtractDecoratorTypesFromLambda( + ExpressionSyntax lambdaExpression, + SemanticModel semanticModel, + CqsConfiguration config, + CancellationToken ct) + { + // In fluent chains like b.AddA(typeof(A)).AddB(typeof(B)), both InvocationExpressions + // start at the same SpanStart (the 'b' identifier). Use ArgumentList position to + // preserve registration order (first-registered = closest to handler). + var allInvocations = lambdaExpression + .DescendantNodesAndSelf() + .OfType() + .ToList(); + + // Warn about any method calls that are not AddRequestHandlerDecorator + foreach (var inv in allInvocations) + { + var name = GetMethodName(inv); + if (name != null && name != "AddRequestHandlerDecorator") + { + config.PendingDiagnostics.Add(new PendingDiagnostic + { + Descriptor = DiagnosticDescriptors.UnsupportedMethodInAddDecorators, + Location = inv.GetLocation(), + MessageArgs = [name] + }); + } + } + + var invocations = allInvocations + .Where(inv => GetMethodName(inv) == "AddRequestHandlerDecorator" && inv.ArgumentList.Arguments.Count > 0) + .OrderBy(inv => inv.ArgumentList.SpanStart); + + foreach (var invocation in invocations) + { + var isConditional = IsInsideConditional(invocation, lambdaExpression); + + if (isConditional) + { + config.PendingDiagnostics.Add(new PendingDiagnostic + { + Descriptor = DiagnosticDescriptors.ConditionalDecoratorRegistration, + Location = invocation.GetLocation(), + MessageArgs = [] + }); + } + + var arg = invocation.ArgumentList.Arguments[0].Expression; + if (arg is TypeOfExpressionSyntax typeOfExpr) + { + var typeInfo = semanticModel.GetTypeInfo(typeOfExpr.Type, ct); + if (typeInfo.Type is INamedTypeSymbol namedType) + { + config.DecoratorTypes.Add(new DecoratorRegistration + { + Type = namedType.OriginalDefinition, + IsConditional = isConditional + }); + } + } + else + { + config.PendingDiagnostics.Add(new PendingDiagnostic + { + Descriptor = DiagnosticDescriptors.TypeofExpressionRequired, + Location = arg.GetLocation(), + MessageArgs = ["AddRequestHandlerDecorator"] + }); + } + } + } + + private static (string Name, Location Location)? ExtractConvenienceMethodInfo( + GeneratorSyntaxContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + var name = GetMethodName(invocation); + if (name == null) + { + return null; + } + + return (name, invocation.GetLocation()); + } + + private static void Execute( + Compilation compilation, + ImmutableArray configurations, + ImmutableArray<(string Name, Location Location)?> convenienceMethods, + SourceProductionContext context) + { + // Report convenience method warnings + foreach (var method in convenienceMethods) + { + if (method.HasValue) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.ConvenienceMethodDetected, + method.Value.Location, + method.Value.Name)); + } + } + + // Report pending diagnostics from all configurations (e.g. non-typeof arguments) + var hasPendingDiagnostics = false; + foreach (var config in configurations) + { + if (config?.PendingDiagnostics != null) + { + foreach (var diag in config.PendingDiagnostics) + { + hasPendingDiagnostics = true; + context.ReportDiagnostic(Diagnostic.Create(diag.Descriptor, diag.Location, diag.MessageArgs)); + } + } + } + + var validConfigs = configurations.Where(c => c != null && c.MarkerTypes.Count > 0).ToList(); + if (validConfigs.Count == 0) + { + // Only report "no configuration found" if there are no pending error diagnostics + // (otherwise the user already has a clear error message about what went wrong) + if (!hasPendingDiagnostics) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.NoConfigurationFound, + Location.None)); + } + return; + } + + // Merge all configurations + var mergedConfig = new CqsConfiguration(); + foreach (var config in validConfigs) + { + mergedConfig.MarkerTypes.AddRange(config!.MarkerTypes); + mergedConfig.DecoratorTypes.AddRange(config!.DecoratorTypes); + mergedConfig.InvocationLocation ??= config.InvocationLocation; + } + + // Resolve core CQS type symbols + var requestHandlerType = compilation.GetTypeByMetadataName("softaware.Cqs.IRequestHandler`2"); + var requestType = compilation.GetTypeByMetadataName("softaware.Cqs.IRequest`1"); + var requestProcessorType = compilation.GetTypeByMetadataName("softaware.Cqs.IRequestProcessor"); + + if (requestHandlerType == null || requestType == null || requestProcessorType == null) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.CoreTypesNotFound, + mergedConfig.InvocationLocation ?? Location.None)); + return; + } + + // Discover handlers in the assemblies of marker types + var handlers = DiscoverHandlers(mergedConfig, requestHandlerType, context); + + // Check for request types without handlers + ReportMissingHandlers(mergedConfig, handlers, requestType, context); + + // For each handler, determine which decorators apply + foreach (var handler in handlers) + { + foreach (var decoratorReg in mergedConfig.DecoratorTypes) + { + if (DecoratorApplies(compilation, decoratorReg.Type, handler.RequestType, handler.ResultType, requestHandlerType)) + { + handler.ApplicableDecorators.Add(decoratorReg); + } + } + } + + // Validate decorator generic shapes and report diagnostics for unsupported ones + var reportedDecoratorShapes = new HashSet(StringComparer.Ordinal); + foreach (var handler in handlers) + { + foreach (var decoratorReg in handler.ApplicableDecorators) + { + if (decoratorReg.Type.IsGenericType && + GetClosedDecoratorName(decoratorReg.Type, handler.RequestType, handler.ResultType) is null) + { + var decoratorName = decoratorReg.Type.ToDisplayString(); + if (reportedDecoratorShapes.Add(decoratorName)) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.UnsupportedDecoratorGenericShape, + mergedConfig.InvocationLocation ?? Location.None, + decoratorReg.Type.Name, + decoratorReg.Type.TypeParameters.Length)); + } + } + } + } + + // Generate registration code + var registrationSource = GenerateRegistrationCode(handlers); + context.AddSource("CqsServiceRegistration.g.cs", registrationSource); + + // Generate static request processor + var processorSource = GenerateRequestProcessorCode(handlers); + context.AddSource("GeneratedRequestProcessor.g.cs", processorSource); + + // Report success diagnostic so users know the generator ran + var totalDecorators = handlers.Sum(h => h.ApplicableDecorators.Count); + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.GenerationSucceeded, + mergedConfig.InvocationLocation ?? Location.None, + handlers.Count, + totalDecorators)); + } + + private static List DiscoverHandlers( + CqsConfiguration config, + INamedTypeSymbol requestHandlerType, + SourceProductionContext context) + { + var handlers = new List(); + var processedAssemblies = new HashSet(); + + foreach (var markerType in config.MarkerTypes) + { + var assembly = markerType.ContainingAssembly; + if (assembly == null || !processedAssemblies.Add(assembly.Name)) + { + continue; + } + + // Walk all types in this assembly + var allTypes = GetAllTypes(assembly.GlobalNamespace); + + foreach (var type in allTypes) + { + if (type.IsAbstract || type.IsStatic || type.TypeKind != TypeKind.Class) + { + continue; + } + + // Find IRequestHandler implementations + foreach (var iface in type.AllInterfaces) + { + if (!SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, requestHandlerType)) + { + continue; + } + + if (iface.TypeArguments[0] is not INamedTypeSymbol requestArg || iface.TypeArguments[1] is not INamedTypeSymbol resultArg) + { + continue; + } + + // Skip decorators (types that have a constructor parameter of IRequestHandler<,>) + if (IsDecorator(type, requestHandlerType)) + { + continue; + } + + // Open generic handlers (e.g. MyHandler : IRequestHandler, int>) + // cannot be registered because the generator produces explicit closed-type registrations. + if (type.TypeParameters.Length > 0) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.OpenGenericRequestNotSupported, + Location.None, + type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat), + requestArg.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat))); + continue; + } + + handlers.Add(new HandlerInfo + { + HandlerType = type, + RequestType = requestArg, + ResultType = resultArg + }); + } + } + } + + return handlers; + } + + private static void ReportMissingHandlers( + CqsConfiguration config, + List handlers, + INamedTypeSymbol requestType, + SourceProductionContext context) + { + var handledRequestTypes = new HashSet(SymbolEqualityComparer.Default); + foreach (var handler in handlers) + { + handledRequestTypes.Add(handler.RequestType); + } + + var processedAssemblies = new HashSet(); + + foreach (var markerType in config.MarkerTypes) + { + var assembly = markerType.ContainingAssembly; + if (assembly == null || !processedAssemblies.Add(assembly.Name)) + { + continue; + } + + foreach (var type in GetAllTypes(assembly.GlobalNamespace)) + { + if (type.IsAbstract || type.IsStatic || type.TypeKind != TypeKind.Class || type.TypeParameters.Length > 0) + { + continue; + } + + foreach (var iface in type.AllInterfaces) + { + if (!SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, requestType)) + { + continue; + } + + if (!handledRequestTypes.Contains(type)) + { + var resultArg = iface.TypeArguments[0]; + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.MissingHandler, + Location.None, + type.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat), + resultArg.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat))); + } + + break; + } + } + } + } + + private static bool IsDecorator(INamedTypeSymbol type, INamedTypeSymbol requestHandlerType) + { + foreach (var constructor in type.Constructors) + { + foreach (var param in constructor.Parameters) + { + if (param.Type is INamedTypeSymbol paramType && + paramType.IsGenericType && + SymbolEqualityComparer.Default.Equals(paramType.OriginalDefinition, requestHandlerType)) + { + return true; + } + } + } + + return false; + } + + private static bool DecoratorApplies( + Compilation compilation, + INamedTypeSymbol openDecoratorType, + INamedTypeSymbol requestType, + INamedTypeSymbol resultType, + INamedTypeSymbol requestHandlerType) + { + // The decorator is an open generic like Decorator + // We need to check if the constraints on TRequest (and TResult) are satisfied + // by the concrete requestType and resultType. + + if (!openDecoratorType.IsGenericType) + { + // Non-generic decorator: implements a specific IRequestHandler + foreach (var iface in openDecoratorType.AllInterfaces) + { + if (SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, requestHandlerType)) + { + var ifaceRequestArg = iface.TypeArguments[0]; + var ifaceResultArg = iface.TypeArguments[1]; + if (SymbolEqualityComparer.Default.Equals(ifaceRequestArg, requestType) && + SymbolEqualityComparer.Default.Equals(ifaceResultArg, resultType)) + { + return true; + } + } + } + return false; + } + + // For generic decorators, check type parameter constraints + var typeParams = openDecoratorType.TypeParameters; + + // We need to figure out which type parameter maps to TRequest and which to TResult. + // Convention: the decorator implements IRequestHandler where + // TRequest is the first type param constraint that involves IRequest/ICommand/IQuery. + // Let's find the IRequestHandler interface implementation to determine the mapping. + + ITypeSymbol? requestTypeParam = null; + ITypeSymbol? resultTypeParam = null; + + foreach (var iface in openDecoratorType.AllInterfaces) + { + if (SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, requestHandlerType)) + { + requestTypeParam = iface.TypeArguments[0]; + resultTypeParam = iface.TypeArguments[1]; + break; + } + } + + if (requestTypeParam == null || resultTypeParam == null) + { + return false; + } + + // Build a mapping from type parameters to concrete types + var typeParamMap = new Dictionary(SymbolEqualityComparer.Default); + + if (requestTypeParam is ITypeParameterSymbol requestTp) + { + typeParamMap[requestTp] = requestType; + } + else if (!SymbolEqualityComparer.Default.Equals(requestTypeParam, requestType)) + { + return false; + } + + if (resultTypeParam is ITypeParameterSymbol resultTp) + { + typeParamMap[resultTp] = resultType; + } + else if (!SymbolEqualityComparer.Default.Equals(resultTypeParam, resultType)) + { + return false; + } + + // Check all constraints on all type parameters + foreach (var tp in typeParams) + { + if (!typeParamMap.TryGetValue(tp, out var concreteType)) + { + continue; + } + + // Check each constraint + foreach (var constraintType in tp.ConstraintTypes) + { + var substituted = SubstituteTypeParameters(constraintType, typeParamMap); + if (!IsAssignableTo(compilation, concreteType, substituted)) + { + return false; + } + } + + // Check special constraints + if (tp.HasReferenceTypeConstraint && !concreteType.IsReferenceType) + { + return false; + } + + if (tp.HasValueTypeConstraint && !concreteType.IsValueType) + { + return false; + } + + if (tp.HasConstructorConstraint) + { + if (concreteType is INamedTypeSymbol namedConcrete) + { + var hasParameterlessCtor = namedConcrete.Constructors + .Any(c => c.Parameters.Length == 0 && c.DeclaredAccessibility == Accessibility.Public); + if (!hasParameterlessCtor) + { + return false; + } + } + } + } + + return true; + } + + private static ITypeSymbol SubstituteTypeParameters( + ITypeSymbol type, + Dictionary map) + { + if (type is ITypeParameterSymbol tp && map.TryGetValue(tp, out var substituted)) + { + return substituted; + } + + if (type is INamedTypeSymbol named && named.IsGenericType) + { + var args = named.TypeArguments; + var newArgs = new ITypeSymbol[args.Length]; + bool changed = false; + + for (int i = 0; i < args.Length; i++) + { + newArgs[i] = SubstituteTypeParameters(args[i], map); + if (!SymbolEqualityComparer.Default.Equals(newArgs[i], args[i])) + { + changed = true; + } + } + + if (changed) + { + return named.OriginalDefinition.Construct(newArgs); + } + } + + return type; + } + + private static bool IsAssignableTo(Compilation compilation, ITypeSymbol source, ITypeSymbol target) + { + if (SymbolEqualityComparer.Default.Equals(source, target)) + { + return true; + } + + // Check if source implements/extends target + if (target is INamedTypeSymbol namedTarget) + { + if (namedTarget.TypeKind == TypeKind.Interface) + { + return source.AllInterfaces.Any(i => + SymbolEqualityComparer.Default.Equals(i, namedTarget)); + } + + // Check base type chain + var current = source.BaseType; + while (current != null) + { + if (SymbolEqualityComparer.Default.Equals(current, namedTarget)) + { + return true; + } + + current = current.BaseType; + } + } + + // Fallback: use Roslyn's conversion classification + var conversion = compilation.ClassifyConversion(source, target); + return conversion.IsImplicit; + } + + private static IEnumerable GetAllTypes(INamespaceSymbol ns) + { + foreach (var type in ns.GetTypeMembers()) + { + yield return type; + foreach (var nested in GetNestedTypes(type)) + { + yield return nested; + } + } + + foreach (var childNs in ns.GetNamespaceMembers()) + { + foreach (var type in GetAllTypes(childNs)) + { + yield return type; + } + } + } + + private static IEnumerable GetNestedTypes(INamedTypeSymbol type) + { + foreach (var nested in type.GetTypeMembers()) + { + yield return nested; + foreach (var deepNested in GetNestedTypes(nested)) + { + yield return deepNested; + } + } + } + + private static string GetFullyQualifiedName(ITypeSymbol type) + { + return type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + + private static string GetOpenGenericTypeName(INamedTypeSymbol openGenericType) + { + // For typeof() expressions of open generic types, we need syntax like: + // typeof(global::TestApp.DecoratorA<,>) for a type with 2 type parameters. + // FullyQualifiedFormat produces e.g. "global::TestApp.DecoratorA" + // which is not valid in a typeof() context. + var fqn = GetFullyQualifiedName(openGenericType); + var angleBracketIndex = fqn.IndexOf('<'); + if (angleBracketIndex < 0) + { + return fqn; + } + + var baseName = fqn.Substring(0, angleBracketIndex); + var commas = new string(',', openGenericType.TypeParameters.Length - 1); + return $"{baseName}<{commas}>"; + } + + private static string GenerateRegistrationCode(List handlers) + { + var hasAnyConditionalDecorator = handlers.Any(h => h.ApplicableDecorators.Any(d => d.IsConditional)); + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.AppendLine("namespace softaware.Cqs.Generated;"); + sb.AppendLine(); + sb.AppendLine("/// "); + sb.AppendLine("/// Source-generated CQS service registrations."); + sb.AppendLine("/// "); + sb.AppendLine("[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]"); + sb.AppendLine("internal static class CqsServiceRegistration"); + sb.AppendLine("{"); + sb.AppendLine(" public static void RegisterAll(global::Microsoft.Extensions.DependencyInjection.IServiceCollection services)"); + sb.AppendLine(" {"); + + if (hasAnyConditionalDecorator) + { + // Register a default empty decorator registry (overridden by AddDecorators at runtime) + sb.AppendLine(" global::Microsoft.Extensions.DependencyInjection.Extensions.ServiceCollectionDescriptorExtensions.TryAddSingleton("); + sb.AppendLine(" services,"); + sb.AppendLine(" new global::softaware.Cqs.DependencyInjection.CqsDecoratorRegistry());"); + sb.AppendLine(); + } + + foreach (var handler in handlers) + { + var requestFqn = GetFullyQualifiedName(handler.RequestType); + var resultFqn = GetFullyQualifiedName(handler.ResultType); + var handlerFqn = GetFullyQualifiedName(handler.HandlerType); + var interfaceFqn = $"global::softaware.Cqs.IRequestHandler<{requestFqn}, {resultFqn}>"; + + if (handler.ApplicableDecorators.Count == 0) + { + // Simple registration without decorators + sb.AppendLine($" global::Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddTransient<{interfaceFqn}>("); + sb.AppendLine($" services,"); + sb.AppendLine($" static sp => global::Microsoft.Extensions.DependencyInjection.ActivatorUtilities.CreateInstance<{handlerFqn}>(sp));"); + } + else + { + var handlerHasConditional = handler.ApplicableDecorators.Any(d => d.IsConditional); + + // Registration with decorator chain + sb.AppendLine($" global::Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddTransient<{interfaceFqn}>("); + sb.AppendLine($" services,"); + + // Can only use 'static' lambda when there are no conditional decorators (no service lookups needed) + var staticModifier = handlerHasConditional ? "" : "static "; + sb.AppendLine($" {staticModifier}sp =>"); + sb.AppendLine($" {{"); + + if (handlerHasConditional) + { + sb.AppendLine($" var __decoratorRegistry = global::Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(sp);"); + } + + sb.AppendLine($" {interfaceFqn} current = global::Microsoft.Extensions.DependencyInjection.ActivatorUtilities.CreateInstance<{handlerFqn}>(sp);"); + + foreach (var decoratorReg in handler.ApplicableDecorators) + { + var closedDecoratorFqn = GetClosedDecoratorName(decoratorReg.Type, handler.RequestType, handler.ResultType); + + if (closedDecoratorFqn is null) + { + // Decorator has an unsupported generic shape — skip (diagnostic reported separately) + continue; + } + + if (decoratorReg.IsConditional) + { + var openDecoratorFqn = GetOpenGenericTypeName(decoratorReg.Type); + sb.AppendLine($" if (__decoratorRegistry.IsEnabled(typeof({openDecoratorFqn})))"); + sb.AppendLine($" {{"); + sb.AppendLine($" current = global::Microsoft.Extensions.DependencyInjection.ActivatorUtilities.CreateInstance<{closedDecoratorFqn}>(sp, current);"); + sb.AppendLine($" }}"); + } + else + { + sb.AppendLine($" current = global::Microsoft.Extensions.DependencyInjection.ActivatorUtilities.CreateInstance<{closedDecoratorFqn}>(sp, current);"); + } + } + + sb.AppendLine($" return current;"); + sb.AppendLine($" }});"); + } + + sb.AppendLine(); + } + + // Register the generated request processor + sb.AppendLine(" global::Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddTransient(services);"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + return sb.ToString(); + } + + private static string? GetClosedDecoratorName( + INamedTypeSymbol openDecoratorType, + INamedTypeSymbol requestType, + INamedTypeSymbol resultType) + { + if (!openDecoratorType.IsGenericType) + { + return GetFullyQualifiedName(openDecoratorType); + } + + // Simple approach: construct the closed type with concrete request and result types + // The decorator has type params that map to TRequest and TResult + var typeParams = openDecoratorType.TypeParameters; + var args = new ITypeSymbol[typeParams.Length]; + + // Find which type param is TRequest and which is TResult by looking at the IRequestHandler implementation + foreach (var iface in openDecoratorType.AllInterfaces) + { + if (iface.OriginalDefinition.MetadataName == "IRequestHandler`2" && + iface.ContainingNamespace?.ToDisplayString() == "softaware.Cqs") + { + for (int i = 0; i < typeParams.Length; i++) + { + if (SymbolEqualityComparer.Default.Equals(iface.TypeArguments[0], typeParams[i])) + { + args[i] = requestType; + } + else if (SymbolEqualityComparer.Default.Equals(iface.TypeArguments[1], typeParams[i])) + { + args[i] = resultType; + } + } + break; + } + } + + // If any type parameters could not be mapped, the decorator has an unsupported generic shape + for (int i = 0; i < args.Length; i++) + { + if (args[i] is null) + { + return null; + } + } + + var closedType = openDecoratorType.Construct(args); + return GetFullyQualifiedName(closedType); + } + + private static string GenerateRequestProcessorCode(List handlers) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.AppendLine("namespace softaware.Cqs.Generated;"); + sb.AppendLine(); + sb.AppendLine("/// "); + sb.AppendLine("/// Source-generated static-dispatch request processor. No reflection at runtime."); + sb.AppendLine("/// "); + sb.AppendLine("[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]"); + sb.AppendLine("internal sealed class GeneratedRequestProcessor : global::softaware.Cqs.IRequestProcessor"); + sb.AppendLine("{"); + sb.AppendLine(" private readonly global::System.IServiceProvider serviceProvider;"); + sb.AppendLine(); + sb.AppendLine(" public GeneratedRequestProcessor(global::System.IServiceProvider serviceProvider)"); + sb.AppendLine(" => this.serviceProvider = serviceProvider;"); + sb.AppendLine(); + sb.AppendLine(" public global::System.Threading.Tasks.Task HandleAsync("); + sb.AppendLine(" global::softaware.Cqs.IRequest request,"); + sb.AppendLine(" global::System.Threading.CancellationToken cancellationToken)"); + sb.AppendLine(" {"); + + int handlerIndex = 0; + foreach (var handler in handlers) + { + var requestFqn = GetFullyQualifiedName(handler.RequestType); + var resultFqn = GetFullyQualifiedName(handler.ResultType); + var interfaceFqn = $"global::softaware.Cqs.IRequestHandler<{requestFqn}, {resultFqn}>"; + var varName = $"r{handlerIndex}"; + handlerIndex++; + + sb.AppendLine($" if (request is {requestFqn} {varName})"); + sb.AppendLine($" {{"); + sb.AppendLine($" var handler = global::Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService<{interfaceFqn}>(this.serviceProvider);"); + sb.AppendLine($" return (global::System.Threading.Tasks.Task)(object)handler.HandleAsync({varName}, cancellationToken);"); + sb.AppendLine($" }}"); + sb.AppendLine(); + } + + sb.AppendLine(" throw new global::System.InvalidOperationException("); + sb.AppendLine(" $\"No handler registered for request type '{request.GetType().Name}'. \" +"); + sb.AppendLine(" $\"Ensure the request type's assembly is included via IncludeTypesFrom(typeof(...)).\");"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + return sb.ToString(); + } +} diff --git a/src/softaware.Cqs.DependencyInjection.SourceGenerator/DiagnosticDescriptors.cs b/src/softaware.Cqs.DependencyInjection.SourceGenerator/DiagnosticDescriptors.cs new file mode 100644 index 0000000..6e7c6df --- /dev/null +++ b/src/softaware.Cqs.DependencyInjection.SourceGenerator/DiagnosticDescriptors.cs @@ -0,0 +1,108 @@ +using Microsoft.CodeAnalysis; + +namespace softaware.Cqs.DependencyInjection.SourceGenerator; + +/// +/// Diagnostic descriptors for the CQS source generator. +/// +internal static class DiagnosticDescriptors +{ + public static readonly DiagnosticDescriptor MissingHandler = new( + id: "CQ0002", + title: "Missing request handler", + messageFormat: "Request type '{0}' has no registered IRequestHandler<{0}, {1}>", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Every request type (ICommand/IQuery) must have a corresponding handler registered via IncludeTypesFrom."); + + public static readonly DiagnosticDescriptor UnsupportedAssemblyOverload = new( + id: "CQ0003", + title: "Unsupported IncludeTypesFrom overload", + messageFormat: "IncludeTypesFrom(Assembly...) is not supported by the source generator. Use IncludeTypesFrom(typeof(MarkerType)) instead.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The source generator can only resolve types known at compile time. Use typeof() expressions instead of Assembly references."); + + public static readonly DiagnosticDescriptor ConvenienceMethodDetected = new( + id: "CQ0004", + title: "Unsupported convenience method", + messageFormat: "Convenience method '{0}' is not supported by the source generator. Use AddRequestHandlerDecorator(typeof(...)) directly.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "The source generator cannot trace through convenience extension methods. Register decorators directly via AddRequestHandlerDecorator(typeof(...))."); + + public static readonly DiagnosticDescriptor NoConfigurationFound = new( + id: "CQ0005", + title: "No CQS configuration found", + messageFormat: "No AddSoftawareCqs call found in the compilation. The source generator has nothing to generate.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "The source generator could not find any AddSoftawareCqs() call. Ensure you call services.AddSoftawareCqs(b => b.IncludeTypesFrom(typeof(YourMarkerType))). Suppress this warning with CQ0005."); + + public static readonly DiagnosticDescriptor CoreTypesNotFound = new( + id: "CQ0006", + title: "Core CQS types not resolved", + messageFormat: "Could not resolve softaware.Cqs core types (IRequestHandler, IRequest, IRequestProcessor). Ensure the softaware.CQS NuGet package is referenced.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "The source generator requires the softaware.CQS core package to resolve handler and request types."); + + public static readonly DiagnosticDescriptor GenerationSucceeded = new( + id: "CQ0007", + title: "CQS source generation succeeded", + messageFormat: "softaware.Cqs source generator: Registered {0} handler(s) with {1} decorator(s). IRequestProcessor → GeneratedRequestProcessor.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "The source generator successfully generated handler registrations (`CqsServiceRegistration.g.cs`) and the static request processor (`GeneratedRequestProcessor.g.cs`)."); + + public static readonly DiagnosticDescriptor TypeofExpressionRequired = new( + id: "CQ0008", + title: "Argument must be a typeof() expression", + messageFormat: "Argument to '{0}' must be a typeof() expression (e.g. typeof(MyType)). The source generator reads types from the syntax tree at compile time and cannot evaluate variables or method calls.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The source generator resolves types at compile time by reading typeof() expressions directly from the syntax tree. Variables, method calls, or other expressions cannot be evaluated. Use a marker type/interface and typeof(Marker) instead."); + + public static readonly DiagnosticDescriptor OpenGenericRequestNotSupported = new( + id: "CQ0009", + title: "Open generic request type not supported", + messageFormat: "Handler '{0}' uses open generic request type '{1}'. Open generic requests (e.g. MyRequest) are not supported by the source generator.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The source generator cannot register handlers for open generic request types because it generates explicit registrations for each concrete request type. Refactor to use a closed generic type or a non-generic base type."); + + public static readonly DiagnosticDescriptor ConditionalDecoratorRegistration = new( + id: "CQ0010", + title: "Conditional decorator registration detected", + messageFormat: "AddRequestHandlerDecorator inside a conditional block will use a runtime registry check. Ensure the AddDecorators lambda is executed at runtime.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "The source generator detected an AddRequestHandlerDecorator call inside an if/switch block. The generated code will check a runtime registry to determine if the decorator should be applied. The AddDecorators lambda must be executed at runtime for conditional decorators to work correctly."); + + public static readonly DiagnosticDescriptor UnsupportedDecoratorGenericShape = new( + id: "CQ0011", + title: "Unsupported decorator generic shape", + messageFormat: "Decorator '{0}' has {1} generic type parameter(s) but not all could be mapped to TRequest/TResult from IRequestHandler. Decorators must have exactly the type parameters used by their IRequestHandler implementation.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The source generator could not map all generic type parameters of the decorator to the handler's request and result types. Ensure the decorator's generic parameters align with the IRequestHandler interface it implements."); + + public static readonly DiagnosticDescriptor UnsupportedMethodInAddDecorators = new( + id: "CQ0012", + title: "Unsupported method call in AddDecorators", + messageFormat: "Method '{0}' inside AddDecorators is not supported by the source generator. Only AddRequestHandlerDecorator calls are recognized; this call will be silently ignored during code generation.", + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "The source generator only processes AddRequestHandlerDecorator calls inside the AddDecorators lambda. Other method calls (e.g. custom extension methods or helpers that register decorators internally) cannot be traced by the source generator and will be ignored. Register decorators directly via AddRequestHandlerDecorator(typeof(...))."); +} diff --git a/src/softaware.Cqs.DependencyInjection.SourceGenerator/softaware.Cqs.DependencyInjection.SourceGenerator.csproj b/src/softaware.Cqs.DependencyInjection.SourceGenerator/softaware.Cqs.DependencyInjection.SourceGenerator.csproj new file mode 100644 index 0000000..ab7a5a2 --- /dev/null +++ b/src/softaware.Cqs.DependencyInjection.SourceGenerator/softaware.Cqs.DependencyInjection.SourceGenerator.csproj @@ -0,0 +1,21 @@ + + + + netstandard2.0 + latest + enable + true + true + false + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/softaware.Cqs.Tests/CQ.Handlers/CommandHandlers/CommandWithDependencyHandler.cs b/src/softaware.Cqs.Tests/CQ.Handlers/CommandHandlers/CommandWithDependencyHandler.cs index 26acb88..ce48811 100644 --- a/src/softaware.Cqs.Tests/CQ.Handlers/CommandHandlers/CommandWithDependencyHandler.cs +++ b/src/softaware.Cqs.Tests/CQ.Handlers/CommandHandlers/CommandWithDependencyHandler.cs @@ -3,16 +3,11 @@ namespace softaware.Cqs.Tests.CQ.Handlers.CommandHandlers; -internal class CommandWithDependencyHandler : IRequestHandler +internal class CommandWithDependencyHandler(IDependency dependency) : IRequestHandler { - private readonly IDependency dependency; - - public CommandWithDependencyHandler(IDependency dependency) - => this.dependency = dependency; - public Task HandleAsync(CommandWithDependency command, CancellationToken cancellationToken) { - this.dependency.SomeMethod(); + dependency.SomeMethod(); return NoResult.CompletedTask; } diff --git a/src/softaware.Cqs.Tests/CQ.Handlers/QueryHandlers/SimpleQueryHandler.cs b/src/softaware.Cqs.Tests/CQ.Handlers/QueryHandlers/SimpleQueryHandler.cs index 25805b5..4f3461c 100644 --- a/src/softaware.Cqs.Tests/CQ.Handlers/QueryHandlers/SimpleQueryHandler.cs +++ b/src/softaware.Cqs.Tests/CQ.Handlers/QueryHandlers/SimpleQueryHandler.cs @@ -1,6 +1,7 @@ using softaware.Cqs.Tests.CQ.Contract.Queries; namespace softaware.Cqs.Tests.CQ.Handlers.QueryHandlers; + internal class SimpleQueryHandler : IRequestHandler, IRequestHandler diff --git a/src/softaware.Cqs.Tests/Fakes/MockTelemetryChannel.cs b/src/softaware.Cqs.Tests/Fakes/MockTelemetryChannel.cs index 7a5addd..e8c8f52 100644 --- a/src/softaware.Cqs.Tests/Fakes/MockTelemetryChannel.cs +++ b/src/softaware.Cqs.Tests/Fakes/MockTelemetryChannel.cs @@ -2,9 +2,10 @@ using Microsoft.ApplicationInsights.Channel; namespace softaware.Cqs.Tests.Fakes; -public class MockTelemetryChannel : ITelemetryChannel + +public sealed class MockTelemetryChannel : ITelemetryChannel { - public ConcurrentBag SentTelemetries { get; } = new ConcurrentBag(); + public ConcurrentBag SentTelemetries { get; } = []; public bool IsFlushed { get; private set; } public bool? DeveloperMode { get; set; } public string EndpointAddress { get; set; } = string.Empty; diff --git a/src/softaware.Cqs.Tests/softaware.Cqs.Tests.csproj b/src/softaware.Cqs.Tests/softaware.Cqs.Tests.csproj index bd9db52..7616c75 100644 --- a/src/softaware.Cqs.Tests/softaware.Cqs.Tests.csproj +++ b/src/softaware.Cqs.Tests/softaware.Cqs.Tests.csproj @@ -1,7 +1,7 @@ - net6.0 + net10.0 enable enable latest diff --git a/src/softaware.Cqs.sln b/src/softaware.Cqs.sln index 0cd9385..9e58b26 100644 --- a/src/softaware.Cqs.sln +++ b/src/softaware.Cqs.sln @@ -39,76 +39,264 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "softaware.Cqs.Decorators.Ap EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "softaware.Cqs.Decorators.ApplicationInsights.DependencyInjection", "softaware.Cqs.Decorators.ApplicationInsights.DependencyInjection\softaware.Cqs.Decorators.ApplicationInsights.DependencyInjection.csproj", "{480F6DEB-6AF7-4A5A-8C5F-2A8C95CE1993}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "softaware.Cqs.DependencyInjection.SourceGenerator", "softaware.Cqs.DependencyInjection.SourceGenerator\softaware.Cqs.DependencyInjection.SourceGenerator.csproj", "{14579293-41E9-48A0-BF1D-D4E3FA87F553}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "softaware.Cqs.DependencyInjection.SourceGenerated", "softaware.Cqs.DependencyInjection.SourceGenerated\softaware.Cqs.DependencyInjection.SourceGenerated.csproj", "{9E38C8C8-CD3F-423A-A40D-791C7EFC680D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "softaware.Cqs.DependencyInjection.SourceGenerator.Tests", "softaware.Cqs.DependencyInjection.SourceGenerator.Tests\softaware.Cqs.DependencyInjection.SourceGenerator.Tests.csproj", "{A2431A2E-F0DA-4D8D-87EE-38EE63A23942}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "softaware.Cqs.Benchmarks", "softaware.Cqs.Benchmarks\softaware.Cqs.Benchmarks.csproj", "{97E5B9D8-ADE7-42B1-B05D-3CD7ED74AEC4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {9DEE2395-1B87-41C1-853C-DB5461C7D149}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9DEE2395-1B87-41C1-853C-DB5461C7D149}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9DEE2395-1B87-41C1-853C-DB5461C7D149}.Debug|x64.ActiveCfg = Debug|Any CPU + {9DEE2395-1B87-41C1-853C-DB5461C7D149}.Debug|x64.Build.0 = Debug|Any CPU + {9DEE2395-1B87-41C1-853C-DB5461C7D149}.Debug|x86.ActiveCfg = Debug|Any CPU + {9DEE2395-1B87-41C1-853C-DB5461C7D149}.Debug|x86.Build.0 = Debug|Any CPU {9DEE2395-1B87-41C1-853C-DB5461C7D149}.Release|Any CPU.ActiveCfg = Release|Any CPU {9DEE2395-1B87-41C1-853C-DB5461C7D149}.Release|Any CPU.Build.0 = Release|Any CPU + {9DEE2395-1B87-41C1-853C-DB5461C7D149}.Release|x64.ActiveCfg = Release|Any CPU + {9DEE2395-1B87-41C1-853C-DB5461C7D149}.Release|x64.Build.0 = Release|Any CPU + {9DEE2395-1B87-41C1-853C-DB5461C7D149}.Release|x86.ActiveCfg = Release|Any CPU + {9DEE2395-1B87-41C1-853C-DB5461C7D149}.Release|x86.Build.0 = Release|Any CPU {596B796A-4DEC-4BE7-B5E6-9F224C145EE3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {596B796A-4DEC-4BE7-B5E6-9F224C145EE3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {596B796A-4DEC-4BE7-B5E6-9F224C145EE3}.Debug|x64.ActiveCfg = Debug|Any CPU + {596B796A-4DEC-4BE7-B5E6-9F224C145EE3}.Debug|x64.Build.0 = Debug|Any CPU + {596B796A-4DEC-4BE7-B5E6-9F224C145EE3}.Debug|x86.ActiveCfg = Debug|Any CPU + {596B796A-4DEC-4BE7-B5E6-9F224C145EE3}.Debug|x86.Build.0 = Debug|Any CPU {596B796A-4DEC-4BE7-B5E6-9F224C145EE3}.Release|Any CPU.ActiveCfg = Release|Any CPU {596B796A-4DEC-4BE7-B5E6-9F224C145EE3}.Release|Any CPU.Build.0 = Release|Any CPU + {596B796A-4DEC-4BE7-B5E6-9F224C145EE3}.Release|x64.ActiveCfg = Release|Any CPU + {596B796A-4DEC-4BE7-B5E6-9F224C145EE3}.Release|x64.Build.0 = Release|Any CPU + {596B796A-4DEC-4BE7-B5E6-9F224C145EE3}.Release|x86.ActiveCfg = Release|Any CPU + {596B796A-4DEC-4BE7-B5E6-9F224C145EE3}.Release|x86.Build.0 = Release|Any CPU {6372820B-CD80-4D21-B9C0-B143815CFE46}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6372820B-CD80-4D21-B9C0-B143815CFE46}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6372820B-CD80-4D21-B9C0-B143815CFE46}.Debug|x64.ActiveCfg = Debug|Any CPU + {6372820B-CD80-4D21-B9C0-B143815CFE46}.Debug|x64.Build.0 = Debug|Any CPU + {6372820B-CD80-4D21-B9C0-B143815CFE46}.Debug|x86.ActiveCfg = Debug|Any CPU + {6372820B-CD80-4D21-B9C0-B143815CFE46}.Debug|x86.Build.0 = Debug|Any CPU {6372820B-CD80-4D21-B9C0-B143815CFE46}.Release|Any CPU.ActiveCfg = Release|Any CPU {6372820B-CD80-4D21-B9C0-B143815CFE46}.Release|Any CPU.Build.0 = Release|Any CPU + {6372820B-CD80-4D21-B9C0-B143815CFE46}.Release|x64.ActiveCfg = Release|Any CPU + {6372820B-CD80-4D21-B9C0-B143815CFE46}.Release|x64.Build.0 = Release|Any CPU + {6372820B-CD80-4D21-B9C0-B143815CFE46}.Release|x86.ActiveCfg = Release|Any CPU + {6372820B-CD80-4D21-B9C0-B143815CFE46}.Release|x86.Build.0 = Release|Any CPU {78ABAB39-7F82-4964-904D-20AFBEF5E570}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {78ABAB39-7F82-4964-904D-20AFBEF5E570}.Debug|Any CPU.Build.0 = Debug|Any CPU + {78ABAB39-7F82-4964-904D-20AFBEF5E570}.Debug|x64.ActiveCfg = Debug|Any CPU + {78ABAB39-7F82-4964-904D-20AFBEF5E570}.Debug|x64.Build.0 = Debug|Any CPU + {78ABAB39-7F82-4964-904D-20AFBEF5E570}.Debug|x86.ActiveCfg = Debug|Any CPU + {78ABAB39-7F82-4964-904D-20AFBEF5E570}.Debug|x86.Build.0 = Debug|Any CPU {78ABAB39-7F82-4964-904D-20AFBEF5E570}.Release|Any CPU.ActiveCfg = Release|Any CPU {78ABAB39-7F82-4964-904D-20AFBEF5E570}.Release|Any CPU.Build.0 = Release|Any CPU + {78ABAB39-7F82-4964-904D-20AFBEF5E570}.Release|x64.ActiveCfg = Release|Any CPU + {78ABAB39-7F82-4964-904D-20AFBEF5E570}.Release|x64.Build.0 = Release|Any CPU + {78ABAB39-7F82-4964-904D-20AFBEF5E570}.Release|x86.ActiveCfg = Release|Any CPU + {78ABAB39-7F82-4964-904D-20AFBEF5E570}.Release|x86.Build.0 = Release|Any CPU {B9334117-F235-45CE-9590-9C1CADD6F6BF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B9334117-F235-45CE-9590-9C1CADD6F6BF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B9334117-F235-45CE-9590-9C1CADD6F6BF}.Debug|x64.ActiveCfg = Debug|Any CPU + {B9334117-F235-45CE-9590-9C1CADD6F6BF}.Debug|x64.Build.0 = Debug|Any CPU + {B9334117-F235-45CE-9590-9C1CADD6F6BF}.Debug|x86.ActiveCfg = Debug|Any CPU + {B9334117-F235-45CE-9590-9C1CADD6F6BF}.Debug|x86.Build.0 = Debug|Any CPU {B9334117-F235-45CE-9590-9C1CADD6F6BF}.Release|Any CPU.ActiveCfg = Release|Any CPU {B9334117-F235-45CE-9590-9C1CADD6F6BF}.Release|Any CPU.Build.0 = Release|Any CPU + {B9334117-F235-45CE-9590-9C1CADD6F6BF}.Release|x64.ActiveCfg = Release|Any CPU + {B9334117-F235-45CE-9590-9C1CADD6F6BF}.Release|x64.Build.0 = Release|Any CPU + {B9334117-F235-45CE-9590-9C1CADD6F6BF}.Release|x86.ActiveCfg = Release|Any CPU + {B9334117-F235-45CE-9590-9C1CADD6F6BF}.Release|x86.Build.0 = Release|Any CPU {3377E1D3-D5AC-4F5C-B04A-B12FBE6AA6A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3377E1D3-D5AC-4F5C-B04A-B12FBE6AA6A4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3377E1D3-D5AC-4F5C-B04A-B12FBE6AA6A4}.Debug|x64.ActiveCfg = Debug|Any CPU + {3377E1D3-D5AC-4F5C-B04A-B12FBE6AA6A4}.Debug|x64.Build.0 = Debug|Any CPU + {3377E1D3-D5AC-4F5C-B04A-B12FBE6AA6A4}.Debug|x86.ActiveCfg = Debug|Any CPU + {3377E1D3-D5AC-4F5C-B04A-B12FBE6AA6A4}.Debug|x86.Build.0 = Debug|Any CPU {3377E1D3-D5AC-4F5C-B04A-B12FBE6AA6A4}.Release|Any CPU.ActiveCfg = Release|Any CPU {3377E1D3-D5AC-4F5C-B04A-B12FBE6AA6A4}.Release|Any CPU.Build.0 = Release|Any CPU + {3377E1D3-D5AC-4F5C-B04A-B12FBE6AA6A4}.Release|x64.ActiveCfg = Release|Any CPU + {3377E1D3-D5AC-4F5C-B04A-B12FBE6AA6A4}.Release|x64.Build.0 = Release|Any CPU + {3377E1D3-D5AC-4F5C-B04A-B12FBE6AA6A4}.Release|x86.ActiveCfg = Release|Any CPU + {3377E1D3-D5AC-4F5C-B04A-B12FBE6AA6A4}.Release|x86.Build.0 = Release|Any CPU {C61BD2B4-021C-44CD-B1DA-1EA3D9E3B9EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C61BD2B4-021C-44CD-B1DA-1EA3D9E3B9EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C61BD2B4-021C-44CD-B1DA-1EA3D9E3B9EB}.Debug|x64.ActiveCfg = Debug|Any CPU + {C61BD2B4-021C-44CD-B1DA-1EA3D9E3B9EB}.Debug|x64.Build.0 = Debug|Any CPU + {C61BD2B4-021C-44CD-B1DA-1EA3D9E3B9EB}.Debug|x86.ActiveCfg = Debug|Any CPU + {C61BD2B4-021C-44CD-B1DA-1EA3D9E3B9EB}.Debug|x86.Build.0 = Debug|Any CPU {C61BD2B4-021C-44CD-B1DA-1EA3D9E3B9EB}.Release|Any CPU.ActiveCfg = Release|Any CPU {C61BD2B4-021C-44CD-B1DA-1EA3D9E3B9EB}.Release|Any CPU.Build.0 = Release|Any CPU + {C61BD2B4-021C-44CD-B1DA-1EA3D9E3B9EB}.Release|x64.ActiveCfg = Release|Any CPU + {C61BD2B4-021C-44CD-B1DA-1EA3D9E3B9EB}.Release|x64.Build.0 = Release|Any CPU + {C61BD2B4-021C-44CD-B1DA-1EA3D9E3B9EB}.Release|x86.ActiveCfg = Release|Any CPU + {C61BD2B4-021C-44CD-B1DA-1EA3D9E3B9EB}.Release|x86.Build.0 = Release|Any CPU {84FAEFC0-570E-4685-93F6-896BDF9FBAA4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {84FAEFC0-570E-4685-93F6-896BDF9FBAA4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {84FAEFC0-570E-4685-93F6-896BDF9FBAA4}.Debug|x64.ActiveCfg = Debug|Any CPU + {84FAEFC0-570E-4685-93F6-896BDF9FBAA4}.Debug|x64.Build.0 = Debug|Any CPU + {84FAEFC0-570E-4685-93F6-896BDF9FBAA4}.Debug|x86.ActiveCfg = Debug|Any CPU + {84FAEFC0-570E-4685-93F6-896BDF9FBAA4}.Debug|x86.Build.0 = Debug|Any CPU {84FAEFC0-570E-4685-93F6-896BDF9FBAA4}.Release|Any CPU.ActiveCfg = Release|Any CPU {84FAEFC0-570E-4685-93F6-896BDF9FBAA4}.Release|Any CPU.Build.0 = Release|Any CPU + {84FAEFC0-570E-4685-93F6-896BDF9FBAA4}.Release|x64.ActiveCfg = Release|Any CPU + {84FAEFC0-570E-4685-93F6-896BDF9FBAA4}.Release|x64.Build.0 = Release|Any CPU + {84FAEFC0-570E-4685-93F6-896BDF9FBAA4}.Release|x86.ActiveCfg = Release|Any CPU + {84FAEFC0-570E-4685-93F6-896BDF9FBAA4}.Release|x86.Build.0 = Release|Any CPU {EC98FC99-F43C-4A3A-AF52-22FB9949B6C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EC98FC99-F43C-4A3A-AF52-22FB9949B6C3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EC98FC99-F43C-4A3A-AF52-22FB9949B6C3}.Debug|x64.ActiveCfg = Debug|Any CPU + {EC98FC99-F43C-4A3A-AF52-22FB9949B6C3}.Debug|x64.Build.0 = Debug|Any CPU + {EC98FC99-F43C-4A3A-AF52-22FB9949B6C3}.Debug|x86.ActiveCfg = Debug|Any CPU + {EC98FC99-F43C-4A3A-AF52-22FB9949B6C3}.Debug|x86.Build.0 = Debug|Any CPU {EC98FC99-F43C-4A3A-AF52-22FB9949B6C3}.Release|Any CPU.ActiveCfg = Release|Any CPU {EC98FC99-F43C-4A3A-AF52-22FB9949B6C3}.Release|Any CPU.Build.0 = Release|Any CPU + {EC98FC99-F43C-4A3A-AF52-22FB9949B6C3}.Release|x64.ActiveCfg = Release|Any CPU + {EC98FC99-F43C-4A3A-AF52-22FB9949B6C3}.Release|x64.Build.0 = Release|Any CPU + {EC98FC99-F43C-4A3A-AF52-22FB9949B6C3}.Release|x86.ActiveCfg = Release|Any CPU + {EC98FC99-F43C-4A3A-AF52-22FB9949B6C3}.Release|x86.Build.0 = Release|Any CPU {66A27C5A-36DA-4CBE-922D-A188547AF9B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {66A27C5A-36DA-4CBE-922D-A188547AF9B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {66A27C5A-36DA-4CBE-922D-A188547AF9B9}.Debug|x64.ActiveCfg = Debug|Any CPU + {66A27C5A-36DA-4CBE-922D-A188547AF9B9}.Debug|x64.Build.0 = Debug|Any CPU + {66A27C5A-36DA-4CBE-922D-A188547AF9B9}.Debug|x86.ActiveCfg = Debug|Any CPU + {66A27C5A-36DA-4CBE-922D-A188547AF9B9}.Debug|x86.Build.0 = Debug|Any CPU {66A27C5A-36DA-4CBE-922D-A188547AF9B9}.Release|Any CPU.ActiveCfg = Release|Any CPU {66A27C5A-36DA-4CBE-922D-A188547AF9B9}.Release|Any CPU.Build.0 = Release|Any CPU + {66A27C5A-36DA-4CBE-922D-A188547AF9B9}.Release|x64.ActiveCfg = Release|Any CPU + {66A27C5A-36DA-4CBE-922D-A188547AF9B9}.Release|x64.Build.0 = Release|Any CPU + {66A27C5A-36DA-4CBE-922D-A188547AF9B9}.Release|x86.ActiveCfg = Release|Any CPU + {66A27C5A-36DA-4CBE-922D-A188547AF9B9}.Release|x86.Build.0 = Release|Any CPU {9CFE11FD-B3A3-4BC2-BD6B-0B0A5663C4ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9CFE11FD-B3A3-4BC2-BD6B-0B0A5663C4ED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9CFE11FD-B3A3-4BC2-BD6B-0B0A5663C4ED}.Debug|x64.ActiveCfg = Debug|Any CPU + {9CFE11FD-B3A3-4BC2-BD6B-0B0A5663C4ED}.Debug|x64.Build.0 = Debug|Any CPU + {9CFE11FD-B3A3-4BC2-BD6B-0B0A5663C4ED}.Debug|x86.ActiveCfg = Debug|Any CPU + {9CFE11FD-B3A3-4BC2-BD6B-0B0A5663C4ED}.Debug|x86.Build.0 = Debug|Any CPU {9CFE11FD-B3A3-4BC2-BD6B-0B0A5663C4ED}.Release|Any CPU.ActiveCfg = Release|Any CPU {9CFE11FD-B3A3-4BC2-BD6B-0B0A5663C4ED}.Release|Any CPU.Build.0 = Release|Any CPU + {9CFE11FD-B3A3-4BC2-BD6B-0B0A5663C4ED}.Release|x64.ActiveCfg = Release|Any CPU + {9CFE11FD-B3A3-4BC2-BD6B-0B0A5663C4ED}.Release|x64.Build.0 = Release|Any CPU + {9CFE11FD-B3A3-4BC2-BD6B-0B0A5663C4ED}.Release|x86.ActiveCfg = Release|Any CPU + {9CFE11FD-B3A3-4BC2-BD6B-0B0A5663C4ED}.Release|x86.Build.0 = Release|Any CPU {B9939F9D-FDED-4C87-BBE2-7C890A5C50B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B9939F9D-FDED-4C87-BBE2-7C890A5C50B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B9939F9D-FDED-4C87-BBE2-7C890A5C50B9}.Debug|x64.ActiveCfg = Debug|Any CPU + {B9939F9D-FDED-4C87-BBE2-7C890A5C50B9}.Debug|x64.Build.0 = Debug|Any CPU + {B9939F9D-FDED-4C87-BBE2-7C890A5C50B9}.Debug|x86.ActiveCfg = Debug|Any CPU + {B9939F9D-FDED-4C87-BBE2-7C890A5C50B9}.Debug|x86.Build.0 = Debug|Any CPU {B9939F9D-FDED-4C87-BBE2-7C890A5C50B9}.Release|Any CPU.ActiveCfg = Release|Any CPU {B9939F9D-FDED-4C87-BBE2-7C890A5C50B9}.Release|Any CPU.Build.0 = Release|Any CPU + {B9939F9D-FDED-4C87-BBE2-7C890A5C50B9}.Release|x64.ActiveCfg = Release|Any CPU + {B9939F9D-FDED-4C87-BBE2-7C890A5C50B9}.Release|x64.Build.0 = Release|Any CPU + {B9939F9D-FDED-4C87-BBE2-7C890A5C50B9}.Release|x86.ActiveCfg = Release|Any CPU + {B9939F9D-FDED-4C87-BBE2-7C890A5C50B9}.Release|x86.Build.0 = Release|Any CPU {FDFC2B95-137D-469E-93A4-D24CB4E36D38}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FDFC2B95-137D-469E-93A4-D24CB4E36D38}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FDFC2B95-137D-469E-93A4-D24CB4E36D38}.Debug|x64.ActiveCfg = Debug|Any CPU + {FDFC2B95-137D-469E-93A4-D24CB4E36D38}.Debug|x64.Build.0 = Debug|Any CPU + {FDFC2B95-137D-469E-93A4-D24CB4E36D38}.Debug|x86.ActiveCfg = Debug|Any CPU + {FDFC2B95-137D-469E-93A4-D24CB4E36D38}.Debug|x86.Build.0 = Debug|Any CPU {FDFC2B95-137D-469E-93A4-D24CB4E36D38}.Release|Any CPU.ActiveCfg = Release|Any CPU {FDFC2B95-137D-469E-93A4-D24CB4E36D38}.Release|Any CPU.Build.0 = Release|Any CPU + {FDFC2B95-137D-469E-93A4-D24CB4E36D38}.Release|x64.ActiveCfg = Release|Any CPU + {FDFC2B95-137D-469E-93A4-D24CB4E36D38}.Release|x64.Build.0 = Release|Any CPU + {FDFC2B95-137D-469E-93A4-D24CB4E36D38}.Release|x86.ActiveCfg = Release|Any CPU + {FDFC2B95-137D-469E-93A4-D24CB4E36D38}.Release|x86.Build.0 = Release|Any CPU {37642C9F-8E24-4C61-85EB-FB80D3E5DFA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {37642C9F-8E24-4C61-85EB-FB80D3E5DFA3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {37642C9F-8E24-4C61-85EB-FB80D3E5DFA3}.Debug|x64.ActiveCfg = Debug|Any CPU + {37642C9F-8E24-4C61-85EB-FB80D3E5DFA3}.Debug|x64.Build.0 = Debug|Any CPU + {37642C9F-8E24-4C61-85EB-FB80D3E5DFA3}.Debug|x86.ActiveCfg = Debug|Any CPU + {37642C9F-8E24-4C61-85EB-FB80D3E5DFA3}.Debug|x86.Build.0 = Debug|Any CPU {37642C9F-8E24-4C61-85EB-FB80D3E5DFA3}.Release|Any CPU.ActiveCfg = Release|Any CPU {37642C9F-8E24-4C61-85EB-FB80D3E5DFA3}.Release|Any CPU.Build.0 = Release|Any CPU + {37642C9F-8E24-4C61-85EB-FB80D3E5DFA3}.Release|x64.ActiveCfg = Release|Any CPU + {37642C9F-8E24-4C61-85EB-FB80D3E5DFA3}.Release|x64.Build.0 = Release|Any CPU + {37642C9F-8E24-4C61-85EB-FB80D3E5DFA3}.Release|x86.ActiveCfg = Release|Any CPU + {37642C9F-8E24-4C61-85EB-FB80D3E5DFA3}.Release|x86.Build.0 = Release|Any CPU {599BD2F8-9628-4D2E-9140-FFF6F62D0753}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {599BD2F8-9628-4D2E-9140-FFF6F62D0753}.Debug|Any CPU.Build.0 = Debug|Any CPU + {599BD2F8-9628-4D2E-9140-FFF6F62D0753}.Debug|x64.ActiveCfg = Debug|Any CPU + {599BD2F8-9628-4D2E-9140-FFF6F62D0753}.Debug|x64.Build.0 = Debug|Any CPU + {599BD2F8-9628-4D2E-9140-FFF6F62D0753}.Debug|x86.ActiveCfg = Debug|Any CPU + {599BD2F8-9628-4D2E-9140-FFF6F62D0753}.Debug|x86.Build.0 = Debug|Any CPU {599BD2F8-9628-4D2E-9140-FFF6F62D0753}.Release|Any CPU.ActiveCfg = Release|Any CPU {599BD2F8-9628-4D2E-9140-FFF6F62D0753}.Release|Any CPU.Build.0 = Release|Any CPU + {599BD2F8-9628-4D2E-9140-FFF6F62D0753}.Release|x64.ActiveCfg = Release|Any CPU + {599BD2F8-9628-4D2E-9140-FFF6F62D0753}.Release|x64.Build.0 = Release|Any CPU + {599BD2F8-9628-4D2E-9140-FFF6F62D0753}.Release|x86.ActiveCfg = Release|Any CPU + {599BD2F8-9628-4D2E-9140-FFF6F62D0753}.Release|x86.Build.0 = Release|Any CPU {480F6DEB-6AF7-4A5A-8C5F-2A8C95CE1993}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {480F6DEB-6AF7-4A5A-8C5F-2A8C95CE1993}.Debug|Any CPU.Build.0 = Debug|Any CPU + {480F6DEB-6AF7-4A5A-8C5F-2A8C95CE1993}.Debug|x64.ActiveCfg = Debug|Any CPU + {480F6DEB-6AF7-4A5A-8C5F-2A8C95CE1993}.Debug|x64.Build.0 = Debug|Any CPU + {480F6DEB-6AF7-4A5A-8C5F-2A8C95CE1993}.Debug|x86.ActiveCfg = Debug|Any CPU + {480F6DEB-6AF7-4A5A-8C5F-2A8C95CE1993}.Debug|x86.Build.0 = Debug|Any CPU {480F6DEB-6AF7-4A5A-8C5F-2A8C95CE1993}.Release|Any CPU.ActiveCfg = Release|Any CPU {480F6DEB-6AF7-4A5A-8C5F-2A8C95CE1993}.Release|Any CPU.Build.0 = Release|Any CPU + {480F6DEB-6AF7-4A5A-8C5F-2A8C95CE1993}.Release|x64.ActiveCfg = Release|Any CPU + {480F6DEB-6AF7-4A5A-8C5F-2A8C95CE1993}.Release|x64.Build.0 = Release|Any CPU + {480F6DEB-6AF7-4A5A-8C5F-2A8C95CE1993}.Release|x86.ActiveCfg = Release|Any CPU + {480F6DEB-6AF7-4A5A-8C5F-2A8C95CE1993}.Release|x86.Build.0 = Release|Any CPU + {14579293-41E9-48A0-BF1D-D4E3FA87F553}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {14579293-41E9-48A0-BF1D-D4E3FA87F553}.Debug|Any CPU.Build.0 = Debug|Any CPU + {14579293-41E9-48A0-BF1D-D4E3FA87F553}.Debug|x64.ActiveCfg = Debug|Any CPU + {14579293-41E9-48A0-BF1D-D4E3FA87F553}.Debug|x64.Build.0 = Debug|Any CPU + {14579293-41E9-48A0-BF1D-D4E3FA87F553}.Debug|x86.ActiveCfg = Debug|Any CPU + {14579293-41E9-48A0-BF1D-D4E3FA87F553}.Debug|x86.Build.0 = Debug|Any CPU + {14579293-41E9-48A0-BF1D-D4E3FA87F553}.Release|Any CPU.ActiveCfg = Release|Any CPU + {14579293-41E9-48A0-BF1D-D4E3FA87F553}.Release|Any CPU.Build.0 = Release|Any CPU + {14579293-41E9-48A0-BF1D-D4E3FA87F553}.Release|x64.ActiveCfg = Release|Any CPU + {14579293-41E9-48A0-BF1D-D4E3FA87F553}.Release|x64.Build.0 = Release|Any CPU + {14579293-41E9-48A0-BF1D-D4E3FA87F553}.Release|x86.ActiveCfg = Release|Any CPU + {14579293-41E9-48A0-BF1D-D4E3FA87F553}.Release|x86.Build.0 = Release|Any CPU + {9E38C8C8-CD3F-423A-A40D-791C7EFC680D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9E38C8C8-CD3F-423A-A40D-791C7EFC680D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9E38C8C8-CD3F-423A-A40D-791C7EFC680D}.Debug|x64.ActiveCfg = Debug|Any CPU + {9E38C8C8-CD3F-423A-A40D-791C7EFC680D}.Debug|x64.Build.0 = Debug|Any CPU + {9E38C8C8-CD3F-423A-A40D-791C7EFC680D}.Debug|x86.ActiveCfg = Debug|Any CPU + {9E38C8C8-CD3F-423A-A40D-791C7EFC680D}.Debug|x86.Build.0 = Debug|Any CPU + {9E38C8C8-CD3F-423A-A40D-791C7EFC680D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9E38C8C8-CD3F-423A-A40D-791C7EFC680D}.Release|Any CPU.Build.0 = Release|Any CPU + {9E38C8C8-CD3F-423A-A40D-791C7EFC680D}.Release|x64.ActiveCfg = Release|Any CPU + {9E38C8C8-CD3F-423A-A40D-791C7EFC680D}.Release|x64.Build.0 = Release|Any CPU + {9E38C8C8-CD3F-423A-A40D-791C7EFC680D}.Release|x86.ActiveCfg = Release|Any CPU + {9E38C8C8-CD3F-423A-A40D-791C7EFC680D}.Release|x86.Build.0 = Release|Any CPU + {A2431A2E-F0DA-4D8D-87EE-38EE63A23942}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A2431A2E-F0DA-4D8D-87EE-38EE63A23942}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A2431A2E-F0DA-4D8D-87EE-38EE63A23942}.Debug|x64.ActiveCfg = Debug|Any CPU + {A2431A2E-F0DA-4D8D-87EE-38EE63A23942}.Debug|x64.Build.0 = Debug|Any CPU + {A2431A2E-F0DA-4D8D-87EE-38EE63A23942}.Debug|x86.ActiveCfg = Debug|Any CPU + {A2431A2E-F0DA-4D8D-87EE-38EE63A23942}.Debug|x86.Build.0 = Debug|Any CPU + {A2431A2E-F0DA-4D8D-87EE-38EE63A23942}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A2431A2E-F0DA-4D8D-87EE-38EE63A23942}.Release|Any CPU.Build.0 = Release|Any CPU + {A2431A2E-F0DA-4D8D-87EE-38EE63A23942}.Release|x64.ActiveCfg = Release|Any CPU + {A2431A2E-F0DA-4D8D-87EE-38EE63A23942}.Release|x64.Build.0 = Release|Any CPU + {A2431A2E-F0DA-4D8D-87EE-38EE63A23942}.Release|x86.ActiveCfg = Release|Any CPU + {A2431A2E-F0DA-4D8D-87EE-38EE63A23942}.Release|x86.Build.0 = Release|Any CPU + {97E5B9D8-ADE7-42B1-B05D-3CD7ED74AEC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {97E5B9D8-ADE7-42B1-B05D-3CD7ED74AEC4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {97E5B9D8-ADE7-42B1-B05D-3CD7ED74AEC4}.Debug|x64.ActiveCfg = Debug|Any CPU + {97E5B9D8-ADE7-42B1-B05D-3CD7ED74AEC4}.Debug|x64.Build.0 = Debug|Any CPU + {97E5B9D8-ADE7-42B1-B05D-3CD7ED74AEC4}.Debug|x86.ActiveCfg = Debug|Any CPU + {97E5B9D8-ADE7-42B1-B05D-3CD7ED74AEC4}.Debug|x86.Build.0 = Debug|Any CPU + {97E5B9D8-ADE7-42B1-B05D-3CD7ED74AEC4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {97E5B9D8-ADE7-42B1-B05D-3CD7ED74AEC4}.Release|Any CPU.Build.0 = Release|Any CPU + {97E5B9D8-ADE7-42B1-B05D-3CD7ED74AEC4}.Release|x64.ActiveCfg = Release|Any CPU + {97E5B9D8-ADE7-42B1-B05D-3CD7ED74AEC4}.Release|x64.Build.0 = Release|Any CPU + {97E5B9D8-ADE7-42B1-B05D-3CD7ED74AEC4}.Release|x86.ActiveCfg = Release|Any CPU + {97E5B9D8-ADE7-42B1-B05D-3CD7ED74AEC4}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -130,6 +318,10 @@ Global {37642C9F-8E24-4C61-85EB-FB80D3E5DFA3} = {51D3C0D3-1FFC-4067-A646-180246B2CB05} {599BD2F8-9628-4D2E-9140-FFF6F62D0753} = {D783F3F7-0AB3-4648-9496-CF72724121B9} {480F6DEB-6AF7-4A5A-8C5F-2A8C95CE1993} = {D783F3F7-0AB3-4648-9496-CF72724121B9} + {14579293-41E9-48A0-BF1D-D4E3FA87F553} = {D783F3F7-0AB3-4648-9496-CF72724121B9} + {9E38C8C8-CD3F-423A-A40D-791C7EFC680D} = {D783F3F7-0AB3-4648-9496-CF72724121B9} + {A2431A2E-F0DA-4D8D-87EE-38EE63A23942} = {51D3C0D3-1FFC-4067-A646-180246B2CB05} + {97E5B9D8-ADE7-42B1-B05D-3CD7ED74AEC4} = {51D3C0D3-1FFC-4067-A646-180246B2CB05} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {991276B4-B34E-4CD5-BD7B-7EFE5ED474C8}