Compartir a través de


Filtros de consulta global

Los filtros de consulta global permiten adjuntar un filtro a un tipo de entidad y tener ese filtro aplicado cada vez que se ejecuta una consulta en ese tipo de entidad; piense en ellos como un operador LINQ Where adicional que se agrega cada vez que se consulta el tipo de entidad. Estos filtros son útiles en una variedad de casos.

Sugerencia

Puede ver un ejemplo de este artículo en GitHub.

Ejemplo básico: eliminación suave

En algunos escenarios, en lugar de eliminar una fila de la base de datos, es preferible establecer una IsDeleted marca para marcar la fila como eliminada; este patrón se denomina eliminación temporal. La eliminación suave permite que las filas se recuperen si es necesario, o conservar un registro de auditoría en el que todavía se puede acceder a las filas eliminadas. Los filtros de consulta globales se pueden usar para filtrar las filas eliminadas temporalmente de forma predeterminada, a la vez que permiten acceder a ellas en lugares específicos deshabilitando el filtro de una consulta específica.

Para habilitar la eliminación temporal, vamos a agregar una propiedad IsDeleted a nuestro tipo de blog:

public class Blog
{
    public int Id { get; set; }
    public bool IsDeleted { get; set; }

    public string Name { get; set; }
}

Ahora configuramos un filtro de consulta global mediante la HasQueryFilter API en OnModelCreating:

modelBuilder.Entity<Blog>().HasQueryFilter(b => !b.IsDeleted);

Ahora podemos consultar nuestras Blog entidades como de costumbre; el filtro configurado garantizará que todas las consultas filtrarán de forma predeterminada todas las instancias donde IsDeleted sea true.

Tenga en cuenta que, en este momento, debe establecer IsDeleted manualmente para realizar una eliminación suave de una entidad. Para una solución más integral, puede sobrescribir el método SaveChangesAsync del tipo de contexto para agregar lógica que repase todas las entidades que el usuario eliminó y, en su lugar, cambiarlas para modificarlas, estableciendo la propiedad IsDeleted en verdadero.

public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
    ChangeTracker.DetectChanges();

    foreach (var item in ChangeTracker.Entries<Blog>().Where(e => e.State == EntityState.Deleted))
    {
        item.State = EntityState.Modified;
        item.CurrentValues["IsDeleted"] = true;
    }

    return await base.SaveChangesAsync(cancellationToken);
}

Esto le permite usar las API de EF que eliminan una instancia de entidad como de costumbre y hacer que se eliminen temporalmente en su lugar.

Uso de datos de contexto: varios inquilinos

Otro escenario estándar para los filtros de consulta global es la multitenencia, donde tu aplicación almacena datos que pertenecen a distintos usuarios en la misma tabla. En tales casos, normalmente hay una columna de identificador de inquilino que asocia la fila a un inquilino específico y los filtros de consulta globales se pueden usar para filtrar automáticamente las filas del inquilino actual. Esto proporciona un fuerte aislamiento de los inquilinos para las consultas de forma predeterminada, lo que elimina la necesidad de pensar en filtrar por inquilino en cada consulta.

Al contrario de la eliminación suave, el sistema multi-tenant requiere conocer el identificador de inquilino actual; este valor se determina normalmente, por ejemplo, cuando el usuario se autentica a través de la web. Para los fines de EF, el identificador de inquilino debe estar disponible en la instancia de contexto, de modo que el filtro de consulta global pueda hacer referencia a él y usarlo al consultar. Vamos a aceptar un parámetro tenantId en el constructor del tipo de contexto y referenciarlo desde nuestro filtro.

public class MultitenancyContext(string tenantId) : DbContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Blog>().HasQueryFilter(b => b.TenantId == tenantId);
    }
}

Esto obliga a cualquier persona a crear un contexto para especificar su identificador de inquilino asociado y garantiza que solo Blog las entidades con ese identificador se devuelvan de las consultas de forma predeterminada.

Nota:

En este ejemplo solo se muestran los conceptos básicos de multitenencia necesarios para demostrar los filtros de consulta globales. Para obtener más información sobre multiinquilino y EF, consulte multiinquilino en aplicaciones de EF Core.

Uso de varios filtros de consulta

Al llamar HasQueryFilter con un filtro simple, se sobrescribe cualquier filtro anterior, por lo que no se pueden definir varios filtros en el mismo tipo de entidad de esta manera:

modelBuilder.Entity<Blog>().HasQueryFilter(b => !b.IsDeleted);
// The following overwrites the previous query filter:
modelBuilder.Entity<Blog>().HasQueryFilter(b => b.TenantId == tenantId);

Nota:

Esta característica se está introduciendo en EF Core 10.0 (en versión preliminar).

Para definir varios filtros de consulta en el mismo tipo de entidad, deben denominarse:

modelBuilder.Entity<Blog>()
    .HasQueryFilter("SoftDeletionFilter", b => !b.IsDeleted)
    .HasQueryFilter("TenantFilter", b => b.TenantId == tenantId);

Esto le permite administrar cada filtro por separado, incluida la deshabilitación selectiva de uno pero no el otro.

Deshabilitación de filtros

Los filtros se pueden deshabilitar para las consultas LINQ individuales mediante el IgnoreQueryFilters operador :

