Interceptores

Los interceptores de Entity Framework Core (EF Core) habilitan la intercepción, la modificación o la supresión de operaciones de EF Core. Esto incluye operaciones de base de datos de bajo nivel tales como ejecutar un comando, así como operaciones de nivel superior tales como llamadas a SaveChanges.

Los interceptores son distintos del registro y el diagnóstico en que permiten la modificación o supresión de la operación que se intercepta. El registro sencillo o Microsoft.Extensions.Logging son mejores opciones de registro.

Los interceptores se registran por instancia de DbContext al configurarse el contexto. Use una escucha de diagnóstico para obtener la misma información, pero para todas las instancias de DbContext del proceso.

Registro de interceptores

Los interceptores se registran mediante AddInterceptors cuando configuran una instancia de DbContext. Esto suele hacerse en una invalidación de DbContext.OnConfiguring. Por ejemplo:

public class ExampleContext : BlogsContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.AddInterceptors(new TaggedQueryCommandInterceptor());
}

Como alternativa, se puede llamar a AddInterceptors como parte de AddDbContext o al crear una instancia de DbContextOptions para pasar al constructor DbContext.

Sugerencia

Se sigue llamando a OnConfiguring cuando se usa AddDbContext o se pasa una instancia DbContextOptions al constructor DbContext. Esto hace que sea el lugar ideal para aplicar la configuración de contexto independientemente de cómo se construye DbContext.

Los interceptores a menudo no tienen estado, lo que significa que se puede usar una sola instancia de interceptor para todas las instancias de DbContext. Por ejemplo:

public class TaggedQueryCommandInterceptorContext : BlogsContext
{
    private static readonly TaggedQueryCommandInterceptor _interceptor
        = new TaggedQueryCommandInterceptor();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.AddInterceptors(_interceptor);
}

Cada instancia del interceptor debe implementar una o varias interfaces derivadas de IInterceptor. Cada instancia solo debe registrarse una vez aunque implementa varias interfaces de interceptación; EF Core enrutará los eventos de cada interfaz según corresponda.

Interceptación de base de datos

Nota:

La interceptación de bases de datos solo está disponible para proveedores de bases de datos relacionales.

La interceptación de base de datos de bajo nivel se divide en las tres interfaces que se muestran en la tabla siguiente.

Interceptor Operaciones de base de datos interceptadas
IDbCommandInterceptor Crear comandos
Ejecutar comandos
Errores de comando
Eliminar DbDataReader del comando
IDbConnectionInterceptor Apertura y cierre de conexiones
Errores de conexión
IDbTransactionInterceptor Creación de transacciones
Usar transacciones existentes
Confirmar transacciones
Revertir transacciones
Crear y usar puntos de retorno
Errores de transacción

Las clases base DbCommandInterceptor, DbConnectionInterceptory DbTransactionInterceptor contienen implementaciones sin operación para cada método de la interfaz correspondiente. Use las clases base para evitar la necesidad de implementar métodos de interceptación sin usar.

Los métodos de cada tipo interceptor vienen en pares, con el primero al que se llama antes de que se inicie la operación de base de datos y el segundo después de que se haya completado la operación. Por ejemplo, se llama DbCommandInterceptor.ReaderExecuting antes de ejecutar una consulta y se llama DbCommandInterceptor.ReaderExecuted una vez enviada la consulta a la base de datos.

Cada par de métodos tiene variaciones sincronizadas y asincrónicas. Esto permite que la E/S asincrónica, como solicitar un token de acceso, se produzca como parte de la interceptación de una operación asincrónica de base de datos.

Ejemplo: Interceptación de comandos para agregar sugerencias de consulta

Sugerencia

Puede descargar el ejemplo del interceptor de comando desde GitHub.

Se puede usar un IDbCommandInterceptor para modificar SQL antes de enviarlo a la base de datos. En este ejemplo se muestra cómo modificar SQL para incluir una sugerencia de consulta.

