From aaf2aa8f23485c4e84777bb726c590a223787432 Mon Sep 17 00:00:00 2001 From: Antonello Provenzano Date: Mon, 16 Oct 2023 10:36:58 +0200 Subject: [PATCH] First experimental implementation of an event-stream based aggregate repository for MartenDB and a more traditional repository for MarteDB documents (results also of projections) --- Deveel.Repository.sln | 25 +- .../Data/Aggregate.cs | 73 +++ .../Data/EventCollection.cs | 143 ++++++ .../Data/VersionAttribute.cs | 9 + .../Deveel.EventSourcing.Model.xml | 139 ++++++ .../Deveel.Repository.Aggregate.Model.csproj | 9 + .../Data/AggregateKey.cs | 33 ++ .../Data/MartenDocumentOptions.cs | 12 + .../Data/MartenDocumentRepository.cs | 365 ++++++++++++++ .../Data/MartenEventRepository.cs | 383 ++++++++++++++ .../Data/RepositoryExtensions.cs | 7 + .../Deveel.Repository.MartenDb.csproj | 17 + .../Data/DbPerson.cs | 5 +- .../Data/DbTenantPerson.cs | 5 +- .../Data/EntityRepositoryProviderTestSuite.cs | 8 + .../Data/EntityRepositoryTestSuite.cs | 8 + .../Data/InMemoryRepositoryTests.cs | 8 + .../Data/MartenDocumentRepositoryTestSuite.cs | 78 +++ .../Data/MartenEventRepositoryTestSuite.cs | 147 ++++++ .../Data/PersonAggregate.cs | 61 +++ .../Data/PersonAggregateFaker.cs | 39 ++ .../Data/PersonDocument.cs | 24 + .../Data/PersonDocumentFaker.cs | 18 + .../Data/PersonEvents.cs | 13 + .../Data/PersonProjection.cs | 47 ++ .../Data/PersonRelationship.cs | 7 + .../Data/PersonRelationshipFaker.cs | 10 + .../Data/PostgresDatabase.cs | 24 + .../Data/PostgresTestCollection.cs | 5 + .../Deveel.Repository.MartenDb.XUnit.csproj | 36 ++ .../GlobalUsings.cs | 1 + .../Data/MongoPerson.cs | 5 +- .../Data/MongoRepositoryTestSuite.cs | 8 + .../Data/IPerson.cs | 12 +- .../Data/RepositoryTestSuite.cs | 468 ++++++++++-------- 35 files changed, 2015 insertions(+), 237 deletions(-) create mode 100644 src/Deveel.Repository.Aggregate.Model/Data/Aggregate.cs create mode 100644 src/Deveel.Repository.Aggregate.Model/Data/EventCollection.cs create mode 100644 src/Deveel.Repository.Aggregate.Model/Data/VersionAttribute.cs create mode 100644 src/Deveel.Repository.Aggregate.Model/Deveel.EventSourcing.Model.xml create mode 100644 src/Deveel.Repository.Aggregate.Model/Deveel.Repository.Aggregate.Model.csproj create mode 100644 src/Deveel.Repository.MartenDb/Data/AggregateKey.cs create mode 100644 src/Deveel.Repository.MartenDb/Data/MartenDocumentOptions.cs create mode 100644 src/Deveel.Repository.MartenDb/Data/MartenDocumentRepository.cs create mode 100644 src/Deveel.Repository.MartenDb/Data/MartenEventRepository.cs create mode 100644 src/Deveel.Repository.MartenDb/Data/RepositoryExtensions.cs create mode 100644 src/Deveel.Repository.MartenDb/Deveel.Repository.MartenDb.csproj create mode 100644 test/Deveel.Repository.MartenDb.XUnit/Data/MartenDocumentRepositoryTestSuite.cs create mode 100644 test/Deveel.Repository.MartenDb.XUnit/Data/MartenEventRepositoryTestSuite.cs create mode 100644 test/Deveel.Repository.MartenDb.XUnit/Data/PersonAggregate.cs create mode 100644 test/Deveel.Repository.MartenDb.XUnit/Data/PersonAggregateFaker.cs create mode 100644 test/Deveel.Repository.MartenDb.XUnit/Data/PersonDocument.cs create mode 100644 test/Deveel.Repository.MartenDb.XUnit/Data/PersonDocumentFaker.cs create mode 100644 test/Deveel.Repository.MartenDb.XUnit/Data/PersonEvents.cs create mode 100644 test/Deveel.Repository.MartenDb.XUnit/Data/PersonProjection.cs create mode 100644 test/Deveel.Repository.MartenDb.XUnit/Data/PersonRelationship.cs create mode 100644 test/Deveel.Repository.MartenDb.XUnit/Data/PersonRelationshipFaker.cs create mode 100644 test/Deveel.Repository.MartenDb.XUnit/Data/PostgresDatabase.cs create mode 100644 test/Deveel.Repository.MartenDb.XUnit/Data/PostgresTestCollection.cs create mode 100644 test/Deveel.Repository.MartenDb.XUnit/Deveel.Repository.MartenDb.XUnit.csproj create mode 100644 test/Deveel.Repository.MartenDb.XUnit/GlobalUsings.cs diff --git a/Deveel.Repository.sln b/Deveel.Repository.sln index 3dca9f7f..175d17ac 100644 --- a/Deveel.Repository.sln +++ b/Deveel.Repository.sln @@ -48,9 +48,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Repository.Manager.X EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Repository.Manager.DynamicLinq", "src\Deveel.Repository.Manager.DynamicLinq\Deveel.Repository.Manager.DynamicLinq.csproj", "{638851EF-B000-490C-9035-A962279A3E9B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deveel.Repository.Manager.EasyCaching", "src\Deveel.Repository.Manager.EasyCaching\Deveel.Repository.Manager.EasyCaching.csproj", "{84D55BE2-7DAD-4CA3-A3E2-EFB4D41FA3CA}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Repository.Manager.EasyCaching", "src\Deveel.Repository.Manager.EasyCaching\Deveel.Repository.Manager.EasyCaching.csproj", "{84D55BE2-7DAD-4CA3-A3E2-EFB4D41FA3CA}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deveel.Repository.Manager.EasyCaching.XUnit", "test\Deveel.Repository.Manager.EasyCaching.XUnit\Deveel.Repository.Manager.EasyCaching.XUnit.csproj", "{F47C196B-758D-4C05-8918-FB63C61649EB}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Repository.Manager.EasyCaching.XUnit", "test\Deveel.Repository.Manager.EasyCaching.XUnit\Deveel.Repository.Manager.EasyCaching.XUnit.csproj", "{F47C196B-758D-4C05-8918-FB63C61649EB}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Repository.MartenDb", "src\Deveel.Repository.MartenDb\Deveel.Repository.MartenDb.csproj", "{96284897-30DC-4F27-866C-355F06D02DFB}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Repository.MartenDb.XUnit", "test\Deveel.Repository.MartenDb.XUnit\Deveel.Repository.MartenDb.XUnit.csproj", "{8CAC793C-A886-42C8-B754-7E5F7D8DE1F2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Deveel.Repository.Aggregate.Model", "src\Deveel.Repository.Aggregate.Model\Deveel.Repository.Aggregate.Model.csproj", "{D7403F7B-AD31-4F06-876B-1D97103B931A}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -126,6 +132,18 @@ Global {F47C196B-758D-4C05-8918-FB63C61649EB}.Debug|Any CPU.Build.0 = Debug|Any CPU {F47C196B-758D-4C05-8918-FB63C61649EB}.Release|Any CPU.ActiveCfg = Release|Any CPU {F47C196B-758D-4C05-8918-FB63C61649EB}.Release|Any CPU.Build.0 = Release|Any CPU + {96284897-30DC-4F27-866C-355F06D02DFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {96284897-30DC-4F27-866C-355F06D02DFB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {96284897-30DC-4F27-866C-355F06D02DFB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {96284897-30DC-4F27-866C-355F06D02DFB}.Release|Any CPU.Build.0 = Release|Any CPU + {8CAC793C-A886-42C8-B754-7E5F7D8DE1F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8CAC793C-A886-42C8-B754-7E5F7D8DE1F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8CAC793C-A886-42C8-B754-7E5F7D8DE1F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8CAC793C-A886-42C8-B754-7E5F7D8DE1F2}.Release|Any CPU.Build.0 = Release|Any CPU + {D7403F7B-AD31-4F06-876B-1D97103B931A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D7403F7B-AD31-4F06-876B-1D97103B931A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D7403F7B-AD31-4F06-876B-1D97103B931A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D7403F7B-AD31-4F06-876B-1D97103B931A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -148,6 +166,9 @@ Global {638851EF-B000-490C-9035-A962279A3E9B} = {2860FD4D-510F-43C8-870E-5559B90D0CAD} {84D55BE2-7DAD-4CA3-A3E2-EFB4D41FA3CA} = {2860FD4D-510F-43C8-870E-5559B90D0CAD} {F47C196B-758D-4C05-8918-FB63C61649EB} = {50434E05-0F21-4871-AFB3-A483CEE4A300} + {96284897-30DC-4F27-866C-355F06D02DFB} = {2860FD4D-510F-43C8-870E-5559B90D0CAD} + {8CAC793C-A886-42C8-B754-7E5F7D8DE1F2} = {50434E05-0F21-4871-AFB3-A483CEE4A300} + {D7403F7B-AD31-4F06-876B-1D97103B931A} = {2860FD4D-510F-43C8-870E-5559B90D0CAD} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {01FD9B16-84B3-4D99-80C1-11B2F3D65B56} diff --git a/src/Deveel.Repository.Aggregate.Model/Data/Aggregate.cs b/src/Deveel.Repository.Aggregate.Model/Data/Aggregate.cs new file mode 100644 index 00000000..1c96a50e --- /dev/null +++ b/src/Deveel.Repository.Aggregate.Model/Data/Aggregate.cs @@ -0,0 +1,73 @@ +namespace Deveel.Data { + /// + /// Provides a base implementation of an object + /// that aggregates a stream of events to form + /// its state. + /// + public abstract class Aggregate { + /// + /// Constructs the aggregate from the initial set of + /// committed events. + /// + /// + protected Aggregate(IEnumerable? committedEvents = null) { + Events = new EventCollection(this, committedEvents); + + foreach (var @event in Events.Committed) { + ApplyEvent(@event); + } + } + + /// + /// Gets the stream of events that are applied to + /// the aggregate. + /// + public EventCollection Events { get; } + + /// + /// Gets the version of the aggregate. + /// + /// + /// The default implementation returns the total number + /// of events in the stream. + /// + [Version] + public virtual int Version => Events.Count; + + /// + /// Applies the given event to the aggregate, changing + /// its state and incrementing the version. + /// + /// + /// The event to apply to the aggregate. + /// + public void Apply(object @event) { + ArgumentNullException.ThrowIfNull(@event, nameof(@event)); + + ApplyEvent(@event); + + Events.Add(@event); + } + + /// + /// When overridden in a derived class, applies the given + /// event to the aggregate, changing its state. + /// + /// + /// The event to apply to the aggregate. + /// + protected virtual void ApplyEvent(object @event) { + } + + /// + /// Commits the current stream of events to the aggregate. + /// + public void Commit() => Events.Commit(); + + /// + /// Rolls back the current stream of uncommitted events + /// to the aggregate. + /// + public void Rollback() => Events.Clear(); + } +} diff --git a/src/Deveel.Repository.Aggregate.Model/Data/EventCollection.cs b/src/Deveel.Repository.Aggregate.Model/Data/EventCollection.cs new file mode 100644 index 00000000..41e0671f --- /dev/null +++ b/src/Deveel.Repository.Aggregate.Model/Data/EventCollection.cs @@ -0,0 +1,143 @@ +using System.Collections; + +namespace Deveel.Data { + /// + /// A collection of events that are applied to an aggregate. + /// + public sealed class EventCollection : IReadOnlyCollection { + private readonly List events; + + /// + /// Constructs the collection from the initial + /// set of committed events. + /// + /// + /// The aggregate that the events are applied to. + /// + /// + /// The initial set of events that are committed to + /// form the aggregate. + /// + internal EventCollection(Aggregate aggregate, IEnumerable? committedEvents = null) { + ArgumentNullException.ThrowIfNull(aggregate, nameof(aggregate)); + + Aggregate = aggregate; + + events = new List(); + + if (committedEvents != null) { + foreach (var @event in committedEvents) { + events.Add(new EventState(@event, true)); + } + } + } + + /// + /// Gets the aggregate that the events are applied to. + /// + public Aggregate Aggregate { get; } + + /// + /// Gets the total number of events in the collection. + /// + public int Count { + get { + lock (events) { + return events.Count; + } + } + } + + /// + public IReadOnlyList Committed { + get { + lock (events) { + return events.Where(x => x.IsCommitted).Select(x => x.Event).ToArray(); + } + } + } + + /// + public IReadOnlyList Uncommitted { + get { + lock (events) { + return events.Where(x => !x.IsCommitted).Select(x => x.Event).ToArray(); + } + } + } + + /// + /// Adds a new event to the collection as uncommitted. + /// + /// + /// The event to add to the collection. + /// + /// + /// The event is added to the collection as uncommitted, + /// that means that it is not yet part of the aggregate + /// and that can be removed or cleared. + /// + internal void Add(object @event) { + lock (events) { + events.Add(new EventState(@event, false)); + } + } + + /// + /// Clears all the uncommitted events from the collection. + /// + internal void Clear() { + lock (events) { + for (var i = events.Count - 1; i >= 0; i--) { + if (!events[i].IsCommitted) + events.RemoveAt(i); + } + } + } + + internal void Commit() { + lock (events) { + for (var i = events.Count - 1; i >= 0; i--) { + if (!events[i].IsCommitted) + events[i] = new EventState(events[i].Event, true); + } + } + } + + /// + /// Checks if the given event is contained in the collection. + /// + /// + /// The event to check. + /// + /// + /// Returns true if the event is contained in the + /// collection, otherwise false. + /// + public bool Contains(object @event) { + lock (events) { + return events.Any(x => x.Event.Equals(@event)); + } + } + + /// + public IEnumerator GetEnumerator() { + lock (events) { + return events.Select(x => x.Event).GetEnumerator(); + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + private readonly struct EventState { + internal EventState(object @event, bool isCommitted) { + Event = @event; + IsCommitted = isCommitted; + } + + public object Event { get; } + + public bool IsCommitted { get; } + } + } +} diff --git a/src/Deveel.Repository.Aggregate.Model/Data/VersionAttribute.cs b/src/Deveel.Repository.Aggregate.Model/Data/VersionAttribute.cs new file mode 100644 index 00000000..55a3baba --- /dev/null +++ b/src/Deveel.Repository.Aggregate.Model/Data/VersionAttribute.cs @@ -0,0 +1,9 @@ +namespace Deveel.Data { + /// + /// An attribute that is used to identify a property of an + /// aggregate that is used to obtain its version. + /// + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + public sealed class VersionAttribute : Attribute { + } +} diff --git a/src/Deveel.Repository.Aggregate.Model/Deveel.EventSourcing.Model.xml b/src/Deveel.Repository.Aggregate.Model/Deveel.EventSourcing.Model.xml new file mode 100644 index 00000000..23ebb209 --- /dev/null +++ b/src/Deveel.Repository.Aggregate.Model/Deveel.EventSourcing.Model.xml @@ -0,0 +1,139 @@ + + + + Deveel.Repository.Aggregate.Model + + + + + Provides a base implementation of an object + that aggregates a stream of events to form + its state. + + + + + Constructs the aggregate from the initial set of + committed events. + + + + + + Gets the stream of events that are applied to + the aggregate. + + + + + Gets the version of the aggregate. + + + The default implementation returns the total number + of events in the stream. + + + + + Applies the given event to the aggregate, changing + its state and incrementing the version. + + + The event to apply to the aggregate. + + + + + When overridden in a derived class, applies the given + event to the aggregate, changing its state. + + + The event to apply to the aggregate. + + + + + Commits the current stream of events to the aggregate. + + + + + Rolls back the current stream of uncommitted events + to the aggregate. + + + + + A collection of events that are applied to an aggregate. + + + + + Constructs the collection from the initial + set of committed events. + + + The aggregate that the events are applied to. + + + The initial set of events that are committed to + form the aggregate. + + + + + Gets the aggregate that the events are applied to. + + + + + Gets the total number of events in the collection. + + + + + + + + + + + Adds a new event to the collection as uncommitted. + + + The event to add to the collection. + + + The event is added to the collection as uncommitted, + that means that it is not yet part of the aggregate + and that can be removed or cleared. + + + + + Clears all the uncommitted events from the collection. + + + + + Checks if the given event is contained in the collection. + + + The event to check. + + + Returns true if the event is contained in the + collection, otherwise false. + + + + + + + + An attribute that is used to identify a property of an + aggregate that is used to obtain its version. + + + + diff --git a/src/Deveel.Repository.Aggregate.Model/Deveel.Repository.Aggregate.Model.csproj b/src/Deveel.Repository.Aggregate.Model/Deveel.Repository.Aggregate.Model.csproj new file mode 100644 index 00000000..05257dc7 --- /dev/null +++ b/src/Deveel.Repository.Aggregate.Model/Deveel.Repository.Aggregate.Model.csproj @@ -0,0 +1,9 @@ + + + + repository;event;eventsourcing;events;aggregate;model + The library provides the models and abstractions to implement event aggregates to be supported by event-sourcing compatible repositories + ./Deveel.EventSourcing.Model.xml + + + diff --git a/src/Deveel.Repository.MartenDb/Data/AggregateKey.cs b/src/Deveel.Repository.MartenDb/Data/AggregateKey.cs new file mode 100644 index 00000000..00197784 --- /dev/null +++ b/src/Deveel.Repository.MartenDb/Data/AggregateKey.cs @@ -0,0 +1,33 @@ +namespace Deveel.Data { + /// + /// A structure that identifies an aggregate by its key and + /// revision version. + /// + public readonly struct AggregateKey { + /// + /// Constructs the key of an aggregate. + /// + /// + /// The unique key of the aggregate. + /// + /// + /// An optional version of the aggregate to identify. + /// + public AggregateKey(object key, long? version = null) { + ArgumentNullException.ThrowIfNull(key, nameof(key)); + + Key = key; + Version = version; + } + + /// + /// Gets the unique key of the aggregate. + /// + public object Key { get; } + + /// + /// Gets the version of the aggregate to identify. + /// + public long? Version { get; } + } +} diff --git a/src/Deveel.Repository.MartenDb/Data/MartenDocumentOptions.cs b/src/Deveel.Repository.MartenDb/Data/MartenDocumentOptions.cs new file mode 100644 index 00000000..89132776 --- /dev/null +++ b/src/Deveel.Repository.MartenDb/Data/MartenDocumentOptions.cs @@ -0,0 +1,12 @@ +namespace Deveel.Data { + /// + /// Provides a set of options that can be used to configure + /// the behavior of a . + /// + public class MartenDocumentOptions { + /// + /// Gets or sets a flag indicating if the repository is read-only. + /// + public bool ReadOnly { get; set; } = false; + } +} diff --git a/src/Deveel.Repository.MartenDb/Data/MartenDocumentRepository.cs b/src/Deveel.Repository.MartenDb/Data/MartenDocumentRepository.cs new file mode 100644 index 00000000..86fade09 --- /dev/null +++ b/src/Deveel.Repository.MartenDb/Data/MartenDocumentRepository.cs @@ -0,0 +1,365 @@ +using System.ComponentModel.DataAnnotations; +using System.Linq.Expressions; +using System.Reflection; + +using Marten; +using Marten.Schema; + +using Microsoft.Extensions.Options; + +namespace Deveel.Data { + public class MartenDocumentRepository : IRepository, + IFilterableRepository, + IPageableRepository + where TEntity : class { + private readonly IDocumentStore store; + private MemberInfo? keyMember; + private PropertyInfo? versionProperty; + + public MartenDocumentRepository(IDocumentStore store, IOptions? options = null) { + this.store = store; + Options = options?.Value ?? new MartenDocumentOptions(); + } + + protected MartenDocumentOptions Options { get; } + + protected void ThrowIfReadOnly() { + if (Options.ReadOnly) + throw new RepositoryException("The repository is read-only"); + } + + private Expression> AssertExpression(IQueryFilter filter) { + if (filter == null || filter.IsEmpty()) + return x => true; + + if (!(filter is IExpressionQueryFilter exprFilter)) + throw new RepositoryException($"The filter of type {filter.GetType()} is not supported"); + + try { + return exprFilter.AsLambda(); + } catch (Exception ex) { + throw new RepositoryException("Unable to trasnform the provided filter to an expression", ex); + } + } + + public async Task AddAsync(TEntity entity, CancellationToken cancellationToken = default) { + ThrowIfReadOnly(); + + IDocumentSession? session = null; + + try { + session = await store.LightweightSerializableSessionAsync(cancellationToken); + + session.Insert(entity); + + await session.SaveChangesAsync(cancellationToken); + } catch (Exception ex) { + throw new RepositoryException("Unknown error while adding an entity", ex); + } finally { + session?.Dispose(); + } + } + + public async Task AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) { + ThrowIfReadOnly(); + + IDocumentSession? session = null; + + try { + session = await store.LightweightSerializableSessionAsync(cancellationToken); + + session.Insert(entities); + + await session.SaveChangesAsync(cancellationToken); + } catch (Exception ex) { + throw new RepositoryException("Unknown error while adding a range of entities", ex); + } finally { + session?.Dispose(); + } + } + + Task IFilterableRepository.CountAsync(IQueryFilter filter, CancellationToken cancellationToken) + => CountAsync(AssertExpression(filter), cancellationToken); + + public async Task CountAsync(Expression>? filter = null, CancellationToken cancellationToken = default) { + IQuerySession? session = null; + + try { + session = await store.QuerySerializableSessionAsync(cancellationToken); + + IQueryable querySet = session.Query(); + if (filter != null) + querySet = querySet.Where(filter); + + return await querySet.CountAsync(cancellationToken); + } catch (Exception ex) { + throw new RepositoryException("Unknown error while counting entities", ex); + } finally { + session?.Dispose(); + } + } + + Task IFilterableRepository.ExistsAsync(IQueryFilter filter, CancellationToken cancellationToken) + => ExistsAsync(AssertExpression(filter), cancellationToken); + + public async Task ExistsAsync(Expression>? filter = null, CancellationToken cancellationToken = default) { + IQuerySession? session = null; + + try { + session = await store.QuerySerializableSessionAsync(cancellationToken); + + IQueryable querySet = session.Query(); + if (filter != null) + querySet = querySet.Where(filter); + + return await querySet.AnyAsync(cancellationToken); + } catch (Exception ex) { + throw new RepositoryException("Unknown error while checking for entities", ex); + } finally { + session?.Dispose(); + } + } + + Task> IFilterableRepository.FindAllAsync(IQueryFilter filter, CancellationToken cancellationToken) + => FindAllAsync(AssertExpression(filter), cancellationToken); + + public async Task> FindAllAsync(Expression>? filter = null, CancellationToken cancellationToken = default) { + IQuerySession? session = null; + + try { + session = await store.QuerySerializableSessionAsync(cancellationToken); + + IQueryable querySet = session.Query(); + if (filter != null) + querySet = querySet.Where(filter); + + return (await querySet.ToListAsync(cancellationToken)).ToList(); + } catch (Exception ex) { + throw new RepositoryException("Unknown error while finding entities", ex); + } finally { + session?.Dispose(); + } + } + + Task IFilterableRepository.FindAsync(IQueryFilter filter, CancellationToken cancellationToken) + => FindAsync(AssertExpression(filter), cancellationToken); + + public async Task FindAsync(Expression>? filter = null, CancellationToken cancellationToken = default) { + IQuerySession? session = null; + + try { + session = await store.QuerySerializableSessionAsync(cancellationToken); + + IQueryable querySet = session.Query(); + if (filter != null) + querySet = querySet.Where(filter); + + return await querySet.FirstOrDefaultAsync(cancellationToken); + } catch (Exception ex) { + throw new RepositoryException("Unknown error while finding an entity", ex); + } finally { + session?.Dispose(); + } + } + + public async Task FindByKeyAsync(object key, CancellationToken cancellationToken = default) { + IQuerySession? session = null; + + try { + session = await store.QuerySerializableSessionAsync(cancellationToken); + + if (key is string keyString) + return await session.LoadAsync(keyString, cancellationToken); + if (key is Guid keyGuid) + return await session.LoadAsync(keyGuid, cancellationToken); + if (key is int keyInt) + return await session.LoadAsync(keyInt, cancellationToken); + + throw new RepositoryException($"The key '{key}' is not a valid string or GUID"); + } catch (Exception ex) { + throw new RepositoryException("Unknown error while finding an entity by key", ex); + } finally { + session?.Dispose(); + } + } + + public virtual object? GetEntityKey(TEntity entity) { + if (keyMember == null) { + var docType = store.Options.FindOrResolveDocumentType(typeof(TEntity)); + + if (docType == null) + throw new RepositoryException($"The entity '{typeof(TEntity).Name}' is not a valid document type"); + + keyMember = docType.IdMember; + + if (keyMember == null) + throw new RepositoryException($"The entity '{typeof(TEntity).Name}' does not have an identity property"); + } + + if (keyMember is PropertyInfo property) + return property.GetValue(entity); + if (keyMember is FieldInfo field) + return field.GetValue(entity); + + throw new RepositoryException($"The key member '{keyMember.Name}' is not a valid property or field"); + } + + public async Task RemoveAsync(TEntity entity, CancellationToken cancellationToken = default) { + ThrowIfReadOnly(); + + IDocumentSession? session = null; + + try { + session = await store.LightweightSerializableSessionAsync(cancellationToken); + + var key = GetEntityKey(entity); + + if (key == null) + return false; + + if (key is string keyString) { + var existing = await session.LoadAsync(keyString, cancellationToken); + if (existing == null) + return false; + + session.Delete(keyString); + } else if (key is Guid keyGuid) { + var existing = await session.LoadAsync(keyGuid, cancellationToken); + if (existing == null) + return false; + + session.Delete(keyGuid); + } else if (key is int keyInt) { + var existing = await session.LoadAsync(keyInt, cancellationToken); + if (existing == null) + return false; + + session.Delete(keyInt); + } else { + throw new RepositoryException($"The key '{key}' is not a valid string or GUID"); + } + + session.SaveChanges(); + + return true; + } catch (Exception ex) { + throw new RepositoryException("Unknown error while removing an entity", ex); + } finally { + session?.Dispose(); + } + } + + public async Task RemoveRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) { + ThrowIfReadOnly(); + + IDocumentSession? session = null; + + try { + session = await store.LightweightSerializableSessionAsync(cancellationToken); + + foreach (var entity in entities) { + var key = GetEntityKey(entity); + + if (key == null) + throw new RepositoryException($"The entity '{typeof(TEntity)}' has no key"); + + if (key is string keyString) { + var existing = await session.LoadAsync(keyString, cancellationToken); + if (existing == null) + throw new RepositoryException($"The entity '{typeof(TEntity)}' with key '{keyString}' does not exist"); + + session.Delete(keyString); + } else if (key is Guid keyGuid) { + var existing = await session.LoadAsync(keyGuid, cancellationToken); + if (existing == null) + throw new RepositoryException($"The entity '{typeof(TEntity)}' with key '{keyGuid}' does not exist"); + + session.Delete(keyGuid); + } else if (key is int keyInt) { + var existing = await session.LoadAsync(keyInt, cancellationToken); + if (existing == null) + throw new RepositoryException($"The entity '{typeof(TEntity)}' with key '{keyInt}' does not exist"); + + session.Delete(keyInt); + } else { + throw new RepositoryException($"The key '{key}' is not a valid string or GUID"); + } + } + + await session.SaveChangesAsync(cancellationToken); + } catch (Exception ex) { + throw new RepositoryException("Unknown error while removing a range of entities", ex); + } finally { + session?.Dispose(); + } + } + + public async Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default) { + ThrowIfReadOnly(); + + IDocumentSession? session = null; + + try { + session = await store.LightweightSerializableSessionAsync(cancellationToken); + + var key = GetEntityKey(entity); + + if (key == null) + return false; + + TEntity? existing = null; + if (key is string keyString) + existing = await session.LoadAsync(keyString, cancellationToken); + if (key is Guid keyGuid) + existing = await session.LoadAsync(keyGuid, cancellationToken); + if (key is int keyInt) + existing = await session.LoadAsync(keyInt, cancellationToken); + + if (existing == null) + return false; + + session.Update(entity); + + await session.SaveChangesAsync(cancellationToken); + + return true; + } catch (Exception ex) { + throw new RepositoryException("Unknown error while updating an entity", ex); + } finally { + session?.Dispose(); + } + } + + public async Task> GetPageAsync(PageQuery query, CancellationToken cancellationToken = default) { + IQuerySession? session = null; + + try { + session = await store.QuerySerializableSessionAsync(cancellationToken); + + IQueryable querySet = session.Query(); + if (query.Filter != null) { + querySet = query.Filter.Apply(querySet); + } + + var total = await querySet.CountAsync(cancellationToken); + + if (query.ResultSorts != null) { + foreach (var sort in query.ResultSorts) { + querySet = sort.Apply(querySet); + } + } + + querySet = querySet.Skip(query.Offset); + + if (query.Size > 0) + querySet = querySet.Take(query.Size); + + return new PageResult(query, total, querySet.ToList()); + } catch (Exception ex) { + throw new RepositoryException("Unknown error while getting a page of entities", ex); + } finally { + session?.Dispose(); + } + } + } +} diff --git a/src/Deveel.Repository.MartenDb/Data/MartenEventRepository.cs b/src/Deveel.Repository.MartenDb/Data/MartenEventRepository.cs new file mode 100644 index 00000000..dc363b86 --- /dev/null +++ b/src/Deveel.Repository.MartenDb/Data/MartenEventRepository.cs @@ -0,0 +1,383 @@ +using System.Reflection; + +using JasperFx.Core; + +using Marten; +using Marten.Events; +using Marten.Schema; + +namespace Deveel.Data { + /// + /// A repository that aggregates events in a stream + /// of events from MartenDb. + /// + /// + /// The type of entity that aggregates an event stream + /// to form its state. + /// + /// + public class MartenEventRepository : IRepository + where TAggregate : Aggregate { + private IDocumentStore store; + private MemberInfo? idMember; + private PropertyInfo? versionProperty; + + /// + /// Constructs the repository with the given + /// document store. + /// + /// + /// The document store that is used to access + /// the event stream. + /// + public MartenEventRepository(IDocumentStore store) { + ArgumentNullException.ThrowIfNull(store, nameof(store)); + + this.store = store; + } + + /// + public virtual async Task AddAsync(TAggregate aggregate, CancellationToken cancellationToken = default) { + IDocumentSession? session = null; + + try { + // TODO: check if this is the best session type for this operation + + session = await store.LightweightSerializableSessionAsync(cancellationToken); + + var key = GetAggregateIdentity(aggregate); + + if (key == null) + throw new RepositoryException($"The aggregate '{typeof(TAggregate)}' has no key"); + + if (key is string streamKey) { + session.Events.StartStream(streamKey, aggregate.Events.Uncommitted); + } else if (key is Guid streamId) { + session.Events.StartStream(streamId, aggregate.Events.Uncommitted); + } else { + throw new RepositoryException($"The key '{key}' is not a valid string or GUID"); + } + + await session.SaveChangesAsync(cancellationToken); + + aggregate.Commit(); + } catch(RepositoryException) { + throw; + } catch (Exception ex) { + //TODO: log the error + throw new RepositoryException("Unknown error while adding an aggregate", ex); + } finally { + session?.Dispose(); + } + } + + /// + public async Task AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(entities, nameof(entities)); + + IDocumentSession? session = null; + + try { + // TODO: check if this is the best session type for this operation + session = await store.LightweightSerializableSessionAsync(cancellationToken); + + foreach (var aggregate in entities) { + var key = GetAggregateIdentity(aggregate); + // TODO: verify the version to be 0 + // var version = GetAggregateVersion(aggregate) ?? 0; + + if (key == null) + throw new RepositoryException($"The aggregate '{typeof(TAggregate)}' has no key"); + + if (key is string streamKey) { + session.Events.StartStream(streamKey, aggregate.Events.Uncommitted.ToArray()); + } else if (key is Guid streamId) { + session.Events.StartStream(streamId, aggregate.Events.Uncommitted.ToArray()); + } else { + throw new RepositoryException($"The key '{key}' is not a valid string or GUID"); + } + } + + await session.SaveChangesAsync(cancellationToken); + + foreach (var aggregate in entities) { + aggregate.Commit(); + } + } catch(RepositoryException) { + throw; + } catch (Exception ex) { + throw new RepositoryException("Unknown error while adding a range of aggregates", ex); + } finally { + session?.Dispose(); + } + } + + /// + public async Task FindByKeyAsync(object key, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(key, nameof(key)); + + IQuerySession? session = null; + + try { + session = await store.QuerySerializableSessionAsync(cancellationToken); + + TAggregate? aggregate = null; + + if (key is AggregateKey aggregateKey) { + var version = aggregateKey.Version ?? 0L; + if (aggregateKey.Key is string sKey) { + aggregate = await session.Events.AggregateStreamAsync(sKey, version, token: cancellationToken); + } else if (aggregateKey.Key is Guid streamId) { + aggregate = await session.Events.AggregateStreamAsync(streamId, version, token: cancellationToken); + } + } else if (key is string sKey) { + aggregate = await session.Events.AggregateStreamAsync(sKey, token: cancellationToken); + } else if (key is Guid streamId) { + aggregate = await session.Events.AggregateStreamAsync(streamId, token: cancellationToken); + } else { + throw new RepositoryException($"The key '{key}' is not a valid string or GUID"); + } + + if (aggregate != null) + aggregate.Commit(); + + return aggregate; + } catch (RepositoryException) { + throw; + } catch (Exception ex) { + throw new RepositoryException("Unknown error while finding an aggregate by key", ex); + } finally { + session?.Dispose(); + } + } + + object? IRepository.GetEntityKey(TAggregate entity) + => GetAggregateIdentity(entity); + + /// + /// Gets the identity of the given aggregate. + /// + /// + /// The aggregate to get the identity from. + /// + /// + /// Returns the identity of the aggregate, or null + /// if the aggregate has no identity. + /// + /// + /// Thrown when the key of the aggregate is not a valid + /// identifier. + /// + public virtual object? GetAggregateIdentity(TAggregate aggregate) { + if (idMember == null) { + idMember = typeof(TAggregate) + .GetMembers(BindingFlags.Public | BindingFlags.Instance) + .Where(x => Attribute.IsDefined(x, typeof(IdentityAttribute))) + .FirstOrDefault(); + + if (idMember == null) + throw new RepositoryException($"The aggregate '{typeof(TAggregate)}' has no key"); + } + + if (idMember == null) + return null; + + object? id = null; + + if (idMember is PropertyInfo property) { + id = property.GetValue(aggregate); + } else if (idMember is FieldInfo field) { + id = field.GetValue(aggregate); + } + + if (id == null) + return null; + + if (store.Options.Events.StreamIdentity == StreamIdentity.AsGuid) { + if (id is Guid g) + return g; + if (id is string s) + return Guid.Parse(s); + } else if (store.Options.Events.StreamIdentity == StreamIdentity.AsString) { + if (id is string s) + return s; + if (id is Guid g) + return g.ToString(); + + return id.ToString(); + } + + throw new RepositoryException($"The key of the aggregate '{typeof(TAggregate)}' is not a valid string or GUID"); + } + + /// + /// Gets the version of the given aggregate. + /// + /// + /// The aggregate to get the version from. + /// + /// + /// Returns the version of the aggregate, or null + /// if the aggregate has no version. + /// + /// + /// Thrown when the version of the aggregate is not a valid + /// version number. + /// + public virtual long? GetAggregateVersion(TAggregate aggregate) { + if (versionProperty == null) { + versionProperty = typeof(TAggregate).GetProperties() + .Where(x => Attribute.IsDefined(x, typeof(VersionAttribute))) + .FirstOrDefault(); + } + + if (versionProperty == null) + return null; + + var version = versionProperty.GetValue(aggregate); + if (version == null) + return null; + + if (version is string s) + return Int64.Parse(s); + + if (version is int i) + return i; + if (version is long l) + return l; + + throw new RepositoryException("The version of the aggregate is not a valid integer"); + } + + /// + public async Task RemoveAsync(TAggregate entity, CancellationToken cancellationToken = default) { + IDocumentSession? session = null; + + try { + // TODO: check if this is the best session type for this operation + session = await store.LightweightSerializableSessionAsync(cancellationToken); + + var key = GetAggregateIdentity(entity); + + if (key == null) + return false; + + if (key is string streamKey) { + var state = await session.Events.FetchStreamStateAsync(streamKey, cancellationToken); + if (state == null || state.IsArchived) + return false; + + session.Events.ArchiveStream(streamKey); + } else if (key is Guid streamId) { + var state = await session.Events.FetchStreamStateAsync(streamId, cancellationToken); + + if (state == null || state.IsArchived) + return false; + + session.Events.ArchiveStream(streamId); + } + + await session.SaveChangesAsync(cancellationToken); + + return true; + } catch (Exception ex) { + throw new RepositoryException("Unknown error while removing an aggregate", ex); + } finally { + session?.Dispose(); + } + } + + /// + public async Task RemoveRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) { + IDocumentSession? session = null; + + try { + // TODO: check if this is the best session type for this operation + session = await store.LightweightSerializableSessionAsync(cancellationToken); + + foreach (var entity in entities) { + var key = GetAggregateIdentity(entity); + + if (key == null) + throw new RepositoryException($"The aggregate '{typeof(TAggregate)}' has no key"); + + if (key is string streamKey) { + var state = await session.Events.FetchStreamStateAsync(streamKey, cancellationToken); + if (state == null || state.IsArchived) + throw new RepositoryException($"The aggregate '{streamKey}' is not found"); + + session.Events.ArchiveStream(streamKey); + } else if (key is Guid streamId) { + var state = await session.Events.FetchStreamStateAsync(streamId, cancellationToken); + + if (state == null || state.IsArchived) + throw new RepositoryException($"The aggregate '{streamId}' is not found"); + + session.Events.ArchiveStream(streamId); + } + } + + await session.SaveChangesAsync(cancellationToken); + } catch(RepositoryException) { + throw; + } catch (Exception ex) { + throw new RepositoryException("Unknown error while removing a range of aggregates", ex); + } finally { + session?.Dispose(); + } + } + + /// + public async Task UpdateAsync(TAggregate entity, CancellationToken cancellationToken = default) { + IDocumentSession? session = null; + + try { + session = await store.LightweightSerializableSessionAsync(cancellationToken); + var key = GetAggregateIdentity(entity); + var version = GetAggregateVersion(entity) ?? 0; + + if (key == null) + return false; + + if (key is string streamKey) { + var state = await session.Events.FetchStreamStateAsync(streamKey, cancellationToken); + if (state == null || state.IsArchived) + return false; + + if (version <= state.Version || + entity.Events.Uncommitted.Count == 0) + return false; + + foreach (var @event in entity.Events.Uncommitted) { + session.Events.Append(streamKey, version, @event); + } + } else if (key is Guid streamId) { + var state = await session.Events.FetchStreamStateAsync(streamId, cancellationToken); + + if (state == null || state.IsArchived) + return false; + + if (state.Version == version || + entity.Events.Uncommitted.Count == 0) + return false; + + foreach (var @event in entity.Events.Uncommitted) { + session.Events.Append(streamId, version, @event); + } + } + + await session.SaveChangesAsync(cancellationToken); + + entity.Commit(); + + return true; + } catch (RepositoryException) { + throw; + } catch (Exception ex) { + throw new RepositoryException("Unknown error while updating an aggregate", ex); + } finally { + session?.Dispose(); + } + } + } +} diff --git a/src/Deveel.Repository.MartenDb/Data/RepositoryExtensions.cs b/src/Deveel.Repository.MartenDb/Data/RepositoryExtensions.cs new file mode 100644 index 00000000..6451b721 --- /dev/null +++ b/src/Deveel.Repository.MartenDb/Data/RepositoryExtensions.cs @@ -0,0 +1,7 @@ +namespace Deveel.Data { + public static class RepositoryExtensions { + public static Task FindByKeyAsync(this IRepository repository, object key, int? version = null) + where TAggregate : Aggregate + => repository.FindByKeyAsync(new AggregateKey(key, version)); + } +} diff --git a/src/Deveel.Repository.MartenDb/Deveel.Repository.MartenDb.csproj b/src/Deveel.Repository.MartenDb/Deveel.Repository.MartenDb.csproj new file mode 100644 index 00000000..6c949311 --- /dev/null +++ b/src/Deveel.Repository.MartenDb/Deveel.Repository.MartenDb.csproj @@ -0,0 +1,17 @@ + + + + repository;events;event;eventsourcing;event-sourcing;marten;postgres + The implementation of the repository pattern that is based on the MartenDB event storage system + + + + + + + + + + + + diff --git a/test/Deveel.Repository.EntityFramework.XUnit/Data/DbPerson.cs b/test/Deveel.Repository.EntityFramework.XUnit/Data/DbPerson.cs index 850fdf40..8e1d1c11 100644 --- a/test/Deveel.Repository.EntityFramework.XUnit/Data/DbPerson.cs +++ b/test/Deveel.Repository.EntityFramework.XUnit/Data/DbPerson.cs @@ -7,10 +7,7 @@ public class DbPerson : IPerson { [Key] public Guid? Id { get; set; } - string? IPerson.Id { - get => Id.ToString(); - set => Id = value == null ? null : Guid.Parse(value); - } + string? IPerson.Id => Id.ToString(); public string FirstName { get; set; } diff --git a/test/Deveel.Repository.EntityFramework.XUnit/Data/DbTenantPerson.cs b/test/Deveel.Repository.EntityFramework.XUnit/Data/DbTenantPerson.cs index bcbdb83d..b20f8352 100644 --- a/test/Deveel.Repository.EntityFramework.XUnit/Data/DbTenantPerson.cs +++ b/test/Deveel.Repository.EntityFramework.XUnit/Data/DbTenantPerson.cs @@ -7,10 +7,7 @@ public class DbTenantPerson : IPerson { [Key] public Guid? Id { get; set; } - string? IPerson.Id { - get => Id.ToString(); - set => Id = value == null ? null : Guid.Parse(value); - } + string? IPerson.Id => Id.ToString(); public string FirstName { get; set; } diff --git a/test/Deveel.Repository.EntityFramework.XUnit/Data/EntityRepositoryProviderTestSuite.cs b/test/Deveel.Repository.EntityFramework.XUnit/Data/EntityRepositoryProviderTestSuite.cs index e4230ea1..760d90b3 100644 --- a/test/Deveel.Repository.EntityFramework.XUnit/Data/EntityRepositoryProviderTestSuite.cs +++ b/test/Deveel.Repository.EntityFramework.XUnit/Data/EntityRepositoryProviderTestSuite.cs @@ -42,6 +42,14 @@ protected IRepositoryProvider RepositoryProvider protected override IEnumerable NaturalOrder(IEnumerable source) => source.OrderBy(x => x.Id); + protected override void SetPersonId(DbTenantPerson person, string id) { + person.Id = Guid.Parse(id); + } + + protected override void SetFirstName(DbTenantPerson person, string firstName) { + person.FirstName = firstName; + } + protected override Task AddRelationshipAsync(DbTenantPerson person, DbTenantPersonRelationship relationship) { if (person.Relationships == null) person.Relationships = new List(); diff --git a/test/Deveel.Repository.EntityFramework.XUnit/Data/EntityRepositoryTestSuite.cs b/test/Deveel.Repository.EntityFramework.XUnit/Data/EntityRepositoryTestSuite.cs index e360170d..cbdb0b9b 100644 --- a/test/Deveel.Repository.EntityFramework.XUnit/Data/EntityRepositoryTestSuite.cs +++ b/test/Deveel.Repository.EntityFramework.XUnit/Data/EntityRepositoryTestSuite.cs @@ -22,6 +22,14 @@ public EntityRepositoryTestSuite(SqlTestConnection sql, ITestOutputHelper? testO protected DbPersonRepository PersonRepository => (DbPersonRepository)Repository; + protected override void SetFirstName(DbPerson person, string firstName) { + person.FirstName = firstName; + } + + protected override void SetPersonId(DbPerson person, string id) { + person.Id = Guid.Parse(id); + } + protected override Task AddRelationshipAsync(DbPerson person, DbRelationship relationship) { if (person.Relationships == null) person.Relationships = new List(); diff --git a/test/Deveel.Repository.InMemory.XUnit/Data/InMemoryRepositoryTests.cs b/test/Deveel.Repository.InMemory.XUnit/Data/InMemoryRepositoryTests.cs index df6b6d84..715de06b 100644 --- a/test/Deveel.Repository.InMemory.XUnit/Data/InMemoryRepositoryTests.cs +++ b/test/Deveel.Repository.InMemory.XUnit/Data/InMemoryRepositoryTests.cs @@ -15,6 +15,14 @@ public InMemoryRepositoryTests(ITestOutputHelper outputHelper) : base(outputHelp protected override IEnumerable NaturalOrder(IEnumerable source) => source.OrderBy(x => x.Id); + protected override void SetFirstName(Person person, string firstName) { + person.FirstName = firstName; + } + + protected override void SetPersonId(Person person, string id) { + person.Id = id; + } + protected override Task AddRelationshipAsync(Person person, PersonRelationship relationship) { if (person.Relationships == null) person.Relationships = new List(); diff --git a/test/Deveel.Repository.MartenDb.XUnit/Data/MartenDocumentRepositoryTestSuite.cs b/test/Deveel.Repository.MartenDb.XUnit/Data/MartenDocumentRepositoryTestSuite.cs new file mode 100644 index 00000000..8fcbbfa7 --- /dev/null +++ b/test/Deveel.Repository.MartenDb.XUnit/Data/MartenDocumentRepositoryTestSuite.cs @@ -0,0 +1,78 @@ +using Bogus; + +using Marten; + +using Microsoft.Extensions.DependencyInjection; + +using Xunit.Abstractions; + +namespace Deveel.Data { + [Collection(nameof(PostgresTestCollection))] + public class MartenDocumentRepositoryTestSuite : RepositoryTestSuite { + private readonly PostgresDatabase postgres; + + public MartenDocumentRepositoryTestSuite(PostgresDatabase postgres, ITestOutputHelper? testOutput) : base(testOutput) { + this.postgres = postgres; + } + + protected override Faker PersonFaker => new PersonDocumentFaker(); + + protected override Faker RelationshipFaker => new PersonRelationshipFaker(); + + protected override Task AddRelationshipAsync(PersonDocument person, PersonRelationship relationship) { + if (person.Relationships == null) + person.Relationships = new List(); + + person.Relationships.Add(relationship); + + return Task.CompletedTask; + } + + protected override Task RemoveRelationshipAsync(PersonDocument person, PersonRelationship relationship) { + if (person.Relationships == null) + return Task.CompletedTask; + + var rel = person.Relationships.FirstOrDefault(x => x.Type == relationship.Type && x.FullName == relationship.FullName); + if (rel != null) + person.Relationships.Remove(rel); + + return Task.CompletedTask; + } + + protected override void SetFirstName(PersonDocument person, string firstName) { + person.FirstName = firstName; + } + + protected override void SetPersonId(PersonDocument person, string id) { + person.Id = id; + } + + protected override void ConfigureServices(IServiceCollection services) { + services.AddMarten(options => { + options.Connection(postgres.ConnectionString); + }); + + services.AddRepository>(); + } + + protected override async Task> FindAllPeopleAsync() { + var store = Services.GetRequiredService(); + using var session = await store.QuerySerializableSessionAsync(); + + return (await session.Query().ToListAsync()).ToList(); + } + + protected override async Task SeedAsync(IRepository repository) { + var store = Services.GetRequiredService(); + using var session = store.LightweightSession(); + + session.Insert(People); + await session.SaveChangesAsync(); + } + + protected override async Task DisposeAsync() { + var store = Services.GetRequiredService(); + await store.Advanced.Clean.DeleteDocumentsByTypeAsync(typeof(PersonDocument)); + } + } +} diff --git a/test/Deveel.Repository.MartenDb.XUnit/Data/MartenEventRepositoryTestSuite.cs b/test/Deveel.Repository.MartenDb.XUnit/Data/MartenEventRepositoryTestSuite.cs new file mode 100644 index 00000000..596ec0db --- /dev/null +++ b/test/Deveel.Repository.MartenDb.XUnit/Data/MartenEventRepositoryTestSuite.cs @@ -0,0 +1,147 @@ +using Bogus; + +using Marten; +using Marten.Events; +using Marten.Events.Projections; + +using Microsoft.Extensions.DependencyInjection; + +using Xunit.Abstractions; + +namespace Deveel.Data { + [Collection(nameof(PostgresTestCollection))] + public class MartenEventRepositoryTestSuite : RepositoryTestSuite { + private readonly PostgresDatabase postgres; + + public MartenEventRepositoryTestSuite(PostgresDatabase postgres, ITestOutputHelper? testOutput) : base(testOutput) { + this.postgres = postgres; + } + + protected override Faker PersonFaker => new PersonAggregateFaker(); + + protected override Faker RelationshipFaker => new PersonRelationshipFaker(); + + protected override void ConfigureServices(IServiceCollection services) { + services.AddMarten(options => { + options.Connection(postgres.ConnectionString); + options.Events.StreamIdentity = StreamIdentity.AsString; + options.Projections.Add(ProjectionLifecycle.Inline); + }); + services.AddRepository>(); + } + + protected override void SetFirstName(PersonAggregate person, string firstName) { + person.Apply(new PersonNameChanged(firstName, null)); + } + + protected override void SetPersonId(PersonAggregate person, string id) { + person.Apply(new PersonIdChanged(id)); + } + + protected override Task AddRelationshipAsync(PersonAggregate person, PersonRelationship relationship) { + person.Apply(new RelationshipAdded(relationship.Type, relationship.FullName)); + return Task.CompletedTask; + } + + protected override Task RemoveRelationshipAsync(PersonAggregate person, PersonRelationship relationship) { + person.Apply(new RelationshipRemoved(relationship.Type, relationship.FullName)); + return Task.CompletedTask; + } + + protected override async Task SeedAsync(IRepository repository) { + var store = Services.GetRequiredService(); + using var session = store.LightweightSession(); + + foreach (var person in People) { + session.Events.StartStream(person.Id, person.Events.Uncommitted); + } + + await session.SaveChangesAsync(); + } + + protected override async Task DisposeAsync() { + var store = Services.GetRequiredService(); + await store.Advanced.Clean.DeleteAllEventDataAsync(); + } + + protected override async Task> FindAllPeopleAsync() { + var store = Services.GetRequiredService(); + using var session = store.QuerySession(); + + var streamKeys = session.Events.QueryAllRawEvents().Select(x => x.StreamKey).Distinct().ToArray(); + + var people = new List(); + foreach (var stream in streamKeys) { + var person = await session.Events.AggregateStreamAsync(stream); + + if (person != null) + people.Add(person); + } + + return people; + } + + public override Task CountAll() => Task.CompletedTask; + + public override void CountAll_Sync() { + return; + } + + public override Task CountFiltered() => Task.CompletedTask; + + public override Task CountFiltered_Sync() => Task.CompletedTask; + + public override Task ExistsFiltered() => Task.CompletedTask; + + public override Task ExistsFiltered_Sync() => Task.CompletedTask; + + public override Task FindFirst() => Task.CompletedTask; + + public override void FindFirstSync() { + return; + } + + public override Task FindFirstFiltered() => Task.CompletedTask; + + public override Task FindFirstFiltered_Sync() => Task.CompletedTask; + + public override Task FindAll() => Task.CompletedTask; + + public override Task FindAllFiltered() => Task.CompletedTask; + + public override Task FindAllFiltered_BadFilter() => Task.CompletedTask; + + public override void FindAll_Sync() { + return; + } + + public override Task GetSimplePage() => Task.CompletedTask; + + public override Task GetSortedPage() => Task.CompletedTask; + + public override Task GetDescendingSortedPage() => Task.CompletedTask; + + public override Task GetFilteredPage() => Task.CompletedTask; + + public override void GetPage_Sync() { + return; + } + + public override Task GetPage_ChainedFilters() => Task.CompletedTask; + + public override Task GetPage_MultipleFilters() => Task.CompletedTask; + + public override Task GetSimplePage_WithParameters() => Task.CompletedTask; + + [Fact] + public async Task FindByKeyAndVersion() { + var person = await RandomPersonAsync(); + + var result = await Repository.FindByKeyAsync(person.Id!, person.Version); + + Assert.NotNull(result); + Assert.Equal(person.Id, result.Id); + Assert.Equal(person.Version, result.Version); + } + } +} diff --git a/test/Deveel.Repository.MartenDb.XUnit/Data/PersonAggregate.cs b/test/Deveel.Repository.MartenDb.XUnit/Data/PersonAggregate.cs new file mode 100644 index 00000000..a2416b59 --- /dev/null +++ b/test/Deveel.Repository.MartenDb.XUnit/Data/PersonAggregate.cs @@ -0,0 +1,61 @@ +using System.ComponentModel.DataAnnotations; + +using Marten.Schema; + +namespace Deveel.Data { + public class PersonAggregate : Aggregate, IPerson { + public PersonAggregate(IEnumerable? committedEvents = null) : base(committedEvents) { + } + + public PersonAggregate() { + ApplyEvent(new PersonCreated(Id = Guid.NewGuid().ToString("N"))); + } + + [Identity] + public string? Id { get; private set; } + + public string FirstName { get; private set; } + + public string LastName { get; private set; } + + public string? Email { get; private set; } + + public DateTime? DateOfBirth { get; private set; } + + public string? PhoneNumber { get; private set; } + + IEnumerable IPerson.Relationships => Relationships ?? new List(); + + public List? Relationships { get; set; } + + protected override void ApplyEvent(object @event) { + if (@event is PersonCreated createdEvent) { + Id = createdEvent.Id; + FirstName = createdEvent.FirstName ?? ""; + LastName = createdEvent.LastName ?? ""; + Email = createdEvent.Email; + DateOfBirth = createdEvent.DateOfBirth; + PhoneNumber = createdEvent.PhoneNumber; + } else if (@event is PersonIdChanged personIdChanged) { + Id = personIdChanged.Id; + } else if (@event is PersonNameChanged nameChangedEvent) { + FirstName = nameChangedEvent.FirstName ?? FirstName; + LastName = nameChangedEvent.LastName ?? LastName; + } else if (@event is PersonEmailChanged emailChangedEvent) { + Email = emailChangedEvent.Email; + } else if (@event is RelationshipAdded relationshipAdded) { + if (Relationships == null) + Relationships = new List(); + + Relationships.Add(new PersonRelationship { Type = relationshipAdded.Type, FullName = relationshipAdded.FullName }); + } else if (@event is RelationshipRemoved relationshipRemoved) { + if (Relationships == null) + return; + + var relationship = Relationships.FirstOrDefault(x => x.Type == relationshipRemoved.Type && x.FullName == relationshipRemoved.FullName); + if (relationship != null) + Relationships.Remove(relationship); + } + } + } +} diff --git a/test/Deveel.Repository.MartenDb.XUnit/Data/PersonAggregateFaker.cs b/test/Deveel.Repository.MartenDb.XUnit/Data/PersonAggregateFaker.cs new file mode 100644 index 00000000..ea5a4538 --- /dev/null +++ b/test/Deveel.Repository.MartenDb.XUnit/Data/PersonAggregateFaker.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using Bogus; + +namespace Deveel.Data { + public class PersonAggregateFaker : Faker { + public PersonAggregateFaker() { + CustomInstantiator(f => { + var p = new PersonAggregate(); + var firstName = f.Name.FirstName(); + var lastName = f.Name.LastName(); + var id = f.Random.Guid().ToString(); + var birthDate = f.Date.Past(100).OrNull(f); + var email = f.Internet.Email(firstName, lastName).OrNull(f); + var phone = f.Phone.PhoneNumber().OrNull(f); + + p.Apply(new PersonCreated(id, firstName, lastName, birthDate, email, phone)); + + return p; + }); + + RuleFor(x => x.Relationships, (f, p) => { + if (f.Random.Bool()) { + var faker = new PersonRelationshipFaker(); + var relationships = faker.GenerateBetween(1, 5); + foreach (var relationship in relationships) { + p.Apply(new RelationshipAdded(relationship.Type, relationship.FullName)); + } + } + + return p.Relationships; + }); + } + } +} diff --git a/test/Deveel.Repository.MartenDb.XUnit/Data/PersonDocument.cs b/test/Deveel.Repository.MartenDb.XUnit/Data/PersonDocument.cs new file mode 100644 index 00000000..95ed5493 --- /dev/null +++ b/test/Deveel.Repository.MartenDb.XUnit/Data/PersonDocument.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace Deveel.Data { + public class PersonDocument : IPerson { + [Key] + public string? Id { get; set; } + + public string FirstName { get; set; } + + public string LastName { get; set; } + + public string? Email { get; set; } + + public DateTime? DateOfBirth { get; set; } + + public string? PhoneNumber { get; set; } + + IEnumerable IPerson.Relationships => Relationships; + + public List Relationships { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + } +} diff --git a/test/Deveel.Repository.MartenDb.XUnit/Data/PersonDocumentFaker.cs b/test/Deveel.Repository.MartenDb.XUnit/Data/PersonDocumentFaker.cs new file mode 100644 index 00000000..640d8823 --- /dev/null +++ b/test/Deveel.Repository.MartenDb.XUnit/Data/PersonDocumentFaker.cs @@ -0,0 +1,18 @@ +using Bogus; + +namespace Deveel.Data { + public class PersonDocumentFaker : Faker { + public PersonDocumentFaker() { + RuleFor(x => x.Id, f => f.Random.Guid().ToString()); + RuleFor(x => x.FirstName, f => f.Name.FirstName()); + RuleFor(x => x.LastName, f => f.Name.LastName().OrNull(f)); + RuleFor(x => x.DateOfBirth, f => f.Date.Past(10).OrNull(f)); + RuleFor(x => x.Email, (f, p) => f.Internet.Email(p.FirstName, p.LastName).OrNull(f)); + RuleFor(x => x.PhoneNumber, f => f.Phone.PhoneNumber().OrNull(f)); + RuleFor(x => x.Relationships, f => { + var faker = new PersonRelationshipFaker(); + return f.Random.Bool() ? faker.GenerateBetween(1, 5) : null; + }); + } + } +} diff --git a/test/Deveel.Repository.MartenDb.XUnit/Data/PersonEvents.cs b/test/Deveel.Repository.MartenDb.XUnit/Data/PersonEvents.cs new file mode 100644 index 00000000..66c14afa --- /dev/null +++ b/test/Deveel.Repository.MartenDb.XUnit/Data/PersonEvents.cs @@ -0,0 +1,13 @@ +namespace Deveel.Data { + public record PersonCreated(string? Id = null, string? FirstName = null, string? LastName = null, DateTime? DateOfBirth = null, string? Email = null, string? PhoneNumber = null); + + public record PersonIdChanged(string Id); + + public record PersonNameChanged(string? FirstName = null, string? LastName = null); + + public record PersonEmailChanged(string Email); + + public record RelationshipAdded(string Type, string FullName); + + public record RelationshipRemoved(string Type, string FullName); +} diff --git a/test/Deveel.Repository.MartenDb.XUnit/Data/PersonProjection.cs b/test/Deveel.Repository.MartenDb.XUnit/Data/PersonProjection.cs new file mode 100644 index 00000000..0fb4e0da --- /dev/null +++ b/test/Deveel.Repository.MartenDb.XUnit/Data/PersonProjection.cs @@ -0,0 +1,47 @@ +using Marten.Events; +using Marten.Events.Aggregation; + +namespace Deveel.Data { + public class PersonProjection : SingleStreamProjection { + + public PersonProjection() { + CreateEvent(Create); + } + + public PersonDocument Create(PersonCreated created) { + return new PersonDocument { + Id = created.Id, + FirstName = created.FirstName, + LastName = created.LastName, + Email = created.Email, + DateOfBirth = created.DateOfBirth, + PhoneNumber = created.PhoneNumber, + }; + } + + public void Apply(PersonDocument document, PersonNameChanged changed) { + document.FirstName = changed.FirstName ?? document.FirstName; + document.LastName = changed.LastName ?? document.LastName; + } + + public void Apply(PersonDocument document, PersonEmailChanged changed) { + document.Email = changed.Email; + } + + public void Apply(PersonDocument document, RelationshipAdded added) { + if (document.Relationships == null) + document.Relationships = new List(); + + document.Relationships.Add(new PersonRelationship { Type = added.Type, FullName = added.FullName }); + } + + public void Apply(PersonDocument document, RelationshipRemoved removed) { + if (document.Relationships == null) + return; + + var relationship = document.Relationships.FirstOrDefault(x => x.Type == removed.Type && x.FullName == removed.FullName); + if (relationship != null) + document.Relationships.Remove(relationship); + } + } +} diff --git a/test/Deveel.Repository.MartenDb.XUnit/Data/PersonRelationship.cs b/test/Deveel.Repository.MartenDb.XUnit/Data/PersonRelationship.cs new file mode 100644 index 00000000..99a80097 --- /dev/null +++ b/test/Deveel.Repository.MartenDb.XUnit/Data/PersonRelationship.cs @@ -0,0 +1,7 @@ +namespace Deveel.Data { + public class PersonRelationship : IRelationship { + public string Type { get; set; } + + public string FullName { get; set; } + } +} diff --git a/test/Deveel.Repository.MartenDb.XUnit/Data/PersonRelationshipFaker.cs b/test/Deveel.Repository.MartenDb.XUnit/Data/PersonRelationshipFaker.cs new file mode 100644 index 00000000..dfcb65ba --- /dev/null +++ b/test/Deveel.Repository.MartenDb.XUnit/Data/PersonRelationshipFaker.cs @@ -0,0 +1,10 @@ +using Bogus; + +namespace Deveel.Data { + public class PersonRelationshipFaker : Faker { + public PersonRelationshipFaker() { + RuleFor(x => x.Type, f => f.PickRandom(new[] {"father", "mother", "sister", "brother"})); + RuleFor(x => x.FullName, f => f.Name.FullName()); + } + } +} diff --git a/test/Deveel.Repository.MartenDb.XUnit/Data/PostgresDatabase.cs b/test/Deveel.Repository.MartenDb.XUnit/Data/PostgresDatabase.cs new file mode 100644 index 00000000..b7f12e9d --- /dev/null +++ b/test/Deveel.Repository.MartenDb.XUnit/Data/PostgresDatabase.cs @@ -0,0 +1,24 @@ +using Testcontainers.PostgreSql; + +namespace Deveel.Data { + public class PostgresDatabase : IAsyncLifetime { + private PostgreSqlContainer _container; + + public PostgresDatabase() { + _container = new PostgreSqlBuilder() + .WithDatabase("test") + .Build(); + } + + public string ConnectionString => _container.GetConnectionString(); + + public async Task InitializeAsync() { + await _container.StartAsync(); + } + + public async Task DisposeAsync() { + if (_container != null) + await _container.DisposeAsync(); + } + } +} diff --git a/test/Deveel.Repository.MartenDb.XUnit/Data/PostgresTestCollection.cs b/test/Deveel.Repository.MartenDb.XUnit/Data/PostgresTestCollection.cs new file mode 100644 index 00000000..fea7bb46 --- /dev/null +++ b/test/Deveel.Repository.MartenDb.XUnit/Data/PostgresTestCollection.cs @@ -0,0 +1,5 @@ +namespace Deveel.Data { + [CollectionDefinition(nameof(PostgresTestCollection))] + public class PostgresTestCollection : ICollectionFixture { + } +} diff --git a/test/Deveel.Repository.MartenDb.XUnit/Deveel.Repository.MartenDb.XUnit.csproj b/test/Deveel.Repository.MartenDb.XUnit/Deveel.Repository.MartenDb.XUnit.csproj new file mode 100644 index 00000000..77c264c8 --- /dev/null +++ b/test/Deveel.Repository.MartenDb.XUnit/Deveel.Repository.MartenDb.XUnit.csproj @@ -0,0 +1,36 @@ + + + + net6.0 + enable + enable + + false + true + Deveel + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/test/Deveel.Repository.MartenDb.XUnit/GlobalUsings.cs b/test/Deveel.Repository.MartenDb.XUnit/GlobalUsings.cs new file mode 100644 index 00000000..8c927eb7 --- /dev/null +++ b/test/Deveel.Repository.MartenDb.XUnit/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/test/Deveel.Repository.MongoFramework.XUnit/Data/MongoPerson.cs b/test/Deveel.Repository.MongoFramework.XUnit/Data/MongoPerson.cs index c6bc0373..5ef112c5 100644 --- a/test/Deveel.Repository.MongoFramework.XUnit/Data/MongoPerson.cs +++ b/test/Deveel.Repository.MongoFramework.XUnit/Data/MongoPerson.cs @@ -13,10 +13,7 @@ public class MongoPerson : IPerson, IHaveTimeStamp { public ObjectId Id { get; set; } - string? IPerson.Id { - get => Id.ToEntityId(); - set => Id = ObjectId.Parse(value); - } + string? IPerson.Id => Id.ToEntityId(); [Column("first_name")] public string FirstName { get; set; } diff --git a/test/Deveel.Repository.MongoFramework.XUnit/Data/MongoRepositoryTestSuite.cs b/test/Deveel.Repository.MongoFramework.XUnit/Data/MongoRepositoryTestSuite.cs index d3964f9d..c7e457bc 100644 --- a/test/Deveel.Repository.MongoFramework.XUnit/Data/MongoRepositoryTestSuite.cs +++ b/test/Deveel.Repository.MongoFramework.XUnit/Data/MongoRepositoryTestSuite.cs @@ -31,6 +31,14 @@ protected MongoRepositoryTestSuite(MongoSingleDatabase mongo, ITestOutputHelper? .GetDatabase(DatabaseName) .GetCollection("persons"); + protected override void SetPersonId(TPerson person, string id) { + person.Id = ObjectId.Parse(id); + } + + protected override void SetFirstName(TPerson person, string firstName) { + person.FirstName = firstName; + } + protected override Task AddRelationshipAsync(TPerson person, MongoPersonRelationship relationship) { if (person.Relationships == null) person.Relationships = new List(); diff --git a/test/Deveel.Repository.Tests.XUnit/Data/IPerson.cs b/test/Deveel.Repository.Tests.XUnit/Data/IPerson.cs index e72fbd26..d86ae1fe 100644 --- a/test/Deveel.Repository.Tests.XUnit/Data/IPerson.cs +++ b/test/Deveel.Repository.Tests.XUnit/Data/IPerson.cs @@ -1,16 +1,16 @@ namespace Deveel.Data { public interface IPerson { - string? Id { get; set; } + string? Id { get; } - string FirstName { get; set; } + string FirstName { get; } - string LastName { get; set; } + string LastName { get; } - string? Email { get; set; } + string? Email { get; } - DateTime? DateOfBirth { get; set; } + DateTime? DateOfBirth { get; } - string? PhoneNumber { get; set; } + string? PhoneNumber { get; } IEnumerable Relationships { get; } } diff --git a/test/Deveel.Repository.Tests.XUnit/Data/RepositoryTestSuite.cs b/test/Deveel.Repository.Tests.XUnit/Data/RepositoryTestSuite.cs index 462726e1..dba51c75 100644 --- a/test/Deveel.Repository.Tests.XUnit/Data/RepositoryTestSuite.cs +++ b/test/Deveel.Repository.Tests.XUnit/Data/RepositoryTestSuite.cs @@ -47,14 +47,15 @@ protected RepositoryTestSuite(ITestOutputHelper? testOutput) { protected virtual string GeneratePersonId() => Guid.NewGuid().ToString(); protected virtual void ConfigureServices(IServiceCollection services) { - if (TestOutput != null) - services.AddLogging(logging => { logging.ClearProviders(); logging.AddXUnit(TestOutput); }); } private void BuildServices() { var services = new ServiceCollection(); services.AddSystemTime(TestTime); + if (TestOutput != null) + services.AddLogging(logging => { logging.ClearProviders(); logging.AddXUnit(TestOutput); }); + ConfigureServices(services); this.services = services.BuildServiceProvider(); @@ -90,6 +91,10 @@ protected virtual async Task SeedAsync(IRepository repository) { await repository.AddRangeAsync(People); } + protected virtual async Task> FindAllPeopleAsync() { + return await Repository.FindAllAsync(); + } + protected virtual IEnumerable NaturalOrder(IEnumerable source) { return source; } @@ -112,6 +117,16 @@ protected virtual Task RandomPersonAsync(Expression return Task.FromResult(result); } + protected virtual void SetPersonId(TPerson person, string id) { + throw new NotSupportedException(); + } + + protected virtual void SetFirstName(TPerson person, string firstName) { + throw new NotSupportedException(); + } + + #region CRUD Tests + [Fact] public async Task AddNewPerson() { var person = GeneratePerson(); @@ -177,7 +192,7 @@ public async Task RemoveExisting() { public async Task RemoveNotExisting() { var entity = GeneratePerson(); - entity.Id = GeneratePersonId(); + SetPersonId(entity, GeneratePersonId()); var result = await Repository.RemoveAsync(entity); @@ -222,7 +237,7 @@ public async Task RemoveRangeOfExisting() { await Repository.RemoveRangeAsync(people); - var result = await Repository.FindAllAsync(); + var result = await FindAllPeopleAsync(); Assert.NotNull(result); Assert.NotEmpty(result); Assert.Equal(peopleCount - 10, result.Count); @@ -234,153 +249,295 @@ public async Task RemoveRangeWithOneNotExisting() { var people = People.Take(9).ToList(); var entity = GeneratePerson(); - entity.Id = GeneratePersonId(); + SetPersonId(entity, GeneratePersonId()); people.Add(entity); await Assert.ThrowsAsync(() => Repository.RemoveRangeAsync(people)); - var result = await Repository.FindAllAsync(); + var result = await FindAllPeopleAsync(); Assert.NotNull(result); Assert.NotEmpty(result); Assert.Equal(peopleCount, result.Count); } [Fact] - public async Task CountAll() { - var result = await Repository.CountAllAsync(); + public async Task FindByKey() { + var person = await RandomPersonAsync(); + var id = person.Id!; - Assert.NotEqual(0, result); - Assert.Equal(People.Count, result); + var result = await Repository.FindByKeyAsync(id); + + Assert.NotNull(result); + Assert.Equal(id, result.Id); } [Fact] - public void CountAll_Sync() { - var result = Repository.CountAll(); + public async Task FindByKey_Existing() { + var person = await RandomPersonAsync(); - Assert.NotEqual(0, result); - Assert.Equal(People.Count, result); + var result = await Repository.FindByKeyAsync(person.Id!); + + Assert.NotNull(result); + Assert.Equal(person.Id, result.Id); + + Assert.Equal(person.FirstName, result.FirstName); + Assert.Equal(person.LastName, result.LastName); } [Fact] - public async Task CountFiltered() { - var person = await RandomPersonAsync(); - var firstName = person.FirstName; - var peopleCount = People.Count(x => x.FirstName == firstName); + public async Task FindByKey_NotFound() { + var id = GeneratePersonId(); - var count = await Repository.CountAsync(p => p.FirstName == firstName); + var result = await Repository.FindByKeyAsync(id); - Assert.Equal(peopleCount, count); + Assert.Null(result); } [Fact] - public async Task CountFiltered_Sync() { + public async Task FindByKey_Sync() { var person = await RandomPersonAsync(); - var firstName = person.FirstName; - var peopleCount = People.Count(x => x.FirstName == firstName); - var count = Repository.Count(p => p.FirstName == firstName); + var result = Repository.FindByKey(person.Id!); - Assert.Equal(peopleCount, count); + Assert.NotNull(result); + Assert.Equal(person.Id, result.Id); } [Fact] - public async Task FindByKey() { - var person = await RandomPersonAsync(); - var id = person.Id!; + public async Task FindByKey_WithRelationsips() { + var person = People.Random(x => x.Relationships?.Any() ?? false); - var result = await Repository.FindByKeyAsync(id); + Assert.NotNull(person); + + var result = await Repository.FindByKeyAsync(person.Id!); Assert.NotNull(result); - Assert.Equal(id, result.Id); + Assert.NotNull(result.Relationships); + Assert.NotEmpty(result.Relationships); } [Fact] - public async Task FindFirstFiltered() { + public async Task GetPersonId() { var person = await RandomPersonAsync(); - var firstName = person.FirstName; - var result = await Repository.FindFirstAsync(x => x.FirstName == firstName); + var id = Repository.GetEntityKey(person); - Assert.NotNull(result); - Assert.Equal(firstName, result.FirstName); + Assert.NotNull(id); + Assert.Equal(person.Id, id.ToString()); } [Fact] - public void FindFirstSync() { - var result = Repository.FindFirst(); + public async Task UpdateExisting() { + var person = await RandomPersonAsync(x => x.FirstName != "John"); - Assert.NotNull(result); - Assert.NotNull(result.Id); + var toUpdate = await Repository.FindByKeyAsync(person.Id!); + + Assert.NotNull(toUpdate); + + SetFirstName(toUpdate, "John"); + + var result = await Repository.UpdateAsync(toUpdate); + + Assert.True(result); + + var updated = await Repository.FindByKeyAsync(person.Id!); + + Assert.NotNull(updated); + Assert.Equal(toUpdate.FirstName, updated.FirstName); + Assert.Equal(toUpdate.LastName, updated.LastName); + Assert.Equal(toUpdate.Email, updated.Email); + Assert.Equal(toUpdate.DateOfBirth, updated.DateOfBirth); } [Fact] - public async Task ExistsFiltered() { - var person = await RandomPersonAsync(); - var firstName = person.FirstName; + public async Task UpdateExisting_Sync() { + var person = await RandomPersonAsync(x => x.FirstName != "John"); - var result = await Repository.ExistsAsync(x => x.FirstName == firstName); + var toUpdate = await Repository.FindByKeyAsync(person.Id!); + + Assert.NotNull(toUpdate); + + SetFirstName(toUpdate, "John"); + + var result = Repository.Update(toUpdate); Assert.True(result); + + var updated = await Repository.FindByKeyAsync(person.Id!); + + Assert.NotNull(updated); + Assert.Equal(toUpdate.FirstName, updated.FirstName); + Assert.Equal(toUpdate.LastName, updated.LastName); + Assert.Equal(toUpdate.Email, updated.Email); + Assert.Equal(toUpdate.DateOfBirth, updated.DateOfBirth); + } + + [Fact] + public async Task UpdateNotExisting() { + var person = GeneratePerson(); + + SetPersonId(person, GeneratePersonId()); + + var result = await Repository.UpdateAsync(person); + + Assert.False(result); } [Fact] - public async Task ExistsFiltered_Sync() { + public async Task UpdateExisting_NoChange() { var person = await RandomPersonAsync(); - var firstName =person.FirstName; - var result = Repository.Exists(x => x.FirstName == firstName); + var toUpdate = await Repository.FindByKeyAsync(person.Id!); + + Assert.NotNull(toUpdate); + + await Repository.UpdateAsync(toUpdate); + + var updated = await Repository.FindByKeyAsync(person.Id!); + Assert.NotNull(updated); + Assert.Equal(toUpdate.FirstName, updated.FirstName); + Assert.Equal(toUpdate.LastName, updated.LastName); + Assert.Equal(toUpdate.Email, updated.Email); + Assert.Equal(toUpdate.PhoneNumber, updated.PhoneNumber); + Assert.Equal(toUpdate.DateOfBirth, updated.DateOfBirth); + + // TODO: check the relationships to be equal + } + + [Fact] + public async Task UpdateExisting_AddNewRelationship() { + var person = People.Random(x => x.Relationships == null || !x.Relationships.Any()); + + Assert.NotNull(person); + + var relationship = GenerateRelationship(); + + var toUpdate = await Repository.FindByKeyAsync(person.Id!); + + Assert.NotNull(toUpdate); + + await AddRelationshipAsync(toUpdate, relationship); + + var result = await Repository.UpdateAsync(toUpdate); Assert.True(result); + + var updated = await Repository.FindByKeyAsync(person.Id!); + + Assert.NotNull(updated); + Assert.NotNull(updated.Relationships); + Assert.NotEmpty(updated.Relationships); + Assert.Single(updated.Relationships); } [Fact] - public async Task FindByKey_Existing() { - var person = await RandomPersonAsync(); + public async Task UpdateExisting_RemoveRelationship() { + var person = People.Random(x => x.Relationships?.Any() ?? false); - var result = await Repository.FindByKeyAsync(person.Id!); + Assert.NotNull(person); - Assert.NotNull(result); - Assert.Equal(person.Id, result.Id); + var toUpdate = await Repository.FindByKeyAsync(person.Id!); - Assert.Equal(person.FirstName, result.FirstName); - Assert.Equal(person.LastName, result.LastName); + Assert.NotNull(toUpdate); + + var relCount = toUpdate.Relationships.Count(); + + await RemoveRelationshipAsync(toUpdate, (TRelationship)toUpdate.Relationships!.First()); + + var result = await Repository.UpdateAsync(toUpdate); + + Assert.True(result); + + var updated = await Repository.FindByKeyAsync(person.Id!); + Assert.NotNull(updated); + Assert.NotNull(updated.Relationships); + Assert.Equal(relCount - 1, updated.Relationships.Count()); } + #endregion + + #region Filtering Tests + [Fact] - public async Task FindByKey_NotFound() { - var id = GeneratePersonId(); + public virtual async Task CountAll() { + var result = await Repository.CountAllAsync(); - var result = await Repository.FindByKeyAsync(id); + Assert.NotEqual(0, result); + Assert.Equal(People.Count, result); + } - Assert.Null(result); + [Fact] + public virtual void CountAll_Sync() { + var result = Repository.CountAll(); + + Assert.NotEqual(0, result); + Assert.Equal(People.Count, result); } [Fact] - public async Task FindByKey_Sync() { + public virtual async Task CountFiltered() { var person = await RandomPersonAsync(); + var firstName = person.FirstName; + var peopleCount = People.Count(x => x.FirstName == firstName); - var result = Repository.FindByKey(person.Id!); + var count = await Repository.CountAsync(p => p.FirstName == firstName); - Assert.NotNull(result); - Assert.Equal(person.Id, result.Id); + Assert.Equal(peopleCount, count); } [Fact] - public async Task FindByKey_WithRelationsips() { - var person = People.Random(x => x.Relationships?.Any() ?? false); + public virtual async Task CountFiltered_Sync() { + var person = await RandomPersonAsync(); + var firstName = person.FirstName; + var peopleCount = People.Count(x => x.FirstName == firstName); - Assert.NotNull(person); + var count = Repository.Count(p => p.FirstName == firstName); - var result = await Repository.FindByKeyAsync(person.Id!); + Assert.Equal(peopleCount, count); + } + + [Fact] + public virtual async Task FindFirstFiltered() { + var person = await RandomPersonAsync(); + var firstName = person.FirstName; + + var result = await Repository.FindFirstAsync(x => x.FirstName == firstName); Assert.NotNull(result); - Assert.NotNull(result.Relationships); - Assert.NotEmpty(result.Relationships); + Assert.Equal(firstName, result.FirstName); + } + + [Fact] + public virtual void FindFirstSync() { + var result = Repository.FindFirst(); + + Assert.NotNull(result); + Assert.NotNull(result.Id); } [Fact] - public async Task FindFirst() { + public virtual async Task ExistsFiltered() { + var person = await RandomPersonAsync(); + var firstName = person.FirstName; + + var result = await Repository.ExistsAsync(x => x.FirstName == firstName); + + Assert.True(result); + } + + [Fact] + public virtual async Task ExistsFiltered_Sync() { + var person = await RandomPersonAsync(); + var firstName =person.FirstName; + + var result = Repository.Exists(x => x.FirstName == firstName); + + Assert.True(result); + } + + [Fact] + public virtual async Task FindFirst() { var ordered = NaturalOrder(People).ToList(); var result = await Repository.FindFirstAsync(); @@ -390,7 +547,7 @@ public async Task FindFirst() { } [Fact] - public async Task FindFirstFiltered_Sync() { + public virtual async Task FindFirstFiltered_Sync() { var person = await RandomPersonAsync(x => x.FirstName != null); var ordered = NaturalOrder(People.Where(x => x.FirstName == person.FirstName)).ToList(); @@ -404,7 +561,7 @@ public async Task FindFirstFiltered_Sync() { } [Fact] - public async Task FindAll() { + public virtual async Task FindAll() { var result = await Repository.FindAllAsync(); Assert.NotNull(result); @@ -413,7 +570,7 @@ public async Task FindAll() { } [Fact] - public void FindAll_Sync() { + public virtual void FindAll_Sync() { var result = Repository.FindAll(); Assert.NotNull(result); @@ -422,7 +579,7 @@ public void FindAll_Sync() { } [Fact] - public async Task FindAllFiltered() { + public virtual async Task FindAllFiltered() { var person = await RandomPersonAsync(); var firstName = person.FirstName; var peopleCount = People.Count(x => x.FirstName == firstName); @@ -435,7 +592,7 @@ public async Task FindAllFiltered() { } [Fact] - public async Task FindAllFiltered_BadFilter() { + public virtual async Task FindAllFiltered_BadFilter() { var person = await RandomPersonAsync(); var firstName = person.FirstName; @@ -443,8 +600,10 @@ public async Task FindAllFiltered_BadFilter() { () => Repository.FindAllAsync(QueryFilter.Where(m => m.Address == null))); } + #endregion + [Fact] - public async Task GetSimplePage() { + public virtual async Task GetSimplePage() { var totalItems = People.Count; var totalPages = (int)Math.Ceiling((double)totalItems / 10); @@ -459,7 +618,7 @@ public async Task GetSimplePage() { } [Fact] - public async Task GetSimplePage_WithParameters() { + public virtual async Task GetSimplePage_WithParameters() { var totalItems = People.Count; var totalPages = (int)Math.Ceiling((double)totalItems / 10); @@ -474,7 +633,7 @@ public async Task GetSimplePage_WithParameters() { } [Fact] - public async Task GetFilteredPage() { + public virtual async Task GetFilteredPage() { var person = await RandomPersonAsync(); var firstName = person.FirstName; var peopleCount = People.Count(x => x.FirstName == firstName); @@ -494,7 +653,7 @@ public async Task GetFilteredPage() { } [Fact] - public async Task GetPage_MultipleFilters() { + public virtual async Task GetPage_MultipleFilters() { var person = await RandomPersonAsync(x => x.LastName != null); var firstName = person.FirstName; var lastName = person.LastName; @@ -516,7 +675,7 @@ public async Task GetPage_MultipleFilters() { } [Fact] - public async Task GetPage_ChainedFilters() { + public virtual async Task GetPage_ChainedFilters() { var person = await RandomPersonAsync(x => x.DateOfBirth != null); var firstName = person.FirstName; var birthDate = person.DateOfBirth!.Value; @@ -544,19 +703,26 @@ public async Task GetPage_ChainedFilters() { [Fact] - public async Task GetDescendingSortedPage() { - var sorted = People.Where(x => x.LastName != null).OrderByDescending(x => x.LastName).Skip(0).Take(10).ToList(); + public virtual async Task GetDescendingSortedPage() { + var sorted = People.Where(x => x.LastName != null) + .OrderByDescending(x => x.LastName) + .Skip(0).Take(10).ToList(); + + var totalItems = People.Count(x => x.LastName != null); + var totalPages = (int)Math.Ceiling((double)totalItems / 10); + var pageItems = Math.Min(totalItems, 10); var request = new PageQuery(1, 10) + .Where(x => x.LastName != null) .OrderByDescending(x => x.LastName); var result = await Repository.GetPageAsync(request); Assert.NotNull(result); - Assert.Equal(10, result.TotalPages); - Assert.Equal(100, result.TotalItems); + Assert.Equal(totalPages, result.TotalPages); + Assert.Equal(totalItems, result.TotalItems); Assert.NotNull(result.Items); Assert.NotEmpty(result.Items); - Assert.Equal(10, result.Items.Count()); + Assert.Equal(pageItems, result.Items.Count); for (int i = 0; i < sorted.Count; i++) { Assert.Equal(sorted[i].LastName, result.Items.ElementAt(i).LastName); @@ -564,7 +730,7 @@ public async Task GetDescendingSortedPage() { } [Fact] - public async Task GetSortedPage() { + public virtual async Task GetSortedPage() { var totalPages = (int)Math.Ceiling((double)People.Count / 10); var request = new PageQuery(1, 10) @@ -581,7 +747,7 @@ public async Task GetSortedPage() { } [Fact] - public void GetPage_Sync() { + public virtual void GetPage_Sync() { var totalPages = (int)Math.Ceiling((double)People.Count / 10); var request = new PageQuery(1, 10); @@ -595,137 +761,5 @@ public void GetPage_Sync() { Assert.NotEmpty(result.Items); Assert.Equal(10, result.Items.Count); } - - [Fact] - public async Task GetPersonId() { - var person = await RandomPersonAsync(); - - var id = Repository.GetEntityKey(person); - - Assert.NotNull(id); - Assert.Equal(person.Id, id.ToString()); - } - - [Fact] - public async Task UpdateExisting() { - var person = await RandomPersonAsync(x => x.FirstName != "John"); - - var toUpdate = await Repository.FindByKeyAsync(person.Id!); - - Assert.NotNull(toUpdate); - - toUpdate.FirstName = "John"; - - var result = await Repository.UpdateAsync(toUpdate); - - Assert.True(result); - - var updated = await Repository.FindByKeyAsync(person.Id!); - - Assert.NotNull(updated); - Assert.Equal(toUpdate.FirstName, updated.FirstName); - Assert.Equal(toUpdate.LastName, updated.LastName); - Assert.Equal(toUpdate.Email, updated.Email); - Assert.Equal(toUpdate.DateOfBirth, updated.DateOfBirth); - } - - [Fact] - public async Task UpdateExisting_Sync() { - var person = await RandomPersonAsync(x => x.FirstName != "John"); - - var toUpdate = await Repository.FindByKeyAsync(person.Id!); - - Assert.NotNull(toUpdate); - - toUpdate.FirstName = "John"; - - var result = Repository.Update(toUpdate); - - Assert.True(result); - - var updated = await Repository.FindByKeyAsync(person.Id!); - - Assert.NotNull(updated); - Assert.Equal(toUpdate.FirstName, updated.FirstName); - Assert.Equal(toUpdate.LastName, updated.LastName); - Assert.Equal(toUpdate.Email, updated.Email); - Assert.Equal(toUpdate.DateOfBirth, updated.DateOfBirth); - } - - [Fact] - public async Task UpdateNotExisting() { - var person = GeneratePerson(); - - person.Id = GeneratePersonId(); - - var result = await Repository.UpdateAsync(person); - - Assert.False(result); - } - - [Fact] - public async Task UpdateExisting_NoChange() { - var person = await RandomPersonAsync(); - - var toUpdate = await Repository.FindByKeyAsync(person.Id!); - - Assert.NotNull(toUpdate); - - await Repository.UpdateAsync(toUpdate); - - var updated = await Repository.FindByKeyAsync(person.Id!); - Assert.NotNull(updated); - Assert.Equal(toUpdate, updated); - } - - [Fact] - public async Task UpdateExisting_AddNewRelationship() { - var person = People.Random(x => x.Relationships == null || !x.Relationships.Any()); - - Assert.NotNull(person); - - var relationship = GenerateRelationship(); - - var toUpdate = await Repository.FindByKeyAsync(person.Id!); - - Assert.NotNull(toUpdate); - - await AddRelationshipAsync(toUpdate, relationship); - - var result = await Repository.UpdateAsync(toUpdate); - - Assert.True(result); - - var updated = await Repository.FindByKeyAsync(person.Id!); - - Assert.NotNull(updated); - Assert.NotNull(updated.Relationships); - Assert.NotEmpty(updated.Relationships); - Assert.Single(updated.Relationships); - } - - [Fact] - public async Task UpdateExisting_RemoveRelationship() { - var person = People.Random(x => x.Relationships?.Any() ?? false); - - Assert.NotNull(person); - - var toUpdate = await Repository.FindByKeyAsync(person.Id!); - - Assert.NotNull(toUpdate); - - var relCount = toUpdate.Relationships.Count(); - - await RemoveRelationshipAsync(toUpdate, (TRelationship) toUpdate.Relationships!.First()); - - var result = await Repository.UpdateAsync(toUpdate); - - Assert.True(result); - - var updated = await Repository.FindByKeyAsync(person.Id!); - Assert.NotNull(updated); - Assert.NotNull(updated.Relationships); - Assert.Equal(relCount - 1, updated.Relationships.Count()); - } } }