Newer
Older
Warehouse / src / Infrastructure / Persistence / Context / ApplicationDbContext.cs
@Derek Comartin Derek Comartin on 22 Aug 2023 6 KB Init
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using MyWarehouse.Application.Dependencies.Services;
using MyWarehouse.Domain.Common;
using MyWarehouse.Domain.Partners;
using MyWarehouse.Domain.Products;
using MyWarehouse.Domain.Transactions;
using MyWarehouse.Infrastructure.Identity.Model;
using MyWarehouse.Domain.Common.ValueObjects.Money;
using MyWarehouse.Domain.Common.ValueObjects.Mass;
using Microsoft.EntityFrameworkCore.ChangeTracking;

namespace MyWarehouse.Infrastructure.Persistence.Context;

// In this particular architecture, AppDbContext only contains
// translation logic required for proper loading and saving of
// domain entities. Query-specific customizations and utility
// logic has been relegated higher, to repositories.

/// <summary>
/// DB context implementation for Entity Framework.
/// </summary>
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
    public DbSet<Product> Products => Set<Product>();
    public DbSet<Partner> Partners => Set<Partner>();
    public DbSet<Transaction> Transactions => Set<Transaction>();

    private readonly ICurrentUserService _currentUser;
    private readonly IDateTime _dateTime;

    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options, ICurrentUserService currentUser, IDateTime dateTime) : base(options)
    {
        _currentUser = currentUser;
        _dateTime = dateTime;

        // The default cascade delete, combined with the default Immediate cascade timing setting,
        // can cause problems if we intend to manually revert the state of EntityEntries.
        // This is because deletion state change cascades to navigation properties, even owned types,
        // and reverting the state change on the root entity doesn't revert the cascaded changes.
        // This is particularly problematic for owned types, where it seems EF incorrectly
        // does a cascade and write nulls to the DB, even when the owned type is non-nullable,
        // causing errors when saving entities.
        // Here I effectively disabled the cascade for then-reverted (soft-deleted) entities.
        // But in a production system the cascade behavior has to be properly designed.
        ChangeTracker.CascadeDeleteTiming = CascadeTiming.OnSaveChanges;
    }

    // Added for LinqPad.
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
    {
        _currentUser = null!;
        _dateTime = null!;
    }

    public override int SaveChanges()
        => SaveChanges(acceptAllChangesOnSuccess: true);

    public override int SaveChanges(bool acceptAllChangesOnSuccess)
    {
        ApplyMyEntityOverrides();
        return base.SaveChanges(acceptAllChangesOnSuccess);
    }

    public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
        => SaveChangesAsync(acceptAllChangesOnSuccess: true, cancellationToken);

    public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
    {
        ApplyMyEntityOverrides();
        return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
    }

    protected override void ConfigureConventions(ModelConfigurationBuilder configBuilder)
    {
        configBuilder.Properties<decimal>()
            .HavePrecision(precision: 18, scale: 4);

        // Configure conversions for certain types centrally.
        // These are technically owned types, but since they are reduced to a primitive in the conversion,
        // there is no need to set them as Owned().
        configBuilder.Properties<Currency>()
            .HaveConversion<CurrencyConverter>()
            .HaveMaxLength(3);

        configBuilder.Properties<MassUnit>()
            .HaveConversion<MassUnitConverter>()
            .HaveMaxLength(3);
    }

    protected override void OnModelCreating(ModelBuilder builder)
    {
        // Configure the value objects of the application domain as EF Core owned types.
        builder.Owned<Money>();
        builder.Owned<Address>();
        builder.Owned<Mass>();

        ConfigureSoftDeleteFilter(builder);

        base.OnModelCreating(builder);
    }

    /// <summary>
    /// Set global filter on all soft-deletable entities to exclude the ones which are 'deleted'.
    /// </summary>
    private static void ConfigureSoftDeleteFilter(ModelBuilder builder)
    {
        foreach (var softDeletableTypeBuilder in builder.Model.GetEntityTypes()
            .Where(x => typeof(ISoftDeletable).IsAssignableFrom(x.ClrType)))
        {
            var parameter = Expression.Parameter(softDeletableTypeBuilder.ClrType, "p");

            softDeletableTypeBuilder.SetQueryFilter(
                Expression.Lambda(
                    Expression.Equal(
                        Expression.Property(parameter, nameof(ISoftDeletable.DeletedAt)),
                        Expression.Constant(null)),
                    parameter)
            );
        }
    }

    /// <summary>
    /// Automatically stores metadata when entities are added, modified, or deleted.
    /// </summary>
    private void ApplyMyEntityOverrides()
    {
        foreach (var entry in ChangeTracker.Entries<IAudited>())
        {
            switch (entry.State)
            {
                case EntityState.Added:
                    entry.Property(nameof(IAudited.CreatedBy)).CurrentValue = _currentUser.UserId;
                    entry.Property(nameof(IAudited.CreatedAt)).CurrentValue = _dateTime.Now;
                    break;
                case EntityState.Modified:
                    entry.Property(nameof(IAudited.LastModifiedBy)).CurrentValue = _currentUser.UserId;
                    entry.Property(nameof(IAudited.LastModifiedAt)).CurrentValue = _dateTime.Now;
                    break;
            }
        }

        foreach (var entry in ChangeTracker.Entries<ISoftDeletable>())
        {
            switch (entry.State)
            {
                case EntityState.Deleted:
                    entry.State = EntityState.Unchanged; // Override removal. Better than Modified, because that flags ALL properties for update.
                    entry.Property(nameof(ISoftDeletable.DeletedBy)).CurrentValue = _currentUser.UserId;
                    entry.Property(nameof(ISoftDeletable.DeletedAt)).CurrentValue = _dateTime.Now;
                    break;
            }
        }
    }
}