A menudo, la parte más complicada de la interceptación es determinar cuándo el comando corresponde a la consulta que debe modificarse. Analizar SQL es una opción, pero tiende a ser frágil. Otra opción consiste en usar Etiquetas de consulta de EF Core para etiquetar cada consulta que se debe modificar. Por ejemplo:

var blogs1 = context.Blogs.TagWith("Use hint: robust plan").ToList();

A continuación, esta etiqueta se puede detectar en el interceptor, ya que siempre se incluirá como comentario en la primera línea del texto del comando. Al detectar la etiqueta, la consulta SQL se modifica para agregar la sugerencia adecuada:

public class TaggedQueryCommandInterceptor : DbCommandInterceptor
{
    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result)
    {
        ManipulateCommand(command);

        return result;
    }

    public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result,
        CancellationToken cancellationToken = default)
    {
        ManipulateCommand(command);

        return new ValueTask<InterceptionResult<DbDataReader>>(result);
    }

    private static void ManipulateCommand(DbCommand command)
    {
        if (command.CommandText.StartsWith("-- Use hint: robust plan", StringComparison.Ordinal))
        {
            command.CommandText += " OPTION (ROBUST PLAN)";
        }
    }
}

Aviso:

  • El interceptor hereda desde DbCommandInterceptor para evitar tener que implementar todos los métodos en la interfaz del interceptor.
  • El interceptor implementa métodos de sincronización y asincrónico. Esto garantiza que la misma sugerencia de consulta se aplica a las consultas sincronizadas y asincrónicas.
  • El interceptor implementa los métodos de Executing que se llama por EF Core con el SQL generado antes de se envía a la base de datos. Contrasta esto con los métodos de Executed, a los que se llama después de que se haya devuelto la llamada a la base de datos.

Al ejecutar el código de este ejemplo, se genera lo siguiente cuando se etiqueta una consulta:

-- Use hint: robust plan

SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b] OPTION (ROBUST PLAN)

Por otro lado, cuando una consulta no está etiquetada, se envía a la base de datos sin modificar:

SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]

Ejemplo: Interceptación de conexión para la autenticación de Azure de SQL mediante AAD

Sugerencia

Puede descargar el ejemplo del interceptor de conexión desde GitHub.

Se puede usar un IDbConnectionInterceptor para manipular el DbConnection antes de usarlo para conectarse a la base de datos. Esto se puede usar para obtener un token de acceso de Azure Active Directory (AAD). Por ejemplo:

public class AadAuthenticationInterceptor : DbConnectionInterceptor
{
    public override InterceptionResult ConnectionOpening(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result)
        => throw new InvalidOperationException("Open connections asynchronously when using AAD authentication.");

    public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result,
        CancellationToken cancellationToken = default)
    {
        var sqlConnection = (SqlConnection)connection;

        var provider = new AzureServiceTokenProvider();
        // Note: in some situations the access token may not be cached automatically the Azure Token Provider.
        // Depending on the kind of token requested, you may need to implement your own caching here.
        sqlConnection.AccessToken = await provider.GetAccessTokenAsync("https://database.windows.net/", null, cancellationToken);

        return result;
    }
}

Sugerencia

Microsoft.Data.SqlClient ahora es compatible con la autenticación de AAD a través de la cadena de conexión. Consulte SqlAuthenticationMethod para obtener más información.

Advertencia

Tenga en cuenta que el interceptor produce si se realiza una llamada de sincronización para abrir la conexión. Esto se debe a que no hay ningún método asincrónico para obtener el token de acceso y no hay ninguna manera universal y sencilla de llamar a un método asincrónico desde contexto no asincrónico sin riesgo de interbloqueo.

Advertencia

en algunas situaciones, es posible que el token de acceso no se almacene en caché automáticamente en el proveedor de tokens de Azure. Según el tipo de token solicitado, es posible que tenga que implementar aquí su propio almacenamiento en caché.

