From 1c92f3d4416a4ddf10f5e2cb8f42b9954371e4ba Mon Sep 17 00:00:00 2001 From: Marco Papst Date: Sun, 12 Apr 2026 23:09:38 +0200 Subject: [PATCH 1/5] Refactor EventCatalog --- .../EventCatalog/EventCatalogEntry.cs | 7 +- .../EventCatalog/EventCatalogProvider.cs | 75 +++++++++++++---- .../EventCatalog/EventCatalogRegistration.cs | 82 ++----------------- .../EventCatalog/IEventCatalog.cs | 6 +- .../EventCatalog/IEventCatalogRegistration.cs | 16 +--- .../EventCatalog/EventCatalogProviderTests.cs | 80 +++++++++--------- 6 files changed, 117 insertions(+), 149 deletions(-) diff --git a/src/Papst.EventStore/EventCatalog/EventCatalogEntry.cs b/src/Papst.EventStore/EventCatalog/EventCatalogEntry.cs index 798d79d..7462399 100644 --- a/src/Papst.EventStore/EventCatalog/EventCatalogEntry.cs +++ b/src/Papst.EventStore/EventCatalog/EventCatalogEntry.cs @@ -6,4 +6,9 @@ /// The name of the Event /// Optional description of the Event /// Optional constraints associated with the Event -public record EventCatalogEntry(string EventName, string? Description, string[]? Constraints); +/// The Json Schema of the Event as string +public record EventCatalogEntry( + string EventName, + string? Description, + string[]? Constraints, + Lazy SchemaJson); diff --git a/src/Papst.EventStore/EventCatalog/EventCatalogProvider.cs b/src/Papst.EventStore/EventCatalog/EventCatalogProvider.cs index 77ffff6..a797ae9 100644 --- a/src/Papst.EventStore/EventCatalog/EventCatalogProvider.cs +++ b/src/Papst.EventStore/EventCatalog/EventCatalogProvider.cs @@ -1,18 +1,27 @@ +using System.Collections.Frozen; using System.Linq; using Papst.EventStore.Exceptions; namespace Papst.EventStore.EventCatalog; /// -/// Default implementation of that delegates to registered instances +/// Default implementation of that materializes registered entries. /// public sealed class EventCatalogProvider : IEventCatalog { - private readonly IEnumerable _registrations; + private readonly FrozenDictionary> _registrations; public EventCatalogProvider(IEnumerable registrations) { - _registrations = registrations; + _registrations = registrations + .SelectMany(registration => registration.GetEntries()) + .GroupBy(entry => entry.Key) + .ToFrozenDictionary( + group => group.Key, + group => group + .SelectMany(entry => entry.Value) + .ToFrozenSet() + ); } /// @@ -20,10 +29,14 @@ public ValueTask> ListEvents(string? n { Type entityType = typeof(TEntity); - IReadOnlyList result = _registrations - .SelectMany(r => r.GetEntries(entityType, name, constraints)) - .ToList() - .AsReadOnly(); + if (!_registrations.TryGetValue(entityType, out FrozenSet? registrationsForEntity)) + { + return new([]); + } + + IReadOnlyList result = registrationsForEntity + .Where(entry => MatchesName(entry, name) && MatchesConstraints(entry, constraints)) + .ToArray(); return new ValueTask>(result); } @@ -31,18 +44,22 @@ public ValueTask> ListEvents(string? n /// public ValueTask GetEventDetails(string eventName) { - List matches = _registrations - .Select(r => r.GetDetails(eventName)) - .Where(d => d is not null) - .Cast() + List<(Type EntityType, EventCatalogEntry Entry)> matches = _registrations + .SelectMany(pair => pair.Value.Select(entry => (EntityType: pair.Key, Entry: entry))) + .Where(match => match.Entry.EventName == eventName) .ToList(); + if (matches.Count == 0) + { + return new ValueTask((EventCatalogEventDetails?)null); + } + if (matches.Count > 1) { - throw new EventCatalogAmbiguousEventException(eventName, matches.Count); + throw new EventCatalogAmbiguousEventException(eventName, matches.Select(match => match.EntityType), matches.Count); } - return new ValueTask(matches.FirstOrDefault()); + return new ValueTask(CreateDetails(matches[0].Entry)); } /// @@ -50,10 +67,36 @@ public ValueTask> ListEvents(string? n { Type entityType = typeof(TEntity); - EventCatalogEventDetails? result = _registrations - .Select(r => r.GetDetails(entityType, eventName)) - .FirstOrDefault(d => d is not null); + if (!_registrations.TryGetValue(entityType, out FrozenSet? registrationsForEntity)) + { + return new ValueTask((EventCatalogEventDetails?)null); + } + + EventCatalogEntry? entry = registrationsForEntity.FirstOrDefault(candidate => candidate.EventName == eventName); + + EventCatalogEventDetails? result = entry is null ? null : CreateDetails(entry); return new ValueTask(result); } + + private static bool MatchesName(EventCatalogEntry entry, string? name) + { + return name is null || entry.EventName == name; + } + + private static bool MatchesConstraints(EventCatalogEntry entry, string[]? constraints) + { + if (constraints is not { Length: > 0 }) + { + return true; + } + + return entry.Constraints is not null && + entry.Constraints.Any(constraints.Contains); + } + + private static EventCatalogEventDetails CreateDetails(EventCatalogEntry entry) + { + return new EventCatalogEventDetails(entry.EventName, entry.Description, entry.Constraints, entry.SchemaJson.Value); + } } diff --git a/src/Papst.EventStore/EventCatalog/EventCatalogRegistration.cs b/src/Papst.EventStore/EventCatalog/EventCatalogRegistration.cs index 2c5d77f..dbc468d 100644 --- a/src/Papst.EventStore/EventCatalog/EventCatalogRegistration.cs +++ b/src/Papst.EventStore/EventCatalog/EventCatalogRegistration.cs @@ -1,5 +1,4 @@ using System.Linq; -using Papst.EventStore.Exceptions; namespace Papst.EventStore.EventCatalog; @@ -8,93 +7,24 @@ namespace Papst.EventStore.EventCatalog; /// public sealed class EventCatalogRegistration : IEventCatalogRegistration { - private readonly record struct CatalogItem(string EventName, string? Description, string[]? Constraints, Lazy SchemaJson); - - private readonly Dictionary> _entityEvents = new(); + private readonly Dictionary> _entityEvents = new(); /// public void RegisterEvent(string eventName, string? description, string[]? constraints, Lazy schemaJson) { Type entityType = typeof(TEntity); - CatalogItem item = new(eventName, description, constraints, schemaJson); + EventCatalogEntry item = new(eventName, description, constraints, schemaJson); - if (!_entityEvents.TryGetValue(entityType, out List? items)) + if (!_entityEvents.TryGetValue(entityType, out var items)) { - items = new List(); + items = []; _entityEvents[entityType] = items; } items.Add(item); } - /// - public IReadOnlyList GetEntries(Type entityType, string? name, string[]? constraints) - { - if (!_entityEvents.TryGetValue(entityType, out List? items)) - { - return Array.Empty(); - } - - IEnumerable filtered = items; - - if (name is not null) - { - filtered = filtered.Where(i => i.EventName == name); - } - - if (constraints is { Length: > 0 }) - { - filtered = filtered.Where(i => - i.Constraints is not null && - i.Constraints.Any(c => constraints.Contains(c))); - } - - return filtered - .Select(i => new EventCatalogEntry(i.EventName, i.Description, i.Constraints)) - .ToList() - .AsReadOnly(); - } - - /// - public EventCatalogEventDetails? GetDetails(string eventName) - { - List<(Type EntityType, CatalogItem Item)> matches = _entityEvents - .SelectMany(pair => pair.Value - .Where(item => item.EventName == eventName) - .Select(item => (pair.Key, item))) - .ToList(); - - if (matches.Count == 0) - { - return null; - } - - if (matches.Count > 1) - { - throw new EventCatalogAmbiguousEventException(eventName, matches.Select(match => match.EntityType), matches.Count); - } - - return CreateDetails(matches[0].Item); - } - - /// - public EventCatalogEventDetails? GetDetails(Type entityType, string eventName) - { - if (!_entityEvents.TryGetValue(entityType, out List? items)) - { - return null; - } - - CatalogItem? found = items.Cast().FirstOrDefault(i => i!.Value.EventName == eventName); - if (!found.HasValue) - { - return null; - } - - return CreateDetails(found.Value); - } - - private static EventCatalogEventDetails CreateDetails(CatalogItem item) + public IEnumerable>> GetEntries() { - return new EventCatalogEventDetails(item.EventName, item.Description, item.Constraints, item.SchemaJson.Value); + return _entityEvents.Select(x => new KeyValuePair>(x.Key, x.Value.ToArray())); } } diff --git a/src/Papst.EventStore/EventCatalog/IEventCatalog.cs b/src/Papst.EventStore/EventCatalog/IEventCatalog.cs index ac78a57..ba6f376 100644 --- a/src/Papst.EventStore/EventCatalog/IEventCatalog.cs +++ b/src/Papst.EventStore/EventCatalog/IEventCatalog.cs @@ -1,16 +1,16 @@ namespace Papst.EventStore.EventCatalog; /// -/// Provides read access to the Event Catalog, allowing queries for registered events per entity +/// Provides read access to the materialized Event Catalog entries registered in the application. /// public interface IEventCatalog { /// - /// List events registered for entity , optionally filtered by name and/or constraints + /// List catalog entries registered for entity , optionally filtered by name and/or constraints. /// /// The Entity to list events for /// Optional event name filter (exact match) - /// Optional constraints filter (events matching any of the given constraints) + /// Optional constraints filter (entries matching any of the given constraints) /// A list of matching instances ValueTask> ListEvents(string? name = null, string[]? constraints = null); diff --git a/src/Papst.EventStore/EventCatalog/IEventCatalogRegistration.cs b/src/Papst.EventStore/EventCatalog/IEventCatalogRegistration.cs index f107fe4..c24b0e7 100644 --- a/src/Papst.EventStore/EventCatalog/IEventCatalogRegistration.cs +++ b/src/Papst.EventStore/EventCatalog/IEventCatalogRegistration.cs @@ -16,18 +16,8 @@ public interface IEventCatalogRegistration void RegisterEvent(string eventName, string? description, string[]? constraints, Lazy schemaJson); /// - /// Get catalog entries for a given entity type, optionally filtered + /// Returns all registered catalog entries grouped by their entity type. /// - IReadOnlyList GetEntries(Type entityType, string? name, string[]? constraints); - - /// - /// Get detailed event information by name. - /// Throws when more than one event with the same name is registered. - /// - EventCatalogEventDetails? GetDetails(string eventName); - - /// - /// Get detailed event information by name, scoped to a specific entity type - /// - EventCatalogEventDetails? GetDetails(Type entityType, string eventName); + /// The registered catalog entries keyed by entity type. + IEnumerable>> GetEntries(); } diff --git a/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs b/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs index 8f58b84..78ac882 100644 --- a/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs +++ b/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs @@ -14,11 +14,11 @@ public class EventCatalogProviderTests private class TestEntity { } private class OtherEntity { } - private static (EventCatalogRegistration registration, EventCatalogProvider provider) CreateCatalog() + private static EventCatalogProvider CreateCatalog(Action? configure = null) { var registration = new EventCatalogRegistration(); - var provider = new EventCatalogProvider(new[] { registration }); - return (registration, provider); + configure?.Invoke(registration); + return new EventCatalogProvider(new[] { registration }); } // --- EventCatalogRegistration tests (via provider) --- @@ -26,9 +26,8 @@ private static (EventCatalogRegistration registration, EventCatalogProvider prov [Fact] public async Task RegisterEvent_ShouldStoreEventForEntity() { - var (registration, provider) = CreateCatalog(); - - registration.RegisterEvent("TestEvent", "A test event", null, new Lazy(() => "{}")); + var provider = CreateCatalog(registration => + registration.RegisterEvent("TestEvent", "A test event", null, new Lazy(() => "{}"))); var entries = await provider.ListEvents(); @@ -39,10 +38,11 @@ public async Task RegisterEvent_ShouldStoreEventForEntity() [Fact] public async Task GetEntries_FilterByName_ReturnsMatching() { - var (registration, provider) = CreateCatalog(); - - registration.RegisterEvent("EventA", null, null, new Lazy(() => "{}")); - registration.RegisterEvent("EventB", null, null, new Lazy(() => "{}")); + var provider = CreateCatalog(registration => + { + registration.RegisterEvent("EventA", null, null, new Lazy(() => "{}")); + registration.RegisterEvent("EventB", null, null, new Lazy(() => "{}")); + }); var entries = await provider.ListEvents(name: "EventA"); @@ -53,10 +53,11 @@ public async Task GetEntries_FilterByName_ReturnsMatching() [Fact] public async Task GetEntries_FilterByConstraints_ReturnsMatching() { - var (registration, provider) = CreateCatalog(); - - registration.RegisterEvent("EventX", null, new[] { "admin" }, new Lazy(() => "{}")); - registration.RegisterEvent("EventY", null, new[] { "user" }, new Lazy(() => "{}")); + var provider = CreateCatalog(registration => + { + registration.RegisterEvent("EventX", null, new[] { "admin" }, new Lazy(() => "{}")); + registration.RegisterEvent("EventY", null, new[] { "user" }, new Lazy(() => "{}")); + }); var entries = await provider.ListEvents(constraints: new[] { "admin" }); @@ -67,11 +68,12 @@ public async Task GetEntries_FilterByConstraints_ReturnsMatching() [Fact] public async Task GetEntries_FilterByNameAndConstraints_ReturnsMatching() { - var (registration, provider) = CreateCatalog(); - - registration.RegisterEvent("EventA", null, new[] { "admin" }, new Lazy(() => "{}")); - registration.RegisterEvent("EventA", null, new[] { "user" }, new Lazy(() => "{}")); - registration.RegisterEvent("EventB", null, new[] { "admin" }, new Lazy(() => "{}")); + var provider = CreateCatalog(registration => + { + registration.RegisterEvent("EventA", null, new[] { "admin" }, new Lazy(() => "{}")); + registration.RegisterEvent("EventA", null, new[] { "user" }, new Lazy(() => "{}")); + registration.RegisterEvent("EventB", null, new[] { "admin" }, new Lazy(() => "{}")); + }); var entries = await provider.ListEvents(name: "EventA", constraints: new[] { "admin" }); @@ -82,9 +84,8 @@ public async Task GetEntries_FilterByNameAndConstraints_ReturnsMatching() [Fact] public async Task GetEntries_ForUnknownEntity_ReturnsEmpty() { - var (registration, provider) = CreateCatalog(); - - registration.RegisterEvent("EventA", null, null, new Lazy(() => "{}")); + var provider = CreateCatalog(registration => + registration.RegisterEvent("EventA", null, null, new Lazy(() => "{}"))); var entries = await provider.ListEvents(); @@ -94,10 +95,9 @@ public async Task GetEntries_ForUnknownEntity_ReturnsEmpty() [Fact] public async Task GetDetails_ReturnsSchemaAndDescription() { - var (registration, provider) = CreateCatalog(); const string schema = """{"type":"object","properties":{"id":{"type":"string"}}}"""; - - registration.RegisterEvent("DetailedEvent", "Has a schema", new[] { "v1" }, new Lazy(() => schema)); + var provider = CreateCatalog(registration => + registration.RegisterEvent("DetailedEvent", "Has a schema", new[] { "v1" }, new Lazy(() => schema))); var details = await provider.GetEventDetails("DetailedEvent"); @@ -111,7 +111,7 @@ public async Task GetDetails_ReturnsSchemaAndDescription() [Fact] public async Task GetDetails_UnknownEvent_ReturnsNull() { - var (_, provider) = CreateCatalog(); + var provider = CreateCatalog(); var details = await provider.GetEventDetails("NonExistent"); @@ -121,15 +121,14 @@ public async Task GetDetails_UnknownEvent_ReturnsNull() [Fact] public async Task GetDetails_LazySchemaEvaluatedOnAccess() { - var (registration, provider) = CreateCatalog(); bool evaluated = false; var lazySchema = new Lazy(() => { evaluated = true; return """{"type":"object"}"""; }); - - registration.RegisterEvent("LazyEvent", null, null, lazySchema); + var provider = CreateCatalog(registration => + registration.RegisterEvent("LazyEvent", null, null, lazySchema)); evaluated.ShouldBeFalse(); @@ -196,10 +195,11 @@ public async Task ListEvents_CombinesMultipleRegistrations() [Fact] public async Task GetEventDetails_EntityScoped_ReturnsDetailsForEntity() { - var (registration, provider) = CreateCatalog(); - - registration.RegisterEvent("SharedEvent", "Test version", new[] { "test" }, new Lazy(() => """{"entity":"test"}""")); - registration.RegisterEvent("SharedEvent", "Other version", new[] { "other" }, new Lazy(() => """{"entity":"other"}""")); + var provider = CreateCatalog(registration => + { + registration.RegisterEvent("SharedEvent", "Test version", new[] { "test" }, new Lazy(() => """{"entity":"test"}""")); + registration.RegisterEvent("SharedEvent", "Other version", new[] { "other" }, new Lazy(() => """{"entity":"other"}""")); + }); var testDetails = await provider.GetEventDetails("SharedEvent"); var otherDetails = await provider.GetEventDetails("SharedEvent"); @@ -216,9 +216,8 @@ public async Task GetEventDetails_EntityScoped_ReturnsDetailsForEntity() [Fact] public async Task GetEventDetails_EntityScoped_UnknownEntity_ReturnsNull() { - var (registration, provider) = CreateCatalog(); - - registration.RegisterEvent("SomeEvent", null, null, new Lazy(() => "{}")); + var provider = CreateCatalog(registration => + registration.RegisterEvent("SomeEvent", null, null, new Lazy(() => "{}"))); var details = await provider.GetEventDetails("SomeEvent"); @@ -228,10 +227,11 @@ public async Task GetEventDetails_EntityScoped_UnknownEntity_ReturnsNull() [Fact] public async Task GetEventDetails_DuplicateEventNamesAcrossEntities_GlobalThrowsAmbiguousException() { - var (registration, provider) = CreateCatalog(); - - registration.RegisterEvent("DuplicateName", "First", null, new Lazy(() => """{"first":true}""")); - registration.RegisterEvent("DuplicateName", "Second", null, new Lazy(() => """{"second":true}""")); + var provider = CreateCatalog(registration => + { + registration.RegisterEvent("DuplicateName", "First", null, new Lazy(() => """{"first":true}""")); + registration.RegisterEvent("DuplicateName", "Second", null, new Lazy(() => """{"second":true}""")); + }); Func act = async () => _ = await provider.GetEventDetails("DuplicateName"); From d4a8cd220c6ea7ccd43cbc2253a7da42f58cb927 Mon Sep 17 00:00:00 2001 From: Marco Papst Date: Sun, 12 Apr 2026 23:35:04 +0200 Subject: [PATCH 2/5] Add Bigger sample project with aggregation and catalog --- Directory.Packages.props | 1 + Papst.EventStore.slnx | 5 +++++ README.md | 2 +- .../EventCatalog/EventCatalogProviderTests.cs | 9 +++++++-- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 657c456..45fd41b 100755 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -43,6 +43,7 @@ + diff --git a/Papst.EventStore.slnx b/Papst.EventStore.slnx index f95c392..3752255 100644 --- a/Papst.EventStore.slnx +++ b/Papst.EventStore.slnx @@ -14,6 +14,11 @@ + + + + + diff --git a/README.md b/README.md index b833988..e886a62 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ EventCatalogEventDetails? scoped = await catalog.GetEventDetails("UserCrea ``` A full working sample is available at [`samples/SampleEventCatalog/`](./samples/SampleEventCatalog/). +For an end-to-end ASP.NET Core example using the in-memory event store, stream aggregation, and read-model repositories, see [`samples/SampleInMemoryAspNetApi/`](./samples/SampleInMemoryAspNetApi/). # Changelog @@ -179,4 +180,3 @@ V4 removes support for authenticating with shared keys against the cosmos DB. Th V3 supports mainly .NET 5.0 and registration of events and event aggregators through reflection - diff --git a/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs b/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs index 78ac882..f201e3f 100644 --- a/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs +++ b/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs @@ -14,10 +14,15 @@ public class EventCatalogProviderTests private class TestEntity { } private class OtherEntity { } - private static EventCatalogProvider CreateCatalog(Action? configure = null) + private static EventCatalogProvider CreateCatalog() + { + return CreateCatalog(_ => { }); + } + + private static EventCatalogProvider CreateCatalog(Action configure) { var registration = new EventCatalogRegistration(); - configure?.Invoke(registration); + configure(registration); return new EventCatalogProvider(new[] { registration }); } From 43851b0fd19d1f091fb58711fd750720b88b9a01 Mon Sep 17 00:00:00 2001 From: Marco Papst Date: Sun, 12 Apr 2026 23:35:41 +0200 Subject: [PATCH 3/5] Add Missing files --- .../SampleInMemoryAspNetApi.Api/Contracts.cs | 13 + .../SampleInMemoryAspNetApi.Api/Program.cs | 338 ++++++++++++++++++ .../Properties/launchSettings.json | 23 ++ .../SampleInMemoryAspNetApi.Api.csproj | 20 ++ .../appsettings.Development.json | 8 + .../appsettings.json | 9 + .../IOrderRepository.cs | 7 + .../InMemoryOrderRepository.cs | 34 ++ .../SampleInMemoryAspNetApi.Orders/Order.cs | 24 ++ .../OrderEventAggregators.cs | 38 ++ .../OrderEvents.cs | 12 + .../SampleInMemoryAspNetApi.Orders.csproj | 15 + .../IUserRepository.cs | 7 + .../InMemoryUserRepository.cs | 33 ++ .../SampleInMemoryAspNetApi.Users.csproj | 15 + .../SampleInMemoryAspNetApi.Users/User.cs | 13 + .../UserEventAggregators.cs | 37 ++ .../UserEvents.cs | 12 + 18 files changed, 658 insertions(+) create mode 100644 samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/Contracts.cs create mode 100644 samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/Program.cs create mode 100644 samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/Properties/launchSettings.json create mode 100644 samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/SampleInMemoryAspNetApi.Api.csproj create mode 100644 samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/appsettings.Development.json create mode 100644 samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/appsettings.json create mode 100644 samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/IOrderRepository.cs create mode 100644 samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/InMemoryOrderRepository.cs create mode 100644 samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/Order.cs create mode 100644 samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/OrderEventAggregators.cs create mode 100644 samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/OrderEvents.cs create mode 100644 samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/SampleInMemoryAspNetApi.Orders.csproj create mode 100644 samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/IUserRepository.cs create mode 100644 samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/InMemoryUserRepository.cs create mode 100644 samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/SampleInMemoryAspNetApi.Users.csproj create mode 100644 samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/User.cs create mode 100644 samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/UserEventAggregators.cs create mode 100644 samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/UserEvents.cs diff --git a/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/Contracts.cs b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/Contracts.cs new file mode 100644 index 0000000..5a0765e --- /dev/null +++ b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/Contracts.cs @@ -0,0 +1,13 @@ +using SampleInMemoryAspNetApi.Orders; + +namespace SampleInMemoryAspNetApi.Api; + +public sealed record CreateUserRequest(string Name, string Email); +public sealed record RenameUserRequest(string Name); +public sealed record DeactivateUserRequest(string Reason); +public sealed record CreateOrderRequest(Guid UserId, List Items); +public sealed record CreateOrderItemRequest(string ProductName, int Quantity, decimal UnitPrice); +public sealed record ChangeOrderStatusRequest(OrderStatus Status); +public sealed record CancelOrderRequest(string Reason); +public sealed record CatalogEventResponse(string EventName, string? Description, string[]? Constraints); +public sealed record CatalogEventDetailsResponse(string EventName, string? Description, string[]? Constraints, string JsonSchema); diff --git a/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/Program.cs b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/Program.cs new file mode 100644 index 0000000..312cb98 --- /dev/null +++ b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/Program.cs @@ -0,0 +1,338 @@ +using Papst.EventStore; +using Papst.EventStore.Aggregation; +using Papst.EventStore.Aggregation.EventRegistration; +using Papst.EventStore.EventCatalog; +using Papst.EventStore.Exceptions; +using Papst.EventStore.InMemory; +using SampleInMemoryAspNetApi.Api; +using SampleInMemoryAspNetApi.Orders; +using SampleInMemoryAspNetApi.Users; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddInMemoryEventStore(); +builder.Services.AddRegisteredEventAggregation(); +SampleInMemoryAspNetApi.Users.EventStoreEventAggregator.AddCodeGeneratedEvents(builder.Services); +SampleInMemoryAspNetApi.Orders.EventStoreEventAggregator.AddCodeGeneratedEvents(builder.Services); +SampleInMemoryAspNetApi.Users.EventStoreEventAggregator.AddCodeGeneratedEventCatalog(builder.Services); +SampleInMemoryAspNetApi.Orders.EventStoreEventAggregator.AddCodeGeneratedEventCatalog(builder.Services); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +app.UseSwagger(); +app.UseSwaggerUI(); + +app.MapGet("/", () => Results.Ok(new +{ + message = "Sample in-memory event sourced API", + endpoints = new[] + { + "POST /users", + "POST /users/{userId}/rename", + "POST /users/{userId}/deactivate", + "GET /users/{userId}", + "POST /orders", + "POST /orders/{orderId}/status", + "POST /orders/{orderId}/cancel", + "GET /orders/{orderId}", + "GET /catalog/{entity}/events", + "GET /catalog/{entity}/events/{eventName}/schema", + "GET /swagger" + } +})); + +RouteGroupBuilder users = app.MapGroup("/users"); +users.MapPost("/", CreateUserAsync); +users.MapPost("/{userId:guid}/rename", RenameUserAsync); +users.MapPost("/{userId:guid}/deactivate", DeactivateUserAsync); +users.MapGet("/{userId:guid}", GetUserAsync); + +RouteGroupBuilder orders = app.MapGroup("/orders"); +orders.MapPost("/", CreateOrderAsync); +orders.MapPost("/{orderId:guid}/status", ChangeOrderStatusAsync); +orders.MapPost("/{orderId:guid}/cancel", CancelOrderAsync); +orders.MapGet("/{orderId:guid}", GetOrderAsync); + +RouteGroupBuilder catalog = app.MapGroup("/catalog"); +catalog.MapGet("/{entity}/events", ListCatalogEventsAsync); +catalog.MapGet("/{entity}/events/{eventName}/schema", GetCatalogEventSchemaAsync); + +app.Run(); + +static async Task CreateUserAsync( + CreateUserRequest request, + IEventStore eventStore, + IEventStreamAggregator aggregator, + IUserRepository repository, + CancellationToken cancellationToken) +{ + Guid userId = Guid.NewGuid(); + IEventStream stream = await eventStore.CreateAsync(userId, nameof(User), cancellationToken); + await stream.AppendAsync(Guid.NewGuid(), new UserRegisteredEvent(userId, request.Name, request.Email), cancellationToken: cancellationToken); + + User? user = await AggregateAndStoreAsync( + stream, + aggregator, + repository.UpsertAsync, + cancellationToken); + + return user is null + ? Results.Problem("User aggregation returned no entity.") + : Results.Created($"/users/{userId}", user); +} + +static async Task RenameUserAsync( + Guid userId, + RenameUserRequest request, + IEventStore eventStore, + IEventStreamAggregator aggregator, + IUserRepository repository, + CancellationToken cancellationToken) +{ + IEventStream? stream = await TryGetStreamAsync(eventStore, userId, cancellationToken); + if (stream is null) + { + return Results.NotFound(); + } + + await stream.AppendAsync(Guid.NewGuid(), new UserRenamedEvent(request.Name), cancellationToken: cancellationToken); + User? user = await AggregateAndStoreAsync(stream, aggregator, repository.UpsertAsync, cancellationToken); + + return user is null + ? Results.Problem("User aggregation returned no entity.") + : Results.Ok(user); +} + +static async Task DeactivateUserAsync( + Guid userId, + DeactivateUserRequest request, + IEventStore eventStore, + IEventStreamAggregator aggregator, + IUserRepository repository, + CancellationToken cancellationToken) +{ + IEventStream? stream = await TryGetStreamAsync(eventStore, userId, cancellationToken); + if (stream is null) + { + return Results.NotFound(); + } + + await stream.AppendAsync(Guid.NewGuid(), new UserDeactivatedEvent(request.Reason), cancellationToken: cancellationToken); + User? user = await AggregateAndStoreAsync(stream, aggregator, repository.UpsertAsync, cancellationToken); + + return user is null + ? Results.Problem("User aggregation returned no entity.") + : Results.Ok(user); +} + +static async Task GetUserAsync( + Guid userId, + IUserRepository repository, + CancellationToken cancellationToken) +{ + User? user = await repository.GetAsync(userId, cancellationToken); + return user is null ? Results.NotFound() : Results.Ok(user); +} + +static async Task CreateOrderAsync( + CreateOrderRequest request, + IEventStore eventStore, + IEventStreamAggregator aggregator, + IUserRepository userRepository, + IOrderRepository orderRepository, + CancellationToken cancellationToken) +{ + User? user = await userRepository.GetAsync(request.UserId, cancellationToken); + if (user is null) + { + return Results.BadRequest(new { message = $"User '{request.UserId}' was not found." }); + } + + Guid orderId = Guid.NewGuid(); + IEventStream stream = await eventStore.CreateAsync(orderId, nameof(Order), cancellationToken); + + List items = request.Items + .Select(item => new OrderItem(item.ProductName, item.Quantity, item.UnitPrice)) + .ToList(); + + decimal total = items.Sum(item => item.Quantity * item.UnitPrice); + + await stream.AppendAsync( + Guid.NewGuid(), + new OrderPlacedEvent(orderId, request.UserId, items, total), + cancellationToken: cancellationToken); + + Order? order = await AggregateAndStoreAsync( + stream, + aggregator, + orderRepository.UpsertAsync, + cancellationToken); + + return order is null + ? Results.Problem("Order aggregation returned no entity.") + : Results.Created($"/orders/{orderId}", order); +} + +static async Task ChangeOrderStatusAsync( + Guid orderId, + ChangeOrderStatusRequest request, + IEventStore eventStore, + IEventStreamAggregator aggregator, + IOrderRepository repository, + CancellationToken cancellationToken) +{ + IEventStream? stream = await TryGetStreamAsync(eventStore, orderId, cancellationToken); + if (stream is null) + { + return Results.NotFound(); + } + + await stream.AppendAsync(Guid.NewGuid(), new OrderStatusChangedEvent(request.Status), cancellationToken: cancellationToken); + Order? order = await AggregateAndStoreAsync(stream, aggregator, repository.UpsertAsync, cancellationToken); + + return order is null + ? Results.Problem("Order aggregation returned no entity.") + : Results.Ok(order); +} + +static async Task CancelOrderAsync( + Guid orderId, + CancelOrderRequest request, + IEventStore eventStore, + IEventStreamAggregator aggregator, + IOrderRepository repository, + CancellationToken cancellationToken) +{ + IEventStream? stream = await TryGetStreamAsync(eventStore, orderId, cancellationToken); + if (stream is null) + { + return Results.NotFound(); + } + + await stream.AppendAsync(Guid.NewGuid(), new OrderCancelledEvent(request.Reason), cancellationToken: cancellationToken); + Order? order = await AggregateAndStoreAsync(stream, aggregator, repository.UpsertAsync, cancellationToken); + + return order is null + ? Results.Problem("Order aggregation returned no entity.") + : Results.Ok(order); +} + +static async Task GetOrderAsync( + Guid orderId, + IOrderRepository repository, + CancellationToken cancellationToken) +{ + Order? order = await repository.GetAsync(orderId, cancellationToken); + return order is null ? Results.NotFound() : Results.Ok(order); +} + +static async Task ListCatalogEventsAsync( + string entity, + IEventCatalog catalog, + CancellationToken cancellationToken) +{ + if (TryResolveCatalogEntity(entity) is not CatalogEntity catalogEntity) + { + return Results.NotFound(); + } + + IReadOnlyList events = catalogEntity switch + { + CatalogEntity.Users => (await catalog.ListEvents()).Select(MapCatalogEvent).ToArray(), + CatalogEntity.Orders => (await catalog.ListEvents()).Select(MapCatalogEvent).ToArray(), + _ => [] + }; + + return Results.Ok(new + { + entity = entity.ToLowerInvariant(), + events + }); +} + +static async Task GetCatalogEventSchemaAsync( + string entity, + string eventName, + IEventCatalog catalog, + CancellationToken cancellationToken) +{ + if (TryResolveCatalogEntity(entity) is not CatalogEntity catalogEntity) + { + return Results.NotFound(); + } + + EventCatalogEventDetails? details = catalogEntity switch + { + CatalogEntity.Users => await catalog.GetEventDetails(eventName), + CatalogEntity.Orders => await catalog.GetEventDetails(eventName), + _ => null + }; + + if (details is null) + { + return Results.NotFound(); + } + + return Results.Ok(MapCatalogEventDetails(details)); +} + +static async Task AggregateAndStoreAsync( + IEventStream stream, + IEventStreamAggregator aggregator, + Func store, + CancellationToken cancellationToken) + where TEntity : class, new() +{ + TEntity? entity = await aggregator.AggregateAsync(stream, cancellationToken); + if (entity is not null) + { + await store(entity, cancellationToken); + } + + return entity; +} + +static async Task TryGetStreamAsync( + IEventStore eventStore, + Guid streamId, + CancellationToken cancellationToken) +{ + try + { + return await eventStore.GetAsync(streamId, cancellationToken); + } + catch (EventStreamNotFoundException) + { + return null; + } +} + +static CatalogEntity? TryResolveCatalogEntity(string entity) +{ + return entity.ToLowerInvariant() switch + { + "users" => CatalogEntity.Users, + "orders" => CatalogEntity.Orders, + _ => null + }; +} + +static CatalogEventResponse MapCatalogEvent(EventCatalogEntry entry) +{ + return new CatalogEventResponse(entry.EventName, entry.Description, entry.Constraints); +} + +static CatalogEventDetailsResponse MapCatalogEventDetails(EventCatalogEventDetails details) +{ + return new CatalogEventDetailsResponse(details.EventName, details.Description, details.Constraints, details.JsonSchema); +} + +enum CatalogEntity +{ + Users, + Orders +} diff --git a/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/Properties/launchSettings.json b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/Properties/launchSettings.json new file mode 100644 index 0000000..0dd669f --- /dev/null +++ b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5105", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7123;http://localhost:5105", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/SampleInMemoryAspNetApi.Api.csproj b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/SampleInMemoryAspNetApi.Api.csproj new file mode 100644 index 0000000..4834d59 --- /dev/null +++ b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/SampleInMemoryAspNetApi.Api.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/appsettings.Development.json b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/appsettings.json b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/appsettings.json new file mode 100644 index 0000000..4d56694 --- /dev/null +++ b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/IOrderRepository.cs b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/IOrderRepository.cs new file mode 100644 index 0000000..7761ec9 --- /dev/null +++ b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/IOrderRepository.cs @@ -0,0 +1,7 @@ +namespace SampleInMemoryAspNetApi.Orders; + +public interface IOrderRepository +{ + ValueTask GetAsync(Guid orderId, CancellationToken cancellationToken = default); + ValueTask UpsertAsync(Order order, CancellationToken cancellationToken = default); +} diff --git a/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/InMemoryOrderRepository.cs b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/InMemoryOrderRepository.cs new file mode 100644 index 0000000..5264f6e --- /dev/null +++ b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/InMemoryOrderRepository.cs @@ -0,0 +1,34 @@ +using System.Collections.Concurrent; + +namespace SampleInMemoryAspNetApi.Orders; + +public sealed class InMemoryOrderRepository : IOrderRepository +{ + private readonly ConcurrentDictionary _orders = new(); + + public ValueTask GetAsync(Guid orderId, CancellationToken cancellationToken = default) + { + _orders.TryGetValue(orderId, out Order? order); + return ValueTask.FromResult(order is null ? null : Clone(order)); + } + + public ValueTask UpsertAsync(Order order, CancellationToken cancellationToken = default) + { + _orders[order.Id] = Clone(order); + return ValueTask.CompletedTask; + } + + private static Order Clone(Order order) + { + return new Order + { + Id = order.Id, + UserId = order.UserId, + Total = order.Total, + Status = order.Status, + CancellationReason = order.CancellationReason, + Version = order.Version, + Items = [.. order.Items] + }; + } +} diff --git a/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/Order.cs b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/Order.cs new file mode 100644 index 0000000..43a5d7a --- /dev/null +++ b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/Order.cs @@ -0,0 +1,24 @@ +using Papst.EventStore; + +namespace SampleInMemoryAspNetApi.Orders; + +public sealed class Order : IEntity +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public decimal Total { get; set; } + public OrderStatus Status { get; set; } + public string? CancellationReason { get; set; } + public List Items { get; set; } = []; + public ulong Version { get; set; } +} + +public sealed record OrderItem(string ProductName, int Quantity, decimal UnitPrice); + +public enum OrderStatus +{ + Pending = 0, + Confirmed = 1, + Shipped = 2, + Cancelled = 3 +} diff --git a/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/OrderEventAggregators.cs b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/OrderEventAggregators.cs new file mode 100644 index 0000000..b690a82 --- /dev/null +++ b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/OrderEventAggregators.cs @@ -0,0 +1,38 @@ +using Papst.EventStore.Aggregation; +using Papst.EventStore; + +namespace SampleInMemoryAspNetApi.Orders; + +public sealed class OrderPlacedEventAggregator : EventAggregatorBase +{ + public override ValueTask ApplyAsync(OrderPlacedEvent evt, Order entity, IAggregatorStreamContext ctx) + { + entity.Id = evt.OrderId; + entity.UserId = evt.UserId; + entity.Total = evt.Total; + entity.Status = OrderStatus.Pending; + entity.CancellationReason = null; + entity.Items = [.. evt.Items]; + + return AsTask(entity); + } +} + +public sealed class OrderStatusChangedEventAggregator : EventAggregatorBase +{ + public override ValueTask ApplyAsync(OrderStatusChangedEvent evt, Order entity, IAggregatorStreamContext ctx) + { + entity.Status = evt.Status; + return AsTask(entity); + } +} + +public sealed class OrderCancelledEventAggregator : EventAggregatorBase +{ + public override ValueTask ApplyAsync(OrderCancelledEvent evt, Order entity, IAggregatorStreamContext ctx) + { + entity.Status = OrderStatus.Cancelled; + entity.CancellationReason = evt.Reason; + return AsTask(entity); + } +} diff --git a/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/OrderEvents.cs b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/OrderEvents.cs new file mode 100644 index 0000000..ef4c9d7 --- /dev/null +++ b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/OrderEvents.cs @@ -0,0 +1,12 @@ +using Papst.EventStore.Aggregation.EventRegistration; + +namespace SampleInMemoryAspNetApi.Orders; + +[EventName("OrderPlaced")] +public sealed record OrderPlacedEvent(Guid OrderId, Guid UserId, List Items, decimal Total); + +[EventName("OrderStatusChanged")] +public sealed record OrderStatusChangedEvent(OrderStatus Status); + +[EventName("OrderCancelled")] +public sealed record OrderCancelledEvent(string Reason); diff --git a/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/SampleInMemoryAspNetApi.Orders.csproj b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/SampleInMemoryAspNetApi.Orders.csproj new file mode 100644 index 0000000..83118bd --- /dev/null +++ b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Orders/SampleInMemoryAspNetApi.Orders.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + + + + + + + + + diff --git a/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/IUserRepository.cs b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/IUserRepository.cs new file mode 100644 index 0000000..bcafe97 --- /dev/null +++ b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/IUserRepository.cs @@ -0,0 +1,7 @@ +namespace SampleInMemoryAspNetApi.Users; + +public interface IUserRepository +{ + ValueTask GetAsync(Guid userId, CancellationToken cancellationToken = default); + ValueTask UpsertAsync(User user, CancellationToken cancellationToken = default); +} diff --git a/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/InMemoryUserRepository.cs b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/InMemoryUserRepository.cs new file mode 100644 index 0000000..9a0bba3 --- /dev/null +++ b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/InMemoryUserRepository.cs @@ -0,0 +1,33 @@ +using System.Collections.Concurrent; + +namespace SampleInMemoryAspNetApi.Users; + +public sealed class InMemoryUserRepository : IUserRepository +{ + private readonly ConcurrentDictionary _users = new(); + + public ValueTask GetAsync(Guid userId, CancellationToken cancellationToken = default) + { + _users.TryGetValue(userId, out User? user); + return ValueTask.FromResult(user is null ? null : Clone(user)); + } + + public ValueTask UpsertAsync(User user, CancellationToken cancellationToken = default) + { + _users[user.Id] = Clone(user); + return ValueTask.CompletedTask; + } + + private static User Clone(User user) + { + return new User + { + Id = user.Id, + Name = user.Name, + Email = user.Email, + IsActive = user.IsActive, + DeactivationReason = user.DeactivationReason, + Version = user.Version + }; + } +} diff --git a/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/SampleInMemoryAspNetApi.Users.csproj b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/SampleInMemoryAspNetApi.Users.csproj new file mode 100644 index 0000000..83118bd --- /dev/null +++ b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/SampleInMemoryAspNetApi.Users.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + + + + + + + + + diff --git a/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/User.cs b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/User.cs new file mode 100644 index 0000000..70ccf95 --- /dev/null +++ b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/User.cs @@ -0,0 +1,13 @@ +using Papst.EventStore; + +namespace SampleInMemoryAspNetApi.Users; + +public sealed class User : IEntity +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public bool IsActive { get; set; } = true; + public string? DeactivationReason { get; set; } + public ulong Version { get; set; } +} diff --git a/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/UserEventAggregators.cs b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/UserEventAggregators.cs new file mode 100644 index 0000000..0dc5b64 --- /dev/null +++ b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/UserEventAggregators.cs @@ -0,0 +1,37 @@ +using Papst.EventStore.Aggregation; +using Papst.EventStore; + +namespace SampleInMemoryAspNetApi.Users; + +public sealed class UserRegisteredEventAggregator : EventAggregatorBase +{ + public override ValueTask ApplyAsync(UserRegisteredEvent evt, User entity, IAggregatorStreamContext ctx) + { + entity.Id = evt.UserId; + entity.Name = evt.Name; + entity.Email = evt.Email; + entity.IsActive = true; + entity.DeactivationReason = null; + + return AsTask(entity); + } +} + +public sealed class UserRenamedEventAggregator : EventAggregatorBase +{ + public override ValueTask ApplyAsync(UserRenamedEvent evt, User entity, IAggregatorStreamContext ctx) + { + entity.Name = evt.Name; + return AsTask(entity); + } +} + +public sealed class UserDeactivatedEventAggregator : EventAggregatorBase +{ + public override ValueTask ApplyAsync(UserDeactivatedEvent evt, User entity, IAggregatorStreamContext ctx) + { + entity.IsActive = false; + entity.DeactivationReason = evt.Reason; + return AsTask(entity); + } +} diff --git a/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/UserEvents.cs b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/UserEvents.cs new file mode 100644 index 0000000..bdf17e3 --- /dev/null +++ b/samples/SampleInMemoryAspNetApi/SampleInMemoryAspNetApi.Users/UserEvents.cs @@ -0,0 +1,12 @@ +using Papst.EventStore.Aggregation.EventRegistration; + +namespace SampleInMemoryAspNetApi.Users; + +[EventName("UserRegistered")] +public sealed record UserRegisteredEvent(Guid UserId, string Name, string Email); + +[EventName("UserRenamed")] +public sealed record UserRenamedEvent(string Name); + +[EventName("UserDeactivated")] +public sealed record UserDeactivatedEvent(string Reason); From b5f580a87bd2a6df5f3e94c6ef00f7c62cc06fa0 Mon Sep 17 00:00:00 2001 From: Martin Hans Date: Thu, 19 Mar 2026 14:55:53 +0100 Subject: [PATCH 4/5] Fix nullable target overloads for SetIfNotNull and Update - Change Update(TProperty?, Action) visibility from public to protected to match base class convention - Add TestAggregator wrappers for Action overloads - Disambiguate test call sites with explicit Action casts --- .../Aggregation/EventAggregatorBase.cs | 29 +++ .../EventAggregatorBaseTests.cs | 166 ++++++++++++++++-- 2 files changed, 182 insertions(+), 13 deletions(-) diff --git a/src/Papst.EventStore/Aggregation/EventAggregatorBase.cs b/src/Papst.EventStore/Aggregation/EventAggregatorBase.cs index 72503fc..6465962 100644 --- a/src/Papst.EventStore/Aggregation/EventAggregatorBase.cs +++ b/src/Papst.EventStore/Aggregation/EventAggregatorBase.cs @@ -42,6 +42,20 @@ protected void Update(TProperty? value, Action setter) whe } } + /// + /// Executes the action when .HasValue is true + /// + /// + /// + /// + protected void Update(TProperty? value, Action setter) where TProperty : struct + { + if (value.HasValue) + { + setter.Invoke(value); + } + } + /// /// Executes the action when is not null /// @@ -71,6 +85,21 @@ protected void SetIfNotNull(Action setter, TProperty? valu } } + /// + /// This overload is for nullable value types. + /// Executes the action when .HasValue is true. + /// + /// + /// + /// + protected void SetIfNotNull(Action setter, TProperty? value) where TProperty : struct + { + if (value.HasValue) + { + setter(value); + } + } + /// /// Returns the given Entity wrapped in a Task /// diff --git a/tests/Papst.EventStore.Tests/EventAggregatorBaseTests.cs b/tests/Papst.EventStore.Tests/EventAggregatorBaseTests.cs index 8be9c84..b75e262 100644 --- a/tests/Papst.EventStore.Tests/EventAggregatorBaseTests.cs +++ b/tests/Papst.EventStore.Tests/EventAggregatorBaseTests.cs @@ -17,7 +17,7 @@ public void SetIfNotNull_ShouldInvokeSetter_WhenNullableStructHasValue() int result = 0; int? value = 42; - _sut.CallSetIfNotNull(v => result = v, value); + _sut.CallSetIfNotNull(v => result = v, value); result.ShouldBe(42); } @@ -28,7 +28,7 @@ public void SetIfNotNull_ShouldNotInvokeSetter_WhenNullableStructIsNull() int result = 0; int? value = null; - _sut.CallSetIfNotNull(v => result = v, value); + _sut.CallSetIfNotNull(v => result = v, value); result.ShouldBe(0); } @@ -39,7 +39,7 @@ public void SetIfNotNull_ShouldInvokeSetter_WhenNullableDateTimeHasValue() DateTime result = default; DateTime? value = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); - _sut.CallSetIfNotNull(v => result = v, value); + _sut.CallSetIfNotNull(v => result = v, value); result.ShouldBe(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); } @@ -50,7 +50,7 @@ public void SetIfNotNull_ShouldNotInvokeSetter_WhenNullableDateTimeIsNull() DateTime result = default; DateTime? value = null; - _sut.CallSetIfNotNull(v => result = v, value); + _sut.CallSetIfNotNull(v => result = v, value); result.ShouldBe(default(DateTime)); } @@ -62,7 +62,7 @@ public void SetIfNotNull_ShouldInvokeSetter_WhenNullableGuidHasValue() Guid expected = Guid.NewGuid(); Guid? value = expected; - _sut.CallSetIfNotNull(v => result = v, value); + _sut.CallSetIfNotNull(v => result = v, value); result.ShouldBe(expected); } @@ -73,7 +73,74 @@ public void SetIfNotNull_ShouldNotInvokeSetter_WhenNullableGuidIsNull() Guid result = Guid.Empty; Guid? value = null; - _sut.CallSetIfNotNull(v => result = v, value); + _sut.CallSetIfNotNull(v => result = v, value); + + result.Should().Be(Guid.Empty); + } + + [Fact] + public void SetIfNotNull_ShouldInvokeSetterForNullable_WhenNullableStructHasValue() + { + int? result = 0; + int? value = 42; + + _sut.CallSetIfNotNull((Action)(v => result = v), value); + + result.Should().Be(42); + } + + [Fact] + public void SetIfNotNull_ShouldNotInvokeSetterForNullable_WhenNullableStructIsNull() + { + int? result = 0; + int? value = null; + + _sut.CallSetIfNotNull((Action)(v => result = v), value); + + result.Should().Be(0); + } + + [Fact] + public void SetIfNotNull_ShouldInvokeSetterForNullable_WhenNullableDateTimeHasValue() + { + DateTime? result = null; + DateTime? value = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + _sut.CallSetIfNotNull((Action)(v => result = v), value); + + result.Should().Be(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + } + + [Fact] + public void SetIfNotNull_ShouldNotInvokeSetterForNullable_WhenNullableDateTimeIsNull() + { + DateTime? result = default(DateTime); + DateTime? value = null; + + _sut.CallSetIfNotNull((Action)(v => result = v), value); + + result.Should().Be(default(DateTime)); + } + + [Fact] + public void SetIfNotNull_ShouldInvokeSetterForNullable_WhenNullableGuidHasValue() + { + Guid? result = Guid.Empty; + Guid expected = new Guid(); + Guid? value = expected; + + _sut.CallSetIfNotNull((Action)(v => result = v), value); + + result.Should().Be(expected); + } + + [Fact] + public void SetIfNotNull_ShouldNotInvokeSetterForNullable_WhenNullableGuidIsNull() + { + Guid? result = Guid.Empty; + Guid? value = null; + + _sut.CallSetIfNotNull((Action)(v => result = v), value); result.ShouldBe(Guid.Empty); } @@ -84,7 +151,7 @@ public void Update_ShouldInvokeSetter_WhenNullableStructHasValue() int result = 0; int? value = 42; - _sut.CallUpdate(value, v => result = v); + _sut.CallUpdate(value, v => result = v); result.ShouldBe(42); } @@ -95,7 +162,7 @@ public void Update_ShouldNotInvokeSetter_WhenNullableStructIsNull() int result = 0; int? value = null; - _sut.CallUpdate(value, v => result = v); + _sut.CallUpdate(value, v => result = v); result.ShouldBe(0); } @@ -106,7 +173,7 @@ public void Update_ShouldInvokeSetter_WhenNullableDateTimeHasValue() DateTime result = default; DateTime? value = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); - _sut.CallUpdate(value, v => result = v); + _sut.CallUpdate(value, v => result = v); result.ShouldBe(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); } @@ -117,7 +184,7 @@ public void Update_ShouldNotInvokeSetter_WhenNullableDateTimeIsNull() DateTime result = default; DateTime? value = null; - _sut.CallUpdate(value, v => result = v); + _sut.CallUpdate(value, v => result = v); result.ShouldBe(default(DateTime)); } @@ -129,7 +196,7 @@ public void Update_ShouldInvokeSetter_WhenNullableGuidHasValue() Guid expected = Guid.NewGuid(); Guid? value = expected; - _sut.CallUpdate(value, v => result = v); + _sut.CallUpdate(value, v => result = v); result.ShouldBe(expected); } @@ -140,7 +207,74 @@ public void Update_ShouldNotInvokeSetter_WhenNullableGuidIsNull() Guid result = Guid.Empty; Guid? value = null; - _sut.CallUpdate(value, v => result = v); + _sut.CallUpdate(value, v => result = v); + + result.Should().Be(Guid.Empty); + } + + [Fact] + public void Update_ShouldInvokeSetterForNullable_WhenNullableStructHasValue() + { + int? result = 0; + int? value = 42; + + _sut.CallUpdate(value,(Action)(v => result = v)); + + result.Should().Be(42); + } + + [Fact] + public void Update_ShouldNotInvokeSetterForNullable_WhenNullableStructIsNull() + { + int? result = 0; + int? value = null; + + _sut.CallUpdate(value, (Action)(v => result = v)); + + result.Should().Be(0); + } + + [Fact] + public void Update_ShouldInvokeSetterForNullable_WhenNullableDateTimeHasValue() + { + DateTime? result = null; + DateTime? value = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + _sut.CallUpdate(value, (Action)(v => result = v)); + + result.Should().Be(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + } + + [Fact] + public void Update_ShouldNotInvokeSetterForNullable_WhenNullableDateTimeIsNull() + { + DateTime? result = default(DateTime); + DateTime? value = null; + + _sut.CallUpdate(value, (Action)(v => result = v)); + + result.Should().Be(default(DateTime)); + } + + [Fact] + public void Update_ShouldInvokeSetterForNullable_WhenNullableGuidHasValue() + { + Guid? result = Guid.Empty; + Guid expected = Guid.NewGuid(); + Guid? value = expected; + + _sut.CallUpdate(value, (Action)(v => result = v)); + + result.Should().Be(expected); + } + + [Fact] + public void Update_ShouldNotInvokeSetterForNullable_WhenNullableGuidIsNull() + { + Guid? result = Guid.Empty; + Guid? value = null; + + _sut.CallUpdate(value, (Action)(v => result = v)); result.ShouldBe(Guid.Empty); } @@ -159,8 +293,14 @@ private class TestAggregator : EventAggregatorBase public void CallSetIfNotNull(Action setter, T? value) where T : struct => SetIfNotNull(setter, value); - + + public void CallSetIfNotNull(Action setter, T? value) where T : struct + => SetIfNotNull(setter, value); + public void CallUpdate(T? value, Action setter) where T : struct => Update(value, setter); + + public void CallUpdate(T? value, Action setter) where T : struct + => Update(value, setter); } } From 5ce1e2208a90ad619edec03c5c2e3a5b08a988eb Mon Sep 17 00:00:00 2001 From: Marco Papst Date: Sun, 12 Apr 2026 23:40:51 +0200 Subject: [PATCH 5/5] After rebase, refactor to use Shouldly --- .../EventAggregatorBaseTests.cs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/Papst.EventStore.Tests/EventAggregatorBaseTests.cs b/tests/Papst.EventStore.Tests/EventAggregatorBaseTests.cs index b75e262..67f753d 100644 --- a/tests/Papst.EventStore.Tests/EventAggregatorBaseTests.cs +++ b/tests/Papst.EventStore.Tests/EventAggregatorBaseTests.cs @@ -75,7 +75,7 @@ public void SetIfNotNull_ShouldNotInvokeSetter_WhenNullableGuidIsNull() _sut.CallSetIfNotNull(v => result = v, value); - result.Should().Be(Guid.Empty); + result.ShouldBe(Guid.Empty); } [Fact] @@ -86,7 +86,7 @@ public void SetIfNotNull_ShouldInvokeSetterForNullable_WhenNullableStructHasValu _sut.CallSetIfNotNull((Action)(v => result = v), value); - result.Should().Be(42); + result.ShouldBe(42); } [Fact] @@ -97,7 +97,7 @@ public void SetIfNotNull_ShouldNotInvokeSetterForNullable_WhenNullableStructIsNu _sut.CallSetIfNotNull((Action)(v => result = v), value); - result.Should().Be(0); + result.ShouldBe(0); } [Fact] @@ -108,7 +108,7 @@ public void SetIfNotNull_ShouldInvokeSetterForNullable_WhenNullableDateTimeHasVa _sut.CallSetIfNotNull((Action)(v => result = v), value); - result.Should().Be(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + result.ShouldBe(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); } [Fact] @@ -119,7 +119,7 @@ public void SetIfNotNull_ShouldNotInvokeSetterForNullable_WhenNullableDateTimeIs _sut.CallSetIfNotNull((Action)(v => result = v), value); - result.Should().Be(default(DateTime)); + result.ShouldBe(default(DateTime)); } [Fact] @@ -131,7 +131,7 @@ public void SetIfNotNull_ShouldInvokeSetterForNullable_WhenNullableGuidHasValue( _sut.CallSetIfNotNull((Action)(v => result = v), value); - result.Should().Be(expected); + result.ShouldBe(expected); } [Fact] @@ -209,7 +209,7 @@ public void Update_ShouldNotInvokeSetter_WhenNullableGuidIsNull() _sut.CallUpdate(value, v => result = v); - result.Should().Be(Guid.Empty); + result.ShouldBe(Guid.Empty); } [Fact] @@ -220,7 +220,7 @@ public void Update_ShouldInvokeSetterForNullable_WhenNullableStructHasValue() _sut.CallUpdate(value,(Action)(v => result = v)); - result.Should().Be(42); + result.ShouldBe(42); } [Fact] @@ -231,7 +231,7 @@ public void Update_ShouldNotInvokeSetterForNullable_WhenNullableStructIsNull() _sut.CallUpdate(value, (Action)(v => result = v)); - result.Should().Be(0); + result.ShouldBe(0); } [Fact] @@ -242,7 +242,7 @@ public void Update_ShouldInvokeSetterForNullable_WhenNullableDateTimeHasValue() _sut.CallUpdate(value, (Action)(v => result = v)); - result.Should().Be(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + result.ShouldBe(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc)); } [Fact] @@ -253,7 +253,7 @@ public void Update_ShouldNotInvokeSetterForNullable_WhenNullableDateTimeIsNull() _sut.CallUpdate(value, (Action)(v => result = v)); - result.Should().Be(default(DateTime)); + result.ShouldBe(default(DateTime)); } [Fact] @@ -265,7 +265,7 @@ public void Update_ShouldInvokeSetterForNullable_WhenNullableGuidHasValue() _sut.CallUpdate(value, (Action)(v => result = v)); - result.Should().Be(expected); + result.ShouldBe(expected); } [Fact]