Skip to content

Commit

Permalink
feat: add simple EF entity stamping hook
Browse files Browse the repository at this point in the history
  • Loading branch information
ascott18 committed Nov 14, 2023
1 parent 309fc64 commit fa1639c
Show file tree
Hide file tree
Showing 9 changed files with 338 additions and 2 deletions.
1 change: 1 addition & 0 deletions docs/cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"vite",
"unplugin",
"IIFE",
"Stampable",

"POCOs",
"dtos",
Expand Down
57 changes: 57 additions & 0 deletions docs/topics/audit-logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,60 @@ This merging only happens together if the existing audit record is recent; the d
## Caveats
Only changes that are tracked by the `DbContext`'s `ChangeTracker` can be audited. Changes that are made with raw SQL, or changes that are made with bulk update functions like [`ExecuteUpdate` or `ExecuteDelete`](https://learn.microsoft.com/en-us/ef/core/performance/efficient-updating?tabs=ef7) will not be audited using this package.


## Audit Stamping

A lightweight alternative or addition to full audit logging is audit stamping - the process of setting fields like `CreatedBy` or `ModifiedOn` on each changed entity. This cannot record a history of exact changes, but can at least record the age of an entity and how recently it changed.

Coalesce offers a simple mechanism to register an Entity Framework save interceptor to perform this kind of action (this **does NOT** require the `IntelliTect.Coalesce.AuditLogging` package). This mechanism operations on all saves that go through Entity Framework, eliminating the need to perform this manually in individual Behaviors, Services, and Custom Methods:

``` c#
services.AddDbContext<AppDbContext>(options => options
.UseSqlServer(connectionString) // (or other provider)
.UseStamping<TrackingBase>((entity, user) => entity.SetTracking(user))
);
```

In the above example, `TrackingBase` is an interface or class that you would write as part of your application that defines the properties and mechanisms for performing the tracking operation. For example:

``` c#
public abstract class TrackingBase
{
[Read, Display(Order = 1000)]
public ApplicationUser CreatedBy { get; set; }

[Read, Display(Order = 1001)]
public string? CreatedById { get; set; }

[Read, Display(Order = 1002)]
public DateTimeOffset CreatedOn { get; set; }


[Read, Display(Order = 1003)]
public ApplicationUser ModifiedBy { get; set; }

[Read, Display(Order = 1004)]
public string? ModifiedById { get; set; }

[Read, Display(Order = 1005)]
public DateTimeOffset ModifiedOn { get; set; }


public void SetTracking(ClaimsPrincipal? user)
=> SetTracking(user?.GetApplicationUserId());

public void SetTracking(int? userId)
{
if (this.CreatedById == null)
{
this.CreatedById = userId;
this.CreatedOn = DateTimeOffset.Now;
}

this.ModifiedById = userId;
this.ModifiedOn = DateTimeOffset.Now;
}
}
```

The overload `UseStamping<TStampable>` will provide the `ClaimsPrincipal` from the current HTTP request if present, defaulting to `null` if an operation occurs outside an HTTP request (e.g. a background job). The overloads `UseStamping<TStampable, TService>` and `UseStamping<TStampable, TService1, TService2>` can be used to inject services into the operation. If more than two services are needed, you should wrap those dependencies up into an additional services that takes them as dependencies.
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using IntelliTect.Coalesce.TypeDefinition;
using IntelliTect.Coalesce.Utilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Metadata;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public AppDbContext(string memoryDatabaseName)
}).Options)
{ }

public AppDbContext(DbContextOptions<AppDbContext> options)
public AppDbContext(DbContextOptions options)
: base(options)
{ }

Expand Down
131 changes: 131 additions & 0 deletions src/IntelliTect.Coalesce.Tests/Tests/EntityStampingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using IntelliTect.Coalesce.Tests.TargetClasses.TestDbContext;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Linq;
using System.Threading.Tasks;
using Xunit;

#nullable enable

namespace IntelliTect.Coalesce.AuditLogging.Tests;

