Skip to content

Commit

Permalink
Feat: EntityFramework Snapshots (#108)
Browse files Browse the repository at this point in the history
* bugfix: don't publish transaction if it wasn't committed to the database

* feat: EntityFramework snapshots

* test: EntityFramework snapshots

* fix: remove test code

* refactor: provide a dbContext which handles the boilerplate

* fix(test): update tests to match new structure

* refactor: use ValueConverter for the ValueObjects

* refactor: make choosing table names mandatory

* refactor: use ValueObjects instead of Raw Values

to keep usage consistent

* refactor: don't require a named DbSet property

prevents using one DbContext for multiple snapshots (and therefore prevents related snapshots)

* refactor: use real transactions for entity framework snapshots in test mode

* refactor: update snapshot reference if one already exists

* chore: coverage for TimeStamp converter

* chore: exclude ToString overrides from coverage

* chore: exclude functioning CommitTransaction from tests

Tests should be running in TestMode, and should not actually commit data.

* chore: remove unused method

* fix: swap order of methods

* fix: working delete method

* Update global.json

* chore: include debug logs

* refactor: SnapshotReference _has one_ Snapshot instead of _owns_ snapshot

* nit: explicit OnDelete behavior

* fix: IEntityFrameworkSnapshot belongs in its own file, and in the Snapshots namespace

* refactor: better snapshot cleanup

1. delete snapshot when all references deleted
2. delete snapshot when all references are changed to point to a different snapshot
3. provide an option to opt-out of cleanup behavior

* refactor: force a constructor instead of using IDbContextFactory

this allows for a single DbContext to have multiple connection configurations (e.g., read-only connection string)

* fix: somehow this wasn't on this branch originally

* refactor: rename bits and bobs

IEntityDbContext, EntityDbContextBase

does not explicitly depend on involving snapshots

* refactor: IEntityDbContextFactory

allow the end user to create DbContexts using just session option names

* Empty Commit
  • Loading branch information
the-avid-engineer authored Aug 30, 2023
1 parent b8cddec commit 1f686b7
Show file tree
Hide file tree
Showing 45 changed files with 1,670 additions and 157 deletions.
9 changes: 8 additions & 1 deletion EntityDb.sln
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EntityDb.SqlDb", "src\Entit
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EntityDb.Json", "src\EntityDb.Json\EntityDb.Json.csproj", "{4936FFE0-98E5-43A2-89C9-0415A13CAA9B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityDb.Provisioner", "src\EntityDb.Provisioner\EntityDb.Provisioner.csproj", "{26FCDB9D-0DE3-4BB9-858D-3E2C3EF763E3}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EntityDb.Provisioner", "src\EntityDb.Provisioner\EntityDb.Provisioner.csproj", "{26FCDB9D-0DE3-4BB9-858D-3E2C3EF763E3}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EntityDb.EntityFramework", "src\EntityDb.EntityFramework\EntityDb.EntityFramework.csproj", "{199606BF-6283-4684-A224-4DA7E80D8F45}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Expand Down Expand Up @@ -115,6 +117,10 @@ Global
{26FCDB9D-0DE3-4BB9-858D-3E2C3EF763E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{26FCDB9D-0DE3-4BB9-858D-3E2C3EF763E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{26FCDB9D-0DE3-4BB9-858D-3E2C3EF763E3}.Release|Any CPU.Build.0 = Release|Any CPU
{199606BF-6283-4684-A224-4DA7E80D8F45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{199606BF-6283-4684-A224-4DA7E80D8F45}.Debug|Any CPU.Build.0 = Debug|Any CPU
{199606BF-6283-4684-A224-4DA7E80D8F45}.Release|Any CPU.ActiveCfg = Release|Any CPU
{199606BF-6283-4684-A224-4DA7E80D8F45}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -135,6 +141,7 @@ Global
{F2491666-31D1-47B5-A493-F25E167D1FDF} = {ABACFBCC-B59F-4616-B6CC-99C37AEC8960}
{4936FFE0-98E5-43A2-89C9-0415A13CAA9B} = {ABACFBCC-B59F-4616-B6CC-99C37AEC8960}
{26FCDB9D-0DE3-4BB9-858D-3E2C3EF763E3} = {ABACFBCC-B59F-4616-B6CC-99C37AEC8960}
{199606BF-6283-4684-A224-4DA7E80D8F45} = {ABACFBCC-B59F-4616-B6CC-99C37AEC8960}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E9D288EE-9351-4018-ABE8-B0968AEB0465}
Expand Down
2 changes: 1 addition & 1 deletion global.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "7.0.100",
"version": "7.0.201",
"allowPrerelease": false,
"rollForward": "disable"
}
Expand Down
1 change: 1 addition & 0 deletions src/EntityDb.Common/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// src
[assembly: InternalsVisibleTo("EntityDb.SqlDb")]
[assembly: InternalsVisibleTo("EntityDb.Npgsql")]
[assembly: InternalsVisibleTo("EntityDb.EntityFramework")]
[assembly: InternalsVisibleTo("EntityDb.InMemory")]
[assembly: InternalsVisibleTo("EntityDb.MongoDb")]
[assembly: InternalsVisibleTo("EntityDb.Provisioner")]
Expand Down
15 changes: 15 additions & 0 deletions src/EntityDb.EntityFramework/Converters/IdConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using EntityDb.Abstractions.ValueObjects;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using System.Linq.Expressions;

namespace EntityDb.EntityFramework.Converters;

internal class IdConverter : ValueConverter<Id, Guid>
{
private static readonly Expression<Func<Id, Guid>> IdToGuid = (id) => id.Value;
private static readonly Expression<Func<Guid, Id>> GuidToId = (guid) => new Id(guid);

public IdConverter() : base(IdToGuid, GuidToId, null)
{
}
}
15 changes: 15 additions & 0 deletions src/EntityDb.EntityFramework/Converters/TimeStampConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using EntityDb.Abstractions.ValueObjects;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using System.Linq.Expressions;

namespace EntityDb.EntityFramework.Converters;

internal class TimeStampConverter : ValueConverter<TimeStamp, DateTime>
{
private static readonly Expression<Func<TimeStamp, DateTime>> TimeStampToDateTime = (timeStamp) => timeStamp.Value;
private static readonly Expression<Func<DateTime, TimeStamp>> DateTimeToTimeStamp = (dateTime) => new TimeStamp(dateTime);

public TimeStampConverter() : base(TimeStampToDateTime, DateTimeToTimeStamp, null)
{
}
}
15 changes: 15 additions & 0 deletions src/EntityDb.EntityFramework/Converters/VersionNumberConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using EntityDb.Abstractions.ValueObjects;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using System.Linq.Expressions;

namespace EntityDb.EntityFramework.Converters;

internal class VersionNumberConverter : ValueConverter<VersionNumber, ulong>
{
private static readonly Expression<Func<VersionNumber, ulong>> VersionNumberToUlong = (versionNumber) => versionNumber.Value;
private static readonly Expression<Func<ulong, VersionNumber>> UlongToVersionNumber = (@ulong) => new VersionNumber(@ulong);

public VersionNumberConverter() : base(VersionNumberToUlong, UlongToVersionNumber, null)
{
}
}
27 changes: 27 additions & 0 deletions src/EntityDb.EntityFramework/DbContexts/EntityDbContextBase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using EntityDb.Abstractions.ValueObjects;
using EntityDb.EntityFramework.Converters;
using Microsoft.EntityFrameworkCore;

namespace EntityDb.EntityFramework.DbContexts;

/// <summary>
/// A DbContext that adds basic converters for types defined in <see cref="Abstractions.ValueObjects"/>
/// </summary>
public abstract class EntityDbContextBase : DbContext
{
/// <inheritdoc />
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder
.Properties<Id>()
.HaveConversion<IdConverter>();

configurationBuilder
.Properties<VersionNumber>()
.HaveConversion<VersionNumberConverter>();

configurationBuilder
.Properties<TimeStamp>()
.HaveConversion<TimeStampConverter>();
}
}
26 changes: 26 additions & 0 deletions src/EntityDb.EntityFramework/DbContexts/EntityDbContextFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using EntityDb.EntityFramework.Sessions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;

namespace EntityDb.EntityFramework.DbContexts;

internal class EntityDbContextFactory<TDbContext> : IEntityDbContextFactory<TDbContext>
where TDbContext : DbContext, IEntityDbContext<TDbContext>
{
private readonly IOptionsFactory<EntityFrameworkSnapshotSessionOptions> _optionsFactory;

public EntityDbContextFactory(IOptionsFactory<EntityFrameworkSnapshotSessionOptions> optionsFactory)
{
_optionsFactory = optionsFactory;
}

public TDbContext Create(string snapshotSessionOptionsName)
{
return TDbContext.Construct(_optionsFactory.Create(snapshotSessionOptionsName));
}

TDbContext IEntityDbContextFactory<TDbContext>.Create(EntityFrameworkSnapshotSessionOptions snapshotSessionOptions)
{
return TDbContext.Construct(snapshotSessionOptions);
}
}
19 changes: 19 additions & 0 deletions src/EntityDb.EntityFramework/DbContexts/IEntityDbContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using EntityDb.EntityFramework.Sessions;
using Microsoft.EntityFrameworkCore;

namespace EntityDb.EntityFramework.DbContexts;

/// <summary>
/// A type of a <see cref="DbContext"/> that can be used for EntityDb purposes.
/// </summary>
/// <typeparam name="TDbContext">The type of the <see cref="DbContext"/></typeparam>
public interface IEntityDbContext<TDbContext>
where TDbContext : DbContext, IEntityDbContext<TDbContext>
{
/// <summary>
/// Returns a new <typeparamref name="TDbContext"/> that will be configured using <paramref name="entityFrameworkSnapshotSessionOptions"/>.
/// </summary>
/// <param name="entityFrameworkSnapshotSessionOptions">The options for the database</param>
/// <returns>A new <typeparamref name="TDbContext"/> that will be configured using <paramref name="entityFrameworkSnapshotSessionOptions"/>.</returns>
static abstract TDbContext Construct(EntityFrameworkSnapshotSessionOptions entityFrameworkSnapshotSessionOptions);
}
20 changes: 20 additions & 0 deletions src/EntityDb.EntityFramework/DbContexts/IEntityDbContextFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using EntityDb.EntityFramework.Sessions;
using Microsoft.EntityFrameworkCore;

namespace EntityDb.EntityFramework.DbContexts;

/// <summary>
/// Represents a type used to create instances of <typeparamref name="TDbContext"/>.
/// </summary>
/// <typeparam name="TDbContext">The type of the <see cref="DbContext"/>.</typeparam>
public interface IEntityDbContextFactory<TDbContext>
{
internal TDbContext Create(EntityFrameworkSnapshotSessionOptions snapshotSessionOptions);

/// <summary>
/// Create a new instance of <typeparamref name="TDbContext"/>.
/// </summary>
/// <param name="snapshotSessionOptionsName">The agent's use case for the <see cref="DbContext"/>.</param>
/// <returns>A new instance of <typeparamref name="TDbContext"/>.</returns>
TDbContext Create(string snapshotSessionOptionsName);
}
17 changes: 17 additions & 0 deletions src/EntityDb.EntityFramework/EntityDb.EntityFramework.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">

<!--Pack-->
<PropertyGroup>
<PackageTags>EntityDb EventSourcing DDD CQRS</PackageTags>
<Description>An implementation of the EntityDb Snapshot Repository interface, specifically for Entity Framework.</Description>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="7.0.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\EntityDb.Common\EntityDb.Common.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using EntityDb.Abstractions.Snapshots;
using EntityDb.Common.Extensions;
using EntityDb.EntityFramework.DbContexts;
using EntityDb.EntityFramework.Snapshots;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System.Diagnostics.CodeAnalysis;

namespace EntityDb.EntityFramework.Extensions;

/// <summary>
/// Extensions for service collections.
/// </summary>
[ExcludeFromCodeCoverage(Justification = "Don't need coverage for non-test mode.")]
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds a production-ready implementation of <see cref="ISnapshotRepositoryFactory{TSnapshot}" /> to a service
/// collection.
/// </summary>
/// <typeparam name="TSnapshot">The type of the snapshot stored in the repository.</typeparam>
/// <typeparam name="TDbContext">The type of the snapshot stored in the repository.</typeparam>
/// <param name="serviceCollection">The service collection.</param>
/// <param name="testMode">Modifies the behavior of the repository to accomodate tests.</param>
public static void AddEntityFrameworkSnapshots<TSnapshot, TDbContext>(this IServiceCollection serviceCollection, bool testMode = false)
where TSnapshot : class, IEntityFrameworkSnapshot<TSnapshot>
where TDbContext : DbContext, IEntityDbContext<TDbContext>
{
serviceCollection.Add<IEntityDbContextFactory<TDbContext>, EntityDbContextFactory<TDbContext>>
(
testMode ? ServiceLifetime.Singleton : ServiceLifetime.Transient
);

serviceCollection.Add<EntityFrameworkSnapshotRepositoryFactory<TSnapshot, TDbContext>>
(
testMode ? ServiceLifetime.Singleton : ServiceLifetime.Transient
);

serviceCollection.Add<ISnapshotRepositoryFactory<TSnapshot>>
(
testMode ? ServiceLifetime.Singleton : ServiceLifetime.Transient,
serviceProvider => serviceProvider
.GetRequiredService<EntityFrameworkSnapshotRepositoryFactory<TSnapshot, TDbContext>>()
.UseTestMode(serviceProvider, testMode)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using EntityDb.EntityFramework.Snapshots;
using System.Diagnostics.CodeAnalysis;

namespace EntityDb.EntityFramework.Extensions;

internal static class EntityFrameworkSnapshotRepositoryFactoryExtensions
{
[ExcludeFromCodeCoverage(Justification = "Tests are only meant to run in test mode.")]
public static IEntityFrameworkSnapshotRepositoryFactory<TSnapshot> UseTestMode<TSnapshot>(
this IEntityFrameworkSnapshotRepositoryFactory<TSnapshot> entityFrameworkSnapshotRepositoyFactory,
IServiceProvider serviceProvider,
bool testMode)
{
return testMode
? TestModeEntityFrameworkSnapshotRepositoryFactory<TSnapshot>.Create(serviceProvider, entityFrameworkSnapshotRepositoyFactory)
: entityFrameworkSnapshotRepositoyFactory;
}
}
41 changes: 41 additions & 0 deletions src/EntityDb.EntityFramework/Predicates/PredicateBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using EntityDb.EntityFramework.Snapshots;
using System.Linq.Expressions;

namespace EntityDb.EntityFramework.Predicates;

/// <summary>
/// Based on http://www.albahari.com/nutshell/predicatebuilder.aspx
/// </summary>
internal static class PredicateExpressionBuilder
{
private static Expression<Func<T, bool>> False<T>()
{
return _ => false;
}

private static Expression<Func<T, bool>> Or<T>
(
Expression<Func<T, bool>> left,
Expression<Func<T, bool>> right
)
{
return Expression.Lambda<Func<T, bool>>
(
Expression.OrElse
(
left.Body,
Expression.Invoke(right, left.Parameters)
),
left.Parameters
);
}

public static Expression<Func<T, bool>> Or<I, T>
(
IEnumerable<I> inputs,
Func<I, Expression<Func<T, bool>>> mapper
)
{
return inputs.Aggregate(False<T>(), (predicate, input) => Or(predicate, mapper.Invoke(input)));
}
}
Loading

0 comments on commit 1f686b7

Please sign in to comment.