From ee4bafb73ac9b0a8f84081c114af4d6200e91c3b Mon Sep 17 00:00:00 2001 From: Oskar Dudycz Date: Wed, 1 May 2024 18:43:17 +0200 Subject: [PATCH] Added optimistic concurrency exercise and marked application layer tests with trait --- .../Tools/MartenTest.cs | 2 +- .../AddProductItemToShoppingCartTests.cs | 12 + .../Incidents/CancelShoppingCartTests.cs | 10 + .../Incidents/ConfirmShoppingCartTests.cs | 12 + .../Incidents/OpenShoppingCartTests.cs | 4 + .../RemoveProductItemFromShoppingCartTests.cs | 14 + .../Core/Marten/DocumentSessionExtensions.cs | 23 +- .../08-ApplicationLogic.Marten/Program.cs | 2 +- ...-OptimisticConcurrency.Marten.Tests.csproj | 32 +++ .../AddProductItemToShoppingCartTests.cs | 107 ++++++++ .../Incidents/CancelShoppingCartTests.cs | 91 ++++++ .../Incidents/ConfirmShoppingCartTests.cs | 103 +++++++ .../Incidents/Fixtures.cs | 51 ++++ .../Incidents/OpenShoppingCartTests.cs | 31 +++ .../RemoveProductItemFromShoppingCartTests.cs | 118 ++++++++ .../Settings.cs | 17 ++ .../10-OptimisticConcurrency.Marten.csproj | 24 +- .../Core/Entities/Aggregate.cs | 39 +++ .../Core/Exceptions/NotFoundException.cs | 13 + .../Core/Http/ETagExtensions.cs | 0 .../Core/Marten/DocumentSessionExtensions.cs | 44 +++ .../Core/Marten/EventMappings.cs | 12 + .../Immutable/BusinessLogic.cs | 258 ------------------ .../Immutable/DocumentSessionExtensions.cs | 41 --- .../Immutable/OptimisticConcurrencyTests.cs | 132 --------- .../Immutable/Pricing/PricingCalculator.cs | 26 ++ .../Immutable/Pricing/ProductItems.cs | 13 + .../Immutable/ShoppingCarts/Api.cs | 133 +++++++++ .../Immutable/ShoppingCarts/Configure.cs | 30 ++ .../Immutable/ShoppingCarts/ShoppingCart.cs | 132 +++++++++ .../ShoppingCarts/ShoppingCartService.cs | 120 ++++++++ .../Mixed/DocumentSessionExtensions.cs | 41 --- .../Mixed/OptimisticConcurrencyTests.cs | 147 ---------- .../Mixed/Pricing/PricingCalculator.cs | 21 ++ .../Mixed/Pricing/ProductItems.cs | 16 ++ .../Mixed/ShoppingCarts/Api.cs | 125 +++++++++ .../Mixed/ShoppingCarts/Configure.cs | 31 +++ .../MixedShoppingCart.cs} | 200 +++++--------- .../Mutable/DocumentSessionExtensions.cs | 41 --- .../Mutable/OptimisticConcurrencyTests.cs | 135 --------- .../Mutable/Pricing/PricingCalculator.cs | 21 ++ .../Mutable/Pricing/ProductItems.cs | 16 ++ .../Mutable/ShoppingCarts/Api.cs | 126 +++++++++ .../Mutable/ShoppingCarts/Configure.cs | 31 +++ .../MutableShoppingCart.cs} | 214 +++++---------- .../Program.cs | 71 +++++ .../Properties/launchSettings.json | 14 + .../10-OptimisticConcurrency.Marten/README.md | 30 -- .../Tools/MartenTest.cs | 41 --- .../appsettings.json | 18 ++ .../assets/events.jpg | Bin 67506 -> 0 bytes .../Core/Http/ETagExtensions.cs | 29 -- .../08-ApplicationLogic.Marten/Program.cs | 2 +- 53 files changed, 1813 insertions(+), 1203 deletions(-) create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/10-OptimisticConcurrency.Marten.Tests.csproj create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/AddProductItemToShoppingCartTests.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/CancelShoppingCartTests.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/ConfirmShoppingCartTests.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/Fixtures.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/OpenShoppingCartTests.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/RemoveProductItemFromShoppingCartTests.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Settings.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Entities/Aggregate.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Exceptions/NotFoundException.cs rename Workshops/IntroductionToEventSourcing/{08-ApplicationLogic.Marten => 10-OptimisticConcurrency.Marten}/Core/Http/ETagExtensions.cs (100%) create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Marten/DocumentSessionExtensions.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Marten/EventMappings.cs delete mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/BusinessLogic.cs delete mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/DocumentSessionExtensions.cs delete mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/OptimisticConcurrencyTests.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/Pricing/PricingCalculator.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/Pricing/ProductItems.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/Api.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/Configure.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/ShoppingCart.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/ShoppingCartService.cs delete mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/DocumentSessionExtensions.cs delete mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/OptimisticConcurrencyTests.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/Pricing/PricingCalculator.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/Pricing/ProductItems.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/ShoppingCarts/Api.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/ShoppingCarts/Configure.cs rename Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/{BusinessLogic.cs => ShoppingCarts/MixedShoppingCart.cs} (50%) delete mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/DocumentSessionExtensions.cs delete mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/OptimisticConcurrencyTests.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/Pricing/PricingCalculator.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/Pricing/ProductItems.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/ShoppingCarts/Api.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/ShoppingCarts/Configure.cs rename Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/{BusinessLogic.cs => ShoppingCarts/MutableShoppingCart.cs} (51%) create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Program.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Properties/launchSettings.json delete mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/README.md delete mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Tools/MartenTest.cs create mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/appsettings.json delete mode 100644 Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/assets/events.jpg delete mode 100644 Workshops/IntroductionToEventSourcing/Solved/08-ApplicationLogic.Marten/Core/Http/ETagExtensions.cs diff --git a/Workshops/IntroductionToEventSourcing/05-GettingStateFromEvents.Marten/Tools/MartenTest.cs b/Workshops/IntroductionToEventSourcing/05-GettingStateFromEvents.Marten/Tools/MartenTest.cs index 972f996cf..8117ce379 100644 --- a/Workshops/IntroductionToEventSourcing/05-GettingStateFromEvents.Marten/Tools/MartenTest.cs +++ b/Workshops/IntroductionToEventSourcing/05-GettingStateFromEvents.Marten/Tools/MartenTest.cs @@ -12,7 +12,7 @@ protected MartenTest() var options = new StoreOptions(); options.Connection( "PORT = 5432; HOST = localhost; TIMEOUT = 15; POOLING = True; DATABASE = 'postgres'; PASSWORD = 'Password12!'; USER ID = 'postgres'"); - options.UseDefaultSerialization(nonPublicMembersStorage: NonPublicMembersStorage.All); + options.UseNewtonsoftForSerialization(nonPublicMembersStorage: NonPublicMembersStorage.All); options.DatabaseSchemaName = options.Events.DatabaseSchemaName = "IntroductionToEventSourcing"; documentStore = new DocumentStore(options); diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/AddProductItemToShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/AddProductItemToShoppingCartTests.cs index 2693b9ae4..c3b98137f 100644 --- a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/AddProductItemToShoppingCartTests.cs +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/AddProductItemToShoppingCartTests.cs @@ -12,7 +12,9 @@ namespace ApplicationLogic.Marten.Tests.Incidents; public class AddProductItemToShoppingCartTests(ApiSpecification api): IClassFixture> { + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -25,7 +27,9 @@ public Task CantAddProductItemToNotExistingShoppingCart(string apiPrefix) => ) .Then(NOT_FOUND); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -38,7 +42,9 @@ public Task AddsProductItemToEmptyShoppingCart(string apiPrefix) => ) .Then(NO_CONTENT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -54,7 +60,9 @@ public Task AddsProductItemToNonEmptyShoppingCart(string apiPrefix) => ) .Then(NO_CONTENT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -71,7 +79,9 @@ public Task CantAddProductItemToConfirmedShoppingCart(string apiPrefix) => ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -88,7 +98,9 @@ public Task CantAddProductItemToCanceledShoppingCart(string apiPrefix) => ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/CancelShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/CancelShoppingCartTests.cs index 8224ded90..ccdb8c5aa 100644 --- a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/CancelShoppingCartTests.cs +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/CancelShoppingCartTests.cs @@ -12,7 +12,9 @@ namespace ApplicationLogic.Marten.Tests.Incidents; public class CancelShoppingCartTests(ApiSpecification api): IClassFixture> { + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -24,7 +26,9 @@ public Task CantCancelNotExistingShoppingCart(string apiPrefix) => ) .Then(NOT_FOUND); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -39,7 +43,9 @@ public Task CancelsNonEmptyShoppingCart(string apiPrefix) => ) .Then(NO_CONTENT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -55,7 +61,9 @@ public Task CantCancelAlreadyCanceledShoppingCart(string apiPrefix) => ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -71,7 +79,9 @@ public Task CantCancelConfirmedShoppingCart(string apiPrefix) => ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/ConfirmShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/ConfirmShoppingCartTests.cs index 1bcb97343..6c1d3cd89 100644 --- a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/ConfirmShoppingCartTests.cs +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/ConfirmShoppingCartTests.cs @@ -12,7 +12,9 @@ namespace ApplicationLogic.Marten.Tests.Incidents; public class ConfirmShoppingCartTests(ApiSpecification api): IClassFixture> { + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -24,7 +26,9 @@ public Task CantConfirmNotExistingShoppingCart(string apiPrefix) => ) .Then(NOT_FOUND); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -36,7 +40,9 @@ public Task CantConfirmEmptyShoppingCart(string apiPrefix) => ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -51,7 +57,9 @@ public Task ConfirmsNonEmptyShoppingCart(string apiPrefix) => ) .Then(NO_CONTENT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -67,7 +75,9 @@ public Task CantConfirmAlreadyConfirmedShoppingCart(string apiPrefix) => ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -83,7 +93,9 @@ public Task CantConfirmCanceledShoppingCart(string apiPrefix) => ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/OpenShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/OpenShoppingCartTests.cs index fb8f7778a..71b63dde2 100644 --- a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/OpenShoppingCartTests.cs +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/OpenShoppingCartTests.cs @@ -9,7 +9,9 @@ namespace ApplicationLogic.Marten.Tests.Incidents; public class OpenShoppingCartTests(ApiSpecification api): IClassFixture> { + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -18,7 +20,9 @@ public Task OpensShoppingCart(string apiPrefix) => .When(POST, URI(ShoppingCarts(apiPrefix, ClientId))) .Then(CREATED_WITH_DEFAULT_HEADERS(locationHeaderPrefix: ShoppingCarts(apiPrefix, ClientId))); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/RemoveProductItemFromShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/RemoveProductItemFromShoppingCartTests.cs index 6b1503465..094998016 100644 --- a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/RemoveProductItemFromShoppingCartTests.cs +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten.Tests/Incidents/RemoveProductItemFromShoppingCartTests.cs @@ -12,7 +12,9 @@ namespace ApplicationLogic.Marten.Tests.Incidents; public class RemoveProductItemFromShoppingCartTests(ApiSpecification api): IClassFixture> { + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -24,7 +26,9 @@ public Task CantRemoveProductItemFromNotExistingShoppingCart(string apiPrefix) = ) .Then(NOT_FOUND); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -36,7 +40,9 @@ public Task CantRemoveProductItemFromEmptyShoppingCart(string apiPrefix) => ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -51,7 +57,9 @@ public Task CanRemoveExistingProductItemFromShoppingCart(string apiPrefix) => ) .Then(NO_CONTENT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -66,7 +74,9 @@ public Task CantRemoveNonExistingProductItemFromEmptyShoppingCart(string apiPref ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -82,7 +92,9 @@ public Task CantRemoveExistingProductItemFromCanceledShoppingCart(string apiPref ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] @@ -98,7 +110,9 @@ public Task CantRemoveExistingProductItemFromConfirmedShoppingCart(string apiPre ) .Then(CONFLICT); + [Theory] + [Trait("Category", "SkipCI")] [InlineData("immutable")] [InlineData("mutable")] [InlineData("mixed")] diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Marten/DocumentSessionExtensions.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Marten/DocumentSessionExtensions.cs index d51da9ae2..f5369263c 100644 --- a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Marten/DocumentSessionExtensions.cs +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Marten/DocumentSessionExtensions.cs @@ -1,5 +1,4 @@ using ApplicationLogic.Marten.Core.Entities; -using ApplicationLogic.Marten.Core.Exceptions; using Marten; namespace ApplicationLogic.Marten.Core.Marten; @@ -8,37 +7,23 @@ public static class DocumentSessionExtensions { public static Task Add(this IDocumentSession documentSession, Guid id, object @event, CancellationToken ct) where T : class - { - documentSession.Events.StartStream(id, @event); - return documentSession.SaveChangesAsync(token: ct); - } + => throw new NotImplementedException("Document Session Extensions not implemented!"); public static Task Add(this IDocumentSession documentSession, Guid id, object[] events, CancellationToken ct) where T : class - { - documentSession.Events.StartStream(id, events); - return documentSession.SaveChangesAsync(token: ct); - } + => throw new NotImplementedException("Document Session Extensions not implemented!"); public static Task GetAndUpdate( this IDocumentSession documentSession, Guid id, Func handle, CancellationToken ct - ) where T : class => - documentSession.Events.WriteToAggregate(id, stream => - stream.AppendMany(handle(stream.Aggregate ?? throw NotFoundException.For(id))), ct); + ) where T : class => throw new NotImplementedException("Document Session Extensions not implemented!"); public static Task GetAndUpdate( this IDocumentSession documentSession, Guid id, Action handle, CancellationToken ct - ) where T : class, IAggregate => - documentSession.Events.WriteToAggregate(id, stream => - { - var aggregate = stream.Aggregate ?? throw NotFoundException.For(id); - handle(aggregate); - stream.AppendMany(aggregate.DequeueUncommittedEvents()); - }, ct); + ) where T : class, IAggregate => throw new NotImplementedException("Document Session Extensions not implemented!"); } diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Program.cs b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Program.cs index 949cb73fb..ecb38b84c 100644 --- a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Program.cs +++ b/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Program.cs @@ -17,7 +17,7 @@ .AddImmutableShoppingCarts() .AddMarten(options => { - var schemaName = Environment.GetEnvironmentVariable("SchemaName") ?? "Workshop_ShoppingCarts"; + var schemaName = Environment.GetEnvironmentVariable("SchemaName") ?? "Workshop_Application_ShoppingCarts"; options.Events.DatabaseSchemaName = schemaName; options.DatabaseSchemaName = schemaName; options.Connection(builder.Configuration.GetConnectionString("ShoppingCarts") ?? diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/10-OptimisticConcurrency.Marten.Tests.csproj b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/10-OptimisticConcurrency.Marten.Tests.csproj new file mode 100644 index 000000000..6a1f4ff9e --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/10-OptimisticConcurrency.Marten.Tests.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + ApplicationLogic.Marten.Tests + ApplicationLogic.Marten.Tests + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/AddProductItemToShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/AddProductItemToShoppingCartTests.cs new file mode 100644 index 000000000..2693b9ae4 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/AddProductItemToShoppingCartTests.cs @@ -0,0 +1,107 @@ +using System.Net; +using ApplicationLogic.Marten.Immutable.ShoppingCarts; +using Bogus; +using Ogooreck.API; +using Xunit; +using static Ogooreck.API.ApiSpecification; +using static ApplicationLogic.Marten.Tests.Incidents.Scenarios; +using static ApplicationLogic.Marten.Tests.Incidents.Fixtures; + +namespace ApplicationLogic.Marten.Tests.Incidents; + +public class AddProductItemToShoppingCartTests(ApiSpecification api): + IClassFixture> +{ + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantAddProductItemToNotExistingShoppingCart(string apiPrefix) => + api.Given() + .When( + POST, + URI(ShoppingCartProductItems(apiPrefix, ClientId, NotExistingShoppingCartId)), + BODY(new AddProductRequest(ProductItem)) + ) + .Then(NOT_FOUND); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task AddsProductItemToEmptyShoppingCart(string apiPrefix) => + api.Given(OpenedShoppingCart(apiPrefix, ClientId)) + .When( + POST, + URI(ctx => ShoppingCartProductItems(apiPrefix, ClientId, ctx.GetCreatedId())), + BODY(new AddProductRequest(ProductItem)) + ) + .Then(NO_CONTENT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task AddsProductItemToNonEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem) + ) + .When( + POST, + URI(ctx => ShoppingCartProductItems(apiPrefix, ClientId, ctx.GetCreatedId())), + BODY(new AddProductRequest(ProductItem)) + ) + .Then(NO_CONTENT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantAddProductItemToConfirmedShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenConfirmed(apiPrefix, ClientId) + ) + .When( + POST, + URI(ctx => ShoppingCartProductItems(apiPrefix, ClientId, ctx.GetCreatedId())), + BODY(new AddProductRequest(ProductItem)) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantAddProductItemToCanceledShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenCanceled(apiPrefix, ClientId) + ) + .When( + POST, + URI(ctx => ShoppingCartProductItems(apiPrefix, ClientId, ctx.GetCreatedId())), + BODY(new AddProductRequest(ProductItem)) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task ReturnsNonEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem) + ) + .When(GET, URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId()))) + .Then(OK); + + private static readonly Faker Faker = new(); + private readonly Guid NotExistingShoppingCartId = Guid.NewGuid(); + private readonly Guid ClientId = Guid.NewGuid(); + private readonly ProductItemRequest ProductItem = new(Guid.NewGuid(), Faker.Random.Number(1, 500)); +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/CancelShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/CancelShoppingCartTests.cs new file mode 100644 index 000000000..8224ded90 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/CancelShoppingCartTests.cs @@ -0,0 +1,91 @@ +using System.Net; +using ApplicationLogic.Marten.Immutable.ShoppingCarts; +using Bogus; +using Ogooreck.API; +using Xunit; +using static Ogooreck.API.ApiSpecification; +using static ApplicationLogic.Marten.Tests.Incidents.Scenarios; +using static ApplicationLogic.Marten.Tests.Incidents.Fixtures; + +namespace ApplicationLogic.Marten.Tests.Incidents; + +public class CancelShoppingCartTests(ApiSpecification api): + IClassFixture> +{ + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantCancelNotExistingShoppingCart(string apiPrefix) => + api.Given() + .When( + DELETE, + URI(ShoppingCart(apiPrefix, ClientId, NotExistingShoppingCartId)) + ) + .Then(NOT_FOUND); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CancelsNonEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem) + ) + .When( + DELETE, + URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId())) + ) + .Then(NO_CONTENT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantCancelAlreadyCanceledShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenCanceled(apiPrefix, ClientId) + ) + .When( + DELETE, + URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId())) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantCancelConfirmedShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenConfirmed(apiPrefix, ClientId) + ) + .When( + DELETE, + URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId())) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task ReturnsNonEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenCanceled(apiPrefix, ClientId) + ) + .When(GET, URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId()))) + .Then(OK); + + private static readonly Faker Faker = new(); + private readonly Guid NotExistingShoppingCartId = Guid.NewGuid(); + private readonly Guid ClientId = Guid.NewGuid(); + private readonly ProductItemRequest ProductItem = new(Guid.NewGuid(), Faker.Random.Number(1, 500)); +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/ConfirmShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/ConfirmShoppingCartTests.cs new file mode 100644 index 000000000..1bcb97343 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/ConfirmShoppingCartTests.cs @@ -0,0 +1,103 @@ +using System.Net; +using ApplicationLogic.Marten.Immutable.ShoppingCarts; +using Bogus; +using Ogooreck.API; +using Xunit; +using static Ogooreck.API.ApiSpecification; +using static ApplicationLogic.Marten.Tests.Incidents.Scenarios; +using static ApplicationLogic.Marten.Tests.Incidents.Fixtures; + +namespace ApplicationLogic.Marten.Tests.Incidents; + +public class ConfirmShoppingCartTests(ApiSpecification api): + IClassFixture> +{ + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantConfirmNotExistingShoppingCart(string apiPrefix) => + api.Given() + .When( + POST, + URI(ConfirmShoppingCart(apiPrefix, ClientId, NotExistingShoppingCartId)) + ) + .Then(NOT_FOUND); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantConfirmEmptyShoppingCart(string apiPrefix) => + api.Given(OpenedShoppingCart(apiPrefix, ClientId)) + .When( + POST, + URI(ctx => ConfirmShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId())) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task ConfirmsNonEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem) + ) + .When( + POST, + URI(ctx => ConfirmShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId())) + ) + .Then(NO_CONTENT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantConfirmAlreadyConfirmedShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenConfirmed(apiPrefix, ClientId) + ) + .When( + POST, + URI(ctx => ConfirmShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId())) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantConfirmCanceledShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenCanceled(apiPrefix, ClientId) + ) + .When( + POST, + URI(ctx => ConfirmShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId())) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task ReturnsNonEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenConfirmed(apiPrefix, ClientId) + ) + .When(GET, URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId()))) + .Then(OK); + + private static readonly Faker Faker = new(); + private readonly Guid NotExistingShoppingCartId = Guid.NewGuid(); + private readonly Guid ClientId = Guid.NewGuid(); + private readonly ProductItemRequest ProductItem = new(Guid.NewGuid(), Faker.Random.Number(1, 500)); +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/Fixtures.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/Fixtures.cs new file mode 100644 index 000000000..f15700bcc --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/Fixtures.cs @@ -0,0 +1,51 @@ +using ApplicationLogic.Marten.Immutable.ShoppingCarts; +using Ogooreck.API; +using static Ogooreck.API.ApiSpecification; + +namespace ApplicationLogic.Marten.Tests.Incidents; + +public static class Fixtures +{ + public static string Clients(string apiPrefix, Guid clientId) => $"/api/{apiPrefix}/clients/{clientId}/"; + + public static string ShoppingCarts(string apiPrefix, Guid clientId) => + $"{Clients(apiPrefix, clientId)}shopping-carts/"; + + public static string ShoppingCart(string apiPrefix, Guid clientId, Guid shoppingCartId) => + $"{ShoppingCarts(apiPrefix, clientId)}{shoppingCartId}/"; + + public static string ConfirmShoppingCart(string apiPrefix, Guid clientId, Guid shoppingCartId) => + $"{ShoppingCart(apiPrefix, clientId, shoppingCartId)}confirm"; + + public static string ShoppingCartProductItems(string apiPrefix, Guid clientId, Guid shoppingCartId) => + $"{ShoppingCart(apiPrefix, clientId, shoppingCartId)}product-items/"; + + public static string ShoppingCartProductItem(string apiPrefix, Guid clientId, Guid shoppingCartId, Guid productId, + int quantity = 1, decimal unitPrice = 100) => + $"{ShoppingCartProductItems(apiPrefix, clientId, shoppingCartId)}{productId}?unitPrice={unitPrice}&quantity={quantity}"; +} + +public static class Scenarios +{ + public static RequestDefinition OpenedShoppingCart(string apiPrefix, Guid clientId) => + SEND(POST, URI(Fixtures.ShoppingCarts(apiPrefix, clientId))); + + public static RequestDefinition WithProductItem(string apiPrefix, Guid clientId, ProductItemRequest productItem) => + SEND( + POST, + URI(ctx => Fixtures.ShoppingCartProductItems(apiPrefix, clientId, ctx.GetCreatedId())), + BODY(new AddProductRequest(productItem)) + ); + + public static RequestDefinition ThenConfirmed(string apiPrefix, Guid clientId) => + SEND( + POST, + URI(ctx => Fixtures.ConfirmShoppingCart(apiPrefix, clientId, ctx.GetCreatedId())) + ); + + public static RequestDefinition ThenCanceled(string apiPrefix, Guid clientId) => + SEND( + DELETE, + URI(ctx => Fixtures.ShoppingCart(apiPrefix, clientId, ctx.GetCreatedId())) + ); +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/OpenShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/OpenShoppingCartTests.cs new file mode 100644 index 000000000..fb8f7778a --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/OpenShoppingCartTests.cs @@ -0,0 +1,31 @@ +using Ogooreck.API; +using Xunit; +using static Ogooreck.API.ApiSpecification; +using static ApplicationLogic.Marten.Tests.Incidents.Scenarios; +using static ApplicationLogic.Marten.Tests.Incidents.Fixtures; + +namespace ApplicationLogic.Marten.Tests.Incidents; + +public class OpenShoppingCartTests(ApiSpecification api): + IClassFixture> +{ + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task OpensShoppingCart(string apiPrefix) => + api.Given() + .When(POST, URI(ShoppingCarts(apiPrefix, ClientId))) + .Then(CREATED_WITH_DEFAULT_HEADERS(locationHeaderPrefix: ShoppingCarts(apiPrefix, ClientId))); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task ReturnsOpenedShoppingCart(string apiPrefix) => + api.Given(OpenedShoppingCart(apiPrefix, ClientId)) + .When(GET, URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId()))) + .Then(OK); + + private readonly Guid ClientId = Guid.NewGuid(); +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/RemoveProductItemFromShoppingCartTests.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/RemoveProductItemFromShoppingCartTests.cs new file mode 100644 index 000000000..6b1503465 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Incidents/RemoveProductItemFromShoppingCartTests.cs @@ -0,0 +1,118 @@ +using System.Net; +using ApplicationLogic.Marten.Immutable.ShoppingCarts; +using Bogus; +using Ogooreck.API; +using Xunit; +using static Ogooreck.API.ApiSpecification; +using static ApplicationLogic.Marten.Tests.Incidents.Scenarios; +using static ApplicationLogic.Marten.Tests.Incidents.Fixtures; + +namespace ApplicationLogic.Marten.Tests.Incidents; + +public class RemoveProductItemFromShoppingCartTests(ApiSpecification api): + IClassFixture> +{ + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantRemoveProductItemFromNotExistingShoppingCart(string apiPrefix) => + api.Given() + .When( + DELETE, + URI(ShoppingCartProductItem(apiPrefix, ClientId, NotExistingShoppingCartId, ProductItem.ProductId!.Value)) + ) + .Then(NOT_FOUND); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantRemoveProductItemFromEmptyShoppingCart(string apiPrefix) => + api.Given(OpenedShoppingCart(apiPrefix, ClientId)) + .When( + DELETE, + URI(ctx => ShoppingCartProductItem(apiPrefix, ClientId, ctx.GetCreatedId(), ProductItem.ProductId!.Value)) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CanRemoveExistingProductItemFromShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem) + ) + .When( + DELETE, + URI(ctx => ShoppingCartProductItem(apiPrefix, ClientId, ctx.GetCreatedId(), ProductItem.ProductId!.Value)) + ) + .Then(NO_CONTENT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantRemoveNonExistingProductItemFromEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem) + ) + .When( + DELETE, + URI(ctx => ShoppingCartProductItem(apiPrefix, ClientId, ctx.GetCreatedId(), NotExistingProductItem.ProductId!.Value)) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantRemoveExistingProductItemFromCanceledShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenCanceled(apiPrefix, ClientId) + ) + .When( + DELETE, + URI(ctx => ShoppingCartProductItem(apiPrefix, ClientId, ctx.GetCreatedId(), ProductItem.ProductId!.Value)) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task CantRemoveExistingProductItemFromConfirmedShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem), + ThenConfirmed(apiPrefix, ClientId) + ) + .When( + DELETE, + URI(ctx => ShoppingCartProductItem(apiPrefix, ClientId, ctx.GetCreatedId(), ProductItem.ProductId!.Value)) + ) + .Then(CONFLICT); + + [Theory] + [InlineData("immutable")] + [InlineData("mutable")] + [InlineData("mixed")] + public Task ReturnsNonEmptyShoppingCart(string apiPrefix) => + api.Given( + OpenedShoppingCart(apiPrefix, ClientId), + WithProductItem(apiPrefix, ClientId, ProductItem) + ) + .When(GET, URI(ctx => ShoppingCart(apiPrefix, ClientId, ctx.GetCreatedId()))) + .Then(OK); + + private static readonly Faker Faker = new(); + private readonly Guid NotExistingShoppingCartId = Guid.NewGuid(); + private readonly Guid ClientId = Guid.NewGuid(); + private readonly ProductItemRequest ProductItem = new(Guid.NewGuid(), Faker.Random.Number(1, 500)); + private readonly ProductItemRequest NotExistingProductItem = new(Guid.NewGuid(), 1); +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Settings.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Settings.cs new file mode 100644 index 000000000..b95fadcec --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten.Tests/Settings.cs @@ -0,0 +1,17 @@ +using Oakton; +using Xunit; +using Xunit.Abstractions; +using Xunit.Sdk; + +[assembly: TestFramework("ApplicationLogic.Marten.Tests.AssemblyFixture", "ApplicationLogic.Marten.Tests")] + +namespace ApplicationLogic.Marten.Tests; + +public sealed class AssemblyFixture : XunitTestFramework +{ + public AssemblyFixture(IMessageSink messageSink) + :base(messageSink) + { + OaktonEnvironment.AutoStartHost = true; + } +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/10-OptimisticConcurrency.Marten.csproj b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/10-OptimisticConcurrency.Marten.csproj index 2e02ece76..9e9376a6e 100644 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/10-OptimisticConcurrency.Marten.csproj +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/10-OptimisticConcurrency.Marten.csproj @@ -1,23 +1,21 @@ - + net8.0 - IntroductionToEventSourcing.OptimisticConcurrency + enable + true + enable + ApplicationLogic.Marten - + + + - - - - all - runtime; build; native; contentfiles; analyzers - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - + + + diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Entities/Aggregate.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Entities/Aggregate.cs new file mode 100644 index 000000000..d5d146210 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Entities/Aggregate.cs @@ -0,0 +1,39 @@ +namespace ApplicationLogic.Marten.Core.Entities; + +public interface IAggregate +{ + public void Apply(object @event); + + object[] DequeueUncommittedEvents(); +} + +public abstract class Aggregate: IAggregate +{ + public Guid Id { get; protected set; } = default!; + + private readonly Queue uncommittedEvents = new(); + + protected virtual void Apply(TEvent @event) { } + + public object[] DequeueUncommittedEvents() + { + var dequeuedEvents = uncommittedEvents.ToArray(); + + uncommittedEvents.Clear(); + + return dequeuedEvents; + } + + protected void Enqueue(object @event) + { + uncommittedEvents.Enqueue(@event); + } + + public void Apply(object @event) + { + if(@event is not TEvent typed) + return; + + Apply(typed); + } +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Exceptions/NotFoundException.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Exceptions/NotFoundException.cs new file mode 100644 index 000000000..77586df02 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Exceptions/NotFoundException.cs @@ -0,0 +1,13 @@ +namespace ApplicationLogic.Marten.Core.Exceptions; + +public class NotFoundException: Exception +{ + private NotFoundException(string typeName, string id): base($"{typeName} with id '{id}' was not found") + { + } + + public static NotFoundException For(Guid id) => + For(id.ToString()); + + public static NotFoundException For(string id) => new(typeof(T).Name, id); +} diff --git a/Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Http/ETagExtensions.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Http/ETagExtensions.cs similarity index 100% rename from Workshops/IntroductionToEventSourcing/08-ApplicationLogic.Marten/Core/Http/ETagExtensions.cs rename to Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Http/ETagExtensions.cs diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Marten/DocumentSessionExtensions.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Marten/DocumentSessionExtensions.cs new file mode 100644 index 000000000..d51da9ae2 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Marten/DocumentSessionExtensions.cs @@ -0,0 +1,44 @@ +using ApplicationLogic.Marten.Core.Entities; +using ApplicationLogic.Marten.Core.Exceptions; +using Marten; + +namespace ApplicationLogic.Marten.Core.Marten; + +public static class DocumentSessionExtensions +{ + public static Task Add(this IDocumentSession documentSession, Guid id, object @event, CancellationToken ct) + where T : class + { + documentSession.Events.StartStream(id, @event); + return documentSession.SaveChangesAsync(token: ct); + } + + public static Task Add(this IDocumentSession documentSession, Guid id, object[] events, CancellationToken ct) + where T : class + { + documentSession.Events.StartStream(id, events); + return documentSession.SaveChangesAsync(token: ct); + } + + public static Task GetAndUpdate( + this IDocumentSession documentSession, + Guid id, + Func handle, + CancellationToken ct + ) where T : class => + documentSession.Events.WriteToAggregate(id, stream => + stream.AppendMany(handle(stream.Aggregate ?? throw NotFoundException.For(id))), ct); + + public static Task GetAndUpdate( + this IDocumentSession documentSession, + Guid id, + Action handle, + CancellationToken ct + ) where T : class, IAggregate => + documentSession.Events.WriteToAggregate(id, stream => + { + var aggregate = stream.Aggregate ?? throw NotFoundException.For(id); + handle(aggregate); + stream.AppendMany(aggregate.DequeueUncommittedEvents()); + }, ct); +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Marten/EventMappings.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Marten/EventMappings.cs new file mode 100644 index 000000000..53865724d --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Core/Marten/EventMappings.cs @@ -0,0 +1,12 @@ +using Marten; + +namespace ApplicationLogic.Marten.Core.Marten; + +public static class EventMappings +{ + public static StoreOptions MapEventWithPrefix(this StoreOptions options, string prefix) where T : class + { + options.Events.MapEventType($"{prefix}-${typeof(T).FullName}"); + return options; + } +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/BusinessLogic.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/BusinessLogic.cs deleted file mode 100644 index afab8942c..000000000 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/BusinessLogic.cs +++ /dev/null @@ -1,258 +0,0 @@ -namespace IntroductionToEventSourcing.OptimisticConcurrency.Immutable; -using static ShoppingCartEvent; - -// ENTITY -public record ShoppingCart( - Guid Id, - Guid ClientId, - ShoppingCartStatus Status, - PricedProductItem[] ProductItems, - DateTime? ConfirmedAt = null, - DateTime? CanceledAt = null -){ - public bool IsClosed => ShoppingCartStatus.Closed.HasFlag(Status); - - public static ShoppingCart Create(ShoppingCartOpened opened) => - new ShoppingCart( - opened.ShoppingCartId, - opened.ClientId, - ShoppingCartStatus.Pending, - [] - ); - - public ShoppingCart Apply(ProductItemAddedToShoppingCart productItemAdded) => - this with - { - ProductItems = ProductItems - .Concat(new[] { productItemAdded.ProductItem }) - .GroupBy(pi => pi.ProductId) - .Select(group => group.Count() == 1 - ? group.First() - : new PricedProductItem( - group.Key, - group.Sum(pi => pi.Quantity), - group.First().UnitPrice - ) - ) - .ToArray() - }; - - public ShoppingCart Apply(ProductItemRemovedFromShoppingCart productItemRemoved) => - this with - { - ProductItems = ProductItems - .Select(pi => pi.ProductId == productItemRemoved.ProductItem.ProductId - ? new PricedProductItem( - pi.ProductId, - pi.Quantity - productItemRemoved.ProductItem.Quantity, - pi.UnitPrice - ) - : pi - ) - .Where(pi => pi.Quantity > 0) - .ToArray() - }; - - public ShoppingCart Apply(ShoppingCartConfirmed confirmed) => - this with - { - Status = ShoppingCartStatus.Confirmed, - ConfirmedAt = confirmed.ConfirmedAt - }; - - public ShoppingCart Apply(ShoppingCartCanceled canceled) => - this with - { - Status = ShoppingCartStatus.Canceled, - CanceledAt = canceled.CanceledAt - }; - - public bool HasEnough(PricedProductItem productItem) - { - var (productId, quantity, _) = productItem; - var currentQuantity = ProductItems.Where(pi => pi.ProductId == productId) - .Select(pi => pi.Quantity) - .FirstOrDefault(); - - return currentQuantity >= quantity; - } -} - -public enum ShoppingCartStatus -{ - Pending = 1, - Confirmed = 2, - Canceled = 4, - - Closed = Confirmed | Canceled -} - -public record OpenShoppingCart( - Guid ShoppingCartId, - Guid ClientId -) -{ - public static OpenShoppingCart From(Guid? cartId, Guid? clientId) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - if (clientId == null || clientId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(clientId)); - - return new OpenShoppingCart(cartId.Value, clientId.Value); - } - - public static ShoppingCartOpened Handle(OpenShoppingCart command) - { - var (shoppingCartId, clientId) = command; - - return new ShoppingCartOpened( - shoppingCartId, - clientId - ); - } -} - -public record AddProductItemToShoppingCart( - Guid ShoppingCartId, - ProductItem ProductItem -) -{ - public static AddProductItemToShoppingCart From(Guid? cartId, ProductItem? productItem) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - if (productItem == null) - throw new ArgumentOutOfRangeException(nameof(productItem)); - - return new AddProductItemToShoppingCart(cartId.Value, productItem); - } - - public static ProductItemAddedToShoppingCart Handle( - IProductPriceCalculator productPriceCalculator, - AddProductItemToShoppingCart command, - ShoppingCart shoppingCart - ) - { - var (cartId, productItem) = command; - - if (shoppingCart.IsClosed) - throw new InvalidOperationException( - $"Adding product item for cart in '{shoppingCart.Status}' status is not allowed."); - - var pricedProductItem = productPriceCalculator.Calculate(productItem); - - return new ProductItemAddedToShoppingCart( - cartId, - pricedProductItem - ); - } -} - -public record RemoveProductItemFromShoppingCart( - Guid ShoppingCartId, - PricedProductItem ProductItem -) -{ - public static RemoveProductItemFromShoppingCart From(Guid? cartId, PricedProductItem? productItem) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - if (productItem == null) - throw new ArgumentOutOfRangeException(nameof(productItem)); - - return new RemoveProductItemFromShoppingCart(cartId.Value, productItem); - } - - public static ProductItemRemovedFromShoppingCart Handle( - RemoveProductItemFromShoppingCart command, - ShoppingCart shoppingCart - ) - { - var (cartId, productItem) = command; - - if (shoppingCart.IsClosed) - throw new InvalidOperationException( - $"Adding product item for cart in '{shoppingCart.Status}' status is not allowed."); - - if (!shoppingCart.HasEnough(productItem)) - throw new InvalidOperationException("Not enough product items to remove"); - - return new ProductItemRemovedFromShoppingCart( - cartId, - productItem - ); - } -} - -public record ConfirmShoppingCart( - Guid ShoppingCartId -) -{ - public static ConfirmShoppingCart From(Guid? cartId) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - - return new ConfirmShoppingCart(cartId.Value); - } - - public static ShoppingCartConfirmed Handle(ConfirmShoppingCart command, ShoppingCart shoppingCart) - { - if (shoppingCart.IsClosed) - throw new InvalidOperationException($"Confirming cart in '{shoppingCart.Status}' status is not allowed."); - - return new ShoppingCartConfirmed( - shoppingCart.Id, - DateTime.UtcNow - ); - } -} - -public record CancelShoppingCart( - Guid ShoppingCartId -) -{ - public static CancelShoppingCart From(Guid? cartId) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - - return new CancelShoppingCart(cartId.Value); - } - - public static ShoppingCartCanceled Handle(CancelShoppingCart command, ShoppingCart shoppingCart) - { - if (shoppingCart.IsClosed) - throw new InvalidOperationException($"Canceling cart in '{shoppingCart.Status}' status is not allowed."); - - return new ShoppingCartCanceled( - shoppingCart.Id, - DateTime.UtcNow - ); - } -} - -public interface IProductPriceCalculator -{ - PricedProductItem Calculate(ProductItem productItems); -} - -public class FakeProductPriceCalculator: IProductPriceCalculator -{ - private readonly int value; - - private FakeProductPriceCalculator(int value) - { - this.value = value; - } - - public static FakeProductPriceCalculator Returning(int value) => new(value); - - public PricedProductItem Calculate(ProductItem productItem) - { - var (productId, quantity) = productItem; - - return new PricedProductItem(productId, quantity, value); - } -} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/DocumentSessionExtensions.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/DocumentSessionExtensions.cs deleted file mode 100644 index 043a09482..000000000 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/DocumentSessionExtensions.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Marten; - -namespace IntroductionToEventSourcing.OptimisticConcurrency.Immutable; - -public static class DocumentSessionExtensions -{ - public static Task Get( - this IDocumentSession session, - Guid id, - CancellationToken cancellationToken = default - ) where TEntity : class - { - // Fill logic here. - throw new NotImplementedException(); - } - - public static Task Add( - this IDocumentSession session, - Func getId, - Func action, - TCommand command, - CancellationToken cancellationToken = default - ) where TEntity : class - { - // Fill logic here. - throw new NotImplementedException(); - } - - public static Task GetAndUpdate( - this IDocumentSession session, - Func getId, - Func action, - TCommand command, - long version, - CancellationToken cancellationToken = default - ) where TEntity : class - { - // Fill logic here. - throw new NotImplementedException(); - } -} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/OptimisticConcurrencyTests.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/OptimisticConcurrencyTests.cs deleted file mode 100644 index 2f4aa36d9..000000000 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/OptimisticConcurrencyTests.cs +++ /dev/null @@ -1,132 +0,0 @@ -using FluentAssertions; -using IntroductionToEventSourcing.OptimisticConcurrency.Tools; -using Marten.Exceptions; -using Xunit; - -namespace IntroductionToEventSourcing.OptimisticConcurrency.Immutable; - -// EVENTS -public abstract record ShoppingCartEvent -{ - public record ShoppingCartOpened( - Guid ShoppingCartId, - Guid ClientId - ): ShoppingCartEvent; - - public record ProductItemAddedToShoppingCart( - Guid ShoppingCartId, - PricedProductItem ProductItem - ): ShoppingCartEvent; - - public record ProductItemRemovedFromShoppingCart( - Guid ShoppingCartId, - PricedProductItem ProductItem - ): ShoppingCartEvent; - - public record ShoppingCartConfirmed( - Guid ShoppingCartId, - DateTime ConfirmedAt - ): ShoppingCartEvent; - - public record ShoppingCartCanceled( - Guid ShoppingCartId, - DateTime CanceledAt - ): ShoppingCartEvent; - - // This won't allow external inheritance - private ShoppingCartEvent(){} -} - -// VALUE OBJECTS - -public record ProductItem( - Guid ProductId, - int Quantity -); - -public record PricedProductItem( - Guid ProductId, - int Quantity, - decimal UnitPrice -); - -// Business logic - -public class OptimisticConcurrencyTests: MartenTest -{ - [Fact] - [Trait("Category", "SkipCI")] - public async Task GettingState_ForSequenceOfEvents_ShouldSucceed() - { - var shoppingCartId = Guid.NewGuid(); - var clientId = Guid.NewGuid(); - var shoesId = Guid.NewGuid(); - var tShirtId = Guid.NewGuid(); - var twoPairsOfShoes = new ProductItem(shoesId, 2); - var tShirt = new ProductItem(tShirtId, 1); - - var shoesPrice = 100; - var tShirtPrice = 50; - - // Open - await DocumentSession.Add( - command => command.ShoppingCartId, - OpenShoppingCart.Handle, - OpenShoppingCart.From(shoppingCartId, clientId), - CancellationToken.None - ); - - // Try to open again - // Should fail as stream was already created - var exception = await Record.ExceptionAsync(async () => - { - await DocumentSession.Add( - command => command.ShoppingCartId, - OpenShoppingCart.Handle, - OpenShoppingCart.From(shoppingCartId, clientId), - CancellationToken.None - ); - } - ); - exception.Should().BeOfType(); - ReOpenSession(); - - // Add two pairs of shoes - await DocumentSession.GetAndUpdate( - command => command.ShoppingCartId, - (command, shoppingCart) => - AddProductItemToShoppingCart.Handle(FakeProductPriceCalculator.Returning(shoesPrice), command, shoppingCart), - AddProductItemToShoppingCart.From(shoppingCartId, twoPairsOfShoes), - 1, - CancellationToken.None - ); - - // Add T-Shirt - // Should fail because of sending the same expected version as previous call - exception = await Record.ExceptionAsync(async () => - { - await DocumentSession.GetAndUpdate( - command => command.ShoppingCartId, - (command, shoppingCart) => - AddProductItemToShoppingCart.Handle(FakeProductPriceCalculator.Returning(tShirtPrice), command, shoppingCart), - AddProductItemToShoppingCart.From(shoppingCartId, tShirt), - 1, - CancellationToken.None - ); - } - ); - exception.Should().BeOfType(); - ReOpenSession(); - - var shoppingCart = await DocumentSession.Get(shoppingCartId, CancellationToken.None); - - shoppingCart.Id.Should().Be(shoppingCartId); - shoppingCart.ClientId.Should().Be(clientId); - shoppingCart.ProductItems.Should().HaveCount(1); - shoppingCart.Status.Should().Be(ShoppingCartStatus.Pending); - - shoppingCart.ProductItems[0].ProductId.Should().Be(shoesId); - shoppingCart.ProductItems[0].Quantity.Should().Be(twoPairsOfShoes.Quantity); - shoppingCart.ProductItems[0].UnitPrice.Should().Be(shoesPrice); - } -} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/Pricing/PricingCalculator.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/Pricing/PricingCalculator.cs new file mode 100644 index 000000000..c999ee055 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/Pricing/PricingCalculator.cs @@ -0,0 +1,26 @@ +using ApplicationLogic.Marten.Immutable.ShoppingCarts; + +namespace ApplicationLogic.Marten.Immutable.Pricing; + +public interface IProductPriceCalculator +{ + PricedProductItem Calculate(ProductItem productItems); +} + +public class FakeProductPriceCalculator: IProductPriceCalculator +{ + private readonly int value; + + private FakeProductPriceCalculator(int value) + { + this.value = value; + } + + public static FakeProductPriceCalculator Returning(int value) => new(value); + + public PricedProductItem Calculate(ProductItem productItem) + { + var (productId, quantity) = productItem; + return new PricedProductItem(productId, quantity, value); + } +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/Pricing/ProductItems.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/Pricing/ProductItems.cs new file mode 100644 index 000000000..1fc2ef71e --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/Pricing/ProductItems.cs @@ -0,0 +1,13 @@ +namespace ApplicationLogic.Marten.Immutable.Pricing; + +public record PricedProductItem( + Guid ProductId, + int Quantity, + decimal UnitPrice +) +{ + public decimal TotalPrice => Quantity * UnitPrice; +} + +public record ProductItem(Guid ProductId, int Quantity); + diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/Api.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/Api.cs new file mode 100644 index 000000000..e027e0639 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/Api.cs @@ -0,0 +1,133 @@ +using ApplicationLogic.Marten.Core.Marten; +using ApplicationLogic.Marten.Immutable.Pricing; +using Core.Validation; +using Marten; +using Marten.Schema.Identity; +using Microsoft.AspNetCore.Mvc; +using static Microsoft.AspNetCore.Http.TypedResults; +using static System.DateTimeOffset; + +namespace ApplicationLogic.Marten.Immutable.ShoppingCarts; + +using static ShoppingCartService; +using static ShoppingCartCommand; + +public static class Api +{ + public static WebApplication ConfigureImmutableShoppingCarts(this WebApplication app) + { + var clients = app.MapGroup("/api/immutable/clients/{clientId:guid}/"); + var shoppingCarts = clients.MapGroup("shopping-carts"); + var shoppingCart = shoppingCarts.MapGroup("{shoppingCartId:guid}"); + var productItems = shoppingCart.MapGroup("product-items"); + + shoppingCarts.MapPost("", + async (IDocumentSession session, + Guid clientId, + CancellationToken ct) => + { + var shoppingCartId = CombGuidIdGeneration.NewGuid(); + + await session.Add(shoppingCartId, + [Handle(new OpenShoppingCart(shoppingCartId, clientId.NotEmpty(), Now))], ct); + + return Created($"/api/immutable/clients/{clientId}/shopping-carts/{shoppingCartId}", shoppingCartId); + } + ); + + productItems.MapPost("", + async ( + IProductPriceCalculator pricingCalculator, + IDocumentSession session, + Guid shoppingCartId, + AddProductRequest body, + CancellationToken ct) => + { + var productItem = body.ProductItem.NotNull().ToProductItem(); + + await session.GetAndUpdate(shoppingCartId, + state => + [ + Handle(pricingCalculator, + new AddProductItemToShoppingCart(shoppingCartId, productItem, Now), + state) + ], ct); + + return NoContent(); + } + ); + + productItems.MapDelete("{productId:guid}", + async ( + IDocumentSession session, + Guid shoppingCartId, + [FromRoute] Guid productId, + [FromQuery] int? quantity, + [FromQuery] decimal? unitPrice, + CancellationToken ct) => + { + var productItem = new PricedProductItem( + productId.NotEmpty(), + quantity.NotNull().Positive(), + unitPrice.NotNull().Positive() + ); + + await session.GetAndUpdate(shoppingCartId, + state => [Handle(new RemoveProductItemFromShoppingCart(shoppingCartId, productItem, Now), state)], + ct); + + return NoContent(); + } + ); + + shoppingCart.MapPost("confirm", + async (IDocumentSession session, + Guid shoppingCartId, + CancellationToken ct) => + { + await session.GetAndUpdate(shoppingCartId, + state => [Handle(new ConfirmShoppingCart(shoppingCartId, Now), state)], ct); + + return NoContent(); + } + ); + + shoppingCart.MapDelete("", + async (IDocumentSession session, + Guid shoppingCartId, + CancellationToken ct) => + { + await session.GetAndUpdate(shoppingCartId, + state => [Handle(new CancelShoppingCart(shoppingCartId, Now), state)], ct); + + return NoContent(); + } + ); + + shoppingCart.MapGet("", + async Task ( + IQuerySession session, + Guid shoppingCartId, + CancellationToken ct) => + { + var result = await session.Events.AggregateStreamAsync(shoppingCartId, token: ct); + + return result is not null ? Ok(result) : NotFound(); + } + ); + + return app; + } +} + +public record ProductItemRequest( + Guid? ProductId, + int? Quantity +) +{ + public ProductItem ToProductItem() => new(ProductId.NotEmpty(), Quantity.NotNull().Positive()); +} + +public record AddProductRequest( + ProductItemRequest? ProductItem +); diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/Configure.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/Configure.cs new file mode 100644 index 000000000..c79e79181 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/Configure.cs @@ -0,0 +1,30 @@ +using ApplicationLogic.Marten.Core.Marten; +using ApplicationLogic.Marten.Immutable.Pricing; +using Marten; + +namespace ApplicationLogic.Marten.Immutable.ShoppingCarts; +using static ShoppingCartEvent; + +public static class Configure +{ + private const string ModulePrefix = "immutable"; + public static IServiceCollection AddImmutableShoppingCarts(this IServiceCollection services) + { + services.AddSingleton(FakeProductPriceCalculator.Returning(100)); + + return services; + } + public static StoreOptions ConfigureImmutableShoppingCarts(this StoreOptions options) + { + options.Projections.LiveStreamAggregation(); + + // this is needed as we're sharing document store and have event types with the same name + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + + return options; + } +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/ShoppingCart.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/ShoppingCart.cs new file mode 100644 index 000000000..b8356e553 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/ShoppingCart.cs @@ -0,0 +1,132 @@ +using ApplicationLogic.Marten.Immutable.Pricing; + +namespace ApplicationLogic.Marten.Immutable.ShoppingCarts; +using static ShoppingCartEvent; + +// EVENTS +public abstract record ShoppingCartEvent +{ + public record ShoppingCartOpened( + Guid ShoppingCartId, + Guid ClientId, + DateTimeOffset OpenedAt + ): ShoppingCartEvent; + + public record ProductItemAddedToShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset AddedAt + ): ShoppingCartEvent; + + public record ProductItemRemovedFromShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset RemovedAt + ): ShoppingCartEvent; + + public record ShoppingCartConfirmed( + Guid ShoppingCartId, + DateTimeOffset ConfirmedAt + ): ShoppingCartEvent; + + public record ShoppingCartCanceled( + Guid ShoppingCartId, + DateTimeOffset CanceledAt + ): ShoppingCartEvent; + + // This won't allow external inheritance + private ShoppingCartEvent(){} +} + +// ENTITY +public record ShoppingCart( + Guid Id, + Guid ClientId, + ShoppingCartStatus Status, + PricedProductItem[] ProductItems, + DateTimeOffset OpenedAt, + DateTimeOffset? ConfirmedAt = null, + DateTimeOffset? CanceledAt = null +) +{ + public bool IsClosed => ShoppingCartStatus.Closed.HasFlag(Status); + + public bool HasEnough(PricedProductItem productItem) + { + var (productId, quantity, _) = productItem; + var currentQuantity = ProductItems.Where(pi => pi.ProductId == productId) + .Select(pi => pi.Quantity) + .FirstOrDefault(); + + return currentQuantity >= quantity; + } + + public static ShoppingCart Default() => + new (default, default, default, [], default); + + public ShoppingCart Apply(ShoppingCartEvent @event) + { + return @event switch + { + ShoppingCartOpened(var shoppingCartId, var clientId, _) => + this with + { + Id = shoppingCartId, + ClientId = clientId, + Status = ShoppingCartStatus.Pending + }, + ProductItemAddedToShoppingCart(_, var pricedProductItem, _) => + this with + { + ProductItems = ProductItems + .Concat(new [] { pricedProductItem }) + .GroupBy(pi => pi.ProductId) + .Select(group => group.Count() == 1? + group.First() + : new PricedProductItem( + group.Key, + group.Sum(pi => pi.Quantity), + group.First().UnitPrice + ) + ) + .ToArray() + }, + ProductItemRemovedFromShoppingCart(_, var pricedProductItem, _) => + this with + { + ProductItems = ProductItems + .Select(pi => pi.ProductId == pricedProductItem.ProductId? + pi with { Quantity = pi.Quantity - pricedProductItem.Quantity } + :pi + ) + .Where(pi => pi.Quantity > 0) + .ToArray() + }, + ShoppingCartConfirmed(_, var confirmedAt) => + this with + { + Status = ShoppingCartStatus.Confirmed, + ConfirmedAt = confirmedAt + }, + ShoppingCartCanceled(_, var canceledAt) => + this with + { + Status = ShoppingCartStatus.Canceled, + CanceledAt = canceledAt + }, + _ => this + }; + } + + // let's make Marten happy + private ShoppingCart():this(Guid.Empty, Guid.Empty, ShoppingCartStatus.Pending, [], default){} +} + +public enum ShoppingCartStatus +{ + Pending = 1, + Confirmed = 2, + Canceled = 4, + + Closed = Confirmed | Canceled +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/ShoppingCartService.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/ShoppingCartService.cs new file mode 100644 index 000000000..d19cde607 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Immutable/ShoppingCarts/ShoppingCartService.cs @@ -0,0 +1,120 @@ +using ApplicationLogic.Marten.Immutable.Pricing; +using Core.Validation; + +namespace ApplicationLogic.Marten.Immutable.ShoppingCarts; +using static ShoppingCartEvent; +using static ShoppingCartCommand; + +public abstract record ShoppingCartCommand +{ + public record OpenShoppingCart( + Guid ShoppingCartId, + Guid ClientId, + DateTimeOffset Now + ): ShoppingCartCommand; + + public record AddProductItemToShoppingCart( + Guid ShoppingCartId, + ProductItem ProductItem, + DateTimeOffset Now + ); + + public record RemoveProductItemFromShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset Now + ); + + public record ConfirmShoppingCart( + Guid ShoppingCartId, + DateTimeOffset Now + ); + + public record CancelShoppingCart( + Guid ShoppingCartId, + DateTimeOffset Now + ): ShoppingCartCommand; + + private ShoppingCartCommand() {} +} + +public static class ShoppingCartService +{ + public static ShoppingCartOpened Handle(OpenShoppingCart command) + { + var (shoppingCartId, clientId, now) = command; + + return new ShoppingCartOpened( + shoppingCartId, + clientId, + now + ); + } + + public static ProductItemAddedToShoppingCart Handle( + IProductPriceCalculator priceCalculator, + AddProductItemToShoppingCart command, + ShoppingCart shoppingCart + ) + { + var (cartId, productItem, now) = command; + + if (shoppingCart.IsClosed) + throw new InvalidOperationException( + $"Adding product item for cart in '{shoppingCart.Status}' status is not allowed."); + + var pricedProductItem = priceCalculator.Calculate(productItem); + + return new ProductItemAddedToShoppingCart( + cartId, + pricedProductItem, + now + ); + } + + public static ProductItemRemovedFromShoppingCart Handle( + RemoveProductItemFromShoppingCart command, + ShoppingCart shoppingCart + ) + { + var (cartId, productItem, now) = command; + + if (shoppingCart.IsClosed) + throw new InvalidOperationException( + $"Adding product item for cart in '{shoppingCart.Status}' status is not allowed."); + + if (!shoppingCart.HasEnough(productItem)) + throw new InvalidOperationException("Not enough product items to remove"); + + return new ProductItemRemovedFromShoppingCart( + cartId, + productItem, + now + ); + } + + public static ShoppingCartConfirmed Handle(ConfirmShoppingCart command, ShoppingCart shoppingCart) + { + if (shoppingCart.IsClosed) + throw new InvalidOperationException($"Confirming cart in '{shoppingCart.Status}' status is not allowed."); + + if(shoppingCart.ProductItems.Length == 0) + throw new InvalidOperationException($"Cannot confirm empty shopping cart"); + + return new ShoppingCartConfirmed( + shoppingCart.Id, + DateTime.UtcNow + ); + } + + public static ShoppingCartCanceled Handle(CancelShoppingCart command, ShoppingCart shoppingCart) + { + if (shoppingCart.IsClosed) + throw new InvalidOperationException($"Canceling cart in '{shoppingCart.Status}' status is not allowed."); + + return new ShoppingCartCanceled( + shoppingCart.Id, + command.Now + ); + } +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/DocumentSessionExtensions.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/DocumentSessionExtensions.cs deleted file mode 100644 index 59f0c80aa..000000000 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/DocumentSessionExtensions.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Marten; - -namespace IntroductionToEventSourcing.OptimisticConcurrency.Mixed; - -public static class DocumentSessionExtensions -{ - public static Task Get( - this IDocumentSession session, - Guid id, - CancellationToken cancellationToken = default - ) where TAggregate : class, IAggregate - { - // Fill logic here. - throw new NotImplementedException(); - } - - public static Task Add( - this IDocumentSession session, - Func getId, - Func action, - TCommand command, - CancellationToken cancellationToken = default - ) where TAggregate : class, IAggregate - { - // Fill logic here. - throw new NotImplementedException(); - } - - public static Task GetAndUpdate( - this IDocumentSession session, - Func getId, - Func action, - TCommand command, - long version, - CancellationToken cancellationToken = default - ) where TAggregate : class, IAggregate - { - // Fill logic here. - throw new NotImplementedException(); - } -} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/OptimisticConcurrencyTests.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/OptimisticConcurrencyTests.cs deleted file mode 100644 index 35c48d0a4..000000000 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/OptimisticConcurrencyTests.cs +++ /dev/null @@ -1,147 +0,0 @@ -using FluentAssertions; -using IntroductionToEventSourcing.OptimisticConcurrency.Tools; -using Marten.Exceptions; -using Xunit; - -namespace IntroductionToEventSourcing.OptimisticConcurrency.Mixed; - -// EVENTS -public abstract record ShoppingCartEvent -{ - public record ShoppingCartOpened( - Guid ShoppingCartId, - Guid ClientId - ): ShoppingCartEvent; - - public record ProductItemAddedToShoppingCart( - Guid ShoppingCartId, - PricedProductItem ProductItem - ): ShoppingCartEvent; - - public record ProductItemRemovedFromShoppingCart( - Guid ShoppingCartId, - PricedProductItem ProductItem - ): ShoppingCartEvent; - - public record ShoppingCartConfirmed( - Guid ShoppingCartId, - DateTime ConfirmedAt - ): ShoppingCartEvent; - - public record ShoppingCartCanceled( - Guid ShoppingCartId, - DateTime CanceledAt - ): ShoppingCartEvent; - - // This won't allow external inheritance - private ShoppingCartEvent(){} -} - -// VALUE OBJECTS -public record ProductItem( - Guid ProductId, - int Quantity -); - -public record PricedProductItem( - Guid ProductId, - int Quantity, - decimal UnitPrice -); - -public static class ShoppingCartExtensions -{ - public static ShoppingCart GetShoppingCart(this IEnumerable events) - { - var shoppingCart = (ShoppingCart)Activator.CreateInstance(typeof(ShoppingCart), true)!; - - foreach (var @event in events) - { - shoppingCart.Evolve(@event); - } - - return shoppingCart; - } -} - -public class OptimisticConcurrencyTests: MartenTest -{ - [Fact] - [Trait("Category", "SkipCI")] - public async Task GettingState_ForSequenceOfEvents_ShouldSucceed() - { - var shoppingCartId = Guid.NewGuid(); - var clientId = Guid.NewGuid(); - var shoesId = Guid.NewGuid(); - var tShirtId = Guid.NewGuid(); - - var twoPairsOfShoes = new ProductItem(shoesId, 2); - var tShirt = new ProductItem(tShirtId, 1); - - var shoesPrice = 100; - var tShirtPrice = 50; - - // Open - await DocumentSession.Add( - command => command.ShoppingCartId, - command => - ShoppingCart.Open(command.ShoppingCartId, command.ClientId).Event, - OpenShoppingCart.From(shoppingCartId, clientId), - CancellationToken.None - ); - - // Try to open again - // Should fail as stream was already created - var exception = await Record.ExceptionAsync(async () => - { - await DocumentSession.Add( - command => command.ShoppingCartId, - command => - ShoppingCart.Open(command.ShoppingCartId, command.ClientId).Event, - OpenShoppingCart.From(shoppingCartId, clientId), - CancellationToken.None - ); - } - ); - exception.Should().BeOfType(); - ReOpenSession(); - - // Add two pairs of shoes - await DocumentSession.GetAndUpdate( - command => command.ShoppingCartId, - (command, shoppingCart) => - shoppingCart.AddProduct(FakeProductPriceCalculator.Returning(shoesPrice), command.ProductItem), - AddProductItemToShoppingCart.From(shoppingCartId, twoPairsOfShoes), - 1, - CancellationToken.None - ); - - // Add T-Shirt - // Should fail because of sending the same expected version as previous call - exception = await Record.ExceptionAsync(async () => - { - await DocumentSession.GetAndUpdate( - command => command.ShoppingCartId, - (command, shoppingCart) => - shoppingCart.AddProduct(FakeProductPriceCalculator.Returning(tShirtPrice), command.ProductItem), - AddProductItemToShoppingCart.From(shoppingCartId, tShirt), - 1, - CancellationToken.None - ); - } - ); - exception.Should().BeOfType(); - ReOpenSession(); - - var shoppingCart = await DocumentSession.Get(shoppingCartId, CancellationToken.None); - - shoppingCart.Id.Should().Be(shoppingCartId); - shoppingCart.ClientId.Should().Be(clientId); - shoppingCart.ProductItems.Should().HaveCount(1); - shoppingCart.Status.Should().Be(ShoppingCartStatus.Pending); - - shoppingCart.ProductItems[0].ProductId.Should().Be(shoesId); - shoppingCart.ProductItems[0].Quantity.Should().Be(twoPairsOfShoes.Quantity); - shoppingCart.ProductItems[0].UnitPrice.Should().Be(shoesPrice); - } -} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/Pricing/PricingCalculator.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/Pricing/PricingCalculator.cs new file mode 100644 index 000000000..304900448 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/Pricing/PricingCalculator.cs @@ -0,0 +1,21 @@ +namespace ApplicationLogic.Marten.Mixed.Pricing; + +public interface IProductPriceCalculator +{ + PricedProductItem Calculate(ProductItem productItems); +} + +public class FakeProductPriceCalculator: IProductPriceCalculator +{ + private readonly int value; + + private FakeProductPriceCalculator(int value) + { + this.value = value; + } + + public static FakeProductPriceCalculator Returning(int value) => new(value); + + public PricedProductItem Calculate(ProductItem productItem) => + new() { ProductId = productItem.ProductId, Quantity = productItem.Quantity, UnitPrice = value }; +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/Pricing/ProductItems.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/Pricing/ProductItems.cs new file mode 100644 index 000000000..108702ca9 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/Pricing/ProductItems.cs @@ -0,0 +1,16 @@ +namespace ApplicationLogic.Marten.Mixed.Pricing; + +public class PricedProductItem +{ + public Guid ProductId { get; set; } + public decimal UnitPrice { get; set; } + public int Quantity { get; set; } + public decimal TotalPrice => Quantity * UnitPrice; +} + +public class ProductItem +{ + public Guid ProductId { get; set; } + public int Quantity { get; set; } +} + diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/ShoppingCarts/Api.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/ShoppingCarts/Api.cs new file mode 100644 index 000000000..340061a2a --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/ShoppingCarts/Api.cs @@ -0,0 +1,125 @@ +using ApplicationLogic.Marten.Core.Marten; +using ApplicationLogic.Marten.Mixed.Pricing; +using Core.Validation; +using Marten; +using Marten.Schema.Identity; +using Microsoft.AspNetCore.Mvc; +using static Microsoft.AspNetCore.Http.TypedResults; +using static System.DateTimeOffset; + +namespace ApplicationLogic.Marten.Mixed.ShoppingCarts; +public static class Api +{ + public static WebApplication ConfigureMixedShoppingCarts(this WebApplication app) + { + var clients = app.MapGroup("/api/mixed/clients/{clientId:guid}/"); + var shoppingCarts = clients.MapGroup("shopping-carts"); + var shoppingCart = shoppingCarts.MapGroup("{shoppingCartId:guid}"); + var productItems = shoppingCart.MapGroup("product-items"); + + shoppingCarts.MapPost("", + async (IDocumentSession session, + Guid clientId, + CancellationToken ct) => + { + var shoppingCartId = CombGuidIdGeneration.NewGuid(); + + await session.Add(shoppingCartId, + [MixedShoppingCart.Open(shoppingCartId, clientId.NotEmpty(), Now).Item1], ct); + + return Created($"/api/mixed/clients/{clientId}/shopping-carts/{shoppingCartId}", shoppingCartId); + } + ); + + productItems.MapPost("", + async ( + IProductPriceCalculator pricingCalculator, + IDocumentSession session, + Guid shoppingCartId, + AddProductRequest body, + CancellationToken ct) => + { + var productItem = body.ProductItem.NotNull().ToProductItem(); + + await session.GetAndUpdate(shoppingCartId, + state => [state.AddProduct(pricingCalculator, productItem, Now)], ct); + + return NoContent(); + } + ); + + productItems.MapDelete("{productId:guid}", + async ( + IDocumentSession session, + Guid shoppingCartId, + [FromRoute] Guid productId, + [FromQuery] int? quantity, + [FromQuery] decimal? unitPrice, + CancellationToken ct) => + { + var productItem = new PricedProductItem + { + ProductId = productId.NotEmpty(), + Quantity = quantity.NotNull().Positive(), + UnitPrice = unitPrice.NotNull().Positive() + }; + + await session.GetAndUpdate(shoppingCartId, + state => [state.RemoveProduct(productItem, Now)], + ct); + + return NoContent(); + } + ); + + shoppingCart.MapPost("confirm", + async (IDocumentSession session, + Guid shoppingCartId, + CancellationToken ct) => + { + await session.GetAndUpdate(shoppingCartId, + state => [state.Confirm(Now)], ct); + + return NoContent(); + } + ); + + shoppingCart.MapDelete("", + async (IDocumentSession session, + Guid shoppingCartId, + CancellationToken ct) => + { + await session.GetAndUpdate(shoppingCartId, + state => [state.Cancel(Now)], ct); + + return NoContent(); + } + ); + + shoppingCart.MapGet("", + async Task ( + IQuerySession session, + Guid shoppingCartId, + CancellationToken ct) => + { + var result = await session.Events.AggregateStreamAsync(shoppingCartId, token: ct); + + return result is not null ? Ok(result) : NotFound(); + } + ); + + return app; + } +} + +public record ProductItemRequest( + Guid? ProductId, + int? Quantity +) +{ + public ProductItem ToProductItem() => new() { ProductId = ProductId.NotEmpty(), Quantity = Quantity.NotNull().Positive()}; +} + +public record AddProductRequest( + ProductItemRequest? ProductItem +); diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/ShoppingCarts/Configure.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/ShoppingCarts/Configure.cs new file mode 100644 index 000000000..8d90d4700 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/ShoppingCarts/Configure.cs @@ -0,0 +1,31 @@ +using ApplicationLogic.Marten.Core.Marten; +using ApplicationLogic.Marten.Mixed.Pricing; +using Marten; + +namespace ApplicationLogic.Marten.Mixed.ShoppingCarts; +using static ShoppingCartEvent; + +public static class Configure +{ + private const string ModulePrefix = "Mixed"; + + public static IServiceCollection AddMixedShoppingCarts(this IServiceCollection services) + { + services.AddSingleton(FakeProductPriceCalculator.Returning(100)); + + return services; + } + public static StoreOptions ConfigureMixedShoppingCarts(this StoreOptions options) + { + options.Projections.LiveStreamAggregation(); + + // this is needed as we're sharing document store and have event types with the same name + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + + return options; + } +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/BusinessLogic.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/ShoppingCarts/MixedShoppingCart.cs similarity index 50% rename from Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/BusinessLogic.cs rename to Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/ShoppingCarts/MixedShoppingCart.cs index b75141760..c16e18260 100644 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/BusinessLogic.cs +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mixed/ShoppingCarts/MixedShoppingCart.cs @@ -1,101 +1,58 @@ -namespace IntroductionToEventSourcing.OptimisticConcurrency.Mixed; -using static ShoppingCartEvent; - -public interface IAggregate -{ - public Guid Id { get; } +using ApplicationLogic.Marten.Mixed.Pricing; - public void Evolve(object @event) { } -} +namespace ApplicationLogic.Marten.Mixed.ShoppingCarts; +using static ShoppingCartEvent; -// COMMANDS -public record OpenShoppingCart( - Guid ShoppingCartId, - Guid ClientId -) -{ - public static OpenShoppingCart From(Guid? cartId, Guid? clientId) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - if (clientId == null || clientId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(clientId)); - - return new OpenShoppingCart(cartId.Value, clientId.Value); - } -} - -public record AddProductItemToShoppingCart( - Guid ShoppingCartId, - ProductItem ProductItem -) -{ - public static AddProductItemToShoppingCart From(Guid? cartId, ProductItem? productItem) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - if (productItem == null) - throw new ArgumentOutOfRangeException(nameof(productItem)); - - return new AddProductItemToShoppingCart(cartId.Value, productItem); - } -} - -public record RemoveProductItemFromShoppingCart( - Guid ShoppingCartId, - PricedProductItem ProductItem -) -{ - public static RemoveProductItemFromShoppingCart From(Guid? cartId, PricedProductItem? productItem) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - if (productItem == null) - throw new ArgumentOutOfRangeException(nameof(productItem)); - - return new RemoveProductItemFromShoppingCart(cartId.Value, productItem); - } -} - -public record ConfirmShoppingCart( - Guid ShoppingCartId -) -{ - public static ConfirmShoppingCart From(Guid? cartId) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - - return new ConfirmShoppingCart(cartId.Value); - } -} - -public record CancelShoppingCart( - Guid ShoppingCartId -) +// EVENTS +public abstract record ShoppingCartEvent { - public static CancelShoppingCart From(Guid? cartId) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - - return new CancelShoppingCart(cartId.Value); - } + public record ShoppingCartOpened( + Guid ShoppingCartId, + Guid ClientId, + DateTimeOffset OpenedAt + ): ShoppingCartEvent; + + public record ProductItemAddedToShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset AddedAt + ): ShoppingCartEvent; + + public record ProductItemRemovedFromShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset RemovedAt + ): ShoppingCartEvent; + + public record ShoppingCartConfirmed( + Guid ShoppingCartId, + DateTimeOffset ConfirmedAt + ): ShoppingCartEvent; + + public record ShoppingCartCanceled( + Guid ShoppingCartId, + DateTimeOffset CanceledAt + ): ShoppingCartEvent; + + // This won't allow external inheritance + private ShoppingCartEvent() { } } -public class ShoppingCart: IAggregate +// ENTITY +// Note: We need to have prefix to be able to register multiple streams with the same name +public class MixedShoppingCart { - public Guid Id { get; private set; } + public Guid Id { get; private set; } public Guid ClientId { get; private set; } public ShoppingCartStatus Status { get; private set; } public IList ProductItems { get; } = new List(); - public DateTime? ConfirmedAt { get; private set; } - public DateTime? CanceledAt { get; private set; } + public DateTimeOffset? ConfirmedAt { get; private set; } + public DateTimeOffset? CanceledAt { get; private set; } public bool IsClosed => ShoppingCartStatus.Closed.HasFlag(Status); - public void Evolve(object @event) + public void Apply(ShoppingCartEvent @event) { switch (@event) { @@ -117,25 +74,28 @@ public void Evolve(object @event) } } - public static (ShoppingCartOpened Event, ShoppingCart Aggregate) Open( + public static (ShoppingCartOpened, MixedShoppingCart) Open( Guid cartId, - Guid clientId) + Guid clientId, + DateTimeOffset now + ) { var @event = new ShoppingCartOpened( cartId, - clientId + clientId, + now ); - - - return (@event, new ShoppingCart(@event)); + return (@event, new MixedShoppingCart(@event)); } - private ShoppingCart(ShoppingCartOpened @event) => + public static MixedShoppingCart Initial() => new(); + + private MixedShoppingCart(ShoppingCartOpened @event) => Apply(@event); //just for default creation of empty object - private ShoppingCart() { } + private MixedShoppingCart() { } private void Apply(ShoppingCartOpened opened) { @@ -146,7 +106,9 @@ private void Apply(ShoppingCartOpened opened) public ProductItemAddedToShoppingCart AddProduct( IProductPriceCalculator productPriceCalculator, - ProductItem productItem) + ProductItem productItem, + DateTimeOffset now + ) { if (IsClosed) throw new InvalidOperationException( @@ -154,7 +116,7 @@ public ProductItemAddedToShoppingCart AddProduct( var pricedProductItem = productPriceCalculator.Calculate(productItem); - var @event = new ProductItemAddedToShoppingCart(Id, pricedProductItem); + var @event = new ProductItemAddedToShoppingCart(Id, pricedProductItem, now); Apply(@event); @@ -163,7 +125,7 @@ public ProductItemAddedToShoppingCart AddProduct( private void Apply(ProductItemAddedToShoppingCart productItemAdded) { - var (_, pricedProductItem) = productItemAdded; + var pricedProductItem = productItemAdded.ProductItem; var productId = pricedProductItem.ProductId; var quantityToAdd = pricedProductItem.Quantity; @@ -174,11 +136,10 @@ private void Apply(ProductItemAddedToShoppingCart productItemAdded) if (current == null) ProductItems.Add(pricedProductItem); else - ProductItems[ProductItems.IndexOf(current)] = - new PricedProductItem(current.ProductId, current.Quantity + quantityToAdd, current.UnitPrice); + ProductItems[ProductItems.IndexOf(current)].Quantity += quantityToAdd; } - public ProductItemRemovedFromShoppingCart RemoveProduct(PricedProductItem productItemToBeRemoved) + public ProductItemRemovedFromShoppingCart RemoveProduct(PricedProductItem productItemToBeRemoved, DateTimeOffset now) { if (IsClosed) throw new InvalidOperationException( @@ -187,7 +148,7 @@ public ProductItemRemovedFromShoppingCart RemoveProduct(PricedProductItem produc if (!HasEnough(productItemToBeRemoved)) throw new InvalidOperationException("Not enough product items to remove"); - var @event = new ProductItemRemovedFromShoppingCart(Id, productItemToBeRemoved); + var @event = new ProductItemRemovedFromShoppingCart(Id, productItemToBeRemoved, now); Apply(@event); @@ -205,7 +166,7 @@ private bool HasEnough(PricedProductItem productItem) private void Apply(ProductItemRemovedFromShoppingCart productItemRemoved) { - var (_, pricedProductItem) = productItemRemoved; + var pricedProductItem = productItemRemoved.ProductItem; var productId = pricedProductItem.ProductId; var quantityToRemove = pricedProductItem.Quantity; @@ -216,17 +177,19 @@ private void Apply(ProductItemRemovedFromShoppingCart productItemRemoved) if (current.Quantity == quantityToRemove) ProductItems.Remove(current); else - ProductItems[ProductItems.IndexOf(current)] = - new PricedProductItem(current.ProductId, current.Quantity - quantityToRemove, current.UnitPrice); + ProductItems[ProductItems.IndexOf(current)].Quantity -= quantityToRemove; } - public ShoppingCartConfirmed Confirm() + public ShoppingCartConfirmed Confirm(DateTimeOffset now) { if (IsClosed) throw new InvalidOperationException( $"Confirming cart in '{Status}' status is not allowed."); - var @event = new ShoppingCartConfirmed(Id, DateTime.UtcNow); + if (ProductItems.Count == 0) + throw new InvalidOperationException($"Cannot confirm empty shopping cart"); + + var @event = new ShoppingCartConfirmed(Id, now); Apply(@event); @@ -239,13 +202,13 @@ private void Apply(ShoppingCartConfirmed confirmed) ConfirmedAt = confirmed.ConfirmedAt; } - public ShoppingCartCanceled Cancel() + public ShoppingCartCanceled Cancel(DateTimeOffset now) { if (IsClosed) throw new InvalidOperationException( $"Canceling cart in '{Status}' status is not allowed."); - var @event = new ShoppingCartCanceled(Id, DateTime.UtcNow); + var @event = new ShoppingCartCanceled(Id, now); Apply(@event); @@ -267,26 +230,3 @@ public enum ShoppingCartStatus Closed = Confirmed | Canceled } - -public interface IProductPriceCalculator -{ - PricedProductItem Calculate(ProductItem productItems); -} - -public class FakeProductPriceCalculator: IProductPriceCalculator -{ - private readonly int value; - - private FakeProductPriceCalculator(int value) - { - this.value = value; - } - - public static FakeProductPriceCalculator Returning(int value) => new(value); - - public PricedProductItem Calculate(ProductItem productItem) - { - var (productId, quantity) = productItem; - return new PricedProductItem(productId, quantity, value); - } -} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/DocumentSessionExtensions.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/DocumentSessionExtensions.cs deleted file mode 100644 index 1dcc1166b..000000000 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/DocumentSessionExtensions.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Marten; - -namespace IntroductionToEventSourcing.OptimisticConcurrency.Mutable; - -public static class DocumentSessionExtensions -{ - public static Task Get( - this IDocumentSession session, - Guid id, - CancellationToken cancellationToken = default - ) where TAggregate : Aggregate - { - // Fill logic here. - throw new NotImplementedException(); - } - - public static Task Add( - this IDocumentSession session, - Func getId, - Func action, - TCommand command, - CancellationToken cancellationToken = default - ) where TAggregate : Aggregate - { - // Fill logic here. - throw new NotImplementedException(); - } - - public static Task GetAndUpdate( - this IDocumentSession session, - Func getId, - Action action, - TCommand command, - long version, - CancellationToken cancellationToken = default - ) where TAggregate : Aggregate - { - // Fill logic here. - throw new NotImplementedException(); - } -} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/OptimisticConcurrencyTests.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/OptimisticConcurrencyTests.cs deleted file mode 100644 index 758b4e83b..000000000 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/OptimisticConcurrencyTests.cs +++ /dev/null @@ -1,135 +0,0 @@ -using FluentAssertions; -using IntroductionToEventSourcing.OptimisticConcurrency.Tools; -using Marten.Exceptions; -using Xunit; - -namespace IntroductionToEventSourcing.OptimisticConcurrency.Mutable; - -// EVENTS -public abstract record ShoppingCartEvent -{ - public record ShoppingCartOpened( - Guid ShoppingCartId, - Guid ClientId - ): ShoppingCartEvent; - - public record ProductItemAddedToShoppingCart( - Guid ShoppingCartId, - PricedProductItem ProductItem - ): ShoppingCartEvent; - - public record ProductItemRemovedFromShoppingCart( - Guid ShoppingCartId, - PricedProductItem ProductItem - ): ShoppingCartEvent; - - public record ShoppingCartConfirmed( - Guid ShoppingCartId, - DateTime ConfirmedAt - ): ShoppingCartEvent; - - public record ShoppingCartCanceled( - Guid ShoppingCartId, - DateTime CanceledAt - ): ShoppingCartEvent; - - // This won't allow external inheritance - private ShoppingCartEvent(){} -} - -// VALUE OBJECTS -public class PricedProductItem -{ - public Guid ProductId { get; set; } - public decimal UnitPrice { get; set; } - public int Quantity { get; set; } - public decimal TotalPrice => Quantity * UnitPrice; -} - -public class ProductItem -{ - public Guid ProductId { get; set; } - public int Quantity { get; set; } -} - -public class OptimisticConcurrencyTests: MartenTest -{ - [Fact] - [Trait("Category", "SkipCI")] - public async Task GettingState_ForSequenceOfEvents_ShouldSucceed() - { - var shoppingCartId = Guid.NewGuid(); - var clientId = Guid.NewGuid(); - var shoesId = Guid.NewGuid(); - var tShirtId = Guid.NewGuid(); - - var twoPairsOfShoes = new ProductItem { ProductId = shoesId, Quantity = 2 }; - var tShirt = new ProductItem { ProductId = tShirtId, Quantity = 1 }; - - var shoesPrice = 100; - var tShirtPrice = 50; - - // Open - await DocumentSession.Add( - command => command.ShoppingCartId, - command => - ShoppingCart.Open(command.ShoppingCartId, command.ClientId), - OpenShoppingCart.From(shoppingCartId, clientId), - CancellationToken.None - ); - - // Try to open again - // Should fail as stream was already created - var exception = await Record.ExceptionAsync(async () => - { - await DocumentSession.Add( - command => command.ShoppingCartId, - command => - ShoppingCart.Open(command.ShoppingCartId, command.ClientId), - OpenShoppingCart.From(shoppingCartId, clientId), - CancellationToken.None - ); - } - ); - exception.Should().BeOfType(); - ReOpenSession(); - - // Add two pairs of shoes - await DocumentSession.GetAndUpdate( - command => command.ShoppingCartId, - (command, shoppingCart) => - shoppingCart.AddProduct(FakeProductPriceCalculator.Returning(shoesPrice), command.ProductItem), - AddProductItemToShoppingCart.From(shoppingCartId, twoPairsOfShoes), - 1, - CancellationToken.None - ); - - // Add T-Shirt - // Should fail because of sending the same expected version as previous call - exception = await Record.ExceptionAsync(async () => - { - await DocumentSession.GetAndUpdate( - command => command.ShoppingCartId, - (command, shoppingCart) => - shoppingCart.AddProduct(FakeProductPriceCalculator.Returning(tShirtPrice), command.ProductItem), - AddProductItemToShoppingCart.From(shoppingCartId, tShirt), - 1, - CancellationToken.None - ); - } - ); - exception.Should().BeOfType(); - ReOpenSession(); - - var shoppingCart = await DocumentSession.Get(shoppingCartId, CancellationToken.None); - - shoppingCart.Id.Should().Be(shoppingCartId); - shoppingCart.ClientId.Should().Be(clientId); - shoppingCart.ProductItems.Should().HaveCount(1); - shoppingCart.Status.Should().Be(ShoppingCartStatus.Pending); - - shoppingCart.ProductItems[0].ProductId.Should().Be(shoesId); - shoppingCart.ProductItems[0].Quantity.Should().Be(twoPairsOfShoes.Quantity); - shoppingCart.ProductItems[0].UnitPrice.Should().Be(shoesPrice); - } -} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/Pricing/PricingCalculator.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/Pricing/PricingCalculator.cs new file mode 100644 index 000000000..c459f7bb4 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/Pricing/PricingCalculator.cs @@ -0,0 +1,21 @@ +namespace ApplicationLogic.Marten.Mutable.Pricing; + +public interface IProductPriceCalculator +{ + PricedProductItem Calculate(ProductItem productItems); +} + +public class FakeProductPriceCalculator: IProductPriceCalculator +{ + private readonly int value; + + private FakeProductPriceCalculator(int value) + { + this.value = value; + } + + public static FakeProductPriceCalculator Returning(int value) => new(value); + + public PricedProductItem Calculate(ProductItem productItem) => + new() { ProductId = productItem.ProductId, Quantity = productItem.Quantity, UnitPrice = value }; +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/Pricing/ProductItems.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/Pricing/ProductItems.cs new file mode 100644 index 000000000..8c98934c7 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/Pricing/ProductItems.cs @@ -0,0 +1,16 @@ +namespace ApplicationLogic.Marten.Mutable.Pricing; + +public class PricedProductItem +{ + public Guid ProductId { get; set; } + public decimal UnitPrice { get; set; } + public int Quantity { get; set; } + public decimal TotalPrice => Quantity * UnitPrice; +} + +public class ProductItem +{ + public Guid ProductId { get; set; } + public int Quantity { get; set; } +} + diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/ShoppingCarts/Api.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/ShoppingCarts/Api.cs new file mode 100644 index 000000000..be7b18493 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/ShoppingCarts/Api.cs @@ -0,0 +1,126 @@ +using ApplicationLogic.Marten.Core.Marten; +using ApplicationLogic.Marten.Mutable.Pricing; +using Core.Validation; +using Marten; +using Marten.Schema.Identity; +using Microsoft.AspNetCore.Mvc; +using static Microsoft.AspNetCore.Http.TypedResults; +using static System.DateTimeOffset; + +namespace ApplicationLogic.Marten.Mutable.ShoppingCarts; + +public static class Api +{ + public static WebApplication ConfigureMutableShoppingCarts(this WebApplication app) + { + var clients = app.MapGroup("/api/mutable/clients/{clientId:guid}/"); + var shoppingCarts = clients.MapGroup("shopping-carts"); + var shoppingCart = shoppingCarts.MapGroup("{shoppingCartId:guid}"); + var productItems = shoppingCart.MapGroup("product-items"); + + shoppingCarts.MapPost("", + async (IDocumentSession session, + Guid clientId, + CancellationToken ct) => + { + var shoppingCartId = CombGuidIdGeneration.NewGuid(); + + await session.Add(shoppingCartId, + MutableShoppingCart.Open(shoppingCartId, clientId.NotEmpty(), Now).DequeueUncommittedEvents(), ct); + + return Created($"/api/mutable/clients/{clientId}/shopping-carts/{shoppingCartId}", shoppingCartId); + } + ); + + productItems.MapPost("", + async ( + IProductPriceCalculator pricingCalculator, + IDocumentSession session, + Guid shoppingCartId, + AddProductRequest body, + CancellationToken ct) => + { + var productItem = body.ProductItem.NotNull().ToProductItem(); + + await session.GetAndUpdate(shoppingCartId, + state => state.AddProduct(pricingCalculator, productItem, Now), ct); + + return NoContent(); + } + ); + + productItems.MapDelete("{productId:guid}", + async ( + IDocumentSession session, + Guid shoppingCartId, + [FromRoute] Guid productId, + [FromQuery] int? quantity, + [FromQuery] decimal? unitPrice, + CancellationToken ct) => + { + var productItem = new PricedProductItem + { + ProductId = productId.NotEmpty(), + Quantity = quantity.NotNull().Positive(), + UnitPrice = unitPrice.NotNull().Positive() + }; + + await session.GetAndUpdate(shoppingCartId, + state => state.RemoveProduct(productItem, Now), + ct); + + return NoContent(); + } + ); + + shoppingCart.MapPost("confirm", + async (IDocumentSession session, + Guid shoppingCartId, + CancellationToken ct) => + { + await session.GetAndUpdate(shoppingCartId, + state => state.Confirm(Now), ct); + + return NoContent(); + } + ); + + shoppingCart.MapDelete("", + async (IDocumentSession session, + Guid shoppingCartId, + CancellationToken ct) => + { + await session.GetAndUpdate(shoppingCartId, + state => state.Cancel(Now), ct); + + return NoContent(); + } + ); + + shoppingCart.MapGet("", + async Task ( + IQuerySession session, + Guid shoppingCartId, + CancellationToken ct) => + { + var result = await session.Events.AggregateStreamAsync(shoppingCartId, token: ct); + + return result is not null ? Ok(result) : NotFound(); + } + ); + + return app; + } +} + +public record ProductItemRequest( + Guid? ProductId, + int? Quantity +) +{ + public ProductItem ToProductItem() => new() { ProductId = ProductId.NotEmpty(), Quantity = Quantity.NotNull().Positive()}; +} + +public record AddProductRequest( + ProductItemRequest? ProductItem +); diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/ShoppingCarts/Configure.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/ShoppingCarts/Configure.cs new file mode 100644 index 000000000..0632d3777 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/ShoppingCarts/Configure.cs @@ -0,0 +1,31 @@ +using ApplicationLogic.Marten.Core.Marten; +using ApplicationLogic.Marten.Mutable.Pricing; +using Marten; + +namespace ApplicationLogic.Marten.Mutable.ShoppingCarts; +using static ShoppingCartEvent; + +public static class Configure +{ + private const string ModulePrefix = "mutable"; + + public static IServiceCollection AddMutableShoppingCarts(this IServiceCollection services) + { + services.AddSingleton(FakeProductPriceCalculator.Returning(100)); + + return services; + } + public static StoreOptions ConfigureMutableShoppingCarts(this StoreOptions options) + { + options.Projections.LiveStreamAggregation(); + + // this is needed as we're sharing document store and have event types with the same name + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + options.MapEventWithPrefix(ModulePrefix); + + return options; + } +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/BusinessLogic.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/ShoppingCarts/MutableShoppingCart.cs similarity index 51% rename from Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/BusinessLogic.cs rename to Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/ShoppingCarts/MutableShoppingCart.cs index 6966a732e..24f8814cd 100644 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/BusinessLogic.cs +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Mutable/ShoppingCarts/MutableShoppingCart.cs @@ -1,115 +1,58 @@ -namespace IntroductionToEventSourcing.OptimisticConcurrency.Mutable; -using static ShoppingCartEvent; - -public abstract class Aggregate -{ - public Guid Id { get; protected set; } = default!; - - private readonly Queue uncommittedEvents = new(); - - public virtual void Evolve(object @event) { } - - public object[] DequeueUncommittedEvents() - { - var dequeuedEvents = uncommittedEvents.ToArray(); - - uncommittedEvents.Clear(); - - return dequeuedEvents; - } - - protected void Enqueue(object @event) - { - uncommittedEvents.Enqueue(@event); - } -} +using ApplicationLogic.Marten.Core.Entities; +using ApplicationLogic.Marten.Mutable.Pricing; -// COMMANDS -public record OpenShoppingCart( - Guid ShoppingCartId, - Guid ClientId -) -{ - public static OpenShoppingCart From(Guid? cartId, Guid? clientId) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - if (clientId == null || clientId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(clientId)); - - return new OpenShoppingCart(cartId.Value, clientId.Value); - } -} - -public record AddProductItemToShoppingCart( - Guid ShoppingCartId, - ProductItem ProductItem -) -{ - public static AddProductItemToShoppingCart From(Guid? cartId, ProductItem? productItem) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - if (productItem == null) - throw new ArgumentOutOfRangeException(nameof(productItem)); - - return new AddProductItemToShoppingCart(cartId.Value, productItem); - } -} - -public record RemoveProductItemFromShoppingCart( - Guid ShoppingCartId, - PricedProductItem ProductItem -) -{ - public static RemoveProductItemFromShoppingCart From(Guid? cartId, PricedProductItem? productItem) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - if (productItem == null) - throw new ArgumentOutOfRangeException(nameof(productItem)); +namespace ApplicationLogic.Marten.Mutable.ShoppingCarts; - return new RemoveProductItemFromShoppingCart(cartId.Value, productItem); - } -} - -public record ConfirmShoppingCart( - Guid ShoppingCartId -) -{ - public static ConfirmShoppingCart From(Guid? cartId) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - - return new ConfirmShoppingCart(cartId.Value); - } -} +using static ShoppingCartEvent; -public record CancelShoppingCart( - Guid ShoppingCartId -) +// EVENTS +public abstract record ShoppingCartEvent { - public static CancelShoppingCart From(Guid? cartId) - { - if (cartId == null || cartId == Guid.Empty) - throw new ArgumentOutOfRangeException(nameof(cartId)); - - return new CancelShoppingCart(cartId.Value); - } + public record ShoppingCartOpened( + Guid ShoppingCartId, + Guid ClientId, + DateTimeOffset OpenedAt + ): ShoppingCartEvent; + + public record ProductItemAddedToShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset AddedAt + ): ShoppingCartEvent; + + public record ProductItemRemovedFromShoppingCart( + Guid ShoppingCartId, + PricedProductItem ProductItem, + DateTimeOffset RemovedAt + ): ShoppingCartEvent; + + public record ShoppingCartConfirmed( + Guid ShoppingCartId, + DateTimeOffset ConfirmedAt + ): ShoppingCartEvent; + + public record ShoppingCartCanceled( + Guid ShoppingCartId, + DateTimeOffset CanceledAt + ): ShoppingCartEvent; + + // This won't allow external inheritance + private ShoppingCartEvent() { } } -public class ShoppingCart: Aggregate +// ENTITY +// Note: We need to have prefix to be able to register multiple streams with the same name +public class MutableShoppingCart: Aggregate { public Guid ClientId { get; private set; } public ShoppingCartStatus Status { get; private set; } public IList ProductItems { get; } = new List(); - public DateTime? ConfirmedAt { get; private set; } - public DateTime? CanceledAt { get; private set; } + public DateTimeOffset? ConfirmedAt { get; private set; } + public DateTimeOffset? CanceledAt { get; private set; } public bool IsClosed => ShoppingCartStatus.Closed.HasFlag(Status); - public override void Evolve(object @event) + protected override void Apply(ShoppingCartEvent @event) { switch (@event) { @@ -131,20 +74,27 @@ public override void Evolve(object @event) } } - public static ShoppingCart Open( + public static MutableShoppingCart Open( Guid cartId, - Guid clientId) + Guid clientId, + DateTimeOffset now + ) { - return new ShoppingCart(cartId, clientId); + return new MutableShoppingCart(cartId, clientId, now); } - private ShoppingCart( + public static MutableShoppingCart Initial() => new(); + + private MutableShoppingCart( Guid id, - Guid clientId) + Guid clientId, + DateTimeOffset now + ) { var @event = new ShoppingCartOpened( id, - clientId + clientId, + now ); Enqueue(@event); @@ -152,7 +102,7 @@ private ShoppingCart( } //just for default creation of empty object - private ShoppingCart() { } + private MutableShoppingCart() { } private void Apply(ShoppingCartOpened opened) { @@ -163,7 +113,9 @@ private void Apply(ShoppingCartOpened opened) public void AddProduct( IProductPriceCalculator productPriceCalculator, - ProductItem productItem) + ProductItem productItem, + DateTimeOffset now + ) { if (IsClosed) throw new InvalidOperationException( @@ -171,7 +123,7 @@ public void AddProduct( var pricedProductItem = productPriceCalculator.Calculate(productItem); - var @event = new ProductItemAddedToShoppingCart(Id, pricedProductItem); + var @event = new ProductItemAddedToShoppingCart(Id, pricedProductItem, now); Enqueue(@event); Apply(@event); @@ -179,7 +131,7 @@ public void AddProduct( private void Apply(ProductItemAddedToShoppingCart productItemAdded) { - var (_, pricedProductItem) = productItemAdded; + var pricedProductItem = productItemAdded.ProductItem; var productId = pricedProductItem.ProductId; var quantityToAdd = pricedProductItem.Quantity; @@ -193,7 +145,7 @@ private void Apply(ProductItemAddedToShoppingCart productItemAdded) current.Quantity += quantityToAdd; } - public void RemoveProduct(PricedProductItem productItemToBeRemoved) + public void RemoveProduct(PricedProductItem productItemToBeRemoved, DateTimeOffset now) { if (IsClosed) throw new InvalidOperationException( @@ -202,7 +154,7 @@ public void RemoveProduct(PricedProductItem productItemToBeRemoved) if (!HasEnough(productItemToBeRemoved)) throw new InvalidOperationException("Not enough product items to remove"); - var @event = new ProductItemRemovedFromShoppingCart(Id, productItemToBeRemoved); + var @event = new ProductItemRemovedFromShoppingCart(Id, productItemToBeRemoved, now); Enqueue(@event); Apply(@event); @@ -219,7 +171,7 @@ private bool HasEnough(PricedProductItem productItem) private void Apply(ProductItemRemovedFromShoppingCart productItemRemoved) { - var (_, pricedProductItem) = productItemRemoved; + var pricedProductItem = productItemRemoved.ProductItem; var productId = pricedProductItem.ProductId; var quantityToRemove = pricedProductItem.Quantity; @@ -233,13 +185,16 @@ private void Apply(ProductItemRemovedFromShoppingCart productItemRemoved) current.Quantity -= quantityToRemove; } - public void Confirm() + public void Confirm(DateTimeOffset now) { if (IsClosed) throw new InvalidOperationException( $"Confirming cart in '{Status}' status is not allowed."); - var @event = new ShoppingCartConfirmed(Id, DateTime.UtcNow); + if (ProductItems.Count == 0) + throw new InvalidOperationException($"Cannot confirm empty shopping cart"); + + var @event = new ShoppingCartConfirmed(Id, now); Enqueue(@event); Apply(@event); @@ -251,13 +206,13 @@ private void Apply(ShoppingCartConfirmed confirmed) ConfirmedAt = confirmed.ConfirmedAt; } - public void Cancel() + public void Cancel(DateTimeOffset now) { if (IsClosed) throw new InvalidOperationException( $"Canceling cart in '{Status}' status is not allowed."); - var @event = new ShoppingCartCanceled(Id, DateTime.UtcNow); + var @event = new ShoppingCartCanceled(Id, now); Enqueue(@event); Apply(@event); @@ -278,30 +233,3 @@ public enum ShoppingCartStatus Closed = Confirmed | Canceled } - -public interface IProductPriceCalculator -{ - PricedProductItem Calculate(ProductItem productItems); -} - -public class FakeProductPriceCalculator: IProductPriceCalculator -{ - private readonly int value; - - private FakeProductPriceCalculator(int value) - { - this.value = value; - } - - public static FakeProductPriceCalculator Returning(int value) => new(value); - - public PricedProductItem Calculate(ProductItem productItem) - { - return new PricedProductItem - { - ProductId = productItem.ProductId, - Quantity = productItem.Quantity, - UnitPrice = value - }; - } -} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Program.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Program.cs new file mode 100644 index 000000000..79a6ef903 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Program.cs @@ -0,0 +1,71 @@ +using ApplicationLogic.Marten.Core.Exceptions; +using ApplicationLogic.Marten.Immutable.ShoppingCarts; +using ApplicationLogic.Marten.Mixed.ShoppingCarts; +using ApplicationLogic.Marten.Mutable.ShoppingCarts; +using Marten; +using Microsoft.AspNetCore.Diagnostics; +using Oakton; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services + .AddRouting() + .AddEndpointsApiExplorer() + .AddSwaggerGen() + .AddMutableShoppingCarts() + .AddMixedShoppingCarts() + .AddImmutableShoppingCarts() + .AddMarten(options => + { + var schemaName = Environment.GetEnvironmentVariable("SchemaName") ?? "Workshop_Optimistic_ShoppingCarts"; + options.Events.DatabaseSchemaName = schemaName; + options.DatabaseSchemaName = schemaName; + options.Connection(builder.Configuration.GetConnectionString("ShoppingCarts") ?? + throw new InvalidOperationException()); + + options.ConfigureImmutableShoppingCarts() + .ConfigureMutableShoppingCarts() + .ConfigureMixedShoppingCarts(); + }) + .OptimizeArtifactWorkflow() + .UseLightweightSessions(); + +builder.Host.ApplyOaktonExtensions(); + +var app = builder.Build(); + +app.UseExceptionHandler(new ExceptionHandlerOptions + { + AllowStatusCode404Response = true, + ExceptionHandler = context => + { + var exception = context.Features.Get()?.Error; + + context.Response.StatusCode = exception switch + { + ArgumentException => StatusCodes.Status400BadRequest, + NotFoundException => StatusCodes.Status404NotFound, + InvalidOperationException => StatusCodes.Status409Conflict, + _ => StatusCodes.Status500InternalServerError, + }; + + return Task.CompletedTask; + } + }) + .UseRouting(); + +app.ConfigureImmutableShoppingCarts() + .ConfigureMutableShoppingCarts() + .ConfigureMixedShoppingCarts(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger() + .UseSwaggerUI(); +} + +return await app.RunOaktonCommands(args); + +public partial class Program +{ +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Properties/launchSettings.json b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Properties/launchSettings.json new file mode 100644 index 000000000..01dd421e9 --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "profiles": { + "Helpdesk.Api": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger/index.html", + "applicationUrl": "http://localhost:5248", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/README.md b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/README.md deleted file mode 100644 index 02ddb0e8e..000000000 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Exercise 10 - Optimistic Concurrency with Marten - -Having the following shopping cart process and implementation from previous exercises: -1. The customer may add a product to the shopping cart only after opening it. -2. When selecting and adding a product to the basket customer needs to provide the quantity chosen. The product price is calculated by the system based on the current price list. -3. The customer may remove a product with a given price from the cart. -4. The customer can confirm the shopping cart and start the order fulfilment process. -5. The customer may also cancel the shopping cart and reject all selected products. -6. After shopping cart confirmation or cancellation, the product can no longer be added or removed from the cart. - -How will the solution change when we add requirements: -1. We can add up to 10 products marked as "Super discount!". -2. The customer can only have one open cart at a time. - -How will we ensure data consistency when, for example, the wife and husband have access to the account, who at the same time tried to open the basket, or the husband confirmed and the wife tried to add another purchase? - -Extend your implementation to include these rules. - -![events](./assets/events.jpg) - -There are two variations: -1. Immutable, with functional command handlers composition and entities as anemic data model: [Immutable/OptimisticConcurrencyTests.cs](./Immutable/OptimisticConcurrencyTests.cs). -2. Classical, mutable aggregates (rich domain model): [Mutable/OptimisticConcurrencyTests.cs](./Mutable/OptimisticConcurrencyTests.cs). -3. Mixed approach, mutable aggregates (rich domain model), returning events from methods, using immutable DTOs: [Mixed/OptimisticConcurrencyTests.cs](./Mixed/OptimisticConcurrencyTests.cs). - -Select your preferred approach (or all) to solve this use case using Marten. Fill appropriate `DocumentSessionExtensions` - -_**Note**: If needed update entities, events or test setup structure_ - - diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Tools/MartenTest.cs b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Tools/MartenTest.cs deleted file mode 100644 index 7d8c2dd9e..000000000 --- a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/Tools/MartenTest.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Marten; - -namespace IntroductionToEventSourcing.OptimisticConcurrency.Tools; - -public abstract class MartenTest: IDisposable -{ - private readonly DocumentStore documentStore; - protected IDocumentSession DocumentSession = default!; - - protected MartenTest() - { - var options = new StoreOptions(); - options.Connection( - "PORT = 5432; HOST = localhost; TIMEOUT = 15; POOLING = True; DATABASE = 'postgres'; PASSWORD = 'Password12!'; USER ID = 'postgres'"); - options.UseDefaultSerialization(nonPublicMembersStorage: NonPublicMembersStorage.All); - options.DatabaseSchemaName = options.Events.DatabaseSchemaName = "IntroductionToEventSourcing"; - - documentStore = new DocumentStore(options); - ReOpenSession(); - } - - protected void ReOpenSession() - { - DocumentSession = documentStore.LightweightSession(); - } - - protected Task AppendEvents(Guid streamId, object[] events, CancellationToken ct) - { - DocumentSession.Events.Append( - streamId, - events - ); - return DocumentSession.SaveChangesAsync(ct); - } - - public virtual void Dispose() - { - DocumentSession.Dispose(); - documentStore.Dispose(); - } -} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/appsettings.json b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/appsettings.json new file mode 100644 index 000000000..26bccde8b --- /dev/null +++ b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/appsettings.json @@ -0,0 +1,18 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "ShoppingCarts": "PORT = 5432; HOST = localhost; TIMEOUT = 15; POOLING = True; MINPOOLSIZE = 1; MAXPOOLSIZE = 100; COMMANDTIMEOUT = 20; DATABASE = 'postgres'; PASSWORD = 'Password12!'; USER ID = 'postgres'" + }, + "KafkaProducer": { + "ProducerConfig": { + "BootstrapServers": "localhost:9092" + }, + "Topic": "Incidents" + } +} diff --git a/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/assets/events.jpg b/Workshops/IntroductionToEventSourcing/10-OptimisticConcurrency.Marten/assets/events.jpg deleted file mode 100644 index 35522f5804b2be096c7dbbc10ca8bedf69faa212..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 67506 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ÐTmn>|e zU^B!}??1vZme5Ex*D@p;hKaT`^toqcM5_2J+VW4~pm}f>{43fL?MLn@|7ESbLBAU3 zjsC+D^m}#;@B21SSCHE5GWla7N;3i404xA8E~;yo@MrZMw76$vo6en zfpR239J71?;Nf%eMDa{Qjk}hr*j@JU54j3e7cmC`FT}8v`L~X<96V(YR@-O8TKh~q z6rRjQB=e+L8KfDZ+3CHAp3EjBF0f-uP|b!v|8uqe{r|DuS*IBXJI~Q)hve$k){K2^ z<{P9+hMJH{Hjkhd#Jw+H9^*($4828K^IL-j9EdUbkc)K$vUH;>O?^J^@~iuR$G1xf z_G3@9^7=!wNJgd5d|fno`&37spqupjt(b-mw@!YA?Ws4?S+;q2k#0{Z8)*~nO zW=Ai^9z-sF3J*7Yd@^nKWKgDs^ltCQ1&PPSINnh+#K$en%1PGywv>A0AkoC<)1NIJ z>cr$;J@WwN=WC-hg_)sKVP|95Ji}GL#82Of<=wEoDh#I!Twj@Qd-?M? z>Oi`kY|lD=^RRg6d)$*zkBHLr%h%myW+CO8(Spa6YpbI3wb-sI$eq zeS>?xDY3zF64hKP^0MyvQlfXXDC60e#(zGDJ9YnBhvXEK{ZQy2tRnNz%zo@&2}s`Z zM_o#Yn0uvS)ci|1FRJv?=7axqa~g_RJhct2D>yX5d>aisj$6@;H9Ffl31L z&)xo#15d$IC!wv=_EY@_r+!xvXOhcrA213g{#zU{W&}p1umAv%`xy1{S)bG#XRWz} z2+CqMGYe+S6V9KRS86YfB(YUKtc}H(71s#*b;kz%P0Q>(t8m^UU9Hg2CEeZ;J;wt-7cucXwLms8yGLx@C zse(puJVdUKYdsHiGiLeIX?}6&AEHRS|oz4LMXn%8KA@$wuE5ljh=xWu}UJ(w& zZS{YM#dJQRJ8NFAU~IXXf_)?M#)8j)qy~z%G{OiO)qE+%%eoiLF9J4b7Q+&GyeCo4 zD^T^@B#dxWOuce)YQP9(RP({FM#6te$6G6r@+l)H?mpPhlep6!o11N%d@rX1&cmm#2fNK8qoOHDEDMeSb-#i9F`ny`LXblPU;Lpk(4woeDD|BQG= zFY;1B#LR)^Um`TW?;$3BEBNaJ(}_*egW-TdovoJ&zj!`e;1|sipM;qVvWkJ-5rZ$b zD0|h!!fCxD`?UYm9e)nw@!R4lH00WW?)a;FTe+tBidOX1m56DuiMzljj97{S@? zjGKmsOt{M*7RY?sje5eShA} z67)xAOlBGeM!;VlE1x+kc89Sbs7Ic8Jau+1HBHuqy)SUlbSsO2zwmcJ5{{eXy_?%t z*X{C3htH}h9pAjU&~LcSCcR$Qk9kV!EB)cR9kjlBfup~OR{UH^E#Z1EaO6TU)J!%} z*4nZB6cO!3tkQJ-@VLZixK2U2T!ECdMXZN>fPZx`Lv-6FDpCKinZZ3Au0#ub$}aDz zUB>m;RGn>2kSL01TY2@{Lfid5Q?>l6u=|U*({5s?($&_Wyi+gdB(EUo3D>$T^Lc1n zy1^&zV~Xj8zgOySdB;3AS#SW2?Xi|pSM$EQkQJ#Zhnr9?can`C)|USae1NM}J>Rnn zE+t(S(2a{@IR;5D(UuaXhqOKLwTKOuIuii=20SHkW!8n0f=W#Gpq0U13?foeQc4+A zJ-t)4Bi?N%0M8Kt6GdB1Y4&cbZ0#6eL&d&mSYpLU*A-awSQz;MSX`%)cz3O$BnNol z|Am?5VJc@|{v~$2o?`;fr!%Ugczs%& zxHyMRpuQPz?mfa)As%n0gZgM5{~RjZ*9Q9SPJs};WG{5As&|N10bg|Bc*-KWp73nS zH1SUA>U;BD28f=ZqZfMC<=5KxXlDcyl&$;PKUk#8<#9domZY!JQ`)wSV$IzNH~~mJ z9-0ugPXJv!?it!J>@d4fP13W;_Ff7|Y@f2J4}JU4A^u*T!!l~&ItHlCSs&eVL8how zHSi`yG>6${)F8|{yNHOrkqrmnGdQT-K7@j23*b5dV6hv2!e z^RfrYk(wY$3*q*UYq?8#YQ!6>Ao})EX~#s%)yCq)l7#{`V|X`z{i`2!PnU=r$Pdi! ziZH7#h9-uljk_lcAau(LcG8knxWVpySr*pMMM^Hn+G~8+LzxL={ut$@N%fL9%jZ|j z`k>zd4N5mBvt@JCsA8s#xdyRO6|46O4E#oF9xMV{N4~-QmH!##e(dC~&)!gmk56Ge z6VJ+uYUtkm!@|u>$lkR0nbtAb0M#_T|_L$jaX7tgk9bH6q)f0zu$Tri_N0t2CA;PSDtLL z+hApU{PzM=cj0)693Kz<4ybonHxJ{_pNdft_;7ixm?y1X{N=Qu0pY9K!DQAhuV)vG9A{8PFB9xjkREM+LG zKWZ8rL&lxG_dNFs$6Plz4n{v_sYOrJtB>VW^_R#n*2N-}-#?Fdy!Zi1wJUt|rrJSr z6{|-@d@jDgvGXNNYw9=dg0-JgLhFYq3H-uQOzm}%cW4n4oO@D}(`HM+Q+!t&6#>y6 zb+sNNhZrd_Oa$1~=O@VpF3$`)^IHTukc6htyq1dy7z1n1W`k*8JA|H<4!Tru(SLVM zW;_O1_deF<%L{NQ&AN(ZCvuNymCZc1ceLOl^6uKHHUU>lhT6JCM#GM%6T`WSKCR}} zea}^wiT$Y-x&cwe&u`ys;~!&GhKMj|1sfz*pkh<`JT(*EZEjOAdayaP3h<@c=(P{Q zRz2ZTCY!bIPyJJMa+Qri7)Ubjy~0(_Eap9R>yvQW)e-pF*RB*+r57=ItN%jBhQSm- zC2hH!Z2Bf)RRnKnLbSu&C^BhqQflqVCMWNd5$VU_$yt^Dp%Q$*uI;QhQk;l zo2pZe$Ed2wjBgkX*<51oL6>sK-92?*N8{8_B{DI^wp7gHGBm7Oa*vs-%1~7 ziU3}`*i#7Rc@yaW_OF{y>Gn-E=cnhx{UpMd(n!j-Jl)k_8O(0k>0h2{Eq{&CGRqsP z5qMOqJ88n!<&rqZoy&c@q(V8JO|Mp=^K5B?rB7|LN1>n~56?-5qN}}a@soGB=);EA z0}9$Yon}S7VN_RD0&tG|{$-fr$$crVGHG1T{sFOXk)|4a?Db2lJVV?8Xz(^9_droh z=G8s^5g!b56Ayn3g$U?8B41T}Iei1<^k|Pz7715>pOT4QM;pVFD&wx-1g*YnVf4nH z4_w0NM~rqA;!sMH(pQvGISUuI%Tw{$T=kYMI*C5k6DjzP$3yTX>i0htW7W5iS8Bx=dW zD5k(a6*=!F%h5MCvss@${-!PO%w0+~p0hS02~_yHtoD|kuzSTz!-NG)`9Kx$ff$K+ zUlqg5?||iQ_*I*GH+uzqgkQ1s!lbw`dB&QZ4MDWbqNjJ!kdN#iG8(LVcswFTbU;Nq zG4j|xC9LhDjnY+z$g0Djy-!SQ0H*Yo-c-xlKS0Q zV;!s;nChO9-@2tndX}panL;Zyaa6vYzjL$VaG028saVOca3eFoRx#0q!2B2}S5Ou2 zF=kNFz7qD(32(W{iAe0b|K-h?`TOE-mG48Z+4t}+LL&tTMHLX>cu zk}|wiN=n;mUj8tiwyeP74tu*D2J1x4AyMvU;5}m+;BnhG!+T#$OLaI$mnt^qEvK;; z$9>f5b97jvf}@^KQv+;r=L>j{;lM!K++>7rB-45`#Af+xlcA29#^(oBMlmXKUvVA6 zm%0992mEt|OPL0nzx&9f$s}ncuVpWHTGQ z`mnatP2mWPIUJqQ0azvc+B=#=F8MVBeHk7__a^;BRQ$VhZ%y#lG?0o9Yno0jvqDuYhQ(-94p zCvlqt0z9Jjc;Xna{CvYnM~@O4wZv`lTF<#R_fbsSSL(~~>K%<$UhOtZ{ZIM#>no10 z)Df>UARhY}7<`Sq(KCP>IsNdO--TjWJj8`Elp6XLE2xAlqyzV|Dv6)~Hbqqbmczzw z65g%ZQ7SZCz(Z7Wr?pxb9EEY{|JS2(Beu^Ws7J<8LLjWC=^WrT$2OuY7t=-tD(bsy z-d);1@Pq8(@v*D%Qjmg4alPP&x4RNVuxo&Tb8fll5zTm_&qt?O#k4uqL(SdG&jPVI zKd;1(bAL*PU(xSb8qq7y<9jhBEw`}A+j)rQ zct=m%bUvYPGkJYKh$$_95TT?}$@g7lUhp#>wT4Ewe(c_IN&|!P@gpo@(f4@lrHE;R zZC79N^sM?h)lKrHBY@>#dQb;$^t<}JseBc}W#8dFzzFRZpWgvgQDrvDKTSvYzM-kI zU0y#3ep$mnE5hO%)ZG~-2?R(sYAWu(4McX}PR@L{$H7!e`NI+X_X^!5AHt0uk<@HT z3IPR;WBvBNRM9rxmOpD_ynbCxP0c=&@?lxvcF`<@uSx0{^;OI#M7l^GUy~JjUlm`M zhob1FlTxPt{A#Gk2@@r2s-uGa7GGhU`wGNngL@&2kL(a$nU%Tr2dqp<*qM4eQmam2 z)@#U(;ZUJ2=1~h7Ru69wo|xkaX;m1W5I+T>0<{;yHKvki?)3xF*iW7V{u{Di8_;|m zSQ*w09IooK-Pb?Jcn6bi**Dp2m>Mk4!{*fIDveT|m9A6@?<&T&x|E7$AH%tJRHvOS zPxA@VA7T~Q2q4DwV^432F%bC|-;E=P0i!UH21o{nyT%aZ@wjP=^e|vQ*Vq1xt|tcK zsCm(6IC@45_%UGtH}-hX^Q>Jz6toX$jhqZNs6=!;{l#h4Do-Dq@7O^SXw(F#RR3N` z0G5PX$~OgD;_W7H zq`u3~E(4a+#6O4xXHc2+C)Z8Fjh0bsevD;dyMU!p-`Q3Y)<^Y-6N-drDivxw`v)YO zxZpFokhgZG_#z|k`}seZkg#GCb5xQp3)xZGSt)3}BP|8+2k5QWWo5fX zwr$^`_v)BNiPX2NS#}#uf3~-+K3~z!V1i7oXVk;HUDMmBZ`ZJq%97iv_(bEcR%2E< zB{JcY6vd^ewF)#-N2(2mLkt505xYvwFjiC>TEY%riu@{O3`d7N96)JlNy0-kO}Q%n zQ_&-ahH5D)2@%%>&>nwSrv}^c54Vt1v=`5OhJo$>*AoA;)_+{w2@b4#R;r6wNbVmY4q{PeuImlyMAwmDYkY&DA&VHbS81Ys9zHQT(F-&Q^WwE6A6^V{XL zRz4vopT-J(;Zpt0SSa%DQ!c<`ZP4-;tJ=`ayOd+Dc;T3yJxJ?uPOK(j^LKiSn~R$F z)aS8mTjL}pm;W(K{+lYs{NP0O%)8{th^{Eh#OzAalDi~Njd4d`L1Qr@g=KsHX|2gw z<{y{C&&78_X8qkO_W)|e$KyXpCYE+Zd;(-sGf-##H(8ihjQJtm z`G97WCZh$bR;34kV@Ev26THUBBDzAMd-mCMYO#SI^Cu94G&~qN|7<#$2D49VHycme z`J?@0IAIL9@=myEf zRv;8E?ihZYf;ryN2F5KsvbAxG#Kg#l72Wis^h`o4gR(ok`)!ma6z|ViRlGyy-zD^o)5xw_+=gQx)T}{l^l27W%eRo<piB6|SpeR}3 zETXfvdau{<24kWmcf{l*-{u!H9MUzsl<;Uwf4U=VI|7p+0CnP@X&L!{w{?_hQu?jj z-h&P0{w&rE^}+Q#`hS&oo>5J9+rE!oL{ORt0xG>DVCZ6@caxBWj)1fPq4#2;NJn}H zscD2BIwHM;bO=R7Is_EyMQ?ofyU#x7j=jen<9_mvn-3X~5Y~ED)|&H~Yt8xlFE4R_ zI5R>0uN#&>bnX6Y&3yH+6^VHTztrv;K+ezlpE%LGfNL4oaz6aN1|G5b(Iq1V000aC zFtG0|oOJ=jEiCBfy8Lf0=dAh4N)t{XYAg6mK=s>IksWu_i7Q0U=GRR@Ux%HPrX8M= zUJ$Z8gTs&6s_XaJodM)6`@b6a-N+yMIH%_4ccn&NAe~$ZW)-)uIpw}X&KCcNoY+5d zb1wV?_7rrr(SRC%EkoF!#^Sozu8mA3;|M(?V^*4At$YA z`;Whe(l^*(!{;<&pWOq%y71-QA3B8p?AAFAU%#s*Cvf0O1~N*B^i8bkyU|3hb41iJ zUVDApN}F=){_g`UPJrKx|35Q~E~afY2u)MJ5_dCpzkRn*jkKP(E6Tnxvw-kTad1rz zM{!Dgv`|QS-Sy45jzu|-SknQ5@D?C5Ms6cVJZKFbE*Q-L`8#fj`iJ-BwkxT>9cVxj-i72p)Z zF=HCfyEAjqe`nA9qwPQO`Ts&VPvMk%)9&>v>!HyRhv|q#Jn>P8*L{}}{Wx3!3 zdIWW=>i*^K1W%V-6uqtqFD6)N;mK|$|0w=;LEprVBbaAtHK9v8#lOO-zNcW8BCPh~ z=ME$J8#Ig*#D*Ck*6c{fYb+9M)j1eX^5%@4G2U&~s(cr(^Dw4`D>R9{@wNt~u+FflMWoGXS$SXdvKr=3vI2x`caqdn}kJ{tlE4Sz_kLS_Qz0y5pyM5;7{O3h3 za4vy_&+%R9dXwK~Q6MNdYQ8$+N!V#1Ie=dGG~)43stbzKwezQlegmKqWgJ;B?@OO6 zJi;iVpMF7@^r8aqv(b*%$}>u~v{Iw7=5(ktM478EC+nfn<0T;*2P`GZ`|{u$*vIka z9F~q0KXd6IRES-BBX$o<9cx7!+VA(H5+`zHPUA&|xLkFsr}=&^)+@`d-oW1O#_}G{wZBiTuP%)s^r~%CK7ejgha65w8$)MGg?)LLrPFzxpgjlbH}chfB!I&@qVzp zZ{dZ)!-ISCGO&r%l;pY4dh>j85xN|RW!`qQkZu;7Y^j>3Y(`=j{0ueD$xx52bCBOE~Su z;7mWk_esD@PkDXp9J%_Po}~F@Cu;PF#1o=jjhWd+i;V{m4rDQ%oJ^Mt8i^r!NpT}K_?^1ea;+ck?TNhUcM}-Ry72(9Dphr55B3@%@Dps z8J$VdA}pG)t!8pEusY?s_WrrKb4?MwK#L1F!7NL7d>Mw=N>}!T2E^?dOWap2mmsVf zl=ZKCM>*9iJ?)-93UDR*-{06=A0H33@ATaSj?w8T`s29 zs^d-ROEYNa8}Dgt7RE1Sq|98PPUD;?>*!gBoaq_Zn=L4|RIcIZ9vb)@1zIri=PlcN<03-X<3fDr5vRprgZpR0k0>?@4ouP$_3^)==9!I~k=| zdT)cMM#Vb2AMt{IDqx$Pf{e^$3zH>e_#_gh&_i%eUVQq^&EXf|(!S%-kiibtY{gk4 zHg2c}vP}kq`9y&O1ltr5$k3%u*IS9CR)bJ$bEg^vxD8fRz(dh4V(33w-Dt{V8ywO$ z&)<1XIRZ3DAz8RCwP_#>sT;|BR_QLmxf8`iV7h`AaZCnd4cBGP8RWO|QoDQBIdLvgNKT$?DD%fp&FF0P}62mUy66l#Ds4+ymaS57nxG4L|Ja}7a%qCkgOZJMf7#UmQ<7QwPs)0S zaMrDF~v|-#sRo#!rwf=}wgr`HKy(@b=6P z=bgW9>R3W@hk^Vk9xg`B%^r7V9|M7GHr6K^C9fo@Ab~8H&D7wpN#8jR*Cq2$*EyJH z8bl)40@c&ao6}+()%x1g!zjAn0B^`Mx9j#;N)@U#Fdg{k$la&6xLj2&Nl)A-l)PWM z;Gca}ai&0;bH9K)e&5BnNXZ~3Pq=8s$oO^{yLxv%pAGGBbXNF|xx_8vBD@ywT3!R~ zkxYxT54zOT)vMm73vUkULc+G8ii$>TFZFQ|ID@nTNw@T5<3+ne_FXw4ejux=fGqE> zfn;?$N9LW8w7KlQIcJKebf}V;@Xn~*0TI&`cYb%f0dnl#9LTYGMS><+M z`4vJ}dN5R2OednG2VT%GqgBU58%xay=TBs|uh1K~WwyJ4q^o}=%>pZ4?OefF%Z%(< zPd1LkjRb%ePG68fG{+vBj&c^#U*_-mg1-0A;cZ5O;>%$gP)|#okNYMEV+A99$rZ_j z@f5$W#+1DNf^Z5T4=uIn{{W6;c|~|CZRdj9`CW(^^t# z2QOT`ABWuR7I?2r`UN-~wR$?WgVu#Y911KwW|PI5tR3}PSJ^Vhiv&z+6^pdglw%-8 zqx|NOGT89;v6j6*V$C^wm%u|TPEO~~dzJUNx%G%He8a=Bn`YEl8s9d7@dvA6 zZky*Amhn-)E_spQJ`g#P7hCT^YH^*-*rWM@@k}aR10qg_r~B;-pJvidMLeX1u|6JS zczrr}szXClN}Ryv%DQ0p@9v1w584pgRf|X0)8JUTsyJ97tMtRWH_|WWa35^$4>3`* z6F!d3Oi<4^iDtj*#5JjLG4Z0?d!+O1*3v3V-0QyE;av4@&QOjFwf6{XgFzmNq&)5K zgj)NNQ^?{v)Zck1?1;+X`)Hc`mI4mCxGMhiwqo#I(K8nHcBT|&!mPpb=*>ZF0Q;JJ z_$asC&U!Y4K#TDn1kJne9MD)k>sIY;@4So=p}4n4_95_UK}PQ!n8K! ztx7RqE`y>scm5n6USlFx5gXE~+dis`5%Qze@O6w8hTNtLLj|E$`5Wa79bGgM%R%P_ zNVf_&VO(lQtB+qvM)hjGacFVN6(Q~p91F`i$3d%ZxhPcU8+vu3+KZ~Lc-t-9T>#r7?eM~ zYbtTPhV&SXbI?B92^|m}+nmifxK-OdCF7QFdlWRoq;KmXs5+FVsH_murisf~{2Ura zER@6O2C9P)$CY9nFgmRcwdsJuXSN79Aze8kA-l-9QlAO&8feL(IoJ|RZN?fYC@I|) zGXS|cC>~n7yVPF|l!-CxH5k zLR5lw5nn)T?c@q)ei1es-$-wz=9+H(Ja#d#I>=KKa7F#^33xtpX#Y#(GI^f9?P`nn zdFUsW{+F_hqn9|z@1fW4`WE0Z>f^I>O)Pi*^)>&QKiN7uz9P#vgfBIc6MO$Pe?^^} z+{uZ(n*XKm^YU7HOzz*;Pu_tzKZ%Yv76ej1cuZr(Wm9Tj=IIt~(WR;mL0B@|T98K+qj-8!Jt6BqB`>DY|7QOQH+x^O_ zPwSGoS!>rYgnJF!LGVlY4+a+0cu!e@e2K!^Aw90f{L$Z1;2cmnC*a;h!|pZB6}p6J zD?wWfb4H>G3Rz?&J#tUKgR`#IzjsW$x0^6s&U?lO!wz?bZ=M;;HoHK0+ z`bsG^&&|5j)XVhH*ftgEA$S?y!V8Py?9Lji#%<5i-8FQjS37`uOw%lmKi(cx3kt^v zFpYinSnP4?&>FMl{fJ_YT7sIN+G6VOZ|BBfKh2mN0+GmJ@Qn1FK-=3LZ*aVx+cLb=uCf zrthFT?n8Lh5%!{Nj@8WtT-knT5Ga$N{Fi| zqvFon{;iAtzrB<;tuJ?z+tGD^=hwjhS|fkvdI$W^5TfJ*alidvdS3r6f-ouKE&3+! zShl0p`3k|#Q;R#`x29HXD%Z-TR{fZWTpv}dAAu&nc|iGJZvVvt z;stCiU;5Ag<3Q&zvujK$uk$*Es@s@gArs+vq*c#S*)jNY<%Exzx?(Oc~FZd z{bYzS8f5s{Z#ChFO0~U|V3tZIa$%U%6C(Lr(&-1#1@|)Br9u2EnTF9VTy-*!RC{JB zVA`4bi4jZn94#&kvi8#YD*i?KZ;q4dTPcaDgXe0-WOjUYw2FO`bokiP)l*Z0F#T+k z2sCC1@^+=sZiIA7U$tau2DHMt;IV@$CL%FxlJ#)u-gYPrNM6A@xOE5>k|fsPs*gs` zb=MnLjaO;J0)4lDlSN91l$0;L8bTxS9`)+0oFxU*i=Kq)n|NdRXzFpa@?4`EZM|du z@mN$=GWro(y}1?Y%CJF<)MgXNx*V>a0ugJKO8*7W*}O$WfLWpoCrD|}c^&Qh8kz1s ziMh9{OI(Ovs_kpBycZTcXm!>5hy@C@!KxFd#Z7VXYjdWr@gez}v+!;|%+GLv66eXY z7YjesS32rD7;+2e!Z#z3TgmAmlkSHhWX!#0v(*l)Lpc`*m6dk=COq!*GpBn!D$q_Z zu`5o%2sPLo9m}%Z+p0*!-CL1XZu6yKFszj>alyp1#`29vN$F~fMWiKR3?v(O>W+n6 zQU>jm=?lP42vA%^bkrP-KrXvAYTLY)MNY#lX}nj7sLQmsOC&we&28RZ*-i{YyVBAL zB61tU&fFYt?XkYOxSQGd3xJCyX)(S+Z|g2~php)iX_cY9n=phWb0mU%Aj2?adS0k8 zWeL-8e{^&)Inlttv5C9>#NNnI?eVByij}VpU{<2=AwM&+5X{+ zN3Wce%#}iW?E9qX4|H?~+LDw>u8?Ggub&+VNjr7;NsrhkgBlQitM(oz=(3BsMn^ z&Y(~TF3Rs8Q1*PX`ZOHvKl4IVORD_@n==v+&sGcQukJxD!riyb(pJ}Qhyd4S~Efz!Lp&e8x)wBgd|4<0QS&d+Lwm>T} zEo5vdbidVuu|Hnm(#s%cJ)hV(j5Y-1^I#vY1?oqvyn?!ze^}p-^Z_w8^V>bFf$pT zz9e80)x*9Bk=kIF1-6J~aU&?yI(26ksyB6%3O*SSx|4<=31%UI>CWKco+01rX*fO^ z(ik16?kJnI*hJtDGr)LjCN}8>3up(AwpxQI%#g4}ypku|1V4uN`Xyx*BkD$=zHkY* zL4`o6S--q~_@p!2r1~rgfhEm^Mt68wpZ}>nS-^a=TAM=ElS?8g1Re!2_xYJJZhwizaIP@6+WZ1Awx86HZFj(L+g#yM`Ya~cMd3xRs=dpNpA#wXwc(4w(*LBU>GL z=9uGe)&@j9YgZ) zLg6#%R5wrQ5^Dp}?BZd5L?CnTmO}U1;20oj_q+_8O3KlTvK3p7s_p z9UP;tDrZ*kM47%tyuT3_^R_9Z-OMTR`6{12&SYK8Q+r)Qh@p1ZC#meFy*7UW6n+ck ztG|dCub414KCCpj&C^n_vJj)j3N&WUd!$dFp7s0`rY)#9T=w-)eC)1-cw&F(QZBZ5 z%m}F&m-UVuonf^^b+)w(w+%Ek33#-vj;hD5C`QdSTc<7m{qPl zJ+b<*=}A9pBERn9`S}vCICdi>{$x0GbxW1B_v9-M#5Dhi;w$ zU7K>Zv^FLyAap41oZhl1I%5Lqly&|XTmZq*(yY4s_*L#CjND-HwoTEBxFW!qZq&)1 z7vKE!Jxtx;i2;2=WF?=s*HwF|r)HuzyX+NPfMoHX&WIdWz`_+GAC)DWWa;svC9?#BhT1J#w`_;~w3@e3zv|8%`O$m`8=(0OIR8I zd{|3X(t0XK%bv^6gH6xRD0hTVR10Y&IusTsP9RY|jw3%`MxqL|M)p2|i#`#m?-ScQ z5A$Qjty__P)SvPn@JA@EH0c)@I>8wDVd(-_)WY|h?CHafuOP zARo*|v{Q~rRM{&r7rS%ka0zJZR|CnM)g2+ol)eo#KS!I|*|~uu*~j;%DdvJ(a@qL$ z$Sq_E?He`%kHet)D1!cg+D9=ZeyU1L(mby`%EOCamzlMVrk7J{eo6Iut(ADt4Ly2p|w?82k0 za@EDukw0K_F=|EXj&TZ@p?9reH`qI-XgCPWWl)p)(i#;J#NEb9AH`KF@K791SA-}e zY{gOmMm+Iwerxrv-Og!GkdglZlz~BteYrHzkfM|rX+iT_M3C-ssLj_&m|VN1Fkgh& z8``3=nfNW#W49KO7kc>F-IR!1$^;$_`utOC&JlP6-fh}ouGhqO6_fA{iXMYH6$^}k zY*BUsQ~6m*^&@G-6@^S@JL(!o2UxMp6Dzffu`Ze~k5fZR=Q(*E_eo0Q+9k#9N*foB zye(};F-)~}K~Z906NG=i-;CE&=B{{Zgae|@h1&$3K?9dk7z-Ay3gMUPQ@~$dgoRg~ z4r!b)-MlJT>c+s=v$^d#`(Eo8;O#~Fo60t1H^uR2g0aDr>2gz2izKJR1tU}8@39ew znv#AjBX(xTUBllZj1x`k26JCt`grO80YBCGIqLuDr}P20?^8i z7@wBeSaNPA3-pZvy0BN%8(?r~GHU zGcxz^yxm_?dA9;}y#1~h`x%O_WLQ`PkRWv@kr}t=Z1x4Qs`pUERmK0O2gi~wiu zoPBrpC3=wd@sk2C-cICWMxhInH-F=e9Qa)^r)`!gmrOg(KR=_p9@3vNe*dNy_y5Vp`K?4~VtU^0#lK_YR1=gLYie#k zU_DxV*-&`>xBe07J=brAn3u)$@5=4~9s$lG$&fK4h9A^QR~FJ=qYE@#q&(x4B3@~8 z+=*Z~cjx-W>wqiI9Qe;j3Vmov1_&E?_&ln4Zz*S{tn&d9Elb5?WJ;@X68io(700%+ z^z`5`&#)?U&z($;O5IU>=wo6L)Ii_mnHT)>-(19>^b(cO@;7zDcQ-P3p`gceV(bUN>;`kA9!gyh9{2+zkI3x)MG|}%@UWJdRJO5p+U+gA(j~B)lh7>OK;w6H*;h*MPa3>`*OzZFPP5U~ z5x1@qSF`>PVP^&$@2dboP&u!xAN0C>3A*!Nh5E2!PH_pIxg04&J%TLrIGHAn2g>h- zlZSIqwP-Eu0cn@EXokV4kjltbl7D;ov#os5M%Jrizt09D76uaw0acxi=roqg?-`6q_TR|0;t5ge3xcqx9+?Rr{@>~VZn8K)}z1^5)p zDzi{O5~t2{oVc5E+hwk)R^K#^EL%3kB{Ob)kZ^dt%;Ck-&)kCM)1;=!m{+%T9OSc1 zIIY&xjupQw`~q+`k^eM!LipXD(#z~`D{7mBru3soB%z*`_M~0o*+%=%Ha{Ajk#w>| zT9e2PrI+7hO{qQW zi`qI5Lrv3EwMP!9O^IH~3L|Ls8r6rOhYA~8q~A@e3?D0?vfD*cJ2i}}jg%DqL7S#O z>~%IJhk?We=rQMyF*&_YpkZ~}Rw(3ZXDZcBbb37%@e|sgrsC+bpXlMWg0fS&pL~7; z9$*(AZplvQ;dPP_AB91XN4W>BlIAv4_e3SvRD0>}@p!Rz7O~Rmxdma<{IL6o$9m`b zfyFk*s?iIbhvBB;jsnl8brfA!HI2w<$+?Ob3YB=`^97lpC}9@9P@8&ojtp$7rm$FJ z530^UscSqJC34PMNO%>;8XP9nn`e$h~qwuamUlk^>Fdd#dnEc=2clG zLT?9OkVJ-L5JQ0GBF`=XnWM)R+T=uS9H9FslNH{^5FQPnFF>mLqhXeiPD;z#Ij9$q zddiZCT*k-!i2#_tG3RJUw^jq0H>Xo+Yd(#cx_0~StoONq zF?sU{9czn#chpqlKR{jzN{5uJjywnXhta#wZ|$LjaXRKd5CA9YY6yvv70JV(XZwTp z>`40LsE`4|qw=i0d(D{Ut|`cT+BSw5MqwLe($ZciDaW)6P${mO`YEs`Xz0*1e{w=W zWgD6?y?X;MgzL09@3L+i-f-ts3Fjbw0v}cF)zBu_Ch}z#J-WP3oO$^D1t^W7X1s)? zcSwCanPff3(`Q69gv4Jz_crle9#p==k#mKlY1ss-57t&RF$HbD;*RP(5BRP=Y?kYc z@ayPckhmopg6i`zd!qcUdBx7T`TDtmZWz$piz$_oZMV>aKy%de-ToQ7F^`FD;mB z0!@X>rlSco;@iUFbGlzRseVGF25AdlEA1>c@M{KMG2@;-d)sKgX#f>@q50VAFjVtNxlL4qaPrHa9w}}OvFqHv6qtoFe7G? zsZLBizl$oVJEB6Zh?!jy)iviXwr%l^rS3`A*xOvB#$VF4p3LGf_e5XH#%j0mjiOxG zqVckw@cA>#Ymge0Ze}M6w+T*XYy`;lUXTqJq~A~&D{HlQDVL?4*?z;g5$&@l;xyTW zIJ2q}PU69%D@{)7-lo9L~*GqmWW1V(3wArAL?I z@KoP1kkTAcqy64CJA<%xs6mawI6#VN&HV{?dQZ2Qvf0R0AAuZB;9QDtMbZyRXtx@= z+V~2Bz7pOXIG(Q>WscKN`qs0@dPiXJ=ZmMVZmYH_o{a`poDHz%2oZ|AZDbn#ft_pc zLXB{_B{$~+50khME#>S3h67&)EV`!U`^I;fI|_--t;76uFgt5L@g3WhVE-Mh7RBW diff --git a/Workshops/IntroductionToEventSourcing/Solved/08-ApplicationLogic.Marten/Core/Http/ETagExtensions.cs b/Workshops/IntroductionToEventSourcing/Solved/08-ApplicationLogic.Marten/Core/Http/ETagExtensions.cs deleted file mode 100644 index 51bf3a7a7..000000000 --- a/Workshops/IntroductionToEventSourcing/Solved/08-ApplicationLogic.Marten/Core/Http/ETagExtensions.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.Net.Http.Headers; - -namespace ApplicationLogic.Marten.Core.Http; - -public static class ETagExtensions -{ - public static int ToExpectedVersion(string? eTag) - { - if (eTag is null) - throw new ArgumentNullException(nameof(eTag)); - - var value = EntityTagHeaderValue.Parse(eTag).Tag.Value; - - if (value is null) - throw new ArgumentNullException(nameof(eTag)); - - return int.Parse(value.Substring(1, value.Length - 2)); - } -} - -[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] -public class FromIfMatchHeaderAttribute: FromHeaderAttribute -{ - public FromIfMatchHeaderAttribute() - { - Name = "If-Match"; - } -} diff --git a/Workshops/IntroductionToEventSourcing/Solved/08-ApplicationLogic.Marten/Program.cs b/Workshops/IntroductionToEventSourcing/Solved/08-ApplicationLogic.Marten/Program.cs index 949cb73fb..ecb38b84c 100644 --- a/Workshops/IntroductionToEventSourcing/Solved/08-ApplicationLogic.Marten/Program.cs +++ b/Workshops/IntroductionToEventSourcing/Solved/08-ApplicationLogic.Marten/Program.cs @@ -17,7 +17,7 @@ .AddImmutableShoppingCarts() .AddMarten(options => { - var schemaName = Environment.GetEnvironmentVariable("SchemaName") ?? "Workshop_ShoppingCarts"; + var schemaName = Environment.GetEnvironmentVariable("SchemaName") ?? "Workshop_Application_ShoppingCarts"; options.Events.DatabaseSchemaName = schemaName; options.DatabaseSchemaName = schemaName; options.Connection(builder.Configuration.GetConnectionString("ShoppingCarts") ??