public class EntityStampingTests
{
public AppDbContext Db { get; }

class TestService
{
public string TestValue => "42";
}

public EntityStampingTests()
{
var appSp = new ServiceCollection()
.AddSingleton<TestService>()
.BuildServiceProvider();

Db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.UseApplicationServiceProvider(appSp)
.Options
);
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task WithCustomApplicationService_SetsStampValue(bool async)
{
var appSp = new ServiceCollection()
.AddSingleton<TestService>()
.BuildServiceProvider();

// Arrange
using var db = BuildDbContext(b => b
.UseStamping<Case, TestService>((obj, service) => obj.Title = service?.TestValue)
.UseApplicationServiceProvider(appSp)
);

// Act
db.Cases.Add(new Case());
if (async) await db.SaveChangesAsync();
else db.SaveChanges();

// Assert
var entity = db.Cases.Single();
Assert.Equal("42", entity.Title);
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task WhenUserUnavailable_InjectsNull(bool async)
{
// Arrange
using var db = BuildDbContext(b => b
.UseStamping<Case>((obj, user) => obj.Title = user?.ToString() ?? "null")
);

// Act
db.Cases.Add(new Case());
if (async) await db.SaveChangesAsync();
else db.SaveChanges();

// Assert
var entity = db.Cases.Single();
Assert.Equal("null", entity.Title);
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task WhenServiceUnavailable_InjectsNull(bool async)
{
// Arrange
using var db = BuildDbContext(b => b
.UseStamping<Case, TestService>((obj, service) => obj.Title = service?.TestValue ?? "null")
);

// Act
db.Cases.Add(new Case());
if (async) await db.SaveChangesAsync();
else db.SaveChanges();

// Assert
var entity = db.Cases.Single();
Assert.Equal("null", entity.Title);
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task CanInjectContext(bool async)
{
// Arrange
using var db = BuildDbContext(b => b
.UseStamping<Case, AppDbContext>((obj, db) => obj.Title = db!.Database.ProviderName)
);

// Act
db.Cases.Add(new Case());
if (async) await db.SaveChangesAsync();
else db.SaveChanges();

// Assert
var entity = db.Cases.Single();
Assert.Equal("Microsoft.EntityFrameworkCore.InMemory", entity.Title);
}

private AppDbContext BuildDbContext(Func<DbContextOptionsBuilder<AppDbContext>, DbContextOptionsBuilder> setup)
{
var builder = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString());

var db = new AppDbContext(setup(builder).Options);

db.Database.EnsureCreated();

return db;
}
}
1 change: 1 addition & 0 deletions src/IntelliTect.Coalesce/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
using System.Runtime.InteropServices;

[assembly: InternalsVisibleTo("IntelliTect.Coalesce.CodeGeneration")]
[assembly: InternalsVisibleTo("IntelliTect.Coalesce.AuditLogging")]
[assembly: InternalsVisibleTo("IntelliTect.Coalesce.Tests")]
[assembly: InternalsVisibleTo("IntelliTect.Coalesce.CodeGeneration.Tests")]
71 changes: 71 additions & 0 deletions src/IntelliTect.Coalesce/Stamping/StampInterceptor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using IntelliTect.Coalesce.Utilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using System;
using System.Data;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace IntelliTect.Coalesce.Stamping;

internal class StampInterceptor<TStampable> : SaveChangesInterceptor
where TStampable : class
{
public Delegate Action { get; }
public Type[] Services { get; }

public StampInterceptor(Delegate action, Type[] services)
{
Action = action;
Services = services;
}

public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
return ValueTask.FromResult(SavingChanges(eventData, result));
}

public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
var db = eventData.Context!;
var provider = new EntityFrameworkServiceProvider(db);

var services = Services
.Select(t => t.IsAssignableFrom(db.GetType())
? db
: provider.GetService(t))
.ToArray();

var entities = db.ChangeTracker
.Entries<TStampable>()
.Where(x => x.State is EntityState.Added or EntityState.Modified)
.ToList();

foreach (var entry in entities)
{
Action.DynamicInvoke([entry.Entity, .. services]);
}

return result;
}
}

