Skip to content

Commit 33a3282

Browse files
authored
Merge pull request #1229 from fullstackhero/fix/identity-dbcontext-interceptor-loop
fix(persistence): add AuditableEntitySaveChangesInterceptor with async-safe recursion guard
2 parents 2546d0a + 4c5081e commit 33a3282

2 files changed

Lines changed: 118 additions & 1 deletion

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
using FSH.Framework.Core.Context;
2+
using FSH.Framework.Core.Domain;
3+
using Microsoft.EntityFrameworkCore;
4+
using Microsoft.EntityFrameworkCore.ChangeTracking;
5+
using Microsoft.EntityFrameworkCore.Diagnostics;
6+
7+
namespace FSH.Framework.Persistence.Inteceptors;
8+
9+
/// <summary>
10+
/// Interceptor that automatically populates audit metadata for entities implementing <see cref="IAuditableEntity"/>
11+
/// and handles soft delete for entities implementing <see cref="ISoftDeletable"/>.
12+
/// Uses an <see cref="AsyncLocal{T}"/> recursion guard to prevent StackOverflowException from nested SaveChanges calls.
13+
/// </summary>
14+
public sealed class AuditableEntitySaveChangesInterceptor : SaveChangesInterceptor
15+
{
16+
private readonly ICurrentUser _currentUser;
17+
private readonly TimeProvider _timeProvider;
18+
19+
private static readonly AsyncLocal<bool> _isSaving = new();
20+
21+
public AuditableEntitySaveChangesInterceptor(ICurrentUser currentUser, TimeProvider timeProvider)
22+
{
23+
_currentUser = currentUser;
24+
_timeProvider = timeProvider;
25+
}
26+
27+
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
28+
DbContextEventData eventData,
29+
InterceptionResult<int> result,
30+
CancellationToken cancellationToken = default)
31+
{
32+
ArgumentNullException.ThrowIfNull(eventData);
33+
34+
if (_isSaving.Value)
35+
{
36+
return await base.SavingChangesAsync(eventData, result, cancellationToken).ConfigureAwait(false);
37+
}
38+
39+
try
40+
{
41+
_isSaving.Value = true;
42+
UpdateAuditEntities(eventData.Context);
43+
return await base.SavingChangesAsync(eventData, result, cancellationToken).ConfigureAwait(false);
44+
}
45+
finally
46+
{
47+
_isSaving.Value = false;
48+
}
49+
}
50+
51+
public override InterceptionResult<int> SavingChanges(
52+
DbContextEventData eventData,
53+
InterceptionResult<int> result)
54+
{
55+
ArgumentNullException.ThrowIfNull(eventData);
56+
57+
if (_isSaving.Value)
58+
{
59+
return base.SavingChanges(eventData, result);
60+
}
61+
62+
try
63+
{
64+
_isSaving.Value = true;
65+
UpdateAuditEntities(eventData.Context);
66+
return base.SavingChanges(eventData, result);
67+
}
68+
finally
69+
{
70+
_isSaving.Value = false;
71+
}
72+
}
73+
74+
private void UpdateAuditEntities(DbContext? context)
75+
{
76+
if (context is null) return;
77+
78+
var userId = _currentUser.IsAuthenticated() ? _currentUser.GetUserId().ToString() : null;
79+
var now = _timeProvider.GetUtcNow();
80+
81+
foreach (var entry in context.ChangeTracker.Entries())
82+
{
83+
if (entry.Entity is IAuditableEntity)
84+
{
85+
if (entry.State == EntityState.Added)
86+
{
87+
entry.Property(nameof(IAuditableEntity.CreatedOnUtc)).CurrentValue = now;
88+
entry.Property(nameof(IAuditableEntity.CreatedBy)).CurrentValue = userId;
89+
}
90+
else if (entry.State == EntityState.Modified || entry.HasChangedOwnedEntities())
91+
{
92+
entry.Property(nameof(IAuditableEntity.LastModifiedOnUtc)).CurrentValue = now;
93+
entry.Property(nameof(IAuditableEntity.LastModifiedBy)).CurrentValue = userId;
94+
}
95+
}
96+
97+
if (entry.Entity is ISoftDeletable && entry.State == EntityState.Deleted)
98+
{
99+
entry.State = EntityState.Modified;
100+
entry.Property(nameof(ISoftDeletable.IsDeleted)).CurrentValue = true;
101+
entry.Property(nameof(ISoftDeletable.DeletedOnUtc)).CurrentValue = now;
102+
entry.Property(nameof(ISoftDeletable.DeletedBy)).CurrentValue = userId;
103+
}
104+
}
105+
}
106+
}
107+
108+
internal static class EntityEntryExtensions
109+
{
110+
public static bool HasChangedOwnedEntities(this EntityEntry entry) =>
111+
entry.References.Any(r =>
112+
r.TargetEntry is not null &&
113+
r.TargetEntry.Metadata.IsOwned() &&
114+
(r.TargetEntry.State == EntityState.Added || r.TargetEntry.State == EntityState.Modified));
115+
}

src/BuildingBlocks/Persistence/PersistenceExtensions.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using FSH.Framework.Persistence.Inteceptors;
1+
using FSH.Framework.Persistence.Inteceptors;
22
using FSH.Framework.Shared.Persistence;
33
using Microsoft.EntityFrameworkCore;
44
using Microsoft.EntityFrameworkCore.Diagnostics;
@@ -31,6 +31,8 @@ public static IServiceCollection AddHeroDatabaseOptions(this IServiceCollection
3131
.Validate(o => !string.IsNullOrWhiteSpace(o.Provider), "DatabaseOptions.Provider is required.")
3232
.ValidateOnStart();
3333
services.AddHostedService<DatabaseOptionsStartupLogger>();
34+
services.TryAddSingleton(TimeProvider.System);
35+
services.AddScoped<ISaveChangesInterceptor, AuditableEntitySaveChangesInterceptor>();
3436
services.AddScoped<ISaveChangesInterceptor, DomainEventsInterceptor>();
3537
return services;
3638
}

0 commit comments

Comments
 (0)