diff --git a/src/E13.Common.Data.Db/BaseDbContext.cs b/src/E13.Common.Data.Db/BaseDbContext.cs index 2e29354..135843d 100644 --- a/src/E13.Common.Data.Db/BaseDbContext.cs +++ b/src/E13.Common.Data.Db/BaseDbContext.cs @@ -16,7 +16,7 @@ namespace E13.Common.Data.Db { - public abstract class BaseDbContext : DbContext + public abstract class BaseDbContext : DbContext, IAuditContext { /// /// The user name used when the user name is null @@ -24,6 +24,11 @@ public abstract class BaseDbContext : DbContext public const string UnknownUser = "*Unknown"; protected ILogger Logger { get;} + + public string? AuditUser { get; set; } + + public string? Source { get; set; } + protected BaseDbContext(DbContextOptions options, ILogger logger) : base(options) { @@ -42,26 +47,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) base.OnModelCreating(modelBuilder); } - public int SaveChanges(string user, string? source = null) - { - if(source == null) - { - var caller = new StackFrame(1).GetMethod() - ?? throw new Exception("Unable to determine the calling method source from new StackFrame(1).GetMethod()"); - - source = $"{caller.DeclaringType?.FullName}.{caller.Name}"; - } - - TagEntries(source, user); - var result = base.SaveChanges(); - - // saving after a reload effectively clears the change tracker affecting no records - Reload(); - base.SaveChanges(); - - return result; - } - /// /// This is used by E13.Common.Data.Db.Tests in order to allow TestDbContext.TestSeed() to seed /// data without going through the TagEntries. @@ -81,70 +66,17 @@ internal int SaveChanges_NoTagEntries() public override int SaveChanges() { - var caller = new StackFrame(1).GetMethod()!; // This is safe because the method is called from somewhere + // 1 – Get caller outside EF + var caller = new StackFrame(1).GetMethod(); + var source = $"{caller?.DeclaringType?.FullName}.{caller?.Name}"; - return SaveChanges(UnknownUser, $"{caller.DeclaringType}.{caller.Name}"); - } + // 2 – Stash it in the scoped audit context (injected) + Source = source ?? "Unknown"; + AuditUser = AuditUser ?? UnknownUser; - public void Reload() => ChangeTracker.Entries() - .Where(e => e.Entity != null).ToList() - .ForEach(e => e.State = EntityState.Detached); - - private void TagEntries(string source, string user) - { - var entries = ChangeTracker.Entries().Where(e => - e.Entity is IEntity && - (e.State == EntityState.Added || e.State == EntityState.Modified || e.State == EntityState.Deleted) - ); - - Logger.LogInformation($"ChangeTracker Contains {entries.Count()}/{ChangeTracker.Entries().Count()} in an Added or Modified state."); - - foreach (var entry in entries) - { - Logger.LogDebug($"Entity: {entry.Entity.GetType().Name}, State: {entry.State}"); - var utcNow = DateTime.UtcNow; - - if (entry.State == EntityState.Added && entry.Entity is ICreatable) - { - SetProperty(entry.Entity, "Created", utcNow); - SetProperty(entry.Entity, "CreatedBy", user); - SetProperty(entry.Entity, "CreatedSource", source); - } - - if (entry.Entity is IModifiable) - { - SetProperty(entry.Entity, "Modified", utcNow); - SetProperty(entry.Entity, "ModifiedBy", user); - SetProperty(entry.Entity, "ModifiedSource", source); - } - - if (entry.Entity is IDeletable deletable) - { - if (entry.State == EntityState.Deleted) - { - entry.State = EntityState.Modified; - SetProperty(entry.Entity, "Deleted", utcNow); - SetProperty(entry.Entity, "DeletedBy", user); - SetProperty(entry.Entity, "DeletedSource", source); - } - else if (deletable.Deleted != null || deletable.DeletedBy != null || deletable.DeletedSource != null) - { - entry.State = EntityState.Modified; - SetProperty(entry.Entity, "Deleted", null); - SetProperty(entry.Entity, "DeletedBy", null); - SetProperty(entry.Entity, "DeletedSource", null); - } - } - } + // 3 – Proceed. Interceptors now have Source & User. + return base.SaveChanges(); } - private void SetProperty(object entity, string propertyName, object? value) - { - var property = entity.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance); - if (property != null && property.CanWrite) - { - property.SetValue(entity, value); - } - } } } diff --git a/src/E13.Common.Data.Db/IAuditContext.cs b/src/E13.Common.Data.Db/IAuditContext.cs new file mode 100644 index 0000000..e5165a7 --- /dev/null +++ b/src/E13.Common.Data.Db/IAuditContext.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace E13.Common.Data.Db +{ + public interface IAuditContext + { + string? AuditUser { get; } + string? Source { get; } + } +} diff --git a/src/E13.Common.Data.Db/Interceptors/CreatableInterceptor.cs b/src/E13.Common.Data.Db/Interceptors/CreatableInterceptor.cs new file mode 100644 index 0000000..e8bf917 --- /dev/null +++ b/src/E13.Common.Data.Db/Interceptors/CreatableInterceptor.cs @@ -0,0 +1,30 @@ +using E13.Common.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace E13.Common.Data.Db.Interceptors +{ + public sealed class CreatableInterceptor : SaveChangesInterceptor + { + public override InterceptionResult SavingChanges( + DbContextEventData data, + InterceptionResult result) + { + var auditContext = data.Context as IAuditContext ?? throw new Exception("Audit context is not set."); + + foreach (var entry in data.Context!.ChangeTracker.Entries() + .Where(e => e.State == EntityState.Added)) + { + entry.Entity.Created = DateTime.UtcNow; + entry.Entity.CreatedBy = auditContext.AuditUser; + entry.Entity.CreatedSource = auditContext.Source; + } + return base.SavingChanges(data, result); + } + } +} diff --git a/src/E13.Common.Data.Db/Interceptors/ModifiableInterceptor.cs b/src/E13.Common.Data.Db/Interceptors/ModifiableInterceptor.cs new file mode 100644 index 0000000..d067fa4 --- /dev/null +++ b/src/E13.Common.Data.Db/Interceptors/ModifiableInterceptor.cs @@ -0,0 +1,30 @@ +using E13.Common.Domain; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace E13.Common.Data.Db.Interceptors +{ + public sealed class ModifiableInterceptor : SaveChangesInterceptor + { + public override InterceptionResult SavingChanges( + DbContextEventData data, + InterceptionResult result) + { + var auditContext = data.Context as IAuditContext ?? throw new Exception("Audit context is not set."); + + foreach (var entry in data.Context!.ChangeTracker.Entries() + .Where(e => e.State is EntityState.Added or EntityState.Modified)) + { + entry.Entity.Modified = DateTime.UtcNow; + entry.Entity.ModifiedBy = auditContext.AuditUser; + entry.Entity.ModifiedSource = auditContext.Source; + } + return base.SavingChanges(data, result); + } + } +} diff --git a/src/E13.Common.Data.Db/Interceptors/SoftDeleteInterceptor.cs b/src/E13.Common.Data.Db/Interceptors/SoftDeleteInterceptor.cs new file mode 100644 index 0000000..dc334ac --- /dev/null +++ b/src/E13.Common.Data.Db/Interceptors/SoftDeleteInterceptor.cs @@ -0,0 +1,44 @@ +using E13.Common.Domain; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace E13.Common.Data.Db.Interceptors +{ + public sealed class SoftDeleteInterceptor : SaveChangesInterceptor + { + public override InterceptionResult SavingChanges( + DbContextEventData data, + InterceptionResult result) + { + var auditContext = data.Context as IAuditContext ?? throw new Exception("Audit context is not set."); + + foreach (var entry in data.Context!.ChangeTracker.Entries()) + { + if (entry.State == EntityState.Deleted) + { + entry.State = EntityState.Modified; + entry.Entity.Deleted = DateTime.UtcNow; + entry.Entity.DeletedBy = auditContext.AuditUser; + entry.Entity.DeletedSource = auditContext.Source; + } + else if (entry.State == EntityState.Modified && + (entry.Entity.Deleted != null || + entry.Entity.DeletedBy != null || + entry.Entity.DeletedSource != null)) + { + // “undelete” scenario + entry.Entity.Deleted = null; + entry.Entity.DeletedBy = null; + entry.Entity.DeletedSource = null; + } + } + return base.SavingChanges(data, result); + } + } + +} diff --git a/test/E13.Common.Data.Db.Tests/BaseDbContext_ICreatableTests.cs b/test/E13.Common.Data.Db.Tests/BaseDbContext_ICreatableTests.cs index 0857174..7df70c8 100644 --- a/test/E13.Common.Data.Db.Tests/BaseDbContext_ICreatableTests.cs +++ b/test/E13.Common.Data.Db.Tests/BaseDbContext_ICreatableTests.cs @@ -1,3 +1,4 @@ +using E13.Common.Data.Db.Interceptors; using E13.Common.Data.Db.Tests.Sample; using FluentAssertions; using Microsoft.EntityFrameworkCore; @@ -16,10 +17,17 @@ public class BaseDbContext_ICreatableTests public void Setup() { var services = new ServiceCollection(); - services.AddDbContext(o => + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddDbContext((sp, o) => { o.UseInMemoryDatabase($"{Guid.NewGuid()}"); o.EnableSensitiveDataLogging(); + o.AddInterceptors( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService()); }); Context = services.BuildServiceProvider().GetService(); @@ -72,9 +80,10 @@ public void SaveChanges_NamedUser_CreatedByNamedUser() if (Context == null) throw new Exception("TestDbContext did not Setup() successfully"); + Context.AuditUser = nameof(SaveChanges_NamedUser_CreatedByNamedUser); var id = Guid.NewGuid(); Context.Creatables.Add(new TestCreatable { Id = id }); - Context.SaveChanges(nameof(SaveChanges_NamedUser_CreatedByNamedUser)); + Context.SaveChanges(); var arranged = Context.Creatables.First(e => e.Id == id); diff --git a/test/E13.Common.Data.Db.Tests/BaseDbContext_IDeletableTests.cs b/test/E13.Common.Data.Db.Tests/BaseDbContext_IDeletableTests.cs index d52d370..412f5fa 100644 --- a/test/E13.Common.Data.Db.Tests/BaseDbContext_IDeletableTests.cs +++ b/test/E13.Common.Data.Db.Tests/BaseDbContext_IDeletableTests.cs @@ -1,3 +1,4 @@ +using E13.Common.Data.Db.Interceptors; using E13.Common.Data.Db.Tests.Sample; using FluentAssertions; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public class BaseDbContext_IDeletableTests public void Setup() { var services = new ServiceCollection(); - services.AddDbContext(o => o.UseInMemoryDatabase($"{Guid.NewGuid()}")); + services.AddDbContext(o => { + o.UseInMemoryDatabase($"{Guid.NewGuid()}"); + o.AddInterceptors(new CreatableInterceptor()); + o.AddInterceptors(new ModifiableInterceptor()); + o.AddInterceptors(new SoftDeleteInterceptor()); + }); Context = services.BuildServiceProvider().GetService(); if (Context == null) @@ -62,12 +68,13 @@ public void SaveChangesForUser_Deleting_SetsDeletedBy() if (Context == null) throw new Exception("TestDbContext did not Setup() successfully"); + Context.AuditUser = nameof(SaveChangesForUser_Deleting_SetsDeletedBy); Context.Deletable.IgnoreQueryFilters().Count().Should().Be(1); Context.Deletable.IgnoreQueryFilters().Count(e => e.DeletedBy == null).Should().Be(1); var arranged = Context.Deletable.IgnoreQueryFilters().First(f => f.Deleted == null); Context.Deletable.Remove(arranged); - Context.SaveChanges(nameof(SaveChangesForUser_Deleting_SetsDeletedBy)); + Context.SaveChanges(); Context.Deletable.IgnoreQueryFilters().Count().Should().Be(1); Context.Deletable.IgnoreQueryFilters().Count(e => e.DeletedBy == null).Should().Be(0); diff --git a/test/E13.Common.Data.Db.Tests/BaseDbContext_IModifiableTests.cs b/test/E13.Common.Data.Db.Tests/BaseDbContext_IModifiableTests.cs index 22acb9a..3fcf86e 100644 --- a/test/E13.Common.Data.Db.Tests/BaseDbContext_IModifiableTests.cs +++ b/test/E13.Common.Data.Db.Tests/BaseDbContext_IModifiableTests.cs @@ -1,3 +1,4 @@ +using E13.Common.Data.Db.Interceptors; using E13.Common.Data.Db.Tests.Sample; using FluentAssertions; using Microsoft.EntityFrameworkCore; @@ -16,7 +17,12 @@ public class BaseDbContext_IModifiableTests public void Setup() { var services = new ServiceCollection(); - services.AddDbContext(o => o.UseInMemoryDatabase($"{Guid.NewGuid()}")); + services.AddDbContext(o => { + o.UseInMemoryDatabase($"{Guid.NewGuid()}"); + o.AddInterceptors(new CreatableInterceptor()); + o.AddInterceptors(new ModifiableInterceptor()); + o.AddInterceptors(new SoftDeleteInterceptor()); + }); Context = services.BuildServiceProvider().GetService(); @@ -99,12 +105,14 @@ public void SaveChanges_NamedUser_UpdatesModifiedBy() if (Context == null) throw new Exception("TestDbContext did not Setup() successfully"); + + Context.AuditUser = nameof(SaveChanges_NamedUser_UpdatesModifiedBy); var arranged = Context.Modifiables.First(); arranged.Text.Should().BeEmpty(); //act arranged.Text = $"{Guid.NewGuid()}"; - Context.SaveChanges(nameof(SaveChanges_NamedUser_UpdatesModifiedBy)); + Context.SaveChanges(); var assert = Context.Modifiables.First(); assert.ModifiedBy.Should().Be(nameof(SaveChanges_NamedUser_UpdatesModifiedBy)); @@ -116,11 +124,11 @@ public void SaveChanges_NamedUser_ModifiedUpdates() if (Context == null) throw new Exception("TestDbContext did not Setup() successfully"); + Context.AuditUser = nameof(SaveChanges_NamedUser_ModifiedUpdates); var arranged = Context.Modifiables.First(); //act arranged.Text = $"{Guid.NewGuid()}"; - Context.SaveChanges(nameof(SaveChanges_NamedUser_ModifiedUpdates)); var actual = Context.Modifiables.First(); actual.Modified.Should().NotBeNull(); diff --git a/test/E13.Common.Data.Db.Tests/Sample/TestCreatable.cs b/test/E13.Common.Data.Db.Tests/Sample/TestCreatable.cs index 5de1a0d..3abc17d 100644 --- a/test/E13.Common.Data.Db.Tests/Sample/TestCreatable.cs +++ b/test/E13.Common.Data.Db.Tests/Sample/TestCreatable.cs @@ -12,8 +12,8 @@ public class TestCreatable : ICreatable public Guid Id { get; set; } public string Text { get; set; } = string.Empty; - public string? CreatedBy { get; set; } = "CreatedBy"; - public string? CreatedSource { get; set; } = "CreatedSource"; + public string? CreatedBy { get; set; } + public string? CreatedSource { get; set; } public DateTime? Created { get; set; } } }