Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 14 additions & 82 deletions src/E13.Common.Data.Db/BaseDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,19 @@

namespace E13.Common.Data.Db
{
public abstract class BaseDbContext : DbContext
public abstract class BaseDbContext : DbContext, IAuditContext
{
/// <summary>
/// The user name used when the user name is null
/// </summary>
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)
{
Expand All @@ -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;
}

/// <summary>
/// This is used by E13.Common.Data.Db.Tests in order to allow TestDbContext.TestSeed() to seed
/// data without going through the TagEntries.
Expand All @@ -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);
}
}
}
}
14 changes: 14 additions & 0 deletions src/E13.Common.Data.Db/IAuditContext.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
30 changes: 30 additions & 0 deletions src/E13.Common.Data.Db/Interceptors/CreatableInterceptor.cs
Original file line number Diff line number Diff line change
@@ -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<int> SavingChanges(
DbContextEventData data,
InterceptionResult<int> result)
{
var auditContext = data.Context as IAuditContext ?? throw new Exception("Audit context is not set.");

foreach (var entry in data.Context!.ChangeTracker.Entries<ICreatable>()
.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);
}
}
}
30 changes: 30 additions & 0 deletions src/E13.Common.Data.Db/Interceptors/ModifiableInterceptor.cs
Original file line number Diff line number Diff line change
@@ -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<int> SavingChanges(
DbContextEventData data,
InterceptionResult<int> result)
{
var auditContext = data.Context as IAuditContext ?? throw new Exception("Audit context is not set.");

foreach (var entry in data.Context!.ChangeTracker.Entries<IModifiable>()
.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);
}
}
}
44 changes: 44 additions & 0 deletions src/E13.Common.Data.Db/Interceptors/SoftDeleteInterceptor.cs
Original file line number Diff line number Diff line change
@@ -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<int> SavingChanges(
DbContextEventData data,
InterceptionResult<int> result)
{
var auditContext = data.Context as IAuditContext ?? throw new Exception("Audit context is not set.");

foreach (var entry in data.Context!.ChangeTracker.Entries<IDeletable>())
{
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);
}
}

}
13 changes: 11 additions & 2 deletions test/E13.Common.Data.Db.Tests/BaseDbContext_ICreatableTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using E13.Common.Data.Db.Interceptors;
using E13.Common.Data.Db.Tests.Sample;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
Expand All @@ -16,10 +17,17 @@ public class BaseDbContext_ICreatableTests
public void Setup()
{
var services = new ServiceCollection();
services.AddDbContext<TestDbContext>(o =>
services.AddScoped<CreatableInterceptor>();
services.AddScoped<ModifiableInterceptor>();
services.AddScoped<SoftDeleteInterceptor>();
services.AddDbContext<IAuditContext, TestDbContext>((sp, o) =>
{
o.UseInMemoryDatabase($"{Guid.NewGuid()}");
o.EnableSensitiveDataLogging();
o.AddInterceptors(
sp.GetRequiredService<CreatableInterceptor>(),
sp.GetRequiredService<ModifiableInterceptor>(),
sp.GetRequiredService<SoftDeleteInterceptor>());
});

Context = services.BuildServiceProvider().GetService<TestDbContext>();
Expand Down Expand Up @@ -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);

Expand Down
11 changes: 9 additions & 2 deletions test/E13.Common.Data.Db.Tests/BaseDbContext_IDeletableTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using E13.Common.Data.Db.Interceptors;
using E13.Common.Data.Db.Tests.Sample;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
Expand All @@ -16,7 +17,12 @@ public class BaseDbContext_IDeletableTests
public void Setup()
{
var services = new ServiceCollection();
services.AddDbContext<TestDbContext>(o => o.UseInMemoryDatabase($"{Guid.NewGuid()}"));
services.AddDbContext<TestDbContext>(o => {
o.UseInMemoryDatabase($"{Guid.NewGuid()}");
o.AddInterceptors(new CreatableInterceptor());
o.AddInterceptors(new ModifiableInterceptor());
o.AddInterceptors(new SoftDeleteInterceptor());
});

Context = services.BuildServiceProvider().GetService<TestDbContext>();
if (Context == null)
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 11 additions & 3 deletions test/E13.Common.Data.Db.Tests/BaseDbContext_IModifiableTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using E13.Common.Data.Db.Interceptors;
using E13.Common.Data.Db.Tests.Sample;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
Expand All @@ -16,7 +17,12 @@ public class BaseDbContext_IModifiableTests
public void Setup()
{
var services = new ServiceCollection();
services.AddDbContext<TestDbContext>(o => o.UseInMemoryDatabase($"{Guid.NewGuid()}"));
services.AddDbContext<TestDbContext>(o => {
o.UseInMemoryDatabase($"{Guid.NewGuid()}");
o.AddInterceptors(new CreatableInterceptor());
o.AddInterceptors(new ModifiableInterceptor());
o.AddInterceptors(new SoftDeleteInterceptor());
});

Context = services.BuildServiceProvider().GetService<TestDbContext>();

Expand Down Expand Up @@ -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));
Expand All @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions test/E13.Common.Data.Db.Tests/Sample/TestCreatable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
}
Loading