From d8c0e8305202efa2c0c2f1689ee85d55b8b7357b Mon Sep 17 00:00:00 2001 From: Marc Wittke Date: Wed, 8 Jun 2022 12:33:57 -0300 Subject: [PATCH 1/2] separate library for EfCore6 --- Backend.Fx.sln | 14 + .../AggregateMapping.cs | 15 + .../Backend.Fx.EfCore6Persistence.csproj | 36 ++ .../DbContextTransactionOperationDecorator.cs | 25 ++ .../Bootstrapping/EfCorePersistenceModule.cs | 73 ++++ .../Bootstrapping/IDbConnectionFactory.cs | 9 + .../DbContextExtensions.cs | 87 +++++ .../Backend.Fx.EfCore6Persistence/EfFlush.cs | 194 ++++++++++ .../EfRepository.cs | 145 +++++++ .../EntityQueryable.cs | 51 +++ .../IAggregateMapping.cs | 18 + .../Mssql/MsSqlSequence.cs | 73 ++++ .../Oracle/OracleSequence.cs | 90 +++++ .../PlainAggregateMapping.cs | 18 + .../Postgres/PostgresSequence.cs | 78 ++++ .../Properties/AssemblyInfo.cs | 6 + ...Backend.Fx.EfCore6Persistence.Tests.csproj | 37 ++ .../DbConnectionEx.cs | 41 ++ .../DummyImpl/Domain/Blog.cs | 37 ++ .../DummyImpl/Domain/BlogAuthorization.cs | 10 + .../DummyImpl/Domain/Blogger.cs | 23 ++ .../DummyImpl/Domain/Post.cs | 48 +++ .../DummyImpl/Persistence/BlogMapping.cs | 27 ++ .../DummyImpl/Persistence/BloggerMapping.cs | 8 + .../DummyImpl/Persistence/TestDbContext.cs | 28 ++ .../Persistence/TestDbContextFactory.cs | 17 + .../Fixtures/DatabaseFixture.cs | 47 +++ .../Fixtures/SqlServerDatabaseFixture.cs | 69 ++++ .../Fixtures/SqliteDatabaseFixture.cs | 33 ++ .../Fixtures/TestDbSession.cs | 40 ++ .../20190624150947_Initial.Designer.cs | 155 ++++++++ .../Migrations/20190624150947_Initial.cs | 105 ++++++ .../Migrations/TestDbContextModelSnapshot.cs | 152 ++++++++ .../TestConfig.cs | 17 + .../TheDbContext.cs | 61 +++ .../TheRepositoryOfComposedAggregate.cs | 353 ++++++++++++++++++ .../TheRepositoryOfPlainAggregate.cs | 191 ++++++++++ 37 files changed, 2431 insertions(+) create mode 100644 src/implementations/Backend.Fx.EfCore6Persistence/AggregateMapping.cs create mode 100644 src/implementations/Backend.Fx.EfCore6Persistence/Backend.Fx.EfCore6Persistence.csproj create mode 100644 src/implementations/Backend.Fx.EfCore6Persistence/Bootstrapping/DbContextTransactionOperationDecorator.cs create mode 100644 src/implementations/Backend.Fx.EfCore6Persistence/Bootstrapping/EfCorePersistenceModule.cs create mode 100644 src/implementations/Backend.Fx.EfCore6Persistence/Bootstrapping/IDbConnectionFactory.cs create mode 100644 src/implementations/Backend.Fx.EfCore6Persistence/DbContextExtensions.cs create mode 100644 src/implementations/Backend.Fx.EfCore6Persistence/EfFlush.cs create mode 100644 src/implementations/Backend.Fx.EfCore6Persistence/EfRepository.cs create mode 100644 src/implementations/Backend.Fx.EfCore6Persistence/EntityQueryable.cs create mode 100644 src/implementations/Backend.Fx.EfCore6Persistence/IAggregateMapping.cs create mode 100644 src/implementations/Backend.Fx.EfCore6Persistence/Mssql/MsSqlSequence.cs create mode 100644 src/implementations/Backend.Fx.EfCore6Persistence/Oracle/OracleSequence.cs create mode 100644 src/implementations/Backend.Fx.EfCore6Persistence/PlainAggregateMapping.cs create mode 100644 src/implementations/Backend.Fx.EfCore6Persistence/Postgres/PostgresSequence.cs create mode 100644 src/implementations/Backend.Fx.EfCore6Persistence/Properties/AssemblyInfo.cs create mode 100644 tests/Backend.Fx.EfCore6Persistence.Tests/Backend.Fx.EfCore6Persistence.Tests.csproj create mode 100644 tests/Backend.Fx.EfCore6Persistence.Tests/DbConnectionEx.cs create mode 100644 tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Domain/Blog.cs create mode 100644 tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Domain/BlogAuthorization.cs create mode 100644 tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Domain/Blogger.cs create mode 100644 tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Domain/Post.cs create mode 100644 tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Persistence/BlogMapping.cs create mode 100644 tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Persistence/BloggerMapping.cs create mode 100644 tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Persistence/TestDbContext.cs create mode 100644 tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Persistence/TestDbContextFactory.cs create mode 100644 tests/Backend.Fx.EfCore6Persistence.Tests/Fixtures/DatabaseFixture.cs create mode 100644 tests/Backend.Fx.EfCore6Persistence.Tests/Fixtures/SqlServerDatabaseFixture.cs create mode 100644 tests/Backend.Fx.EfCore6Persistence.Tests/Fixtures/SqliteDatabaseFixture.cs create mode 100644 tests/Backend.Fx.EfCore6Persistence.Tests/Fixtures/TestDbSession.cs create mode 100644 tests/Backend.Fx.EfCore6Persistence.Tests/Migrations/20190624150947_Initial.Designer.cs create mode 100644 tests/Backend.Fx.EfCore6Persistence.Tests/Migrations/20190624150947_Initial.cs create mode 100644 tests/Backend.Fx.EfCore6Persistence.Tests/Migrations/TestDbContextModelSnapshot.cs create mode 100644 tests/Backend.Fx.EfCore6Persistence.Tests/TestConfig.cs create mode 100644 tests/Backend.Fx.EfCore6Persistence.Tests/TheDbContext.cs create mode 100644 tests/Backend.Fx.EfCore6Persistence.Tests/TheRepositoryOfComposedAggregate.cs create mode 100644 tests/Backend.Fx.EfCore6Persistence.Tests/TheRepositoryOfPlainAggregate.cs diff --git a/Backend.Fx.sln b/Backend.Fx.sln index 8f1cedbe..8353da85 100644 --- a/Backend.Fx.sln +++ b/Backend.Fx.sln @@ -54,6 +54,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "messagebus", "messagebus", EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dependencyinjection", "dependencyinjection", "{22E4DE95-C3E5-49E6-83BF-BF30905A746B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Backend.Fx.EfCore6Persistence", "src\implementations\Backend.Fx.EfCore6Persistence\Backend.Fx.EfCore6Persistence.csproj", "{38034961-CE3B-4286-A9EB-496DECA39632}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Backend.Fx.EfCore6Persistence.Tests", "tests\Backend.Fx.EfCore6Persistence.Tests\Backend.Fx.EfCore6Persistence.Tests.csproj", "{E50D7E8D-D012-4683-BA05-C877BAA25230}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -107,6 +111,14 @@ Global {DF40E1E8-FB19-455E-9CED-212C544AA8BC}.Debug|Any CPU.Build.0 = Debug|Any CPU {DF40E1E8-FB19-455E-9CED-212C544AA8BC}.Release|Any CPU.ActiveCfg = Release|Any CPU {DF40E1E8-FB19-455E-9CED-212C544AA8BC}.Release|Any CPU.Build.0 = Release|Any CPU + {38034961-CE3B-4286-A9EB-496DECA39632}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {38034961-CE3B-4286-A9EB-496DECA39632}.Debug|Any CPU.Build.0 = Debug|Any CPU + {38034961-CE3B-4286-A9EB-496DECA39632}.Release|Any CPU.ActiveCfg = Release|Any CPU + {38034961-CE3B-4286-A9EB-496DECA39632}.Release|Any CPU.Build.0 = Release|Any CPU + {E50D7E8D-D012-4683-BA05-C877BAA25230}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E50D7E8D-D012-4683-BA05-C877BAA25230}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E50D7E8D-D012-4683-BA05-C877BAA25230}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E50D7E8D-D012-4683-BA05-C877BAA25230}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -131,6 +143,8 @@ Global {2C826FC0-443A-4874-B213-C35BFDEA200A} = {8BC1C02F-0785-4161-BC37-7D462BD6F42D} {22E4DE95-C3E5-49E6-83BF-BF30905A746B} = {739A7296-579F-4D9A-BC73-DCECD260D7A0} {FF042FB5-BA44-4655-8903-2644FE549810} = {22E4DE95-C3E5-49E6-83BF-BF30905A746B} + {38034961-CE3B-4286-A9EB-496DECA39632} = {ADC35CAD-F5B1-42B6-A0CC-B96974C11F11} + {E50D7E8D-D012-4683-BA05-C877BAA25230} = {C7885592-A4B8-4BA8-8D3A-1EDA4025D17A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {45648557-C751-44AD-9C87-0F12EB673969} diff --git a/src/implementations/Backend.Fx.EfCore6Persistence/AggregateMapping.cs b/src/implementations/Backend.Fx.EfCore6Persistence/AggregateMapping.cs new file mode 100644 index 00000000..a7ce812b --- /dev/null +++ b/src/implementations/Backend.Fx.EfCore6Persistence/AggregateMapping.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using Backend.Fx.BuildingBlocks; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Fx.EfCorePersistence +{ + public abstract class AggregateMapping : IAggregateMapping where T : AggregateRoot + { + public abstract IEnumerable>> IncludeDefinitions { get; } + + public abstract void ApplyEfMapping(ModelBuilder modelBuilder); + } +} \ No newline at end of file diff --git a/src/implementations/Backend.Fx.EfCore6Persistence/Backend.Fx.EfCore6Persistence.csproj b/src/implementations/Backend.Fx.EfCore6Persistence/Backend.Fx.EfCore6Persistence.csproj new file mode 100644 index 00000000..f8dc3d71 --- /dev/null +++ b/src/implementations/Backend.Fx.EfCore6Persistence/Backend.Fx.EfCore6Persistence.csproj @@ -0,0 +1,36 @@ + + + + net6.0 + true + snupkg + false + false + false + false + + + + Marc Wittke + anic GmbH + All rights reserved. Distributed under the terms of the MIT License. + Persistence implementation for Backend.Fx using Entity Framework Core 6 + False + MIT + https://github.com/marcwittke/Backend.Fx + Backend.Fx + Git + https://github.com/marcwittke/Backend.Fx.git + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/implementations/Backend.Fx.EfCore6Persistence/Bootstrapping/DbContextTransactionOperationDecorator.cs b/src/implementations/Backend.Fx.EfCore6Persistence/Bootstrapping/DbContextTransactionOperationDecorator.cs new file mode 100644 index 00000000..6dedd0be --- /dev/null +++ b/src/implementations/Backend.Fx.EfCore6Persistence/Bootstrapping/DbContextTransactionOperationDecorator.cs @@ -0,0 +1,25 @@ +using System.Data; +using System.Data.Common; +using Backend.Fx.Environment.Persistence; +using Backend.Fx.Patterns.DependencyInjection; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Fx.EfCorePersistence.Bootstrapping +{ + public class DbContextTransactionOperationDecorator : DbTransactionOperationDecorator + { + private readonly DbContext _dbContext; + + public DbContextTransactionOperationDecorator(DbContext dbContext, IDbConnection dbConnection, IOperation operation) + : base(dbConnection, operation) + { + _dbContext = dbContext; + } + + public override void Begin() + { + base.Begin(); + _dbContext.Database.UseTransaction((DbTransaction) CurrentTransaction); + } + } +} \ No newline at end of file diff --git a/src/implementations/Backend.Fx.EfCore6Persistence/Bootstrapping/EfCorePersistenceModule.cs b/src/implementations/Backend.Fx.EfCore6Persistence/Bootstrapping/EfCorePersistenceModule.cs new file mode 100644 index 00000000..c8db9817 --- /dev/null +++ b/src/implementations/Backend.Fx.EfCore6Persistence/Bootstrapping/EfCorePersistenceModule.cs @@ -0,0 +1,73 @@ +using System; +using System.Data; +using System.Linq; +using System.Reflection; +using Backend.Fx.BuildingBlocks; +using Backend.Fx.Environment.Persistence; +using Backend.Fx.Patterns.DependencyInjection; +using Backend.Fx.Patterns.EventAggregation.Domain; +using Backend.Fx.Patterns.IdGeneration; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Backend.Fx.EfCorePersistence.Bootstrapping +{ + public class EfCorePersistenceModule : IModule + where TDbContext : DbContext + { + private readonly ILoggerFactory _loggerFactory; + private readonly Action, IDbConnection> _configure; + private readonly IDbConnectionFactory _dbConnectionFactory; + private readonly IEntityIdGenerator _entityIdGenerator; + private readonly Assembly[] _assemblies; + + public EfCorePersistenceModule(IDbConnectionFactory dbConnectionFactory, IEntityIdGenerator entityIdGenerator, + ILoggerFactory loggerFactory, Action, IDbConnection> configure, params Assembly[] assemblies) + { + _dbConnectionFactory = dbConnectionFactory; + _entityIdGenerator = entityIdGenerator; + _loggerFactory = loggerFactory; + _configure = configure; + _assemblies = assemblies; + } + + public void Register(ICompositionRoot compositionRoot) + { + // by letting the container create the connection we can be sure, that only one connection per scope is used, and disposing is done accordingly + compositionRoot.InfrastructureModule.RegisterScoped(() => _dbConnectionFactory.Create()); + + // singleton id generator + compositionRoot.InfrastructureModule.RegisterInstance(_entityIdGenerator); + + // EF core requires us to flush frequently, because of a missing identity map + compositionRoot.InfrastructureModule.RegisterScoped(); + + // EF Repositories + compositionRoot.InfrastructureModule.RegisterScoped(typeof(IRepository<>), typeof(EfRepository<>)); + + // IQueryable is supported, but should be use with caution, since it bypasses authorization + compositionRoot.InfrastructureModule.RegisterScoped(typeof(IQueryable<>), typeof(EntityQueryable<>)); + + // DbContext is injected into repositories + compositionRoot.InfrastructureModule.RegisterScoped(() => CreateDbContextOptions(compositionRoot.InstanceProvider.GetInstance())); + compositionRoot.InfrastructureModule.RegisterScoped(); + + // wrapping the operation: connection.open - transaction.begin - operation - (flush) - transaction.commit - connection.close + compositionRoot.InfrastructureModule.RegisterDecorator(); + compositionRoot.InfrastructureModule.RegisterDecorator(); + compositionRoot.InfrastructureModule.RegisterDecorator(); + + // ensure everything dirty is flushed to the db before handling domain events + compositionRoot.InfrastructureModule.RegisterDecorator(); + + compositionRoot.InfrastructureModule.RegisterScoped(typeof(IAggregateMapping<>), _assemblies); + } + + protected virtual DbContextOptions CreateDbContextOptions(IDbConnection connection) + { + var dbContextOptionsBuilder = new DbContextOptionsBuilder(); + _configure.Invoke(dbContextOptionsBuilder, connection); + return dbContextOptionsBuilder.UseLoggerFactory(_loggerFactory).Options; + } + } +} \ No newline at end of file diff --git a/src/implementations/Backend.Fx.EfCore6Persistence/Bootstrapping/IDbConnectionFactory.cs b/src/implementations/Backend.Fx.EfCore6Persistence/Bootstrapping/IDbConnectionFactory.cs new file mode 100644 index 00000000..df953115 --- /dev/null +++ b/src/implementations/Backend.Fx.EfCore6Persistence/Bootstrapping/IDbConnectionFactory.cs @@ -0,0 +1,9 @@ +using System.Data; + +namespace Backend.Fx.EfCorePersistence.Bootstrapping +{ + public interface IDbConnectionFactory + { + IDbConnection Create(); + } +} \ No newline at end of file diff --git a/src/implementations/Backend.Fx.EfCore6Persistence/DbContextExtensions.cs b/src/implementations/Backend.Fx.EfCore6Persistence/DbContextExtensions.cs new file mode 100644 index 00000000..35668e25 --- /dev/null +++ b/src/implementations/Backend.Fx.EfCore6Persistence/DbContextExtensions.cs @@ -0,0 +1,87 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Reflection; +using Backend.Fx.BuildingBlocks; +using Backend.Fx.Extensions; +using Backend.Fx.Logging; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.Extensions.Logging; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace Backend.Fx.EfCorePersistence +{ + public static class DbContextExtensions + { + private static readonly ILogger Logger = Log.Create(typeof(DbContextExtensions)); + + public static void DisableChangeTracking(this DbContext dbContext) + { + Logger.LogDebug("Disabling change tracking on {DbContextTypeName} instance", dbContext.GetType().Name); + dbContext.ChangeTracker.AutoDetectChangesEnabled = false; + dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + } + + public static void RegisterRowVersionProperty(this ModelBuilder modelBuilder) + { + modelBuilder.Model + .GetEntityTypes() + .Where(mt => typeof(Entity).GetTypeInfo().IsAssignableFrom(mt.ClrType.GetTypeInfo())) + .ForAll(mt => modelBuilder.Entity(mt.ClrType).Property("RowVersion").IsRowVersion()); + } + + public static void RegisterEntityIdAsNeverGenerated(this ModelBuilder modelBuilder) + { + modelBuilder.Model + .GetEntityTypes() + .Where(mt => typeof(Entity).GetTypeInfo().IsAssignableFrom(mt.ClrType.GetTypeInfo())) + .ForAll(mt => modelBuilder.Entity(mt.ClrType).Property(nameof(Entity.Id)).ValueGeneratedNever()); + } + + public static void ApplyAggregateMappings(this DbContext dbContext, ModelBuilder modelBuilder) + { + //CAVE: IAggregateMapping implementations must reside in the same assembly as the Applications DbContext-type + var aggregateDefinitionTypeInfos = dbContext + .GetType() + .GetTypeInfo() + .Assembly + .ExportedTypes + .Select(t => t.GetTypeInfo()) + .Where(t => t.IsClass && !t.IsAbstract && !t.IsGenericType && typeof(IAggregateMapping).GetTypeInfo().IsAssignableFrom(t)); + foreach (TypeInfo typeInfo in aggregateDefinitionTypeInfos) + { + var aggregateMapping = (IAggregateMapping) Activator.CreateInstance(typeInfo.AsType()); + aggregateMapping.ApplyEfMapping(modelBuilder); + } + } + + + + public static void TraceChangeTrackerState(this DbContext dbContext) + { + if (Logger.IsEnabled(LogLevel.Trace)) + try + { + var changeTrackerState = new + { + added = dbContext.ChangeTracker.Entries().Where(entry => entry.State == EntityState.Added).ToArray(), + modified = dbContext.ChangeTracker.Entries().Where(entry => entry.State == EntityState.Modified).ToArray(), + deleted = dbContext.ChangeTracker.Entries().Where(entry => entry.State == EntityState.Deleted).ToArray(), + unchanged = dbContext.ChangeTracker.Entries().Where(entry => entry.State == EntityState.Unchanged).ToArray() + }; + + Logger.LogTrace("Change tracker state: {@ChangeTrackerState}", changeTrackerState); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Change tracker state could not be dumped"); + } + } + + private static string GetPrimaryKeyValue(EntityEntry entry) + { + return (entry.Entity as Entity)?.Id.ToString(CultureInfo.InvariantCulture) ?? "?"; + } + } +} \ No newline at end of file diff --git a/src/implementations/Backend.Fx.EfCore6Persistence/EfFlush.cs b/src/implementations/Backend.Fx.EfCore6Persistence/EfFlush.cs new file mode 100644 index 00000000..c9b25368 --- /dev/null +++ b/src/implementations/Backend.Fx.EfCore6Persistence/EfFlush.cs @@ -0,0 +1,194 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Security.Principal; +using Backend.Fx.BuildingBlocks; +using Backend.Fx.Environment.DateAndTime; +using Backend.Fx.Environment.Persistence; +using Backend.Fx.Exceptions; +using Backend.Fx.Extensions; +using Backend.Fx.Logging; +using Backend.Fx.Patterns.DependencyInjection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.Logging; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace Backend.Fx.EfCorePersistence +{ + public class EfFlush : ICanFlush + { + private static readonly ILogger Logger = Log.Create(); + public DbContext DbContext { get; } + public ICurrentTHolder IdentityHolder { get; } + public IClock Clock { get; } + + public EfFlush(DbContext dbContext, ICurrentTHolder identityHolder, IClock clock) + { + DbContext = dbContext; + Logger.LogInformation("Disabling auto detect changes on this DbContext. Changes will be detected explicitly when flushing"); + DbContext.ChangeTracker.AutoDetectChangesEnabled = false; + IdentityHolder = identityHolder; + Clock = clock; + } + + public void Flush() + { + DetectChanges(); + UpdateTrackingProperties(); + DbContext.TraceChangeTrackerState(); + CheckForMissingTenantIds(); + SaveChanges(); + } + + private void DetectChanges() + { + using (Logger.LogDebugDuration("Detecting changes")) + { + DbContext.ChangeTracker.DetectChanges(); + } + } + + private void UpdateTrackingProperties() + { + using (Logger.LogDebugDuration("Updating tracking properties of created and modified entities")) + { + UpdateTrackingProperties(IdentityHolder.Current.Name, Clock.UtcNow); + } + } + + private void CheckForMissingTenantIds() + { + using (Logger.LogDebugDuration("Checking for missing tenant ids")) + { + AggregateRoot[] aggregatesWithoutTenantId = DbContext + .ChangeTracker + .Entries() + .Where(e => e.State == EntityState.Added) + .Select(e => e.Entity) + .OfType() + .Where(ent => ent.TenantId == 0) + .ToArray(); + if (aggregatesWithoutTenantId.Length > 0) + { + throw new InvalidOperationException( + $"Attempt to save aggregate root entities without tenant id: {string.Join(",", aggregatesWithoutTenantId.Select(agg => agg.DebuggerDisplay))}"); + } + } + } + + private void SaveChanges() + { + using (Logger.LogDebugDuration("Saving changes")) + { + try + { + DbContext.SaveChanges(); + } + catch (DbUpdateConcurrencyException concurrencyException) + { + throw new ConflictedException("Saving changes failed due to optimistic concurrency violation", concurrencyException); + } + } + } + + private void UpdateTrackingProperties(string identity, DateTime utcNow) + { + identity ??= "anonymous"; + var isTraceEnabled = Logger.IsEnabled(LogLevel.Trace); + var count = 0; + + // Modifying an entity (also removing an entity from an aggregate) should leave the aggregate root as modified + DbContext.ChangeTracker + .Entries() + .Where(entry => entry.State == EntityState.Added || entry.State == EntityState.Modified || entry.State == EntityState.Deleted) + .Where(entry => !(entry.Entity is AggregateRoot)) + .ToArray() + .ForAll(entry => + { + EntityEntry aggregateRootEntry = GetAggregateRootEntry(DbContext.ChangeTracker, entry); + if (aggregateRootEntry.State == EntityState.Unchanged) aggregateRootEntry.State = EntityState.Modified; + }); + + DbContext.ChangeTracker + .Entries() + .Where(entry => entry.State == EntityState.Added || entry.State == EntityState.Modified) + .ForAll(entry => + { + try + { + count++; + Entity entity = entry.Entity; + + if (entry.State == EntityState.Added) + { + if (isTraceEnabled) Logger.LogTrace("tracking that {EntityTypeName}[{Id}] was created by {Identity} at {UtcNow}", entity.GetType().Name, entity.Id, identity, utcNow); + entity.SetCreatedProperties(identity, utcNow); + } + else if (entry.State == EntityState.Modified) + { + if (isTraceEnabled) Logger.LogTrace("tracking that {EntityTypeName}[{Id}] was modified by {Identity} at {UtcNow}", entity.GetType().Name, entity.Id, identity, utcNow); + entity.SetModifiedProperties(identity, utcNow); + + // this line causes the recent changes of tracking properties to be detected before flushing + entry.State = EntityState.Modified; + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Updating tracking properties failed"); + throw; + } + }); + if (count > 0) Logger.LogDebug("Tracked {EntityCount} entities as created/changed on {UtcNow} by {Identity}", count, utcNow, identity); + } + + /// + /// This method finds the EntityEntry<AggregateRoot> of an EntityEntry<Entity> + /// assuming it has been loaded and is being tracked by the change tracker. + /// + [return: NotNull] + private static EntityEntry GetAggregateRootEntry(ChangeTracker changeTracker, EntityEntry entry) + { + Logger.LogDebug("Searching aggregate root of {EntityTypeName}[{Id}]", entry.Entity.GetType().Name, (entry.Entity as Identified)?.Id); + foreach (NavigationEntry navigation in entry.Navigations) + { + TypeInfo navTargetTypeInfo = navigation.Metadata.TargetEntityType.ClrType.GetTypeInfo(); + int navigationTargetForeignKeyValue; + + if (navigation.CurrentValue == null) + { + var navigationMetadata = ((INavigation)navigation.Metadata); + // orphaned entity, original value contains the foreign key value + if (navigationMetadata.ForeignKey.Properties.Count > 1) throw new InvalidOperationException("Foreign Keys with multiple properties are not supported."); + + IProperty property = navigationMetadata.ForeignKey.Properties[0]; + navigationTargetForeignKeyValue = (int) entry.OriginalValues[property]; + } + else + { + // added or modified entity, current value contains the foreign key value + navigationTargetForeignKeyValue = ((Entity) navigation.CurrentValue).Id; + } + + // assumption: an entity cannot be loaded on its own. Everything on the navigation path starting from the + // aggregate root must have been loaded before, therefore we can find it using the change tracker + var navigationTargetEntry = changeTracker + .Entries() + .Single(ent => Equals(ent.Entity.GetType().GetTypeInfo(), navTargetTypeInfo) + && ent.Property(nameof(Entity.Id)).CurrentValue.Equals(navigationTargetForeignKeyValue)); + + // if the target is AggregateRoot, no (further) recursion is needed + if (typeof(AggregateRoot).GetTypeInfo().IsAssignableFrom(navTargetTypeInfo)) return navigationTargetEntry; + + // recurse in case of "Entity -> Entity -> AggregateRoot" + Logger.LogDebug("Recursing..."); + return GetAggregateRootEntry(changeTracker, navigationTargetEntry); + } + + throw new InvalidOperationException($"Could not find aggregate root of {entry.Entity.GetType().Name}[{(entry.Entity as Identified)?.Id}]"); + } + } +} \ No newline at end of file diff --git a/src/implementations/Backend.Fx.EfCore6Persistence/EfRepository.cs b/src/implementations/Backend.Fx.EfCore6Persistence/EfRepository.cs new file mode 100644 index 00000000..03258217 --- /dev/null +++ b/src/implementations/Backend.Fx.EfCore6Persistence/EfRepository.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Backend.Fx.BuildingBlocks; +using Backend.Fx.Environment.MultiTenancy; +using Backend.Fx.Exceptions; +using Backend.Fx.Logging; +using Backend.Fx.Patterns.Authorization; +using Backend.Fx.Patterns.DependencyInjection; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Logging; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace Backend.Fx.EfCorePersistence +{ + public class EfRepository : Repository, IAsyncRepository where TAggregateRoot : AggregateRoot + { + private static readonly ILogger Logger = Log.Create>(); + private readonly IAggregateAuthorization _aggregateAuthorization; + private readonly IAggregateMapping _aggregateMapping; + private DbContext _dbContext; + + [SuppressMessage("ReSharper", "EF1001")] + public EfRepository([CanBeNull] DbContext dbContext, IAggregateMapping aggregateMapping, + ICurrentTHolder currentTenantIdHolder, IAggregateAuthorization aggregateAuthorization) + : base(currentTenantIdHolder, aggregateAuthorization) + { + _dbContext = dbContext; + _aggregateMapping = aggregateMapping; + _aggregateAuthorization = aggregateAuthorization; + + // somewhat a hack: using the internal EF Core services against advice + var localViewListener = dbContext?.GetService(); + localViewListener?.RegisterView(AuthorizeChanges); + } + + [SuppressMessage("ReSharper", "EF1001")] + public DbContext DbContext + { + get => _dbContext ?? throw new InvalidOperationException( + "This EfRepository does not have a DbContext yet. You might either make sure a proper DbContext gets injected or the DbContext is initialized later using a derived class") + ; + protected set + { + if (_dbContext != null) throw new InvalidOperationException("This EfRepository has already a DbContext assigned. It is not allowed to change it later."); + _dbContext = value; + var localViewListener = _dbContext?.GetService(); + localViewListener?.RegisterView(AuthorizeChanges); + } + } + + public async Task SingleAsync(int id, CancellationToken cancellationToken = default) + { + return await AggregateQueryable.SingleAsync(agg => agg.Id == id, cancellationToken); + } + + public async Task SingleOrDefaultAsync(int id, CancellationToken cancellationToken = default) + { + return await AggregateQueryable.SingleOrDefaultAsync(agg => agg.Id == id, cancellationToken); + } + + public async Task GetAllAsync(CancellationToken cancellationToken = default) + { + return await AggregateQueryable.ToArrayAsync(cancellationToken); + } + + public async Task AnyAsync(CancellationToken cancellationToken = default) + { + return await AggregateQueryable.AnyAsync(cancellationToken); + } + + public async Task ResolveAsync(IEnumerable ids, CancellationToken cancellationToken = default) + { + if (ids == null) + { + return Array.Empty(); + } + + int[] idsToResolve = ids as int[] ?? ids.ToArray(); + TAggregateRoot[] resolved = await AggregateQueryable.Where(agg => idsToResolve.Contains(agg.Id)).ToArrayAsync(cancellationToken); + if (resolved.Length != idsToResolve.Length) + { + throw new ArgumentException($"The following {AggregateTypeName} ids could not be resolved: {string.Join(", ", idsToResolve.Except(resolved.Select(agg => agg.Id)))}"); + } + return resolved; + } + + protected override IQueryable RawAggregateQueryable + { + get + { + IQueryable queryable = DbContext.Set(); + if (_aggregateMapping.IncludeDefinitions != null) + foreach (var include in _aggregateMapping.IncludeDefinitions) + queryable = queryable.Include(include); + return queryable; + } + } + + /// + /// Due to the fact, that a real lifecycle hook API is not yet available (see issue https://github.com/aspnet/EntityFrameworkCore/issues/626) + /// we are using an internal service to achieve the same goal: When a state change occurs from unchanged to modified, and this repository is + /// handling this type of aggregate, we're calling IAggregateAuthorization.CanModify to enforce user privilege checking. + /// We're accepting the possible instability of EF Core internals due to the fact that there is a full API feature in the pipeline that will + /// make this workaround obsolete. Also, not much of an effort was done to write this code, so if we have to deal with this issue in the future + /// again, we do not loose a lot. + /// + /// + /// + [SuppressMessage("ReSharper", "EF1001")] + private void AuthorizeChanges(InternalEntityEntry entry, EntityState previousState) + { + if (previousState == EntityState.Unchanged && entry.EntityState == EntityState.Modified && entry.EntityType.ClrType == typeof(TAggregateRoot)) + { + var aggregateRoot = (TAggregateRoot) entry.Entity; + if (!_aggregateAuthorization.CanModify(aggregateRoot)) throw new ForbiddenException("Unauthorized attempt to modify {AggregateTypeName}[{aggregateRoot.Id}]") + .AddError($"You are not allowed to modify {AggregateTypeName}[{aggregateRoot.Id}]"); + } + } + + protected override void AddPersistent(TAggregateRoot aggregateRoot) + { + Logger.LogDebug("Persistently adding new {AggregateTypeName}", AggregateTypeName); + DbContext.Set().Add(aggregateRoot); + } + + protected override void AddRangePersistent(TAggregateRoot[] aggregateRoots) + { + Logger.LogDebug("Persistently adding {Count} item(s) of type {AggregateTypeName}", aggregateRoots.Length, AggregateTypeName); + DbContext.Set().AddRange(aggregateRoots); + } + + protected override void DeletePersistent(TAggregateRoot aggregateRoot) + { + Logger.LogDebug("Persistently removing {AggregateTypeName}[{Id}]", AggregateTypeName, aggregateRoot.Id); + DbContext.Set().Remove(aggregateRoot); + } + } +} \ No newline at end of file diff --git a/src/implementations/Backend.Fx.EfCore6Persistence/EntityQueryable.cs b/src/implementations/Backend.Fx.EfCore6Persistence/EntityQueryable.cs new file mode 100644 index 00000000..48d4a959 --- /dev/null +++ b/src/implementations/Backend.Fx.EfCore6Persistence/EntityQueryable.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using Backend.Fx.BuildingBlocks; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Fx.EfCorePersistence +{ + public class EntityQueryable : IQueryable where TEntity : Entity + { + [CanBeNull] private DbContext _dbContext; + + public EntityQueryable(DbContext dbContext) + { + _dbContext = dbContext; + } + + public DbContext DbContext + { + get => _dbContext ?? throw new InvalidOperationException( + "This EntityQueryable does not have a DbContext yet. You might either make sure a proper DbContext gets injected or the DbContext is initialized later using a derived class") + ; + protected set + { + if (_dbContext != null) throw new InvalidOperationException("This EntityQueryable has already a DbContext assigned. It is not allowed to change it later."); + _dbContext = value; + } + } + + private IQueryable InnerQueryable => DbContext.Set(); + + public IEnumerator GetEnumerator() + { + return InnerQueryable.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable) InnerQueryable).GetEnumerator(); + } + + public Type ElementType => InnerQueryable.ElementType; + + public Expression Expression => InnerQueryable.Expression; + + public IQueryProvider Provider => InnerQueryable.Provider; + } +} \ No newline at end of file diff --git a/src/implementations/Backend.Fx.EfCore6Persistence/IAggregateMapping.cs b/src/implementations/Backend.Fx.EfCore6Persistence/IAggregateMapping.cs new file mode 100644 index 00000000..76dd9fd5 --- /dev/null +++ b/src/implementations/Backend.Fx.EfCore6Persistence/IAggregateMapping.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using Backend.Fx.BuildingBlocks; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Fx.EfCorePersistence +{ + public interface IAggregateMapping + { + void ApplyEfMapping(ModelBuilder modelBuilder); + } + + public interface IAggregateMapping : IAggregateMapping where T : AggregateRoot + { + IEnumerable>> IncludeDefinitions { get; } + } +} \ No newline at end of file diff --git a/src/implementations/Backend.Fx.EfCore6Persistence/Mssql/MsSqlSequence.cs b/src/implementations/Backend.Fx.EfCore6Persistence/Mssql/MsSqlSequence.cs new file mode 100644 index 00000000..078d172d --- /dev/null +++ b/src/implementations/Backend.Fx.EfCore6Persistence/Mssql/MsSqlSequence.cs @@ -0,0 +1,73 @@ +using System; +using System.Data; +using Backend.Fx.EfCorePersistence.Bootstrapping; +using Backend.Fx.Logging; +using Backend.Fx.Patterns.IdGeneration; +using Microsoft.Extensions.Logging; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace Backend.Fx.EfCorePersistence.Mssql +{ + public abstract class MsSqlSequence : ISequence + { + private static readonly ILogger Logger = Log.Create(); + private readonly IDbConnectionFactory _dbConnectionFactory; + + protected MsSqlSequence(IDbConnectionFactory dbConnectionFactory) + { + _dbConnectionFactory = dbConnectionFactory; + } + + protected abstract string SequenceName { get; } + protected virtual string SchemaName { get; } = "dbo"; + + public void EnsureSequence() + { + Logger.LogInformation("Ensuring existence of mssql sequence {SchemaName}.{SequenceName}", SchemaName, SequenceName); + using (IDbConnection dbConnection = _dbConnectionFactory.Create()) + { + dbConnection.Open(); + bool sequenceExists; + using (IDbCommand cmd = dbConnection.CreateCommand()) + { + cmd.CommandText = $"SELECT count(*) FROM sys.sequences seq join sys.schemas s on s.schema_id = seq.schema_id WHERE seq.name = '{SequenceName}' and s.name = '{SchemaName}'"; + sequenceExists = (int) cmd.ExecuteScalar() == 1; + } + + if (sequenceExists) + { + Logger.LogInformation("Sequence {SchemaName}.{SequenceName} exists", SchemaName, SequenceName); + } + else + { + Logger.LogInformation("Sequence {SchemaName}.{SequenceName} does not exist yet and will be created now", SchemaName, SequenceName); + using (IDbCommand cmd = dbConnection.CreateCommand()) + { + cmd.CommandText = $"CREATE SEQUENCE [{SchemaName}].[{SequenceName}] START WITH 1 INCREMENT BY {Increment}"; + cmd.ExecuteNonQuery(); + Logger.LogInformation("Sequence {SchemaName}.{SequenceName} created", SchemaName, SequenceName); + } + } + } + } + + public int GetNextValue() + { + using (IDbConnection dbConnection = _dbConnectionFactory.Create()) + { + dbConnection.Open(); + int nextValue; + using (IDbCommand selectNextValCommand = dbConnection.CreateCommand()) + { + selectNextValCommand.CommandText = $"SELECT next value FOR {SchemaName}.{SequenceName}"; + nextValue = Convert.ToInt32(selectNextValCommand.ExecuteScalar()); + Logger.LogDebug("{SchemaName}.{SequenceName} served {NextValue} as next value", SchemaName, SequenceName, nextValue); + } + + return nextValue; + } + } + + public abstract int Increment { get; } + } +} \ No newline at end of file diff --git a/src/implementations/Backend.Fx.EfCore6Persistence/Oracle/OracleSequence.cs b/src/implementations/Backend.Fx.EfCore6Persistence/Oracle/OracleSequence.cs new file mode 100644 index 00000000..7aa7e248 --- /dev/null +++ b/src/implementations/Backend.Fx.EfCore6Persistence/Oracle/OracleSequence.cs @@ -0,0 +1,90 @@ +using System; +using System.Data; +using Backend.Fx.EfCorePersistence.Bootstrapping; +using Backend.Fx.Logging; +using Backend.Fx.Patterns.IdGeneration; +using Microsoft.Extensions.Logging; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace Backend.Fx.EfCorePersistence.Oracle +{ + public abstract class OracleSequence : ISequence + { + private static readonly ILogger Logger = Log.Create(); + private readonly IDbConnectionFactory _dbConnectionFactory; + + protected OracleSequence(IDbConnectionFactory dbConnectionFactory) + { + _dbConnectionFactory = dbConnectionFactory; + } + + protected abstract string SequenceName { get; } + protected abstract string SchemaName { get; } + + private string SchemaPrefix + { + get + { + if (string.IsNullOrEmpty(SchemaName)) return string.Empty; + + return SchemaName + "."; + } + } + + public void EnsureSequence() + { + Logger.LogInformation("Ensuring existence of oracle sequence {SchemaPrefix}.{SequenceName}", SchemaPrefix, SequenceName); + + using (IDbConnection dbConnection = _dbConnectionFactory.Create()) + { + dbConnection.Open(); + bool sequenceExists; + using (IDbCommand command = dbConnection.CreateCommand()) + { + command.CommandText = $"SELECT count(*) FROM user_sequences WHERE sequence_name = '{SequenceName}'"; + sequenceExists = (decimal)command.ExecuteScalar() == 1; + } + + if (sequenceExists) + { + Logger.LogInformation("Oracle sequence {SchemaPrefix}.{SequenceName} exists", SchemaPrefix, SequenceName); + } + else + { + Logger.LogInformation("Oracle sequence {SchemaPrefix}.{SequenceName} does not exist yet and will be created now", + SchemaPrefix, + SequenceName); + using (IDbCommand cmd = dbConnection.CreateCommand()) + { + cmd.CommandText = $"CREATE SEQUENCE {SchemaPrefix}{SequenceName} START WITH 1 INCREMENT BY {Increment}"; + cmd.ExecuteNonQuery(); + Logger.LogInformation("Oracle sequence {SchemaPrefix}.{SequenceName} created", SchemaPrefix, SequenceName); + } + } + } + } + + public int GetNextValue() + { + using (IDbConnection dbConnection = _dbConnectionFactory.Create()) + { + dbConnection.Open(); + + int nextValue; + using (IDbCommand command = dbConnection.CreateCommand()) + { + command.CommandText = $"SELECT {SchemaPrefix}{SequenceName}.NEXTVAL FROM dual"; + nextValue = Convert.ToInt32(command.ExecuteScalar()); + Logger.LogDebug("Oracle sequence {SchemaPrefix}.{SequenceName} served {NextValue} as next value", + SchemaPrefix, + SequenceName, + nextValue); + } + + return nextValue; + } + } + + public abstract int Increment { get; } + } +} \ No newline at end of file diff --git a/src/implementations/Backend.Fx.EfCore6Persistence/PlainAggregateMapping.cs b/src/implementations/Backend.Fx.EfCore6Persistence/PlainAggregateMapping.cs new file mode 100644 index 00000000..2bf4efec --- /dev/null +++ b/src/implementations/Backend.Fx.EfCore6Persistence/PlainAggregateMapping.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using Backend.Fx.BuildingBlocks; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Fx.EfCorePersistence +{ + public class PlainAggregateMapping : AggregateMapping + where TAggregateRoot : AggregateRoot + { + public override IEnumerable>> IncludeDefinitions => new Expression>[0]; + + public override void ApplyEfMapping(ModelBuilder modelBuilder) + { + } + } +} \ No newline at end of file diff --git a/src/implementations/Backend.Fx.EfCore6Persistence/Postgres/PostgresSequence.cs b/src/implementations/Backend.Fx.EfCore6Persistence/Postgres/PostgresSequence.cs new file mode 100644 index 00000000..83e54535 --- /dev/null +++ b/src/implementations/Backend.Fx.EfCore6Persistence/Postgres/PostgresSequence.cs @@ -0,0 +1,78 @@ +using System; +using System.Data; +using Backend.Fx.EfCorePersistence.Bootstrapping; +using Backend.Fx.Logging; +using Backend.Fx.Patterns.IdGeneration; +using Microsoft.Extensions.Logging; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace Backend.Fx.EfCorePersistence.Postgres +{ + public abstract class PostgresSequence : ISequence + { + private static readonly ILogger Logger = Log.Create(); + private readonly IDbConnectionFactory _dbConnectionFactory; + + protected PostgresSequence(IDbConnectionFactory dbConnectionFactory) + { + _dbConnectionFactory = dbConnectionFactory; + } + + protected abstract string SequenceName { get; } + protected abstract string SchemaName { get; } + + public void EnsureSequence() + { + Logger.LogInformation("Ensuring existence of postgres sequence {SchemaName}.{SequenceName}", SchemaName, SequenceName); + + using (IDbConnection dbConnection = _dbConnectionFactory.Create()) + { + dbConnection.Open(); + bool sequenceExists; + using (IDbCommand command = dbConnection.CreateCommand()) + { + command.CommandText = $"SELECT count(*) FROM information_schema.sequences WHERE sequence_name = '{SequenceName}' AND sequence_schema = '{SchemaName}'"; + sequenceExists = (long) command.ExecuteScalar() == 1L; + } + + if (sequenceExists) + { + Logger.LogInformation("Sequence {SchemaName}.{SequenceName} exists", SchemaName, SequenceName); + } + else + { + Logger.LogInformation( + "Sequence {SchemaName}.{SequenceName} does not exist yet and will be created now", + SchemaName, + SequenceName); + using (IDbCommand cmd = dbConnection.CreateCommand()) + { + cmd.CommandText = $"CREATE SEQUENCE {SchemaName}.{SequenceName} START WITH 1 INCREMENT BY {Increment}"; + cmd.ExecuteNonQuery(); + Logger.LogInformation("Sequence {SchemaName}.{SequenceName} created", SchemaName, SequenceName); + } + } + } + } + + public int GetNextValue() + { + using (IDbConnection dbConnection = _dbConnectionFactory.Create()) + { + dbConnection.Open(); + + int nextValue; + using (IDbCommand command = dbConnection.CreateCommand()) + { + command.CommandText = $"SELECT nextval('{SchemaName}.{SequenceName}');"; + nextValue = Convert.ToInt32(command.ExecuteScalar()); + Logger.LogDebug("{SchemaName}.{SequenceName} served {2} as next value", SchemaName, SequenceName, nextValue); + } + + return nextValue; + } + } + + public abstract int Increment { get; } + } +} \ No newline at end of file diff --git a/src/implementations/Backend.Fx.EfCore6Persistence/Properties/AssemblyInfo.cs b/src/implementations/Backend.Fx.EfCore6Persistence/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..5f565d7d --- /dev/null +++ b/src/implementations/Backend.Fx.EfCore6Persistence/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Reflection; + +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyVersion("0.0.0.0")] +[assembly: AssemblyFileVersion("0.0.0.0")] +[assembly: AssemblyInformationalVersion("0.0.0.0")] \ No newline at end of file diff --git a/tests/Backend.Fx.EfCore6Persistence.Tests/Backend.Fx.EfCore6Persistence.Tests.csproj b/tests/Backend.Fx.EfCore6Persistence.Tests/Backend.Fx.EfCore6Persistence.Tests.csproj new file mode 100644 index 00000000..0187d99b --- /dev/null +++ b/tests/Backend.Fx.EfCore6Persistence.Tests/Backend.Fx.EfCore6Persistence.Tests.csproj @@ -0,0 +1,37 @@ + + + + net6.0 + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + diff --git a/tests/Backend.Fx.EfCore6Persistence.Tests/DbConnectionEx.cs b/tests/Backend.Fx.EfCore6Persistence.Tests/DbConnectionEx.cs new file mode 100644 index 00000000..499d8a51 --- /dev/null +++ b/tests/Backend.Fx.EfCore6Persistence.Tests/DbConnectionEx.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Data; +using JetBrains.Annotations; + +namespace Backend.Fx.EfCorePersistence.Tests +{ + public static class DbConnectionEx + { + public static void ExecuteNonQuery(this IDbConnection openConnection, string cmd) + { + using (IDbCommand command = openConnection.CreateCommand()) + { + command.CommandText = cmd; + command.ExecuteNonQuery(); + } + } + + public static T ExecuteScalar(this IDbConnection openConnection, string cmd) + { + using (IDbCommand command = openConnection.CreateCommand()) + { + command.CommandText = cmd; + object scalarResult = command.ExecuteScalar(); + if (typeof(T) == typeof(int)) return (T) (object) Convert.ToInt32(scalarResult); + return (T) scalarResult; + } + } + + [UsedImplicitly] + public static IEnumerable ExecuteReader(this IDbConnection openConnection, string cmd, Func forEachResultFunc) + { + using (IDbCommand command = openConnection.CreateCommand()) + { + command.CommandText = cmd; + IDataReader reader = command.ExecuteReader(); + while (reader.NextResult()) yield return forEachResultFunc(reader); + } + } + } +} \ No newline at end of file diff --git a/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Domain/Blog.cs b/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Domain/Blog.cs new file mode 100644 index 00000000..27c8be41 --- /dev/null +++ b/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Domain/Blog.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using Backend.Fx.BuildingBlocks; +using Backend.Fx.Patterns.IdGeneration; +using JetBrains.Annotations; + +namespace Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain +{ + public class Blog : AggregateRoot + { + [UsedImplicitly] + private Blog() + { + } + + public Blog(int id, string name) : base(id) + { + Name = name; + } + + public string Name { get; private set; } + + public ISet Posts { get; } = new HashSet(); + + public Post AddPost(IEntityIdGenerator idGenerator, string name, bool isPublic = false) + { + var post = new Post(idGenerator.NextId(), this, name, isPublic); + Posts.Add(post); + return post; + } + + public void Modify(string modified) + { + Name = modified; + foreach (Post post in Posts) post.SetName(modified); + } + } +} \ No newline at end of file diff --git a/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Domain/BlogAuthorization.cs b/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Domain/BlogAuthorization.cs new file mode 100644 index 00000000..958e27a8 --- /dev/null +++ b/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Domain/BlogAuthorization.cs @@ -0,0 +1,10 @@ +using Backend.Fx.Patterns.Authorization; +using JetBrains.Annotations; + +namespace Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain +{ + [UsedImplicitly] + public class BlogAuthorization : AllowAll + { + } +} \ No newline at end of file diff --git a/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Domain/Blogger.cs b/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Domain/Blogger.cs new file mode 100644 index 00000000..a21db5ab --- /dev/null +++ b/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Domain/Blogger.cs @@ -0,0 +1,23 @@ +using Backend.Fx.BuildingBlocks; +using JetBrains.Annotations; + +namespace Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain +{ + public class Blogger : AggregateRoot + { + [UsedImplicitly] + private Blogger() + { + } + + public Blogger(int id, string lastName, string firstName) : base(id) + { + LastName = lastName; + FirstName = firstName; + } + + public string LastName { get; set; } + public string FirstName { get; set; } + public string Bio { get; set; } + } +} \ No newline at end of file diff --git a/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Domain/Post.cs b/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Domain/Post.cs new file mode 100644 index 00000000..e71875c0 --- /dev/null +++ b/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Domain/Post.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using Backend.Fx.BuildingBlocks; +using JetBrains.Annotations; + +namespace Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain +{ + public class Post : Entity + { + [UsedImplicitly] + private Post() + { + } + + public Post(int id, Blog blog, string name, bool isPublic = false) : base(id) + { + Blog = blog; + BlogId = blog.Id; + Name = name; + TargetAudience = new TargetAudience {IsPublic = isPublic, Culture = "fr-FR"}; + } + + [UsedImplicitly] public int BlogId { get; private set; } + + [UsedImplicitly] public Blog Blog { get; private set; } + + [UsedImplicitly] public string Name { get; private set; } + + [UsedImplicitly] public TargetAudience TargetAudience { get; set; } + + public void SetName(string name) + { + Name = name; + } + } + + public class TargetAudience : ValueObject + { + public string Culture { get; set; } + + public bool IsPublic { get; set; } + + protected override IEnumerable GetEqualityComponents() + { + yield return Culture; + yield return IsPublic; + } + } +} \ No newline at end of file diff --git a/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Persistence/BlogMapping.cs b/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Persistence/BlogMapping.cs new file mode 100644 index 00000000..4f21dddb --- /dev/null +++ b/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Persistence/BlogMapping.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Fx.EfCorePersistence.Tests.DummyImpl.Persistence +{ + public class BlogMapping : AggregateMapping + { + public override IEnumerable>> IncludeDefinitions + { + get + { + return new Expression>[] + { + blog => blog.Posts + }; + } + } + + public override void ApplyEfMapping(ModelBuilder modelBuilder) + { + modelBuilder.Entity().OwnsOne(p => p.TargetAudience); + } + } +} \ No newline at end of file diff --git a/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Persistence/BloggerMapping.cs b/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Persistence/BloggerMapping.cs new file mode 100644 index 00000000..2659bcd9 --- /dev/null +++ b/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Persistence/BloggerMapping.cs @@ -0,0 +1,8 @@ +using Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain; + +namespace Backend.Fx.EfCorePersistence.Tests.DummyImpl.Persistence +{ + public class BloggerMapping : PlainAggregateMapping + { + } +} \ No newline at end of file diff --git a/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Persistence/TestDbContext.cs b/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Persistence/TestDbContext.cs new file mode 100644 index 00000000..bd40f2f5 --- /dev/null +++ b/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Persistence/TestDbContext.cs @@ -0,0 +1,28 @@ +using Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain; +using Backend.Fx.Environment.MultiTenancy; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Fx.EfCorePersistence.Tests.DummyImpl.Persistence +{ + public sealed class TestDbContext : DbContext + { + public TestDbContext([NotNull] DbContextOptions options) : base(options) + { + Database.AutoTransactionsEnabled = false; + } + + public DbSet Bloggers { get; [UsedImplicitly] set; } + + public DbSet Blogs { get; [UsedImplicitly] set; } + public DbSet Posts { get; [UsedImplicitly] set; } + public DbSet Tenants { get; [UsedImplicitly] set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + this.ApplyAggregateMappings(modelBuilder); + modelBuilder.RegisterRowVersionProperty(); + modelBuilder.RegisterEntityIdAsNeverGenerated(); + } + } +} \ No newline at end of file diff --git a/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Persistence/TestDbContextFactory.cs b/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Persistence/TestDbContextFactory.cs new file mode 100644 index 00000000..cefd3713 --- /dev/null +++ b/tests/Backend.Fx.EfCore6Persistence.Tests/DummyImpl/Persistence/TestDbContextFactory.cs @@ -0,0 +1,17 @@ +using System; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Backend.Fx.EfCorePersistence.Tests.DummyImpl.Persistence +{ + [UsedImplicitly] + [Obsolete("Only for migration support at design time")] + public class TestDbContextFactory : IDesignTimeDbContextFactory + { + public TestDbContext CreateDbContext(string[] args) + { + return new TestDbContext(new DbContextOptionsBuilder().UseSqlite("DataSource=:memory:").Options); + } + } +} \ No newline at end of file diff --git a/tests/Backend.Fx.EfCore6Persistence.Tests/Fixtures/DatabaseFixture.cs b/tests/Backend.Fx.EfCore6Persistence.Tests/Fixtures/DatabaseFixture.cs new file mode 100644 index 00000000..59578f8c --- /dev/null +++ b/tests/Backend.Fx.EfCore6Persistence.Tests/Fixtures/DatabaseFixture.cs @@ -0,0 +1,47 @@ +using System.Data; +using System.Security.Principal; +using Backend.Fx.EfCorePersistence.Tests.DummyImpl.Persistence; +using Backend.Fx.Environment.Authentication; +using Backend.Fx.Environment.DateAndTime; +using Backend.Fx.Environment.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Fx.EfCorePersistence.Tests.Fixtures +{ + public abstract class DatabaseFixture + { + public void CreateDatabase() + { + using (var dbContext = new TestDbContext(GetDbContextOptionsForDbCreation())) + { + dbContext.Database.EnsureCreated(); + } + } + + protected abstract DbContextOptions GetDbContextOptionsForDbCreation(); + + public abstract DbContextOptionsBuilder GetDbContextOptionsBuilder(IDbConnection connection); + + public abstract DbConnectionOperationDecorator UseOperation(); + + public TestDbSession CreateTestDbSession(DbConnectionOperationDecorator operation = null, IIdentity asIdentity = null, IClock clock = null) + { + CurrentIdentityHolder CreateAsIdentity() + { + var cih = new CurrentIdentityHolder(); + cih.ReplaceCurrent(asIdentity); + return cih; + } + + clock ??= new WallClock(); + operation ??= UseOperation(); + + operation.Begin(); + + var identityHolder = asIdentity == null + ? CurrentIdentityHolder.CreateSystem() + : CreateAsIdentity(); + return new TestDbSession(this, operation, identityHolder, clock); + } + } +} \ No newline at end of file diff --git a/tests/Backend.Fx.EfCore6Persistence.Tests/Fixtures/SqlServerDatabaseFixture.cs b/tests/Backend.Fx.EfCore6Persistence.Tests/Fixtures/SqlServerDatabaseFixture.cs new file mode 100644 index 00000000..5f57d5b2 --- /dev/null +++ b/tests/Backend.Fx.EfCore6Persistence.Tests/Fixtures/SqlServerDatabaseFixture.cs @@ -0,0 +1,69 @@ +using System; +using System.Data; +using System.Data.SqlClient; +using Backend.Fx.EfCorePersistence.Tests.DummyImpl.Persistence; +using Backend.Fx.Environment.Persistence; +using Backend.Fx.Patterns.DependencyInjection; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Fx.EfCorePersistence.Tests.Fixtures +{ + [Obsolete("Not supported on build agents")] + public class SqlServerDatabaseFixture : DatabaseFixture + { + private static int _testindex = 1; + private readonly string _connectionString; + + public SqlServerDatabaseFixture() + { + var dbName = $"TestFixture_{_testindex++:000}"; + var sqlConnectionStringBuilder = new SqlConnectionStringBuilder("Server=.\\SQLExpress;Trusted_Connection=True;"); + using (IDbConnection connection = new SqlConnection(sqlConnectionStringBuilder.ConnectionString)) + { + connection.Open(); + + using (IDbCommand dropCommand = connection.CreateCommand()) + { + dropCommand.CommandText = $"IF EXISTS(SELECT * FROM sys.Databases WHERE Name='{dbName}') ALTER DATABASE [{dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE"; + dropCommand.ExecuteNonQuery(); + } + + using (IDbCommand dropCommand = connection.CreateCommand()) + { + dropCommand.CommandText = $"IF EXISTS(SELECT * FROM sys.Databases WHERE Name='{dbName}') DROP DATABASE [{dbName}]"; + dropCommand.ExecuteNonQuery(); + } + + using (IDbCommand createCommand = connection.CreateCommand()) + { + createCommand.CommandText = $"CREATE DATABASE [{dbName}]"; + createCommand.ExecuteNonQuery(); + } + + connection.Close(); + } + + sqlConnectionStringBuilder.InitialCatalog = dbName; + _connectionString = sqlConnectionStringBuilder.ConnectionString; + } + + protected override DbContextOptions GetDbContextOptionsForDbCreation() + { + return new DbContextOptionsBuilder().UseSqlServer(_connectionString).Options; + } + + + public override DbContextOptionsBuilder GetDbContextOptionsBuilder(IDbConnection connection) + { + return new DbContextOptionsBuilder().UseSqlServer((SqlConnection) connection); + } + + public override DbConnectionOperationDecorator UseOperation() + { + var sqliteConnection = new SqlConnection(_connectionString); + IOperation operation = new Operation(); + operation = new DbTransactionOperationDecorator(sqliteConnection, operation); + return new DbConnectionOperationDecorator(sqliteConnection, operation); + } + } +} \ No newline at end of file diff --git a/tests/Backend.Fx.EfCore6Persistence.Tests/Fixtures/SqliteDatabaseFixture.cs b/tests/Backend.Fx.EfCore6Persistence.Tests/Fixtures/SqliteDatabaseFixture.cs new file mode 100644 index 00000000..18440c2c --- /dev/null +++ b/tests/Backend.Fx.EfCore6Persistence.Tests/Fixtures/SqliteDatabaseFixture.cs @@ -0,0 +1,33 @@ +using System.Data; +using System.IO; +using Backend.Fx.EfCorePersistence.Tests.DummyImpl.Persistence; +using Backend.Fx.Environment.Persistence; +using Backend.Fx.Patterns.DependencyInjection; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Backend.Fx.EfCorePersistence.Tests.Fixtures +{ + public class SqliteDatabaseFixture : DatabaseFixture + { + private readonly string _connectionString = "Data Source=" + Path.GetTempFileName(); + + protected override DbContextOptions GetDbContextOptionsForDbCreation() + { + return new DbContextOptionsBuilder().UseSqlite(_connectionString).Options; + } + + public override DbContextOptionsBuilder GetDbContextOptionsBuilder(IDbConnection connection) + { + return new DbContextOptionsBuilder().UseSqlite((SqliteConnection) connection); + } + + public override DbConnectionOperationDecorator UseOperation() + { + var sqliteConnection = new SqliteConnection(_connectionString); + IOperation operation = new Operation(); + operation = new DbTransactionOperationDecorator(sqliteConnection, operation); + return new DbConnectionOperationDecorator(sqliteConnection, operation); + } + } +} \ No newline at end of file diff --git a/tests/Backend.Fx.EfCore6Persistence.Tests/Fixtures/TestDbSession.cs b/tests/Backend.Fx.EfCore6Persistence.Tests/Fixtures/TestDbSession.cs new file mode 100644 index 00000000..8fe47f7f --- /dev/null +++ b/tests/Backend.Fx.EfCore6Persistence.Tests/Fixtures/TestDbSession.cs @@ -0,0 +1,40 @@ +using System; +using System.Data; +using System.Security.Principal; +using Backend.Fx.EfCorePersistence.Tests.DummyImpl.Persistence; +using Backend.Fx.Environment.DateAndTime; +using Backend.Fx.Environment.Persistence; +using Backend.Fx.Patterns.DependencyInjection; + +namespace Backend.Fx.EfCorePersistence.Tests.Fixtures +{ + public class TestDbSession : ICanFlush, IDisposable + { + private readonly DbConnectionOperationDecorator _operation; + private readonly EfFlush _efFlush; + + public TestDbSession(DatabaseFixture fixture, DbConnectionOperationDecorator operation, ICurrentTHolder identityHolder, IClock clock) + { + _operation = operation; + DbContext = new TestDbContext(fixture.GetDbContextOptionsBuilder(operation.DbConnection).Options); + _efFlush = new EfFlush(DbContext, identityHolder, clock); + DbConnection = operation.DbConnection; + } + + + public TestDbContext DbContext { get; } + public IDbConnection DbConnection { get; } + + public void Flush() + { + _efFlush.Flush(); + } + + public void Dispose() + { + _efFlush.Flush(); + DbContext.Dispose(); + _operation.Complete(); + } + } +} \ No newline at end of file diff --git a/tests/Backend.Fx.EfCore6Persistence.Tests/Migrations/20190624150947_Initial.Designer.cs b/tests/Backend.Fx.EfCore6Persistence.Tests/Migrations/20190624150947_Initial.Designer.cs new file mode 100644 index 00000000..1c1cdd8b --- /dev/null +++ b/tests/Backend.Fx.EfCore6Persistence.Tests/Migrations/20190624150947_Initial.Designer.cs @@ -0,0 +1,155 @@ +// +using System; +using Backend.Fx.EfCorePersistence.Tests.DummyImpl.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Backend.Fx.EfCorePersistence.Tests.Migrations +{ + [DbContext(typeof(TestDbContext))] + [Migration("20190624150947_Initial")] + partial class Initial + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.1.1-rtm-30846"); + + modelBuilder.Entity("Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain.Blog", b => + { + b.Property("Id"); + + b.Property("ChangedBy") + .HasMaxLength(100); + + b.Property("ChangedOn"); + + b.Property("CreatedBy") + .HasMaxLength(100); + + b.Property("CreatedOn"); + + b.Property("Name"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate(); + + b.Property("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Blogs"); + }); + + modelBuilder.Entity("Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain.Blogger", b => + { + b.Property("Id"); + + b.Property("Bio"); + + b.Property("ChangedBy") + .HasMaxLength(100); + + b.Property("ChangedOn"); + + b.Property("CreatedBy") + .HasMaxLength(100); + + b.Property("CreatedOn"); + + b.Property("FirstName"); + + b.Property("LastName"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate(); + + b.Property("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Bloggers"); + }); + + modelBuilder.Entity("Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain.Post", b => + { + b.Property("Id"); + + b.Property("BlogId"); + + b.Property("ChangedBy") + .HasMaxLength(100); + + b.Property("ChangedOn"); + + b.Property("CreatedBy") + .HasMaxLength(100); + + b.Property("CreatedOn"); + + b.Property("Name"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate(); + + b.HasKey("Id"); + + b.HasIndex("BlogId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Backend.Fx.Environment.MultiTenancy.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("DefaultCultureName"); + + b.Property("Description"); + + b.Property("IsDemoTenant"); + + b.Property("Name") + .IsRequired(); + + b.Property("State"); + + b.HasKey("Id"); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain.Post", b => + { + b.HasOne("Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain.Blog", "Blog") + .WithMany("Posts") + .HasForeignKey("BlogId") + .OnDelete(DeleteBehavior.Cascade); + + b.OwnsOne("Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain.TargetAudience", "TargetAudience", b1 => + { + b1.Property("PostId"); + + b1.Property("Culture"); + + b1.Property("IsPublic"); + + b1.ToTable("Posts"); + + b1.HasOne("Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain.Post") + .WithOne("TargetAudience") + .HasForeignKey("Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain.TargetAudience", "PostId") + .OnDelete(DeleteBehavior.Cascade); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/tests/Backend.Fx.EfCore6Persistence.Tests/Migrations/20190624150947_Initial.cs b/tests/Backend.Fx.EfCore6Persistence.Tests/Migrations/20190624150947_Initial.cs new file mode 100644 index 00000000..841db0cf --- /dev/null +++ b/tests/Backend.Fx.EfCore6Persistence.Tests/Migrations/20190624150947_Initial.cs @@ -0,0 +1,105 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +// ReSharper disable RedundantArgumentDefaultValue + +namespace Backend.Fx.EfCorePersistence.Tests.Migrations +{ + public partial class Initial : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + "Bloggers", + table => new + { + Id = table.Column(nullable: false), + CreatedOn = table.Column(nullable: false), + CreatedBy = table.Column(maxLength: 100, nullable: true), + ChangedOn = table.Column(nullable: true), + ChangedBy = table.Column(maxLength: 100, nullable: true), + TenantId = table.Column(nullable: false), + LastName = table.Column(nullable: true), + FirstName = table.Column(nullable: true), + Bio = table.Column(nullable: true), + RowVersion = table.Column(rowVersion: true, nullable: true) + }, + constraints: table => { table.PrimaryKey("PK_Bloggers", x => x.Id); }); + + migrationBuilder.CreateTable( + "Blogs", + table => new + { + Id = table.Column(nullable: false), + CreatedOn = table.Column(nullable: false), + CreatedBy = table.Column(maxLength: 100, nullable: true), + ChangedOn = table.Column(nullable: true), + ChangedBy = table.Column(maxLength: 100, nullable: true), + TenantId = table.Column(nullable: false), + Name = table.Column(nullable: true), + RowVersion = table.Column(rowVersion: true, nullable: true) + }, + constraints: table => { table.PrimaryKey("PK_Blogs", x => x.Id); }); + + migrationBuilder.CreateTable( + "Tenants", + table => new + { + Id = table.Column(nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(nullable: false), + Description = table.Column(nullable: true), + IsDemoTenant = table.Column(nullable: false), + State = table.Column(nullable: false), + DefaultCultureName = table.Column(nullable: true) + }, + constraints: table => { table.PrimaryKey("PK_Tenants", x => x.Id); }); + + migrationBuilder.CreateTable( + "Posts", + table => new + { + Id = table.Column(nullable: false), + CreatedOn = table.Column(nullable: false), + CreatedBy = table.Column(maxLength: 100, nullable: true), + ChangedOn = table.Column(nullable: true), + ChangedBy = table.Column(maxLength: 100, nullable: true), + BlogId = table.Column(nullable: false), + Name = table.Column(nullable: true), + TargetAudience_Culture = table.Column(nullable: true), + TargetAudience_IsPublic = table.Column(nullable: false), + RowVersion = table.Column(rowVersion: true, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Posts", x => x.Id); + table.ForeignKey( + "FK_Posts_Blogs_BlogId", + x => x.BlogId, + "Blogs", + "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + "IX_Posts_BlogId", + "Posts", + "BlogId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + "Bloggers"); + + migrationBuilder.DropTable( + "Posts"); + + migrationBuilder.DropTable( + "Tenants"); + + migrationBuilder.DropTable( + "Blogs"); + } + } +} \ No newline at end of file diff --git a/tests/Backend.Fx.EfCore6Persistence.Tests/Migrations/TestDbContextModelSnapshot.cs b/tests/Backend.Fx.EfCore6Persistence.Tests/Migrations/TestDbContextModelSnapshot.cs new file mode 100644 index 00000000..e76dd8b8 --- /dev/null +++ b/tests/Backend.Fx.EfCore6Persistence.Tests/Migrations/TestDbContextModelSnapshot.cs @@ -0,0 +1,152 @@ +// +using System; +using Backend.Fx.EfCorePersistence.Tests.DummyImpl.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Backend.Fx.EfCorePersistence.Tests.Migrations +{ + [DbContext(typeof(TestDbContext))] + partial class TestDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.1.1-rtm-30846"); + + modelBuilder.Entity("Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain.Blog", b => + { + b.Property("Id"); + + b.Property("ChangedBy") + .HasMaxLength(100); + + b.Property("ChangedOn"); + + b.Property("CreatedBy") + .HasMaxLength(100); + + b.Property("CreatedOn"); + + b.Property("Name"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate(); + + b.Property("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Blogs"); + }); + + modelBuilder.Entity("Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain.Blogger", b => + { + b.Property("Id"); + + b.Property("Bio"); + + b.Property("ChangedBy") + .HasMaxLength(100); + + b.Property("ChangedOn"); + + b.Property("CreatedBy") + .HasMaxLength(100); + + b.Property("CreatedOn"); + + b.Property("FirstName"); + + b.Property("LastName"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate(); + + b.Property("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Bloggers"); + }); + + modelBuilder.Entity("Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain.Post", b => + { + b.Property("Id"); + + b.Property("BlogId"); + + b.Property("ChangedBy") + .HasMaxLength(100); + + b.Property("ChangedOn"); + + b.Property("CreatedBy") + .HasMaxLength(100); + + b.Property("CreatedOn"); + + b.Property("Name"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate(); + + b.HasKey("Id"); + + b.HasIndex("BlogId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("Backend.Fx.Environment.MultiTenancy.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("DefaultCultureName"); + + b.Property("Description"); + + b.Property("IsDemoTenant"); + + b.Property("Name") + .IsRequired(); + + b.Property("State"); + + b.HasKey("Id"); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain.Post", b => + { + b.HasOne("Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain.Blog", "Blog") + .WithMany("Posts") + .HasForeignKey("BlogId") + .OnDelete(DeleteBehavior.Cascade); + + b.OwnsOne("Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain.TargetAudience", "TargetAudience", b1 => + { + b1.Property("PostId"); + + b1.Property("Culture"); + + b1.Property("IsPublic"); + + b1.ToTable("Posts"); + + b1.HasOne("Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain.Post") + .WithOne("TargetAudience") + .HasForeignKey("Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain.TargetAudience", "PostId") + .OnDelete(DeleteBehavior.Cascade); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/tests/Backend.Fx.EfCore6Persistence.Tests/TestConfig.cs b/tests/Backend.Fx.EfCore6Persistence.Tests/TestConfig.cs new file mode 100644 index 00000000..6a3fbb4c --- /dev/null +++ b/tests/Backend.Fx.EfCore6Persistence.Tests/TestConfig.cs @@ -0,0 +1,17 @@ +// using Backend.Fx.EfCorePersistence.Tests; +// using Backend.Fx.NLogLogging; +// using MarcWittke.Xunit.AssemblyFixture; +// using Xunit; +// +// [assembly: TestFramework("MarcWittke.Xunit.AssemblyFixture.XunitTestFrameworkWithAssemblyFixture", "MarcWittke.Xunit.AssemblyFixture")] +// [assembly: AssemblyFixture(typeof(TestLoggingFixture))] +// +// namespace Backend.Fx.EfCorePersistence.Tests +// { +// public class TestLoggingFixture : LoggingFixture +// { +// public TestLoggingFixture() : base("Backend.Fx") +// { +// } +// } +// } \ No newline at end of file diff --git a/tests/Backend.Fx.EfCore6Persistence.Tests/TheDbContext.cs b/tests/Backend.Fx.EfCore6Persistence.Tests/TheDbContext.cs new file mode 100644 index 00000000..2374a566 --- /dev/null +++ b/tests/Backend.Fx.EfCore6Persistence.Tests/TheDbContext.cs @@ -0,0 +1,61 @@ +using System.Linq; +using Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain; +using Backend.Fx.EfCorePersistence.Tests.Fixtures; +using Backend.Fx.Tests; +using Microsoft.EntityFrameworkCore; +using Serilog.Formatting.Display; +using Xunit; +using Xunit.Abstractions; + +namespace Backend.Fx.EfCorePersistence.Tests +{ + public class TheDbContext: TestWithLogging + { + public TheDbContext(ITestOutputHelper output) : base(output) + { + _fixture = new SqliteDatabaseFixture(); + _fixture.CreateDatabase(); + } + + private readonly DatabaseFixture _fixture; + private static int _nextTenantId = 2675; + private readonly int _tenantId = _nextTenantId++; + + [Fact] + public void CanClearAndReplaceDependentEntities() + { + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + var blog = new Blog(1, "original blog") {TenantId = _tenantId}; + blog.Posts.Add(new Post(1, blog, "new name 1")); + blog.Posts.Add(new Post(2, blog, "new name 2")); + blog.Posts.Add(new Post(3, blog, "new name 3")); + blog.Posts.Add(new Post(4, blog, "new name 4")); + blog.Posts.Add(new Post(5, blog, "new name 5")); + dbSession.DbContext.Add(blog); + } + + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + Blog blog = dbSession.DbContext.Blogs.Include(b => b.Posts).Single(b => b.Id == 1); + blog.Posts.Clear(); + blog.Posts.Add(new Post(6, blog, "new name 6")); + blog.Posts.Add(new Post(7, blog, "new name 7")); + blog.Posts.Add(new Post(8, blog, "new name 8")); + blog.Posts.Add(new Post(9, blog, "new name 9")); + blog.Posts.Add(new Post(10, blog, "new name 10")); + } + + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + Blog blog = dbSession.DbContext.Blogs.Include(b => b.Posts).Single(b => b.Id == 1); + + Assert.Equal(5, blog.Posts.Count); + + for (var i = 1; i <= 5; i++) Assert.DoesNotContain(blog.Posts, p => p.Id == i); + + for (var i = 6; i <= 10; i++) Assert.Contains(blog.Posts, p => p.Id == i); + } + } + } +} \ No newline at end of file diff --git a/tests/Backend.Fx.EfCore6Persistence.Tests/TheRepositoryOfComposedAggregate.cs b/tests/Backend.Fx.EfCore6Persistence.Tests/TheRepositoryOfComposedAggregate.cs new file mode 100644 index 00000000..a8ebb6a0 --- /dev/null +++ b/tests/Backend.Fx.EfCore6Persistence.Tests/TheRepositoryOfComposedAggregate.cs @@ -0,0 +1,353 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain; +using Backend.Fx.EfCorePersistence.Tests.DummyImpl.Persistence; +using Backend.Fx.EfCorePersistence.Tests.Fixtures; +using Backend.Fx.Environment.DateAndTime; +using Backend.Fx.Environment.MultiTenancy; +using Backend.Fx.Extensions; +using Backend.Fx.Patterns.Authorization; +using Backend.Fx.Patterns.IdGeneration; +using Backend.Fx.Tests; +using FakeItEasy; +using Xunit; +using Xunit.Abstractions; + +namespace Backend.Fx.EfCorePersistence.Tests +{ + public class TheRepositoryOfComposedAggregate : TestWithLogging + { + public TheRepositoryOfComposedAggregate(ITestOutputHelper testOutputHelper) : base(testOutputHelper) + { + A.CallTo(() => _idGenerator.NextId()).ReturnsLazily(() => _nextId++); + //_fixture = new SqlServerDatabaseFixture(); + _fixture = new SqliteDatabaseFixture(); + _fixture.CreateDatabase(); + } + + private static int _nextTenantId = 57839; + private static int _nextId = 1; + private readonly int _tenantId = _nextTenantId++; + + private readonly IEqualityComparer _tolerantDateTimeComparer = + new TolerantDateTimeComparer(TimeSpan.FromMilliseconds(5000)); + + private readonly IEntityIdGenerator _idGenerator = A.Fake(); + private readonly DatabaseFixture _fixture; + + private int CreateBlogWithPost(IDbConnection dbConnection, int postCount = 1) + { + { + var blogId = _nextId++; + dbConnection.ExecuteNonQuery( + $"INSERT INTO Blogs (Id, TenantId, Name, CreatedOn, CreatedBy) VALUES ({blogId}, {CurrentTenantIdHolder.Create(_tenantId).Current.Value}, 'my blog', CURRENT_TIMESTAMP, 'persistence test')"); + var count = dbConnection.ExecuteScalar("SELECT count(*) FROM Blogs"); + Assert.Equal(1, count); + + for (var i = 0; i < postCount; i++) + dbConnection.ExecuteNonQuery( + $"INSERT INTO Posts (Id, BlogId, Name, TargetAudience_IsPublic, TargetAudience_Culture, CreatedOn, CreatedBy) VALUES ({_nextId++}, {blogId}, 'my post {i:00}', '1', 'de-DE', CURRENT_TIMESTAMP, 'persistence test')"); + + return blogId; + } + } + + //FAILING!!!! + // this shows, that ValueObjects treated as OwnedTypes are not supported very well + //[Fact] + //public void CanUpdateDependantValueObject() + //{ + // using (DbSession dbs = _fixture.UseDbSession()) + // { + // int id = CreateBlogWithPost(dbSession.DbConnection, 10); + // Post post; + + // using (var uow = dbs.UseUnitOfWork(_clock)) + // { + // var sut = new EfRepository(uow.DbContext, new BlogMapping(), CurrentTenantIdHolder.Create(_tenantId), + // new AllowAll()); + // var blog = sut.Single(id); + // post = blog.Posts.First(); + // post.TargetAudience = new TargetAudience{Culture = "es-AR", IsPublic = false}; + // uow.Complete(); + // } + + // + // { + // string culture = dbSession.DbConnection.ExecuteScalar($"SELECT TargetAudience_Culture ame FROM Posts where id = {post.Id}"); + // Assert.Equal("es-AR", culture); + + // string strChangedOn = dbSession.DbConnection.ExecuteScalar($"SELECT ChangedOn FROM Posts where id = {post.Id}"); + // DateTime changedOn = DateTime.Parse(strChangedOn); + // Assert.Equal(_clock.UtcNow, changedOn, new TolerantDateTimeComparer(TimeSpan.FromMilliseconds(500))); + // } + // } + //} + + [Fact] + public void CanAddDependent() + { + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + var id = CreateBlogWithPost(dbSession.DbConnection, 10); + var sut = new EfRepository(dbSession.DbContext, + new BlogMapping(), + CurrentTenantIdHolder.Create(_tenantId), + new AllowAll()); + Blog blog = sut.Single(id); + blog.Posts.Add(new Post(_idGenerator.NextId(), blog, "added")); + } + + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + var count = dbSession.DbConnection.ExecuteScalar("SELECT count(*) FROM Posts"); + Assert.Equal(11, count); + } + } + + [Fact] + public void CanCreate() + { + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + var count = dbSession.DbConnection.ExecuteScalar("SELECT count(*) FROM Blogs"); + Assert.Equal(0, count); + + count = dbSession.DbConnection.ExecuteScalar("SELECT count(*) FROM Posts"); + Assert.Equal(0, count); + } + + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + var sut = new EfRepository(dbSession.DbContext, + new BlogMapping(), + CurrentTenantIdHolder.Create(_tenantId), + new AllowAll()); + var blog = new Blog(_idGenerator.NextId(), "my blog"); + blog.AddPost(_idGenerator, "my post"); + sut.Add(blog); + } + + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + var count = dbSession.DbConnection.ExecuteScalar("SELECT count(*) FROM Blogs"); + Assert.Equal(1, count); + + count = dbSession.DbConnection.ExecuteScalar("SELECT count(*) FROM Posts"); + Assert.Equal(1, count); + } + } + + [Fact] + public void CanDelete() + { + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + var id = CreateBlogWithPost(dbSession.DbConnection); + + var sut = new EfRepository(dbSession.DbContext, + new BlogMapping(), + CurrentTenantIdHolder.Create(_tenantId), + new AllowAll()); + Blog blog = sut.Single(id); + sut.Delete(blog); + } + + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + var count = dbSession.DbConnection.ExecuteScalar("SELECT count(*) FROM Blogs"); + Assert.Equal(0, count); + + count = dbSession.DbConnection.ExecuteScalar("SELECT count(*) FROM Posts"); + Assert.Equal(0, count); + } + } + + [Fact] + public void CanDeleteDependent() + { + int id; + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + id = CreateBlogWithPost(dbSession.DbConnection, 10); + var count = dbSession.DbConnection.ExecuteScalar("SELECT count(*) FROM Posts"); + Assert.Equal(10, count); + } + + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + var sut = new EfRepository(dbSession.DbContext, + new BlogMapping(), + CurrentTenantIdHolder.Create(_tenantId), + new AllowAll()); + Blog blog = sut.Single(id); + Post firstPost = blog.Posts.First(); + firstPost.SetName("sadfasfsadf"); + blog.Posts.Remove(firstPost); + } + + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + var count = dbSession.DbConnection.ExecuteScalar("SELECT count(*) FROM Posts"); + Assert.Equal(9, count); + } + } + + + [Fact] + public void CanRead() + { + int id; + Blog blog; + + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + id = CreateBlogWithPost(dbSession.DbConnection); + } + + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + var sut = new EfRepository(dbSession.DbContext, + new BlogMapping(), + CurrentTenantIdHolder.Create(_tenantId), + new AllowAll()); + blog = sut.Single(id); + } + + Assert.NotNull(blog); + Assert.Equal(id, blog.Id); + Assert.Equal("my blog", blog.Name); + Assert.NotEmpty(blog.Posts); + } + + + [Fact] + public void CanReplaceDependentCollection() + { + int id; + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + id = CreateBlogWithPost(dbSession.DbConnection, 10); + } + + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + var sut = new EfRepository(dbSession.DbContext, + new BlogMapping(), + CurrentTenantIdHolder.Create(_tenantId), + new AllowAll()); + Blog blog = sut.Single(id); + blog.Posts.Clear(); + blog.Posts.Add(new Post(_idGenerator.NextId(), blog, "new name 1")); + blog.Posts.Add(new Post(_idGenerator.NextId(), blog, "new name 2")); + blog.Posts.Add(new Post(_idGenerator.NextId(), blog, "new name 3")); + blog.Posts.Add(new Post(_idGenerator.NextId(), blog, "new name 4")); + blog.Posts.Add(new Post(_idGenerator.NextId(), blog, "new name 5")); + } + + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + var count = dbSession.DbConnection.ExecuteScalar("SELECT count(*) FROM Posts"); + Assert.Equal(5, count); + } + } + + [Fact] + public void CanUpdate() + { + int id; + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + id = CreateBlogWithPost(dbSession.DbConnection); + } + + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + var sut = new EfRepository(dbSession.DbContext, + new BlogMapping(), + CurrentTenantIdHolder.Create(_tenantId), + new AllowAll()); + Blog blog = sut.Single(id); + blog.Modify("modified"); + } + + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + Assert.Equal(1, dbSession.DbConnection.ExecuteScalar("SELECT count(*) FROM Blogs")); + Assert.Equal(id, dbSession.DbConnection.ExecuteScalar("SELECT Id FROM Blogs LIMIT 1")); + Assert.Equal("modified", dbSession.DbConnection.ExecuteScalar("SELECT Name FROM Blogs LIMIT 1")); + Assert.Equal("modified", dbSession.DbConnection.ExecuteScalar("SELECT Name FROM Posts LIMIT 1")); + } + } + + [Fact] + public void CanUpdateDependant() + { + var clock = new AdjustableClock(new WallClock()); + clock.OverrideUtcNow(new DateTime(2020, 01, 20, 20, 30, 40)); + + int id; + using (TestDbSession dbSession = _fixture.CreateTestDbSession(clock: clock)) + { + id = CreateBlogWithPost(dbSession.DbConnection, 10); + } + + Post post; + + using (TestDbSession dbSession = _fixture.CreateTestDbSession(clock: clock)) + { + var sut = new EfRepository(dbSession.DbContext, + new BlogMapping(), + CurrentTenantIdHolder.Create(_tenantId), + new AllowAll()); + Blog blog = sut.Single(id); + post = blog.Posts.First(); + post.SetName("modified"); + } + + using (TestDbSession dbSession = _fixture.CreateTestDbSession(clock: clock)) + { + var name = dbSession.DbConnection.ExecuteScalar($"SELECT name FROM Posts where id = {post.Id}"); + Assert.Equal("modified", name); + + var strChangedOn = dbSession.DbConnection.ExecuteScalar($"SELECT changedon FROM Posts where id = {post.Id}"); + DateTime changedOn = DateTime.Parse(strChangedOn); + Assert.Equal(clock.UtcNow, changedOn, new TolerantDateTimeComparer(TimeSpan.FromMilliseconds(500))); + } + } + + [Fact] + public void UpdatesAggregateTrackingPropertiesOnDeleteOfDependant() + { + var clock = new AdjustableClock(new WallClock()); + clock.OverrideUtcNow(new DateTime(2020, 01, 20, 20, 30, 40)); + + int id; + using (TestDbSession dbSession = _fixture.CreateTestDbSession(clock: clock)) + { + id = CreateBlogWithPost(dbSession.DbConnection, 10); + } + + DateTime expectedModifiedOn = clock.Advance(TimeSpan.FromHours(1)); + + using (TestDbSession dbSession = _fixture.CreateTestDbSession(clock: clock)) + { + var sut = new EfRepository(dbSession.DbContext, + new BlogMapping(), + CurrentTenantIdHolder.Create(_tenantId), + new AllowAll()); + Blog b = sut.Single(id); + b.Posts.Remove(b.Posts.First()); + } + + using (TestDbSession dbSession = _fixture.CreateTestDbSession(clock: clock)) + { + Blog blog = dbSession.DbContext.Set().Find(id); + Assert.NotNull(blog.ChangedOn); + Assert.Equal(expectedModifiedOn, blog.ChangedOn.Value, _tolerantDateTimeComparer); + } + } + } +} \ No newline at end of file diff --git a/tests/Backend.Fx.EfCore6Persistence.Tests/TheRepositoryOfPlainAggregate.cs b/tests/Backend.Fx.EfCore6Persistence.Tests/TheRepositoryOfPlainAggregate.cs new file mode 100644 index 00000000..a77f4b57 --- /dev/null +++ b/tests/Backend.Fx.EfCore6Persistence.Tests/TheRepositoryOfPlainAggregate.cs @@ -0,0 +1,191 @@ +using System; +using System.Threading.Tasks; +using Backend.Fx.EfCorePersistence.Tests.DummyImpl.Domain; +using Backend.Fx.EfCorePersistence.Tests.DummyImpl.Persistence; +using Backend.Fx.EfCorePersistence.Tests.Fixtures; +using Backend.Fx.Environment.Authentication; +using Backend.Fx.Environment.MultiTenancy; +using Backend.Fx.Extensions; +using Backend.Fx.Patterns.Authorization; +using Backend.Fx.Tests; +using Xunit; +using Xunit.Abstractions; + +namespace Backend.Fx.EfCorePersistence.Tests +{ + public class TheRepositoryOfPlainAggregate: TestWithLogging + { + public TheRepositoryOfPlainAggregate(ITestOutputHelper testOutputHelper) : base(testOutputHelper) + { + //_fixture = new SqlServerDatabaseFixture(); + _fixture = new SqliteDatabaseFixture(); + _fixture.CreateDatabase(); + } + + private static int _nextTenantId = 12312; + private readonly int _tenantId = _nextTenantId++; + private readonly DatabaseFixture _fixture; + + [Fact] + public void CanCreate() + { + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + var repo = new EfRepository(dbSession.DbContext, new BloggerMapping(), CurrentTenantIdHolder.Create(_tenantId), new AllowAll()); + repo.Add(new Blogger(345, "Metulsky", "Bratislav")); + } + + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + var count = dbSession.DbConnection.ExecuteScalar("SELECT Count(*) FROM Bloggers"); + Assert.Equal(1, count); + + count = dbSession.DbConnection.ExecuteScalar( + $"SELECT Count(*) FROM Bloggers WHERE Id=345"); + Assert.Equal(1, count); + } + } + + + [Fact] + public void CanDelete() + { + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + dbSession.DbConnection.ExecuteNonQuery( + "INSERT INTO Bloggers (Id, TenantId, CreatedOn, CreatedBy, FirstName, LastName, Bio) " + + $"VALUES (555, {_tenantId}, '2012-05-12 23:12:09', 'the test', 'Bratislav', 'Metulsky', 'whatever')"); + } + + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + var repo = new EfRepository(dbSession.DbContext, new BloggerMapping(), CurrentTenantIdHolder.Create(_tenantId), new AllowAll()); + Blogger bratislavMetulsky = repo.Single(555); + repo.Delete(bratislavMetulsky); + } + + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + var count = dbSession.DbConnection.ExecuteScalar("SELECT Count(*) FROM Bloggers WHERE Id = 555"); + Assert.Equal(0, count); + } + } + + + + [Fact] + public void CanRead() + { + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + dbSession.DbConnection.ExecuteNonQuery( + "INSERT INTO Bloggers (Id, TenantId, CreatedOn, CreatedBy, FirstName, LastName, Bio) " + + $"VALUES (444, {_tenantId}, '2012-05-12 23:12:09', 'the test', 'Bratislav', 'Metulsky', 'whatever')"); + + { + var repo = new EfRepository(dbSession.DbContext, new BloggerMapping(), CurrentTenantIdHolder.Create(_tenantId), new AllowAll()); + + bool any = repo.Any(); + Assert.True(any); + + Blogger[] all = repo.GetAll(); + Assert.NotEmpty(all); + + Blogger bratislavMetulsky = repo.Single(444); + Assert.Equal(_tenantId, bratislavMetulsky.TenantId); + Assert.Equal("the test", bratislavMetulsky.CreatedBy); + Assert.Equal(new DateTime(2012, 05, 12, 23, 12, 09), bratislavMetulsky.CreatedOn); + Assert.Equal("Bratislav", bratislavMetulsky.FirstName); + Assert.Equal("Metulsky", bratislavMetulsky.LastName); + Assert.Equal("whatever", bratislavMetulsky.Bio); + + bratislavMetulsky = repo.SingleOrDefault(444); + Assert.NotNull(bratislavMetulsky); + Assert.Equal(_tenantId, bratislavMetulsky.TenantId); + Assert.Equal("the test", bratislavMetulsky.CreatedBy); + Assert.Equal(new DateTime(2012, 05, 12, 23, 12, 09), bratislavMetulsky.CreatedOn); + Assert.Equal("Bratislav", bratislavMetulsky.FirstName); + Assert.Equal("Metulsky", bratislavMetulsky.LastName); + Assert.Equal("whatever", bratislavMetulsky.Bio); + } + } + } + + + [Fact] + public async Task CanReadAsync() + { + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + dbSession.DbConnection.ExecuteNonQuery( + "INSERT INTO Bloggers (Id, TenantId, CreatedOn, CreatedBy, FirstName, LastName, Bio) " + + $"VALUES (555, {_tenantId}, '2012-05-12 23:12:09', 'the test', 'Bratislav', 'Metulsky', 'whatever')"); + + { + var repo = new EfRepository(dbSession.DbContext, new BloggerMapping(), CurrentTenantIdHolder.Create(_tenantId), new AllowAll()); + + bool any = await repo.AnyAsync(); + Assert.True(any); + + Blogger[] all = await repo.GetAllAsync(); + Assert.NotEmpty(all); + + Blogger bratislavMetulsky = await repo.SingleAsync(555); + Assert.Equal(_tenantId, bratislavMetulsky.TenantId); + Assert.Equal("the test", bratislavMetulsky.CreatedBy); + Assert.Equal(new DateTime(2012, 05, 12, 23, 12, 09), bratislavMetulsky.CreatedOn); + Assert.Equal("Bratislav", bratislavMetulsky.FirstName); + Assert.Equal("Metulsky", bratislavMetulsky.LastName); + Assert.Equal("whatever", bratislavMetulsky.Bio); + + bratislavMetulsky = await repo.SingleOrDefaultAsync(555); + Assert.NotNull(bratislavMetulsky); + Assert.Equal(_tenantId, bratislavMetulsky.TenantId); + Assert.Equal("the test", bratislavMetulsky.CreatedBy); + Assert.Equal(new DateTime(2012, 05, 12, 23, 12, 09), bratislavMetulsky.CreatedOn); + Assert.Equal("Bratislav", bratislavMetulsky.FirstName); + Assert.Equal("Metulsky", bratislavMetulsky.LastName); + Assert.Equal("whatever", bratislavMetulsky.Bio); + } + } + } + + [Fact] + public void CanUpdate() + { + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + dbSession.DbConnection.ExecuteNonQuery( + "INSERT INTO Bloggers (Id, TenantId, CreatedOn, CreatedBy, FirstName, LastName, Bio) " + + $"VALUES (456, {_tenantId}, '2012-05-12 23:12:09', 'the test', 'Bratislav', 'Metulsky', 'whatever')"); + } + + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + var repo = new EfRepository(dbSession.DbContext, new BloggerMapping(), + CurrentTenantIdHolder.Create(_tenantId), new AllowAll()); + Blogger bratislavMetulsky = repo.Single(456); + bratislavMetulsky.FirstName = "Johnny"; + bratislavMetulsky.LastName = "Flash"; + bratislavMetulsky.Bio = "Der lustige Clown"; + } + + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + var count = dbSession.DbConnection.ExecuteScalar( + $"SELECT Count(*) FROM Bloggers WHERE FirstName = 'Johnny' AND LastName = 'Flash' AND TenantId = '{_tenantId}'"); + Assert.Equal(1, count); + } + + using (TestDbSession dbSession = _fixture.CreateTestDbSession()) + { + var repo = new EfRepository(dbSession.DbContext, new BloggerMapping(), CurrentTenantIdHolder.Create(_tenantId), new AllowAll()); + Blogger johnnyFlash = repo.Single(456); + Assert.Equal(DateTime.UtcNow, johnnyFlash.ChangedOn, new TolerantDateTimeComparer(TimeSpan.FromMilliseconds(5000))); + Assert.Equal(new SystemIdentity().Name, johnnyFlash.ChangedBy); + Assert.Equal("Johnny", johnnyFlash.FirstName); + Assert.Equal("Flash", johnnyFlash.LastName); + } + } + } +} \ No newline at end of file From d2f92120630f9fec4155bac26031d5cb1704a60b Mon Sep 17 00:00:00 2001 From: Marc Wittke Date: Wed, 8 Jun 2022 16:44:02 -0300 Subject: [PATCH 2/2] Ensure DateTime.Kind = UTC everywhere --- .../Environment/DateAndTime/AdjustableClock.cs | 5 +++-- .../Backend.Fx/Environment/DateAndTime/FrozenClock.cs | 2 +- .../Environment/DateAndTime/TheAdjustableClock.cs | 9 +++++++++ .../Environment/DateAndTime/TheFrozenClock.cs | 10 +++++++++- .../Environment/DateAndTime/TheWallClock.cs | 7 +++++++ 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/abstractions/Backend.Fx/Environment/DateAndTime/AdjustableClock.cs b/src/abstractions/Backend.Fx/Environment/DateAndTime/AdjustableClock.cs index f9572972..6fbc1aed 100644 --- a/src/abstractions/Backend.Fx/Environment/DateAndTime/AdjustableClock.cs +++ b/src/abstractions/Backend.Fx/Environment/DateAndTime/AdjustableClock.cs @@ -1,4 +1,5 @@ using System; +using System.Runtime.InteropServices; using Backend.Fx.Logging; using Microsoft.Extensions.Logging; using ILogger = Microsoft.Extensions.Logging.ILogger; @@ -21,8 +22,8 @@ public AdjustableClock(IClock clockImplementation) public void OverrideUtcNow(DateTime utcNow) { - Logger.LogTrace("Adjusting clock to {UtcNow}", utcNow); - _overriddenUtcNow = utcNow; + Logger.LogTrace("Adjusting clock to {UtcNow} UTC", utcNow); + _overriddenUtcNow = new DateTime(utcNow.Ticks, DateTimeKind.Utc); } public DateTime Advance(TimeSpan timespan) diff --git a/src/abstractions/Backend.Fx/Environment/DateAndTime/FrozenClock.cs b/src/abstractions/Backend.Fx/Environment/DateAndTime/FrozenClock.cs index 22f3244a..6244ce91 100644 --- a/src/abstractions/Backend.Fx/Environment/DateAndTime/FrozenClock.cs +++ b/src/abstractions/Backend.Fx/Environment/DateAndTime/FrozenClock.cs @@ -16,7 +16,7 @@ public class FrozenClock : IClock public FrozenClock(IClock clock) { UtcNow = DateTime.UtcNow; - Logger.LogTrace("Freezing clock at {UtcNow}", UtcNow); + Logger.LogTrace("Freezing clock at {UtcNow} UTC", UtcNow); } public DateTime UtcNow { get; } diff --git a/tests/Backend.Fx.Tests/Environment/DateAndTime/TheAdjustableClock.cs b/tests/Backend.Fx.Tests/Environment/DateAndTime/TheAdjustableClock.cs index 9e0cf2a0..22fd3999 100644 --- a/tests/Backend.Fx.Tests/Environment/DateAndTime/TheAdjustableClock.cs +++ b/tests/Backend.Fx.Tests/Environment/DateAndTime/TheAdjustableClock.cs @@ -20,6 +20,15 @@ public void AllowsOverridingOfUtcNow() Assert.Equal(overriddenUtcNow, sut.UtcNow); } + [Fact] + public void OverriddenTimeIsKindUtc() + { + var overriddenUtcNow = new DateTime(2000, 1, 1, 12, 0, 0); + var sut = new AdjustableClock(new WallClock()); + sut.OverrideUtcNow(overriddenUtcNow); + Assert.Equal(DateTimeKind.Utc, sut.UtcNow.Kind); + } + public TheAdjustableClock(ITestOutputHelper output) : base(output) { } diff --git a/tests/Backend.Fx.Tests/Environment/DateAndTime/TheFrozenClock.cs b/tests/Backend.Fx.Tests/Environment/DateAndTime/TheFrozenClock.cs index a4be9bb8..bf6c7427 100644 --- a/tests/Backend.Fx.Tests/Environment/DateAndTime/TheFrozenClock.cs +++ b/tests/Backend.Fx.Tests/Environment/DateAndTime/TheFrozenClock.cs @@ -11,7 +11,7 @@ public class TheFrozenClock : TestWithLogging [Fact] public void IsFrozen() { - + IClock sut = new FrozenClock(new WallClock()); DateTime systemUtcNow = sut.UtcNow; Thread.Sleep(100); @@ -19,6 +19,14 @@ public void IsFrozen() Assert.NotEqual(DateTime.UtcNow, sut.UtcNow); } + [Fact] + + public void FrozenTimeIsKindUtc() + { + var sut = new FrozenClock(new WallClock()); + Assert.Equal(DateTimeKind.Utc, sut.UtcNow.Kind); + } + public TheFrozenClock(ITestOutputHelper output) : base(output) { } diff --git a/tests/Backend.Fx.Tests/Environment/DateAndTime/TheWallClock.cs b/tests/Backend.Fx.Tests/Environment/DateAndTime/TheWallClock.cs index efe0b818..205d4309 100644 --- a/tests/Backend.Fx.Tests/Environment/DateAndTime/TheWallClock.cs +++ b/tests/Backend.Fx.Tests/Environment/DateAndTime/TheWallClock.cs @@ -12,6 +12,13 @@ public class TheWallClock : TestWithLogging { private readonly IEqualityComparer _tolerantDateTimeComparer = new TolerantDateTimeComparer(TimeSpan.FromMilliseconds(10)); + [Fact] + public void IsKindUtc() + { + IClock sut = new WallClock(); + Assert.True(sut.UtcNow.Kind == DateTimeKind.Utc); + } + [Fact] public void IsTheSystemClock() {