Ejemplo: Interceptación avanzada de comandos para el almacenamiento en caché

Los interceptores de EF Core pueden:

  • Indica a EF Core que suprima la ejecución de la operación que se intercepta
  • Cambio del resultado de la operación notificada a EF Core

En este ejemplo se muestra un interceptor que usa estas características para comportarse como una caché primitiva de segundo nivel. Los resultados de la consulta almacenadas en caché se devuelven para una consulta específica, lo que evita un recorrido de ida y vuelta de la base de datos.

Advertencia

Tenga cuidado al cambiar el comportamiento predeterminado de EF Core de esta manera. EF Core puede comportarse de maneras inesperadas si obtiene un resultado anómalo que no puede procesar correctamente. Además, en este ejemplo se muestran los conceptos del interceptor; no está pensado como plantilla para una implementación sólida de caché de segundo nivel.

En este ejemplo, la aplicación ejecuta con frecuencia una consulta para obtener el "mensaje diario" más reciente:

async Task<string> GetDailyMessage(DailyMessageContext context)
    => (await context.DailyMessages.TagWith("Get_Daily_Message").OrderBy(e => e.Id).LastAsync()).Message;

Esta consulta está etiquetada para que se pueda detectar fácilmente en el interceptor. La idea es consultar solo la base de datos de un nuevo mensaje una vez al día. En otras ocasiones, la aplicación usará un resultado almacenado en caché. (En el ejemplo se usa un retraso de 10 segundos en el ejemplo para simular un nuevo día.)

Estado del interceptor

Este interceptor es con estado: almacena el identificador y el texto del mensaje diario más reciente consultado, además de la hora en que se ejecutó esa consulta. Debido a este estado, también necesitamos un bloqueo, ya que el almacenamiento en caché requiere que varias instancias de contexto usen el mismo interceptor.

private readonly object _lock = new object();
private int _id;
private string _message;
private DateTime _queriedAt;

Antes de la ejecución

En el método de Executing (es decir, antes de realizar una llamada a una base de datos), el interceptor detecta la consulta etiquetada y, a continuación, comprueba si hay un resultado almacenado en caché. Si se encuentra este resultado, se suprime la consulta y se usan en su lugar los resultados almacenados en caché.

public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
    DbCommand command,
    CommandEventData eventData,
    InterceptionResult<DbDataReader> result,
    CancellationToken cancellationToken = default)
{
    if (command.CommandText.StartsWith("-- Get_Daily_Message", StringComparison.Ordinal))
    {
        lock (_lock)
        {
            if (_message != null
                && DateTime.UtcNow < _queriedAt + new TimeSpan(0, 0, 10))
            {
                command.CommandText = "-- Get_Daily_Message: Skipping DB call; using cache.";
                result = InterceptionResult<DbDataReader>.SuppressWithResult(new CachedDailyMessageDataReader(_id, _message));
            }
        }
    }

    return new ValueTask<InterceptionResult<DbDataReader>>(result);
}

Tenga en cuenta cómo el código llama a InterceptionResult<TResult>.SuppressWithResult y pasa un DbDataReader de reemplazo que contiene los datos almacenados en caché. A continuación, se devuelve esta InterceptionResult, lo que provoca la supresión de la ejecución de consultas. EF Core usa en su lugar el lector de reemplazo como resultado de la consulta.

Este interceptor también manipula el texto del comando. Esta manipulación no es necesaria, pero mejora la claridad en los mensajes de registro. No es necesario que el texto del comando sea válido, ya que la consulta no se va a ejecutar.

Después de la ejecución

Si no hay ningún mensaje almacenado en caché disponible o si ha expirado, el código anterior no suprime el resultado. Por lo tanto, EF Core ejecutará la consulta como normal. A continuación, volverá al método de Executed del interceptor después de la ejecución. En este punto, si el resultado aún no es un lector almacenado en caché, el nuevo identificador de mensaje y la cadena se extrae del lector real y se almacena en caché listo para el siguiente uso de esta consulta.

