Backend/e-suite.Database.Audit/e-suite.Database.Audit/AuditEngine/AuditEngineCore.cs

513 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq.Dynamic.Core;
using System.Linq.Expressions;
using System.Reflection;
using e_suite.Database.Audit.Attributes;
using e_suite.Database.Audit.Extensions;
using e_suite.Database.Audit.Tables.Audit;
using eSuite.Core.Clock;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace e_suite.Database.Audit.AuditEngine;
public class AuditEngineCore
{
private readonly AuditableEntityContext _auditableEntityContext;
private readonly IClock _clock;
public AuditEngineCore(AuditableEntityContext auditableEntityContext, IClock clock)
{
_auditableEntityContext = auditableEntityContext;
_clock = clock;
}
#pragma warning disable IDE0060 // Remove unused parameter
public List<AuditRecorder> OnBeforeSaveChanges(AuditableEntityContext dbContext, AuditUserDetails auditUserDetails, CancellationToken cancellationToken)
#pragma warning restore IDE0060 // Remove unused parameter
{
if (!_auditableEntityContext.ChangeTracker.AutoDetectChangesEnabled)
{
_auditableEntityContext.ChangeTracker.DetectChanges();
}
var auditEntries = new List<AuditRecorder>();
foreach (var entry in _auditableEntityContext.ChangeTracker.Entries())
{
cancellationToken.ThrowIfCancellationRequested();
if (HasNoAuditAttribute(entry.Entity) || entry.State == EntityState.Detached
|| entry.State == EntityState.Unchanged)
continue;
var auditEntry = new AuditRecorder(entry, auditUserDetails, _clock);
auditEntry.AuditType = entry.State switch
{
EntityState.Added => AuditType.Create,
EntityState.Deleted => AuditType.Purge,
EntityState.Modified => AuditType.Update,
_ => auditEntry.AuditType
};
foreach (var property in entry.Properties)
{
cancellationToken.ThrowIfCancellationRequested();
if (property.IsTemporary)
{
continue;
}
var propertyName = property.Metadata.Name;
var propertyInfo = entry.Entity.GetType().GetProperties().Single(x => x.Name == propertyName);
var isLastUpdated = propertyInfo.CustomAttributes.Any(x => x.AttributeType == typeof(AuditLastUpdatedAttribute));
if (isLastUpdated)
{
continue;
}
// Value converter + comparer
var converter = property.Metadata.GetValueConverter(); //todo need a unit test for when a value converter is present on the table.
var comparer = property.Metadata.GetValueComparer();
// Raw CLR values
var currentClr = property.CurrentValue;
var oldClr = property.OriginalValue;
// Convert to provider (JSON for Tasks)
var currentValue = converter?.ConvertToProvider(currentClr) ?? currentClr;
var oldValue = converter?.ConvertToProvider(oldClr) ?? oldClr;
var isSoftDelete =
propertyInfo.CustomAttributes.SingleOrDefault(x =>
x.AttributeType == typeof(AuditSoftDeleteAttribute));
if (isSoftDelete != null && auditEntry.AuditType == AuditType.Update)
{
if (property.IsModified)
{
if (!currentValue!.Equals(oldValue))
{
var deletedValue = isSoftDelete.ConstructorArguments[0];
auditEntry.AuditType = deletedValue.Value!.Equals(currentValue)
? AuditType.Delete
: AuditType.Restore;
continue;
}
}
}
var isRedacted =
propertyInfo.CustomAttributes.Any(x => x.AttributeType == typeof(RedactAuditAttribute));
var currentDisplayName = GetNewName(property) ?? GetEnumAsString(currentValue);
var oldDisplayName = GetOldName(property) ?? GetEnumAsString(oldValue);
// Compare using EFs own comparer if available
var valuesMatch = comparer != null
? comparer.Equals(currentClr, oldClr)
: Equals(currentValue, oldValue);
if (isRedacted)
{
const string redacted = "<Redacted>";
currentValue = redacted;
oldValue = redacted;
currentDisplayName = null;
oldDisplayName = null;
}
switch (entry.State)
{
case EntityState.Added:
auditEntry.Fields[propertyName] = new Change
{
NewValue = currentValue,
NewDisplayName = currentDisplayName
};
break;
case EntityState.Deleted:
auditEntry.Fields[propertyName] = new Change
{
OldValue = oldValue,
OldDisplayName = oldDisplayName
};
break;
case EntityState.Modified:
if (!valuesMatch)
{
auditEntry.Fields[propertyName] = new Change
{
OldValue = oldValue,
OldDisplayName = oldDisplayName,
NewValue = currentValue,
NewDisplayName = currentDisplayName
};
}
break;
}
}
if (auditEntry.Fields.Count != 0 | auditEntry.AuditType != AuditType.Update )
auditEntries.Add(auditEntry);
}
return auditEntries;
}
public void OnAfterSaveChanges(AuditableEntityContext dbContext, List<AuditRecorder> auditEntries, CancellationToken cancellationToken)
{
foreach (var auditEntry in auditEntries)
{
cancellationToken.ThrowIfCancellationRequested();
if (auditEntry.Entry != null)
auditEntry.DrillUp = TraceDrillUp(dbContext, auditEntry.Entry.Entity, cancellationToken);
AddAuditEntry(auditEntry, cancellationToken);
}
}
private void AddAuditEntry(AuditRecorder auditEntry, CancellationToken cancellationToken)
{
var auditLog = _auditableEntityContext.AuditDetails.Add(auditEntry.ToAudit());
cancellationToken.ThrowIfCancellationRequested();
var childAuditDrillUp = auditEntry.DrillUp;
var childDrillDownEntity = new AuditEntry
{
AuditLog = auditLog.Entity,
DisplayName = childAuditDrillUp.EntryName,
EntityName = childAuditDrillUp.EntityName,
PrimaryKey = childAuditDrillUp.ToPrimaryKeyJson(),
};
_auditableEntityContext.AuditEntries.Add(childDrillDownEntity);
childDrillDownEntity.IsPrimary = true;
foreach (var parent in childAuditDrillUp.Parents)
{
cancellationToken.ThrowIfCancellationRequested();
AddParent(auditLog.Entity, childDrillDownEntity, parent, cancellationToken);
}
}
private void AddParent(AuditDetail auditLog, AuditEntry childDrillDownEntity, AuditDrillUp parent, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var parentDrillDownEntity = new AuditEntry
{
AuditLog = auditLog,
DisplayName = parent.EntryName,
EntityName = parent.EntityName,
PrimaryKey = parent.ToPrimaryKeyJson(),
};
_auditableEntityContext.AuditEntries.Add(childDrillDownEntity);
_auditableEntityContext.AuditDrillHierarchies.Add(new AuditDrillHierarchy
{
ParentAuditDrillDownEntity = parentDrillDownEntity,
ChildAuditDrillDownEntity = childDrillDownEntity
});
foreach (var grandparent in parent.Parents)
AddParent(auditLog, parentDrillDownEntity, grandparent, cancellationToken);
}
private AuditDrillUp TraceDrillUp(AuditableEntityContext dbContext, object entity, CancellationToken cancellationToken)
{
var type = entity.GetType();
var drillUp = new AuditDrillUp
{
EntityName = type.FullName ?? type.Name,
};
try
{
drillUp.EntryName = GetAuditName(entity) ?? string.Empty;
}
catch
{
LoadForeignKeysIfNotAlreadyLoaded(dbContext, entity, cancellationToken);
drillUp.EntryName = GetAuditName(entity) ?? string.Empty;
}
var properties = type.GetProperties();
foreach (var property in properties)
{
cancellationToken.ThrowIfCancellationRequested();
var isPrimaryKey = property.CustomAttributes.SingleOrDefault(x => x.AttributeType == typeof(KeyAttribute)) != null;
if (isPrimaryKey)
{
drillUp.PrimaryKey[property.Name] = property.GetValue(entity);
}
var isLastUpdated = property.CustomAttributes.Any(x => x.AttributeType == typeof(AuditLastUpdatedAttribute));
if (isLastUpdated)
{
property.SetValue(entity, _clock.GetNow);
}
var isParent = property.CustomAttributes.SingleOrDefault(x => x.AttributeType == typeof(AuditParentAttribute)) != null;
if (isParent)
{
LoadParentIfNotAlreadyLoaded(dbContext, property, entity, cancellationToken);
var parent = property.GetValue(entity);
if (parent != null)
{
var parentDrillUp = TraceDrillUp(dbContext, parent, cancellationToken);
drillUp.Parents.Add(parentDrillUp);
}
}
}
return drillUp;
}
private static void LoadForeignKeysIfNotAlreadyLoaded(AuditableEntityContext dbContext, object entity, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var type = entity.GetType();
var properties = type.GetProperties();
foreach (var property in properties)
{
var foreignKeyAttribute = property.CustomAttributes.SingleOrDefault(x => x.AttributeType == typeof(ForeignKeyAttribute));
var lookupFieldName = foreignKeyAttribute?.ConstructorArguments[0].Value?.ToString();
if (lookupFieldName == null)
continue;
var lookupField = type.GetProperty(lookupFieldName);
var lookupValue = lookupField?.GetValue(entity);
if (lookupValue == null)
continue;
cancellationToken.ThrowIfCancellationRequested();
var dbSetProperty = FindDbSet(dbContext, property.PropertyType);
if (dbSetProperty == null)
continue;
cancellationToken.ThrowIfCancellationRequested();
if (dbSetProperty.GetValue(dbContext) is not IQueryable dbSet)
continue;
cancellationToken.ThrowIfCancellationRequested();
var primaryKeyProperty = GetPrimaryKeyProperty(dbSet.ElementType);
if (primaryKeyProperty == null)
continue;
cancellationToken.ThrowIfCancellationRequested();
var result = FindObjectByProperty(dbSet, primaryKeyProperty, lookupValue);
cancellationToken.ThrowIfCancellationRequested();
property.SetValue(entity, result);
}
}
private static void LoadParentIfNotAlreadyLoaded(AuditableEntityContext dbContext, PropertyInfo property, object entity, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var parent = property.GetValue(entity);
if (parent != null)
return;
cancellationToken.ThrowIfCancellationRequested();
var foreignKeyAttribute = property.CustomAttributes.SingleOrDefault(x => x.AttributeType == typeof(ForeignKeyAttribute));
var lookupFieldName = foreignKeyAttribute?.ConstructorArguments[0].Value?.ToString();
if (lookupFieldName == null)
return;
cancellationToken.ThrowIfCancellationRequested();
var entityType = entity.GetType();
var lookupField = entityType.GetProperty(lookupFieldName);
var lookupValue = lookupField?.GetValue(entity);
if (lookupValue == null)
return;
cancellationToken.ThrowIfCancellationRequested();
var dbSetProperty = FindDbSet(dbContext, property.PropertyType);
if (dbSetProperty == null)
return;
cancellationToken.ThrowIfCancellationRequested();
if (dbSetProperty.GetValue(dbContext) is not IQueryable dbSet)
return;
cancellationToken.ThrowIfCancellationRequested();
var primaryKeyProperty = GetPrimaryKeyProperty(dbSet.ElementType);
if (primaryKeyProperty == null)
return;
cancellationToken.ThrowIfCancellationRequested();
var result = FindObjectByProperty(dbSet, primaryKeyProperty, lookupValue);
cancellationToken.ThrowIfCancellationRequested();
property.SetValue(entity, result);
}
private static object FindObjectByProperty(IQueryable query, PropertyInfo primaryKeyProperty, object lookupValue)
{
var parameter = Expression.Parameter(query.ElementType);
var propExpr = Expression.Property(parameter, primaryKeyProperty);
var lambdaBody = Expression.Equal(propExpr, Expression.Constant(lookupValue, primaryKeyProperty.PropertyType));
var filterEFn = Expression.Lambda(lambdaBody, parameter);
return query.Where(filterEFn).FirstOrDefault();
}
private static PropertyInfo? GetPrimaryKeyProperty(Type entityType)
{
var entityProperties = entityType.GetProperties();
foreach (var entityProperty in entityProperties)
{
if (entityProperty.CustomAttributes.SingleOrDefault(x => x.AttributeType == typeof(KeyAttribute)) != null)
{
return entityProperty;
}
}
return null;
}
private static PropertyInfo? FindDbSet(AuditableEntityContext dbContext, Type type)
{
var thisProperties = dbContext.GetType().GetProperties();
foreach (var propertyInfo in thisProperties)
{
if (!propertyInfo.PropertyType.IsGenericType)
continue;
if (propertyInfo.PropertyType.GetGenericTypeDefinition() != typeof(DbSet<>))
continue;
if (propertyInfo.PropertyType.GenericTypeArguments[0] == type)
return propertyInfo;
}
return null;
}
private static string? GetEnumAsString(object? enumValue)
{
if (enumValue == null || !enumValue.GetType().IsEnum)
return null;
var displayAttribute = ((Enum)enumValue).GetAttribute<DisplayAttribute>();
return displayAttribute != null ? displayAttribute.Name : enumValue.ToString();
}
private static string? GetNewName(PropertyEntry property)
{
var foreignKeyPropertyName = GetForeignKeyPropertyName(property);
if (foreignKeyPropertyName == null)
return null;
var foreignKeyPropertyType = GetForeignKeyPropertyType(property, foreignKeyPropertyName);
if (foreignKeyPropertyType == null)
return null;
var value = property.EntityEntry.Entity.GetType().GetProperty(foreignKeyPropertyType.Name)
?.GetValue(property.EntityEntry.Entity, null);
return GetAuditName(value);
}
private static string? GetAuditName(object? value)
{
if (value == null)
return null;
var properties = value.GetType().GetProperties();
foreach (var propertyInfo in properties)
{
var auditNameProperty =
propertyInfo.CustomAttributes.SingleOrDefault(x => x.AttributeType == typeof(AuditNameAttribute));
if (auditNameProperty == null)
continue;
var actualValue = propertyInfo.GetValue(value, null);
{
return actualValue?.ToString();
}
}
return null;
}
private string? GetOldName(PropertyEntry property)
{
if (property.OriginalValue == null)
return null;
var foreignKeyPropertyName = GetForeignKeyPropertyName(property);
if (foreignKeyPropertyName == null)
return null;
var foreignKeyPropertyType = GetForeignKeyPropertyType(property, foreignKeyPropertyName);
if (foreignKeyPropertyType == null)
return null;
var dbSetProperty = FindDbSet(_auditableEntityContext, foreignKeyPropertyType.PropertyType)!;
var dbSet = (dbSetProperty.GetValue(_auditableEntityContext) as IQueryable)!;
var primaryKeyProperty = GetPrimaryKeyProperty(dbSet.ElementType)!;
var value = FindObjectByProperty(dbSet, primaryKeyProperty, property.OriginalValue!);
return GetAuditName(value);
}
private static PropertyInfo? GetForeignKeyPropertyType(PropertyEntry property, string foreignKeyPropertyName)
{
var entityProperties = property.EntityEntry.Entity.GetType().GetProperties();
foreach (var entityProperty in entityProperties)
{
if (entityProperty.Name == foreignKeyPropertyName)
{
return entityProperty;
}
}
return null;
}
private static string? GetForeignKeyPropertyName(PropertyEntry property)
{
var propertyInfo = property.EntityEntry.Entity.GetType().GetProperties();
foreach (var entityProperty in propertyInfo)
{
var foreignKeyAttributeData = entityProperty.CustomAttributes.SingleOrDefault(x => x.AttributeType == typeof(ForeignKeyAttribute));
if (foreignKeyAttributeData != null)
{
if (foreignKeyAttributeData.ConstructorArguments.Any(x => x.Value!.Equals(property.Metadata.Name)))
{
return entityProperty.Name;
}
}
}
return null;
}
private static bool HasNoAuditAttribute(object entity)
{
var type = entity.GetType();
foreach (var attribute in type.CustomAttributes)
{
if (attribute.AttributeType == typeof(NoAuditAttribute))
{
return true;
}
}
return false;
}
}