| | | 1 | | using Microsoft.EntityFrameworkCore.Diagnostics; |
| | | 2 | | |
| | | 3 | | namespace ZhEaIsNsAaBn.Infrastructure.Core; |
| | | 4 | | |
| | | 5 | | /// <summary> |
| | | 6 | | /// Stamps the audit fields on <c>SaveChanges</c>, for one context (added to its options by <c>WithAuditing<TId>() |
| | | 7 | | /// <c>UseAuditing<TId>()</c>). Fields come from the Domain interfaces or the Domain attributes; entities without |
| | | 8 | | /// <list type="bullet"> |
| | | 9 | | /// <item><b>Added:</b> <c>CreatedDate</c>, <c>CreatedById</c>, <c>CreatedByUser</c>. Without a current user, a creator |
| | | 10 | | /// (non-default) is kept; an id without a name gets the id's text.</item> |
| | | 11 | | /// <item><b>Modified:</b> <c>UpdatedDate</c>, <c>UpdatedById</c>, <c>UpdatedByUser</c>. The created fields, the soft-de |
| | | 12 | | /// deleted fields are <b>never written by an update</b> (a disconnected <c>Update( entity )</c> can't reset them, and a |
| | | 13 | | /// soft-deleted row back; use <c>Restore[Async]</c>).</item> |
| | | 14 | | /// <item><b>Deleted:</b> soft delete — the flag, <c>DeletedDate</c>, <c>DeletedById</c>, <c>DeletedByUser</c> — when th |
| | | 15 | | /// soft-delete flag and isn't <c>HardDelete<T>()</c>; otherwise a real delete. Properties edited before <c>Remove |
| | | 16 | | /// it (the created fields stay protected).</item> |
| | | 17 | | /// </list> |
| | | 18 | | /// The user is <see cref="ICurrentUserProvider{TId}"/>'s, else the system user; the time is the <see cref="TimeProvider |
| | | 19 | | /// </summary> |
| | | 20 | | /// <typeparam name="TId">The user key type.</typeparam> |
| | | 21 | | public sealed class AuditInterceptor< TId > : SaveChangesInterceptor, IAuditStamper |
| | | 22 | | where TId : struct, IEquatable<TId> |
| | | 23 | | { |
| | | 24 | | private readonly AuditOptions<TId> _options; |
| | | 25 | | private readonly ICurrentUserProvider<TId>? _currentUser; |
| | | 26 | | |
| | | 27 | | |
| | | 28 | | /// <summary>Creates the interceptor.</summary> |
| | | 29 | | /// <param name="options">The settings; <see cref="AuditOptions{TId}.SystemUser"/> must have been called.</param> |
| | | 30 | | /// <param name="currentUser">The current user; <see langword="null"/> stamps the system user always.</param> |
| | | 31 | | /// <exception cref="ArgumentNullException"><paramref name="options"/> is <see langword="null"/>.</exception> |
| | | 32 | | /// <exception cref="InvalidOperationException">No system user is set.</exception> |
| | 12 | 33 | | public AuditInterceptor( AuditOptions<TId> options, ICurrentUserProvider<TId>? currentUser = null ) |
| | | 34 | | { |
| | 12 | 35 | | ArgumentNullException.ThrowIfNull( options ); |
| | | 36 | | |
| | 12 | 37 | | options.Validate(); |
| | | 38 | | |
| | 12 | 39 | | _options = options; |
| | 12 | 40 | | _currentUser = currentUser; |
| | 12 | 41 | | } |
| | | 42 | | |
| | | 43 | | |
| | 168 | 44 | | Type IAuditStamper.IdType => typeof( TId ); |
| | | 45 | | |
| | | 46 | | |
| | | 47 | | /// <inheritdoc /> |
| | | 48 | | public override InterceptionResult<int> SavingChanges( DbContextEventData eventData, InterceptionResult<int> result |
| | | 49 | | { |
| | 0 | 50 | | ArgumentNullException.ThrowIfNull( eventData ); |
| | | 51 | | |
| | 0 | 52 | | if ( eventData.Context is { } context ) { Apply( context ); } |
| | | 53 | | |
| | | 54 | | |
| | 0 | 55 | | return result; |
| | | 56 | | } |
| | | 57 | | |
| | | 58 | | /// <inheritdoc /> |
| | | 59 | | public override ValueTask<InterceptionResult<int>> SavingChangesAsync( DbContextEventData eventData, InterceptionRes |
| | | 60 | | { |
| | 160 | 61 | | ArgumentNullException.ThrowIfNull( eventData ); |
| | | 62 | | |
| | 320 | 63 | | if ( eventData.Context is { } context ) { Apply( context ); } |
| | | 64 | | |
| | | 65 | | |
| | 160 | 66 | | return ValueTask.FromResult( result ); |
| | | 67 | | } |
| | | 68 | | |
| | | 69 | | |
| | 24 | 70 | | AuditStamp IAuditStamper.Stamp() => Stamp(); |
| | | 71 | | |
| | 8 | 72 | | bool IAuditStamper.IsHardDelete( Type entityType ) => _options.IsHardDelete( entityType ); |
| | | 73 | | |
| | | 74 | | |
| | | 75 | | private AuditStamp Stamp() |
| | | 76 | | { |
| | 184 | 77 | | var now = _options.TimeProvider.GetLocalNow().DateTime; |
| | | 78 | | |
| | 184 | 79 | | return _currentUser?.Id is { } id |
| | 184 | 80 | | ? new AuditStamp( now, id, _currentUser.Name ?? id.ToString() ?? string.Empty ) |
| | 184 | 81 | | : new AuditStamp( now, _options.SystemUserId!.Value, _options.SystemUserName! ); |
| | | 82 | | } |
| | | 83 | | |
| | | 84 | | private void Apply( DbContext context ) |
| | | 85 | | { |
| | | 86 | | // Entries() runs DetectChanges first; materialise, because soft deletes change states while we walk. |
| | 160 | 87 | | var entries = context.ChangeTracker.Entries().Where( e => e.State is EntityState.Added or EntityState.Modified o |
| | | 88 | | |
| | 160 | 89 | | if ( entries.Count == 0 ) { return; } |
| | | 90 | | |
| | | 91 | | |
| | 160 | 92 | | var stamp = Stamp(); |
| | 160 | 93 | | var hasUser = _currentUser?.Id is not null; |
| | | 94 | | |
| | 712 | 95 | | foreach ( var entry in entries ) |
| | | 96 | | { |
| | 196 | 97 | | var map = AuditMap.For( entry.Metadata.ClrType ); |
| | | 98 | | |
| | 196 | 99 | | if ( map.IsEmpty ) { continue; } |
| | | 100 | | |
| | | 101 | | |
| | 172 | 102 | | switch ( entry.State ) |
| | | 103 | | { |
| | | 104 | | case EntityState.Added: |
| | 108 | 105 | | Set( entry, map, AuditRole.CreatedDate, stamp.Now ); |
| | | 106 | | |
| | 200 | 107 | | if ( hasUser ) { SetCreator( entry, map, stamp.UserId, stamp.UserName ); } |
| | 16 | 108 | | else { KeepOrSetCreator( entry, map, stamp ); } |
| | | 109 | | |
| | 16 | 110 | | break; |
| | | 111 | | |
| | | 112 | | case EntityState.Modified: |
| | 20 | 113 | | Keep( entry, map, AuditRole.CreatedDate, AuditRole.CreatedById, AuditRole.CreatedByUser, |
| | 20 | 114 | | AuditRole.SoftDeleteFlag, AuditRole.DeletedDate, AuditRole.DeletedById, AuditRole.DeletedByUse |
| | | 115 | | |
| | 20 | 116 | | Set( entry, map, AuditRole.UpdatedDate, stamp.Now ); |
| | 20 | 117 | | Set( entry, map, AuditRole.UpdatedById, stamp.UserId ); |
| | 20 | 118 | | Set( entry, map, AuditRole.UpdatedByUser, stamp.UserName ); |
| | | 119 | | |
| | 20 | 120 | | break; |
| | | 121 | | |
| | 44 | 122 | | case EntityState.Deleted when map.SoftDeleteFlag is not null && Mapped( entry, map.SoftDeleteFlag.Name ) |
| | 36 | 123 | | SoftDelete( context, entry, map, stamp ); |
| | | 124 | | |
| | | 125 | | break; |
| | | 126 | | } |
| | | 127 | | } |
| | 160 | 128 | | } |
| | | 129 | | |
| | | 130 | | private void SetCreator( EntityEntry entry, AuditMap map, object userId, string userName ) |
| | | 131 | | { |
| | 108 | 132 | | Set( entry, map, AuditRole.CreatedById, userId ); |
| | 108 | 133 | | Set( entry, map, AuditRole.CreatedByUser, userName ); |
| | 108 | 134 | | } |
| | | 135 | | |
| | | 136 | | /// No current user: a creator the app already set (e.g. a background job) is kept; a missing id is the system user' |
| | | 137 | | /// name is the kept id's text, else the system user's. |
| | | 138 | | private void KeepOrSetCreator( EntityEntry entry, AuditMap map, AuditStamp stamp ) |
| | | 139 | | { |
| | 16 | 140 | | var id = Current( entry, map, AuditRole.CreatedById ); |
| | 16 | 141 | | var name = Current( entry, map, AuditRole.CreatedByUser ) as string; |
| | | 142 | | |
| | 16 | 143 | | var keepId = id is not null && !Equals( id, default( TId ) ); |
| | 16 | 144 | | var keepName = !string.IsNullOrEmpty( name ); |
| | | 145 | | |
| | | 146 | | |
| | 16 | 147 | | SetCreator( entry, |
| | 16 | 148 | | map, |
| | 16 | 149 | | keepId ? id! : stamp.UserId, |
| | 16 | 150 | | keepName ? name! : keepId ? Convert.ToString( id, CultureInfo.InvariantCulture ) ?? string.Empty : s |
| | 16 | 151 | | } |
| | | 152 | | |
| | | 153 | | private void SoftDelete( DbContext context, EntityEntry entry, AuditMap map, AuditStamp stamp ) |
| | | 154 | | { |
| | | 155 | | // Edits made before Remove() are saved with the soft delete; the created fields stay protected. |
| | 36 | 156 | | var edited = entry.Properties |
| | 36 | 157 | | .Where( p => !p.Metadata.IsPrimaryKey() && !p.Metadata.GetValueComparer().Equals( p.CurrentVal |
| | 36 | 158 | | .Select( p => p.Metadata.Name ) |
| | 36 | 159 | | .ToList(); |
| | | 160 | | |
| | | 161 | | // Unchanged first, so only the delete fields and the edited ones are written (not every column). |
| | 36 | 162 | | entry.State = EntityState.Unchanged; |
| | | 163 | | |
| | 96 | 164 | | foreach ( var name in edited ) { entry.Property( name ).IsModified = true; } |
| | | 165 | | |
| | 36 | 166 | | Keep( entry, map, AuditRole.CreatedDate, AuditRole.CreatedById, AuditRole.CreatedByUser ); |
| | | 167 | | |
| | 36 | 168 | | Set( entry, map, AuditRole.SoftDeleteFlag, true ); |
| | 36 | 169 | | Set( entry, map, AuditRole.DeletedDate, stamp.Now ); |
| | 36 | 170 | | Set( entry, map, AuditRole.DeletedById, stamp.UserId ); |
| | 36 | 171 | | Set( entry, map, AuditRole.DeletedByUser, stamp.UserName ); |
| | | 172 | | |
| | | 173 | | |
| | | 174 | | // Owned dependents were marked Deleted with their owner; they stay with the soft-deleted row. |
| | 88 | 175 | | foreach ( var navigation in entry.Navigations.Where( n => n.Metadata.TargetEntityType.IsOwned() ) ) |
| | | 176 | | { |
| | 8 | 177 | | var owned = navigation switch |
| | 8 | 178 | | { |
| | 4 | 179 | | ReferenceEntry reference => reference.CurrentValue is null ? Array.Empty<object>() : [ |
| | 4 | 180 | | CollectionEntry collection => collection.CurrentValue?.Cast<object>().ToArray() ?? Array |
| | 0 | 181 | | _ => Array.Empty<object>() |
| | 8 | 182 | | }; |
| | | 183 | | |
| | 52 | 184 | | foreach ( var dependent in owned.Select( context.Entry ).Where( d => d.State == EntityState.Deleted ) ) { de |
| | | 185 | | } |
| | 36 | 186 | | } |
| | | 187 | | |
| | | 188 | | private void Set( EntityEntry entry, AuditMap map, AuditRole role, object value ) |
| | | 189 | | { |
| | 568 | 190 | | if ( map[ role ] is not { } property || !Mapped( entry, property.Name ) ) { return; } |
| | | 191 | | |
| | 632 | 192 | | if ( role is AuditRole.CreatedById or AuditRole.UpdatedById or AuditRole.DeletedById ) { ( (IAuditStamper) this |
| | | 193 | | |
| | | 194 | | |
| | 488 | 195 | | entry.Property( property.Name ).CurrentValue = value; |
| | 488 | 196 | | } |
| | | 197 | | |
| | | 198 | | private static void Keep( EntityEntry entry, AuditMap map, params AuditRole[] roles ) |
| | | 199 | | { |
| | 608 | 200 | | foreach ( var role in roles ) |
| | | 201 | | { |
| | 248 | 202 | | if ( map[ role ] is not { } property || !Mapped( entry, property.Name ) ) { continue; } |
| | | 203 | | |
| | | 204 | | |
| | 240 | 205 | | var value = entry.Property( property.Name ); |
| | | 206 | | |
| | 240 | 207 | | if ( !value.IsModified ) { continue; } |
| | | 208 | | |
| | | 209 | | |
| | 52 | 210 | | value.CurrentValue = value.OriginalValue; |
| | 52 | 211 | | value.IsModified = false; |
| | | 212 | | } |
| | 56 | 213 | | } |
| | | 214 | | |
| | | 215 | | private static object? Current( EntityEntry entry, AuditMap map, AuditRole role ) |
| | 32 | 216 | | => map[ role ] is { } property && Mapped( entry, property.Name ) ? entry.Property( property.Name ).CurrentValue |
| | | 217 | | |
| | 800 | 218 | | private static bool Mapped( EntityEntry entry, string propertyName ) => entry.Metadata.FindProperty( propertyName ) |
| | | 219 | | } |