public override async ValueTask<DbDataReader> ReaderExecutedAsync(
    DbCommand command,
    CommandExecutedEventData eventData,
    DbDataReader result,
    CancellationToken cancellationToken = default)
{
    if (command.CommandText.StartsWith("-- Get_Daily_Message", StringComparison.Ordinal)
        && !(result is CachedDailyMessageDataReader))
    {
        try
        {
            await result.ReadAsync(cancellationToken);

            lock (_lock)
            {
                _id = result.GetInt32(0);
                _message = result.GetString(1);
                _queriedAt = DateTime.UtcNow;
                return new CachedDailyMessageDataReader(_id, _message);
            }
        }
        finally
        {
            await result.DisposeAsync();
        }
    }

    return result;
}

Demostración

El ejemplo de interceptor de almacenamiento en caché contiene una aplicación de consola sencilla que consulta mensajes diarios para probar el almacenamiento en caché:

// 1. Initialize the database with some daily messages.
using (var context = new DailyMessageContext())
{
    await context.Database.EnsureDeletedAsync();
    await context.Database.EnsureCreatedAsync();

    context.AddRange(
        new DailyMessage { Message = "Remember: All builds are GA; no builds are RTM." },
        new DailyMessage { Message = "Keep calm and drink tea" });

    await context.SaveChangesAsync();
}

// 2. Query for the most recent daily message. It will be cached for 10 seconds.
using (var context = new DailyMessageContext())
{
    Console.WriteLine(await GetDailyMessage(context));
}

// 3. Insert a new daily message.
using (var context = new DailyMessageContext())
{
    context.Add(new DailyMessage { Message = "Free beer for unicorns" });

    await context.SaveChangesAsync();
}

// 4. Cached message is used until cache expires.
using (var context = new DailyMessageContext())
{
    Console.WriteLine(await GetDailyMessage(context));
}

// 5. Pretend it's the next day.
Thread.Sleep(10000);

// 6. Cache is expired, so the last message will not be queried again.
using (var context = new DailyMessageContext())
{
    Console.WriteLine(await GetDailyMessage(context));
}

async Task<string> GetDailyMessage(DailyMessageContext context)
    => (await context.DailyMessages.TagWith("Get_Daily_Message").OrderBy(e => e.Id).LastAsync()).Message;

Esta acción devuelve la siguiente salida:

info: 10/15/2020 12:32:11.801 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      -- Get_Daily_Message

      SELECT "d"."Id", "d"."Message"
      FROM "DailyMessages" AS "d"
      ORDER BY "d"."Id" DESC
      LIMIT 1

Keep calm and drink tea

info: 10/15/2020 12:32:11.821 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='Free beer for unicorns' (Size = 22)], CommandType='Text', CommandTimeout='30']
      INSERT INTO "DailyMessages" ("Message")
      VALUES (@p0);
      SELECT "Id"
      FROM "DailyMessages"
      WHERE changes() = 1 AND "rowid" = last_insert_rowid();

info: 10/15/2020 12:32:11.826 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      -- Get_Daily_Message: Skipping DB call; using cache.

Keep calm and drink tea

info: 10/15/2020 12:32:21.833 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      -- Get_Daily_Message

      SELECT "d"."Id", "d"."Message"
      FROM "DailyMessages" AS "d"
      ORDER BY "d"."Id" DESC
      LIMIT 1

Free beer for unicorns

Tenga en cuenta que en la salida del registro que la aplicación sigue usando el mensaje almacenado en caché hasta que expire el tiempo de espera, momento en el que la base de datos se consulta de nuevo para cualquier mensaje nuevo.

SaveChanges interception

Sugerencia

Puede descargar el ejemplo del interceptor de conexión desde GitHub.

