Novedades y más novedades. Ahora le toca el momento a .Net 7 Entity Framework Preview 7. La más importante de mencionar es Interceptos, pero, hay muchas más como:
- Relaciones unidireccionales de muchos a muchos
- Configure columnas para propiedades asignadas a varias tablas usando TPT, TPC y división de entidades
- Traducir funciones agregadas de estadísticas en SQL Server
- Inclusión filtrada para navegaciones ocultas
- Facilite el paso de tokens de cancelación a FindAsync
- Traduce string.Join y string.Concat
Interceptors
Los interceptores de EF Core permiten la interceptación, modificación o supresión de las operaciones de EF Core. Esto incluye operaciones de base de datos de bajo nivel, como ejecutar un comando, así como operaciones de nivel superior, como llamadas a SaveChanges. Los interceptores admiten operaciones asíncronas y la capacidad de modificar o suprimir operaciones, lo que los hace más potentes que los eventos, registros y diagnósticos tradicionales.
Los interceptores se registran al configurar una instancia de DbContext, en OnConfiguring o AddDbContext. Por ejemplo:
public class ExampleContext : DbContext { protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.AddInterceptors(new TaggedQueryCommandInterceptor()); }
Interceptores nuevos y mejorados en EF7
- Intercepción para crear y llenar nuevas instancias de entidad (también conocida como «materialización»).
- Intercepción para modificar el árbol de expresiones LINQ antes de que se compile una consulta.
- Intercepción para el manejo de concurrencia optimista (DbUpdateConcurrencyException).
- Interceptación de conexiones antes de comprobar si se ha establecido la cadena de conexión.
- Intercepción para cuando EF Core ha terminado de consumir un conjunto de resultados, pero antes de que se cierre ese conjunto de resultados.
- Intercepción para la creación de una DbConnection por EF Core.
- Intercepción para DbCommand después de que se haya inicializado
- Cuando una entidad está a punto de ser rastreada o cambiar de estado, pero antes de que realmente sea rastreada o cambie de estado.
- Antes y después de que EF Core detecte cambios en entidades y propiedades (también conocido como intercepción DetectChanges).
Simple actions on entity creation
El nuevo IMaterializationInterceptor admite la intercepción antes y después de que se cree una instancia de entidad, y antes y después de que se inicializan las propiedades de esa instancia. El interceptor puede cambiar o reemplazar la instancia de la entidad en cada punto. Esto permite:
- Configuración de propiedades no asignadas o métodos de llamada necesarios para la validación, valores calculados o indicadores
- Usar una fábrica para crear instancias
- Crear una instancia de entidad diferente a la que normalmente crearía EF, como una instancia de un caché o de un tipo de proxy
- Inyectar servicios en una instancia de entidad
Imaginemos que queremos realizar un seguimiento de la hora en que se recuperó una entidad de la base de datos, tal vez para que pueda mostrarse a un usuario que edita los datos. Para lograr esto, primero definimos una interfaz:
public interface IHasRetrieved { DateTime Retrieved { get; set; } }
El uso de una interfaz es común con los interceptores, permite que el mismo interceptor funcione con muchos tipos de entidades diferentes. Por ejemplo:
public class Customer : IHasRetrieved { public int Id { get; set; } public string Name { get; set; } = null!; public string? PhoneNumber { get; set; } [NotMapped] public DateTime Retrieved { get; set; } }
Tengamos en cuenta que el atributo [NotMapped] se usa para indicar que esta propiedad se usa solo mientras se trabaja con la entidad y no debe persistir en la base de datos.
El interceptor luego debe implementar el método apropiado de IMaterializationInterceptor y establecer el tiempo recuperado:
public class SetRetrievedInterceptor : IMaterializationInterceptor { public object InitializedInstance(MaterializationInterceptionData materializationData, object instance) { if (instance is IHasRetrieved hasRetrieved) { hasRetrieved.Retrieved = DateTime.UtcNow; } return instance; } }
Una instancia de este interceptor se registra al configurar el DbContext:
public class CustomerContext : DbContext { private static readonly SetRetrievedInterceptor _setRetrievedInterceptor = new(); public DbSet<Customer> Customers => Set<Customer>(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder .AddInterceptors(_setRetrievedInterceptor) .UseSqlite("Data Source = customers.db"); }
Ahora, siempre que se consulte a un Cliente desde la base de datos, la propiedad Recuperado se establecerá automáticamente. Por ejemplo:
using (var context = new CustomerContext()) { var customer = context.Customers.Single(e => e.Name == "Alice"); Console.WriteLine($"Customer '{customer.Name}' was retrieved at '{customer.Retrieved.ToLocalTime()}'"); }
Salida:
Customer 'Alice' was retrieved at '8/6/2022 7:50:25 PM'
LINQ expression tree interception
EF Core utiliza consultas de .NET LINQ. Por lo general, esto implica usar el compilador C#, VB o F# para crear un árbol de expresión que luego EF Core traduce al SQL apropiado. Por ejemplo, considere un método que devuelve una página de clientes:
List<Customer> GetPageOfCustomers(string sortProperty, int page) { using var context = new CustomerContext(); return context.Customers .OrderBy(e => EF.Property<object>(e, sortProperty)) .Skip(page * 20).Take(20).ToList(); }
Funcionará bien siempre que la propiedad utilizada para ordenar siempre devuelva un orden estable. Pero siempre no será el caso. Por ejemplo, la consulta LINQ anterior genera lo siguiente en SQLite al realizar el pedido por Cliente.Ciudad:
SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber" FROM "Customers" AS "c" ORDER BY "c"."City" LIMIT @__p_1 OFFSET @__p_0
Si hay varios clientes con la misma ciudad, entonces el orden de esta consulta no es estable. Esto podría dar lugar a resultados perdidos o duplicados a medida que el usuario mira los datos.
Una forma común de solucionar este problema es realizar una ordenación secundaria por clave principal. Peroo, en lugar de agregar esto manualmente a cada consulta, podemos interceptar el árbol de expresión de la consulta y agregar la ordenación secundaria dinámicamente cada vez que se encuentre un OrderBy. Para facilitar esto, usaremos nuevamente una interfaz, esta vez para cualquier entidad que tenga una clave primaria entera:
public interface IHasIntKey { int Id { get; } }
Esta interfaz es implementada por los tipos de entidad de interés:
public class Customer : IHasIntKey { public int Id { get; set; } public string Name { get; set; } = null!; public string? City { get; set; } public string? PhoneNumber { get; set; } }
Luego necesitamos un interceptor que implemente IQueryExpressionInterceptor:
public class KeyOrderingExpressionInterceptor : IQueryExpressionInterceptor { public Expression ProcessingQuery(Expression queryExpression, QueryExpressionEventData eventData) => new KeyOrderingExpressionVisitor().Visit(queryExpression); private class KeyOrderingExpressionVisitor : ExpressionVisitor { private static readonly MethodInfo ThenByMethod = typeof(Queryable).GetMethods() .Single(m => m.Name == nameof(Queryable.ThenBy) && m.GetParameters().Length == 2); protected override Expression VisitMethodCall(MethodCallExpression? methodCallExpression) { var methodInfo = methodCallExpression!.Method; if (methodInfo.DeclaringType == typeof(Queryable) && methodInfo.Name == nameof(Queryable.OrderBy) && methodInfo.GetParameters().Length == 2) { var sourceType = methodCallExpression.Type.GetGenericArguments()[0]; if (typeof(IHasIntKey).IsAssignableFrom(sourceType)) { var lambdaExpression = (LambdaExpression)((UnaryExpression)methodCallExpression.Arguments[1]).Operand; var entityParameterExpression = lambdaExpression.Parameters[0]; return Expression.Call( ThenByMethod.MakeGenericMethod( sourceType, typeof(int)), methodCallExpression, Expression.Lambda( typeof(Func<,>).MakeGenericType(entityParameterExpression.Type, typeof(int)), Expression.Property(entityParameterExpression, nameof(IHasIntKey.Id)), entityParameterExpression)); } } return base.VisitMethodCall(methodCallExpression); } } }
Normalmente esto es bastante complicado, trabajar con árboles de expresión no es fácil. Veamos lo que está pasando:
- Básicamente, el interceptor encapsula un ExpressionVisitor. El visitante anula VisitMethodCall, que se llamará siempre que haya una llamada a un método en el árbol de expresión de consulta.
- El visitante comprueba si se trata o no de una llamada al método Queryable.OrderBy que nos interesa.
- Si es así, el visitante verifica aún más cuándo la llamada al método genérico es para un tipo que implementa nuestra interfaz IHasIntKey.
- En este punto sabemos que la llamada al método tiene la forma OrderBy(e => …). Extraemos la expresión lambda de esta llamada y obtenemos el parámetro utilizado en esa expresión, es decir, la e.
- Ahora construimos un nuevo MethodCallExpression usando el método de construcción Expression.Call. En este caso, el método que se llama es ThenBy(e => e.Id). Construimos esto usando el parámetro extraído arriba y un acceso de propiedad a la propiedad Id de la interfaz IHasIntKey.
- La entrada en esta llamada es el OrderBy(e => …) original, por lo que el resultado final es una expresión para OrderBy(e => …).ThenBy(e => e.Id).
- Esta expresión modificada la devuelve el visitante, lo que significa que la consulta LINQ ahora se ha modificado correctamente para incluir una llamada ThenBy.
- EF Core continues and compiles this query expression into the appropriate SQL for the database being used.
Este interceptor se registra de la misma manera que lo hicimos para el primer ejemplo. Ejecutar GetPageOfCustomers ahora genera el siguiente SQL:
SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber" FROM "Customers" AS "c" ORDER BY "c"."City", "c"."Id" LIMIT @__p_1 OFFSET @__p_0
Ahora siempre producirá un pedido estable, incluso si hay varios clientes con la misma ciudad.
¡Uf! Eso es mucho código para hacer un cambio simple en una consulta. Y lo que es peor, es posible que ni siquiera funcione para todas las consultas. Es notoriamente difícil escribir una expresión visitante que reconozca todas las formas de consulta que debería, y ninguna de las que no debería. Por ejemplo, es probable que esto no funcione si la ordenación se realiza en una subconsulta.
Esto nos lleva a un punto crítico sobre los interceptores: siempre pregúntese si hay una forma más fácil de hacer lo que quiere. Los interceptores son poderosos, pero es fácil equivocarse. Son, como dice el refrán, una manera fácil de pegarse un tiro en el pie.
For example, imagine if we instead changed our GetPageOfCustomers method like so:
List<Customer> GetPageOfCustomers2(string sortProperty, int page) { using var context = new CustomerContext(); return context.Customers .OrderBy(e => EF.Property<object>(e, sortProperty)) .ThenBy(e => e.Id) .Skip(page * 20).Take(20).ToList(); }
En este caso, el ThenBy simplemente se agrega a la consulta. Sí, es posible que deba hacerse por separado para cada consulta, pero es simple, fácil de entender y siempre funcionará.
Intercepción de concurrencia optimista
EF Core admite el patrón de simultaneidad optimista al verificar que la cantidad de filas realmente afectadas por una actualización o eliminación sea la misma que la cantidad de filas que se espera que se vean afectadas. Esto a menudo se combina con un token de concurrencia; es decir, un valor de columna que solo coincidirá con su valor esperado si la fila no se ha actualizado desde que se leyó el valor esperado.+
EF señala una violación de la simultaneidad optimista al lanzar una excepción DbUpdateConcurrencyException. En EF7, ISaveChangesInterceptor tiene nuevos métodos ThrowingConcurrencyException y ThrowingConcurrencyExceptionAsync que se llaman antes de que se produzca DbUpdateConcurrencyException. Estos puntos de intercepción permiten suprimir la excepción, posiblemente junto con cambios de base de datos asíncronos para resolver la infracción.
Por ejemplo, si dos solicitudes intentan eliminar la misma entidad casi al mismo tiempo, la segunda eliminación puede fallar porque la fila en la base de datos ya no existe. Esto puede estar bien; el resultado final es que la entidad se eliminó de todos modos. El siguiente interceptor demuestra cómo se puede hacer esto:
public class SuppressDeleteConcurrencyInterceptor : ISaveChangesInterceptor { public InterceptionResult ThrowingConcurrencyException( ConcurrencyExceptionEventData eventData, InterceptionResult result) { if (eventData.Entries.All(e => e.State == EntityState.Deleted)) { Console.WriteLine("Suppressing Concurrency violation for command:"); Console.WriteLine(((RelationalConcurrencyExceptionEventData) eventData).Command.CommandText); return InterceptionResult.Suppress(); } return result; } public ValueTask<InterceptionResult> ThrowingConcurrencyExceptionAsync( ConcurrencyExceptionEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) => new(ThrowingConcurrencyException(eventData, result)); }
Hay varias cosas que vale la pena señalar sobre este interceptor:
- Se implementan los métodos de intercepción sincrónicos y asincrónicos. Esto es importante si la aplicación puede llamar a SaveChanges o SaveChangesAsync. Sin embargo, si todo el código de la aplicación es asíncrono, solo se debe implementar ThrowingConcurrencyExceptionAsync. Del mismo modo, si la aplicación nunca usa métodos de base de datos sincrónicos, solo se debe implementar ThrowingConcurrencyException. Esto es generalmente cierto para todos los interceptores con métodos de sincronización y asíncrono. (Podría valer la pena implementar el método que su aplicación no usa para lanzar, en caso de que aparezca algún código de sincronización/asincronía).
- El interceptor tiene acceso a los objetos EntityEntry para las entidades que se guardan. En este caso, esto se usa para verificar si la violación de concurrencia está ocurriendo o no para una operación de eliminación.
- Si la aplicación utiliza un proveedor de base de datos relacional, el objeto ConcurrencyExceptionEventData se puede convertir en un objeto RelationalConcurrencyExceptionEventData. Esto proporciona información adicional específica de la relación sobre la operación de la base de datos que se está realizando. En este caso, el texto del comando de relación se imprime en la consola.
- Devolver InterceptionResult.Suppress() le indica a EF Core que suprima la acción que estaba a punto de realizar; en este caso, lanza la excepción DbUpdateConcurrencyException. Esta es una de las características más poderosas de los interceptores para cambiar el comportamiento de EF Core, en lugar de simplemente observar lo que hace EF Core..
Conclusiones
Tenemos varios interceptores nuevos y mejoras en el comportamiento de los interceptores que teníamos hasta el momento. Para serles sincero, son bastante cosas que asimilar y soluciones que hoy tengo a implementar, espero ansioso es versión final. En próximos post veremos más novedades.