diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 00000000..9f19ec52 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-reportgenerator-globaltool": { + "version": "5.1.2", + "commands": [ + "reportgenerator" + ] + } + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7ecc656c..71ea7ccc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ TestResults -CoverageResults +CoverageReport ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. diff --git a/generate-coverage-report.sh b/generate-coverage-report.sh new file mode 100644 index 00000000..b505b0ba --- /dev/null +++ b/generate-coverage-report.sh @@ -0,0 +1,9 @@ +#!/bin/sh +rm -rf ./TestResults ./CoverageReport +dotnet tool restore +dotnet restore EntityDb.sln --locked-mode +#dotnet test EntityDb.sln --no-restore -c Debug --collect:"XPlat Code Coverage" -r ./TestResults -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover +#dotnet reportgenerator -reports:"./TestResults/**/coverage.opencover.xml" -targetdir:"CoverageReport" -reporttypes:Html -license:"ew0KICAiTGljZW5zZSI6IHsNCiAgICAiSWQiOiAiZDZkYjQ2NTYtMjUyMS00MjNlLWE0MTgtZmU2NjJiNDZiMDk3IiwNCiAgICAiTG9naW4iOiAidGhlLWF2aWQtZW5naW5lZXIiLA0KICAgICJOYW1lIjogIkNocmlzIFBoaWxpcHMiLA0KICAgICJFbWFpbCI6IG51bGwsDQogICAgIkxpY2Vuc2VUeXBlIjogIlBybyIsDQogICAgIklzc3VlZEF0IjogIjIwMjItMDMtMTlUMDA6MjA6MTYuNDQ2OTg3MVoiDQogIH0sDQogICJTaWduYXR1cmUiOiAidmZtTldaN2VVeTZjbVpaUFVMS2hCUzBSYVAzNVFjdlNRMWdEbU1PWTFhenlsTlpUVDNEUjl0bWptZDc3RTZVaTAycFZOWDA2YTdCMzc4ZHV6NHZ0TFdFOWg2VDNOMUhXWTJwXHUwMDJCN0FYbVlqc1x1MDAyQmdTbVNud0tueWFiNjJ5dHhLd3Y0TWdLbjh2OHF4aXlCOVBJMGV4YkJPTE11VXAwWVQ4WDc0YW9teWhEam5aWm9kbXhrN05zYzllQTBxRnBFaEZ0QkEzRzNFSWdcdTAwMkJNNHVPb002VEtNMERaV2g1NGFsVHloU25QS2ZNSjRuMWp1OWxGaHlHektDaXhOcUJhTk5LSVl6UnFEWmhxbEpYSGFBQmM4RnNGVlNWVnBwQTN6dUR3aGxxYmxHZGhOVFpTM0w2Y1FyNXNrMS9RV1ZpNDYwbXFnVWNlSVx1MDAyQmI3R2d5eFFCM3AyUG9ZQ1FFREE9PSINCn0=" +dotnet test EntityDb.sln --no-restore -c Debug --collect:"XPlat Code Coverage" -r ./TestResults +dotnet reportgenerator -reports:"./TestResults/**/coverage.cobertura.xml" -targetdir:"CoverageReport" -reporttypes:Html -license:"ew0KICAiTGljZW5zZSI6IHsNCiAgICAiSWQiOiAiZDZkYjQ2NTYtMjUyMS00MjNlLWE0MTgtZmU2NjJiNDZiMDk3IiwNCiAgICAiTG9naW4iOiAidGhlLWF2aWQtZW5naW5lZXIiLA0KICAgICJOYW1lIjogIkNocmlzIFBoaWxpcHMiLA0KICAgICJFbWFpbCI6IG51bGwsDQogICAgIkxpY2Vuc2VUeXBlIjogIlBybyIsDQogICAgIklzc3VlZEF0IjogIjIwMjItMDMtMTlUMDA6MjA6MTYuNDQ2OTg3MVoiDQogIH0sDQogICJTaWduYXR1cmUiOiAidmZtTldaN2VVeTZjbVpaUFVMS2hCUzBSYVAzNVFjdlNRMWdEbU1PWTFhenlsTlpUVDNEUjl0bWptZDc3RTZVaTAycFZOWDA2YTdCMzc4ZHV6NHZ0TFdFOWg2VDNOMUhXWTJwXHUwMDJCN0FYbVlqc1x1MDAyQmdTbVNud0tueWFiNjJ5dHhLd3Y0TWdLbjh2OHF4aXlCOVBJMGV4YkJPTE11VXAwWVQ4WDc0YW9teWhEam5aWm9kbXhrN05zYzllQTBxRnBFaEZ0QkEzRzNFSWdcdTAwMkJNNHVPb002VEtNMERaV2g1NGFsVHloU25QS2ZNSjRuMWp1OWxGaHlHektDaXhOcUJhTk5LSVl6UnFEWmhxbEpYSGFBQmM4RnNGVlNWVnBwQTN6dUR3aGxxYmxHZGhOVFpTM0w2Y1FyNXNrMS9RV1ZpNDYwbXFnVWNlSVx1MDAyQmI3R2d5eFFCM3AyUG9ZQ1FFREE9PSINCn0=" +open CoverageReport/index.html \ No newline at end of file diff --git a/src/EntityDb.Abstractions/Projections/IProjectionRepository.cs b/src/EntityDb.Abstractions/Projections/IProjectionRepository.cs new file mode 100644 index 00000000..5288183d --- /dev/null +++ b/src/EntityDb.Abstractions/Projections/IProjectionRepository.cs @@ -0,0 +1,36 @@ +using EntityDb.Abstractions.Disposables; +using EntityDb.Abstractions.Snapshots; +using EntityDb.Abstractions.Transactions; +using EntityDb.Abstractions.ValueObjects; +using System.Threading.Tasks; + +namespace EntityDb.Abstractions.Projections; + +/// +/// Encapsulates the snapshot repository for a projection. +/// +/// The type of the projection. +public interface IProjectionRepository : IDisposableResource +{ + /// + /// The strategy for mapping between projection id and entity id. + /// + IProjectionStrategy ProjectionStrategy { get; } + + /// + /// The backing transaction repository. + /// + ITransactionRepository TransactionRepository { get; } + + /// + /// The backing snapshot repository. + /// + ISnapshotRepository SnapshotRepository { get; } + + /// + /// Returns the current state of a . + /// + /// The id of the projection. + /// The current state of a . + Task GetCurrent(Id projectionId); +} diff --git a/src/EntityDb.Abstractions/Projections/IProjectionRepositoryFactory.cs b/src/EntityDb.Abstractions/Projections/IProjectionRepositoryFactory.cs new file mode 100644 index 00000000..69449ebc --- /dev/null +++ b/src/EntityDb.Abstractions/Projections/IProjectionRepositoryFactory.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; + +namespace EntityDb.Abstractions.Projections; + +/// +/// Represents a type used to create instances of +/// +/// The type of projection managed by the . +public interface IProjectionRepositoryFactory +{ + /// + /// Create a new instance of + /// + /// The agent's use case for the transaction repository. + /// The agent's use case for the snapshot repository. + /// A new instance of . + Task> CreateRepository(string transactionSessionOptionsName, + string snapshotSessionOptionsName); +} diff --git a/src/EntityDb.Abstractions/Projections/IProjectionStrategy.cs b/src/EntityDb.Abstractions/Projections/IProjectionStrategy.cs new file mode 100644 index 00000000..fb50513a --- /dev/null +++ b/src/EntityDb.Abstractions/Projections/IProjectionStrategy.cs @@ -0,0 +1,26 @@ +using EntityDb.Abstractions.ValueObjects; +using System.Threading.Tasks; + +namespace EntityDb.Abstractions.Projections; + +/// +/// Represents a type that can map a projection it to a set of entity ids. +/// +/// +public interface IProjectionStrategy +{ + /// + /// Map a projection id to a set of entity ids. + /// + /// The id of the projection. + /// A snapshot of the projection, if one exists. (This can be used to avoid running a query, if one were necessary.) + /// The set of entity ids to query for running the projection. + Task GetEntityIds(Id projectionId, TProjection projectionSnapshot); + + /// + /// Map an entity id to a set of projection ids. + /// + /// The id of th entity. + /// The set of projection ids to query for running the projection. + Task GetProjectionIds(Id entityId); +} diff --git a/src/EntityDb.Common/Annotations/EntityAnnotation.cs b/src/EntityDb.Common/Annotations/EntityAnnotation.cs index af479ed8..173e1e4c 100644 --- a/src/EntityDb.Common/Annotations/EntityAnnotation.cs +++ b/src/EntityDb.Common/Annotations/EntityAnnotation.cs @@ -1,4 +1,6 @@ using EntityDb.Abstractions.Annotations; +using EntityDb.Abstractions.Transactions; +using EntityDb.Abstractions.Transactions.Steps; using EntityDb.Abstractions.ValueObjects; namespace EntityDb.Common.Annotations; @@ -10,4 +12,18 @@ internal record EntityAnnotation Id EntityId, VersionNumber EntityVersionNumber, TData Data -) : IEntityAnnotation; +) : IEntityAnnotation +{ + public static EntityAnnotation CreateFrom(ITransaction transaction, ITransactionStep transactionStep, + TData data) + { + return new + ( + transaction.Id, + transaction.TimeStamp, + transactionStep.EntityId, + transactionStep.EntityVersionNumber, + data + ); + } +} diff --git a/src/EntityDb.Common/Disposables/DisposableResourceBaseClass.cs b/src/EntityDb.Common/Disposables/DisposableResourceBaseClass.cs index 2ff35fe1..d0ae36f2 100644 --- a/src/EntityDb.Common/Disposables/DisposableResourceBaseClass.cs +++ b/src/EntityDb.Common/Disposables/DisposableResourceBaseClass.cs @@ -1,10 +1,12 @@ using EntityDb.Abstractions.Disposables; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; namespace EntityDb.Common.Disposables; internal class DisposableResourceBaseClass : IDisposableResource { + [ExcludeFromCodeCoverage(Justification = "All Tests Use DisposeAsync")] public virtual void Dispose() { DisposeAsync().AsTask().Wait(); diff --git a/src/EntityDb.Common/Disposables/DisposableResourceBaseRecord.cs b/src/EntityDb.Common/Disposables/DisposableResourceBaseRecord.cs index 41a998b2..340d7669 100644 --- a/src/EntityDb.Common/Disposables/DisposableResourceBaseRecord.cs +++ b/src/EntityDb.Common/Disposables/DisposableResourceBaseRecord.cs @@ -1,10 +1,12 @@ using EntityDb.Abstractions.Disposables; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; namespace EntityDb.Common.Disposables; internal record DisposableResourceBaseRecord : IDisposableResource { + [ExcludeFromCodeCoverage(Justification = "All Tests Use DisposeAsync")] public virtual void Dispose() { DisposeAsync().AsTask().Wait(); diff --git a/src/EntityDb.Common/Entities/IEntity.cs b/src/EntityDb.Common/Entities/IEntity.cs index b5540d98..1e3ea6d4 100644 --- a/src/EntityDb.Common/Entities/IEntity.cs +++ b/src/EntityDb.Common/Entities/IEntity.cs @@ -15,10 +15,16 @@ public interface IEntity /// A new instance of . abstract static TEntity Construct(Id entityId); + /// + /// Returns the id of the entity. + /// + /// The id of this entity. + Id GetId(); + /// /// Returns the version number of the entity. /// - /// + /// The id of this entity. VersionNumber GetVersionNumber(); /// diff --git a/src/EntityDb.Common/Extensions/ServiceCollectionExtensions.cs b/src/EntityDb.Common/Extensions/ServiceCollectionExtensions.cs index dc566ec7..554e0401 100644 --- a/src/EntityDb.Common/Extensions/ServiceCollectionExtensions.cs +++ b/src/EntityDb.Common/Extensions/ServiceCollectionExtensions.cs @@ -1,7 +1,9 @@ using EntityDb.Abstractions.Agents; using EntityDb.Abstractions.Entities; +using EntityDb.Abstractions.Projections; using EntityDb.Abstractions.Transactions; using EntityDb.Common.Entities; +using EntityDb.Common.Projections; using EntityDb.Common.Snapshots; using EntityDb.Common.Transactions; using EntityDb.Common.Transactions.Builders; @@ -85,16 +87,49 @@ public static void AddEntity(this IServiceCollection serviceCollection) /// /// Adds a transaction subscriber that records snapshots of entities. /// - /// The type of the snapshot. /// The service collection. /// The agent's intent for the snapshot repository. /// If true then snapshots will be synchronously recorded. - public static void AddEntitySnapshotTransactionSubscriber(this IServiceCollection serviceCollection, + /// The type of the entity. + public static void AddEntitySnapshotTransactionSubscriber(this IServiceCollection serviceCollection, + string snapshotSessionOptionsName, bool synchronousMode = false) + where TEntity : IEntity, ISnapshot + { + serviceCollection.AddSingleton(serviceProvider => + EntitySnapshotTransactionSubscriber.Create(serviceProvider, snapshotSessionOptionsName, + synchronousMode)); + } + + /// + /// Adds a projection strategy. + /// + /// The service collection. + /// The type of the projection. + /// The type of the projection strategy. + public static void AddProjection( + this IServiceCollection serviceCollection) + where TProjection : IProjection + where TProjectionStrategy : class, IProjectionStrategy + { + serviceCollection.AddSingleton, TProjectionStrategy>(); + serviceCollection + .AddTransient, ProjectionRepositoryFactory>(); + } + + /// + /// Adds a transaction subscriber that records snapshots of projections. + /// + /// The service collection. + /// The agent's intent for the snapshot repository. + /// If true then snapshots will be synchronously recorded. + /// The type of the projection. + public static void AddProjectionSnapshotTransactionSubscriber( + this IServiceCollection serviceCollection, string snapshotSessionOptionsName, bool synchronousMode = false) - where TSnapshot : ISnapshot + where TProjection : IProjection, ISnapshot { serviceCollection.AddSingleton(serviceProvider => - EntitySnapshotTransactionSubscriber.Create(serviceProvider, snapshotSessionOptionsName, + ProjectionSnapshotTransactionSubscriber.Create(serviceProvider, snapshotSessionOptionsName, synchronousMode)); } } diff --git a/src/EntityDb.Common/Projections/IProjection.cs b/src/EntityDb.Common/Projections/IProjection.cs new file mode 100644 index 00000000..74160e09 --- /dev/null +++ b/src/EntityDb.Common/Projections/IProjection.cs @@ -0,0 +1,31 @@ +using EntityDb.Abstractions.Annotations; +using EntityDb.Abstractions.ValueObjects; + +namespace EntityDb.Common.Projections; + +/// +/// Provides basic functionality for the common implementations. +/// +/// +public interface IProjection +{ + /// + /// Creates a new instance of a . + /// + /// The id of the entity. + /// A new instance of . + abstract static TProjection Construct(Id projectionId); + + /// + /// Returns the current version number of an entity. + /// + /// + VersionNumber GetEntityVersionNumber(Id entityId); + + /// + /// Returns a new that incorporates the commands for a particular entity id. + /// + /// The annotated commands. + /// A new that incorporates . + TProjection Reduce(params IEntityAnnotation[] annotatedCommands); +} diff --git a/src/EntityDb.Common/Projections/ProjectionRepository.cs b/src/EntityDb.Common/Projections/ProjectionRepository.cs new file mode 100644 index 00000000..07b14eaf --- /dev/null +++ b/src/EntityDb.Common/Projections/ProjectionRepository.cs @@ -0,0 +1,67 @@ +using EntityDb.Abstractions.Projections; +using EntityDb.Abstractions.Snapshots; +using EntityDb.Abstractions.Transactions; +using EntityDb.Abstractions.ValueObjects; +using EntityDb.Common.Disposables; +using EntityDb.Common.Queries; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Threading.Tasks; + +namespace EntityDb.Common.Projections; + +internal sealed class ProjectionRepository : DisposableResourceBaseClass, IProjectionRepository + where TProjection : IProjection +{ + public IProjectionStrategy ProjectionStrategy { get; } + public ITransactionRepository TransactionRepository { get; } + public ISnapshotRepository SnapshotRepository { get; } + + public ProjectionRepository + ( + IProjectionStrategy projectionStrategy, + ISnapshotRepository snapshotRepository, + ITransactionRepository transactionRepository + ) + { + ProjectionStrategy = projectionStrategy; + TransactionRepository = transactionRepository; + SnapshotRepository = snapshotRepository; + } + + public async Task GetCurrent(Id projectionId) + { + var projection = await SnapshotRepository.GetSnapshot(projectionId) ?? TProjection.Construct(projectionId); + + var entityIds = await ProjectionStrategy.GetEntityIds(projectionId, projection); + + if (entityIds.Length == 0) + { + return projection; + } + + foreach (var entityId in entityIds) + { + var entityVersionNumber = projection.GetEntityVersionNumber(entityId); + + var commandQuery = new GetCurrentEntityQuery(entityId, entityVersionNumber); + + var annotatedCommands = await TransactionRepository.GetAnnotatedCommands(commandQuery); + + projection = projection.Reduce(annotatedCommands); + } + + return projection; + } + + public static ProjectionRepository Create + ( + IServiceProvider serviceProvider, + ITransactionRepository transactionRepository, + ISnapshotRepository snapshotRepository + ) + { + return ActivatorUtilities.CreateInstance>(serviceProvider, + transactionRepository, snapshotRepository); + } +} diff --git a/src/EntityDb.Common/Projections/ProjectionRepositoryFactory.cs b/src/EntityDb.Common/Projections/ProjectionRepositoryFactory.cs new file mode 100644 index 00000000..a9405524 --- /dev/null +++ b/src/EntityDb.Common/Projections/ProjectionRepositoryFactory.cs @@ -0,0 +1,39 @@ +using EntityDb.Abstractions.Projections; +using EntityDb.Abstractions.Snapshots; +using EntityDb.Abstractions.Transactions; +using System; +using System.Threading.Tasks; + +namespace EntityDb.Common.Projections; + +internal class ProjectionRepositoryFactory : IProjectionRepositoryFactory + where TProjection : IProjection +{ + private readonly IServiceProvider _serviceProvider; + private readonly ITransactionRepositoryFactory _transactionRepositoryFactory; + private readonly ISnapshotRepositoryFactory _snapshotRepositoryFactory; + + public ProjectionRepositoryFactory + ( + IServiceProvider serviceProvider, + ITransactionRepositoryFactory transactionRepositoryFactory, + ISnapshotRepositoryFactory snapshotRepositoryFactory + ) + { + _serviceProvider = serviceProvider; + _transactionRepositoryFactory = transactionRepositoryFactory; + _snapshotRepositoryFactory = snapshotRepositoryFactory; + } + + public async Task> CreateRepository(string transactionSessionOptionsName, string snapshotSessionOptionsName) + { + var transactionRepository = + await _transactionRepositoryFactory.CreateRepository(transactionSessionOptionsName); + + var snapshotRepository = + await _snapshotRepositoryFactory.CreateRepository(snapshotSessionOptionsName); + + return ProjectionRepository.Create(_serviceProvider, + transactionRepository, snapshotRepository); + } +} diff --git a/src/EntityDb.Common/Transactions/EntitySnapshotTransactionSubscriber.cs b/src/EntityDb.Common/Transactions/EntitySnapshotTransactionSubscriber.cs index 65c0a56c..f0a9f293 100644 --- a/src/EntityDb.Common/Transactions/EntitySnapshotTransactionSubscriber.cs +++ b/src/EntityDb.Common/Transactions/EntitySnapshotTransactionSubscriber.cs @@ -1,22 +1,24 @@ using EntityDb.Abstractions.Snapshots; using EntityDb.Abstractions.Transactions; +using EntityDb.Abstractions.ValueObjects; +using EntityDb.Common.Entities; using EntityDb.Common.Snapshots; using Microsoft.Extensions.DependencyInjection; using System; -using System.Linq; +using System.Collections.Generic; using System.Threading.Tasks; namespace EntityDb.Common.Transactions; -internal class EntitySnapshotTransactionSubscriber : TransactionSubscriber - where TSnapshot : ISnapshot +internal class EntitySnapshotTransactionSubscriber : TransactionSubscriber + where TEntity : IEntity, ISnapshot { - private readonly ISnapshotRepositoryFactory _snapshotRepositoryFactory; + private readonly ISnapshotRepositoryFactory _snapshotRepositoryFactory; private readonly string _snapshotSessionOptionsName; - + public EntitySnapshotTransactionSubscriber ( - ISnapshotRepositoryFactory snapshotRepositoryFactory, + ISnapshotRepositoryFactory snapshotRepositoryFactory, string snapshotSessionOptionsName, bool synchronousMode ) : base(synchronousMode) @@ -25,40 +27,45 @@ bool synchronousMode _snapshotSessionOptionsName = snapshotSessionOptionsName; } + public Task> CreateSnapshotRepository() + { + return _snapshotRepositoryFactory.CreateRepository(_snapshotSessionOptionsName); + } + protected override async Task NotifyAsync(ITransaction transaction) { - var snapshotRepository = - await _snapshotRepositoryFactory.CreateRepository(_snapshotSessionOptionsName); + await using var snapshotRepository = await CreateSnapshotRepository(); - var stepGroups = transaction.Steps - .GroupBy(step => step.EntityId); + var entityCache = new Dictionary(); - foreach (var stepGroup in stepGroups) + foreach (var step in transaction.Steps) { - var entity = stepGroup.Last().Entity; - - if (entity is not TSnapshot nextSnapshot) + if (step.Entity is not TEntity entity) { - return; + continue; } - - var snapshotId = stepGroup.Key; - - var previousSnapshot = await snapshotRepository.GetSnapshot(snapshotId); - if (!nextSnapshot.ShouldReplace(previousSnapshot)) + var entityId = entity.GetId(); + + entityCache.TryGetValue(entityId, out var previousSnapshot); + + previousSnapshot ??= await snapshotRepository.GetSnapshot(entityId); + + if (!entity.ShouldReplace(previousSnapshot)) { continue; } - await snapshotRepository.PutSnapshot(snapshotId, nextSnapshot); + await snapshotRepository.PutSnapshot(entityId, entity); + + entityCache[entityId] = entity; } } - public static EntitySnapshotTransactionSubscriber Create(IServiceProvider serviceProvider, + public static EntitySnapshotTransactionSubscriber Create(IServiceProvider serviceProvider, string snapshotSessionOptionsName, bool synchronousMode) { - return ActivatorUtilities.CreateInstance>(serviceProvider, + return ActivatorUtilities.CreateInstance>(serviceProvider, snapshotSessionOptionsName, synchronousMode); } diff --git a/src/EntityDb.Common/Transactions/ProjectionSnapshotTransactionSubscriber.cs b/src/EntityDb.Common/Transactions/ProjectionSnapshotTransactionSubscriber.cs new file mode 100644 index 00000000..750b4daf --- /dev/null +++ b/src/EntityDb.Common/Transactions/ProjectionSnapshotTransactionSubscriber.cs @@ -0,0 +1,86 @@ +using EntityDb.Abstractions.Projections; +using EntityDb.Abstractions.Snapshots; +using EntityDb.Abstractions.Transactions; +using EntityDb.Abstractions.Transactions.Steps; +using EntityDb.Abstractions.ValueObjects; +using EntityDb.Common.Annotations; +using EntityDb.Common.Projections; +using EntityDb.Common.Snapshots; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace EntityDb.Common.Transactions; + +internal class ProjectionSnapshotTransactionSubscriber : TransactionSubscriber + where TProjection : IProjection, ISnapshot +{ + private readonly IProjectionStrategy _projectionStrategy; + private readonly ISnapshotRepositoryFactory _snapshotRepositoryFactory; + private readonly string _snapshotSessionOptionsName; + + public ProjectionSnapshotTransactionSubscriber + ( + IProjectionStrategy projectionStrategy, + ISnapshotRepositoryFactory snapshotRepositoryFactory, + string snapshotSessionOptionsName, + bool synchronousMode + ) : base(synchronousMode) + { + _projectionStrategy = projectionStrategy; + _snapshotRepositoryFactory = snapshotRepositoryFactory; + _snapshotSessionOptionsName = snapshotSessionOptionsName; + } + + public Task> CreateSnapshotRepository() + { + return _snapshotRepositoryFactory.CreateRepository(_snapshotSessionOptionsName); + } + + protected override async Task NotifyAsync(ITransaction transaction) + { + await using var snapshotRepository = await CreateSnapshotRepository(); + + var projectionCache = new Dictionary(); + + foreach (var step in transaction.Steps) + { + if (step is not IAppendCommandTransactionStep appendCommandTransactionStep) + { + continue; + } + + var projectionIds = await _projectionStrategy.GetProjectionIds(appendCommandTransactionStep.EntityId); + + foreach (var projectionId in projectionIds) + { + projectionCache.TryGetValue(projectionId, out var previousProjection); + + previousProjection ??= await snapshotRepository.GetSnapshot(projectionId) ?? TProjection.Construct(projectionId); + + var projection = previousProjection; + + var annotatedCommand = EntityAnnotation.CreateFrom(transaction, appendCommandTransactionStep, appendCommandTransactionStep.Command); + + projection = projection.Reduce(annotatedCommand); + + if (projection.ShouldReplace(previousProjection)) + { + await snapshotRepository.PutSnapshot(projectionId, projection); + } + + projectionCache[projectionId] = projection; + } + } + } + + public static ProjectionSnapshotTransactionSubscriber Create(IServiceProvider serviceProvider, + string snapshotSessionOptionsName, bool synchronousMode) + { + return ActivatorUtilities.CreateInstance>(serviceProvider, + snapshotSessionOptionsName, + synchronousMode); + } +} diff --git a/src/EntityDb.InMemory/Extensions/ServiceCollectionExtensions.cs b/src/EntityDb.InMemory/Extensions/ServiceCollectionExtensions.cs index 3a698b55..68347252 100644 --- a/src/EntityDb.InMemory/Extensions/ServiceCollectionExtensions.cs +++ b/src/EntityDb.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -3,12 +3,14 @@ using EntityDb.InMemory.Sessions; using EntityDb.InMemory.Snapshots; using Microsoft.Extensions.DependencyInjection; +using System.Diagnostics.CodeAnalysis; namespace EntityDb.InMemory.Extensions; /// /// Extensions for service collections. /// +[ExcludeFromCodeCoverage(Justification = "Don't need coverage for non-test mode.")] public static class ServiceCollectionExtensions { /// diff --git a/src/EntityDb.MongoDb/Sessions/IMongoSession.cs b/src/EntityDb.MongoDb/Sessions/IMongoSession.cs index 784c3115..3ec96190 100644 --- a/src/EntityDb.MongoDb/Sessions/IMongoSession.cs +++ b/src/EntityDb.MongoDb/Sessions/IMongoSession.cs @@ -11,7 +11,6 @@ namespace EntityDb.MongoDb.Sessions; internal interface IMongoSession : IDisposableResource { IMongoDatabase MongoDatabase { get; } - IClientSessionHandle ClientSessionHandle { get; } Task Insert(string collectionName, TDocument[] bsonDocuments, CancellationToken cancellationToken); diff --git a/src/EntityDb.MongoDb/Sessions/TestModeMongoSession.cs b/src/EntityDb.MongoDb/Sessions/TestModeMongoSession.cs index 3734aad8..a422cca5 100644 --- a/src/EntityDb.MongoDb/Sessions/TestModeMongoSession.cs +++ b/src/EntityDb.MongoDb/Sessions/TestModeMongoSession.cs @@ -11,7 +11,6 @@ namespace EntityDb.MongoDb.Sessions; internal record TestModeMongoSession(IMongoSession MongoSession) : DisposableResourceBaseRecord, IMongoSession { public IMongoDatabase MongoDatabase => MongoSession.MongoDatabase; - public IClientSessionHandle ClientSessionHandle => MongoSession.ClientSessionHandle; public Task Insert(string collectionName, TDocument[] bsonDocuments, CancellationToken cancellationToken) { diff --git a/src/EntityDb.Mvc/Agents/HttpContextAgent.cs b/src/EntityDb.Mvc/Agents/HttpContextAgent.cs index 7fd244ba..538dc1f5 100644 --- a/src/EntityDb.Mvc/Agents/HttpContextAgent.cs +++ b/src/EntityDb.Mvc/Agents/HttpContextAgent.cs @@ -7,11 +7,6 @@ namespace EntityDb.Mvc.Agents; internal record HttpContextAgent(HttpContext HttpContext, IOptionsFactory HttpContextAgentSignatureOptionsFactory) : IAgent { - public bool HasRole(string role) - { - return HttpContext.User.IsInRole(role); - } - public TimeStamp GetTimeStamp() { return TimeStamp.UtcNow; diff --git a/src/EntityDb.Redis/Extensions/ServiceCollectionExtensions.cs b/src/EntityDb.Redis/Extensions/ServiceCollectionExtensions.cs index d6a3336f..03a8d320 100644 --- a/src/EntityDb.Redis/Extensions/ServiceCollectionExtensions.cs +++ b/src/EntityDb.Redis/Extensions/ServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using System; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; namespace EntityDb.Redis.Extensions; @@ -13,6 +14,7 @@ namespace EntityDb.Redis.Extensions; /// /// Extensions for service collections. /// +[ExcludeFromCodeCoverage(Justification = "Don't need coverage for non-test mode.")] public static class ServiceCollectionExtensions { internal static void AddJsonElementEnvelopeService(this IServiceCollection serviceCollection) diff --git a/test/.DS_Store b/test/.DS_Store new file mode 100644 index 00000000..0c114044 Binary files /dev/null and b/test/.DS_Store differ diff --git a/test/EntityDb.Common.Tests/Entities/EntityTests.cs b/test/EntityDb.Common.Tests/Entities/EntityTests.cs index cf58606e..53ef9357 100644 --- a/test/EntityDb.Common.Tests/Entities/EntityTests.cs +++ b/test/EntityDb.Common.Tests/Entities/EntityTests.cs @@ -31,11 +31,11 @@ private static ITransaction BuildTransaction Id entityId, VersionNumber from, VersionNumber to, - TransactionEntity? entity = null + TestEntity? entity = null ) { var transactionBuilder = serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .ForSingleEntity(entityId); if (entity != null) @@ -57,8 +57,8 @@ private static ITransaction BuildTransaction } [Theory] - [MemberData(nameof(AddTransactionsAndSnapshots))] - public async Task GivenEntityWithNVersions_WhenGettingAtVersionM_ThenReturnAtVersionM(TransactionsAdder transactionsAdder, SnapshotsAdder snapshotsAdder) + [MemberData(nameof(AddTransactionsAndEntitySnapshots))] + public async Task GivenEntityWithNVersions_WhenGettingAtVersionM_ThenReturnAtVersionM(TransactionsAdder transactionsAdder, SnapshotsAdder entitySnapshotsAdder) { // ARRANGE @@ -72,13 +72,13 @@ public async Task GivenEntityWithNVersions_WhenGettingAtVersionM_ThenReturnAtVer using var serviceScope = CreateServiceScope(serviceCollection => { transactionsAdder.Add(serviceCollection); - snapshotsAdder.Add(serviceCollection); + entitySnapshotsAdder.Add(serviceCollection); }); var entityId = Id.NewId(); await using var entityRepository = await serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .CreateRepository(TestSessionOptions.Write, TestSessionOptions.Write); @@ -149,7 +149,7 @@ public async Task GivenExistingEntityWithNoSnapshot_WhenGettingEntity_ThenGetCom }); await using var entityRepository = await serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .CreateRepository(TestSessionOptions.Write); var transaction = BuildTransaction(serviceScope, entityId, new VersionNumber(1), expectedVersionNumber); @@ -185,10 +185,10 @@ public async Task GivenNoSnapshotRepositoryFactory_WhenCreatingEntityRepository_ // ACT var snapshotRepositoryFactory = serviceScope.ServiceProvider - .GetService>(); + .GetService>(); await using var entityRepository = await serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .CreateRepository("NOT NULL", "NOT NULL"); // ASSERT @@ -212,10 +212,10 @@ public async Task GivenNoSnapshotSessionOptions_WhenCreatingEntityRepository_The // ACT var snapshotRepositoryFactory = serviceScope.ServiceProvider - .GetService>(); + .GetService>(); await using var entityRepository = await serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .CreateRepository("NOT NULL"); // ASSERT @@ -239,10 +239,10 @@ public async Task GivenSnapshotRepositoryFactoryAndSnapshotSessionOptions_WhenCr // ACT var snapshotRepositoryFactory = serviceScope.ServiceProvider - .GetService>(); + .GetService>(); await using var entityRepository = await serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .CreateRepository("NOT NULL", "NOT NULL"); // ASSERT @@ -257,7 +257,7 @@ public async Task GivenSnapshotAndNewCommands_WhenGettingSnapshotOrDefault_ThenR { // ARRANGE - var snapshot = new TransactionEntity(new VersionNumber(1)); + var snapshot = new TestEntity(default, new VersionNumber(1)); var newCommands = new object[] { @@ -274,7 +274,7 @@ public async Task GivenSnapshotAndNewCommands_WhenGettingSnapshotOrDefault_ThenR // ACT await using var entityRepository = await serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .CreateRepository("NOT NULL", "NOT NULL"); var snapshotOrDefault = await entityRepository.GetCurrent(default); @@ -297,7 +297,7 @@ public async Task GivenNonExistentEntityId_WhenGettingCurrentEntity_ThenThrow() }); await using var entityRepository = await serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .CreateRepository(default!); // ASSERT diff --git a/test/EntityDb.Common.Tests/Implementations/Agents/NoAgent.cs b/test/EntityDb.Common.Tests/Implementations/Agents/NoAgent.cs index 03a7e63a..d831cc3f 100644 --- a/test/EntityDb.Common.Tests/Implementations/Agents/NoAgent.cs +++ b/test/EntityDb.Common.Tests/Implementations/Agents/NoAgent.cs @@ -5,11 +5,6 @@ namespace EntityDb.Common.Tests.Implementations.Agents; public class NoAgent : IAgent { - public bool HasRole(string role) - { - return false; - } - public TimeStamp GetTimeStamp() { return TimeStamp.UtcNow; diff --git a/test/EntityDb.Common.Tests/Implementations/Commands/Count.cs b/test/EntityDb.Common.Tests/Implementations/Commands/Count.cs index 62078cf6..16f5d756 100644 --- a/test/EntityDb.Common.Tests/Implementations/Commands/Count.cs +++ b/test/EntityDb.Common.Tests/Implementations/Commands/Count.cs @@ -1,15 +1,24 @@ using EntityDb.Abstractions.Reducers; using EntityDb.Common.Tests.Implementations.Entities; +using EntityDb.Common.Tests.Implementations.Projections; namespace EntityDb.Common.Tests.Implementations.Commands; -public record Count(ulong Number) : IReducer +public record Count(ulong Number) : IReducer, IReducer { - public TransactionEntity Reduce(TransactionEntity entity) + public TestEntity Reduce(TestEntity entity) { return entity with { VersionNumber = entity.VersionNumber.Next() }; } + + public OneToOneProjection Reduce(OneToOneProjection projection) + { + return projection with + { + EntityVersionNumber = projection.EntityVersionNumber.Next() + }; + } } \ No newline at end of file diff --git a/test/EntityDb.Common.Tests/Implementations/Commands/DoNothing.cs b/test/EntityDb.Common.Tests/Implementations/Commands/DoNothing.cs index 07f45da6..22fe303d 100644 --- a/test/EntityDb.Common.Tests/Implementations/Commands/DoNothing.cs +++ b/test/EntityDb.Common.Tests/Implementations/Commands/DoNothing.cs @@ -1,12 +1,18 @@ using EntityDb.Abstractions.Reducers; using EntityDb.Common.Tests.Implementations.Entities; +using EntityDb.Common.Tests.Implementations.Projections; namespace EntityDb.Common.Tests.Implementations.Commands; -public record DoNothing : IReducer +public record DoNothing : IReducer, IReducer { - public TransactionEntity Reduce(TransactionEntity entity) + public TestEntity Reduce(TestEntity entity) { return entity with { VersionNumber = entity.VersionNumber.Next() }; } + + public OneToOneProjection Reduce(OneToOneProjection projection) + { + return projection with { EntityVersionNumber = projection.EntityVersionNumber.Next() }; + } } \ No newline at end of file diff --git a/test/EntityDb.Common.Tests/Implementations/Entities/IEntityWithVersionNumber.cs b/test/EntityDb.Common.Tests/Implementations/Entities/IEntityWithVersionNumber.cs new file mode 100644 index 00000000..aa96e45d --- /dev/null +++ b/test/EntityDb.Common.Tests/Implementations/Entities/IEntityWithVersionNumber.cs @@ -0,0 +1,8 @@ +using EntityDb.Abstractions.ValueObjects; + +namespace EntityDb.Common.Tests.Implementations.Entities; + +public interface IEntityWithVersionNumber +{ + static abstract TEntity Construct(Id id, VersionNumber versionNumber); +} \ No newline at end of file diff --git a/test/EntityDb.Common.Tests/Implementations/Entities/TestEntity.cs b/test/EntityDb.Common.Tests/Implementations/Entities/TestEntity.cs new file mode 100644 index 00000000..68edbb72 --- /dev/null +++ b/test/EntityDb.Common.Tests/Implementations/Entities/TestEntity.cs @@ -0,0 +1,69 @@ +using EntityDb.Common.Entities; +using System; +using System.Threading; +using EntityDb.Abstractions.Reducers; +using EntityDb.Abstractions.ValueObjects; +using EntityDb.Common.Snapshots; +using EntityDb.Common.Tests.Implementations.Snapshots; + +namespace EntityDb.Common.Tests.Implementations.Entities; + +public record TestEntity +( + Id Id, + VersionNumber VersionNumber = default +) +: IEntity, ISnapshot, IEntityWithVersionNumber, ISnapshotWithVersionNumber, ISnapshotWithShouldReplaceLogic +{ + public const string MongoCollectionName = "Test"; + public const string RedisKeyNamespace = "test-entity"; + + public static TestEntity Construct(Id entityId) + { + return new TestEntity(entityId); + } + + public static TestEntity Construct(Id entityId, VersionNumber versionNumber) + { + return new TestEntity(entityId, versionNumber); + } + + public Id GetId() + { + return Id; + } + + public VersionNumber GetVersionNumber() + { + return VersionNumber; + } + + public TestEntity Reduce(object[] commands) + { + var newEntity = this; + + foreach (var command in commands) + { + if (command is not IReducer reducer) + { + throw new NotImplementedException(); + } + + newEntity = reducer.Reduce(newEntity); + } + + return newEntity; + } + + public static AsyncLocal?> ShouldReplaceLogic { get; } = new(); + + public bool ShouldReplace(TestEntity? previousSnapshot) + { + if (ShouldReplaceLogic.Value != null) + { + return ShouldReplaceLogic.Value.Invoke(this, previousSnapshot); + } + + return !Equals(previousSnapshot); + } +} \ No newline at end of file diff --git a/test/EntityDb.Common.Tests/Implementations/Entities/TransactionEntity.cs b/test/EntityDb.Common.Tests/Implementations/Entities/TransactionEntity.cs deleted file mode 100644 index ba9387fd..00000000 --- a/test/EntityDb.Common.Tests/Implementations/Entities/TransactionEntity.cs +++ /dev/null @@ -1,49 +0,0 @@ -using EntityDb.Common.Entities; -using System; -using EntityDb.Abstractions.Reducers; -using EntityDb.Abstractions.ValueObjects; -using EntityDb.Common.Snapshots; - -namespace EntityDb.Common.Tests.Implementations.Entities; - -public record TransactionEntity -( - VersionNumber VersionNumber = default -) -: IEntity, ISnapshot -{ - public const string MongoCollectionName = "Test"; - public const string RedisKeyNamespace = "test"; - - public static TransactionEntity Construct(Id entityId) - { - return new TransactionEntity(); - } - - public VersionNumber GetVersionNumber() - { - return VersionNumber; - } - - public TransactionEntity Reduce(object[] commands) - { - var newEntity = this; - - foreach (var command in commands) - { - if (command is not IReducer reducer) - { - throw new NotImplementedException(); - } - - newEntity = reducer.Reduce(newEntity); - } - - return newEntity; - } - - public bool ShouldReplace(TransactionEntity? previousSnapshot) - { - return true; - } -} \ No newline at end of file diff --git a/test/EntityDb.Common.Tests/Implementations/Projections/OneToOneProjection.cs b/test/EntityDb.Common.Tests/Implementations/Projections/OneToOneProjection.cs new file mode 100644 index 00000000..a41c386b --- /dev/null +++ b/test/EntityDb.Common.Tests/Implementations/Projections/OneToOneProjection.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading; +using EntityDb.Abstractions.Annotations; +using EntityDb.Abstractions.Reducers; +using EntityDb.Abstractions.ValueObjects; +using EntityDb.Common.Projections; +using EntityDb.Common.Snapshots; +using EntityDb.Common.Tests.Implementations.Snapshots; + +namespace EntityDb.Common.Tests.Implementations.Projections; + +public record OneToOneProjection +( + Id Id, + VersionNumber EntityVersionNumber = default +) : IProjection, ISnapshot, ISnapshotWithVersionNumber, ISnapshotWithShouldReplaceLogic +{ + public const string RedisKeyNamespace = "one-to-one-projection"; + + public static OneToOneProjection Construct(Id projectionId) + { + return new OneToOneProjection(projectionId); + } + + public static OneToOneProjection Construct(Id projectionId, VersionNumber versionNumber) + { + return new OneToOneProjection(projectionId, versionNumber); + } + + public VersionNumber GetEntityVersionNumber(Id entityId) + { + return EntityVersionNumber; + } + + public OneToOneProjection Reduce(params IEntityAnnotation[] annotatedCommands) + { + var newProjection = this; + + foreach (var annotatedCommand in annotatedCommands) + { + var command = annotatedCommand.Data; + + if (command is not IReducer reducer) + { + throw new NotImplementedException(); + } + + newProjection = reducer.Reduce(newProjection); + } + + return newProjection; + } + + public static AsyncLocal?> ShouldReplaceLogic { get; } = new(); + + public bool ShouldReplace(OneToOneProjection? previousSnapshot) + { + if (ShouldReplaceLogic.Value != null) + { + return ShouldReplaceLogic.Value.Invoke(this, previousSnapshot); + } + + return !Equals(previousSnapshot); + } +} \ No newline at end of file diff --git a/test/EntityDb.Common.Tests/Implementations/Projections/OneToOneProjectionStrategy.cs b/test/EntityDb.Common.Tests/Implementations/Projections/OneToOneProjectionStrategy.cs new file mode 100644 index 00000000..ea652f9b --- /dev/null +++ b/test/EntityDb.Common.Tests/Implementations/Projections/OneToOneProjectionStrategy.cs @@ -0,0 +1,18 @@ +using System.Threading.Tasks; +using EntityDb.Abstractions.Projections; +using EntityDb.Abstractions.ValueObjects; + +namespace EntityDb.Common.Tests.Implementations.Projections; + +public class SingleEntityProjectionStrategy : IProjectionStrategy +{ + public Task GetEntityIds(Id projectionId, OneToOneProjection projectionSnapshot) + { + return Task.FromResult(new[] { projectionId }); + } + + public Task GetProjectionIds(Id entityId) + { + return Task.FromResult(new[] { entityId }); + } +} \ No newline at end of file diff --git a/test/EntityDb.Common.Tests/Implementations/Seeders/TransactionSeeder.cs b/test/EntityDb.Common.Tests/Implementations/Seeders/TransactionSeeder.cs index fb6a8479..9d68e6bc 100644 --- a/test/EntityDb.Common.Tests/Implementations/Seeders/TransactionSeeder.cs +++ b/test/EntityDb.Common.Tests/Implementations/Seeders/TransactionSeeder.cs @@ -1,12 +1,38 @@ +using System.Collections.Generic; using System.Collections.Immutable; +using System.Linq; using EntityDb.Abstractions.Transactions; using EntityDb.Abstractions.Transactions.Steps; using EntityDb.Abstractions.ValueObjects; +using EntityDb.Common.Entities; using EntityDb.Common.Tests.Implementations.Agents; +using EntityDb.Common.Tests.Implementations.Entities; using EntityDb.Common.Transactions; +using EntityDb.Common.Transactions.Steps; namespace EntityDb.Common.Tests.Implementations.Seeders; +public static class TransactionStepSeeder +{ + public static IEnumerable CreateFromCommands(Id entityId, uint numCommands) + where TEntity : IEntityWithVersionNumber + { + for (var previousVersionNumber = new VersionNumber(0); previousVersionNumber.Value < numCommands; previousVersionNumber = previousVersionNumber.Next()) + { + var entityVersionNumber = previousVersionNumber.Next(); + + yield return new AppendCommandTransactionStep + { + EntityId = entityId, + Entity = TEntity.Construct(entityId, entityVersionNumber), + EntityVersionNumber = entityVersionNumber, + PreviousEntityVersionNumber = previousVersionNumber, + Command = CommandSeeder.Create() + }; + } + } +} + public static class TransactionSeeder { public static ITransaction Create(params ITransactionStep[] transactionSteps) @@ -19,4 +45,12 @@ public static ITransaction Create(params ITransactionStep[] transactionSteps) Steps = transactionSteps.ToImmutableArray() }; } + + public static ITransaction Create(Id entityId, uint numCommands) + where TEntity : IEntityWithVersionNumber + { + var transactionSteps = TransactionStepSeeder.CreateFromCommands(entityId, numCommands).ToArray(); + + return Create(transactionSteps); + } } \ No newline at end of file diff --git a/test/EntityDb.Common.Tests/Implementations/Snapshots/ISnapshotWithShouldReplaceLogic.cs b/test/EntityDb.Common.Tests/Implementations/Snapshots/ISnapshotWithShouldReplaceLogic.cs new file mode 100644 index 00000000..790b4eaf --- /dev/null +++ b/test/EntityDb.Common.Tests/Implementations/Snapshots/ISnapshotWithShouldReplaceLogic.cs @@ -0,0 +1,9 @@ +using System; +using System.Threading; + +namespace EntityDb.Common.Tests.Implementations.Snapshots; + +public interface ISnapshotWithShouldReplaceLogic +{ + static abstract AsyncLocal?> ShouldReplaceLogic { get; } +} \ No newline at end of file diff --git a/test/EntityDb.Common.Tests/Implementations/Snapshots/ISnapshotWithVersionNumber.cs b/test/EntityDb.Common.Tests/Implementations/Snapshots/ISnapshotWithVersionNumber.cs new file mode 100644 index 00000000..95cf626f --- /dev/null +++ b/test/EntityDb.Common.Tests/Implementations/Snapshots/ISnapshotWithVersionNumber.cs @@ -0,0 +1,8 @@ +using EntityDb.Abstractions.ValueObjects; + +namespace EntityDb.Common.Tests.Implementations.Snapshots; + +public interface ISnapshotWithVersionNumber +{ + static abstract TSnapshot Construct(Id id, VersionNumber versionNumber); +} \ No newline at end of file diff --git a/test/EntityDb.Common.Tests/Projections/ProjectionsTests.cs b/test/EntityDb.Common.Tests/Projections/ProjectionsTests.cs new file mode 100644 index 00000000..d682a87e --- /dev/null +++ b/test/EntityDb.Common.Tests/Projections/ProjectionsTests.cs @@ -0,0 +1,202 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using EntityDb.Abstractions.Entities; +using EntityDb.Abstractions.Projections; +using EntityDb.Abstractions.Transactions; +using EntityDb.Abstractions.ValueObjects; +using EntityDb.Common.Entities; +using EntityDb.Common.Projections; +using EntityDb.Common.Tests.Implementations.Entities; +using EntityDb.Common.Tests.Implementations.Projections; +using EntityDb.Common.Tests.Implementations.Seeders; +using EntityDb.Common.Tests.Implementations.Snapshots; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Moq; +using Shouldly; +using Xunit; + +namespace EntityDb.Common.Tests.Projections; + +public class ProjectionsTests : TestsBase +{ + public ProjectionsTests(IServiceProvider startupServiceProvider) : base(startupServiceProvider) + { + } + + private async Task Generic_Given_When_Then(TransactionsAdder transactionsAdder, SnapshotsAdder snapshotsAdder) + where TProjection : IProjection + { + // ARRANGE + + using var serviceScope = CreateServiceScope(serviceCollection => + { + transactionsAdder.Add(serviceCollection); + snapshotsAdder.Add(serviceCollection); + }); + + await using var projectionRepository = await serviceScope.ServiceProvider + .GetRequiredService>() + .CreateRepository(TestSessionOptions.Write, TestSessionOptions.Write); + + // ACT + + // ASSERT + } + + private async Task + Generic_GivenProjectionStrategyReturnsNoEntityIds_WhenGettingProjection_ThenReturnDefaultProjection(TransactionsAdder transactionsAdder, SnapshotsAdder snapshotsAdder) + where TProjection : IProjection + { + // ARRANGE + + var projectionId = Id.NewId(); + var expectedProjection = TProjection.Construct(projectionId); + + var mockProjectionStrategy = new Mock>(); + + mockProjectionStrategy + .Setup(strategy => strategy.GetEntityIds(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + using var serviceScope = CreateServiceScope(serviceCollection => + { + transactionsAdder.Add(serviceCollection); + snapshotsAdder.Add(serviceCollection); + + serviceCollection.RemoveAll(typeof(IProjectionStrategy<>)); + + serviceCollection.AddSingleton(mockProjectionStrategy.Object); + }); + + var projectionStrategy = serviceScope.ServiceProvider + .GetRequiredService>(); + + await using var projectionRepository = await serviceScope.ServiceProvider + .GetRequiredService>() + .CreateRepository(TestSessionOptions.Write, TestSessionOptions.Write); + + // ACT + + var actualEntityIds = await projectionStrategy.GetEntityIds(projectionId, default!); + + var actualProjection = await projectionRepository.GetCurrent(projectionId); + + // ASSERT + + actualEntityIds.ShouldBeEmpty(); + + actualProjection.ShouldBeEquivalentTo(expectedProjection); + } + + private async Task Generic_GivenEmptyTransactionRepository_WhenGettingProjection_ThenReturnDefaultProjection(TransactionsAdder transactionsAdder, SnapshotsAdder snapshotsAdder) + where TProjection : IProjection + { + // ARRANGE + + var projectionId = Id.NewId(); + var expectedProjection = TProjection.Construct(projectionId); + + using var serviceScope = CreateServiceScope(serviceCollection => + { + transactionsAdder.Add(serviceCollection); + snapshotsAdder.Add(serviceCollection); + }); + + await using var projectionRepository = await serviceScope.ServiceProvider + .GetRequiredService>() + .CreateRepository(TestSessionOptions.Write, TestSessionOptions.Write); + + // ACT + + var actualProjection = await projectionRepository.GetCurrent(projectionId); + + // ASSERT + + actualProjection.ShouldBeEquivalentTo(expectedProjection); + } + + + private async Task Generic_GivenTransactionCommitted_WhenGettingProjection_ThenReturnExpectedProjection(TransactionsAdder transactionsAdder, SnapshotsAdder snapshotsAdder) + where TEntity : IEntity, IEntityWithVersionNumber + where TProjection : IProjection, ISnapshotWithShouldReplaceLogic + { + // ARRANGE + + const uint numberOfVersionNumbers = 5; + const uint replaceAtVersionNumber = 3; + + TProjection.ShouldReplaceLogic.Value = (projection, _) => projection.GetEntityVersionNumber(default) == new VersionNumber(replaceAtVersionNumber); + + var projectionId = Id.NewId(); + var transaction = TransactionSeeder.Create(projectionId, numberOfVersionNumbers); + + using var serviceScope = CreateServiceScope(serviceCollection => + { + transactionsAdder.Add(serviceCollection); + snapshotsAdder.Add(serviceCollection); + }); + + await using var entityRepository = await serviceScope.ServiceProvider + .GetRequiredService>() + .CreateRepository(TestSessionOptions.Write); + + await using var projectionRepository = await serviceScope.ServiceProvider + .GetRequiredService>() + .CreateRepository(TestSessionOptions.Write, TestSessionOptions.Write); + + var transactionInserted = await entityRepository.PutTransaction(transaction); + + // ARRANGE ASSERTIONS + + numberOfVersionNumbers.ShouldBeGreaterThan(replaceAtVersionNumber); + + transactionInserted.ShouldBeTrue(); + + // ACT + + var currentProjection = await projectionRepository.GetCurrent(projectionId); + var projectionSnapshot = await projectionRepository.SnapshotRepository.GetSnapshot(projectionId); + + // ASSERT + + currentProjection.GetEntityVersionNumber(default).Value.ShouldBe(numberOfVersionNumbers); + projectionSnapshot.ShouldNotBeNull().GetEntityVersionNumber(default).Value.ShouldBe(replaceAtVersionNumber); + } + + [Theory] + [MemberData(nameof(AddTransactionsAndOneToOneProjectionSnapshots))] + public async Task GivenEmptyTransactionRepository_WhenGettingProjection_ThenReturnDefaultProjection(TransactionsAdder transactionsAdder, SnapshotsAdder snapshotsAdder) + { + await GetType() + .GetMethod(nameof(Generic_GivenEmptyTransactionRepository_WhenGettingProjection_ThenReturnDefaultProjection), ~BindingFlags.Public)! + .MakeGenericMethod(snapshotsAdder.SnapshotType) + .Invoke(this, new object?[] { transactionsAdder, snapshotsAdder }) + .ShouldBeAssignableTo()!; + } + + [Theory] + [MemberData(nameof(AddTransactionsAndOneToOneProjectionSnapshots))] + public async Task GivenProjectionStrategyReturnsNoEntityIds_WhenGettingProjection_ThenReturnDefaultProjection( + TransactionsAdder transactionsAdder, SnapshotsAdder snapshotsAdder) + { + await GetType() + .GetMethod(nameof(Generic_GivenProjectionStrategyReturnsNoEntityIds_WhenGettingProjection_ThenReturnDefaultProjection), ~BindingFlags.Public)! + .MakeGenericMethod(snapshotsAdder.SnapshotType) + .Invoke(this, new object?[] { transactionsAdder, snapshotsAdder }) + .ShouldBeAssignableTo()!; + } + + [Theory] + [MemberData(nameof(AddTransactionsAndOneToOneProjectionSnapshots))] + public async Task GivenTransactionCommitted_WhenGettingProjection_ThenReturnExpectedProjection(TransactionsAdder transactionsAdder, SnapshotsAdder snapshotsAdder) + { + await GetType() + .GetMethod(nameof(Generic_GivenTransactionCommitted_WhenGettingProjection_ThenReturnExpectedProjection), ~BindingFlags.Public)! + .MakeGenericMethod(transactionsAdder.EntityType, snapshotsAdder.SnapshotType) + .Invoke(this, new object?[] { transactionsAdder, snapshotsAdder }) + .ShouldBeAssignableTo()!; + } +} \ No newline at end of file diff --git a/test/EntityDb.Common.Tests/Snapshots/SnapshotTests.cs b/test/EntityDb.Common.Tests/Snapshots/SnapshotTests.cs index 6caca798..b1ec2d9b 100644 --- a/test/EntityDb.Common.Tests/Snapshots/SnapshotTests.cs +++ b/test/EntityDb.Common.Tests/Snapshots/SnapshotTests.cs @@ -1,13 +1,14 @@ using EntityDb.Abstractions.Snapshots; using EntityDb.Common.Exceptions; -using EntityDb.Common.Tests.Implementations.Entities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Moq; using Shouldly; using System; +using System.Reflection; using System.Threading.Tasks; using EntityDb.Abstractions.ValueObjects; +using EntityDb.Common.Tests.Implementations.Snapshots; using Microsoft.Extensions.Logging; using Xunit; @@ -19,9 +20,8 @@ public SnapshotTests(IServiceProvider startupServiceProvider) : base(startupServ { } - [Theory] - [MemberData(nameof(AddSnapshots))] - public async Task GivenEmptySnapshotRepository_WhenGoingThroughFullCycle_ThenOriginalMatchesSnapshot(SnapshotsAdder snapshotsAdder) + private async Task GivenEmptySnapshotRepository_WhenSnapshotInsertedAndFetched_ThenInsertedMatchesFetched(SnapshotsAdder snapshotsAdder) + where TSnapshot : ISnapshotWithVersionNumber { // ARRANGE @@ -30,12 +30,11 @@ public async Task GivenEmptySnapshotRepository_WhenGoingThroughFullCycle_ThenOri snapshotsAdder.Add(serviceCollection); }); - var expectedSnapshot = new TransactionEntity { VersionNumber = new VersionNumber(300) }; - var snapshotId = Id.NewId(); + var expectedSnapshot = TSnapshot.Construct(snapshotId, new VersionNumber(300)); await using var snapshotRepositoryFactory = serviceScope.ServiceProvider - .GetRequiredService>(); + .GetRequiredService>(); await using var snapshotRepository = await snapshotRepositoryFactory .CreateRepository(TestSessionOptions.Write); @@ -54,8 +53,19 @@ public async Task GivenEmptySnapshotRepository_WhenGoingThroughFullCycle_ThenOri } [Theory] - [MemberData(nameof(AddSnapshots))] - public async Task GivenReadOnlyMode_WhenPuttingSnapshot_ThenCannotWriteInReadOnlyModeExceptionIsLogged(SnapshotsAdder snapshotsAdder) + [MemberData(nameof(AddEntitySnapshots))] + [MemberData(nameof(AddOneToOneProjectionSnapshots))] + public async Task GivenSnapshotsAdder_WhenSnapshotInsertedAndFetched_ThenInsertedMatchesFetched(SnapshotsAdder snapshotsAdder) + { + await GetType() + .GetMethod(nameof(GivenEmptySnapshotRepository_WhenSnapshotInsertedAndFetched_ThenInsertedMatchesFetched), ~BindingFlags.Public)! + .MakeGenericMethod(snapshotsAdder.SnapshotType) + .Invoke(this, new object?[] { snapshotsAdder }) + .ShouldBeAssignableTo()!; + } + + private async Task GivenEmptySnapshotRepository_WhenPuttingSnapshotInReadOnlyMode_ThenCannotWriteInReadOnlyModeExceptionIsLogged(SnapshotsAdder snapshotsAdder) + where TSnapshot : ISnapshotWithVersionNumber { // ARRANGE @@ -70,10 +80,10 @@ public async Task GivenReadOnlyMode_WhenPuttingSnapshot_ThenCannotWriteInReadOnl serviceCollection.AddSingleton(loggerFactory); }); - var snapshot = new TransactionEntity(); + var snapshot = TSnapshot.Construct(default, new VersionNumber(1)); await using var snapshotRepository = await serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .CreateRepository(TestSessionOptions.ReadOnly); // ACT @@ -86,16 +96,27 @@ public async Task GivenReadOnlyMode_WhenPuttingSnapshot_ThenCannotWriteInReadOnl loggerVerifier.Invoke(Times.Once()); } - + [Theory] - [MemberData(nameof(AddSnapshots))] - public async Task GivenSnapshotInserted_WhenReadingInVariousReadModes_ThenReturnSameSnapshot(SnapshotsAdder snapshotsAdder) + [MemberData(nameof(AddEntitySnapshots))] + [MemberData(nameof(AddOneToOneProjectionSnapshots))] + public async Task GivenSnapshotsAdder_WhenPuttingSnapshotInReadOnlyMode_ThenCannotWriteInReadOnlyModeExceptionIsLogged(SnapshotsAdder snapshotsAdder) + { + await GetType() + .GetMethod(nameof(GivenEmptySnapshotRepository_WhenPuttingSnapshotInReadOnlyMode_ThenCannotWriteInReadOnlyModeExceptionIsLogged), ~BindingFlags.Public)! + .MakeGenericMethod(snapshotsAdder.SnapshotType) + .Invoke(this, new object?[] { snapshotsAdder }) + .ShouldBeAssignableTo()!; + } + + private async Task GivenInsertedSnapshot_WhenReadInVariousReadModes_ThenReturnSameSnapshot(SnapshotsAdder snapshotsAdder) + where TSnapshot : ISnapshotWithVersionNumber { // ARRANGE var snapshotId = Id.NewId(); - var expectedSnapshot = new TransactionEntity(new VersionNumber(5000)); + var expectedSnapshot = TSnapshot.Construct(default, new VersionNumber(5000)); using var serviceScope = CreateServiceScope(serviceCollection => { @@ -103,15 +124,15 @@ public async Task GivenSnapshotInserted_WhenReadingInVariousReadModes_ThenReturn }); await using var writeSnapshotRepository = await serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .CreateRepository(TestSessionOptions.Write); await using var readOnlySnapshotRepository = await serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .CreateRepository(TestSessionOptions.ReadOnly); await using var readOnlySecondaryPreferredSnapshotRepository = await serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .CreateRepository(TestSessionOptions.ReadOnlySecondaryPreferred); var inserted = await writeSnapshotRepository.PutSnapshot(snapshotId, expectedSnapshot); @@ -131,4 +152,16 @@ public async Task GivenSnapshotInserted_WhenReadingInVariousReadModes_ThenReturn readOnlySnapshot.ShouldBeEquivalentTo(expectedSnapshot); readOnlySecondaryPreferredSnapshot.ShouldBeEquivalentTo(expectedSnapshot); } + + [Theory] + [MemberData(nameof(AddEntitySnapshots))] + [MemberData(nameof(AddOneToOneProjectionSnapshots))] + public async Task GivenSnapshotsAdder_WhenReadingInsertedSnapshotInVariousReadModes_ThenReturnSameSnapshot(SnapshotsAdder snapshotsAdder) + { + await GetType() + .GetMethod(nameof(GivenInsertedSnapshot_WhenReadInVariousReadModes_ThenReturnSameSnapshot), ~BindingFlags.Public)! + .MakeGenericMethod(snapshotsAdder.SnapshotType) + .Invoke(this, new object?[] { snapshotsAdder }) + .ShouldBeAssignableTo()!; + } } \ No newline at end of file diff --git a/test/EntityDb.Common.Tests/Snapshots/TryCatchSnapshotRepositoryTests.cs b/test/EntityDb.Common.Tests/Snapshots/TryCatchSnapshotRepositoryTests.cs index 34489797..192621f0 100644 --- a/test/EntityDb.Common.Tests/Snapshots/TryCatchSnapshotRepositoryTests.cs +++ b/test/EntityDb.Common.Tests/Snapshots/TryCatchSnapshotRepositoryTests.cs @@ -27,14 +27,14 @@ public async Task GivenRepositoryAlwaysThrows_WhenExecutingAnyMethod_ThenExcepti var (loggerFactory, loggerVerifier) = GetMockedLoggerFactory(); - var snapshotRepositoryMock = new Mock>(MockBehavior.Strict); + var snapshotRepositoryMock = new Mock>(MockBehavior.Strict); snapshotRepositoryMock .Setup(repository => repository.GetSnapshot(It.IsAny(), It.IsAny())) .ThrowsAsync(new NotImplementedException()); snapshotRepositoryMock - .Setup(repository => repository.PutSnapshot(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(repository => repository.PutSnapshot(It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new NotImplementedException()); snapshotRepositoryMock @@ -48,7 +48,7 @@ public async Task GivenRepositoryAlwaysThrows_WhenExecutingAnyMethod_ThenExcepti serviceCollection.AddSingleton(loggerFactory); }); - var tryCatchSnapshotRepository = TryCatchSnapshotRepository + var tryCatchSnapshotRepository = TryCatchSnapshotRepository .Create(serviceScope.ServiceProvider, snapshotRepositoryMock.Object); // ACT diff --git a/test/EntityDb.Common.Tests/StartupBase.cs b/test/EntityDb.Common.Tests/StartupBase.cs index e335cec5..1de488b2 100644 --- a/test/EntityDb.Common.Tests/StartupBase.cs +++ b/test/EntityDb.Common.Tests/StartupBase.cs @@ -31,7 +31,7 @@ public virtual void AddServices(IServiceCollection serviceCollection) // Transaction Entity - serviceCollection.AddEntity(); + serviceCollection.AddEntity(); // Snapshot Session Options diff --git a/test/EntityDb.Common.Tests/TestsBase.cs b/test/EntityDb.Common.Tests/TestsBase.cs index bb1cb633..87d2c5c9 100644 --- a/test/EntityDb.Common.Tests/TestsBase.cs +++ b/test/EntityDb.Common.Tests/TestsBase.cs @@ -12,6 +12,8 @@ using System.Threading; using System.Threading.Tasks; using EntityDb.Abstractions.ValueObjects; +using EntityDb.Common.Extensions; +using EntityDb.Common.Tests.Implementations.Projections; using EntityDb.InMemory.Extensions; using EntityDb.MongoDb.Provisioner.Extensions; using EntityDb.Redis.Extensions; @@ -42,7 +44,7 @@ public void Dispose() public delegate void AddTransactionsDelegate(IServiceCollection serviceCollection); - public record TransactionsAdder(string Name, AddTransactionsDelegate AddTransactionsDelegate) + public record TransactionsAdder(string Name, Type EntityType, AddTransactionsDelegate AddTransactionsDelegate) { public void Add(IServiceCollection serviceCollection) { @@ -57,7 +59,7 @@ public override string ToString() public delegate void AddSnapshotsDelegate(IServiceCollection serviceCollection); - public record SnapshotsAdder(string Name, AddSnapshotsDelegate AddSnapshotsDelegate) + public record SnapshotsAdder(string Name, Type SnapshotType, AddSnapshotsDelegate AddSnapshotsDelegate) { public void Add(IServiceCollection serviceCollection) { @@ -83,46 +85,85 @@ protected TestsBase(IServiceProvider startupServiceProvider) private static readonly TransactionsAdder[] AllTransactionsAdders = { - new("MongoDb", serviceCollection => + new("MongoDb", typeof(TestEntity), serviceCollection => { serviceCollection.AddAutoProvisionMongoDbTransactions ( - TransactionEntity.MongoCollectionName, + TestEntity.MongoCollectionName, _ => "mongodb://127.0.0.1:27017/?connect=direct&replicaSet=entitydb", true ); }) }; - private static readonly SnapshotsAdder[] AllSnapshotsAdders = + private static readonly AddSnapshotsDelegate AddEntitySnapshotsSharedResources = serviceCollection => { - new("Redis", serviceCollection => + serviceCollection.AddEntitySnapshotTransactionSubscriber(TestSessionOptions.Write, true); + }; + + private static readonly SnapshotsAdder[] AllEntitySnapshotsAdders = + { + new("Redis", typeof(TestEntity), AddEntitySnapshotsSharedResources + (serviceCollection => { - serviceCollection.AddRedisSnapshots + serviceCollection.AddRedisSnapshots ( - TransactionEntity.RedisKeyNamespace, + TestEntity.RedisKeyNamespace, _ => "127.0.0.1:6379", true ); - }), - new("InMemory", serviceCollection => + })), + new("InMemory", typeof(TestEntity), AddEntitySnapshotsSharedResources + (serviceCollection => { - serviceCollection.AddInMemorySnapshots + serviceCollection.AddInMemorySnapshots ( testMode: true ); - }) + })) + }; + + private static readonly AddSnapshotsDelegate AddOneToOneProjectionSnapshotsSharedResources = serviceCollection => + { + serviceCollection.AddProjection(); + serviceCollection.AddProjectionSnapshotTransactionSubscriber(TestSessionOptions.Write, true); + }; + + private static readonly SnapshotsAdder[] AllOneToOneProjectionSnapshotsAdders = + { + new("Redis", typeof(OneToOneProjection), AddOneToOneProjectionSnapshotsSharedResources + (serviceCollection => + { + serviceCollection.AddRedisSnapshots + ( + OneToOneProjection.RedisKeyNamespace, + _ => "127.0.0.1:6379", + true + ); + })), + new("InMemory", typeof(OneToOneProjection), AddOneToOneProjectionSnapshotsSharedResources + (serviceCollection => + { + serviceCollection.AddInMemorySnapshots + ( + testMode: true + ); + })) }; - public static IEnumerable AddTransactionsAndSnapshots() => + public static IEnumerable AddTransactionsAndEntitySnapshots() => + from transactionsAdder in AllTransactionsAdders + from snapshotsAdder in AllEntitySnapshotsAdders + select new object[] { transactionsAdder, snapshotsAdder }; + + public static IEnumerable AddTransactionsAndOneToOneProjectionSnapshots() => from transactionsAdder in AllTransactionsAdders - from snapshotsAdder in AllSnapshotsAdders + from snapshotsAdder in AllOneToOneProjectionSnapshotsAdders select new object[] { transactionsAdder, snapshotsAdder }; public static IEnumerable AddTransactions() => AllTransactionsAdders .Select(transactionsAdder => new object[] { transactionsAdder }); - public static IEnumerable AddSnapshots() => AllSnapshotsAdders + public static IEnumerable AddEntitySnapshots() => AllEntitySnapshotsAdders + .Select(snapshotsAdder => new object[] { snapshotsAdder }); + + public static IEnumerable AddOneToOneProjectionSnapshots() => AllOneToOneProjectionSnapshotsAdders .Select(snapshotsAdder => new object[] { snapshotsAdder }); protected IServiceScope CreateServiceScope(Action? configureServices = null) @@ -255,12 +296,12 @@ protected static ITransactionRepositoryFactory GetMockedTransactionRepositoryFac return transactionRepositoryFactoryMock.Object; } - protected static ISnapshotRepositoryFactory GetMockedSnapshotRepositoryFactory + protected static ISnapshotRepositoryFactory GetMockedSnapshotRepositoryFactory ( - TransactionEntity? snapshot = null + TestEntity? snapshot = null ) { - var snapshotRepositoryMock = new Mock>(MockBehavior.Strict); + var snapshotRepositoryMock = new Mock>(MockBehavior.Strict); snapshotRepositoryMock .Setup(repository => repository.GetSnapshot(It.IsAny(), It.IsAny())) @@ -270,7 +311,7 @@ protected static ISnapshotRepositoryFactory GetMockedSnapshot .Setup(repository => repository.DisposeAsync()) .Returns(ValueTask.CompletedTask); - var snapshotRepositoryFactoryMock = new Mock>(MockBehavior.Strict); + var snapshotRepositoryFactoryMock = new Mock>(MockBehavior.Strict); snapshotRepositoryFactoryMock .Setup(factory => factory.CreateRepository(It.IsAny(), It.IsAny())) diff --git a/test/EntityDb.Common.Tests/Transactions/EntitySnapshotTransactionSubscriberTests.cs b/test/EntityDb.Common.Tests/Transactions/EntitySnapshotTransactionSubscriberTests.cs new file mode 100644 index 00000000..29da05db --- /dev/null +++ b/test/EntityDb.Common.Tests/Transactions/EntitySnapshotTransactionSubscriberTests.cs @@ -0,0 +1,123 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using EntityDb.Abstractions.ValueObjects; +using EntityDb.Common.Entities; +using EntityDb.Common.Snapshots; +using EntityDb.Common.Tests.Implementations.Entities; +using EntityDb.Common.Tests.Implementations.Seeders; +using EntityDb.Common.Tests.Implementations.Snapshots; +using EntityDb.Common.Transactions; +using Shouldly; +using Xunit; + +namespace EntityDb.Common.Tests.Transactions; + +public class EntitySnapshotTransactionSubscriberTests : TestsBase +{ + public EntitySnapshotTransactionSubscriberTests(IServiceProvider startupServiceProvider) : base(startupServiceProvider) + { + } + + + private async Task + Generic_GivenSnapshotShouldReplaceAlwaysReturnsTrue_WhenRunningEntitySnapshotTransactionSubscriber_ThenAlwaysWriteSnapshot( + TransactionsAdder transactionsAdder, SnapshotsAdder snapshotsAdder) + where TEntity : IEntity, IEntityWithVersionNumber, ISnapshot, ISnapshotWithShouldReplaceLogic + { + // ARRANGE + + TEntity.ShouldReplaceLogic.Value = (_, _) => true; + + using var serviceScope = CreateServiceScope(serviceCollection => + { + transactionsAdder.Add(serviceCollection); + snapshotsAdder.Add(serviceCollection); + }); + + var subscriber = + EntitySnapshotTransactionSubscriber.Create(serviceScope.ServiceProvider, + TestSessionOptions.Write, true); + + var entityId = Id.NewId(); + + const uint numberOfVersionNumbers = 10; + + var transaction = TransactionSeeder.Create(entityId, numberOfVersionNumbers); + + await using var snapshotRepository = await subscriber.CreateSnapshotRepository(); + + // ACT + + subscriber.Notify(transaction); + + var snapshot = await snapshotRepository.GetSnapshot(entityId); + + // ASSERT + + snapshot.ShouldNotBe(default); + snapshot!.GetVersionNumber().Value.ShouldBe(numberOfVersionNumbers); + } + + [Theory] + [MemberData(nameof(AddTransactionsAndEntitySnapshots))] + private async Task + GivenSnapshotShouldReplaceAlwaysReturnsTrue_WhenRunningEntitySnapshotTransactionSubscriber_ThenAlwaysWriteSnapshot( + TransactionsAdder transactionsAdder, SnapshotsAdder snapshotsAdder) + { + await GetType() + .GetMethod(nameof(Generic_GivenSnapshotShouldReplaceAlwaysReturnsTrue_WhenRunningEntitySnapshotTransactionSubscriber_ThenAlwaysWriteSnapshot), ~BindingFlags.Public)! + .MakeGenericMethod(transactionsAdder.EntityType) + .Invoke(this, new object?[] { transactionsAdder, snapshotsAdder }) + .ShouldBeAssignableTo()!; + } + + private async Task + Generic_GivenSnapshotShouldReplaceAlwaysReturnsFalse_WhenRunningEntitySnapshotTransactionSubscriber_ThenNeverWriteSnapshot( + TransactionsAdder transactionsAdder, SnapshotsAdder snapshotsAdder) + where TEntity : IEntity, IEntityWithVersionNumber, ISnapshot, ISnapshotWithShouldReplaceLogic + { + // ARRANGE + + TEntity.ShouldReplaceLogic.Value = (_, _) => false; + + using var serviceScope = CreateServiceScope(serviceCollection => + { + transactionsAdder.Add(serviceCollection); + snapshotsAdder.Add(serviceCollection); + }); + + var subscriber = + EntitySnapshotTransactionSubscriber.Create(serviceScope.ServiceProvider, + TestSessionOptions.Write, true); + + var entityId = Id.NewId(); + + var transaction = TransactionSeeder.Create(entityId, 10); + + await using var snapshotRepository = await subscriber.CreateSnapshotRepository(); + + // ACT + + subscriber.Notify(transaction); + + var snapshot = await snapshotRepository.GetSnapshot(entityId); + + // ASSERT + + snapshot.ShouldBe(default); + } + + [Theory] + [MemberData(nameof(AddTransactionsAndEntitySnapshots))] + private async Task + GivenSnapshotShouldReplaceAlwaysReturnsFalse_WhenRunningEntitySnapshotTransactionSubscriber_ThenNeverWriteSnapshot( + TransactionsAdder transactionsAdder, SnapshotsAdder snapshotsAdder) + { + await GetType() + .GetMethod(nameof(Generic_GivenSnapshotShouldReplaceAlwaysReturnsFalse_WhenRunningEntitySnapshotTransactionSubscriber_ThenNeverWriteSnapshot), ~BindingFlags.Public)! + .MakeGenericMethod(transactionsAdder.EntityType) + .Invoke(this, new object?[] { transactionsAdder, snapshotsAdder }) + .ShouldBeAssignableTo()!; + } +} \ No newline at end of file diff --git a/test/EntityDb.Common.Tests/Transactions/SingleEntityTransactionBuilderTests.cs b/test/EntityDb.Common.Tests/Transactions/SingleEntityTransactionBuilderTests.cs index 4d6e1e56..b0010316 100644 --- a/test/EntityDb.Common.Tests/Transactions/SingleEntityTransactionBuilderTests.cs +++ b/test/EntityDb.Common.Tests/Transactions/SingleEntityTransactionBuilderTests.cs @@ -29,7 +29,7 @@ public void GivenEntityNotKnown_WhenGettingEntity_ThenThrow() using var serviceScope = CreateServiceScope(); var transactionBuilder = serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .ForSingleEntity(default); // ASSERT @@ -48,11 +48,11 @@ public void GivenEntityKnown_WhenGettingEntity_ThenReturnExpectedEntity() var expectedEntityId = Id.NewId(); - var expectedEntity = TransactionEntity + var expectedEntity = TestEntity .Construct(expectedEntityId); var transactionBuilder = serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .ForSingleEntity(expectedEntityId); transactionBuilder.Load(expectedEntity); @@ -80,7 +80,7 @@ public void GivenLeasingStrategy_WhenBuildingNewEntityWithLease_ThenTransactionD using var serviceScope = CreateServiceScope(); var transactionBuilder = serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .ForSingleEntity(default); // ACT @@ -113,11 +113,11 @@ public async Task GivenExistingEntityId_WhenUsingEntityIdForLoadTwice_ThenLoadTh }); var transactionBuilder = serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .ForSingleEntity(entityId); await using var entityRepository = await serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .CreateRepository(default!); var entity = await entityRepository.GetCurrent(entityId); @@ -150,7 +150,7 @@ public void GivenNonExistingEntityId_WhenUsingValidVersioningStrategy_ThenVersio }); var transactionBuilder = serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .ForSingleEntity(entityId); // ACT @@ -188,11 +188,11 @@ public async Task GivenExistingEntity_WhenAppendingNewCommand_ThenTransactionBui }); var transactionBuilder = serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .ForSingleEntity(entityId); await using var entityRepository = await serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .CreateRepository(default!); var entity = await entityRepository.GetCurrent(entityId); diff --git a/test/EntityDb.Common.Tests/Transactions/TransactionTests.cs b/test/EntityDb.Common.Tests/Transactions/TransactionTests.cs index b7f99a12..6fed0183 100644 --- a/test/EntityDb.Common.Tests/Transactions/TransactionTests.cs +++ b/test/EntityDb.Common.Tests/Transactions/TransactionTests.cs @@ -415,7 +415,7 @@ private static ITransaction BuildTransaction ) { var transactionBuilder = serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .ForSingleEntity(entityId); foreach (var count in counts) @@ -473,7 +473,7 @@ public async Task GivenReadOnlyMode_WhenPuttingTransaction_ThenCannotWriteInRead }); var transaction = serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .ForSingleEntity(default) .Append(CommandSeeder.Create()) .Build(default!, default); @@ -508,13 +508,13 @@ public async Task GivenNonUniqueTransactionIds_WhenPuttingTransactions_ThenSecon var firstTransaction = serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .ForSingleEntity(default) .Append(CommandSeeder.Create()) .Build(default!, transactionId); var secondTransaction = serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .ForSingleEntity(default) .Append(CommandSeeder.Create()) .Build(default!, transactionId); @@ -547,7 +547,7 @@ public async Task GivenNonUniqueVersionNumbers_WhenInsertingCommands_ThenReturnF }); var transaction = (serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .ForSingleEntity(default) .Append(CommandSeeder.Create()) .Build(default!, default) @@ -650,13 +650,13 @@ public async Task var entityId = Id.NewId(); var firstTransaction = serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .ForSingleEntity(entityId) .Append(CommandSeeder.Create()) .Build(default!, Id.NewId()); var secondTransaction = serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .ForSingleEntity(entityId) .Append(CommandSeeder.Create()) .Build(default!, Id.NewId()); @@ -705,7 +705,7 @@ public async Task GivenNonUniqueTags_WhenInsertingTagDocuments_ThenReturnTrue(Tr var tag = TagSeeder.Create(); var transaction = serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .ForSingleEntity(default) .Add(tag) .Add(tag) @@ -737,7 +737,7 @@ public async Task GivenNonUniqueLeases_WhenInsertingLeaseDocuments_ThenReturnFal var lease = LeaseSeeder.Create(); var transaction = serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .ForSingleEntity(default) .Add(lease) .Add(lease) @@ -826,14 +826,13 @@ public async Task GivenEntityInserted_WhenGettingEntity_ThenReturnEntity(Transac transactionsAdder.Add(serviceCollection); }); - var expectedEntity = new TransactionEntity(new VersionNumber(1)); - var entityId = Id.NewId(); + var expectedEntity = new TestEntity(entityId, new VersionNumber(1)); await using var transactionRepository = await serviceScope.ServiceProvider .GetRequiredService().CreateRepository(TestSessionOptions.Write); - var entityRepository = EntityRepository.Create(serviceScope.ServiceProvider, transactionRepository); + var entityRepository = EntityRepository.Create(serviceScope.ServiceProvider, transactionRepository); var transaction = BuildTransaction(serviceScope, Id.NewId(), entityId, new[] { 0UL }); @@ -867,7 +866,7 @@ public async Task GivenEntityInsertedWithTags_WhenRemovingAllTags_ThenFinalEntit var entityId = Id.NewId(); var transactionBuilder = serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .ForSingleEntity(entityId); var tag = new Tag("Foo", "Bar"); @@ -925,7 +924,7 @@ public async Task GivenEntityInsertedWithLeases_WhenRemovingAllLeases_ThenFinalE var entityId = Id.NewId(); var transactionBuilder = serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .ForSingleEntity(entityId); var lease = new Lease("Foo", "Bar", "Baz"); @@ -983,7 +982,7 @@ public async Task GivenTransactionCreatesEntity_WhenQueryingForVersionOne_ThenRe var expectedCommand = new Count(1); var transactionBuilder = serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .ForSingleEntity(default); var transaction = transactionBuilder @@ -1032,7 +1031,7 @@ public async Task var expectedCommand = new Count(2); var transactionBuilder = serviceScope.ServiceProvider - .GetRequiredService>() + .GetRequiredService>() .ForSingleEntity(default); var firstTransaction = transactionBuilder