var allBlogs = await context.Blogs.IgnoreQueryFilters().ToListAsync();

Si se configuran varios filtros con nombre, esto deshabilita todos ellos. Para deshabilitar selectivamente filtros específicos (a partir de EF 10), pase la lista de nombres de filtro que se deshabilitarán:

var allBlogs = await context.Blogs.IgnoreQueryFilters(["SoftDeletionFilter"]).ToListAsync();

Filtros de consulta y navegaciones necesarias

Precaución

El uso de la navegación necesaria para acceder a la entidad que tiene definido el filtro de consulta global puede provocar resultados inesperados.

Las navegaciones necesarias en EF implican que la entidad relacionada siempre está presente. Dado que se pueden usar combinaciones internas para obtener entidades relacionadas, si el filtro de consulta excluye una entidad relacionada necesaria, la entidad padre también se excluirá. Esto puede dar lugar a que se recuperen inesperadamente menos elementos de los esperados.

Para ilustrar el problema, podemos usar Blog y Post entidades y configurarlos de la siguiente manera:

modelBuilder.Entity<Blog>().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired();
modelBuilder.Entity<Blog>().HasQueryFilter(b => b.Url.Contains("fish"));

El modelo se puede inicializar con los datos siguientes:

db.Blogs.Add(
    new Blog
    {
        Url = "http://sample.com/blogs/fish",
        Posts =
        [
            new() { Title = "Fish care 101" },
            new() { Title = "Caring for tropical fish" },
            new() { Title = "Types of ornamental fish" }
        ]
    });

db.Blogs.Add(
    new Blog
    {
        Url = "http://sample.com/blogs/cats",
        Posts =
        [
            new() { Title = "Cat care 101" },
            new() { Title = "Caring for tropical cats" },
            new() { Title = "Types of ornamental cats" }
        ]
    });

El problema se puede observar al ejecutar las dos consultas siguientes:

var allPosts = await db.Posts.ToListAsync();
var allPostsWithBlogsIncluded = await db.Posts.Include(p => p.Blog).ToListAsync();

Con la configuración anterior, la primera consulta devuelve las 6 Post instancias, pero la segunda consulta devuelve solo 3. Esta falta de coincidencia se produce porque el Include método de la segunda consulta carga las entidades relacionadas Blog . Dado que se requiere la navegación entre Blog y Post , EF Core usa INNER JOIN al construir la consulta:

SELECT [p].[PostId], [p].[BlogId], [p].[Content], [p].[IsDeleted], [p].[Title], [t].[BlogId], [t].[Name], [t].[Url]
FROM [Posts] AS [p]
INNER JOIN (
    SELECT [b].[BlogId], [b].[Name], [b].[Url]
    FROM [Blogs] AS [b]
    WHERE [b].[Url] LIKE N'%fish%'
) AS [t] ON [p].[BlogId] = [t].[BlogId]

El uso de INNER JOIN filtra todas las filas Post cuyas filas Blog relacionadas se han filtrado mediante un filtro de consulta. Este problema se puede solucionar configurando la navegación como navegación opcional en lugar de necesaria, lo que hace que EF genere un LEFT JOIN en lugar de :INNER JOIN

modelBuilder.Entity<Blog>().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired(false);
modelBuilder.Entity<Blog>().HasQueryFilter(b => b.Url.Contains("fish"));

Un enfoque alternativo consiste en especificar filtros coherentes sobre los tipos de entidad Blog y Post, y una vez que se aplican filtros coincidentes a Blog y Post, las filas de Post que podrían terminar en un estado inesperado se eliminan, y ambas consultas devuelven 3 resultados.

modelBuilder.Entity<Blog>().HasMany(b => b.Posts).WithOne(p => p.Blog).IsRequired();
modelBuilder.Entity<Blog>().HasQueryFilter(b => b.Url.Contains("fish"));
modelBuilder.Entity<Post>().HasQueryFilter(p => p.Blog.Url.Contains("fish"));

Filtros de consulta e IEntityTypeConfiguration

Si el filtro de consulta necesita tener acceso a un identificador de arrendatario o información contextual similar, IEntityTypeConfiguration<TEntity> puede suponer una complicación adicional, ya que, a diferencia de OnModelCreating, no hay ninguna instancia de su tipo de contexto disponible fácilmente para hacer referencia desde el filtro de consulta. Como solución alternativa, agregue un contexto ficticio al tipo de configuración y haga referencia al siguiente:

private sealed class CustomerEntityConfiguration : IEntityTypeConfiguration<Customer>
{
    private readonly SomeDbContext _context = null!;

    public void Configure(EntityTypeBuilder<Customer> builder)
    {
        builder.HasQueryFilter(d => d.TenantId == _context.TenantId);
    }
}

Limitaciones

Los filtros de consulta global tienen las siguientes limitaciones:

  • Los filtros solo se pueden definir para el tipo de entidad raíz de una jerarquía de herencia.
  • Actualmente EF Core no detecta ciclos en definiciones de filtro de consulta globales, por lo que debe tener cuidado al definirlos. Si se especifica incorrectamente, los ciclos podrían provocar bucles infinitos durante la traducción de consultas.