From cdd6a03ef25e28602bbeb5d8aad9e5a3dbf14d19 Mon Sep 17 00:00:00 2001 From: Rung Date: Mon, 1 Jul 2024 17:08:22 +0700 Subject: [PATCH] Add DddDbContext --- .../Codehard.Common.DomainModel/Entity.cs | 2 +- .../Extensions/QueryableExtensions.cs | 27 +- .../DddEntityTests.cs | 183 +++++++++++ .../DomainEntityTests.cs | 2 + .../Entities/DomainDrivenDesign/DddA.cs | 17 + .../Entities/DomainDrivenDesign/DddB.cs | 19 ++ .../Entities/DomainDrivenDesign/DddC.cs | 10 + .../Entities/EntityA.cs | 2 +- .../TestDddDbContext.cs | 14 + .../DbContexts/DddDbContext.cs | 304 ++++++++++++++++++ .../Extensions/ModelBuilderExtensions.cs | 4 + .../Extensions/SpecificationExtensions.cs | 4 + 12 files changed, 581 insertions(+), 7 deletions(-) create mode 100644 src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/DddEntityTests.cs create mode 100644 src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/Entities/DomainDrivenDesign/DddA.cs create mode 100644 src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/Entities/DomainDrivenDesign/DddB.cs create mode 100644 src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/Entities/DomainDrivenDesign/DddC.cs create mode 100644 src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/TestDddDbContext.cs create mode 100644 src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework/DbContexts/DddDbContext.cs diff --git a/src/Codehard.Common/Codehard.Common.DomainModel/Entity.cs b/src/Codehard.Common/Codehard.Common.DomainModel/Entity.cs index e361bd6..55dec78 100644 --- a/src/Codehard.Common/Codehard.Common.DomainModel/Entity.cs +++ b/src/Codehard.Common/Codehard.Common.DomainModel/Entity.cs @@ -26,7 +26,7 @@ protected Entity(Action lazyLoader) /// /// Gets the unique identifier for the entity. /// - public abstract TKey Id { get; protected init; } + public abstract TKey Id { get; init; } /// /// Gets a read-only collection of notifications associated with the entity. diff --git a/src/Codehard.Functional/Codehard.Functional.EntityFramework/Extensions/QueryableExtensions.cs b/src/Codehard.Functional/Codehard.Functional.EntityFramework/Extensions/QueryableExtensions.cs index fdfbab7..6b303eb 100644 --- a/src/Codehard.Functional/Codehard.Functional.EntityFramework/Extensions/QueryableExtensions.cs +++ b/src/Codehard.Functional/Codehard.Functional.EntityFramework/Extensions/QueryableExtensions.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Linq.Expressions; -using System.Threading; -using System.Threading.Tasks; using LanguageExt; using static LanguageExt.Prelude; @@ -163,6 +158,28 @@ public static Aff> SingleOrNoneAff( await source.SingleOrNoneAsync(predicate, ct)); } + /// + /// Asynchronously returns the only element of a sequence satisfying a specified condition within an Aff monad. + /// This method will returns fail if no such element exists or if more than one element satisfies the condition. + /// + /// The type of the elements in the sequence. + /// The IQueryable<TSource> to get the single element from. + /// A lambda expression representing the condition to satisfy. + /// The CancellationToken to observe while waiting for the task to complete. + /// + /// An Aff<TSource> that represents the asynchronous operation. + /// The Aff monad wraps the result, which is the only element of the sequence satisfying the specified condition. + /// + public static Aff SingleOrFailAff( + this IQueryable source, + Expression> predicate, + CancellationToken ct = default) + { + return + Aff(async () => + await source.SingleAsync(predicate, ct)); + } + /// /// Asynchronously returns the first element of a sequence as an Option<T> within a Task, /// or a None value if the sequence is empty. diff --git a/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/DddEntityTests.cs b/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/DddEntityTests.cs new file mode 100644 index 0000000..dddaace --- /dev/null +++ b/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/DddEntityTests.cs @@ -0,0 +1,183 @@ +using Codehard.Infrastructure.EntityFramework.Tests.Entities.DomainDrivenDesign; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Codehard.Infrastructure.EntityFramework.Tests; + +public class DddEntityTests +{ + [Fact] + public async Task WhenSaveAggregateRoot_UsingDddDbContext_ThenSaveAllEntities() + { + // Arrange + var options = new DbContextOptionsBuilder() + .UseSqlite(CreateInMemoryDatabase()) + .Options; + + await using var context = new TestDddDbContext(options); + + await context.Database.EnsureCreatedAsync(); + + var a = new DddA { Id = Guid.NewGuid() }; + var b1 = new DddB { Id = Guid.NewGuid() }; + var c1 = new DddC { Id = Guid.NewGuid() }; + + a.AddB(b1); + b1.AddC(c1); + context.As.Add(a); + + await context.SaveChangesAsync(); + + context.ChangeTracker.Clear(); + + // Act + var savedA = await context.As + .Include(dddA => dddA.Bs) + .ThenInclude(dddB => dddB.Cs) + .SingleAsync(dddA => dddA.Id == a.Id); + + // Assert + Assert.NotNull(savedA); + Assert.NotEmpty(savedA.Bs); + Assert.NotEmpty(savedA.Bs.First().Cs); + } + + [Fact] + public async Task WhenAddNonAggregateRootDirectly_UsingDddDbContextSet_ThenExceptionIsThrown() + { + // Arrange + var options = new DbContextOptionsBuilder() + .UseSqlite(CreateInMemoryDatabase()) + .Options; + + await using var context = new TestDddDbContext(options); + + await context.Database.EnsureCreatedAsync(); + + var b1 = new DddB { Id = Guid.NewGuid() }; + var c1 = new DddC { Id = Guid.NewGuid() }; + + b1.AddC(c1); + + InvalidOperationException? exception = null; + + // Act + try + { + context.Set().Add(b1); + } + catch (InvalidOperationException e) + { + exception = e; + } + + // Assert + Assert.NotNull(exception); + } + + [Fact] + public async Task WhenAddNonAggregateRootDirectly_UsingDddDbContext_ThenExceptionIsThrown() + { + // Arrange + var options = new DbContextOptionsBuilder() + .UseSqlite(CreateInMemoryDatabase()) + .Options; + + await using var context = new TestDddDbContext(options); + + await context.Database.EnsureCreatedAsync(); + + var b1 = new DddB { Id = Guid.NewGuid() }; + var c1 = new DddC { Id = Guid.NewGuid() }; + + b1.AddC(c1); + + InvalidOperationException? exception = null; + + // Act + try + { + context.Add(b1); + } + catch (InvalidOperationException e) + { + exception = e; + } + + // Assert + Assert.NotNull(exception); + } + + [Fact] + public async Task WhenAddNonAggregateRootDirectly_UsingDddDbContextGenericAsync_ThenExceptionIsThrown() + { + // Arrange + var options = new DbContextOptionsBuilder() + .UseSqlite(CreateInMemoryDatabase()) + .Options; + + await using var context = new TestDddDbContext(options); + + await context.Database.EnsureCreatedAsync(); + + var b1 = new DddB { Id = Guid.NewGuid() }; + var c1 = new DddC { Id = Guid.NewGuid() }; + + b1.AddC(c1); + + InvalidOperationException? exception = null; + + // Act + try + { + await context.AddAsync(b1); + } + catch (InvalidOperationException e) + { + exception = e; + } + + // Assert + Assert.NotNull(exception); + } + + [Fact] + public async Task WhenAddNonAggregateRootDirectly_UsingDddDbContextAsync_ThenExceptionIsThrown() + { + // Arrange + var options = new DbContextOptionsBuilder() + .UseSqlite(CreateInMemoryDatabase()) + .Options; + + await using var context = new TestDddDbContext(options); + + await context.Database.EnsureCreatedAsync(); + + var b1 = new DddB { Id = Guid.NewGuid() }; + var c1 = new DddC { Id = Guid.NewGuid() }; + + b1.AddC(c1); + + InvalidOperationException? exception = null; + + // Act + try + { + await context.AddAsync((object)b1); + } + catch (InvalidOperationException e) + { + exception = e; + } + + // Assert + Assert.NotNull(exception); + } + + private static SqliteConnection CreateInMemoryDatabase() + { + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + return connection; + } +} \ No newline at end of file diff --git a/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/DomainEntityTests.cs b/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/DomainEntityTests.cs index 0c4db46..467f5c3 100644 --- a/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/DomainEntityTests.cs +++ b/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/DomainEntityTests.cs @@ -21,10 +21,12 @@ public async Task WhenSaveChangesAsync_UsingDomainEventDbContext_ShouldPublishAn var loggerMock = new Mock>(); var logger = loggerMock.Object; + await using var context = new TestDbContext( options, builder => builder.ApplyConfigurationsFromAssemblyFor(assembly), logger); + await context.Database.EnsureCreatedAsync(); // Act diff --git a/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/Entities/DomainDrivenDesign/DddA.cs b/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/Entities/DomainDrivenDesign/DddA.cs new file mode 100644 index 0000000..c1baf04 --- /dev/null +++ b/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/Entities/DomainDrivenDesign/DddA.cs @@ -0,0 +1,17 @@ +using Codehard.Common.DomainModel; + +namespace Codehard.Infrastructure.EntityFramework.Tests.Entities.DomainDrivenDesign; + +public class DddA : Entity, IAggregateRoot +{ + public override Guid Id { get; init; } + + private readonly List bs = new(); + + public IReadOnlyCollection Bs => bs.AsReadOnly(); + + public void AddB(DddB b) + { + bs.Add(b); + } +} \ No newline at end of file diff --git a/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/Entities/DomainDrivenDesign/DddB.cs b/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/Entities/DomainDrivenDesign/DddB.cs new file mode 100644 index 0000000..11f8621 --- /dev/null +++ b/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/Entities/DomainDrivenDesign/DddB.cs @@ -0,0 +1,19 @@ +using Codehard.Common.DomainModel; + +namespace Codehard.Infrastructure.EntityFramework.Tests.Entities.DomainDrivenDesign; + +public class DddB : Entity +{ + public override Guid Id { get; init; } + + public DddA A { get; private set; } = null!; + + private readonly List cs = new(); + + public IReadOnlyCollection Cs => cs.AsReadOnly(); + + public void AddC(DddC c) + { + cs.Add(c); + } +} \ No newline at end of file diff --git a/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/Entities/DomainDrivenDesign/DddC.cs b/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/Entities/DomainDrivenDesign/DddC.cs new file mode 100644 index 0000000..f993e3c --- /dev/null +++ b/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/Entities/DomainDrivenDesign/DddC.cs @@ -0,0 +1,10 @@ +using Codehard.Common.DomainModel; + +namespace Codehard.Infrastructure.EntityFramework.Tests.Entities.DomainDrivenDesign; + +public class DddC : Entity +{ + public override Guid Id { get; init; } + + public DddB B { get; private set; } = null!; +} \ No newline at end of file diff --git a/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/Entities/EntityA.cs b/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/Entities/EntityA.cs index 688420c..97cc213 100644 --- a/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/Entities/EntityA.cs +++ b/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/Entities/EntityA.cs @@ -26,7 +26,7 @@ public struct EntityAKey public class EntityA : Entity { - public override EntityAKey Id { get; protected init; } + public override EntityAKey Id { get; init; } public string Value { get; set; } = string.Empty; diff --git a/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/TestDddDbContext.cs b/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/TestDddDbContext.cs new file mode 100644 index 0000000..cec8938 --- /dev/null +++ b/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework.Tests/TestDddDbContext.cs @@ -0,0 +1,14 @@ +using Codehard.Infrastructure.EntityFramework.DbContexts; +using Codehard.Infrastructure.EntityFramework.Tests.Entities.DomainDrivenDesign; +using Microsoft.EntityFrameworkCore; + +namespace Codehard.Infrastructure.EntityFramework.Tests; + +public class TestDddDbContext : DddDbContext +{ + public TestDddDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet As { get; set; } +} \ No newline at end of file diff --git a/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework/DbContexts/DddDbContext.cs b/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework/DbContexts/DddDbContext.cs new file mode 100644 index 0000000..d5f1da2 --- /dev/null +++ b/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework/DbContexts/DddDbContext.cs @@ -0,0 +1,304 @@ +using Codehard.Common.DomainModel; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace Codehard.Infrastructure.EntityFramework.DbContexts; + +/// +/// Base class for DbContexts that enforce the use of aggregate roots. +/// +public class DddDbContext : DbContext +{ + /// + /// Create a new instance of . + /// + /// + protected DddDbContext(DbContextOptions options) : base(options) { } + + /// + /// Create a DbSet for an aggregate root entity. + /// + /// + /// + /// + public new DbSet Set() where TEntity : class + { + if (!typeof(IAggregateRoot<>).IsAssignableFrom(typeof(TEntity))) + { + throw new InvalidOperationException($"Cannot create DbSet for {typeof(TEntity).Name} as it is not an aggregate root."); + } + return base.Set(); + } + + /// + /// Adds the specified aggregate root entity to the context. + /// + /// + /// + /// + /// + public override EntityEntry Add(TEntity entity) + { + if (!typeof(IAggregateRoot<>).IsAssignableFrom(typeof(TEntity))) + { + throw new InvalidOperationException($"Cannot add {typeof(TEntity).Name} directly as it is not an aggregate root."); + } + return base.Add(entity); + } + + /// + /// Adds the given aggregate root entity to the context. + /// + /// + /// + /// + public override EntityEntry Add(object entity) + { + if (!typeof(IAggregateRoot<>).IsAssignableFrom(entity.GetType())) + { + throw new InvalidOperationException($"Cannot add {entity.GetType().Name} directly as it is not an aggregate root."); + } + return base.Add(entity); + } + + /// + /// Asynchronously adds the specified aggregate root entity to the context. + /// + /// + /// + /// + /// + public override ValueTask AddAsync( + object entity, + CancellationToken cancellationToken = default) + { + if (!typeof(IAggregateRoot<>).IsAssignableFrom(entity.GetType())) + { + throw new InvalidOperationException($"Cannot add {entity.GetType().Name} directly as it is not an aggregate root."); + } + + return base.AddAsync(entity, cancellationToken); + } + + /// + /// Asynchronously adds the specified aggregate root entity to the context. + /// + /// + /// + /// + /// + /// + public override ValueTask> AddAsync( + TEntity entity, + CancellationToken cancellationToken = default) + { + if (!typeof(IAggregateRoot<>).IsAssignableFrom(typeof(TEntity))) + { + throw new InvalidOperationException($"Cannot add {typeof(TEntity).Name} directly as it is not an aggregate root."); + } + return base.AddAsync(entity, cancellationToken); + } + + /// + /// Updates the given aggregate root entity in the context. + /// + /// + /// + /// + /// + public override EntityEntry Update(TEntity entity) + { + if (!typeof(IAggregateRoot<>).IsAssignableFrom(typeof(TEntity))) + { + throw new InvalidOperationException($"Cannot update {typeof(TEntity).Name} directly as it is not an aggregate root."); + } + return base.Update(entity); + } + + /// + /// Updates the given aggregate root entity in the context. + /// + /// + /// + /// + public override EntityEntry Update(object entity) + { + if (!typeof(IAggregateRoot<>).IsAssignableFrom(entity.GetType())) + { + throw new InvalidOperationException($"Cannot update {entity.GetType().Name} directly as it is not an aggregate root."); + } + return base.Update(entity); + } + + /// + /// Removes the given aggregate root entity from the context. + /// + /// + /// + /// + /// + public override EntityEntry Remove(TEntity entity) + { + if (!typeof(IAggregateRoot<>).IsAssignableFrom(typeof(TEntity))) + { + throw new InvalidOperationException($"Cannot remove {typeof(TEntity).Name} directly as it is not an aggregate root."); + } + + return base.Remove(entity); + } + + /// + /// Removes the given aggregate root entity from the context. + /// + /// + /// + /// + public override EntityEntry Remove(object entity) + { + if (!typeof(IAggregateRoot<>).IsAssignableFrom(entity.GetType())) + { + throw new InvalidOperationException($"Cannot remove {entity.GetType().Name} directly as it is not an aggregate root."); + } + return base.Remove(entity); + } + + /// + /// Adds the given aggregate root entities to the context. + /// + /// + /// + public override void AddRange(params object[] entities) + { + foreach (var entity in entities) + { + if (!typeof(IAggregateRoot<>).IsAssignableFrom(entity.GetType())) + { + throw new InvalidOperationException($"Cannot add {entity.GetType().Name} directly as it is not an aggregate root."); + } + } + base.AddRange(entities); + } + + /// + /// Adds the given aggregate root entities to the context. + /// + /// + /// + public override void AddRange(IEnumerable entities) + { + foreach (var entity in entities) + { + if (!typeof(IAggregateRoot<>).IsAssignableFrom(entity.GetType())) + { + throw new InvalidOperationException($"Cannot add {entity.GetType().Name} directly as it is not an aggregate root."); + } + } + base.AddRange(entities); + } + + + /// + /// Asynchronously adds the given aggregate root entities to the context. + /// + /// + /// + /// + public override Task AddRangeAsync(params object[] entities) + { + foreach (var entity in entities) + { + if (!typeof(IAggregateRoot<>).IsAssignableFrom(entity.GetType())) + { + throw new InvalidOperationException($"Cannot add {entity.GetType().Name} directly as it is not an aggregate root."); + } + } + return base.AddRangeAsync(entities); + } + + /// + /// Asynchronously adds the given aggregate root entities to the context. + /// + /// + /// + /// + /// + public override Task AddRangeAsync(IEnumerable entities, CancellationToken cancellationToken = default) + { + foreach (var entity in entities) + { + if (!typeof(IAggregateRoot<>).IsAssignableFrom(entity.GetType())) + { + throw new InvalidOperationException($"Cannot add {entity.GetType().Name} directly as it is not an aggregate root."); + } + } + return base.AddRangeAsync(entities, cancellationToken); + } + + /// + /// Update the given aggregate root entities to the context. + /// + /// + /// + public override void UpdateRange(params object[] entities) + { + foreach (var entity in entities) + { + if (!typeof(IAggregateRoot<>).IsAssignableFrom(entity.GetType())) + { + throw new InvalidOperationException($"Cannot update {entity.GetType().Name} directly as it is not an aggregate root."); + } + } + base.UpdateRange(entities); + } + + /// + /// Update the given aggregate root entities to the context. + /// + /// + /// + public override void UpdateRange(IEnumerable entities) + { + foreach (var entity in entities) + { + if (!typeof(IAggregateRoot<>).IsAssignableFrom(entity.GetType())) + { + throw new InvalidOperationException($"Cannot update {entity.GetType().Name} directly as it is not an aggregate root."); + } + } + base.UpdateRange(entities); + } + + /// + /// Remove the given aggregate root entities to the context. + /// + /// + /// + public override void RemoveRange(params object[] entities) + { + foreach (var entity in entities) + { + if (!typeof(IAggregateRoot<>).IsAssignableFrom(entity.GetType())) + { + throw new InvalidOperationException($"Cannot remove {entity.GetType().Name} directly as it is not an aggregate root."); + } + } + base.RemoveRange(entities); + } + + /// + /// Remove the given aggregate root entities to the context. + /// + /// + /// + public override void RemoveRange(IEnumerable entities) + { + foreach (var entity in entities) + { + if (!typeof(IAggregateRoot<>).IsAssignableFrom(entity.GetType())) + { + throw new InvalidOperationException($"Cannot remove {entity.GetType().Name} directly as it is not an aggregate root."); + } + } + base.RemoveRange(entities); + } +} \ No newline at end of file diff --git a/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework/Extensions/ModelBuilderExtensions.cs b/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework/Extensions/ModelBuilderExtensions.cs index 72306c1..c229e0f 100644 --- a/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework/Extensions/ModelBuilderExtensions.cs +++ b/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework/Extensions/ModelBuilderExtensions.cs @@ -3,6 +3,10 @@ namespace Codehard.Infrastructure.EntityFramework.Extensions; +/// +/// Provides extension methods for the ModelBuilder class. +/// These methods allow for applying entity type configurations from a specified assembly to the model. +/// public static class ModelBuilderExtensions { /// diff --git a/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework/Extensions/SpecificationExtensions.cs b/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework/Extensions/SpecificationExtensions.cs index f5c111b..aea3bfc 100644 --- a/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework/Extensions/SpecificationExtensions.cs +++ b/src/Codehard.Infrastructure/Codehard.Infrastructure.EntityFramework/Extensions/SpecificationExtensions.cs @@ -1,7 +1,11 @@ using Microsoft.EntityFrameworkCore; +// ReSharper disable once CheckNamespace namespace Codehard.Common.DomainModel.Extensions; +/// +/// Provides extension methods for applying specifications to a DbSet. +/// public static class SpecificationExtensions { ///