From 2e02d277ac016ba8b174c5492d7a9254d23045ec Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Wed, 20 May 2026 00:02:48 -0500 Subject: [PATCH] feat: add splitter aggregator generator --- .../enterprise-messaging-workflows.md | 4 + .../examples/generated-splitter-aggregator.md | 79 ++++ docs/examples/index.md | 3 + docs/examples/toc.yml | 3 + docs/generators/index.md | 8 + docs/generators/messaging.md | 44 ++- docs/guides/pattern-coverage.md | 4 +- docs/patterns/messaging/message-routing.md | 38 ++ ...rnKitExampleServiceCollectionExtensions.cs | 15 + .../Messaging/MessageRoutingExample.cs | 102 +++-- .../PatternKitExampleCatalog.cs | 8 + .../PatternKitPatternCatalog.cs | 20 +- .../Messaging/SplitterAggregatorAttributes.cs | 82 ++++ .../AnalyzerReleases.Unshipped.md | 6 + .../Messaging/SplitterAggregatorGenerator.cs | 363 ++++++++++++++++++ .../Messaging/MessageRoutingExampleTests.cs | 80 +++- .../PatternKitPatternCatalogTests.cs | 4 +- .../AbstractionsAttributeCoverageTests.cs | 32 ++ .../SplitterAggregatorGeneratorTests.cs | 245 ++++++++++++ 19 files changed, 1091 insertions(+), 49 deletions(-) create mode 100644 docs/examples/generated-splitter-aggregator.md create mode 100644 src/PatternKit.Generators.Abstractions/Messaging/SplitterAggregatorAttributes.cs create mode 100644 src/PatternKit.Generators/Messaging/SplitterAggregatorGenerator.cs create mode 100644 test/PatternKit.Generators.Tests/SplitterAggregatorGeneratorTests.cs diff --git a/docs/examples/enterprise-messaging-workflows.md b/docs/examples/enterprise-messaging-workflows.md index bd5ece62..2d509064 100644 --- a/docs/examples/enterprise-messaging-workflows.md +++ b/docs/examples/enterprise-messaging-workflows.md @@ -25,6 +25,7 @@ Example source: | Source-generated dispatcher | `DispatcherExample.cs` | Compile-time mediator commands, notifications, streams, and paging. | | Source-generated content router | `ContentRouterGeneratorExample.cs` | Attribute-driven content routing without runtime scanning. | | Source-generated recipient list | `RecipientListGeneratorExample.cs` | Attribute-driven fan-out without runtime scanning. | +| Source-generated splitter and aggregator | `MessageRoutingExample.cs` | Attribute-driven split projection, correlation, completion, and result projection without runtime scanning. | | Resilient checkout orchestration | `ResilientCheckoutDemo.cs` | Route selection, routing-slip execution, command compensation, and fallback routes. | | Collaborating service mailboxes | `ServiceCollaborationMailboxDemo.cs` | Inventory, payment, shipping, and notification mailboxes collaborating over correlated messages. | | Backplane facade | `BackplaneFacadeDemo.cs` | MassTransit/MediatR-shaped host builder, typed client, request/reply, and pub/sub over an application-owned transport boundary. | @@ -66,6 +67,8 @@ public static partial class FulfillmentSlip The generated factories are AOT-friendly and do not scan assemblies. The runtime builders are better for user- or tenant-defined routing. +Generated splitter and aggregator contracts follow the same rule: use `[GenerateSplitter]` and `[SplitterProjection]` for a stable split projection, then `[GenerateAggregator]` with `[AggregatorCorrelation]`, `[AggregatorCompletion]`, and `[AggregatorProjection]` for the matching rejoin contract. + ## Testing Guidance The example tests use behavior-oriented assertions: @@ -91,5 +94,6 @@ The example tests use behavior-oriented assertions: - [Mailbox](../patterns/messaging/mailbox.md) - [Idempotent Receiver, Inbox, and Outbox](../patterns/messaging/reliability.md) - [Messaging Generators](../generators/messaging.md) +- [Generated Splitter And Aggregator](generated-splitter-aggregator.md) - [Resilient Checkout and Collaborating Mailboxes](resilient-checkout-and-mailboxes.md) - [Messaging Backplane Facade](messaging-backplane-facade.md) diff --git a/docs/examples/generated-splitter-aggregator.md b/docs/examples/generated-splitter-aggregator.md new file mode 100644 index 00000000..32532bc9 --- /dev/null +++ b/docs/examples/generated-splitter-aggregator.md @@ -0,0 +1,79 @@ +# Generated Splitter And Aggregator + +This example shows the fluent and source-generated paths for two Enterprise Integration Patterns that are often used together: + +- Splitter turns one aggregate message into item-level messages while preserving correlation metadata. +- Aggregator collects related item messages and projects a result when the completion policy is satisfied. + +The source-generated path is useful when the split projection, correlation key, completion rule, and projection are stable application contracts. It emits ordinary `Splitter` and `Aggregator` factories, so the generated artifacts can be registered through `IServiceCollection` like any other PatternKit primitive. + +## Source + +- `src/PatternKit.Examples/Messaging/MessageRoutingExample.cs` +- `test/PatternKit.Examples.Tests/Messaging/MessageRoutingExampleTests.cs` + +## Fluent Path + +```csharp +var splitter = Splitter.Create() + .Use((message, context) => message.Payload.Lines) + .Build(); + +var lineMessages = splitter.Split(orderMessage); + +var aggregator = Aggregator.Create() + .KeyBy((message, context) => message.Headers.CorrelationId ?? message.Payload.Sku) + .CompleteWhen((key, messages, context) => messages.Count == lineMessages.Count) + .Project((key, messages, context) => messages.Sum(message => message.Payload.Amount)) + .Build(); +``` + +## Source-Generated Path + +```csharp +[GenerateSplitter(typeof(RoutedOrder), typeof(RoutedLine), FactoryName = "CreateLineSplitter")] +public static partial class GeneratedOrderLineSplitter +{ + [SplitterProjection] + private static IEnumerable ProjectLines(Message message, MessageContext context) + => message.Payload.Lines; +} + +[GenerateAggregator(typeof(string), typeof(RoutedLine), typeof(decimal), FactoryName = "CreateLineTotalAggregator")] +public static partial class GeneratedOrderLineAggregator +{ + [AggregatorCorrelation] + private static string Correlate(Message message, MessageContext context) + => message.Headers.CorrelationId ?? message.Payload.Sku; + + [AggregatorCompletion] + private static bool Complete(string key, IReadOnlyList> messages, MessageContext context) + => messages.Count == 2; + + [AggregatorProjection] + private static decimal Project(string key, IReadOnlyList> messages, MessageContext context) + => messages.Sum(message => message.Payload.Amount); +} +``` + +## Dependency Injection + +```csharp +var services = new ServiceCollection(); +services.AddGeneratedSplitterAggregatorExample(); + +using var provider = services.BuildServiceProvider(validateScopes: true); +var example = provider.GetRequiredService(); +var summary = example.Runner.RunGenerated(); +``` + +The extension registers a `MessageRoutingExampleRunner` with fluent and generated entry points. Applications can copy that shape directly: build the generated factories once at startup, register them as singletons, and inject the resulting splitter or aggregator into handlers. + +## Validation + +The TinyBDD tests assert that: + +- fluent and generated paths route, split, and aggregate the same order message +- child messages preserve causation and correlation metadata +- the generated example is importable through `Microsoft.Extensions.DependencyInjection` +- the example catalog advertises messaging, source-generation, and DI integration surfaces diff --git a/docs/examples/index.md b/docs/examples/index.md index 54c1dc84..ce66025b 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -72,6 +72,9 @@ Welcome! This section collects small, focused demos that show **how to compose b * **Generated Recipient List** Shows fluent and source-generated recipient-list fan-out side by side, with an importable `IServiceCollection` extension. See [Generated Recipient List](generated-recipient-list.md). +* **Generated Splitter and Aggregator** + Shows fluent and source-generated split/rejoin message routing side by side, with an importable `IServiceCollection` extension. See [Generated Splitter And Aggregator](generated-splitter-aggregator.md). + * **Resilient Checkout and Collaborating Mailboxes** Application-shaped messaging demos: checkout route selection, routing-slip execution, command compensation, fallback routes, and service mailboxes collaborating over correlated messages. See [Resilient Checkout and Collaborating Mailboxes](resilient-checkout-and-mailboxes.md). diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index f2425ebf..cc89a933 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -61,6 +61,9 @@ - name: Generated Recipient List href: generated-recipient-list.md +- name: Generated Splitter and Aggregator + href: generated-splitter-aggregator.md + - name: CQRS Dispatcher href: cqrs-dispatcher.md diff --git a/docs/generators/index.md b/docs/generators/index.md index 264e9c41..30f215e4 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -67,6 +67,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Message Envelope**](messaging.md#generated-message-envelope) | Required message metadata contracts | `[GenerateMessageEnvelope]` | | [**Content Router**](messaging.md#generated-content-router) | Content-based message routing factories | `[GenerateContentRouter]` | | [**Recipient List**](messaging.md#generated-recipient-list) | Recipient fan-out factories | `[GenerateRecipientList]` | +| [**Splitter / Aggregator**](messaging.md#generated-splitter-and-aggregator) | Split/rejoin message routing factories | `[GenerateSplitter]` / `[GenerateAggregator]` | | [**Routing Slip**](messaging.md#generated-routing-slip) | Ordered message itinerary factories | `[GenerateRoutingSlip]` | | [**Saga**](messaging.md#generated-saga) | Typed process-manager transition factories | `[GenerateSaga]` | @@ -147,6 +148,13 @@ public static partial class OrderAcceptedEnvelope { } [GenerateContentRouter(typeof(Order), typeof(string))] public static partial class OrderRouter { } +// Splitter and aggregator - generated split/rejoin factories +[GenerateSplitter(typeof(Order), typeof(OrderLine))] +public static partial class OrderSplitter { } + +[GenerateAggregator(typeof(string), typeof(OrderLine), typeof(decimal))] +public static partial class OrderLineAggregator { } + // Routing slip - generated ordered itinerary factory [GenerateRoutingSlip(typeof(Order))] public static partial class OrderSlip { } diff --git a/docs/generators/messaging.md b/docs/generators/messaging.md index 905b95d2..c201d1a4 100644 --- a/docs/generators/messaging.md +++ b/docs/generators/messaging.md @@ -1,11 +1,12 @@ # Messaging Generators -PatternKit includes six messaging-oriented source generators: +PatternKit includes seven messaging-oriented source generators: - for source-generated mediator dispatchers. - for required message-envelope contracts. - for content-based message routers. - for recipient-list fan-out. +- and for split/rejoin routing. - for ordered routing-slip factories. - for typed saga/process-manager factories. @@ -137,6 +138,46 @@ Example files: - `src/PatternKit.Examples/Messaging/RecipientListGeneratorExample.cs` - `test/PatternKit.Examples.Tests/Messaging/RecipientListGeneratorExampleTests.cs` +## Generated Splitter And Aggregator + +`[GenerateSplitter]` creates a `Splitter` factory from one static projection method. `[GenerateAggregator]` creates an `Aggregator` factory from static correlation, completion, and projection methods: + +```csharp +using PatternKit.Generators.Messaging; +using PatternKit.Messaging; + +[GenerateSplitter(typeof(Order), typeof(OrderLine), FactoryName = "CreateLineSplitter")] +public static partial class OrderSplitter +{ + [SplitterProjection] + private static IEnumerable ProjectLines(Message message, MessageContext context) + => message.Payload.Lines; +} + +[GenerateAggregator(typeof(string), typeof(OrderLine), typeof(decimal), FactoryName = "CreateLineTotal")] +public static partial class OrderLineAggregator +{ + [AggregatorCorrelation] + private static string Correlate(Message message, MessageContext context) + => message.Headers.CorrelationId ?? message.Payload.OrderId; + + [AggregatorCompletion] + private static bool Complete(string key, IReadOnlyList> messages, MessageContext context) + => messages.Count == 2; + + [AggregatorProjection] + private static decimal Project(string key, IReadOnlyList> messages, MessageContext context) + => messages.Sum(message => message.Payload.Amount); +} +``` + +Use generated splitter/aggregator factories when the split projection and rejoin contract are stable. Use runtime builders when completion rules depend on tenant configuration or runtime-discovered topology. + +Example files: + +- `src/PatternKit.Examples/Messaging/MessageRoutingExample.cs` +- `test/PatternKit.Examples.Tests/Messaging/MessageRoutingExampleTests.cs` + ## Generated Saga `[GenerateSaga]` emits a process-manager factory from typed transition methods: @@ -173,6 +214,7 @@ Example source: | `PKME001`-`PKME004` | Message Envelope | Non-partial host, missing headers, invalid header configuration, or duplicate names. | | `PKCR001`-`PKCR005` | Content Router | Non-partial host, missing routes, invalid signatures, duplicate defaults, or duplicate route identity. | | `PKRL001`-`PKRL004` | Recipient List | Non-partial host, missing recipients, invalid signatures, or duplicate recipient identity. | +| `PKSA001`-`PKSA006` | Splitter / Aggregator | Non-partial host, missing contract methods, invalid signatures, or invalid duplicate policy. | | `PKRS001`-`PKRS003` | Routing Slip | Non-partial host, missing steps, or invalid step signatures. | | `PKSG001`-`PKSG004` | Saga | Non-partial host, missing transitions, invalid transition signatures, or invalid completion checks. | diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 3ec20591..40cab0f0 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -47,8 +47,8 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Enterprise Integration | Message Envelope | `Message`, headers, context | Messaging generator | | Enterprise Integration | Content-Based Router | `ContentRouter` | Messaging generator | | Enterprise Integration | Recipient List | `RecipientList` | Messaging generator | -| Enterprise Integration | Splitter | `Splitter` | Tracked in [#211](https://github.com/JerrettDavis/PatternKit/issues/211) | -| Enterprise Integration | Aggregator | `Aggregator` | Tracked in [#211](https://github.com/JerrettDavis/PatternKit/issues/211) | +| Enterprise Integration | Splitter | `Splitter` | Messaging generator | +| Enterprise Integration | Aggregator | `Aggregator` | Messaging generator | | Enterprise Integration | Routing Slip | `RoutingSlip` | Messaging generator | | Enterprise Integration | Saga / Process Manager | `Saga` | Messaging generator | | Enterprise Integration | Mailbox | `Mailbox` | Tracked in [#209](https://github.com/JerrettDavis/PatternKit/issues/209) | diff --git a/docs/patterns/messaging/message-routing.md b/docs/patterns/messaging/message-routing.md index af652d6a..9cb5b57b 100644 --- a/docs/patterns/messaging/message-routing.md +++ b/docs/patterns/messaging/message-routing.md @@ -71,6 +71,18 @@ var splitter = Splitter.Create() var lineMessages = splitter.Split(orderMessage); ``` +Use `[GenerateSplitter]` when the split projection is stable and should be compiled into a named factory: + +```csharp +[GenerateSplitter(typeof(Order), typeof(LineItem))] +public static partial class OrderLineSplitter +{ + [SplitterProjection] + private static IEnumerable ProjectLines(Message message, MessageContext context) + => message.Payload.Lines; +} +``` + ## Aggregator `Aggregator` groups messages in memory until a completion policy is satisfied, then projects the completed group into a result and removes it from the open groups. @@ -91,6 +103,26 @@ if (result.Completed) Duplicate message ids are ignored by default. Use `DuplicateMessagePolicy.Replace` or `DuplicateMessagePolicy.Include` when a workflow needs different behavior. +Use `[GenerateAggregator]` when the correlation, completion, and projection contract is part of the application topology: + +```csharp +[GenerateAggregator(typeof(string), typeof(LineItem), typeof(decimal))] +public static partial class OrderLineAggregator +{ + [AggregatorCorrelation] + private static string Correlate(Message message, MessageContext context) + => message.Headers.CorrelationId ?? message.Payload.Sku; + + [AggregatorCompletion] + private static bool Complete(string key, IReadOnlyList> messages, MessageContext context) + => messages.Count == 2; + + [AggregatorProjection] + private static decimal Project(string key, IReadOnlyList> messages, MessageContext context) + => messages.Sum(message => message.Payload.Amount); +} +``` + ## Choosing Boundaries Use these primitives for: @@ -117,9 +149,15 @@ Use external infrastructure for: - - - +- +- - - - +- +- +- +- ## Example Source diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 66320a9f..c7c85f84 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -86,6 +86,7 @@ public sealed record EventProcessingVisitorExample(Func RunAsync); public sealed record MessageRouterVisitorExample(Func Run); public sealed record GeneratedMessageEnvelopeExample(MessageEnvelopeExampleRunner Runner); public sealed record GeneratedRecipientListExample(RecipientListGeneratorExampleRunner Runner); +public sealed record GeneratedSplitterAggregatorExample(MessageRoutingExampleRunner Runner); public sealed record PatternsShowcaseExample(ShowcaseFacade Facade); public sealed record SourceGeneratorApplicationSuiteExample(Func> BuildProductionAsync); public sealed record EnterpriseMessagingWorkflowSuiteExample(Func Run); @@ -127,6 +128,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddMessageRouterVisitorExample() .AddGeneratedMessageEnvelopeExample() .AddGeneratedRecipientListExample() + .AddGeneratedSplitterAggregatorExample() .AddPatternsShowcaseExample() .AddSourceGeneratorApplicationSuiteExample() .AddEnterpriseMessagingWorkflowSuiteExample() @@ -326,6 +328,12 @@ public static IServiceCollection AddMessageRouterVisitorExample(this IServiceCol return services.RegisterExample("Message Router Visitor", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddMessageRoutingExample(this IServiceCollection services) + { + services.AddSingleton(new MessageRoutingExampleRunner(MessageRoutingExample.RunFluent, MessageRoutingExample.RunGenerated)); + return services; + } + public static IServiceCollection AddGeneratedMessageEnvelopeExample(this IServiceCollection services) { services.AddMessageEnvelopeExample(); @@ -340,6 +348,13 @@ public static IServiceCollection AddGeneratedRecipientListExample(this IServiceC return services.RegisterExample("Generated Recipient List", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); } + public static IServiceCollection AddGeneratedSplitterAggregatorExample(this IServiceCollection services) + { + services.AddMessageRoutingExample(); + services.AddSingleton(sp => new(sp.GetRequiredService())); + return services.RegisterExample("Generated Splitter and Aggregator", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection); + } + public static IServiceCollection AddPatternsShowcaseExample(this IServiceCollection services) { services.AddSingleton(_ => PatternShowcase.PatternShowcase.Build()); diff --git a/src/PatternKit.Examples/Messaging/MessageRoutingExample.cs b/src/PatternKit.Examples/Messaging/MessageRoutingExample.cs index efc74e57..44b82dcf 100644 --- a/src/PatternKit.Examples/Messaging/MessageRoutingExample.cs +++ b/src/PatternKit.Examples/Messaging/MessageRoutingExample.cs @@ -1,5 +1,6 @@ using PatternKit.Messaging; using PatternKit.Messaging.Routing; +using PatternKit.Generators.Messaging; namespace PatternKit.Examples.Messaging; @@ -11,18 +12,44 @@ public static class MessageRoutingExample /// /// Runs a small order routing flow through router, recipient list, splitter, and aggregator. /// - public static RoutingSummary Run() + public static RoutingSummary Run() => RunFluent(); + + /// + /// Runs the routing flow with fluent Splitter and Aggregator factories. + /// + public static RoutingSummary RunFluent() { - var order = new RoutedOrder("order-42", "enterprise", [ - new RoutedLine("sku-1", 30m), - new RoutedLine("sku-2", 70m) - ]); + var message = CreateOrderMessage(); + var splitter = Splitter.Create() + .Use((m, _) => m.Payload.Lines) + .Build(); + var lineMessages = splitter.Split(message); + var aggregator = Aggregator.Create() + .KeyBy((m, _) => m.Headers.CorrelationId ?? m.Payload.Sku) + .CompleteWhen((_, messages, _) => messages.Count == lineMessages.Count) + .Project((_, messages, _) => messages.Sum(m => m.Payload.Amount)) + .Build(); - var message = Message - .Create(order) - .WithMessageId("msg-order-42") - .WithCorrelationId(order.Id); + return RunWith(message, lineMessages, aggregator, "fluent"); + } + /// + /// Runs the routing flow with source-generated Splitter and Aggregator factories. + /// + public static RoutingSummary RunGenerated() + { + var message = CreateOrderMessage(); + var lineMessages = GeneratedOrderLineSplitter.CreateLineSplitter().Split(message); + var aggregator = GeneratedOrderLineAggregator.CreateLineTotalAggregator(); + return RunWith(message, lineMessages, aggregator, "source-generated"); + } + + private static RoutingSummary RunWith( + Message message, + IReadOnlyList> lineMessages, + Aggregator aggregator, + string path) + { var route = ContentRouter.Create() .When((m, _) => m.Payload.CustomerTier == "enterprise").Then((_, _) => "priority") .Default((_, _) => "standard") @@ -37,17 +64,6 @@ public static RoutingSummary Run() var recipientResult = recipientList.Dispatch(message); - var lineMessages = Splitter.Create() - .Use((m, _) => m.Payload.Lines) - .Build() - .Split(message); - - var aggregator = Aggregator.Create() - .KeyBy((m, _) => m.Headers.CorrelationId ?? m.Payload.Sku) - .CompleteWhen((_, messages, _) => messages.Count == lineMessages.Count) - .Project((_, messages, _) => messages.Sum(m => m.Payload.Amount)) - .Build(); - decimal total = 0m; foreach (var line in lineMessages) { @@ -61,10 +77,27 @@ public static RoutingSummary Run() recipientResult.DeliveredRecipients.ToArray(), lineMessages.Count, total, - lineMessages[0].Headers.CausationId!); + lineMessages[0].Headers.CausationId!, + path); + } + + private static Message CreateOrderMessage() + { + var order = new RoutedOrder("order-42", "enterprise", [ + new RoutedLine("sku-1", 30m), + new RoutedLine("sku-2", 70m) + ]); + + return Message + .Create(order) + .WithMessageId("msg-order-42") + .WithCorrelationId(order.Id); } } +/// DI-friendly entry points for fluent and generated message routing examples. +public sealed record MessageRoutingExampleRunner(Func RunFluent, Func RunGenerated); + /// Example order payload routed by the messaging demo. public sealed record RoutedOrder(string Id, string CustomerTier, IReadOnlyList Lines); @@ -77,4 +110,29 @@ public sealed record RoutingSummary( IReadOnlyList Recipients, int SplitCount, decimal AggregatedTotal, - string CausationId); + string CausationId, + string Path); + +[GenerateSplitter(typeof(RoutedOrder), typeof(RoutedLine), FactoryName = "CreateLineSplitter")] +public static partial class GeneratedOrderLineSplitter +{ + [SplitterProjection] + private static IEnumerable ProjectLines(Message message, MessageContext context) + => message.Payload.Lines; +} + +[GenerateAggregator(typeof(string), typeof(RoutedLine), typeof(decimal), FactoryName = "CreateLineTotalAggregator", DuplicatePolicy = "Ignore")] +public static partial class GeneratedOrderLineAggregator +{ + [AggregatorCorrelation] + private static string Correlate(Message message, MessageContext context) + => message.Headers.CorrelationId ?? message.Payload.Sku; + + [AggregatorCompletion] + private static bool Complete(string key, IReadOnlyList> messages, MessageContext context) + => messages.Count == 2; + + [AggregatorProjection] + private static decimal Project(string key, IReadOnlyList> messages, MessageContext context) + => messages.Sum(message => message.Payload.Amount); +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 8982e8df..a50b09e5 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -248,6 +248,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, ["RecipientList"], ["fan-out routing", "source-generated factory", "DI composition"]), + Descriptor( + "Generated Splitter and Aggregator", + "src/PatternKit.Examples/Messaging/MessageRoutingExample.cs", + "test/PatternKit.Examples.Tests/Messaging/MessageRoutingExampleTests.cs", + "docs/examples/generated-splitter-aggregator.md", + ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection, + ["Splitter", "Aggregator"], + ["split/rejoin routing", "source-generated factories", "DI composition"]), Descriptor( "CQRS Dispatcher", "src/PatternKit.Examples/Messaging/CqrsPatternExample.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 5b5e6bba..f022178b 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -406,27 +406,27 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "docs/patterns/messaging/message-routing.md", "src/PatternKit.Core/Messaging/Routing/Splitter.cs", "test/PatternKit.Tests/Messaging/Routing/SplitterTests.cs", + "docs/generators/messaging.md", + "src/PatternKit.Generators/Messaging/SplitterAggregatorGenerator.cs", + "test/PatternKit.Generators.Tests/SplitterAggregatorGeneratorTests.cs", null, - null, - null, - "https://github.com/JerrettDavis/PatternKit/issues/211", - "docs/examples/enterprise-messaging-workflows.md", + "docs/examples/generated-splitter-aggregator.md", "src/PatternKit.Examples/Messaging/MessageRoutingExample.cs", "test/PatternKit.Examples.Tests/Messaging/MessageRoutingExampleTests.cs", - ["fluent splitter", "generated splitter tracked", "line-item message example"]), + ["fluent splitter", "generated splitter", "DI-importable line-item message example"]), Pattern("Aggregator", PatternFamily.EnterpriseIntegration, "docs/patterns/messaging/message-routing.md", "src/PatternKit.Core/Messaging/Routing/Aggregator.cs", "test/PatternKit.Tests/Messaging/Routing/AggregatorTests.cs", + "docs/generators/messaging.md", + "src/PatternKit.Generators/Messaging/SplitterAggregatorGenerator.cs", + "test/PatternKit.Generators.Tests/SplitterAggregatorGeneratorTests.cs", null, - null, - null, - "https://github.com/JerrettDavis/PatternKit/issues/211", - "docs/examples/enterprise-messaging-workflows.md", + "docs/examples/generated-splitter-aggregator.md", "src/PatternKit.Examples/Messaging/MessageRoutingExample.cs", "test/PatternKit.Examples.Tests/Messaging/MessageRoutingExampleTests.cs", - ["fluent aggregator", "generated aggregator tracked", "correlated total example"]), + ["fluent aggregator", "generated aggregator", "DI-importable correlated total example"]), Pattern("Routing Slip", PatternFamily.EnterpriseIntegration, "docs/patterns/messaging/routing-slip.md", diff --git a/src/PatternKit.Generators.Abstractions/Messaging/SplitterAggregatorAttributes.cs b/src/PatternKit.Generators.Abstractions/Messaging/SplitterAggregatorAttributes.cs new file mode 100644 index 00000000..a1cc164d --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Messaging/SplitterAggregatorAttributes.cs @@ -0,0 +1,82 @@ +using System; + +namespace PatternKit.Generators.Messaging; + +/// +/// Generates a typed splitter factory for a partial class or struct. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateSplitterAttribute : Attribute +{ + /// Creates a splitter generator attribute. + public GenerateSplitterAttribute(Type payloadType, Type itemType) + { + PayloadType = payloadType ?? throw new ArgumentNullException(nameof(payloadType)); + ItemType = itemType ?? throw new ArgumentNullException(nameof(itemType)); + } + + /// Message payload type accepted by the generated splitter. + public Type PayloadType { get; } + + /// Item payload type produced by the generated splitter. + public Type ItemType { get; } + + /// Name of the generated splitter factory method. + public string FactoryName { get; set; } = "Create"; +} + +/// +/// Marks the static method used by a generated splitter projection. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class SplitterProjectionAttribute : Attribute; + +/// +/// Generates a typed aggregator factory for a partial class or struct. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateAggregatorAttribute : Attribute +{ + /// Creates an aggregator generator attribute. + public GenerateAggregatorAttribute(Type keyType, Type itemType, Type resultType) + { + KeyType = keyType ?? throw new ArgumentNullException(nameof(keyType)); + ItemType = itemType ?? throw new ArgumentNullException(nameof(itemType)); + ResultType = resultType ?? throw new ArgumentNullException(nameof(resultType)); + } + + /// Aggregation correlation key type. + public Type KeyType { get; } + + /// Item payload type collected by the generated aggregator. + public Type ItemType { get; } + + /// Result type projected when a group completes. + public Type ResultType { get; } + + /// Name of the generated aggregator factory method. + public string FactoryName { get; set; } = "Create"; + + /// + /// Duplicate message-id policy emitted into the generated aggregator. Supported values are Ignore, Include, and Replace. + /// + public string DuplicatePolicy { get; set; } = "Ignore"; +} + +/// +/// Marks the static method used by a generated aggregator key selector. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class AggregatorCorrelationAttribute : Attribute; + +/// +/// Marks the static method used by a generated aggregator completion policy. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class AggregatorCompletionAttribute : Attribute; + +/// +/// Marks the static method used by a generated aggregator result projection. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class AggregatorProjectionAttribute : Attribute; diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 85fe85ad..a58c0bf2 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -178,3 +178,9 @@ PKRL001 | PatternKit.Generators.Messaging | Error | Recipient list type must be PKRL002 | PatternKit.Generators.Messaging | Error | Recipient list must declare at least one recipient. PKRL003 | PatternKit.Generators.Messaging | Error | Recipient handler or predicate signature is invalid. PKRL004 | PatternKit.Generators.Messaging | Error | Recipient name or order is duplicated. +PKSA001 | PatternKit.Generators.Messaging | Error | Splitter or aggregator host must be partial. +PKSA002 | PatternKit.Generators.Messaging | Error | Generated splitter host must declare exactly one projection. +PKSA003 | PatternKit.Generators.Messaging | Error | Generated splitter projection signature is invalid. +PKSA004 | PatternKit.Generators.Messaging | Error | Generated aggregator host must declare correlation, completion, and projection methods. +PKSA005 | PatternKit.Generators.Messaging | Error | Generated aggregator method signature is invalid. +PKSA006 | PatternKit.Generators.Messaging | Error | Generated aggregator duplicate policy is invalid. diff --git a/src/PatternKit.Generators/Messaging/SplitterAggregatorGenerator.cs b/src/PatternKit.Generators/Messaging/SplitterAggregatorGenerator.cs new file mode 100644 index 00000000..f82b4d23 --- /dev/null +++ b/src/PatternKit.Generators/Messaging/SplitterAggregatorGenerator.cs @@ -0,0 +1,363 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.Messaging; + +[Generator] +public sealed class SplitterAggregatorGenerator : IIncrementalGenerator +{ + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKSA001", + "Splitter or aggregator type must be partial", + "Type '{0}' is marked for generated splitter or aggregator factories but is not declared as partial", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingSplitterProjection = new( + "PKSA002", + "Splitter projection is missing", + "Type '{0}' is marked with [GenerateSplitter] but does not declare exactly one [SplitterProjection] method", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidSplitterProjection = new( + "PKSA003", + "Splitter projection signature is invalid", + "Splitter projection '{0}' must be static and return IEnumerable with Message and MessageContext parameters", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingAggregatorParts = new( + "PKSA004", + "Aggregator methods are missing", + "Type '{0}' is marked with [GenerateAggregator] but must declare exactly one correlation, completion, and projection method", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidAggregatorMethod = new( + "PKSA005", + "Aggregator method signature is invalid", + "Aggregator method '{0}' has an invalid signature for the generated key, item, and result types", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidDuplicatePolicy = new( + "PKSA006", + "Aggregator duplicate policy is invalid", + "Generated aggregator duplicate policy '{0}' is invalid. Supported values are Ignore, Include, and Replace.", + "PatternKit.Generators.Messaging", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var splitters = context.SyntaxProvider.ForAttributeWithMetadataName( + "PatternKit.Generators.Messaging.GenerateSplitterAttribute", + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(splitters, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Messaging.GenerateSplitterAttribute"); + if (attr is not null) + GenerateSplitter(spc, candidate.Type, candidate.Node, attr); + }); + + var aggregators = context.SyntaxProvider.ForAttributeWithMetadataName( + "PatternKit.Generators.Messaging.GenerateAggregatorAttribute", + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(aggregators, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(a => + a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Messaging.GenerateAggregatorAttribute"); + if (attr is not null) + GenerateAggregator(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void GenerateSplitter( + SourceProductionContext context, + INamedTypeSymbol type, + TypeDeclarationSyntax node, + AttributeData attribute) + { + if (!IsPartial(node)) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var payloadType = attribute.ConstructorArguments.Length >= 1 + ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol + : null; + var itemType = attribute.ConstructorArguments.Length >= 2 + ? attribute.ConstructorArguments[1].Value as INamedTypeSymbol + : null; + if (payloadType is null || itemType is null) + return; + + var projections = GetMarkedMethods(type, "PatternKit.Generators.Messaging.SplitterProjectionAttribute"); + if (projections.Count != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingSplitterProjection, node.Identifier.GetLocation(), type.Name)); + return; + } + + var projection = projections[0]; + if (!IsSplitterProjection(projection, payloadType, itemType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidSplitterProjection, projection.Locations.FirstOrDefault(), projection.Name)); + return; + } + + var factoryName = GetNamedString(attribute, "FactoryName") ?? "Create"; + context.AddSource($"{type.Name}.Splitter.g.cs", SourceText.From( + GenerateSplitterSource(type, payloadType, itemType, projection.Name, factoryName), + Encoding.UTF8)); + } + + private static void GenerateAggregator( + SourceProductionContext context, + INamedTypeSymbol type, + TypeDeclarationSyntax node, + AttributeData attribute) + { + if (!IsPartial(node)) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var keyType = attribute.ConstructorArguments.Length >= 1 + ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol + : null; + var itemType = attribute.ConstructorArguments.Length >= 2 + ? attribute.ConstructorArguments[1].Value as INamedTypeSymbol + : null; + var resultType = attribute.ConstructorArguments.Length >= 3 + ? attribute.ConstructorArguments[2].Value as INamedTypeSymbol + : null; + if (keyType is null || itemType is null || resultType is null) + return; + + var correlationMethods = GetMarkedMethods(type, "PatternKit.Generators.Messaging.AggregatorCorrelationAttribute"); + var completionMethods = GetMarkedMethods(type, "PatternKit.Generators.Messaging.AggregatorCompletionAttribute"); + var projectionMethods = GetMarkedMethods(type, "PatternKit.Generators.Messaging.AggregatorProjectionAttribute"); + if (correlationMethods.Count != 1 || completionMethods.Count != 1 || projectionMethods.Count != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingAggregatorParts, node.Identifier.GetLocation(), type.Name)); + return; + } + + var correlation = correlationMethods[0]; + var completion = completionMethods[0]; + var projection = projectionMethods[0]; + if (!IsAggregatorCorrelation(correlation, keyType, itemType)) + context.ReportDiagnostic(Diagnostic.Create(InvalidAggregatorMethod, correlation.Locations.FirstOrDefault(), correlation.Name)); + if (!IsAggregatorCompletion(completion, keyType, itemType)) + context.ReportDiagnostic(Diagnostic.Create(InvalidAggregatorMethod, completion.Locations.FirstOrDefault(), completion.Name)); + if (!IsAggregatorProjection(projection, keyType, itemType, resultType)) + context.ReportDiagnostic(Diagnostic.Create(InvalidAggregatorMethod, projection.Locations.FirstOrDefault(), projection.Name)); + if (!IsAggregatorCorrelation(correlation, keyType, itemType) || + !IsAggregatorCompletion(completion, keyType, itemType) || + !IsAggregatorProjection(projection, keyType, itemType, resultType)) + { + return; + } + + var duplicatePolicy = GetNamedString(attribute, "DuplicatePolicy") ?? "Ignore"; + if (!TryNormalizeDuplicatePolicy(duplicatePolicy, out var normalizedPolicy)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidDuplicatePolicy, node.Identifier.GetLocation(), duplicatePolicy)); + return; + } + + var factoryName = GetNamedString(attribute, "FactoryName") ?? "Create"; + context.AddSource($"{type.Name}.Aggregator.g.cs", SourceText.From( + GenerateAggregatorSource(type, keyType, itemType, resultType, correlation.Name, completion.Name, projection.Name, normalizedPolicy, factoryName), + Encoding.UTF8)); + } + + private static List GetMarkedMethods(INamedTypeSymbol type, string attributeName) + => type.GetMembers().OfType() + .Where(method => method.GetAttributes().Any(attr => attr.AttributeClass?.ToDisplayString() == attributeName)) + .ToList(); + + private static bool IsSplitterProjection(IMethodSymbol method, INamedTypeSymbol payloadType, INamedTypeSymbol itemType) + => method.IsStatic && + method.Parameters.Length == 2 && + IsMessageOf(method.Parameters[0].Type, payloadType) && + method.Parameters[1].Type.ToDisplayString() == "PatternKit.Messaging.MessageContext" && + IsEnumerableOf(method.ReturnType, itemType); + + private static bool IsAggregatorCorrelation(IMethodSymbol method, INamedTypeSymbol keyType, INamedTypeSymbol itemType) + => method.IsStatic && + SymbolEqualityComparer.Default.Equals(method.ReturnType, keyType) && + method.Parameters.Length == 2 && + IsMessageOf(method.Parameters[0].Type, itemType) && + method.Parameters[1].Type.ToDisplayString() == "PatternKit.Messaging.MessageContext"; + + private static bool IsAggregatorCompletion(IMethodSymbol method, INamedTypeSymbol keyType, INamedTypeSymbol itemType) + => method.IsStatic && + method.ReturnType.SpecialType == SpecialType.System_Boolean && + method.Parameters.Length == 3 && + SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, keyType) && + IsReadOnlyListOfMessage(method.Parameters[1].Type, itemType) && + method.Parameters[2].Type.ToDisplayString() == "PatternKit.Messaging.MessageContext"; + + private static bool IsAggregatorProjection(IMethodSymbol method, INamedTypeSymbol keyType, INamedTypeSymbol itemType, INamedTypeSymbol resultType) + => method.IsStatic && + SymbolEqualityComparer.Default.Equals(method.ReturnType, resultType) && + method.Parameters.Length == 3 && + SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, keyType) && + IsReadOnlyListOfMessage(method.Parameters[1].Type, itemType) && + method.Parameters[2].Type.ToDisplayString() == "PatternKit.Messaging.MessageContext"; + + private static bool IsMessageOf(ITypeSymbol type, INamedTypeSymbol payloadType) + => type is INamedTypeSymbol named && + named.ConstructedFrom.ToDisplayString() == "PatternKit.Messaging.Message" && + SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], payloadType); + + private static bool IsEnumerableOf(ITypeSymbol type, INamedTypeSymbol itemType) + { + if (type is INamedTypeSymbol named && + named.ConstructedFrom.ToDisplayString() == "System.Collections.Generic.IEnumerable" && + SymbolEqualityComparer.Default.Equals(named.TypeArguments[0], itemType)) + { + return true; + } + + return type.AllInterfaces.Any(iface => + iface.ConstructedFrom.ToDisplayString() == "System.Collections.Generic.IEnumerable" && + SymbolEqualityComparer.Default.Equals(iface.TypeArguments[0], itemType)); + } + + private static bool IsReadOnlyListOfMessage(ITypeSymbol type, INamedTypeSymbol itemType) + => type is INamedTypeSymbol named && + named.ConstructedFrom.ToDisplayString() == "System.Collections.Generic.IReadOnlyList" && + named.TypeArguments.Length == 1 && + IsMessageOf(named.TypeArguments[0], itemType); + + private static string GenerateSplitterSource( + INamedTypeSymbol type, + INamedTypeSymbol payloadType, + INamedTypeSymbol itemType, + string projectionMethodName, + string factoryName) + { + var payload = payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var item = itemType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var sb = CreatePreamble(type); + sb.Append(" public static global::PatternKit.Messaging.Routing.Splitter<") + .Append(payload) + .Append(", ") + .Append(item) + .Append("> ") + .Append(factoryName) + .AppendLine("()"); + sb.Append(" => global::PatternKit.Messaging.Routing.Splitter<") + .Append(payload) + .Append(", ") + .Append(item) + .AppendLine(">.Create()"); + sb.Append(" .Use(").Append(projectionMethodName).AppendLine(")"); + sb.AppendLine(" .Build();"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string GenerateAggregatorSource( + INamedTypeSymbol type, + INamedTypeSymbol keyType, + INamedTypeSymbol itemType, + INamedTypeSymbol resultType, + string correlationMethodName, + string completionMethodName, + string projectionMethodName, + string duplicatePolicy, + string factoryName) + { + var key = keyType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var item = itemType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var result = resultType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var sb = CreatePreamble(type); + sb.Append(" public static global::PatternKit.Messaging.Routing.Aggregator<") + .Append(key) + .Append(", ") + .Append(item) + .Append(", ") + .Append(result) + .Append("> ") + .Append(factoryName) + .AppendLine("()"); + sb.Append(" => global::PatternKit.Messaging.Routing.Aggregator<") + .Append(key) + .Append(", ") + .Append(item) + .Append(", ") + .Append(result) + .AppendLine(">.Create()"); + sb.Append(" .KeyBy(").Append(correlationMethodName).AppendLine(")"); + sb.Append(" .CompleteWhen(").Append(completionMethodName).AppendLine(")"); + sb.Append(" .Project(").Append(projectionMethodName).AppendLine(")"); + sb.Append(" .Duplicates(global::PatternKit.Messaging.Routing.DuplicateMessagePolicy.").Append(duplicatePolicy).AppendLine(")"); + sb.AppendLine(" .Build();"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static StringBuilder CreatePreamble(INamedTypeSymbol type) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append("partial ").Append(GetKind(type)).Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + return sb; + } + + private static bool IsPartial(TypeDeclarationSyntax node) + => node.Modifiers.Any(static modifier => modifier.Text == "partial"); + + private static string GetKind(INamedTypeSymbol type) + => type.TypeKind == TypeKind.Struct ? "struct" : "class"; + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static bool TryNormalizeDuplicatePolicy(string value, out string normalized) + { + normalized = value; + if (string.Equals(value, "Ignore", System.StringComparison.OrdinalIgnoreCase)) + normalized = "Ignore"; + else if (string.Equals(value, "Include", System.StringComparison.OrdinalIgnoreCase)) + normalized = "Include"; + else if (string.Equals(value, "Replace", System.StringComparison.OrdinalIgnoreCase)) + normalized = "Replace"; + else + return false; + + return true; + } +} diff --git a/test/PatternKit.Examples.Tests/Messaging/MessageRoutingExampleTests.cs b/test/PatternKit.Examples.Tests/Messaging/MessageRoutingExampleTests.cs index 312848c2..ceda5133 100644 --- a/test/PatternKit.Examples.Tests/Messaging/MessageRoutingExampleTests.cs +++ b/test/PatternKit.Examples.Tests/Messaging/MessageRoutingExampleTests.cs @@ -1,20 +1,78 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; using PatternKit.Examples.Messaging; +using PatternKit.Examples.ProductionReadiness; using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; namespace PatternKit.Examples.Tests.Messaging; -public sealed class MessageRoutingExampleTests +[Feature("Generated splitter and aggregator example")] +public sealed class MessageRoutingExampleTests(ITestOutputHelper output) : TinyBddXunitBase(output) { - [Scenario("Run ComposesEnterpriseRoutingPrimitives")] + [Scenario("Fluent and generated splitter aggregator paths produce the same routing summary")] [Fact] - public void Run_ComposesEnterpriseRoutingPrimitives() - { - var summary = MessageRoutingExample.Run(); + public Task Fluent_And_Generated_Splitter_Aggregator_Paths_Produce_The_Same_Routing_Summary() + => Given("message routing example entry points", () => + new MessageRoutingExampleRunner(MessageRoutingExample.RunFluent, MessageRoutingExample.RunGenerated)) + .When("running both splitter aggregator paths", runner => new + { + Fluent = runner.RunFluent(), + Generated = runner.RunGenerated() + }) + .Then("both paths route and fan out the order consistently", result => + { + ScenarioExpect.Equal("priority", result.Generated.Route); + ScenarioExpect.Equal(result.Fluent.Route, result.Generated.Route); + ScenarioExpect.Equal(result.Fluent.Recipients, result.Generated.Recipients); + ScenarioExpect.Equal(["audit", "billing"], result.Generated.Recipients); + }) + .And("both paths split and aggregate the same correlated line items", result => + { + ScenarioExpect.Equal(result.Fluent.SplitCount, result.Generated.SplitCount); + ScenarioExpect.Equal(2, result.Generated.SplitCount); + ScenarioExpect.Equal(result.Fluent.AggregatedTotal, result.Generated.AggregatedTotal); + ScenarioExpect.Equal(100m, result.Generated.AggregatedTotal); + ScenarioExpect.Equal("msg-order-42", result.Generated.CausationId); + }) + .And("the generated path advertises its source-generated factories", result => + ScenarioExpect.Equal("source-generated", result.Generated.Path)) + .AssertPassed(); - ScenarioExpect.Equal("priority", summary.Route); - ScenarioExpect.Equal(["audit", "billing"], summary.Recipients); - ScenarioExpect.Equal(2, summary.SplitCount); - ScenarioExpect.Equal(100m, summary.AggregatedTotal); - ScenarioExpect.Equal("msg-order-42", summary.CausationId); - } + [Scenario("Generated splitter aggregator example is importable through IServiceCollection")] + [Fact] + public Task Generated_Splitter_Aggregator_Example_Is_Importable_Through_IServiceCollection() + => Given("a service collection using the PatternKit splitter aggregator extension", () => + { + var services = new ServiceCollection(); + services.AddGeneratedSplitterAggregatorExample(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("resolving and running the generated splitter aggregator example", provider => + { + using (provider) + { + var example = provider.GetRequiredService(); + var summary = example.Runner.RunGenerated(); + var descriptor = provider.GetServices() + .Single(descriptor => descriptor.ExampleName == "Generated Splitter and Aggregator"); + + return new MessageRoutingImportRun(summary, descriptor.Integration); + } + }) + .Then("the generated runner returns expected routing metadata", result => + { + ScenarioExpect.Equal("priority", result.Summary.Route); + ScenarioExpect.Equal(["audit", "billing"], result.Summary.Recipients); + ScenarioExpect.Equal(2, result.Summary.SplitCount); + ScenarioExpect.Equal(100m, result.Summary.AggregatedTotal); + }) + .And("the descriptor advertises DI source generation and messaging", result => + result.Integration.HasFlag(ExampleIntegrationSurface.DependencyInjection) + && result.Integration.HasFlag(ExampleIntegrationSurface.SourceGenerator) + && result.Integration.HasFlag(ExampleIntegrationSurface.Messaging)) + .AssertPassed(); + + private sealed record MessageRoutingImportRun(RoutingSummary Summary, ExampleIntegrationSurface Integration); } diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 8d338279..96557b19 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -121,15 +121,13 @@ public Task Each_Pattern_Has_Fluent_Generated_Documented_And_Example_Paths() ScenarioExpect.Equal( [ "Abstract Factory has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/207", - "Aggregator has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/211", "Idempotent Receiver has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/213", "Inbox has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/213", "Interpreter has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/206", "Mailbox has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/209", "Outbox has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/213", "Publish-Subscribe has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/214", - "Request-Reply has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/214", - "Splitter has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/211" + "Request-Reply has a tracked source-generated gap: https://github.com/JerrettDavis/PatternKit/issues/214" ], tracked); }) .AssertPassed(); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 74738ff5..85ca9529 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -79,6 +79,12 @@ private enum TestTrigger { typeof(GenerateContentRouterAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(ContentRouteAttribute), AttributeTargets.Method, false, false }, { typeof(ContentRouteDefaultAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateSplitterAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(SplitterProjectionAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateAggregatorAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(AggregatorCorrelationAttribute), AttributeTargets.Method, false, false }, + { typeof(AggregatorCompletionAttribute), AttributeTargets.Method, false, false }, + { typeof(AggregatorProjectionAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateMessageEnvelopeAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(MessageEnvelopeHeaderAttribute), AttributeTargets.Class | AttributeTargets.Struct, true, false }, { typeof(ObserverAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, @@ -341,6 +347,15 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf AsyncFactoryName = "BuildRecipientsAsync" }; var recipient = new RecipientListRecipientAttribute("priority-audit", 5, "IsPriority"); + var splitter = new GenerateSplitterAttribute(typeof(string), typeof(int)) + { + FactoryName = "BuildSplitter" + }; + var aggregator = new GenerateAggregatorAttribute(typeof(string), typeof(int), typeof(decimal)) + { + FactoryName = "BuildAggregator", + DuplicatePolicy = "Replace" + }; var envelope = new GenerateMessageEnvelopeAttribute(typeof(string)) { FactoryName = "BuildEnvelope", @@ -386,6 +401,14 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Equal("priority-audit", recipient.Name); ScenarioExpect.Equal(5, recipient.Order); ScenarioExpect.Equal("IsPriority", recipient.PredicateMethodName); + ScenarioExpect.Equal(typeof(string), splitter.PayloadType); + ScenarioExpect.Equal(typeof(int), splitter.ItemType); + ScenarioExpect.Equal("BuildSplitter", splitter.FactoryName); + ScenarioExpect.Equal(typeof(string), aggregator.KeyType); + ScenarioExpect.Equal(typeof(int), aggregator.ItemType); + ScenarioExpect.Equal(typeof(decimal), aggregator.ResultType); + ScenarioExpect.Equal("BuildAggregator", aggregator.FactoryName); + ScenarioExpect.Equal("Replace", aggregator.DuplicatePolicy); ScenarioExpect.Equal(typeof(string), envelope.PayloadType); ScenarioExpect.Equal("BuildEnvelope", envelope.FactoryName); ScenarioExpect.Equal("BuildContext", envelope.ContextFactoryName); @@ -403,11 +426,20 @@ public void Flyweight_Iterator_And_Messaging_Attributes_Expose_Defaults_And_Conf ScenarioExpect.Throws(() => new GenerateRecipientListAttribute(null!)); ScenarioExpect.Throws(() => new RecipientListRecipientAttribute("", 1, "Predicate")); ScenarioExpect.Throws(() => new RecipientListRecipientAttribute("name", 1, "")); + ScenarioExpect.Throws(() => new GenerateSplitterAttribute(null!, typeof(int))); + ScenarioExpect.Throws(() => new GenerateSplitterAttribute(typeof(string), null!)); + ScenarioExpect.Throws(() => new GenerateAggregatorAttribute(null!, typeof(int), typeof(decimal))); + ScenarioExpect.Throws(() => new GenerateAggregatorAttribute(typeof(string), null!, typeof(decimal))); + ScenarioExpect.Throws(() => new GenerateAggregatorAttribute(typeof(string), typeof(int), null!)); ScenarioExpect.Throws(() => new GenerateMessageEnvelopeAttribute(null!)); ScenarioExpect.Throws(() => new MessageEnvelopeHeaderAttribute("", typeof(string))); ScenarioExpect.Throws(() => new MessageEnvelopeHeaderAttribute("tenant-id", null!)); ScenarioExpect.IsType(new SagaCompleteWhenAttribute()); ScenarioExpect.IsType(new ContentRouteDefaultAttribute()); + ScenarioExpect.IsType(new SplitterProjectionAttribute()); + ScenarioExpect.IsType(new AggregatorCorrelationAttribute()); + ScenarioExpect.IsType(new AggregatorCompletionAttribute()); + ScenarioExpect.IsType(new AggregatorProjectionAttribute()); ScenarioExpect.IsType(new FlyweightFactoryAttribute()); ScenarioExpect.IsType(new IteratorStepAttribute()); ScenarioExpect.IsType(new TraversalIteratorAttribute()); diff --git a/test/PatternKit.Generators.Tests/SplitterAggregatorGeneratorTests.cs b/test/PatternKit.Generators.Tests/SplitterAggregatorGeneratorTests.cs new file mode 100644 index 00000000..a16544da --- /dev/null +++ b/test/PatternKit.Generators.Tests/SplitterAggregatorGeneratorTests.cs @@ -0,0 +1,245 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using PatternKit.Generators.Messaging; +using TinyBDD; + +namespace PatternKit.Generators.Tests; + +public sealed class SplitterAggregatorGeneratorTests +{ + [Scenario("Generates typed splitter factory")] + [Fact] + public void GeneratesTypedSplitterFactory() + { + var source = """ + using System.Collections.Generic; + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + + namespace MyApp; + + public sealed record Order(string Id, IReadOnlyList Lines); + public sealed record Line(string OrderId, decimal Amount); + + [GenerateSplitter(typeof(Order), typeof(Line), FactoryName = "CreateLineSplitter")] + public static partial class OrderLineSplitter + { + [SplitterProjection] + private static IEnumerable ProjectLines(Message message, MessageContext context) + => message.Payload.Lines; + } + + public static class Demo + { + public static int Run() + { + var splitter = OrderLineSplitter.CreateLineSplitter(); + var parts = splitter.Split(Message.Create(new Order("order-1", [new Line("order-1", 12m)]))); + return parts.Count; + } + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesTypedSplitterFactory)); + var gen = new SplitterAggregatorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources)); + ScenarioExpect.Equal("OrderLineSplitter.Splitter.g.cs", generated.HintName); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("CreateLineSplitter()", text); + ScenarioExpect.Contains(".Use(ProjectLines)", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("Generates typed aggregator factory")] + [Fact] + public void GeneratesTypedAggregatorFactory() + { + var source = """ + using System.Collections.Generic; + using System.Linq; + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + + namespace MyApp; + + public sealed record Line(string OrderId, decimal Amount); + + [GenerateAggregator(typeof(string), typeof(Line), typeof(decimal), FactoryName = "CreateLineTotal", DuplicatePolicy = "Replace")] + public static partial class OrderLineAggregator + { + [AggregatorCorrelation] + private static string Correlate(Message message, MessageContext context) + => message.Payload.OrderId; + + [AggregatorCompletion] + private static bool Complete(string key, IReadOnlyList> messages, MessageContext context) + => messages.Count == 2; + + [AggregatorProjection] + private static decimal Project(string key, IReadOnlyList> messages, MessageContext context) + => messages.Sum(message => message.Payload.Amount); + } + + public static class Demo + { + public static decimal Run() + { + var aggregator = OrderLineAggregator.CreateLineTotal(); + aggregator.Add(Message.Create(new Line("order-1", 12m)).WithMessageId("line-1")); + var result = aggregator.Add(Message.Create(new Line("order-1", 15m)).WithMessageId("line-2")); + return result.Result; + } + } + """; + + var comp = CreateCompilation(source, nameof(GeneratesTypedAggregatorFactory)); + var gen = new SplitterAggregatorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics)); + var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources)); + ScenarioExpect.Equal("OrderLineAggregator.Aggregator.g.cs", generated.HintName); + var text = generated.SourceText.ToString(); + ScenarioExpect.Contains("CreateLineTotal()", text); + ScenarioExpect.Contains(".KeyBy(Correlate)", text); + ScenarioExpect.Contains(".CompleteWhen(Complete)", text); + ScenarioExpect.Contains(".Project(Project)", text); + ScenarioExpect.Contains("DuplicateMessagePolicy.Replace", text); + + var emit = updated.Emit(Stream.Null); + ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Scenario("Reports diagnostic for non-partial splitter contract")] + [Fact] + public void ReportsDiagnosticForNonPartialSplitterContract() + { + var source = """ + using PatternKit.Generators.Messaging; + + namespace MyApp; + + public sealed record Order; + public sealed record Line; + + [GenerateSplitter(typeof(Order), typeof(Line))] + public static class OrderLineSplitter; + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForNonPartialSplitterContract)); + var gen = new SplitterAggregatorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKSA001", diagnostic.Id); + } + + [Scenario("Reports diagnostic for missing splitter projection")] + [Fact] + public void ReportsDiagnosticForMissingSplitterProjection() + { + var source = """ + using PatternKit.Generators.Messaging; + + namespace MyApp; + + public sealed record Order; + public sealed record Line; + + [GenerateSplitter(typeof(Order), typeof(Line))] + public static partial class OrderLineSplitter; + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForMissingSplitterProjection)); + var gen = new SplitterAggregatorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKSA002", diagnostic.Id); + } + + [Scenario("Reports diagnostic for invalid aggregator projection")] + [Fact] + public void ReportsDiagnosticForInvalidAggregatorProjection() + { + var source = """ + using System.Collections.Generic; + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + + namespace MyApp; + + public sealed record Line(string OrderId, decimal Amount); + + [GenerateAggregator(typeof(string), typeof(Line), typeof(decimal))] + public static partial class OrderLineAggregator + { + [AggregatorCorrelation] + private static string Correlate(Message message, MessageContext context) => message.Payload.OrderId; + + [AggregatorCompletion] + private static bool Complete(string key, IReadOnlyList> messages, MessageContext context) => true; + + [AggregatorProjection] + private static string Project(string key, IReadOnlyList> messages, MessageContext context) => "wrong"; + } + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForInvalidAggregatorProjection)); + var gen = new SplitterAggregatorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKSA005", diagnostic.Id); + } + + [Scenario("Reports diagnostic for invalid duplicate policy")] + [Fact] + public void ReportsDiagnosticForInvalidDuplicatePolicy() + { + var source = """ + using System.Collections.Generic; + using PatternKit.Generators.Messaging; + using PatternKit.Messaging; + + namespace MyApp; + + public sealed record Line(string OrderId, decimal Amount); + + [GenerateAggregator(typeof(string), typeof(Line), typeof(decimal), DuplicatePolicy = "Drop")] + public static partial class OrderLineAggregator + { + [AggregatorCorrelation] + private static string Correlate(Message message, MessageContext context) => message.Payload.OrderId; + + [AggregatorCompletion] + private static bool Complete(string key, IReadOnlyList> messages, MessageContext context) => true; + + [AggregatorProjection] + private static decimal Project(string key, IReadOnlyList> messages, MessageContext context) => 0m; + } + """; + + var comp = CreateCompilation(source, nameof(ReportsDiagnosticForInvalidDuplicatePolicy)); + var gen = new SplitterAggregatorGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)); + ScenarioExpect.Equal("PKSA006", diagnostic.Id); + } + + private static CSharpCompilation CreateCompilation(string source, string assemblyName) + => RoslynTestHelpers.CreateCompilation( + source, + assemblyName, + extra: + [ + MetadataReference.CreateFromFile(typeof(PatternKit.Messaging.Message<>).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location) + ]); +}