From 4c5081e7a05c5331ce5ecab0f4890ab2ca9d689b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9sar=20Castro?= Date: Mon, 16 Mar 2026 22:57:31 +0100 Subject: [PATCH] feat(persistence): add AuditableEntitySaveChangesInterceptor with recursion guard - Implemented AuditableEntitySaveChangesInterceptor to handle IAuditableEntity and ISoftDeletable. - Added a recursion guard using [ThreadStatic] bool _isSaving to prevent StackOverflowException. - Registered the interceptor globally in PersistenceExtensions. - Registered TimeProvider.System as a singleton service. - Registered DomainEventsInterceptor in PersistenceExtensions. --- .../AuditableEntitySaveChangesInterceptor.cs | 115 ++++++++++++++++++ .../Persistence/PersistenceExtensions.cs | 4 +- 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 src/BuildingBlocks/Persistence/Inteceptors/AuditableEntitySaveChangesInterceptor.cs diff --git a/src/BuildingBlocks/Persistence/Inteceptors/AuditableEntitySaveChangesInterceptor.cs b/src/BuildingBlocks/Persistence/Inteceptors/AuditableEntitySaveChangesInterceptor.cs new file mode 100644 index 0000000000..eb232175ff --- /dev/null +++ b/src/BuildingBlocks/Persistence/Inteceptors/AuditableEntitySaveChangesInterceptor.cs @@ -0,0 +1,115 @@ +using FSH.Framework.Core.Context; +using FSH.Framework.Core.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace FSH.Framework.Persistence.Inteceptors; + +/// +/// Interceptor that automatically populates audit metadata for entities implementing +/// and handles soft delete for entities implementing . +/// Uses an recursion guard to prevent StackOverflowException from nested SaveChanges calls. +/// +public sealed class AuditableEntitySaveChangesInterceptor : SaveChangesInterceptor +{ + private readonly ICurrentUser _currentUser; + private readonly TimeProvider _timeProvider; + + private static readonly AsyncLocal _isSaving = new(); + + public AuditableEntitySaveChangesInterceptor(ICurrentUser currentUser, TimeProvider timeProvider) + { + _currentUser = currentUser; + _timeProvider = timeProvider; + } + + public override async ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(eventData); + + if (_isSaving.Value) + { + return await base.SavingChangesAsync(eventData, result, cancellationToken).ConfigureAwait(false); + } + + try + { + _isSaving.Value = true; + UpdateAuditEntities(eventData.Context); + return await base.SavingChangesAsync(eventData, result, cancellationToken).ConfigureAwait(false); + } + finally + { + _isSaving.Value = false; + } + } + + public override InterceptionResult SavingChanges( + DbContextEventData eventData, + InterceptionResult result) + { + ArgumentNullException.ThrowIfNull(eventData); + + if (_isSaving.Value) + { + return base.SavingChanges(eventData, result); + } + + try + { + _isSaving.Value = true; + UpdateAuditEntities(eventData.Context); + return base.SavingChanges(eventData, result); + } + finally + { + _isSaving.Value = false; + } + } + + private void UpdateAuditEntities(DbContext? context) + { + if (context is null) return; + + var userId = _currentUser.IsAuthenticated() ? _currentUser.GetUserId().ToString() : null; + var now = _timeProvider.GetUtcNow(); + + foreach (var entry in context.ChangeTracker.Entries()) + { + if (entry.Entity is IAuditableEntity) + { + if (entry.State == EntityState.Added) + { + entry.Property(nameof(IAuditableEntity.CreatedOnUtc)).CurrentValue = now; + entry.Property(nameof(IAuditableEntity.CreatedBy)).CurrentValue = userId; + } + else if (entry.State == EntityState.Modified || entry.HasChangedOwnedEntities()) + { + entry.Property(nameof(IAuditableEntity.LastModifiedOnUtc)).CurrentValue = now; + entry.Property(nameof(IAuditableEntity.LastModifiedBy)).CurrentValue = userId; + } + } + + if (entry.Entity is ISoftDeletable && entry.State == EntityState.Deleted) + { + entry.State = EntityState.Modified; + entry.Property(nameof(ISoftDeletable.IsDeleted)).CurrentValue = true; + entry.Property(nameof(ISoftDeletable.DeletedOnUtc)).CurrentValue = now; + entry.Property(nameof(ISoftDeletable.DeletedBy)).CurrentValue = userId; + } + } + } +} + +internal static class EntityEntryExtensions +{ + public static bool HasChangedOwnedEntities(this EntityEntry entry) => + entry.References.Any(r => + r.TargetEntry is not null && + r.TargetEntry.Metadata.IsOwned() && + (r.TargetEntry.State == EntityState.Added || r.TargetEntry.State == EntityState.Modified)); +} diff --git a/src/BuildingBlocks/Persistence/PersistenceExtensions.cs b/src/BuildingBlocks/Persistence/PersistenceExtensions.cs index f96a927742..1dfea60315 100644 --- a/src/BuildingBlocks/Persistence/PersistenceExtensions.cs +++ b/src/BuildingBlocks/Persistence/PersistenceExtensions.cs @@ -1,4 +1,4 @@ -using FSH.Framework.Persistence.Inteceptors; +using FSH.Framework.Persistence.Inteceptors; using FSH.Framework.Shared.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; @@ -31,6 +31,8 @@ public static IServiceCollection AddHeroDatabaseOptions(this IServiceCollection .Validate(o => !string.IsNullOrWhiteSpace(o.Provider), "DatabaseOptions.Provider is required.") .ValidateOnStart(); services.AddHostedService(); + services.TryAddSingleton(TimeProvider.System); + services.AddScoped(); services.AddScoped(); return services; }