diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 83ea7ea..0000000 --- a/.editorconfig +++ /dev/null @@ -1,28 +0,0 @@ -# EditorConfig is awesome:http://EditorConfig.org - -# top-most EditorConfig file -root = true - -# Don't use tabs for indentation. -[*] -indent_style = space -# (Please don't specify an indent_size here; that has too many unintended consequences.) - -# Code files -[*.{cs,csx,vb,vbx}] -indent_size = 4 -insert_final_newline = true -charset = utf-8-bom -max_line_length = 120 - -# Xml project files -[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] -indent_size = 2 - -# Xml config files -[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] -indent_size = 2 - -# JSON files -[*.json] -indent_size = 2 \ No newline at end of file diff --git a/LICENSE b/LICENSE index 3c50500..855d86f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,9 +1,9 @@ -Copyright (c) 2013, Cognisant Research +Copyright (c) 2025, Pharmaxo Scientific All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. -Neither the name of Cognisant Research nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. +Neither the name of Pharmaxo Scientific nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md index 88cb901..d85c88d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -cr-aggregaterepository +Pharmaxo Scientific Aggregate Repository ====================== -Common library for saving and rebuilding of objects from event streams - Includes JOliver, GetEventStore and InMemory Implementations +Common library for saving and rebuilding of objects from Kurrent DB event streams using the KurrentDBClient. -[![cognisant-libs MyGet Build Status](https://www.myget.org/BuildSource/Badge/cognisant-libs?identifier=a31a1e31-93c4-4e75-8491-957e0983949f)](https://www.myget.org/) +[Pharmaxo Scientific Aggregate Repository](https://www.nuget.org/packages/PharmaxoScientific.AggregateRepository.KurrentDB) diff --git a/build.cmd b/build.cmd index 0192423..4b20564 100644 --- a/build.cmd +++ b/build.cmd @@ -1,16 +1,25 @@ @echo off SET VERSION=0.0.0 -IF NOT [%1]==[] (set VERSION=%1) +IF NOT [%1]==[] (SET VERSION=%1) SET TAG=0.0.0 -IF NOT [%2]==[] (set TAG=%2) +IF NOT [%2]==[] (SET TAG=%2) SET TAG=%TAG:tags/=% -curl -o nuget.exe https://dist.nuget.org/win-x86-commandline/latest/nuget.exe -.\\nuget.exe restore .\\src\\AggregateRepository.EventStore.Tests\\AggregateRepository.EventStore.Tests.csproj -PackagesDirectory .\\src\\packages -Verbosity detailed +SET RUNTESTS=true +IF NOT [%3]==[] (SET RUNTESTS=%3) -dotnet test .\src\AggregateRepository.EventStore.Tests\AggregateRepository.EventStore.Tests.csproj -if %errorlevel% neq 0 exit /b %errorlevel% +dotnet restore .\src\AggregateRepository.KurrentDB.sln -PackagesDirectory .\src\packages -Verbosity detailed -dotnet pack .\src\AggregateRepository.EventStore\AggregateRepository.EventStore.csproj -o .\dist -p:Version="%VERSION%" -p:PackageVersion="%VERSION%" -p:Tag="%TAG%" -c Release \ No newline at end of file +dotnet format .\src\AggregateRepository.KurrentDB.sln --severity warn --verify-no-changes -v diag +IF %errorlevel% neq 0 EXIT /B %errorlevel% + +IF /I "%RUNTESTS%"=="true" ( + dotnet test .\src\AggregateRepository.KurrentDB.Tests\AggregateRepository.KurrentDB.Tests.csproj + IF %errorlevel% neq 0 EXIT /B %errorlevel% +) ELSE ( + ECHO Skipping tests because RUNTESTS is not set to "true". +) + +dotnet pack .\src\AggregateRepository.KurrentDB\AggregateRepository.KurrentDB.csproj -o .\dist -p:Version="%VERSION%" -p:PackageVersion="%VERSION%" -p:Tag="%TAG%" -c Release \ No newline at end of file diff --git a/build.sh b/build.sh deleted file mode 100755 index cf0562d..0000000 --- a/build.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash -set -e -version="0.0.0" -if [ -n "$1" ]; then version="$1" -fi - -tag="0.0.0" -if [ -n "$2" ]; then tag="$2" -fi -tag=${tag/tags\//} - -dotnet test .\\src\\AggregateRepository.EventStore.Tests\\AggregateRepository.EventStore.Tests.csproj -dotnet pack .\\src\\AggregateRepository.EventStore\\AggregateRepository.EventStore.csproj -o .\\dist -p:Version="$version" -p:PackageVersion="$version" -p:Tag="$tag" -c Release diff --git a/src/.editorconfig b/src/.editorconfig new file mode 100644 index 0000000..f38e0d6 --- /dev/null +++ b/src/.editorconfig @@ -0,0 +1,212 @@ +# editorconfig.org + +# Pharmaxo Scientific .net Codestyles editorconfig v1.0 + +# top-most EditorConfig file +root = true + +# Default settings: +# A newline ending every file +# Use 4 spaces as indentation +[*] +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[project.json] +indent_size = 2 + +# C# and Visual Basic files +[*.{cs,vb}] +charset = utf-8-bom + +# Analyzers +dotnet_analyzer_diagnostic.category-Security.severity = warning +dotnet_code_quality.ca1802.api_surface = private, internal + +# Miscellaneous style rules +dotnet_sort_system_directives_first = true +dotnet_style_predefined_type_for_locals_parameters_members = true:warning +dotnet_style_predefined_type_for_member_access = true:warning + +# avoid this. unless absolutely necessary +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_event = false:warning + +# name all constant fields using PascalCase +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = warning +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.required_modifiers = const +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# static fields should have _ prefix +dotnet_naming_rule.static_fields_should_have_prefix.severity = warning +dotnet_naming_rule.static_fields_should_have_prefix.symbols = static_fields +dotnet_naming_rule.static_fields_should_have_prefix.style = static_prefix_style +dotnet_naming_symbols.static_fields.applicable_kinds = field +dotnet_naming_symbols.static_fields.required_modifiers = static +dotnet_naming_symbols.static_fields.applicable_accessibilities = private, internal, private_protected +dotnet_naming_style.static_prefix_style.required_prefix = _ +dotnet_naming_style.static_prefix_style.capitalization = camel_case + +# internal and private fields should be _camelCase +dotnet_naming_rule.camel_case_for_private_internal_fields.severity = warning +dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields +dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style +dotnet_naming_symbols.private_internal_fields.applicable_kinds = field +dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal +dotnet_naming_style.camel_case_underscore_style.required_prefix = _ +dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case + +# Code quality +dotnet_style_readonly_field = true:warning +dotnet_code_quality_unused_parameters = non_public:warning + +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion + +# CA2208: Instantiate argument exceptions correctly +dotnet_diagnostic.CA2208.severity = error + +# C# files +[*.cs] +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Experimental New line rules + +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false:warning +dotnet_style_allow_multiple_blank_lines_experimental = false:warning + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current + +# Modifier preferences +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:warning + +# Code style defaults +csharp_using_directive_placement = outside_namespace:warning +csharp_prefer_braces = true:warning +csharp_preserve_single_line_blocks = true:warning +csharp_preserve_single_line_statements = false:warning +csharp_prefer_static_local_function = true:suggestion +csharp_prefer_simple_using_statement = false:none +csharp_style_prefer_switch_expression = true:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_constructors = when_on_single_line:suggestion +csharp_style_expression_bodied_operators = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = when_on_single_line:suggestion +csharp_style_expression_bodied_indexers = when_on_single_line:suggestion +csharp_style_expression_bodied_accessors = when_on_single_line:suggestion +csharp_style_expression_bodied_lambdas = when_on_single_line:suggestion +csharp_style_expression_bodied_local_functions = when_on_single_line:suggestion + +# Pattern matching +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion + +# Null checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Other features +csharp_style_prefer_index_operator = false:none +csharp_style_prefer_range_operator = false:none +csharp_style_pattern_local_over_anonymous_function = false:none + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = do_not_ignore +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Namespace preference +csharp_style_namespace_declarations = file_scoped:warning +dotnet_style_namespace_match_folder = true:suggestion + +# Types: Will suggest var in all instances, but does not enforce it. +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = false:none +csharp_style_var_elsewhere = true:suggestion + +# License header (second line required to set severity level for this) +file_header_template = Copyright (c) Pharmaxo. All rights reserved. +dotnet_diagnostic.IDE0073.severity = warning + +# Xml project files +[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,nativeproj,locproj}] +indent_size = 2 + +# Xml build files +[*.builds] +indent_size = 2 + +# Xml files +[*.{xml,stylecop,resx,ruleset}] +indent_size = 2 + +# Xml config files +[*.{props,targets,config,nuspec}] +indent_size = 2 + +# Shell scripts +[*.sh] +end_of_line = lf + +[*.{cmd, bat}] +end_of_line = crlf + +# Markdown files +[*.md] + # Double trailing spaces can be used for BR tags, and other instances are enforced by Markdownlint +trim_trailing_whitespace = false diff --git a/src/AggregateRepository.EventStore.Tests/AggregateRepositoryTestFixture.cs b/src/AggregateRepository.EventStore.Tests/AggregateRepositoryTestFixture.cs deleted file mode 100644 index fd01b1e..0000000 --- a/src/AggregateRepository.EventStore.Tests/AggregateRepositoryTestFixture.cs +++ /dev/null @@ -1,200 +0,0 @@ -// -// Copyright (c) Corsham Science. All rights reserved. -// - -namespace CorshamScience.AggregateRepository.EventStore.Tests -{ - using System; - using System.Collections.Generic; - using System.Threading.Tasks; - using CorshamScience.AggregateRepository.Core; - using CorshamScience.AggregateRepository.Core.Exceptions; - using NUnit.Framework; - - [TestFixture] - public abstract class AggregateRepositoryTestFixture - { - private List _storedEvents = new List(); - private TestAggregate _retrievedAggregate; - private string _aggregateIdUnderTest; - - protected IAggregateRepository RepoUnderTest { get; set; } - - [SetUp] - public async Task SetUp() - { - await InitRepository(); - _aggregateIdUnderTest = Guid.NewGuid().ToString(); - _storedEvents = new List(); - } - - [TearDown] - public async Task TearDown() => await CleanUpRepository(); - - [Test] - public void Retreiving_an_aggregate_from_an_empty_eventstore_should_throw_an_exception() => Assert.ThrowsAsync(async () => await RepoUnderTest.GetAggregateAsync(_aggregateIdUnderTest)); - - [Test] - public async Task Retreiving_a_nonexistant_aggregate_id_should_throw_an_exception() - { - var aggregate = new TestAggregate(_aggregateIdUnderTest); - for (var i = 0; i < 2; i++) - { - var eventId = Guid.NewGuid(); - _storedEvents.Add(eventId); - aggregate.GenerateEvent(eventId); - } - - await RepoUnderTest.SaveAsync(aggregate); - Assert.ThrowsAsync(async () => await RepoUnderTest.GetAggregateAsync(Guid.NewGuid().ToString())); - } - - [Test] - public async Task Retrieving_a_newly_created_aggregate_reconstructs_the_entity_correctly() - { - var aggregate = new TestAggregate(_aggregateIdUnderTest); - await RepoUnderTest.SaveAsync(aggregate); - - _retrievedAggregate = await RepoUnderTest.GetAggregateAsync(_aggregateIdUnderTest); - Assert.AreEqual(_aggregateIdUnderTest, _retrievedAggregate.Id); - Assert.AreEqual(0, _retrievedAggregate.EventsApplied.Count); - } - - [Test] - public async Task Retrieving_an_aggregate_with_events_reconstructs_the_entity_correctly() - { - var aggregate = new TestAggregate(_aggregateIdUnderTest); - for (var i = 0; i < 5; i++) - { - var eventId = Guid.NewGuid(); - _storedEvents.Add(eventId); - aggregate.GenerateEvent(eventId); - } - - await RepoUnderTest.SaveAsync(aggregate); - _retrievedAggregate = await RepoUnderTest.GetAggregateAsync(_aggregateIdUnderTest); - - Assert.AreEqual(_aggregateIdUnderTest, _retrievedAggregate.Id); - Assert.AreEqual(_storedEvents.Count, _retrievedAggregate.EventsApplied.Count); - foreach (var id in _storedEvents) - { - Assert.Contains(id, _retrievedAggregate.EventsApplied); - } - } - - [Test] - public async Task Retrieving_an_aggregate_with_events_when_specifying_a_version_reconstructs_the_entity_correctly() - { - var aggregate = new TestAggregate(_aggregateIdUnderTest); - for (var i = 0; i < 5; i++) - { - var eventId = Guid.NewGuid(); - _storedEvents.Add(eventId); - aggregate.GenerateEvent(eventId); - } - - await RepoUnderTest.SaveAsync(aggregate); - _retrievedAggregate = await RepoUnderTest.GetAggregateAsync(_aggregateIdUnderTest, 6); - - Assert.AreEqual(_aggregateIdUnderTest, _retrievedAggregate.Id); - Assert.AreEqual(_storedEvents.Count, _retrievedAggregate.EventsApplied.Count); - foreach (var id in _storedEvents) - { - Assert.Contains(id, _retrievedAggregate.EventsApplied); - } - } - - [Test] - public async Task Retrieving_an_aggregate_with_events_reconstructs_the_entity_correctly_when_the_event_store_contains_multiple_aggregates() - { - var aggregate = new TestAggregate(_aggregateIdUnderTest); - for (var i = 0; i < 5; i++) - { - var eventId = Guid.NewGuid(); - _storedEvents.Add(eventId); - aggregate.GenerateEvent(eventId); - } - - await RepoUnderTest.SaveAsync(aggregate); - var secondAggregate = new TestAggregate(Guid.NewGuid().ToString()); - for (var i = 0; i < 6; i++) - { - var eventId = Guid.NewGuid(); - secondAggregate.GenerateEvent(eventId); - } - - await RepoUnderTest.SaveAsync(secondAggregate); - _retrievedAggregate = await RepoUnderTest.GetAggregateAsync(_aggregateIdUnderTest); - - Assert.AreEqual(_aggregateIdUnderTest, _retrievedAggregate.Id); - Assert.AreEqual(_storedEvents.Count, _retrievedAggregate.EventsApplied.Count); - foreach (var id in _storedEvents) - { - Assert.Contains(id, _retrievedAggregate.EventsApplied); - } - } - - [Test] - public async Task Saving_new_events_to_an_existing_aggregate_should_correctly_persist_events() - { - var aggregate = new TestAggregate(_aggregateIdUnderTest); - await RepoUnderTest.SaveAsync(aggregate); - - _retrievedAggregate = await RepoUnderTest.GetAggregateAsync(_aggregateIdUnderTest); - - var eventId = Guid.NewGuid(); - _retrievedAggregate.GenerateEvent(eventId); - - await RepoUnderTest.SaveAsync(_retrievedAggregate); - var actualAggregate = await RepoUnderTest.GetAggregateAsync(_aggregateIdUnderTest); - - Assert.AreEqual(1, actualAggregate.EventsApplied.Count); - Assert.AreEqual(_aggregateIdUnderTest, actualAggregate.Id); - Assert.AreEqual(eventId, actualAggregate.EventsApplied[0]); - } - - [Test] - public async Task Saving_an_aggregate_with_expected_version_less_than_the_actual_version_should_throw_a_concurrency_exception() - { - var aggregate = new TestAggregate(_aggregateIdUnderTest); - for (var i = 0; i < 5; i++) - { - var eventId = Guid.NewGuid(); - _storedEvents.Add(eventId); - aggregate.GenerateEvent(eventId); - } - - await RepoUnderTest.SaveAsync(aggregate); - - _retrievedAggregate = await RepoUnderTest.GetAggregateAsync(_aggregateIdUnderTest, 3); - - for (var i = 0; i < 5; i++) - { - var eventId = Guid.NewGuid(); - _storedEvents.Add(eventId); - _retrievedAggregate.GenerateEvent(eventId); - } - - Assert.ThrowsAsync(async () => await RepoUnderTest.SaveAsync(_retrievedAggregate)); - } - - [Test] - public async Task Retrieving_an_aggregate_with_expected_version_greater_than_the_actual_version_should_throw_a_concurrency_exception() - { - var aggregate = new TestAggregate(_aggregateIdUnderTest); - for (var i = 0; i < 5; i++) - { - var eventId = Guid.NewGuid(); - _storedEvents.Add(eventId); - aggregate.GenerateEvent(eventId); - } - - await RepoUnderTest.SaveAsync(aggregate); - Assert.ThrowsAsync(async () => await RepoUnderTest.GetAggregateAsync(_aggregateIdUnderTest, 10)); - } - - protected abstract Task InitRepository(); - - protected abstract Task CleanUpRepository(); - } -} diff --git a/src/AggregateRepository.EventStore.Tests/EventStoreAggregateRepositoryTests.cs b/src/AggregateRepository.EventStore.Tests/EventStoreAggregateRepositoryTests.cs deleted file mode 100644 index 32e66cd..0000000 --- a/src/AggregateRepository.EventStore.Tests/EventStoreAggregateRepositoryTests.cs +++ /dev/null @@ -1,63 +0,0 @@ -// -// Copyright (c) Corsham Science. All rights reserved. -// - -namespace CorshamScience.AggregateRepository.EventStore.Tests -{ - using CorshamScience.AggregateRepository.EventStore; - using DotNet.Testcontainers.Builders; - using System.Runtime.InteropServices; - using DotNet.Testcontainers.Images; - using DotNet.Testcontainers.Containers; - using global::EventStore.Client; - - internal class EventStoreAggregateRepositoryTests : AggregateRepositoryTestFixture - { - private IContainer? _container; - private EventStoreClient? _client; - - protected override async Task InitRepository() - { - const string eventStoreVersion = "23.10.0"; - var imageName = RuntimeInformation.OSArchitecture == Architecture.Arm64 - // if on arm (like an m1 mac) use the alpha arm image from github - ? $"ghcr.io/eventstore/eventstore:{eventStoreVersion}-alpha-arm64v8" - : $"eventstore/eventstore:{eventStoreVersion}-bookworm-slim"; - - const int hostPort = 2113; - - _container = new ContainerBuilder() - .WithImage(new DockerImage(imageName)) - .WithCleanUp(true) - .WithPortBinding(hostPort) - .WithEnvironment(new Dictionary - { - { "EVENTSTORE_INSECURE", "true" }, - { "EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP", "true" }, - }) - .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(hostPort)) - .Build(); - - await _container.StartAsync(); - - var settings = EventStoreClientSettings - .Create($"esdb://admin:changeit@127.0.0.1:{hostPort}?tls=false"); - - _client = new EventStoreClient(settings); - RepoUnderTest = new EventStoreAggregateRepository(_client); - } - - protected override async Task CleanUpRepository() - { - if (_container != null) - { - await _container.DisposeAsync(); - } - - if (_client != null) - { - await _client.DisposeAsync(); - } - } - } -} diff --git a/src/AggregateRepository.EventStore.Tests/TestAggregate.cs b/src/AggregateRepository.EventStore.Tests/TestAggregate.cs deleted file mode 100644 index 64e0d35..0000000 --- a/src/AggregateRepository.EventStore.Tests/TestAggregate.cs +++ /dev/null @@ -1,37 +0,0 @@ -// -// Copyright (c) Corsham Science. All rights reserved. -// - -namespace CorshamScience.AggregateRepository.EventStore.Tests -{ - using CorshamScience.AggregateRepository.Core; - - internal sealed class TestAggregate : AggregateBase - { - private object _id = null!; - - public TestAggregate(string aggregateId) => RaiseEvent(new TestAggregateCreated(aggregateId)); - - // ReSharper disable once UnusedMember.Local - private TestAggregate() - { - } - - // ReSharper disable once ConvertToAutoPropertyWithPrivateSetter - public override object Id => _id; - - public List EventsApplied { get; } = new List(); - - protected override EventMap Map => new EventMap - { - [typeof(TestEvent)] = e => Apply((TestEvent)e), - [typeof(TestAggregateCreated)] = e => Apply((TestAggregateCreated)e), - }; - - public void Apply(TestEvent e) => EventsApplied.Add(e.EventId); - - public void Apply(TestAggregateCreated e) => _id = e.AggregateId; - - public void GenerateEvent(Guid eventId) => RaiseEvent(new TestEvent(eventId)); - } -} diff --git a/src/AggregateRepository.EventStore.Tests/TestAggregateCreated.cs b/src/AggregateRepository.EventStore.Tests/TestAggregateCreated.cs deleted file mode 100644 index 347a9eb..0000000 --- a/src/AggregateRepository.EventStore.Tests/TestAggregateCreated.cs +++ /dev/null @@ -1,13 +0,0 @@ -// -// Copyright (c) Corsham Science. All rights reserved. -// - -namespace CorshamScience.AggregateRepository.EventStore.Tests -{ - internal class TestAggregateCreated - { - public TestAggregateCreated(object aggregateId) => AggregateId = aggregateId; - - public object AggregateId { get; } - } -} diff --git a/src/AggregateRepository.EventStore.Tests/TestEvent.cs b/src/AggregateRepository.EventStore.Tests/TestEvent.cs deleted file mode 100644 index 2b83ef6..0000000 --- a/src/AggregateRepository.EventStore.Tests/TestEvent.cs +++ /dev/null @@ -1,15 +0,0 @@ -// -// Copyright (c) Corsham Science. All rights reserved. -// - -namespace CorshamScience.AggregateRepository.EventStore.Tests -{ - using System; - - internal class TestEvent - { - public TestEvent(Guid eventId) => EventId = eventId; - - public Guid EventId { get; } - } -} diff --git a/src/AggregateRepository.EventStore.Tests/Usings.cs b/src/AggregateRepository.EventStore.Tests/Usings.cs deleted file mode 100644 index cefced4..0000000 --- a/src/AggregateRepository.EventStore.Tests/Usings.cs +++ /dev/null @@ -1 +0,0 @@ -global using NUnit.Framework; \ No newline at end of file diff --git a/src/AggregateRepository.EventStore/AggregateRepository.EventStore.csproj b/src/AggregateRepository.EventStore/AggregateRepository.EventStore.csproj deleted file mode 100644 index 2f11f56..0000000 --- a/src/AggregateRepository.EventStore/AggregateRepository.EventStore.csproj +++ /dev/null @@ -1,50 +0,0 @@ - - - - net6.0 - enable - enable - CorshamScience.AggregateRepository.EventStore - CorshamScience.AggregateRepository.EventStore - Corsham Science - Corsham Science - EventStore implementation for saving and rebuilding objects from event streams. - Corsham Science 2022 - https://github.com/qphl/AggregateRepository.EventStore - https://github.com/qphl/AggregateRepository.EventStore - https://raw.githubusercontent.com/qphl/AggregateRepository.EventStore/master/logo.png - Event Store, Event Sourcing, Aggregate Repository - CorshamScience.AggregateRepository - https://GitHub.com/qphl/AggregateRepository.EventStore/releases/tag/ - CorshamScience.AggregateRepository.EventStore - BSD-3-Clause - Logo.png - - - - bin\Debug\netstandard2.0\CorshamScience.AggregateRepository.EventStore.xml - - - - true - - bin\Release\netstandard2.0\CorshamScience.AggregateRepository.Persistence.EventStore.xml - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - True - - - diff --git a/src/AggregateRepository.EventStore/EventStoreAggregateRepository.cs b/src/AggregateRepository.EventStore/EventStoreAggregateRepository.cs deleted file mode 100644 index 73d9e9d..0000000 --- a/src/AggregateRepository.EventStore/EventStoreAggregateRepository.cs +++ /dev/null @@ -1,163 +0,0 @@ -// -// Copyright (c) Corsham Science. All rights reserved. -// - -namespace CorshamScience.AggregateRepository.EventStore -{ - using System; - using System.Text; - using CorshamScience.AggregateRepository.Core; - using CorshamScience.AggregateRepository.Core.Exceptions; - using global::EventStore.Client; - using Newtonsoft.Json; - using Newtonsoft.Json.Linq; - - /// - /// - /// Implementation of which uses Event Store as underlying storage for an aggregate's events. - /// - public class EventStoreAggregateRepository : IAggregateRepository - { - private readonly EventStoreClient _eventStoreClient; - - /// - /// Initializes a new instance of the class using the provided to store and retrieve events for an . - /// - /// The GRPC to connect to. - public EventStoreAggregateRepository(EventStoreClient eventStoreClient) => _eventStoreClient = eventStoreClient; - - /// - /// - /// Thrown when the provided 's ID matches a deleted stream in the EventStore the is configured to use. - /// - public async Task SaveAsync(IAggregate aggregateToSave) - { - var events = aggregateToSave.GetUncommittedEvents().Cast().ToList(); - var streamName = StreamNameForAggregateId(aggregateToSave.Id); - - var originalVersion = aggregateToSave.Version - events.Count; - ulong expectedVersion = originalVersion == 0 ? expectedVersion = StreamRevision.None : (ulong)(originalVersion - 1); - - var preparedEvents = events - .Select(ToEventData) - .ToArray(); - - try - { - await _eventStoreClient.AppendToStreamAsync(streamName, expectedVersion, preparedEvents) - .ConfigureAwait(false); - aggregateToSave.ClearUncommittedEvents(); - } - catch (StreamDeletedException ex) - { - throw new AggregateNotFoundException("Aggregate not found, stream deleted", ex); - } - catch (WrongExpectedVersionException ex) - { - throw new AggregateVersionException("Aggregate version incorrect", ex); - } - } - - /// - public async Task GetAggregateAsync(object aggregateId, int version = int.MaxValue) - where T : IAggregate - { - if (version <= 0) - { - throw new InvalidOperationException("Cannot get version <= 0"); - } - - var streamName = StreamNameForAggregateId(aggregateId); - var events = ReadFromStream(streamName, version); - - return await CreateAndRehydrateAggregateAsync(events, version).ConfigureAwait(false); - } - - private static async Task CreateAndRehydrateAggregateAsync(EventStoreClient.ReadStreamResult events, int version) - where T : IAggregate - { - var aggregate = (T)Activator.CreateInstance(typeof(T), true) !; - - var eventCount = 0; - - await foreach (var @event in events.ConfigureAwait(false)) - { - eventCount++; - aggregate.ApplyEvent(Deserialize(@event)); - } - - // If version is greater than number of events, throw exception - if (eventCount < version && version != int.MaxValue) - { - throw new AggregateVersionException("version is higher than actual version"); - } - - return aggregate; - } - - private static EventData ToEventData(object @event) - { - var data = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(@event)); - - var eventHeaders = new - { - ClrType = @event.GetType().AssemblyQualifiedName, - }; - - var metadata = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(eventHeaders)); - var typeName = @event.GetType().Name; - - return new ( - Uuid.NewUuid(), - typeName, - data, - metadata); - } - - private static object Deserialize(ResolvedEvent resolvedEvent) - { - const string metaDataPropertyName = "ClrType"; - - var jsonData = Encoding.UTF8.GetString(resolvedEvent.Event.Data.Span); - var metaData = Encoding.UTF8.GetString(resolvedEvent.Event.Metadata.Span); - var eventClrTypeName = JObject.Parse(metaData).Property(metaDataPropertyName)?.Value?.ToObject(); - - if (eventClrTypeName is null) - { - throw new InvalidOperationException($"Event Metadata has no property '{metaDataPropertyName}'"); - } - - var type = Type.GetType(eventClrTypeName); - if (type is null) - { - throw new InvalidOperationException($"Could not find type ${eventClrTypeName}"); - } - - var deserialized = JsonConvert.DeserializeObject(jsonData, type); - if (deserialized is null) - { - throw new InvalidOperationException($"Failed to deserialize event of type ${eventClrTypeName}"); - } - - return deserialized; - } - - private static string StreamNameForAggregateId(object id) => "aggregate-" + id; - - private EventStoreClient.ReadStreamResult ReadFromStream(string streamName, int version) - { - var events = _eventStoreClient.ReadStreamAsync( - Direction.Forwards, - streamName, - StreamPosition.Start, - version); - - if (events.ReadState.Result != ReadState.Ok) - { - throw new AggregateNotFoundException(streamName); - } - - return events; - } - } -} diff --git a/src/AggregateRepository.EventStore.Tests/AggregateRepository.EventStore.Tests.csproj b/src/AggregateRepository.KurrentDB.Tests/AggregateRepository.KurrentDB.Tests.csproj similarity index 85% rename from src/AggregateRepository.EventStore.Tests/AggregateRepository.EventStore.Tests.csproj rename to src/AggregateRepository.KurrentDB.Tests/AggregateRepository.KurrentDB.Tests.csproj index c8331e2..7ecebba 100644 --- a/src/AggregateRepository.EventStore.Tests/AggregateRepository.EventStore.Tests.csproj +++ b/src/AggregateRepository.KurrentDB.Tests/AggregateRepository.KurrentDB.Tests.csproj @@ -1,10 +1,10 @@  - net6.0 + net8.0;net481 + latest enable enable - false @@ -26,7 +26,7 @@ - + diff --git a/src/AggregateRepository.KurrentDB.Tests/AggregateRepositoryTestFixture.cs b/src/AggregateRepository.KurrentDB.Tests/AggregateRepositoryTestFixture.cs new file mode 100644 index 0000000..667ffdf --- /dev/null +++ b/src/AggregateRepository.KurrentDB.Tests/AggregateRepositoryTestFixture.cs @@ -0,0 +1,193 @@ +// Copyright (c) Pharmaxo. All rights reserved. + +using CorshamScience.AggregateRepository.Core; +using CorshamScience.AggregateRepository.Core.Exceptions; + +namespace AggregateRepository.KurrentDB.Tests; + +[TestFixture] +public abstract class AggregateRepositoryTestFixture +{ + private List _storedEvents = []; + private TestAggregate? _retrievedAggregate; + private string _aggregateIdUnderTest; + + protected IAggregateRepository RepoUnderTest { get; set; } = null!; + + [SetUp] + public async Task SetUp() + { + await InitRepository(); + _aggregateIdUnderTest = Guid.NewGuid().ToString(); + _storedEvents = []; + } + + [TearDown] + public async Task TearDown() => await CleanUpRepository(); + + [Test] + public void Retreiving_an_aggregate_from_an_empty_kurrentdb_should_throw_an_exception() => Assert.ThrowsAsync(async () => await RepoUnderTest.GetAggregateAsync(_aggregateIdUnderTest)); + + [Test] + public async Task Retreiving_a_nonexistant_aggregate_id_should_throw_an_exception() + { + var aggregate = new TestAggregate(_aggregateIdUnderTest); + for (var i = 0; i < 2; i++) + { + var eventId = Guid.NewGuid(); + _storedEvents.Add(eventId); + aggregate.GenerateEvent(eventId); + } + + await RepoUnderTest.SaveAsync(aggregate); + Assert.ThrowsAsync(async () => await RepoUnderTest.GetAggregateAsync(Guid.NewGuid().ToString())); + } + + [Test] + public async Task Retrieving_a_newly_created_aggregate_reconstructs_the_entity_correctly() + { + var aggregate = new TestAggregate(_aggregateIdUnderTest); + await RepoUnderTest.SaveAsync(aggregate); + + _retrievedAggregate = await RepoUnderTest.GetAggregateAsync(_aggregateIdUnderTest); + Assert.That(_retrievedAggregate.Id, Is.EqualTo(_aggregateIdUnderTest)); + Assert.That(_retrievedAggregate.EventsApplied.Count, Is.EqualTo(0)); + } + + [Test] + public async Task Retrieving_an_aggregate_with_events_reconstructs_the_entity_correctly() + { + var aggregate = new TestAggregate(_aggregateIdUnderTest); + for (var i = 0; i < 5; i++) + { + var eventId = Guid.NewGuid(); + _storedEvents.Add(eventId); + aggregate.GenerateEvent(eventId); + } + + await RepoUnderTest.SaveAsync(aggregate); + _retrievedAggregate = await RepoUnderTest.GetAggregateAsync(_aggregateIdUnderTest); + + Assert.That(_retrievedAggregate.Id, Is.EqualTo(_aggregateIdUnderTest)); + Assert.That(_retrievedAggregate.EventsApplied.Count, Is.EqualTo(_storedEvents.Count)); + foreach (var id in _storedEvents) + { + Assert.Contains(id, _retrievedAggregate.EventsApplied); + } + } + + [Test] + public async Task Retrieving_an_aggregate_with_events_when_specifying_a_version_reconstructs_the_entity_correctly() + { + var aggregate = new TestAggregate(_aggregateIdUnderTest); + for (var i = 0; i < 5; i++) + { + var eventId = Guid.NewGuid(); + _storedEvents.Add(eventId); + aggregate.GenerateEvent(eventId); + } + + await RepoUnderTest.SaveAsync(aggregate); + _retrievedAggregate = await RepoUnderTest.GetAggregateAsync(_aggregateIdUnderTest, 6); + + Assert.That(_retrievedAggregate.Id, Is.EqualTo(_aggregateIdUnderTest)); + Assert.That(_retrievedAggregate.EventsApplied.Count, Is.EqualTo(_storedEvents.Count)); + foreach (var id in _storedEvents) + { + Assert.Contains(id, _retrievedAggregate.EventsApplied); + } + } + + [Test] + public async Task Retrieving_an_aggregate_with_events_reconstructs_the_entity_correctly_when_the_event_store_contains_multiple_aggregates() + { + var aggregate = new TestAggregate(_aggregateIdUnderTest); + for (var i = 0; i < 5; i++) + { + var eventId = Guid.NewGuid(); + _storedEvents.Add(eventId); + aggregate.GenerateEvent(eventId); + } + + await RepoUnderTest.SaveAsync(aggregate); + var secondAggregate = new TestAggregate(Guid.NewGuid().ToString()); + for (var i = 0; i < 6; i++) + { + var eventId = Guid.NewGuid(); + secondAggregate.GenerateEvent(eventId); + } + + await RepoUnderTest.SaveAsync(secondAggregate); + _retrievedAggregate = await RepoUnderTest.GetAggregateAsync(_aggregateIdUnderTest); + + Assert.That(_retrievedAggregate.Id, Is.EqualTo(_aggregateIdUnderTest)); + Assert.That(_retrievedAggregate.EventsApplied.Count, Is.EqualTo(_storedEvents.Count)); + foreach (var id in _storedEvents) + { + Assert.Contains(id, _retrievedAggregate.EventsApplied); + } + } + + [Test] + public async Task Saving_new_events_to_an_existing_aggregate_should_correctly_persist_events() + { + var aggregate = new TestAggregate(_aggregateIdUnderTest); + await RepoUnderTest.SaveAsync(aggregate); + + _retrievedAggregate = await RepoUnderTest.GetAggregateAsync(_aggregateIdUnderTest); + + var eventId = Guid.NewGuid(); + _retrievedAggregate.GenerateEvent(eventId); + + await RepoUnderTest.SaveAsync(_retrievedAggregate); + var actualAggregate = await RepoUnderTest.GetAggregateAsync(_aggregateIdUnderTest); + + Assert.That(actualAggregate.EventsApplied.Count, Is.EqualTo(1)); + Assert.That(actualAggregate.Id, Is.EqualTo(_aggregateIdUnderTest)); + Assert.That(actualAggregate.EventsApplied[0], Is.EqualTo(eventId)); + } + + [Test] + public async Task Saving_an_aggregate_with_expected_version_less_than_the_actual_version_should_throw_a_concurrency_exception() + { + var aggregate = new TestAggregate(_aggregateIdUnderTest); + for (var i = 0; i < 5; i++) + { + var eventId = Guid.NewGuid(); + _storedEvents.Add(eventId); + aggregate.GenerateEvent(eventId); + } + + await RepoUnderTest.SaveAsync(aggregate); + + _retrievedAggregate = await RepoUnderTest.GetAggregateAsync(_aggregateIdUnderTest, 3); + + for (var i = 0; i < 5; i++) + { + var eventId = Guid.NewGuid(); + _storedEvents.Add(eventId); + _retrievedAggregate.GenerateEvent(eventId); + } + + Assert.ThrowsAsync(async () => await RepoUnderTest.SaveAsync(_retrievedAggregate)); + } + + [Test] + public async Task Retrieving_an_aggregate_with_expected_version_greater_than_the_actual_version_should_throw_a_concurrency_exception() + { + var aggregate = new TestAggregate(_aggregateIdUnderTest); + for (var i = 0; i < 5; i++) + { + var eventId = Guid.NewGuid(); + _storedEvents.Add(eventId); + aggregate.GenerateEvent(eventId); + } + + await RepoUnderTest.SaveAsync(aggregate); + Assert.ThrowsAsync(async () => await RepoUnderTest.GetAggregateAsync(_aggregateIdUnderTest, 10)); + } + + protected abstract Task InitRepository(); + + protected abstract Task CleanUpRepository(); +} diff --git a/src/AggregateRepository.KurrentDB.Tests/KurrentDbAggregateRepositoryTests.cs b/src/AggregateRepository.KurrentDB.Tests/KurrentDbAggregateRepositoryTests.cs new file mode 100644 index 0000000..05415db --- /dev/null +++ b/src/AggregateRepository.KurrentDB.Tests/KurrentDbAggregateRepositoryTests.cs @@ -0,0 +1,63 @@ +// Copyright (c) Pharmaxo. All rights reserved. + +using System.Runtime.InteropServices; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Images; +using KurrentDB.Client; +using PharmaxoScientific.AggregateRepository.KurrentDB; + +namespace AggregateRepository.KurrentDB.Tests; + +internal class KurrentDbAggregateRepositoryTests : AggregateRepositoryTestFixture +{ + private IContainer? _container; + private KurrentDBClient? _client; + + protected override async Task InitRepository() + { + const string eventStoreVersion = "24.10.5"; + + var imageName = RuntimeInformation.OSArchitecture == Architecture.Arm64 + // if on arm (like an m1 mac) use the alpha arm image from github + ? $"ghcr.io/eventstore/eventstore:{eventStoreVersion}-alpha-arm64v8" + : $"eventstore/eventstore:{eventStoreVersion}-bookworm-slim"; + + const int hostPort = 2113; + + _container = new ContainerBuilder() + .WithImage(new DockerImage(imageName)) + .WithCleanUp(true) + .WithPortBinding(hostPort) + .WithCreateParameterModifier(cmd => cmd.User = "root") + .WithEnvironment(new Dictionary + { + { "EVENTSTORE_INSECURE", "false" }, + { "EVENTSTORE_DEV", "true" }, + { "EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP", "true" }, + }) + .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(hostPort)) + .Build(); + + await _container.StartAsync(); + + var settings = KurrentDBClientSettings + .Create($"esdb://admin:changeit@127.0.0.1:{hostPort}?tls=true&tlsVerifyCert=false"); + + _client = new KurrentDBClient(settings); + RepoUnderTest = new KurrentDbAggregateRepository(_client); + } + + protected override async Task CleanUpRepository() + { + if (_container != null) + { + await _container.DisposeAsync(); + } + + if (_client != null) + { + await _client.DisposeAsync(); + } + } +} diff --git a/src/AggregateRepository.KurrentDB.Tests/TestAggregate.cs b/src/AggregateRepository.KurrentDB.Tests/TestAggregate.cs new file mode 100644 index 0000000..e49c75d --- /dev/null +++ b/src/AggregateRepository.KurrentDB.Tests/TestAggregate.cs @@ -0,0 +1,34 @@ +// Copyright (c) Pharmaxo. All rights reserved. + +using CorshamScience.AggregateRepository.Core; + +namespace AggregateRepository.KurrentDB.Tests; + +internal sealed class TestAggregate : AggregateBase +{ + private object _id = null!; + + public TestAggregate(string aggregateId) => RaiseEvent(new TestAggregateCreated(aggregateId)); + + // ReSharper disable once UnusedMember.Local + private TestAggregate() + { + } + + // ReSharper disable once ConvertToAutoPropertyWithPrivateSetter + public override object Id => _id; + + public List EventsApplied { get; } = new List(); + + protected override EventMap Map => new EventMap + { + [typeof(TestEvent)] = e => Apply((TestEvent)e), + [typeof(TestAggregateCreated)] = e => Apply((TestAggregateCreated)e), + }; + + public void Apply(TestEvent e) => EventsApplied.Add(e.EventId); + + public void Apply(TestAggregateCreated e) => _id = e.AggregateId; + + public void GenerateEvent(Guid eventId) => RaiseEvent(new TestEvent(eventId)); +} diff --git a/src/AggregateRepository.KurrentDB.Tests/TestAggregateCreated.cs b/src/AggregateRepository.KurrentDB.Tests/TestAggregateCreated.cs new file mode 100644 index 0000000..809daa1 --- /dev/null +++ b/src/AggregateRepository.KurrentDB.Tests/TestAggregateCreated.cs @@ -0,0 +1,10 @@ +// Copyright (c) Pharmaxo. All rights reserved. + +namespace AggregateRepository.KurrentDB.Tests; + +internal class TestAggregateCreated +{ + public TestAggregateCreated(object aggregateId) => AggregateId = aggregateId; + + public object AggregateId { get; } +} diff --git a/src/AggregateRepository.KurrentDB.Tests/TestEvent.cs b/src/AggregateRepository.KurrentDB.Tests/TestEvent.cs new file mode 100644 index 0000000..890be5d --- /dev/null +++ b/src/AggregateRepository.KurrentDB.Tests/TestEvent.cs @@ -0,0 +1,10 @@ +// Copyright (c) Pharmaxo. All rights reserved. + +namespace AggregateRepository.KurrentDB.Tests; + +internal class TestEvent +{ + public TestEvent(Guid eventId) => EventId = eventId; + + public Guid EventId { get; } +} diff --git a/src/AggregateRepository.KurrentDB.Tests/Usings.cs b/src/AggregateRepository.KurrentDB.Tests/Usings.cs new file mode 100644 index 0000000..e8f0746 --- /dev/null +++ b/src/AggregateRepository.KurrentDB.Tests/Usings.cs @@ -0,0 +1,3 @@ +// Copyright (c) Pharmaxo. All rights reserved. + +global using NUnit.Framework; diff --git a/src/AggregateRepository.EventStore.sln b/src/AggregateRepository.KurrentDB.sln similarity index 83% rename from src/AggregateRepository.EventStore.sln rename to src/AggregateRepository.KurrentDB.sln index e4be82a..464d05e 100644 --- a/src/AggregateRepository.EventStore.sln +++ b/src/AggregateRepository.KurrentDB.sln @@ -3,9 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.3.32929.385 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AggregateRepository.EventStore", "AggregateRepository.EventStore\AggregateRepository.EventStore.csproj", "{84A2B093-71F2-4015-94E1-5188AD8AEDF6}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AggregateRepository.KurrentDB", "AggregateRepository.KurrentDB\AggregateRepository.KurrentDB.csproj", "{84A2B093-71F2-4015-94E1-5188AD8AEDF6}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AggregateRepository.EventStore.Tests", "AggregateRepository.EventStore.Tests\AggregateRepository.EventStore.Tests.csproj", "{5870B925-53E3-4B53-9975-46F508F11445}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AggregateRepository.KurrentDB.Tests", "AggregateRepository.KurrentDB.Tests\AggregateRepository.KurrentDB.Tests.csproj", "{5870B925-53E3-4B53-9975-46F508F11445}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/src/AggregateRepository.KurrentDB/AggregateRepository.KurrentDB.csproj b/src/AggregateRepository.KurrentDB/AggregateRepository.KurrentDB.csproj new file mode 100644 index 0000000..3427345 --- /dev/null +++ b/src/AggregateRepository.KurrentDB/AggregateRepository.KurrentDB.csproj @@ -0,0 +1,47 @@ + + + + net8.0;net481 + latest + enable + enable + PharmaxoScientific.AggregateRepository.KurrentDB + PharmaxoScientific.AggregateRepository.KurrentDB + Pharmaxo Scientific + Pharmaxo Scientific + KurrentDB implementation for saving and rebuilding objects from event streams. + Pharmaxo Scientific 2025 + https://github.com/qphl/AggregateRepository.KurrentDB + https://github.com/qphl/AggregateRepository.KurrentDB + https://raw.githubusercontent.com/qphl/AggregateRepository.KurrentDB/master/logo.png + Event Store, KurrentDB, Event Sourcing, Aggregate Repository + PharmaxoScientific.AggregateRepository.KurrentDB + https://GitHub.com/qphl/AggregateRepository.KurrentDB/releases/tag/ + PharmaxoScientific.AggregateRepository.KurrentDB + BSD-3-Clause + Logo.png + + + + bin\Debug\net8.0\PharmaxoScientific.AggregateRepository.KurrentDB.xml + + + + true + + bin\Release\net8.0\PharmaxoScientific.AggregateRepository.KurrentDB.xml + + + + + + + + + + + + True + + + diff --git a/src/AggregateRepository.KurrentDB/KurrentDbAggregateRepository.cs b/src/AggregateRepository.KurrentDB/KurrentDbAggregateRepository.cs new file mode 100644 index 0000000..4dfe0d6 --- /dev/null +++ b/src/AggregateRepository.KurrentDB/KurrentDbAggregateRepository.cs @@ -0,0 +1,159 @@ +// Copyright (c) Pharmaxo. All rights reserved. + +using System.Text; +using CorshamScience.AggregateRepository.Core; +using CorshamScience.AggregateRepository.Core.Exceptions; +using KurrentDB.Client; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace PharmaxoScientific.AggregateRepository.KurrentDB; + +/// +/// +/// Implementation of which uses KurrentDB as underlying storage for an aggregate's events. +/// +public class KurrentDbAggregateRepository : IAggregateRepository +{ + private readonly KurrentDBClient _kurrentDbClient; + + /// + /// Initializes a new instance of the class using the provided to store and retrieve events for an . + /// + /// The GRPC to connect to. + public KurrentDbAggregateRepository(KurrentDBClient kurrentDbClient) => _kurrentDbClient = kurrentDbClient; + + /// + /// + /// Thrown when the provided 's ID matches a deleted stream in the KurrentDB the is configured to use. + /// + public async Task SaveAsync(IAggregate aggregateToSave) + { + var events = aggregateToSave.GetUncommittedEvents().Cast().ToList(); + var streamName = StreamNameForAggregateId(aggregateToSave.Id); + + var originalVersion = aggregateToSave.Version - events.Count; + var expectedVersion = originalVersion == 0 ? StreamState.NoStream : (ulong)(originalVersion - 1); + + var preparedEvents = events + .Select(ToEventData) + .ToArray(); + + try + { + await _kurrentDbClient.AppendToStreamAsync(streamName, expectedVersion, preparedEvents) + .ConfigureAwait(false); + aggregateToSave.ClearUncommittedEvents(); + } + catch (StreamDeletedException ex) + { + throw new AggregateNotFoundException("Aggregate not found, stream deleted", ex); + } + catch (WrongExpectedVersionException ex) + { + throw new AggregateVersionException("Aggregate version incorrect", ex); + } + } + + /// + public async Task GetAggregateAsync(object aggregateId, int version = int.MaxValue) + where T : IAggregate + { + if (version <= 0) + { + throw new InvalidOperationException("Cannot get version <= 0"); + } + + var streamName = StreamNameForAggregateId(aggregateId); + var events = ReadFromStream(streamName, version); + + return await CreateAndRehydrateAggregateAsync(events, version).ConfigureAwait(false); + } + + private static async Task CreateAndRehydrateAggregateAsync(KurrentDBClient.ReadStreamResult events, int version) + where T : IAggregate + { + var aggregate = (T)Activator.CreateInstance(typeof(T), true)!; + + var eventCount = 0; + + await foreach (var @event in events.ConfigureAwait(false)) + { + eventCount++; + aggregate.ApplyEvent(Deserialize(@event)); + } + + // If version is greater than number of events, throw exception + if (eventCount < version && version != int.MaxValue) + { + throw new AggregateVersionException("version is higher than actual version"); + } + + return aggregate; + } + + private static EventData ToEventData(object @event) + { + var data = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(@event)); + + var eventHeaders = new + { + ClrType = @event.GetType().AssemblyQualifiedName + }; + + var metadata = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(eventHeaders)); + var typeName = @event.GetType().Name; + + return new( + Uuid.NewUuid(), + typeName, + data, + metadata); + } + + private static object Deserialize(ResolvedEvent resolvedEvent) + { + const string metaDataPropertyName = "ClrType"; + + var jsonData = Encoding.UTF8.GetString(resolvedEvent.Event.Data.Span.ToArray()); + var metaData = Encoding.UTF8.GetString(resolvedEvent.Event.Metadata.Span.ToArray()); + var eventClrTypeName = JObject.Parse(metaData).Property(metaDataPropertyName)?.Value.ToObject(); + + if (eventClrTypeName is null) + { + throw new InvalidOperationException($"Event Metadata has no property '{metaDataPropertyName}'"); + } + + var type = Type.GetType(eventClrTypeName); + if (type is null) + { + throw new InvalidOperationException($"Could not find type ${eventClrTypeName}"); + } + + var deserialized = JsonConvert.DeserializeObject(jsonData, type); + if (deserialized is null) + { + throw new InvalidOperationException($"Failed to deserialize event of type ${eventClrTypeName}"); + } + + return deserialized; + } + + private static string StreamNameForAggregateId(object id) => "aggregate-" + id; + + private KurrentDBClient.ReadStreamResult ReadFromStream(string streamName, int version) + { + var events = _kurrentDbClient.ReadStreamAsync( + Direction.Forwards, + streamName, + StreamPosition.Start, + version); + + if (events.ReadState.Result != ReadState.Ok) + { + throw new AggregateNotFoundException(streamName); + } + + return events; + } +} diff --git a/src/AggregateRepository.EventStore/Logo.png b/src/AggregateRepository.KurrentDB/Logo.png similarity index 100% rename from src/AggregateRepository.EventStore/Logo.png rename to src/AggregateRepository.KurrentDB/Logo.png diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets new file mode 100644 index 0000000..80f498a --- /dev/null +++ b/src/Directory.Build.targets @@ -0,0 +1,5 @@ + + + true + +