Los puntos de interceptación SaveChanges y SaveChangesAsync se definen mediante la interfaz de ISaveChangesInterceptor. En cuanto a otros interceptores, la clase base de SaveChangesInterceptor con métodos sin operación se proporciona como comodidad.

Sugerencia

Los interceptores son potentes. Sin embargo, en muchos casos puede ser más fácil invalidar el método SaveChanges o usar los eventos de .NET para SaveChanges expuestos en DbContext.

Ejemplo: Interceptación de SaveChanges para la auditoría

SaveChanges se puede interceptar para crear un registro de auditoría independiente de los cambios realizados.

Nota:

Esto no está pensado para ser una solución de auditoría sólida. En su lugar, se trata de un ejemplo simplista que se usa para mostrar las características de la interceptación.

El contexto de la aplicación

El ejemplo de para auditar usa un DbContext simple con blogs y publicaciones.

public class BlogsContext : DbContext
{
    private readonly AuditingInterceptor _auditingInterceptor = new AuditingInterceptor("DataSource=audit.db");

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .AddInterceptors(_auditingInterceptor)
            .UseSqlite("DataSource=blogs.db");

    public DbSet<Blog> Blogs { get; set; }
}

public class Blog
{
    public int Id { get; set; }
    public string Name { get; set; }

    public ICollection<Post> Posts { get; } = new List<Post>();
}

public class Post
{
    public int Id { get; set; }
    public string Title { get; set; }

    public Blog Blog { get; set; }
}

Tenga en cuenta que se registra una nueva instancia del interceptor para cada instancia de DbContext. Esto se debe a que el interceptor de auditoría contiene el estado vinculado a la instancia de contexto actual.

Contexto de auditoría

El ejemplo también contiene un segundo DbContext y un modelo que se usan para la base de datos de auditoría.

public class AuditContext : DbContext
{
    private readonly string _connectionString;

    public AuditContext(string connectionString)
    {
        _connectionString = connectionString;
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder.UseSqlite(_connectionString);

    public DbSet<SaveChangesAudit> SaveChangesAudits { get; set; }
}

public class SaveChangesAudit
{
    public int Id { get; set; }
    public Guid AuditId { get; set; }
    public DateTime StartTime { get; set; }
    public DateTime EndTime { get; set; }
    public bool Succeeded { get; set; }
    public string ErrorMessage { get; set; }

    public ICollection<EntityAudit> Entities { get; } = new List<EntityAudit>();
}

public class EntityAudit
{
    public int Id { get; set; }
    public EntityState State { get; set; }
    public string AuditMessage { get; set; }

    public SaveChangesAudit SaveChangesAudit { get; set; }
}

Interceptor

La idea general para la auditoría con el interceptor es:

