From 76edb194161684e7d66570f6c6d6712da3f98370 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 22 May 2026 01:11:39 -0500 Subject: [PATCH] feat: add event-carried state transfer pattern --- docs/examples/index.md | 3 + .../inventory-event-carried-state-transfer.md | 12 ++ docs/examples/toc.yml | 3 + .../event-carried-state-transfer.md | 26 +++ docs/generators/index.md | 5 + docs/generators/toc.yml | 3 + docs/guides/pattern-coverage.md | 1 + docs/patterns/messaging/README.md | 6 + .../messaging/event-carried-state-transfer.md | 18 ++ docs/patterns/toc.yml | 2 + .../EventCarriedStateTransfer.cs | 107 ++++++++++ ...rnKitExampleServiceCollectionExtensions.cs | 12 ++ .../InventoryEventCarriedStateTransferDemo.cs | 94 +++++++++ .../PatternKitExampleCatalog.cs | 8 + .../PatternKitPatternCatalog.cs | 13 ++ .../EventCarriedStateTransferAttributes.cs | 30 +++ .../AnalyzerReleases.Unshipped.md | 3 + .../EventCarriedStateTransferGenerator.cs | 183 ++++++++++++++++++ ...ntoryEventCarriedStateTransferDemoTests.cs | 54 ++++++ .../PatternKitPatternCatalogTests.cs | 3 +- .../AbstractionsAttributeCoverageTests.cs | 28 +++ ...EventCarriedStateTransferGeneratorTests.cs | 95 +++++++++ .../EventCarriedStateTransferTests.cs | 73 +++++++ 23 files changed, 781 insertions(+), 1 deletion(-) create mode 100644 docs/examples/inventory-event-carried-state-transfer.md create mode 100644 docs/generators/event-carried-state-transfer.md create mode 100644 docs/patterns/messaging/event-carried-state-transfer.md create mode 100644 src/PatternKit.Core/EnterpriseIntegration/EventCarriedStateTransfer/EventCarriedStateTransfer.cs create mode 100644 src/PatternKit.Examples/EventCarriedStateTransferDemo/InventoryEventCarriedStateTransferDemo.cs create mode 100644 src/PatternKit.Generators.Abstractions/EnterpriseIntegration/EventCarriedStateTransferAttributes.cs create mode 100644 src/PatternKit.Generators/EventCarriedStateTransfer/EventCarriedStateTransferGenerator.cs create mode 100644 test/PatternKit.Examples.Tests/EventCarriedStateTransferDemo/InventoryEventCarriedStateTransferDemoTests.cs create mode 100644 test/PatternKit.Generators.Tests/EventCarriedStateTransferGeneratorTests.cs create mode 100644 test/PatternKit.Tests/EnterpriseIntegration/EventCarriedStateTransfer/EventCarriedStateTransferTests.cs diff --git a/docs/examples/index.md b/docs/examples/index.md index 19832295..5ea83882 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -90,6 +90,9 @@ Welcome! This section collects small, focused demos that show **how to compose b * **Order Canonical Data Model** Shows fluent and source-generated order normalization into an application-owned canonical contract, with an importable `IServiceCollection` extension. See [Order Canonical Data Model](order-canonical-data-model.md). +* **Inventory Event-Carried State Transfer** + Shows fluent and source-generated inventory projection events that carry enough state to update a local read model, with an importable `IServiceCollection` extension. See [Inventory Event-Carried State Transfer](inventory-event-carried-state-transfer.md). + * **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). diff --git a/docs/examples/inventory-event-carried-state-transfer.md b/docs/examples/inventory-event-carried-state-transfer.md new file mode 100644 index 00000000..e154ea2f --- /dev/null +++ b/docs/examples/inventory-event-carried-state-transfer.md @@ -0,0 +1,12 @@ +# Inventory Event-Carried State Transfer + +The inventory event-carried state transfer example projects `InventoryAdjustedEvent` payloads into an importable inventory read model service. + +```csharp +services.AddInventoryEventCarriedStateTransferDemo(); + +var runner = provider.GetRequiredService(); +var summary = runner.RunGenerated(new InventoryAdjustedEvent("SKU-100", 12, "CHI-01", 4)); +``` + +The example includes fluent and source-generated construction plus an `IServiceCollection` extension for standard .NET hosts. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 789796be..d75e0ca2 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -82,6 +82,9 @@ - name: Order Canonical Data Model href: order-canonical-data-model.md +- name: Inventory Event-Carried State Transfer + href: inventory-event-carried-state-transfer.md + - name: Generated Claim Check href: generated-claim-check.md diff --git a/docs/generators/event-carried-state-transfer.md b/docs/generators/event-carried-state-transfer.md new file mode 100644 index 00000000..418b844c --- /dev/null +++ b/docs/generators/event-carried-state-transfer.md @@ -0,0 +1,26 @@ +# Event-Carried State Transfer Generator + +`[GenerateEventCarriedStateTransfer]` creates a typed `EventCarriedStateTransfer` factory from key, version, and state mapper methods. + +```csharp +[GenerateEventCarriedStateTransfer(typeof(InventoryAdjustedEvent), typeof(string), typeof(InventoryReadModel), TransferName = "inventory-state")] +public static partial class InventoryStateTransfer +{ + [EventCarriedStateKey] + private static string Key(InventoryAdjustedEvent evt) => evt.Sku; + + [EventCarriedStateVersion] + private static long Version(InventoryAdjustedEvent evt) => evt.Version; + + [EventCarriedStateMapper] + private static InventoryReadModel Map(InventoryAdjustedEvent evt) => new(evt.Sku, evt.QuantityOnHand, evt.Warehouse); +} +``` + +The generated factory is parameterless and can be registered directly with `IServiceCollection`. + +Diagnostics: + +- `PKECST001`: host type must be partial. +- `PKECST002`: exactly one key selector, version selector, and state mapper are required. +- `PKECST003`: selector or mapper signature is invalid. diff --git a/docs/generators/index.md b/docs/generators/index.md index 4598b600..bf31c166 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -89,6 +89,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Message Envelope**](messaging.md#generated-message-envelope) | Required message metadata contracts | `[GenerateMessageEnvelope]` | | [**Message Translator**](message-translator.md) | Partner and transport event normalization | `[GenerateMessageTranslator]` | | [**Canonical Data Model**](canonical-data-model.md) | Source-to-canonical contract normalization | `[GenerateCanonicalDataModel]` | +| [**Event-Carried State Transfer**](event-carried-state-transfer.md) | State-rich event projection factories | `[GenerateEventCarriedStateTransfer]` | | [**Claim Check**](claim-check.md) | External payload storage references | `[GenerateClaimCheck]` | | [**Dead Letter Channel**](dead-letter-channel.md) | Failed-message capture and replay handoff | `[GenerateDeadLetterChannel]` | | [**Content Router**](messaging.md#generated-content-router) | Content-based message routing factories | `[GenerateContentRouter]` | @@ -195,6 +196,10 @@ public static partial class LegacyOrderAcl { } [GenerateMessageTranslator(typeof(PartnerOrderAccepted), typeof(CommerceOrderAccepted))] public static partial class PartnerOrderTranslator { } +// Event-carried state transfer - state-rich projection events +[GenerateEventCarriedStateTransfer(typeof(InventoryAdjustedEvent), typeof(string), typeof(InventoryReadModel))] +public static partial class InventoryStateTransfer { } + // Claim check - external payload storage reference [GenerateClaimCheck(typeof(LargeOrderDocument), StoreName = "document-archive")] public static partial class LargeDocumentClaims { } diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 217b17b9..713ca845 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -28,6 +28,9 @@ - name: Canonical Data Model href: canonical-data-model.md +- name: Event-Carried State Transfer + href: event-carried-state-transfer.md + - name: Chain href: chain.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 1292ff71..c42c4ff0 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -53,6 +53,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Enterprise Integration | Message Envelope | `Message`, headers, context | Messaging generator | | Enterprise Integration | Message Translator | `MessageTranslator` | Message Translator generator | | Enterprise Integration | Canonical Data Model | `CanonicalDataModel` | Canonical Data Model generator | +| Enterprise Integration | Event-Carried State Transfer | `EventCarriedStateTransfer` | Event-Carried State Transfer generator | | Enterprise Integration | Claim Check | `ClaimCheck` | Claim Check generator | | Enterprise Integration | Dead Letter Channel | `DeadLetterChannel` | Dead Letter Channel generator | | Enterprise Integration | Content-Based Router | `ContentRouter` | Messaging generator | diff --git a/docs/patterns/messaging/README.md b/docs/patterns/messaging/README.md index c9992b3a..fead6c18 100644 --- a/docs/patterns/messaging/README.md +++ b/docs/patterns/messaging/README.md @@ -56,6 +56,12 @@ Service activators invoke application service operations from typed messages whi [Learn More](service-activator.md) +## Event-Carried State Transfer + +Event-carried state transfer publishes enough state in an event for subscribers to update local read models without calling back into the source service. + +[Learn More](event-carried-state-transfer.md) + ## Idempotent Receiver, Inbox, and Outbox Idempotency and handoff helpers compose message handlers with pluggable stores, inbox boundaries, and outbox records without claiming broker durability or exactly-once delivery. diff --git a/docs/patterns/messaging/event-carried-state-transfer.md b/docs/patterns/messaging/event-carried-state-transfer.md new file mode 100644 index 00000000..4d49ca04 --- /dev/null +++ b/docs/patterns/messaging/event-carried-state-transfer.md @@ -0,0 +1,18 @@ +# Event-Carried State Transfer + +Event-Carried State Transfer publishes enough state in an event for subscribers to update their local model without calling back into the source service. + +```csharp +var transfer = EventCarriedStateTransfer + .Create("inventory-state") + .WithKey(evt => evt.Sku) + .WithVersion(evt => evt.Version) + .WithState(evt => new InventoryReadModel(evt.Sku, evt.QuantityOnHand, evt.Warehouse)) + .Build(); + +var carried = transfer.Transfer(inventoryAdjusted); +``` + +Use it when downstream services own read models, caches, or projections that should move forward from the event stream itself. The runtime path returns explicit transfer failures for selector or mapper errors. + +The source-generated path uses `[GenerateEventCarriedStateTransfer]`, `[EventCarriedStateKey]`, `[EventCarriedStateVersion]`, and `[EventCarriedStateMapper]`. Import the example through `AddInventoryEventCarriedStateTransferDemo()` or `AddPatternKitExamples()`. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index c28087ee..3f363b0f 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -313,6 +313,8 @@ href: messaging/message-translator.md - name: Canonical Data Model href: messaging/canonical-data-model.md + - name: Event-Carried State Transfer + href: messaging/event-carried-state-transfer.md - name: Claim Check href: messaging/claim-check.md - name: Dead Letter Channel diff --git a/src/PatternKit.Core/EnterpriseIntegration/EventCarriedStateTransfer/EventCarriedStateTransfer.cs b/src/PatternKit.Core/EnterpriseIntegration/EventCarriedStateTransfer/EventCarriedStateTransfer.cs new file mode 100644 index 00000000..83fdf91c --- /dev/null +++ b/src/PatternKit.Core/EnterpriseIntegration/EventCarriedStateTransfer/EventCarriedStateTransfer.cs @@ -0,0 +1,107 @@ +namespace PatternKit.EnterpriseIntegration.EventCarriedStateTransfer; + +public sealed class EventCarriedStateTransferResult +{ + private EventCarriedStateTransferResult(string transferName, TKey? key, long version, TState? state, Exception? exception, bool transferred) + => (TransferName, Key, Version, State, Exception, Transferred) = (transferName, key, version, state, exception, transferred); + + public string TransferName { get; } + + public TKey? Key { get; } + + public long Version { get; } + + public TState? State { get; } + + public Exception? Exception { get; } + + public bool Transferred { get; } + + public bool Failed => !Transferred; + + public static EventCarriedStateTransferResult Success(string transferName, TKey key, long version, TState state) + => new(transferName, key, version, state, null, true); + + public static EventCarriedStateTransferResult Failure(string transferName, Exception exception) + => new(transferName, default, 0, default, exception ?? throw new ArgumentNullException(nameof(exception)), false); +} + +public sealed class EventCarriedStateTransfer +{ + private readonly Func _keySelector; + private readonly Func _versionSelector; + private readonly Func _stateSelector; + + private EventCarriedStateTransfer( + string name, + Func? keySelector, + Func? versionSelector, + Func? stateSelector) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Event-carried state transfer name is required.", nameof(name)); + + Name = name; + _keySelector = keySelector ?? throw new InvalidOperationException("Event-carried state transfer requires a key selector."); + _versionSelector = versionSelector ?? throw new InvalidOperationException("Event-carried state transfer requires a version selector."); + _stateSelector = stateSelector ?? throw new InvalidOperationException("Event-carried state transfer requires a state mapper."); + } + + public string Name { get; } + + public EventCarriedStateTransferResult Transfer(TEvent @event) + { + if (@event is null) + throw new ArgumentNullException(nameof(@event)); + + try + { + var key = _keySelector(@event); + if (key is null) + return EventCarriedStateTransferResult.Failure(Name, new InvalidOperationException("Event-carried state key selector returned null.")); + + var state = _stateSelector(@event); + if (state is null) + return EventCarriedStateTransferResult.Failure(Name, new InvalidOperationException("Event-carried state mapper returned null.")); + + return EventCarriedStateTransferResult.Success(Name, key, _versionSelector(@event), state); + } + catch (Exception ex) + { + return EventCarriedStateTransferResult.Failure(Name, ex); + } + } + + public static Builder Create(string name = "event-carried-state-transfer") => new(name); + + public sealed class Builder + { + private readonly string _name; + private Func? _keySelector; + private Func? _versionSelector; + private Func? _stateSelector; + + internal Builder(string name) => _name = name; + + public Builder WithKey(Func keySelector) + { + _keySelector = keySelector ?? throw new ArgumentNullException(nameof(keySelector)); + return this; + } + + public Builder WithVersion(Func versionSelector) + { + _versionSelector = versionSelector ?? throw new ArgumentNullException(nameof(versionSelector)); + return this; + } + + public Builder WithState(Func stateSelector) + { + _stateSelector = stateSelector ?? throw new ArgumentNullException(nameof(stateSelector)); + return this; + } + + public EventCarriedStateTransfer Build() + => new(_name, _keySelector, _versionSelector, _stateSelector); + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 2a435bfa..9331fcff 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -32,6 +32,7 @@ using PatternKit.Examples.DomainEventDemo; using PatternKit.Examples.EnterpriseFeatureSlices; using PatternKit.Examples.EventSourcingDemo; +using PatternKit.Examples.EventCarriedStateTransferDemo; using PatternKit.Examples.ExternalConfigurationStoreDemo; using PatternKit.Examples.FeatureToggleDemo; using PatternKit.Examples.FlyweightDemo; @@ -142,6 +143,7 @@ public sealed record InventoryServiceActivatorExampleService(ServiceActivator("Order Canonical Data Model", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddInventoryEventCarriedStateTransferExample(this IServiceCollection services) + { + services.AddInventoryEventCarriedStateTransferDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + sp.GetRequiredService())); + return services.RegisterExample("Inventory Event-Carried State Transfer", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddGeneratedClaimCheckExample(this IServiceCollection services) { services.AddLargeDocumentClaimCheckExample(); diff --git a/src/PatternKit.Examples/EventCarriedStateTransferDemo/InventoryEventCarriedStateTransferDemo.cs b/src/PatternKit.Examples/EventCarriedStateTransferDemo/InventoryEventCarriedStateTransferDemo.cs new file mode 100644 index 00000000..09c0535d --- /dev/null +++ b/src/PatternKit.Examples/EventCarriedStateTransferDemo/InventoryEventCarriedStateTransferDemo.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.EnterpriseIntegration.EventCarriedStateTransfer; +using PatternKit.Generators.EventCarriedStateTransfer; + +namespace PatternKit.Examples.EventCarriedStateTransferDemo; + +public sealed record InventoryAdjustedEvent(string Sku, int QuantityOnHand, string Warehouse, long Version); + +public sealed record InventoryReadModel(string Sku, int QuantityOnHand, string Warehouse); + +public sealed record InventoryProjectionSummary(string TransferName, string Sku, long Version, int QuantityOnHand, string Warehouse); + +public interface IInventoryReadModelStore +{ + void Upsert(string sku, long version, InventoryReadModel state); + + InventoryProjectionSummary? Find(string sku); +} + +public sealed class InMemoryInventoryReadModelStore : IInventoryReadModelStore +{ + private readonly Dictionary _summaries = new(StringComparer.OrdinalIgnoreCase); + + public void Upsert(string sku, long version, InventoryReadModel state) + => _summaries[sku] = new("inventory-state", sku, version, state.QuantityOnHand, state.Warehouse); + + public InventoryProjectionSummary? Find(string sku) + => _summaries.TryGetValue(sku, out var summary) ? summary : null; +} + +public sealed class InventoryProjectionService( + EventCarriedStateTransfer transfer, + IInventoryReadModelStore store) +{ + public InventoryProjectionSummary Project(InventoryAdjustedEvent evt) + { + var result = transfer.Transfer(evt); + if (result.Failed) + throw new InvalidOperationException("Inventory event could not transfer carried state.", result.Exception); + + store.Upsert(result.Key!, result.Version, result.State!); + return store.Find(result.Key!)!; + } +} + +public static class InventoryStateTransfers +{ + public static EventCarriedStateTransfer CreateFluent() + => EventCarriedStateTransfer.Create("inventory-state") + .WithKey(static evt => evt.Sku) + .WithVersion(static evt => evt.Version) + .WithState(ToReadModel) + .Build(); + + public static InventoryReadModel ToReadModel(InventoryAdjustedEvent evt) + => new(evt.Sku, evt.QuantityOnHand, evt.Warehouse); +} + +[GenerateEventCarriedStateTransfer(typeof(InventoryAdjustedEvent), typeof(string), typeof(InventoryReadModel), FactoryMethodName = "Create", TransferName = "inventory-state")] +public static partial class GeneratedInventoryStateTransfer +{ + [EventCarriedStateKey] + private static string Key(InventoryAdjustedEvent evt) => evt.Sku; + + [EventCarriedStateVersion] + private static long Version(InventoryAdjustedEvent evt) => evt.Version; + + [EventCarriedStateMapper] + private static InventoryReadModel Map(InventoryAdjustedEvent evt) => InventoryStateTransfers.ToReadModel(evt); +} + +public sealed class InventoryEventCarriedStateTransferDemoRunner(InventoryProjectionService service) +{ + public InventoryProjectionSummary RunGenerated(InventoryAdjustedEvent evt) => service.Project(evt); + + public static InventoryProjectionSummary RunFluent() + { + var store = new InMemoryInventoryReadModelStore(); + var service = new InventoryProjectionService(InventoryStateTransfers.CreateFluent(), store); + return service.Project(new InventoryAdjustedEvent("SKU-100", 12, "CHI-01", 4)); + } +} + +public static class InventoryEventCarriedStateTransferServiceCollectionExtensions +{ + public static IServiceCollection AddInventoryEventCarriedStateTransferDemo(this IServiceCollection services) + { + services.AddSingleton(static _ => GeneratedInventoryStateTransfer.Create()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 635527e2..d32186fc 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -304,6 +304,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["Canonical Data Model"], ["partner order normalization", "source-generated canonical adapter", "DI composition"]), + Descriptor( + "Inventory Event-Carried State Transfer", + "src/PatternKit.Examples/EventCarriedStateTransferDemo/InventoryEventCarriedStateTransferDemo.cs", + "test/PatternKit.Examples.Tests/EventCarriedStateTransferDemo/InventoryEventCarriedStateTransferDemoTests.cs", + "docs/examples/inventory-event-carried-state-transfer.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["Event-Carried State Transfer"], + ["inventory read-model projection", "source-generated state transfer", "DI composition"]), Descriptor( "Generated Claim Check", "src/PatternKit.Examples/Messaging/LargeDocumentClaimCheckExample.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index bf95eac5..10a20350 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -480,6 +480,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/CanonicalDataModelDemo/OrderCanonicalDataModelDemoTests.cs", ["fluent source normalization", "generated canonical adapter", "DI-importable canonical order example"]), + Pattern("Event-Carried State Transfer", PatternFamily.EnterpriseIntegration, + "docs/patterns/messaging/event-carried-state-transfer.md", + "src/PatternKit.Core/EnterpriseIntegration/EventCarriedStateTransfer/EventCarriedStateTransfer.cs", + "test/PatternKit.Tests/EnterpriseIntegration/EventCarriedStateTransfer/EventCarriedStateTransferTests.cs", + "docs/generators/event-carried-state-transfer.md", + "src/PatternKit.Generators/EventCarriedStateTransfer/EventCarriedStateTransferGenerator.cs", + "test/PatternKit.Generators.Tests/EventCarriedStateTransferGeneratorTests.cs", + null, + "docs/examples/inventory-event-carried-state-transfer.md", + "src/PatternKit.Examples/EventCarriedStateTransferDemo/InventoryEventCarriedStateTransferDemo.cs", + "test/PatternKit.Examples.Tests/EventCarriedStateTransferDemo/InventoryEventCarriedStateTransferDemoTests.cs", + ["fluent state extraction", "generated state transfer factory", "DI-importable inventory projection example"]), + Pattern("Claim Check", PatternFamily.EnterpriseIntegration, "docs/patterns/messaging/claim-check.md", "src/PatternKit.Core/Messaging/Transformation/ClaimCheck.cs", diff --git a/src/PatternKit.Generators.Abstractions/EnterpriseIntegration/EventCarriedStateTransferAttributes.cs b/src/PatternKit.Generators.Abstractions/EnterpriseIntegration/EventCarriedStateTransferAttributes.cs new file mode 100644 index 00000000..2025cced --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/EnterpriseIntegration/EventCarriedStateTransferAttributes.cs @@ -0,0 +1,30 @@ +namespace PatternKit.Generators.EventCarriedStateTransfer; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class GenerateEventCarriedStateTransferAttribute(Type eventType, Type keyType, Type stateType) : Attribute +{ + public Type EventType { get; } = eventType ?? throw new ArgumentNullException(nameof(eventType)); + + public Type KeyType { get; } = keyType ?? throw new ArgumentNullException(nameof(keyType)); + + public Type StateType { get; } = stateType ?? throw new ArgumentNullException(nameof(stateType)); + + public string FactoryMethodName { get; set; } = "Create"; + + public string TransferName { get; set; } = "event-carried-state-transfer"; +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class EventCarriedStateKeyAttribute : Attribute +{ +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class EventCarriedStateVersionAttribute : Attribute +{ +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class EventCarriedStateMapperAttribute : Attribute +{ +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 00138747..f1e4c843 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -334,6 +334,9 @@ PKCAD005 | PatternKit.Generators.Messaging | Error | Channel Adapter outbound tr PKCDM001 | PatternKit.Generators.CanonicalDataModel | Error | Canonical Data Model host must be partial. PKCDM002 | PatternKit.Generators.CanonicalDataModel | Error | Canonical Data Model mapper is missing. PKCDM003 | PatternKit.Generators.CanonicalDataModel | Error | Canonical Data Model mapper signature is invalid. +PKECST001 | PatternKit.Generators.EventCarriedStateTransfer | Error | Event-Carried State Transfer host must be partial. +PKECST002 | PatternKit.Generators.EventCarriedStateTransfer | Error | Event-Carried State Transfer methods are missing. +PKECST003 | PatternKit.Generators.EventCarriedStateTransfer | Error | Event-Carried State Transfer method signature is invalid. PKGWY001 | PatternKit.Generators.Messaging | Error | Messaging Gateway host type must be partial. PKGWY002 | PatternKit.Generators.Messaging | Error | Messaging Gateway must declare exactly one handler. PKGWY003 | PatternKit.Generators.Messaging | Error | Messaging Gateway handler signature is invalid. diff --git a/src/PatternKit.Generators/EventCarriedStateTransfer/EventCarriedStateTransferGenerator.cs b/src/PatternKit.Generators/EventCarriedStateTransfer/EventCarriedStateTransferGenerator.cs new file mode 100644 index 00000000..ab9a1611 --- /dev/null +++ b/src/PatternKit.Generators/EventCarriedStateTransfer/EventCarriedStateTransferGenerator.cs @@ -0,0 +1,183 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.EventCarriedStateTransfer; + +[Generator] +public sealed class EventCarriedStateTransferGenerator : IIncrementalGenerator +{ + private const string AttributeName = "PatternKit.Generators.EventCarriedStateTransfer.GenerateEventCarriedStateTransferAttribute"; + private const string KeyAttributeName = "PatternKit.Generators.EventCarriedStateTransfer.EventCarriedStateKeyAttribute"; + private const string VersionAttributeName = "PatternKit.Generators.EventCarriedStateTransfer.EventCarriedStateVersionAttribute"; + private const string MapperAttributeName = "PatternKit.Generators.EventCarriedStateTransfer.EventCarriedStateMapperAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKECST001", "Event-Carried State Transfer host must be partial", + "Type '{0}' is marked with [GenerateEventCarriedStateTransfer] but is not declared as partial", + "PatternKit.Generators.EventCarriedStateTransfer", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingMember = new( + "PKECST002", "Event-Carried State Transfer methods are missing", + "Event-Carried State Transfer type '{0}' must declare exactly one key selector, one version selector, and one state mapper", + "PatternKit.Generators.EventCarriedStateTransfer", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidMember = new( + "PKECST003", "Event-Carried State Transfer method signature is invalid", + "Event-Carried State Transfer method '{0}' has an invalid static signature for the configured event, key, version, or state type", + "PatternKit.Generators.EventCarriedStateTransfer", DiagnosticSeverity.Error, true); + + private static readonly SymbolDisplayFormat TypeFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + AttributeName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(static a => a.AttributeClass?.ToDisplayString() == AttributeName); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate(SourceProductionContext context, INamedTypeSymbol type, TypeDeclarationSyntax node, AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var eventType = attribute.ConstructorArguments.Length >= 1 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + var keyType = attribute.ConstructorArguments.Length >= 2 ? attribute.ConstructorArguments[1].Value as INamedTypeSymbol : null; + var stateType = attribute.ConstructorArguments.Length >= 3 ? attribute.ConstructorArguments[2].Value as INamedTypeSymbol : null; + if (eventType is null || keyType is null || stateType is null) + return; + + var keySelectors = MembersWith(type, KeyAttributeName); + var versionSelectors = MembersWith(type, VersionAttributeName); + var mappers = MembersWith(type, MapperAttributeName); + if (keySelectors.Length != 1 || versionSelectors.Length != 1 || mappers.Length != 1) + { + context.ReportDiagnostic(Diagnostic.Create(MissingMember, node.Identifier.GetLocation(), type.Name)); + return; + } + + if (!IsSelector(keySelectors[0], eventType, keyType) || + !IsVersionSelector(versionSelectors[0], eventType) || + !IsSelector(mappers[0], eventType, stateType)) + { + var invalid = + !IsSelector(keySelectors[0], eventType, keyType) ? keySelectors[0] : + !IsVersionSelector(versionSelectors[0], eventType) ? versionSelectors[0] : + mappers[0]; + context.ReportDiagnostic(Diagnostic.Create(InvalidMember, invalid.Locations.FirstOrDefault(), invalid.Name)); + return; + } + + context.AddSource($"{type.Name}.EventCarriedStateTransfer.g.cs", SourceText.From(GenerateSource( + type, + eventType, + keyType, + stateType, + keySelectors[0].Name, + versionSelectors[0].Name, + mappers[0].Name, + GetNamedString(attribute, "FactoryMethodName") ?? "Create", + GetNamedString(attribute, "TransferName") ?? "event-carried-state-transfer"), Encoding.UTF8)); + } + + private static IMethodSymbol[] MembersWith(INamedTypeSymbol type, string attributeName) + => type.GetMembers().OfType() + .Where(method => method.GetAttributes().Any(attr => attr.AttributeClass?.ToDisplayString() == attributeName)) + .ToArray(); + + private static bool IsSelector(IMethodSymbol method, INamedTypeSymbol eventType, ITypeSymbol returnType) + => method.IsStatic && + SymbolEqualityComparer.Default.Equals(method.ReturnType, returnType) && + method.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, eventType); + + private static bool IsVersionSelector(IMethodSymbol method, INamedTypeSymbol eventType) + => method.IsStatic && + method.ReturnType.SpecialType == SpecialType.System_Int64 && + method.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, eventType); + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol eventType, + INamedTypeSymbol keyType, + INamedTypeSymbol stateType, + string keySelectorName, + string versionSelectorName, + string mapperName, + string factoryMethodName, + string transferName) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var eventTypeName = eventType.ToDisplayString(TypeFormat); + var keyTypeName = keyType.ToDisplayString(TypeFormat); + var stateTypeName = stateType.ToDisplayString(TypeFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + if (type.IsStatic) + sb.Append("static "); + else if (type.IsAbstract && type.TypeKind == TypeKind.Class) + sb.Append("abstract "); + else if (type.IsSealed && type.TypeKind == TypeKind.Class) + sb.Append("sealed "); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + sb.AppendLine("{"); + sb.Append(" public static global::PatternKit.EnterpriseIntegration.EventCarriedStateTransfer.EventCarriedStateTransfer<") + .Append(eventTypeName).Append(", ").Append(keyTypeName).Append(", ").Append(stateTypeName).Append("> ") + .Append(factoryMethodName).AppendLine("()"); + sb.AppendLine(" {"); + sb.Append(" return global::PatternKit.EnterpriseIntegration.EventCarriedStateTransfer.EventCarriedStateTransfer<") + .Append(eventTypeName).Append(", ").Append(keyTypeName).Append(", ").Append(stateTypeName) + .Append(">.Create(\"").Append(Escape(transferName)).AppendLine("\")"); + sb.Append(" .WithKey(").Append(keySelectorName).AppendLine(")"); + sb.Append(" .WithVersion(").Append(versionSelectorName).AppendLine(")"); + sb.Append(" .WithState(").Append(mapperName).AppendLine(")"); + sb.AppendLine(" .Build();"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; +} diff --git a/test/PatternKit.Examples.Tests/EventCarriedStateTransferDemo/InventoryEventCarriedStateTransferDemoTests.cs b/test/PatternKit.Examples.Tests/EventCarriedStateTransferDemo/InventoryEventCarriedStateTransferDemoTests.cs new file mode 100644 index 00000000..65f356c7 --- /dev/null +++ b/test/PatternKit.Examples.Tests/EventCarriedStateTransferDemo/InventoryEventCarriedStateTransferDemoTests.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.EventCarriedStateTransferDemo; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.EventCarriedStateTransferDemo; + +[Feature("Inventory Event-Carried State Transfer example")] +public sealed class InventoryEventCarriedStateTransferDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent and generated paths project carried inventory state")] + [Fact] + public Task Fluent_And_Generated_Paths_Project_Carried_Inventory_State() + => Given("an inventory event", () => new InventoryAdjustedEvent("SKU-100", 12, "CHI-01", 4)) + .When("fluent and generated transfers project the event", evt => new + { + Fluent = InventoryEventCarriedStateTransferDemoRunner.RunFluent(), + Generated = BuildServiceProvider().GetRequiredService().RunGenerated(evt) + }) + .Then("both paths update the read model without a source service callback", result => + { + ScenarioExpect.Equal("SKU-100", result.Fluent.Sku); + ScenarioExpect.Equal(12, result.Fluent.QuantityOnHand); + ScenarioExpect.Equal("CHI-01", result.Generated.Warehouse); + ScenarioExpect.Equal(4L, result.Generated.Version); + }) + .AssertPassed(); + + [Scenario("Inventory state transfer is importable through AddPatternKitExamples")] + [Fact] + public Task Inventory_State_Transfer_Is_Importable_Through_AddPatternKitExamples() + => Given("the aggregate PatternKit example registration", BuildAggregateProvider) + .When("the inventory state transfer example is resolved", provider => provider.GetRequiredService()) + .Then("the runner and service are available through standard IoC", example => + { + var summary = example.Runner.RunGenerated(new InventoryAdjustedEvent("SKU-200", 25, "DFW-02", 9)); + ScenarioExpect.Equal("SKU-200", summary.Sku); + ScenarioExpect.Equal(25, summary.QuantityOnHand); + ScenarioExpect.NotNull(example.Service); + }) + .AssertPassed(); + + private static ServiceProvider BuildServiceProvider() + => new ServiceCollection() + .AddInventoryEventCarriedStateTransferDemo() + .BuildServiceProvider(); + + private static ServiceProvider BuildAggregateProvider() + => new ServiceCollection() + .AddPatternKitExamples() + .BuildServiceProvider(); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 347d4a98..00314b96 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -47,6 +47,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Message Envelope", "Message Translator", "Canonical Data Model", + "Event-Carried State Transfer", "Claim Check", "Dead Letter Channel", "Content-Based Router", @@ -133,7 +134,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() ScenarioExpect.Equal(EnterprisePatternAdditions.OrderBy(static x => x), patterns.Select(static p => p.Name).OrderBy(static x => x))) .And("enterprise entries are grouped by integration reliability and architecture families", patterns => { - ScenarioExpect.Equal(28, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); + ScenarioExpect.Equal(29, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(9, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); ScenarioExpect.Equal(15, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index 973a51c9..9a5f17c7 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -12,6 +12,7 @@ using PatternKit.Generators.DataMapping; using PatternKit.Generators.Decorator; using PatternKit.Generators.DomainEvents; +using PatternKit.Generators.EventCarriedStateTransfer; using PatternKit.Generators.EventSourcing; using PatternKit.Generators.Facade; using PatternKit.Generators.FeatureToggles; @@ -78,6 +79,10 @@ private enum TestTrigger { typeof(CacheAsidePredicateAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateCanonicalDataModelAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(CanonicalDataModelMapperAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateEventCarriedStateTransferAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(EventCarriedStateKeyAttribute), AttributeTargets.Method, false, false }, + { typeof(EventCarriedStateVersionAttribute), AttributeTargets.Method, false, false }, + { typeof(EventCarriedStateMapperAttribute), AttributeTargets.Method, false, false }, { typeof(GenerateExternalConfigurationStoreAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(ExternalConfigurationLoaderAttribute), AttributeTargets.Method, false, false }, { typeof(ExternalConfigurationValidatorAttribute), AttributeTargets.Method, false, false }, @@ -416,6 +421,29 @@ public void CanonicalDataModel_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.IsType(new CanonicalDataModelMapperAttribute()); } + [Scenario("Event-Carried State Transfer Attributes Expose Defaults And Configuration")] + [Fact] + public void EventCarriedStateTransfer_Attributes_Expose_Defaults_And_Configuration() + { + var transfer = new GenerateEventCarriedStateTransferAttribute(typeof(string), typeof(Guid), typeof(int)) + { + FactoryMethodName = "BuildInventoryState", + TransferName = "inventory-state" + }; + + ScenarioExpect.Equal(typeof(string), transfer.EventType); + ScenarioExpect.Equal(typeof(Guid), transfer.KeyType); + ScenarioExpect.Equal(typeof(int), transfer.StateType); + ScenarioExpect.Equal("BuildInventoryState", transfer.FactoryMethodName); + ScenarioExpect.Equal("inventory-state", transfer.TransferName); + ScenarioExpect.Throws(() => new GenerateEventCarriedStateTransferAttribute(null!, typeof(Guid), typeof(int))); + ScenarioExpect.Throws(() => new GenerateEventCarriedStateTransferAttribute(typeof(string), null!, typeof(int))); + ScenarioExpect.Throws(() => new GenerateEventCarriedStateTransferAttribute(typeof(string), typeof(Guid), null!)); + ScenarioExpect.IsType(new EventCarriedStateKeyAttribute()); + ScenarioExpect.IsType(new EventCarriedStateVersionAttribute()); + ScenarioExpect.IsType(new EventCarriedStateMapperAttribute()); + } + [Scenario("Priority Queue Attributes Expose Defaults And Configuration")] [Fact] public void PriorityQueue_Attributes_Expose_Defaults_And_Configuration() diff --git a/test/PatternKit.Generators.Tests/EventCarriedStateTransferGeneratorTests.cs b/test/PatternKit.Generators.Tests/EventCarriedStateTransferGeneratorTests.cs new file mode 100644 index 00000000..04cc8b25 --- /dev/null +++ b/test/PatternKit.Generators.Tests/EventCarriedStateTransferGeneratorTests.cs @@ -0,0 +1,95 @@ +using Microsoft.CodeAnalysis; +using PatternKit.EnterpriseIntegration.EventCarriedStateTransfer; +using PatternKit.Generators.EventCarriedStateTransfer; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Event-Carried State Transfer generator")] +public sealed partial class EventCarriedStateTransferGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates event-carried state transfer factory")] + [Fact] + public Task Generates_Event_Carried_State_Transfer_Factory() + => Given("an event-carried state transfer declaration", () => Compile(""" + using PatternKit.Generators.EventCarriedStateTransfer; + namespace Demo; + public sealed record ProductStockChanged(string Sku, int QuantityOnHand, long Sequence); + public sealed record ProductInventoryState(string Sku, int QuantityOnHand); + [GenerateEventCarriedStateTransfer(typeof(ProductStockChanged), typeof(string), typeof(ProductInventoryState), FactoryMethodName = "Build", TransferName = "inventory-state")] + public static partial class ProductInventoryStateTransfer + { + [EventCarriedStateKey] + private static string Key(ProductStockChanged evt) => evt.Sku; + [EventCarriedStateVersion] + private static long Version(ProductStockChanged evt) => evt.Sequence; + [EventCarriedStateMapper] + private static ProductInventoryState Map(ProductStockChanged evt) => new(evt.Sku, evt.QuantityOnHand); + } + """)) + .Then("the generated source creates the configured transfer", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("Build()", source); + ScenarioExpect.Contains("EventCarriedStateTransfer.Create(\"inventory-state\")", source); + ScenarioExpect.Contains(".WithKey(Key)", source); + ScenarioExpect.Contains(".WithVersion(Version)", source); + ScenarioExpect.Contains(".WithState(Map)", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid event-carried state transfer declarations")] + [Fact] + public Task Reports_Diagnostics_For_Invalid_Event_Carried_State_Transfer_Declarations() + => Given("invalid event-carried state transfer declarations", () => new[] + { + Compile(""" + using PatternKit.Generators.EventCarriedStateTransfer; + [GenerateEventCarriedStateTransfer(typeof(string), typeof(string), typeof(int))] + public static class TransferHost; + """), + Compile(""" + using PatternKit.Generators.EventCarriedStateTransfer; + [GenerateEventCarriedStateTransfer(typeof(string), typeof(string), typeof(int))] + public static partial class TransferHost; + """), + Compile(""" + using PatternKit.Generators.EventCarriedStateTransfer; + [GenerateEventCarriedStateTransfer(typeof(string), typeof(string), typeof(int))] + public static partial class TransferHost + { + [EventCarriedStateKey] + private static string Key(string value) => value; + [EventCarriedStateVersion] + private static string Version(string value) => value; + [EventCarriedStateMapper] + private static int Map(string value) => value.Length; + } + """) + }) + .Then("diagnostics identify invalid declarations", results => + { + ScenarioExpect.Contains(results[0].Diagnostics, diagnostic => diagnostic.Id == "PKECST001"); + ScenarioExpect.Contains(results[1].Diagnostics, diagnostic => diagnostic.Id == "PKECST002"); + ScenarioExpect.Contains(results[2].Diagnostics, diagnostic => diagnostic.Id == "PKECST003"); + }) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "EventCarriedStateTransferGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(EventCarriedStateTransfer<,,>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new EventCarriedStateTransferGenerator(), out var run, out var updated); + var result = run.Results.Single(); + var emit = updated.Emit(Stream.Null); + return new(result.Diagnostics.ToArray(), result.GeneratedSources.Select(static source => source.SourceText.ToString()).ToArray(), emit.Success, emit.Diagnostics.Select(static diagnostic => diagnostic.ToString()).ToArray()); + } + + private sealed record GeneratorResult(IReadOnlyList Diagnostics, IReadOnlyList GeneratedSources, bool EmitSuccess, IReadOnlyList EmitDiagnostics); +} diff --git a/test/PatternKit.Tests/EnterpriseIntegration/EventCarriedStateTransfer/EventCarriedStateTransferTests.cs b/test/PatternKit.Tests/EnterpriseIntegration/EventCarriedStateTransfer/EventCarriedStateTransferTests.cs new file mode 100644 index 00000000..9bb2230b --- /dev/null +++ b/test/PatternKit.Tests/EnterpriseIntegration/EventCarriedStateTransfer/EventCarriedStateTransferTests.cs @@ -0,0 +1,73 @@ +using PatternKit.EnterpriseIntegration.EventCarriedStateTransfer; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.EnterpriseIntegration.EventCarriedStateTransfer; + +[Feature("Event-Carried State Transfer")] +public sealed class EventCarriedStateTransferTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Event-carried state transfer extracts keyed state from an event")] + [Fact] + public Task Event_Carried_State_Transfer_Extracts_Keyed_State_From_An_Event() + => Given("an inventory state transfer", CreateTransfer) + .When("a product stock event is transferred", transfer => transfer.Transfer(new ProductStockChanged("SKU-100", 12, 4))) + .Then("subscribers receive the state and version without a source service callback", result => + { + ScenarioExpect.True(result.Transferred); + ScenarioExpect.Equal("inventory-state", result.TransferName); + ScenarioExpect.Equal("SKU-100", result.Key); + ScenarioExpect.Equal(4L, result.Version); + ScenarioExpect.Equal(12, result.State!.QuantityOnHand); + }) + .AssertPassed(); + + [Scenario("Event-carried state transfer reports mapper failures")] + [Fact] + public Task Event_Carried_State_Transfer_Reports_Mapper_Failures() + => Given("a transfer with a failing mapper", () => EventCarriedStateTransfer.Create("inventory-state") + .WithKey(static evt => evt.Sku) + .WithVersion(static evt => evt.Sequence) + .WithState(static _ => throw new InvalidOperationException("bad payload")) + .Build()) + .When("the event is transferred", transfer => transfer.Transfer(new ProductStockChanged("SKU-100", 12, 4))) + .Then("the failure is explicit", result => + { + ScenarioExpect.True(result.Failed); + ScenarioExpect.Equal("bad payload", result.Exception!.Message); + }) + .AssertPassed(); + + [Scenario("Event-carried state transfer validates configuration")] + [Fact] + public Task Event_Carried_State_Transfer_Validates_Configuration() + => Given("invalid transfer configuration", () => true) + .Then("invalid names are rejected", _ => + ScenarioExpect.Throws(() => EventCarriedStateTransfer.Create("") + .WithKey(static evt => evt.Sku) + .WithVersion(static evt => evt.Sequence) + .WithState(ToState) + .Build())) + .And("missing selectors are rejected", _ => + ScenarioExpect.Throws(() => EventCarriedStateTransfer.Create().Build())) + .And("null selectors are rejected", _ => + ScenarioExpect.Throws(() => EventCarriedStateTransfer.Create().WithKey(null!))) + .And("null events are rejected", _ => + ScenarioExpect.Throws(() => CreateTransfer().Transfer(null!))) + .AssertPassed(); + + private static EventCarriedStateTransfer CreateTransfer() + => EventCarriedStateTransfer.Create("inventory-state") + .WithKey(static evt => evt.Sku) + .WithVersion(static evt => evt.Sequence) + .WithState(ToState) + .Build(); + + private static ProductInventoryState ToState(ProductStockChanged evt) + => new(evt.Sku, evt.QuantityOnHand); + + private sealed record ProductStockChanged(string Sku, int QuantityOnHand, long Sequence); + + private sealed record ProductInventoryState(string Sku, int QuantityOnHand); +}