L'obiettivo è tenere traccia di chi ha modificato ed eliminato un'entità.
Quindi ho un'entità che implementa un'interfaccia:
interface IAuditable {
string ModifiedBy {get;set;}
}
class User: IAuditable {
public int UserId {get;set;}
public string UserName {get;set;}
public string ModifiedBy {get;set;}
[Timestamp]
public byte[] RowVersion { get; set; }
}
Ora l'operazione di rimozione del codice dell'entità potrebbe apparire così:
User user = context.Users.First();
user.ModifiedBy = CurrentUser.Name;
context.Users.Remove(employer);
context.SaveContext();
In realtà: l' aggiornamento ModifiedBy
non verrà mai eseguito (quando i trigger della mia cronologia db si aspettano di "gestirlo"). Solo l'istruzione delete verrà eseguita sul DB.
Voglio sapere come forzare "aggiornare" le entità / voci cancellate da EF Core (che implementa l'interfaccia specifica) se l'entità è stata modificata.
Nota: RowVersion
aggiunge ulteriore complessità.
PS Per inserire manualmente ulteriori chiamate SaveContext - ovviamente è un'opzione, ma vorrei avere una soluzione generica: molti vari aggiornamenti ed eliminazioni, quindi un SaveContext fa tutte le analisi.
Per aggiornare tali proprietà manualmente prima che SaveContext var deletedEntries = entries.Where(e => e.State == EntityState.Deleted && isAuditable(e))
non è un'opzione poiché può rovinare la gestione degli ordini dei blocchi EF Core e quindi provocare deadlock .
La soluzione più chiara sarebbe quella di rimanere con una chiamata SaveContext ma iniettare l'istruzione UPDATE su campi controllabili appena prima della chiamata EF CORE
DELETE
. Come raggiungere questo obiettivo? Forse qualcuno ha già la soluzione?
L'alternativa potrebbe essere "all'eliminazione non comporre l'istruzione DELETE ma chiama la procedura memorizzata che può accettare i campi controllabili come parametri"
Voglio sapere come iniettare la mia "istruzione UPDATE" appena prima che EF chiami la sua "dichiarazione DELETE"? Abbiamo tale API?
Domanda interessante. Al momento della stesura (EF Core 2.1.3), non esiste un'API pubblica di questo tipo. La seguente soluzione si basa sulle API interne, che in EF Core sono fortunatamente accessibili al pubblico con il tipico disclaimer delle API interne:
Questa API supporta l'infrastruttura Entity Framework Core e non deve essere utilizzata direttamente dal tuo codice. Questa API può cambiare o essere rimossa nelle versioni future.
Ora la soluzione Il servizio responsabile della creazione del comando di modifica si chiama ICommandBatchPreparer
:
Un servizio per la preparazione di un elenco di ModificationCommandBatch per le entità rappresentate dall'elenco fornito di IUpdateEntry .
Contiene un singolo metodo chiamato BatchCommands
:
Crea i batch di comandi necessari per inserire / aggiornare / eliminare le entità rappresentate dall'elenco fornito di IUpdateEntry .
con la seguente firma:
public IEnumerable<ModificationCommandBatch> BatchCommands(
IReadOnlyList<IUpdateEntry> entries);
e implementazione predefinita nella classe CommandBatchPreparer
.
Sostituiremo quel servizio con un'implementazione personalizzata che estenderà l'elenco con voci "modificate" e useremo l'implementazione di base per fare il lavoro effettivo. Poiché il batch è fondamentalmente un elenco di comandi di modifica ordinati per dipendenza e quindi per tipo con Delete
prima Update
, utilizzeremo prima i lotti separati per i comandi di aggiornamento e concateneremo il resto dopo.
I comandi di modifica generati si basano su IUpdateEntry
:
Le informazioni passate a un provider di database per salvare le modifiche a un'entità nel database.
Fortunatamente si tratta di un'interfaccia, quindi forniremo la nostra implementazione per le voci "modificate" aggiuntive, nonché per le voci di eliminazione corrispondenti (ne parleremo più avanti).
Innanzitutto creeremo un'implementazione di base che delega semplicemente le chiamate all'oggetto sottostante, permettendoci così di sovrascrivere in seguito solo i metodi essenziali per ciò che stiamo cercando di ottenere:
class DelegatingEntry : IUpdateEntry
{
public DelegatingEntry(IUpdateEntry source) { Source = source; }
public IUpdateEntry Source { get; }
public virtual IEntityType EntityType => Source.EntityType;
public virtual EntityState EntityState => Source.EntityState;
public virtual IUpdateEntry SharedIdentityEntry => Source.SharedIdentityEntry;
public virtual object GetCurrentValue(IPropertyBase propertyBase) => Source.GetCurrentValue(propertyBase);
public virtual TProperty GetCurrentValue<TProperty>(IPropertyBase propertyBase) => Source.GetCurrentValue<TProperty>(propertyBase);
public virtual object GetOriginalValue(IPropertyBase propertyBase) => Source.GetOriginalValue(propertyBase);
public virtual TProperty GetOriginalValue<TProperty>(IProperty property) => Source.GetOriginalValue<TProperty>(property);
public virtual bool HasTemporaryValue(IProperty property) => Source.HasTemporaryValue(property);
public virtual bool IsModified(IProperty property) => Source.IsModified(property);
public virtual bool IsStoreGenerated(IProperty property) => Source.IsStoreGenerated(property);
public virtual void SetCurrentValue(IPropertyBase propertyBase, object value) => Source.SetCurrentValue(propertyBase, value);
public virtual EntityEntry ToEntityEntry() => Source.ToEntityEntry();
}
Ora la prima voce personalizzata:
class AuditUpdateEntry : DelegatingEntry
{
public AuditUpdateEntry(IUpdateEntry source) : base(source) { }
public override EntityState EntityState => EntityState.Modified;
public override bool IsModified(IProperty property)
{
if (property.Name == nameof(IAuditable.ModifiedBy)) return true;
return false;
}
public override bool IsStoreGenerated(IProperty property)
=> property.ValueGenerated.ForUpdate()
&& (property.AfterSaveBehavior == PropertySaveBehavior.Ignore
|| !IsModified(property));
}
Innanzitutto "modifichiamo" lo stato di origine da Deleted
a Modified
. Quindi modifichiamo il metodo IsModified
che restituisce false
per le voci Deleted
per restituire true
per le proprietà controllabili, costringendole così a essere incluse nel comando di aggiornamento. Infine, modifichiamo il metodo IsStoreGenerated
che restituisce anche false
per le voci Deleted
per restituire il risultato corrispondente per le voci Modified
( codice EF Core ). Ciò è necessario per consentire a EF Core di gestire correttamente i valori generati dal database durante l'aggiornamento come RowVersion
. Dopo aver eseguito il comando, EF Core chiamerà SetCurrentValue
con i valori restituiti dal database. Ciò che non accade per le normali voci Deleted
e per le normali voci Modified
propaga alla loro entità.
Il che ci porta alla necessità della seconda voce personalizzata, che avvolgerà la voce originale e sarà anche usata come fonte per AuditUpdateEntry
, quindi riceverà da essa SetCurrentValue
. Memorizzerà i valori ricevuti internamente, mantenendo inalterato lo stato dell'entità originale e li tratterà come "attuali" e "originali". Ciò è essenziale perché il comando di eliminazione verrà eseguito dopo l'aggiornamento e se RowVersion
non restituisce il nuovo valore come "originale", il comando di eliminazione generato non riuscirà.
Ecco l'implementazione:
class AuditDeleteEntry : DelegatingEntry
{
public AuditDeleteEntry(IUpdateEntry source) : base(source) { }
Dictionary<IPropertyBase, object> updatedValues;
public override object GetCurrentValue(IPropertyBase propertyBase)
{
if (updatedValues != null && updatedValues.TryGetValue(propertyBase, out var value))
return value;
return base.GetCurrentValue(propertyBase);
}
public override object GetOriginalValue(IPropertyBase propertyBase)
{
if (updatedValues != null && updatedValues.TryGetValue(propertyBase, out var value))
return value;
return base.GetOriginalValue(propertyBase);
}
public override void SetCurrentValue(IPropertyBase propertyBase, object value)
{
if (updatedValues == null) updatedValues = new Dictionary<IPropertyBase, object>();
updatedValues[propertyBase] = value;
}
}
Con queste due voci personalizzate siamo pronti per implementare il nostro generatore di comandi personalizzati:
class AuditableCommandBatchPreparer : CommandBatchPreparer
{
public AuditableCommandBatchPreparer(CommandBatchPreparerDependencies dependencies) : base(dependencies) { }
public override IEnumerable<ModificationCommandBatch> BatchCommands(IReadOnlyList<IUpdateEntry> entries)
{
List<IUpdateEntry> auditEntries = null;
List<AuditUpdateEntry> auditUpdateEntries = null;
for (int i = 0; i < entries.Count; i++)
{
var entry = entries[i];
if (entry.EntityState == EntityState.Deleted && typeof(IAuditable).IsAssignableFrom(entry.EntityType.ClrType))
{
if (auditEntries == null)
{
auditEntries = entries.Take(i).ToList();
auditUpdateEntries = new List<AuditUpdateEntry>();
}
var deleteEntry = new AuditDeleteEntry(entry);
var updateEntry = new AuditUpdateEntry(deleteEntry);
auditEntries.Add(deleteEntry);
auditUpdateEntries.Add(updateEntry);
}
else
{
auditEntries?.Add(entry);
}
}
return auditEntries != null ?
base.BatchCommands(auditUpdateEntries).Concat(base.BatchCommands(auditEntries)) :
base.BatchCommands(entries);
}
}
e abbiamo quasi finito. Aggiungi un metodo di supporto per la registrazione dei nostri servizi:
public static class AuditableExtensions
{
public static DbContextOptionsBuilder AddAudit(this DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.ReplaceService<ICommandBatchPreparer, AuditableCommandBatchPreparer>();
return optionsBuilder;
}
}
e chiamalo da te DbContext
derivato classe OnConfiguring
override:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
// ...
optionsBuilder.AddAudit();
}
e il gioco è fatto.
Tutto questo è per singolo campo controllabile popolato manualmente solo per avere l'idea. Può essere esteso con più campi verificabili, registrando un servizio di provider di campi controllabili personalizzati e compilando automaticamente i valori per le operazioni di inserimento / aggiornamento / eliminazione ecc.
PS Codice completo
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Internal;
using Microsoft.EntityFrameworkCore.Update;
using Microsoft.EntityFrameworkCore.Update.Internal;
using Auditable.Internal;
namespace Auditable
{
public interface IAuditable
{
string ModifiedBy { get; set; }
}
public static class AuditableExtensions
{
public static DbContextOptionsBuilder AddAudit(this DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.ReplaceService<ICommandBatchPreparer, AuditableCommandBatchPreparer>();
return optionsBuilder;
}
}
}
namespace Auditable.Internal
{
class AuditableCommandBatchPreparer : CommandBatchPreparer
{
public AuditableCommandBatchPreparer(CommandBatchPreparerDependencies dependencies) : base(dependencies) { }
public override IEnumerable<ModificationCommandBatch> BatchCommands(IReadOnlyList<IUpdateEntry> entries)
{
List<IUpdateEntry> auditEntries = null;
List<AuditUpdateEntry> auditUpdateEntries = null;
for (int i = 0; i < entries.Count; i++)
{
var entry = entries[i];
if (entry.EntityState == EntityState.Deleted && typeof(IAuditable).IsAssignableFrom(entry.EntityType.ClrType))
{
if (auditEntries == null)
{
auditEntries = entries.Take(i).ToList();
auditUpdateEntries = new List<AuditUpdateEntry>();
}
var deleteEntry = new AuditDeleteEntry(entry);
var updateEntry = new AuditUpdateEntry(deleteEntry);
auditEntries.Add(deleteEntry);
auditUpdateEntries.Add(updateEntry);
}
else
{
auditEntries?.Add(entry);
}
}
return auditEntries != null ?
base.BatchCommands(auditUpdateEntries).Concat(base.BatchCommands(auditEntries)) :
base.BatchCommands(entries);
}
}
class AuditUpdateEntry : DelegatingEntry
{
public AuditUpdateEntry(IUpdateEntry source) : base(source) { }
public override EntityState EntityState => EntityState.Modified;
public override bool IsModified(IProperty property)
{
if (property.Name == nameof(IAuditable.ModifiedBy)) return true;
return false;
}
public override bool IsStoreGenerated(IProperty property)
=> property.ValueGenerated.ForUpdate()
&& (property.AfterSaveBehavior == PropertySaveBehavior.Ignore
|| !IsModified(property));
}
class AuditDeleteEntry : DelegatingEntry
{
public AuditDeleteEntry(IUpdateEntry source) : base(source) { }
Dictionary<IPropertyBase, object> updatedValues;
public override object GetCurrentValue(IPropertyBase propertyBase)
{
if (updatedValues != null && updatedValues.TryGetValue(propertyBase, out var value))
return value;
return base.GetCurrentValue(propertyBase);
}
public override object GetOriginalValue(IPropertyBase propertyBase)
{
if (updatedValues != null && updatedValues.TryGetValue(propertyBase, out var value))
return value;
return base.GetOriginalValue(propertyBase);
}
public override void SetCurrentValue(IPropertyBase propertyBase, object value)
{
if (updatedValues == null) updatedValues = new Dictionary<IPropertyBase, object>();
updatedValues[propertyBase] = value;
}
}
class DelegatingEntry : IUpdateEntry
{
public DelegatingEntry(IUpdateEntry source) { Source = source; }
public IUpdateEntry Source { get; }
public virtual IEntityType EntityType => Source.EntityType;
public virtual EntityState EntityState => Source.EntityState;
public virtual IUpdateEntry SharedIdentityEntry => Source.SharedIdentityEntry;
public virtual object GetCurrentValue(IPropertyBase propertyBase) => Source.GetCurrentValue(propertyBase);
public virtual TProperty GetCurrentValue<TProperty>(IPropertyBase propertyBase) => Source.GetCurrentValue<TProperty>(propertyBase);
public virtual object GetOriginalValue(IPropertyBase propertyBase) => Source.GetOriginalValue(propertyBase);
public virtual TProperty GetOriginalValue<TProperty>(IProperty property) => Source.GetOriginalValue<TProperty>(property);
public virtual bool HasTemporaryValue(IProperty property) => Source.HasTemporaryValue(property);
public virtual bool IsModified(IProperty property) => Source.IsModified(property);
public virtual bool IsStoreGenerated(IProperty property) => Source.IsStoreGenerated(property);
public virtual void SetCurrentValue(IPropertyBase propertyBase, object value) => Source.SetCurrentValue(propertyBase, value);
public virtual EntityEntry ToEntityEntry() => Source.ToEntityEntry();
}
}