  • Se crea un mensaje de auditoría al principio de SaveChanges y se escribe en la base de datos de auditoría
  • SaveChanges puede continuar
  • Si SaveChanges se realiza correctamente, el mensaje de auditoría se actualiza para indicar que se ha realizado correctamente
  • Si se produce un error en SaveChanges, el mensaje de auditoría se actualiza para indicar el error

La primera fase se controla antes de enviar los cambios a la base de datos mediante invalidaciones de ISaveChangesInterceptor.SavingChanges y ISaveChangesInterceptor.SavingChangesAsync.

public async ValueTask<InterceptionResult<int>> SavingChangesAsync(
    DbContextEventData eventData,
    InterceptionResult<int> result,
    CancellationToken cancellationToken = default)
{
    _audit = CreateAudit(eventData.Context);

    using var auditContext = new AuditContext(_connectionString);

    auditContext.Add(_audit);
    await auditContext.SaveChangesAsync();

    return result;
}

public InterceptionResult<int> SavingChanges(
    DbContextEventData eventData,
    InterceptionResult<int> result)
{
    _audit = CreateAudit(eventData.Context);

    using var auditContext = new AuditContext(_connectionString);
    auditContext.Add(_audit);
    auditContext.SaveChanges();

    return result;
}

La invalidación de métodos sincronizados y asincrónicos garantiza que la auditoría se produzca independientemente de si se llama a SaveChanges o SaveChangesAsync. Observe también que la sobrecarga asincrónica es capaz de realizar E/S asincrónica sin bloqueo en la base de datos de auditoría. Es posible que desee iniciar desde el método de sincronización de SavingChanges para asegurarse de que toda la E/S de la base de datos es asincrónica. Esto requiere que la aplicación siempre llame a SaveChangesAsync y nunca a SaveChanges.

El mensaje de auditoría

Cada método interceptor tiene un parámetro de eventData que proporciona información contextual sobre el evento que se intercepta. En este caso, la aplicación actual DbContext se incluye en los datos del evento, que luego se usa para crear un mensaje de auditoría.

private static SaveChangesAudit CreateAudit(DbContext context)
{
    context.ChangeTracker.DetectChanges();

    var audit = new SaveChangesAudit { AuditId = Guid.NewGuid(), StartTime = DateTime.UtcNow };

    foreach (var entry in context.ChangeTracker.Entries())
    {
        var auditMessage = entry.State switch
        {
            EntityState.Deleted => CreateDeletedMessage(entry),
            EntityState.Modified => CreateModifiedMessage(entry),
            EntityState.Added => CreateAddedMessage(entry),
            _ => null
        };

        if (auditMessage != null)
        {
            audit.Entities.Add(new EntityAudit { State = entry.State, AuditMessage = auditMessage });
        }
    }

    return audit;

    string CreateAddedMessage(EntityEntry entry)
        => entry.Properties.Aggregate(
            $"Inserting {entry.Metadata.DisplayName()} with ",
            (auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");

    string CreateModifiedMessage(EntityEntry entry)
        => entry.Properties.Where(property => property.IsModified || property.Metadata.IsPrimaryKey()).Aggregate(
            $"Updating {entry.Metadata.DisplayName()} with ",
            (auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");

    string CreateDeletedMessage(EntityEntry entry)
        => entry.Properties.Where(property => property.Metadata.IsPrimaryKey()).Aggregate(
            $"Deleting {entry.Metadata.DisplayName()} with ",
            (auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");
}

El resultado es una entidad de SaveChangesAudit con una colección de entidades de EntityAudit, una para cada inserción, actualización o eliminación. A continuación, el interceptor inserta estas entidades en la base de datos de auditoría.

Sugerencia

ToString se invalida en cada clase de datos de eventos de EF Core para generar el mensaje de registro equivalente para el evento. Por ejemplo, la llamada ContextInitializedEventData.ToString genera "Entity Framework Core 5.0.0 inicializado "BlogsContext" mediante el proveedor "Microsoft.EntityFrameworkCore.Sqlite" con opciones: None".

Detección de éxito

La entidad de auditoría se almacena en el interceptor para que se pueda acceder de nuevo una vez que SaveChanges se realice correctamente o se produzca un error. Para éxito, se llama a ISaveChangesInterceptor.SavedChanges o ISaveChangesInterceptor.SavedChangesAsync.

public int SavedChanges(SaveChangesCompletedEventData eventData, int result)
{
    using var auditContext = new AuditContext(_connectionString);

    auditContext.Attach(_audit);
    _audit.Succeeded = true;
    _audit.EndTime = DateTime.UtcNow;

    auditContext.SaveChanges();

    return result;
}

public async ValueTask<int> SavedChangesAsync(
    SaveChangesCompletedEventData eventData,
    int result,
    CancellationToken cancellationToken = default)
{
    using var auditContext = new AuditContext(_connectionString);

    auditContext.Attach(_audit);
    _audit.Succeeded = true;
    _audit.EndTime = DateTime.UtcNow;

    await auditContext.SaveChangesAsync(cancellationToken);

    return result;
}

La entidad auditoría se adjunta al contexto de auditoría, ya que ya existe en la base de datos y debe actualizarse. A continuación, establecemos Succeeded y EndTime, que marca estas propiedades como modificadas para que SaveChanges envíe una actualización a la base de datos de auditoría.

Detección de errores

El error se controla de la misma manera que el éxito, pero en el método de ISaveChangesInterceptor.SaveChangesFailed o ISaveChangesInterceptor.SaveChangesFailedAsync. Los datos del evento contienen la excepción que se produjo.

public void SaveChangesFailed(DbContextErrorEventData eventData)
{
    using var auditContext = new AuditContext(_connectionString);

    auditContext.Attach(_audit);
    _audit.Succeeded = false;
    _audit.EndTime = DateTime.UtcNow;
    _audit.ErrorMessage = eventData.Exception.Message;

    auditContext.SaveChanges();
}

public async Task SaveChangesFailedAsync(
    DbContextErrorEventData eventData,
    CancellationToken cancellationToken = default)
{
    using var auditContext = new AuditContext(_connectionString);

    auditContext.Attach(_audit);
    _audit.Succeeded = false;
    _audit.EndTime = DateTime.UtcNow;
    _audit.ErrorMessage = eventData.Exception.InnerException?.Message;

    await auditContext.SaveChangesAsync(cancellationToken);
}

Demostración

El ejemplo de auditoría contiene una aplicación de consola sencilla que realiza cambios en la base de datos de registro y, a continuación, muestra la auditoría que se creó.

// Insert, update, and delete some entities

using (var context = new BlogsContext())
{
    context.Add(
        new Blog { Name = "EF Blog", Posts = { new Post { Title = "EF Core 3.1!" }, new Post { Title = "EF Core 5.0!" } } });

    await context.SaveChangesAsync();
}

using (var context = new BlogsContext())
{
    var blog = context.Blogs.Include(e => e.Posts).Single();

    blog.Name = "EF Core Blog";
    context.Remove(blog.Posts.First());
    blog.Posts.Add(new Post { Title = "EF Core 6.0!" });

    context.SaveChanges();
}

// Do an insert that will fail

using (var context = new BlogsContext())
{
    try
    {
        context.Add(new Post { Id = 3, Title = "EF Core 3.1!" });

        await context.SaveChangesAsync();
    }
    catch (DbUpdateException)
    {
    }
}

// Look at the audit trail

using (var context = new AuditContext("DataSource=audit.db"))
{
    foreach (var audit in context.SaveChangesAudits.Include(e => e.Entities).ToList())
    {
        Console.WriteLine(
            $"Audit {audit.AuditId} from {audit.StartTime} to {audit.EndTime} was{(audit.Succeeded ? "" : " not")} successful.");

        foreach (var entity in audit.Entities)
        {
            Console.WriteLine($"  {entity.AuditMessage}");
        }

        if (!audit.Succeeded)
        {
            Console.WriteLine($"  Error: {audit.ErrorMessage}");
        }
    }
}

El resultado muestra el contenido de la base de datos de auditoría:

Audit 52e94327-1767-4046-a3ca-4c6b1eecbca6 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was successful.
  Inserting Blog with Id: '-2147482647' Name: 'EF Blog'
  Inserting Post with Id: '-2147482647' BlogId: '-2147482647' Title: 'EF Core 3.1!'
  Inserting Post with Id: '-2147482646' BlogId: '-2147482647' Title: 'EF Core 5.0!'
Audit 8450f57a-5030-4211-a534-eb66b8da7040 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was successful.
  Inserting Post with Id: '-2147482645' BlogId: '1' Title: 'EF Core 6.0!'
  Updating Blog with Id: '1' Name: 'EF Core Blog'
  Deleting Post with Id: '1'
Audit 201fef4d-66a7-43ad-b9b6-b57e9d3f37b3 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was not successful.
  Inserting Post with Id: '3' BlogId: '' Title: 'EF Core 3.1!'
  Error: SQLite Error 19: 'UNIQUE constraint failed: Post.Id'.