internal sealed class StampInterceptor<TStampable, TService> : StampInterceptor<TStampable>
where TStampable : class
{
public StampInterceptor(Action<TStampable, TService?> action)
: base(action, [typeof(TService)]) { }
}

internal sealed class StampInterceptor<TStampable, TService1, TService2> : StampInterceptor<TStampable>
where TStampable : class
{
public StampInterceptor(Action<TStampable, TService1?, TService2?> action)
: base(action, [typeof(TService1), typeof(TService2)]) { }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using IntelliTect.Coalesce.Stamping;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using System;
using System.Security.Claims;

namespace IntelliTect.Coalesce;

public static class StampingDbContextOptionsBuilderExtensions
{
/// <summary>
/// Adds a save interceptor that will perform a simple action during <see cref="DbContext.SaveChanges()"/>
/// on all <see cref="EntityState.Added"/> or <see cref="EntityState.Modified"/> entities.
/// The action will receive the <see cref="ClaimsPrincipal"/> from the current <see cref="HttpContext"/>.
/// </summary>
/// <typeparam name="TStampable">The type of entity to be stamped. Can be a base class or interface.</typeparam>
/// <param name="builder">The db context options to configure.</param>
/// <param name="stampAction">The action to take against the entity.</param>
public static DbContextOptionsBuilder UseStamping<TStampable>(
this DbContextOptionsBuilder builder,
Action<TStampable, ClaimsPrincipal?> stampAction
)
where TStampable : class
{
builder.AddInterceptors(new StampInterceptor<TStampable, IHttpContextAccessor>((target, accessor) =>
{
var user = accessor?.HttpContext?.User;
stampAction(target, user);
}));

return builder;
}

/// <summary>
/// Adds a save interceptor that will perform a simple action during <see cref="DbContext.SaveChanges()"/>
/// on all <see cref="EntityState.Added"/> or <see cref="EntityState.Modified"/> entities.
/// The action will receive the service specified by <typeparamref name="TService"/>.
/// </summary>
/// <typeparam name="TStampable">The type of entity to be stamped. Can be a base class or interface.</typeparam>
/// <typeparam name="TService">The service to dependency inject into the action.</typeparam>
/// <param name="builder">The db context options to configure.</param>
/// <param name="stampAction">The action to take against the entity.</param>
public static DbContextOptionsBuilder UseStamping<TStampable, TService>(
this DbContextOptionsBuilder builder,
Action<TStampable, TService?> stampAction
)
where TStampable : class
{
builder.AddInterceptors(new StampInterceptor<TStampable, TService>(stampAction));

return builder;
}

/// <summary>
/// Adds a save interceptor that will perform a simple action during <see cref="DbContext.SaveChanges()"/>
/// on all <see cref="EntityState.Added"/> or <see cref="EntityState.Modified"/> entities.
/// The action will receive the services specified by <typeparamref name="TService1"/> and <typeparamref name="TService2"/>.
/// </summary>
/// <typeparam name="TStampable">The type of entity to be stamped. Can be a base class or interface.</typeparam>
/// <typeparam name="TService1">A service to dependency inject into the action.</typeparam>
/// <typeparam name="TService2">A second service to dependency inject into the action.</typeparam>
/// <param name="builder">The db context options to configure.</param>
/// <param name="stampAction">The action to take against the entity.</param>
public static DbContextOptionsBuilder UseStamping<TStampable, TService1, TService2>(
this DbContextOptionsBuilder builder,
Action<TStampable, TService1?, TService2?> stampAction
)
where TStampable : class
{
builder.AddInterceptors(new StampInterceptor<TStampable, TService1, TService2>(stampAction));

return builder;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
using System;
using System.Linq;

namespace IntelliTect.Coalesce.AuditLogging.Internal;
namespace IntelliTect.Coalesce.Utilities;

internal class EntityFrameworkServiceProvider(DbContext db) : IServiceProvider
{
Expand Down

0 comments on commit fa1639c

Please sign in to comment.