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