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/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);
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/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/EventAggregatorBaseTests.cs b/tests/Papst.EventStore.Tests/EventAggregatorBaseTests.cs
index 8be9c84..67f753d 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.ShouldBe(Guid.Empty);
+ }
+
+ [Fact]
+ public void SetIfNotNull_ShouldInvokeSetterForNullable_WhenNullableStructHasValue()
+ {
+ int? result = 0;
+ int? value = 42;
+
+ _sut.CallSetIfNotNull((Action)(v => result = v), value);
+
+ result.ShouldBe(42);
+ }
+
+ [Fact]
+ public void SetIfNotNull_ShouldNotInvokeSetterForNullable_WhenNullableStructIsNull()
+ {
+ int? result = 0;
+ int? value = null;
+
+ _sut.CallSetIfNotNull((Action)(v => result = v), value);
+
+ result.ShouldBe(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.ShouldBe(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.ShouldBe(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.ShouldBe(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.ShouldBe(Guid.Empty);
+ }
+
+ [Fact]
+ public void Update_ShouldInvokeSetterForNullable_WhenNullableStructHasValue()
+ {
+ int? result = 0;
+ int? value = 42;
+
+ _sut.CallUpdate(value,(Action)(v => result = v));
+
+ result.ShouldBe(42);
+ }
+
+ [Fact]
+ public void Update_ShouldNotInvokeSetterForNullable_WhenNullableStructIsNull()
+ {
+ int? result = 0;
+ int? value = null;
+
+ _sut.CallUpdate(value, (Action)(v => result = v));
+
+ result.ShouldBe(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.ShouldBe(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.ShouldBe(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.ShouldBe(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);
}
}
diff --git a/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs b/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs
index 8f58b84..f201e3f 100644
--- a/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs
+++ b/tests/Papst.EventStore.Tests/EventCatalog/EventCatalogProviderTests.cs
@@ -14,11 +14,16 @@ public class EventCatalogProviderTests
private class TestEntity { }
private class OtherEntity { }
- private static (EventCatalogRegistration registration, EventCatalogProvider provider) CreateCatalog()
+ private static EventCatalogProvider CreateCatalog()
+ {
+ return CreateCatalog(_ => { });
+ }
+
+ private static EventCatalogProvider CreateCatalog(Action configure)
{
var registration = new EventCatalogRegistration();
- var provider = new EventCatalogProvider(new[] { registration });
- return (registration, provider);
+ configure(registration);
+ return new EventCatalogProvider(new[] { registration });
}
// --- EventCatalogRegistration tests (via provider) ---
@@ -26,9 +31,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 +43,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 +58,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 +73,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 +89,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 +100,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 +116,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 +126,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 +200,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 +221,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 +232,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");