diff --git a/docs/examples/order-materialized-view-pattern.md b/docs/examples/order-materialized-view-pattern.md new file mode 100644 index 0000000..83b48ed --- /dev/null +++ b/docs/examples/order-materialized-view-pattern.md @@ -0,0 +1,28 @@ +# Order Materialized View Pattern + +This production-shaped example builds an order read model from placed, paid, and shipped events. + +It demonstrates: + +- fluent `MaterializedView` construction +- generated materialized view factory with `[GenerateMaterializedView]` +- deterministic projection handler ordering +- singleton `IMaterializedView` registration through `IServiceCollection` + +```csharp +var services = new ServiceCollection(); +services.AddOrderMaterializedViewDemo(); + +using var provider = services.BuildServiceProvider(); +using var scope = provider.CreateScope(); + +var workflow = scope.ServiceProvider.GetRequiredService(); +var summary = await workflow.BuildReadModelAsync(events); +``` + +The registered view is singleton because it is stateless; importing applications can register consuming projection workflows with the lifetime that matches their event store, inbox, hosted consumer, tenant, or ASP.NET Core request services. + +Files: + +- `src/PatternKit.Examples/MaterializedViewDemo/OrderMaterializedViewDemo.cs` +- `test/PatternKit.Examples.Tests/MaterializedViewDemo/OrderMaterializedViewDemoTests.cs` diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 170b368..c59507e 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -112,6 +112,9 @@ - name: Order Audit Log Pattern href: order-audit-log-pattern.md +- name: Order Materialized View Pattern + href: order-materialized-view-pattern.md + - name: Generated Mailbox href: generated-mailbox.md diff --git a/docs/generators/index.md b/docs/generators/index.md index 10b67b8..d9b3bd7 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -65,6 +65,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Unit of Work**](unit-of-work.md) | Ordered commit and rollback units | `[GenerateUnitOfWork]` | | [**Data Mapper**](data-mapper.md) | Domain/data model mapper factories | `[GenerateDataMapper]` | | [**Identity Map**](identity-map.md) | Scoped object identity caches from key selectors | `[GenerateIdentityMap]` | +| [**Materialized View**](materialized-view.md) | Event projection read-model factories from handlers | `[GenerateMaterializedView]` | | [**Transaction Script**](transaction-script.md) | Typed application workflow factories | `[GenerateTransactionScript]` | | [**Service Layer**](service-layer.md) | Application operation boundary factories | `[GenerateServiceLayerOperation]` | | [**Domain Event**](domain-event.md) | Domain event dispatcher factories | `[GenerateDomainEventDispatcher]` | diff --git a/docs/generators/materialized-view.md b/docs/generators/materialized-view.md new file mode 100644 index 0000000..7e7b642 --- /dev/null +++ b/docs/generators/materialized-view.md @@ -0,0 +1,28 @@ +# Materialized View Generator + +`GenerateMaterializedViewAttribute` creates a typed `MaterializedView` factory from annotated projection handler methods. + +```csharp +[GenerateMaterializedView(typeof(OrderReadModel), typeof(OrderEvent), FactoryName = "CreateView", ViewName = "order-read-model")] +public static partial class GeneratedOrderMaterializedView +{ + [MaterializedViewHandler(typeof(OrderPlaced), Order = 10)] + private static OrderReadModel ApplyPlaced(OrderReadModel state, OrderPlaced @event) + => state with { OrderId = @event.OrderId }; +} +``` + +The generated factory is equivalent to: + +```csharp +MaterializedView + .Create("order-read-model") + .WithHandler(ApplyPlaced, 10) + .Build(); +``` + +Diagnostics: + +- `PKMV001`: host type must be partial. +- `PKMV002`: at least one `[MaterializedViewHandler]` method is required. +- `PKMV003`: handler methods must be static and return the state type, or `ValueTask` with a `CancellationToken`. diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index b1607aa..3535553 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -73,6 +73,9 @@ - name: Identity Map href: identity-map.md +- name: Materialized View + href: materialized-view.md + - name: Iterator href: iterator.md diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 39b1f3f..df2f7a1 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -78,6 +78,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Application Architecture | Event Sourcing | `IEventStore` and `InMemoryEventStore` | Event Sourcing generator | | Application Architecture | Feature Toggle | `IFeatureToggleSet` and `FeatureToggleSet` | Feature Toggle generator | | Application Architecture | Audit Log | `IAuditLog` and `InMemoryAuditLog` | Audit Log generator | +| Application Architecture | Materialized View | `IMaterializedView` and `MaterializedView` | Materialized View generator | | Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer` | Anti-Corruption Layer generator | ## Research Baselines diff --git a/docs/patterns/application/materialized-view.md b/docs/patterns/application/materialized-view.md new file mode 100644 index 0000000..b8f7d4d --- /dev/null +++ b/docs/patterns/application/materialized-view.md @@ -0,0 +1,24 @@ +# Materialized View + +Materialized View builds a query-optimized read model by replaying domain or integration events. Use it when command-side state is not shaped for screens, reports, APIs, or search endpoints. + +PatternKit provides `IMaterializedView` and `MaterializedView` in `PatternKit.Application.MaterializedViews`. + +```csharp +var view = MaterializedView + .Create("order-read-model") + .WithHandler((state, e) => state with { OrderId = e.OrderId }) + .WithHandler((state, _) => state with { Status = "Paid" }) + .Build(); + +var projected = await view.ProjectAsync(OrderReadModel.Empty("order-read-model"), events); +``` + +Handlers are ordered, deterministic, cancellation-aware, and can be synchronous or asynchronous. Register `IMaterializedView` through `IServiceCollection` when projection workflows need to compose with event stores, inboxes, hosted services, or ASP.NET Core request scopes. + +Use the source-generated path when the read model and event handlers are stable. + +See also: + +- [Materialized View generator](../../generators/materialized-view.md) +- [Order Materialized View example](../../examples/order-materialized-view-pattern.md) diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index d045e24..01c9754 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -355,6 +355,8 @@ href: application/feature-toggle.md - name: Audit Log href: application/audit-log.md + - name: Materialized View + href: application/materialized-view.md - name: Specification href: application/specification.md - name: Type-Dispatcher diff --git a/src/PatternKit.Core/Application/MaterializedViews/MaterializedView.cs b/src/PatternKit.Core/Application/MaterializedViews/MaterializedView.cs new file mode 100644 index 0000000..5cabba4 --- /dev/null +++ b/src/PatternKit.Core/Application/MaterializedViews/MaterializedView.cs @@ -0,0 +1,129 @@ +namespace PatternKit.Application.MaterializedViews; + +public interface IMaterializedView +{ + string Name { get; } + + ValueTask ProjectAsync(TState initialState, IEnumerable events, CancellationToken cancellationToken = default); +} + +public sealed class MaterializedView : IMaterializedView +{ + private readonly HandlerRegistration[] _handlers; + + private MaterializedView(string name, IEnumerable handlers) + { + Name = name; + _handlers = handlers + .OrderBy(static handler => handler.Order) + .ThenBy(static handler => handler.Index) + .ToArray(); + } + + public string Name { get; } + + public static Builder Create(string name) => new(name); + + public async ValueTask ProjectAsync(TState initialState, IEnumerable events, CancellationToken cancellationToken = default) + { + if (events is null) + throw new ArgumentNullException(nameof(events)); + + var state = initialState; + foreach (var @event in events) + { + cancellationToken.ThrowIfCancellationRequested(); + if (@event is null) + throw new ArgumentException("Materialized view event stream cannot contain null events.", nameof(events)); + + var eventType = @event.GetType(); + foreach (var handler in _handlers) + { + if (!handler.EventType.IsAssignableFrom(eventType)) + continue; + + state = await handler.ApplyAsync(state, @event, cancellationToken).ConfigureAwait(false); + } + } + + return state; + } + + public sealed class Builder + { + private readonly List _handlers = new(); + private readonly string _name; + + internal Builder(string name) + { + _name = string.IsNullOrWhiteSpace(name) + ? throw new ArgumentException("Materialized view name is required.", nameof(name)) + : name; + } + + public Builder WithHandler(Func handler, int order = 0) + where TSpecificEvent : TEvent + { + if (handler is null) + throw new ArgumentNullException(nameof(handler)); + + _handlers.Add(new HandlerRegistration( + typeof(TSpecificEvent), + order, + _handlers.Count, + (state, @event, _) => @event is TSpecificEvent specificEvent + ? new ValueTask(handler(state, specificEvent)) + : throw new InvalidOperationException("Materialized view handler received an incompatible event."))); + return this; + } + + public Builder WithAsyncHandler( + Func> handler, + int order = 0) + where TSpecificEvent : TEvent + { + if (handler is null) + throw new ArgumentNullException(nameof(handler)); + + _handlers.Add(new HandlerRegistration( + typeof(TSpecificEvent), + order, + _handlers.Count, + (state, @event, cancellationToken) => @event is TSpecificEvent specificEvent + ? handler(state, specificEvent, cancellationToken) + : throw new InvalidOperationException("Materialized view handler received an incompatible event."))); + return this; + } + + public MaterializedView Build() + { + if (_handlers.Count == 0) + throw new InvalidOperationException("Materialized view requires at least one event handler."); + + return new MaterializedView(_name, _handlers); + } + } + + private sealed class HandlerRegistration + { + public HandlerRegistration( + Type eventType, + int order, + int index, + Func> applyAsync) + { + EventType = eventType; + Order = order; + Index = index; + ApplyAsync = applyAsync; + } + + public Type EventType { get; } + + public int Order { get; } + + public int Index { get; } + + public Func> ApplyAsync { get; } + } +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index 26419c5..10e5cf0 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -33,6 +33,7 @@ using PatternKit.Examples.Generators.Builders.CorporateApplicationBuilderDemo; using PatternKit.Examples.Generators.Visitors; using PatternKit.Examples.IdentityMapDemo; +using PatternKit.Examples.MaterializedViewDemo; using PatternKit.Examples.MementoDemo; using PatternKit.Examples.Messaging; using PatternKit.Examples.ObserverDemo; @@ -142,6 +143,7 @@ public sealed record OrderTableDataGatewayPatternExample(OrderTableDataGatewayDe public sealed record OrderEventSourcingPatternExample(OrderEventSourcingDemoRunner Runner); public sealed record CheckoutFeatureTogglePatternExample(CheckoutFeatureToggleDemoRunner Runner); public sealed record OrderAuditLogPatternExample(OrderAuditLogDemoRunner Runner); +public sealed record OrderMaterializedViewPatternExample(OrderMaterializedViewDemoRunner Runner, OrderMaterializedViewWorkflow Workflow); public sealed record PrototypeGameCharacterFactoryExample(Prototype Factory); public sealed record ProxyPatternDemonstrationsExample(Proxy RemoteProxy, Proxy<(string To, string Subject, string Body), bool> EmailProxy); public sealed record FlyweightGlyphCacheExample(Func> RenderSentence); @@ -209,6 +211,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddOrderEventSourcingPatternExample() .AddCheckoutFeatureTogglePatternExample() .AddOrderAuditLogPatternExample() + .AddOrderMaterializedViewPatternExample() .AddPrototypeGameCharacterFactoryExample() .AddProxyPatternDemonstrationsExample() .AddFlyweightGlyphCacheExample() @@ -624,6 +627,15 @@ public static IServiceCollection AddOrderAuditLogPatternExample(this IServiceCol return services.RegisterExample("Order Audit Log Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddOrderMaterializedViewPatternExample(this IServiceCollection services) + { + services.AddOrderMaterializedViewDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + sp.GetRequiredService())); + return services.RegisterExample("Order Materialized View Pattern", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + public static IServiceCollection AddPrototypeGameCharacterFactoryExample(this IServiceCollection services) { services.AddSingleton(_ => PrototypeDemo.PrototypeDemo.CreateCharacterFactory()); diff --git a/src/PatternKit.Examples/MaterializedViewDemo/OrderMaterializedViewDemo.cs b/src/PatternKit.Examples/MaterializedViewDemo/OrderMaterializedViewDemo.cs new file mode 100644 index 0000000..92ef16d --- /dev/null +++ b/src/PatternKit.Examples/MaterializedViewDemo/OrderMaterializedViewDemo.cs @@ -0,0 +1,173 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.MaterializedViews; +using PatternKit.Generators.MaterializedViews; + +namespace PatternKit.Examples.MaterializedViewDemo; + +public static class OrderMaterializedViewDemo +{ + public static async ValueTask RunFluentAsync() + => await RunScenarioAsync(OrderMaterializedViewPolicies.CreateFluentView(), "order-100"); + + public static async ValueTask RunGeneratedAsync() + => await RunScenarioAsync(GeneratedOrderMaterializedView.CreateView(), "order-200"); + + private static async ValueTask RunScenarioAsync( + IMaterializedView view, + string orderId) + { + var events = new OrderReadModelEvent[] + { + new OrderPlacedForReadModel(orderId, "customer-1", 125m, DateTimeOffset.UtcNow), + new OrderPaymentCapturedForReadModel(orderId, "payment-1", DateTimeOffset.UtcNow), + new OrderShippedForReadModel(orderId, "tracking-1", DateTimeOffset.UtcNow) + }; + + var projected = await view.ProjectAsync(OrderReadModel.Empty(view.Name), events).ConfigureAwait(false); + return new OrderMaterializedViewSummary( + projected.ViewName, + projected.OrderId, + projected.CustomerId, + projected.Total, + projected.Status, + projected.LastUpdatedAt, + projected.Changes.Count); + } +} + +public abstract record OrderReadModelEvent(string OrderId, DateTimeOffset OccurredAt); + +public sealed record OrderPlacedForReadModel(string OrderId, string CustomerId, decimal Total, DateTimeOffset OccurredAt) + : OrderReadModelEvent(OrderId, OccurredAt); + +public sealed record OrderPaymentCapturedForReadModel(string OrderId, string PaymentId, DateTimeOffset OccurredAt) + : OrderReadModelEvent(OrderId, OccurredAt); + +public sealed record OrderShippedForReadModel(string OrderId, string TrackingNumber, DateTimeOffset OccurredAt) + : OrderReadModelEvent(OrderId, OccurredAt); + +public sealed record OrderReadModel( + string ViewName, + string OrderId, + string CustomerId, + decimal Total, + string Status, + DateTimeOffset LastUpdatedAt, + IReadOnlyList Changes) +{ + public static OrderReadModel Empty(string viewName) + => new(viewName, "", "", 0m, "Pending", DateTimeOffset.MinValue, Array.Empty()); +} + +public sealed record OrderMaterializedViewSummary( + string ViewName, + string OrderId, + string CustomerId, + decimal Total, + string Status, + DateTimeOffset LastUpdatedAt, + int ChangeCount); + +public static class OrderMaterializedViewPolicies +{ + public static MaterializedView CreateFluentView() + => MaterializedView.Create("order-read-model") + .WithHandler(ApplyOrderPlaced) + .WithHandler(ApplyPaymentCaptured) + .WithHandler(ApplyOrderShipped) + .Build(); + + public static OrderReadModel ApplyOrderPlaced(OrderReadModel state, OrderPlacedForReadModel @event) + => state with + { + OrderId = @event.OrderId, + CustomerId = @event.CustomerId, + Total = @event.Total, + Status = "Placed", + LastUpdatedAt = @event.OccurredAt, + Changes = Append(state.Changes, "placed") + }; + + public static OrderReadModel ApplyPaymentCaptured(OrderReadModel state, OrderPaymentCapturedForReadModel @event) + => state with + { + OrderId = @event.OrderId, + Status = "Paid", + LastUpdatedAt = @event.OccurredAt, + Changes = Append(state.Changes, "paid") + }; + + public static OrderReadModel ApplyOrderShipped(OrderReadModel state, OrderShippedForReadModel @event) + => state with + { + OrderId = @event.OrderId, + Status = "Shipped", + LastUpdatedAt = @event.OccurredAt, + Changes = Append(state.Changes, "shipped") + }; + + private static IReadOnlyList Append(IReadOnlyList values, string value) + { + var next = values.ToList(); + next.Add(value); + return next; + } +} + +public sealed class OrderMaterializedViewWorkflow +{ + private readonly IMaterializedView _view; + + public OrderMaterializedViewWorkflow(IMaterializedView view) + { + _view = view; + } + + public async ValueTask BuildReadModelAsync( + IReadOnlyList events, + CancellationToken cancellationToken = default) + { + var projected = await _view.ProjectAsync(OrderReadModel.Empty(_view.Name), events, cancellationToken).ConfigureAwait(false); + return new OrderMaterializedViewSummary( + projected.ViewName, + projected.OrderId, + projected.CustomerId, + projected.Total, + projected.Status, + projected.LastUpdatedAt, + projected.Changes.Count); + } +} + +public sealed record OrderMaterializedViewDemoRunner( + Func> RunFluentAsync, + Func> RunGeneratedAsync); + +public static class OrderMaterializedViewServiceCollectionExtensions +{ + public static IServiceCollection AddOrderMaterializedViewDemo(this IServiceCollection services) + { + services.AddSingleton>(_ => OrderMaterializedViewPolicies.CreateFluentView()); + services.AddSingleton(); + services.AddSingleton(new OrderMaterializedViewDemoRunner( + OrderMaterializedViewDemo.RunFluentAsync, + OrderMaterializedViewDemo.RunGeneratedAsync)); + return services; + } +} + +[GenerateMaterializedView(typeof(OrderReadModel), typeof(OrderReadModelEvent), FactoryName = "CreateView", ViewName = "order-read-model")] +public static partial class GeneratedOrderMaterializedView +{ + [MaterializedViewHandler(typeof(OrderPlacedForReadModel), Order = 10)] + private static OrderReadModel ApplyOrderPlaced(OrderReadModel state, OrderPlacedForReadModel @event) + => OrderMaterializedViewPolicies.ApplyOrderPlaced(state, @event); + + [MaterializedViewHandler(typeof(OrderPaymentCapturedForReadModel), Order = 20)] + private static OrderReadModel ApplyPaymentCaptured(OrderReadModel state, OrderPaymentCapturedForReadModel @event) + => OrderMaterializedViewPolicies.ApplyPaymentCaptured(state, @event); + + [MaterializedViewHandler(typeof(OrderShippedForReadModel), Order = 30)] + private static OrderReadModel ApplyOrderShipped(OrderReadModel state, OrderShippedForReadModel @event) + => OrderMaterializedViewPolicies.ApplyOrderShipped(state, @event); +} diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index be44fe4..387d177 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -384,6 +384,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["AuditLog"], ["append-only order audit trail", "source-generated audit log factory", "DI composition"]), + Descriptor( + "Order Materialized View Pattern", + "src/PatternKit.Examples/MaterializedViewDemo/OrderMaterializedViewDemo.cs", + "test/PatternKit.Examples.Tests/MaterializedViewDemo/OrderMaterializedViewDemoTests.cs", + "docs/examples/order-materialized-view-pattern.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["MaterializedView"], + ["event-sourced read model", "source-generated projection factory", "DI composition"]), Descriptor( "Generated Mailbox", "src/PatternKit.Examples/Messaging/MailboxExample.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index cef7ad2..4d1bce4 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -804,6 +804,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/AuditLogDemo/OrderAuditLogDemoTests.cs", ["fluent append-only audit trail", "generated audit log factory", "DI-importable order audit workflow"]), + Pattern("Materialized View", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/materialized-view.md", + "src/PatternKit.Core/Application/MaterializedViews/MaterializedView.cs", + "test/PatternKit.Tests/Application/MaterializedViews/MaterializedViewTests.cs", + "docs/generators/materialized-view.md", + "src/PatternKit.Generators/MaterializedViews/MaterializedViewGenerator.cs", + "test/PatternKit.Generators.Tests/MaterializedViewGeneratorTests.cs", + null, + "docs/examples/order-materialized-view-pattern.md", + "src/PatternKit.Examples/MaterializedViewDemo/OrderMaterializedViewDemo.cs", + "test/PatternKit.Examples.Tests/MaterializedViewDemo/OrderMaterializedViewDemoTests.cs", + ["fluent event projection", "generated projection factory", "DI-importable read-model workflow"]), + Pattern("Anti-Corruption Layer", PatternFamily.ApplicationArchitecture, "docs/patterns/application/anti-corruption-layer.md", "src/PatternKit.Core/Application/AntiCorruption/AntiCorruptionLayer.cs", diff --git a/src/PatternKit.Generators.Abstractions/MaterializedViews/MaterializedViewAttributes.cs b/src/PatternKit.Generators.Abstractions/MaterializedViews/MaterializedViewAttributes.cs new file mode 100644 index 0000000..e19c7e5 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/MaterializedViews/MaterializedViewAttributes.cs @@ -0,0 +1,32 @@ +namespace PatternKit.Generators.MaterializedViews; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class GenerateMaterializedViewAttribute : Attribute +{ + public GenerateMaterializedViewAttribute(Type stateType, Type eventType) + { + StateType = stateType ?? throw new ArgumentNullException(nameof(stateType)); + EventType = eventType ?? throw new ArgumentNullException(nameof(eventType)); + } + + public Type StateType { get; } + + public Type EventType { get; } + + public string FactoryName { get; set; } = "Create"; + + public string ViewName { get; set; } = ""; +} + +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +public sealed class MaterializedViewHandlerAttribute : Attribute +{ + public MaterializedViewHandlerAttribute(Type eventType) + { + EventType = eventType ?? throw new ArgumentNullException(nameof(eventType)); + } + + public Type EventType { get; } + + public int Order { get; set; } +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index d2d91ab..130ab89 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -101,6 +101,9 @@ PKDE004 | PatternKit.Generators.DomainEvents | Error | Domain Event handler orde PKAUD001 | PatternKit.Generators.AuditLog | Error | Audit Log host must be partial. PKAUD002 | PatternKit.Generators.AuditLog | Error | Audit Log must declare exactly one key selector. PKAUD003 | PatternKit.Generators.AuditLog | Error | Audit Log key selector signature is invalid. +PKMV001 | PatternKit.Generators.MaterializedViews | Error | Materialized View host must be partial. +PKMV002 | PatternKit.Generators.MaterializedViews | Error | Materialized View requires handlers. +PKMV003 | PatternKit.Generators.MaterializedViews | Error | Materialized View handler signature is invalid. PKES001 | PatternKit.Generators.EventSourcing | Error | Event Store host must be partial. PKFT001 | PatternKit.Generators.FeatureToggles | Error | Feature Toggle host must be partial. PKFT002 | PatternKit.Generators.FeatureToggles | Error | Feature Toggle set must declare at least one rule. diff --git a/src/PatternKit.Generators/MaterializedViews/MaterializedViewGenerator.cs b/src/PatternKit.Generators/MaterializedViews/MaterializedViewGenerator.cs new file mode 100644 index 0000000..2950930 --- /dev/null +++ b/src/PatternKit.Generators/MaterializedViews/MaterializedViewGenerator.cs @@ -0,0 +1,213 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Immutable; +using System.Linq; +using System.Text; + +namespace PatternKit.Generators.MaterializedViews; + +[Generator] +public sealed class MaterializedViewGenerator : IIncrementalGenerator +{ + private const string GenerateAttributeName = "PatternKit.Generators.MaterializedViews.GenerateMaterializedViewAttribute"; + private const string HandlerAttributeName = "PatternKit.Generators.MaterializedViews.MaterializedViewHandlerAttribute"; + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKMV001", "Materialized View host must be partial", + "Type '{0}' is marked with [GenerateMaterializedView] but is not declared as partial", + "PatternKit.Generators.MaterializedViews", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor MissingHandlers = new( + "PKMV002", "Materialized View requires handlers", + "Type '{0}' must declare at least one [MaterializedViewHandler] method", + "PatternKit.Generators.MaterializedViews", DiagnosticSeverity.Error, true); + + private static readonly DiagnosticDescriptor InvalidHandler = new( + "PKMV003", "Materialized View handler signature is invalid", + "Method '{0}' must be static and return the state type or ValueTask of the state type with parameters ({1}, handler event) or ({1}, handler event, CancellationToken)", + "PatternKit.Generators.MaterializedViews", DiagnosticSeverity.Error, true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + GenerateAttributeName, + 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() == GenerateAttributeName); + 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 stateType = attribute.ConstructorArguments.Length > 0 ? attribute.ConstructorArguments[0].Value as INamedTypeSymbol : null; + var eventType = attribute.ConstructorArguments.Length > 1 ? attribute.ConstructorArguments[1].Value as INamedTypeSymbol : null; + if (stateType is null || eventType is null) + return; + + var handlers = GetHandlers(type, stateType, eventType, context).ToArray(); + if (handlers.Length == 0) + { + context.ReportDiagnostic(Diagnostic.Create(MissingHandlers, node.Identifier.GetLocation(), type.Name)); + return; + } + + if (handlers.Any(static handler => !handler.Valid)) + return; + + var viewName = GetNamedString(attribute, "ViewName"); + if (string.IsNullOrWhiteSpace(viewName)) + viewName = type.Name; + + var factoryName = GetNamedString(attribute, "FactoryName") ?? "Create"; + context.AddSource($"{type.Name}.MaterializedView.g.cs", SourceText.From( + GenerateSource(type, stateType, eventType, handlers, factoryName, viewName!), + Encoding.UTF8)); + } + + private static IEnumerable GetHandlers(INamedTypeSymbol type, INamedTypeSymbol stateType, INamedTypeSymbol baseEventType, SourceProductionContext context) + { + foreach (var method in type.GetMembers().OfType()) + { + var attr = method.GetAttributes().FirstOrDefault(static a => a.AttributeClass?.ToDisplayString() == HandlerAttributeName); + if (attr is null) + continue; + + var handlerEventType = attr.ConstructorArguments.Length > 0 ? attr.ConstructorArguments[0].Value as INamedTypeSymbol : null; + var order = GetNamedInt(attr, "Order") ?? 0; + var valid = IsValidHandler(method, stateType, baseEventType, handlerEventType, out var async); + if (!valid) + context.ReportDiagnostic(Diagnostic.Create(InvalidHandler, method.Locations.FirstOrDefault(), method.Name, stateType.ToDisplayString())); + + if (handlerEventType is not null) + yield return new HandlerModel(method.Name, handlerEventType, order, async, valid); + } + } + + private static bool IsValidHandler( + IMethodSymbol method, + INamedTypeSymbol stateType, + INamedTypeSymbol baseEventType, + INamedTypeSymbol? handlerEventType, + out bool async) + { + async = false; + if (!method.IsStatic || handlerEventType is null) + return false; + if (!IsAssignableTo(handlerEventType, baseEventType)) + return false; + if (method.Parameters.Length is not (2 or 3)) + return false; + if (!SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, stateType)) + return false; + if (!SymbolEqualityComparer.Default.Equals(method.Parameters[1].Type, handlerEventType)) + return false; + + if (method.Parameters.Length == 2 && SymbolEqualityComparer.Default.Equals(method.ReturnType, stateType)) + return true; + + if (method.Parameters.Length == 3 && + method.Parameters[2].Type.ToDisplayString() == "System.Threading.CancellationToken" && + method.ReturnType is INamedTypeSymbol returnType && + returnType.ConstructedFrom.ToDisplayString() == "System.Threading.Tasks.ValueTask" && + SymbolEqualityComparer.Default.Equals(returnType.TypeArguments[0], stateType)) + { + async = true; + return true; + } + + return false; + } + + private static bool IsAssignableTo(INamedTypeSymbol type, INamedTypeSymbol baseType) + { + for (ITypeSymbol? current = type; current is not null; current = current.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(current, baseType)) + return true; + } + + return type.AllInterfaces.Any(candidate => SymbolEqualityComparer.Default.Equals(candidate, baseType)); + } + + private static string GenerateSource( + INamedTypeSymbol type, + INamedTypeSymbol stateType, + INamedTypeSymbol eventType, + IReadOnlyList handlers, + string factoryName, + string viewName) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var stateName = stateType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var eventName = eventType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + 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.Application.MaterializedViews.MaterializedView<") + .Append(stateName).Append(", ").Append(eventName).Append("> ").Append(factoryName).AppendLine("()"); + sb.Append(" => global::PatternKit.Application.MaterializedViews.MaterializedView<") + .Append(stateName).Append(", ").Append(eventName).Append(">.Create(\"").Append(Escape(viewName)).AppendLine("\")"); + + foreach (var handler in handlers.OrderBy(static h => h.Order).ThenBy(static h => h.MethodName)) + { + var method = handler.Async ? "WithAsyncHandler" : "WithHandler"; + sb.Append(" .").Append(method).Append('<') + .Append(handler.EventType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .Append(">(").Append(handler.MethodName).Append(", ").Append(handler.Order).AppendLine(")"); + } + + sb.AppendLine(" .Build();"); + 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 int? GetNamedInt(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as int?; + + 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" + }; + + private sealed record HandlerModel(string MethodName, INamedTypeSymbol EventType, int Order, bool Async, bool Valid); +} diff --git a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs index 73c0d04..f3c9195 100644 --- a/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs +++ b/test/PatternKit.Examples.Tests/DependencyInjection/PatternKitExampleDependencyInjectionTests.cs @@ -8,6 +8,7 @@ using PatternKit.Examples.DependencyInjection; using PatternKit.Examples.DomainEventDemo; using PatternKit.Examples.IdentityMapDemo; +using PatternKit.Examples.MaterializedViewDemo; using PatternKit.Examples.Messaging; using PatternKit.Examples.ObserverDemo; using PatternKit.Examples.PointOfSale; @@ -117,6 +118,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() var eventSourcing = provider.GetRequiredService(); var featureToggles = provider.GetRequiredService(); var auditLog = provider.GetRequiredService(); + var materializedView = provider.GetRequiredService(); var inventoryRetry = provider.GetRequiredService(); var fulfillmentBreaker = provider.GetRequiredService(); var shippingBulkhead = provider.GetRequiredService(); @@ -198,6 +200,7 @@ public Task IoC_Registered_Examples_Can_Be_Used_By_Importing_Applications() ("event sourcing example replays paid order streams", eventSourcing.Runner.RunFluentAsync().AsTask().GetAwaiter().GetResult().Paid), ("feature toggle example evaluates checkout features", featureToggles.Runner.RunFluent().NewCheckoutEnabled), ("audit log example records order actions", auditLog.Runner.RunFluentAsync().AsTask().GetAwaiter().GetResult().EntryCount == 2), + ("materialized view example builds shipped order read models", materializedView.Runner.RunFluentAsync().AsTask().GetAwaiter().GetResult().Status == "Shipped"), ("generated retry policy recovers inventory lookups", inventoryRetry.Service.CheckAsync("SKU-42").GetAwaiter().GetResult().Available), ("generated circuit breaker isolates fulfillment outages", CircuitBreakerOpens(fulfillmentBreaker.Service)), ("generated bulkhead reserves shipping allocations", shippingBulkhead.Service.ReserveAsync("ORDER-100").GetAwaiter().GetResult().Succeeded), diff --git a/test/PatternKit.Examples.Tests/MaterializedViewDemo/OrderMaterializedViewDemoTests.cs b/test/PatternKit.Examples.Tests/MaterializedViewDemo/OrderMaterializedViewDemoTests.cs new file mode 100644 index 0000000..6f68f41 --- /dev/null +++ b/test/PatternKit.Examples.Tests/MaterializedViewDemo/OrderMaterializedViewDemoTests.cs @@ -0,0 +1,63 @@ +using PatternKit.Examples.MaterializedViewDemo; +using Microsoft.Extensions.DependencyInjection; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Examples.Tests.MaterializedViewDemo; + +[Feature("Order materialized view example")] +public sealed class OrderMaterializedViewDemoTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Fluent materialized view builds a production read model")] + [Fact] + public Task Fluent_Materialized_View_Builds_A_Production_Read_Model() + => Given("the fluent order materialized view example", () => OrderMaterializedViewDemo.RunFluentAsync().AsTask()) + .Then("the final read model is shipped", summary => + { + ScenarioExpect.Equal("order-read-model", summary.ViewName); + ScenarioExpect.Equal("Shipped", summary.Status); + ScenarioExpect.Equal(3, summary.ChangeCount); + }) + .AssertPassed(); + + [Scenario("Generated materialized view builds a production read model")] + [Fact] + public Task Generated_Materialized_View_Builds_A_Production_Read_Model() + => Given("the generated order materialized view example", () => OrderMaterializedViewDemo.RunGeneratedAsync().AsTask()) + .Then("the final read model is shipped", summary => + { + ScenarioExpect.Equal("order-read-model", summary.ViewName); + ScenarioExpect.Equal("Shipped", summary.Status); + ScenarioExpect.Equal(3, summary.ChangeCount); + }) + .AssertPassed(); + + [Scenario("Materialized view workflow is importable through IServiceCollection")] + [Fact] + public Task Materialized_View_Workflow_Is_Importable_Through_IServiceCollection() + => Given("an importing app service provider", () => + { + var services = new ServiceCollection(); + services.AddOrderMaterializedViewDemo(); + return services.BuildServiceProvider(validateScopes: true); + }) + .When("resolving and running the workflow", provider => + { + using (provider) + { + var workflow = provider.GetRequiredService(); + return workflow.BuildReadModelAsync([ + new OrderPlacedForReadModel("order-di", "customer-di", 42m, DateTimeOffset.UtcNow), + new OrderPaymentCapturedForReadModel("order-di", "payment-di", DateTimeOffset.UtcNow) + ]).AsTask(); + } + }) + .Then("the workflow projects the supplied events", summary => + { + ScenarioExpect.Equal("order-di", summary.OrderId); + ScenarioExpect.Equal("Paid", summary.Status); + ScenarioExpect.Equal(2, summary.ChangeCount); + }) + .AssertPassed(); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index b9354f9..ada10aa 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -72,6 +72,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Event Sourcing", "Feature Toggle", "Audit Log", + "Materialized View", "Anti-Corruption Layer" ]; @@ -116,7 +117,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() ScenarioExpect.Equal(13, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(5, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); - ScenarioExpect.Equal(14, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(15, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs index a92565e..a6e96a7 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsAttributeCoverageTests.cs @@ -18,6 +18,7 @@ using PatternKit.Generators.IdentityMap; using PatternKit.Generators.Interpreter; using PatternKit.Generators.Iterator; +using PatternKit.Generators.MaterializedViews; using PatternKit.Generators.Messaging; using PatternKit.Generators.Observer; using PatternKit.Generators.Prototype; @@ -116,6 +117,8 @@ private enum TestTrigger { typeof(InterpreterNonTerminalAttribute), AttributeTargets.Method, true, false }, { typeof(GenerateIdentityMapAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(IdentityMapKeySelectorAttribute), AttributeTargets.Method, false, false }, + { typeof(GenerateMaterializedViewAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, + { typeof(MaterializedViewHandlerAttribute), AttributeTargets.Method, false, false }, { typeof(IteratorAttribute), AttributeTargets.Class | AttributeTargets.Struct, false, false }, { typeof(IteratorStepAttribute), AttributeTargets.Method, false, false }, { typeof(TraversalIteratorAttribute), AttributeTargets.Class, false, false }, @@ -1076,6 +1079,15 @@ public void State_And_Template_Attributes_Expose_Defaults_And_Configuration() FactoryName = "BuildOrderAudit", LogName = "order-audit" }; + var materializedView = new GenerateMaterializedViewAttribute(typeof(int), typeof(string)) + { + FactoryName = "BuildOrderReadModel", + ViewName = "order-read-model" + }; + var materializedViewHandler = new MaterializedViewHandlerAttribute(typeof(string)) + { + Order = 30 + }; var tableGateway = new GenerateTableDataGatewayAttribute(typeof(string), typeof(int)) { FactoryName = "BuildOrderTable", @@ -1141,6 +1153,12 @@ public void State_And_Template_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.Equal(typeof(Guid), auditLog.KeyType); ScenarioExpect.Equal("BuildOrderAudit", auditLog.FactoryName); ScenarioExpect.Equal("order-audit", auditLog.LogName); + ScenarioExpect.Equal(typeof(int), materializedView.StateType); + ScenarioExpect.Equal(typeof(string), materializedView.EventType); + ScenarioExpect.Equal("BuildOrderReadModel", materializedView.FactoryName); + ScenarioExpect.Equal("order-read-model", materializedView.ViewName); + ScenarioExpect.Equal(typeof(string), materializedViewHandler.EventType); + ScenarioExpect.Equal(30, materializedViewHandler.Order); ScenarioExpect.Equal(typeof(string), tableGateway.RowType); ScenarioExpect.Equal(typeof(int), tableGateway.KeyType); ScenarioExpect.Equal("BuildOrderTable", tableGateway.FactoryName); @@ -1160,6 +1178,9 @@ public void State_And_Template_Attributes_Expose_Defaults_And_Configuration() ScenarioExpect.Throws(() => new FeatureToggleRuleAttribute("")); ScenarioExpect.Throws(() => new GenerateAuditLogAttribute(null!, typeof(Guid))); ScenarioExpect.Throws(() => new GenerateAuditLogAttribute(typeof(string), null!)); + ScenarioExpect.Throws(() => new GenerateMaterializedViewAttribute(null!, typeof(string))); + ScenarioExpect.Throws(() => new GenerateMaterializedViewAttribute(typeof(int), null!)); + ScenarioExpect.Throws(() => new MaterializedViewHandlerAttribute(null!)); ScenarioExpect.Throws(() => new GenerateTableDataGatewayAttribute(null!, typeof(int))); ScenarioExpect.Throws(() => new GenerateTableDataGatewayAttribute(typeof(string), null!)); ScenarioExpect.IsType(new TableGatewayKeySelectorAttribute()); diff --git a/test/PatternKit.Generators.Tests/MaterializedViewGeneratorTests.cs b/test/PatternKit.Generators.Tests/MaterializedViewGeneratorTests.cs new file mode 100644 index 0000000..07b4893 --- /dev/null +++ b/test/PatternKit.Generators.Tests/MaterializedViewGeneratorTests.cs @@ -0,0 +1,139 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Application.MaterializedViews; +using PatternKit.Generators.MaterializedViews; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Materialized View generator")] +public sealed partial class MaterializedViewGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generator emits materialized view factory")] + [Fact] + public Task Generator_Emits_Materialized_View_Factory() + => Given("a valid materialized view declaration", () => Compile(""" + using PatternKit.Generators.MaterializedViews; + namespace Demo; + public abstract record OrderEvent(string OrderId); + public sealed record OrderPlaced(string OrderId) : OrderEvent(OrderId); + public sealed record OrderState(string OrderId); + [GenerateMaterializedView(typeof(OrderState), typeof(OrderEvent), FactoryName = "Build", ViewName = "order-read-model")] + public static partial class OrderReadModelProjection + { + [MaterializedViewHandler(typeof(OrderPlaced), Order = 10)] + private static OrderState ApplyPlaced(OrderState state, OrderPlaced @event) => new(@event.OrderId); + } + """)) + .Then("generated source creates the view with handlers", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("Build()", source); + ScenarioExpect.Contains("MaterializedView.Create(\"order-read-model\")", source); + ScenarioExpect.Contains(".WithHandler(ApplyPlaced, 10)", source); + }) + .And("generated source compiles", result => + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics))) + .AssertPassed(); + + [Scenario("Generator emits async materialized view handlers")] + [Fact] + public Task Generator_Emits_Async_Materialized_View_Handlers() + => Given("a valid materialized view declaration with async handler", () => Compile(""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.MaterializedViews; + public abstract record OrderEvent(string OrderId); + public sealed record OrderPaid(string OrderId) : OrderEvent(OrderId); + public sealed record OrderState(string OrderId); + [GenerateMaterializedView(typeof(OrderState), typeof(OrderEvent), FactoryName = "CreateProjection")] + internal partial struct OrderProjection + { + [MaterializedViewHandler(typeof(OrderPaid), Order = 20)] + private static ValueTask ApplyPaid(OrderState state, OrderPaid @event, CancellationToken cancellationToken) + => new(new OrderState(@event.OrderId)); + } + """)) + .Then("generated source uses async handler registration", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("internal partial struct OrderProjection", source); + ScenarioExpect.Contains(".WithAsyncHandler(ApplyPaid, 20)", source); + ScenarioExpect.Contains("CreateProjection()", source); + }) + .And("generated source compiles", result => + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics))) + .AssertPassed(); + + [Scenario("Generator reports invalid materialized view declarations")] + [Fact] + public Task Generator_Reports_Invalid_Materialized_View_Declarations() + => Given("a non-partial materialized view declaration", () => Compile(""" + using PatternKit.Generators.MaterializedViews; + public abstract record OrderEvent(string OrderId); + public sealed record OrderState(string OrderId); + [GenerateMaterializedView(typeof(OrderState), typeof(OrderEvent))] + public static class OrderProjection; + """)) + .Then("the partial diagnostic is reported", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == "PKMV001")) + .AssertPassed(); + + [Scenario("Generator reports materialized view declarations without handlers")] + [Fact] + public Task Generator_Reports_Materialized_View_Declarations_Without_Handlers() + => Given("a partial materialized view declaration without handlers", () => Compile(""" + using PatternKit.Generators.MaterializedViews; + public abstract record OrderEvent(string OrderId); + public sealed record OrderState(string OrderId); + [GenerateMaterializedView(typeof(OrderState), typeof(OrderEvent))] + public static partial class OrderProjection; + """)) + .Then("the missing handlers diagnostic is reported", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == "PKMV002")) + .AssertPassed(); + + [Scenario("Generator reports invalid materialized view handlers")] + [Fact] + public Task Generator_Reports_Invalid_Materialized_View_Handlers() + => Given("a materialized view with invalid handler signature", () => Compile(""" + using PatternKit.Generators.MaterializedViews; + public abstract record OrderEvent(string OrderId); + public sealed record OrderPlaced(string OrderId) : OrderEvent(OrderId); + public sealed record OrderState(string OrderId); + [GenerateMaterializedView(typeof(OrderState), typeof(OrderEvent))] + public static partial class OrderProjection + { + [MaterializedViewHandler(typeof(OrderPlaced))] + private static string ApplyPlaced(OrderState state, OrderPlaced @event) => @event.OrderId; + } + """)) + .Then("the handler diagnostic is reported", result => + ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == "PKMV003")) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "MaterializedViewGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(MaterializedView<,>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new MaterializedViewGenerator(), out var run, out var updated); + var result = run.Results.Single(); + var emit = updated.Emit(Stream.Null); + return new GeneratorResult( + 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/Application/MaterializedViews/MaterializedViewTests.cs b/test/PatternKit.Tests/Application/MaterializedViews/MaterializedViewTests.cs new file mode 100644 index 0000000..5a6cae9 --- /dev/null +++ b/test/PatternKit.Tests/Application/MaterializedViews/MaterializedViewTests.cs @@ -0,0 +1,106 @@ +using PatternKit.Application.MaterializedViews; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Tests.Application.MaterializedViews; + +[Feature("Materialized View")] +public sealed class MaterializedViewTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Materialized view projects ordered events into a read model")] + [Fact] + public Task Materialized_View_Projects_Ordered_Events_Into_A_Read_Model() + => Given("a materialized view with order handlers", () => MaterializedView.Create("orders") + .WithHandler((state, @event) => state with { OrderId = @event.OrderId, Status = "Placed" }, order: 10) + .WithHandler((state, _) => state with { Status = "Paid" }, order: 20) + .Build()) + .When("projecting an event stream", view => view.ProjectAsync( + new OrderReadModel("", "Pending"), + new OrderEvent[] { new OrderPlaced("order-1"), new OrderPaid("order-1") }).AsTask()) + .Then("the read model contains the latest state", projected => + { + ScenarioExpect.Equal("order-1", projected.OrderId); + ScenarioExpect.Equal("Paid", projected.Status); + }) + .AssertPassed(); + + [Scenario("Materialized view applies handlers deterministically")] + [Fact] + public Task Materialized_View_Applies_Handlers_Deterministically() + => Given("a materialized view with multiple handlers for the same event", () => MaterializedView.Create("projection") + .WithHandler((state, _) => state.Append("second"), order: 20) + .WithHandler((state, _) => state.Append("first"), order: 10) + .Build()) + .When("projecting one event", view => view.ProjectAsync(new ProjectionState(Array.Empty()), [new ProjectionEvent()]).AsTask()) + .Then("handlers run by order", state => + ScenarioExpect.Equal(["first", "second"], state.Steps)) + .AssertPassed(); + + [Scenario("Materialized view supports async and base event handlers")] + [Fact] + public Task Materialized_View_Supports_Async_And_Base_Event_Handlers() + => Given("a materialized view with async and base handlers", () => MaterializedView.Create("projection") + .WithAsyncHandler((state, _, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask(state.Append("base")); + }) + .WithHandler((state, _) => state.Append("child")) + .Build()) + .When("projecting a derived event", view => view.ProjectAsync(new ProjectionState(Array.Empty()), [new ProjectionChildEvent()]).AsTask()) + .Then("both compatible handlers run", state => + ScenarioExpect.Equal(["base", "child"], state.Steps)) + .AssertPassed(); + + [Scenario("Materialized view validates configuration")] + [Fact] + public Task Materialized_View_Validates_Configuration() + => Given("invalid materialized view inputs", () => true) + .Then("blank names are rejected", _ => + ScenarioExpect.Throws(() => MaterializedView.Create(""))) + .And("null handlers are rejected", _ => + ScenarioExpect.Throws(() => MaterializedView.Create("orders") + .WithHandler(null!))) + .And("empty handler sets are rejected", _ => + ScenarioExpect.Throws(() => MaterializedView.Create("orders").Build())) + .And("null event streams are rejected", _ => AssertNullEventStreamRejectedAsync()) + .And("null events are rejected", _ => AssertNullEventRejectedAsync()) + .AssertPassed(); + + private static Task AssertNullEventStreamRejectedAsync() + => ScenarioExpect.ThrowsAsync(() => MaterializedView.Create("orders") + .WithHandler((state, _) => state) + .Build() + .ProjectAsync(new OrderReadModel("", ""), null!) + .AsTask()); + + private static Task AssertNullEventRejectedAsync() + => ScenarioExpect.ThrowsAsync(() => MaterializedView.Create("orders") + .WithHandler((state, _) => state) + .Build() + .ProjectAsync(new OrderReadModel("", ""), [null!]) + .AsTask()); + + private abstract record OrderEvent(string OrderId); + + private sealed record OrderPlaced(string OrderId) : OrderEvent(OrderId); + + private sealed record OrderPaid(string OrderId) : OrderEvent(OrderId); + + private sealed record OrderReadModel(string OrderId, string Status); + + private record ProjectionEvent; + + private sealed record ProjectionChildEvent : ProjectionEvent; + + private sealed record ProjectionState(IReadOnlyList Steps) + { + public ProjectionState Append(string step) + { + var next = Steps.ToList(); + next.Add(step); + return this with { Steps = next }; + } + } +}