DEV Community

Cover image for Global Query Filters in EF Core
Anton Martyniuk
Anton Martyniuk

Posted on • Originally published at antondevtips.com on

Global Query Filters in EF Core

What are global query filters?

Global query filters in Entity Framework Core (EF Core) is a powerful feature that can be effectively used to manage data access patterns.

Global query filters are LINQ query predicates applied to EF Core entity models. These filters are automatically applied to all queries involving corresponding entities. This is especially useful in multi-tenant applications or scenarios requiring soft deletion.

Query filters for a soft delete use case

Let’s explore a use case where global query filters are particularly useful —  entity soft deletion. In some applications entities can’t be completely deleted from the database and should remain for statistics and history purposes, for example. Or to ensure that related data remains unchanged, i.e: referenced by foreign keys. A solution for this use case is soft deletion.

Soft deletion is implemented by adding an is_deleted column to the database table for required entities. Whenever an entity is considered deleted - this column is set to true. In most of the application's database queries "deleted" entities should be ignored in read operations and not visible to the end user.

Let’s explore an example for the following entities:

public class Author
{
    public required Guid Id { get; set; }
    public required string Name { get; set; }
    public required string Country { get; set; }
    public required List<Book> Books { get; set; } = [];
}

public class Book
{
    public required Guid Id { get; set; }
    public required string Title { get; set; }
    public required int Year { get; set; }
    public required bool IsDeleted { get; set; }
    public required Guid TenantId { get; set; }
    public required Author Author { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

We need to create and setup our DbContext with a global query filter:

public class ApplicationDbContext : DbContext
{
    public DbSet<Author> Authors { get; set; } = default!;

    public DbSet<Book> Books { get; set; } = default!;

    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Book>()
            .HasQueryFilter(x => !x.IsDeleted);

        base.OnModelCreating(modelBuilder);

        modelBuilder.Entity<Author>(entity =>
        {
            entity.ToTable("authors");

            entity.HasKey(x => x.Id);
            entity.HasIndex(x => x.Name);

            entity.Property(x => x.Id).IsRequired();
            entity.Property(x => x.Name).IsRequired();
            entity.Property(x => x.Country).IsRequired();

            entity.HasMany(x => x.Books)
                .WithOne(x => x.Author);
        });

        modelBuilder.Entity<Book>(entity =>
        {
            entity.ToTable("books");

            entity.HasKey(x => x.Id);
            entity.HasIndex(x => x.Title);

            entity.Property(x => x.Id).IsRequired();
            entity.Property(x => x.Title).IsRequired();
            entity.Property(x => x.Year).IsRequired();
            entity.Property(x => x.IsDeleted).IsRequired();

            entity.HasOne(x => x.Author)
                .WithMany(x => x.Books);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

In this code we’re applying a query filter to the Book entity on the IsDeleted property:

modelBuilder.Entity<Book>()
    .HasQueryFilter(x => !x.IsDeleted);
Enter fullscreen mode Exit fullscreen mode

Here we are filtering out all softly deleted books from the result query. When querying books from DbContext this query filter is applied automatically. Let’s have a look on the following minimal API endpoint:

app.MapGet("/api/books", async (ApplicationDbContext dbContext) =>
{
    var nonDeletedBooks = await dbContext.Books.ToListAsync();
    return Results.Ok(nonDeletedBooks);
});
Enter fullscreen mode Exit fullscreen mode

Every time we query books, we only get those that are not deleted, thus we don’t need to use a LINQ Where statement in all DbContext queries.

In some cases, however, we might need to access all entities and ignore the query filter. EF Core has a special method called IgnoreQueryFilters for such a case:

app.MapGet("/api/all-books", async (ApplicationDbContext dbContext) =>
{
    var allBooks = await dbContext.Books
        .IgnoreQueryFilters()
        .Where(x => x.IsDeleted)
        .ToListAsync();

    return Results.Ok(allBooks);
});
Enter fullscreen mode Exit fullscreen mode

That way all the books are retrieved from the database and query filter on the Book entity is completely ignored.

Query filters for a multi-tenant application

Another useful use case for global query filters is multi-tenancy. A multi-tenant application is an application that shares a software for different customers. All the data stored for customers should not be visible to other customers.

Let’s explore the simplest implementation of multi-tenancy by storing all the data in the same database and table.

First, we need to add a TenantId property to the Books entity:

public class Book
{
    // Other properties ...

    public required Guid TenantId { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Second, we need to update the DbContext:

public class ApplicationDbContext : DbContext
{
    private readonly Guid? _currentTenantId;

    public DbSet<Author> Authors { get; set; } = default!;

    public DbSet<Book> Books { get; set; } = default!;

    public ApplicationDbContext(
        DbContextOptions<ApplicationDbContext> options,
        ITenantService tenantService) : base(options)
    {
        _currentTenantId = tenantService.GetCurrentTenantId();
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Book>()
            .HasQueryFilter(x => !x.IsDeleted && x.TenantId == _currentTenantId);

        base.OnModelCreating(modelBuilder);

        // Rest of the code remains unchanged
    }
}
Enter fullscreen mode Exit fullscreen mode

In this code we’ve updated the query filter and added a x.TenantId == _currentTenantId statement. As we're creating DbContext per request - we can inject the current tenant id from the request (identifier of the customer accessing data in our application).

Here’s a simple tenant service implementation that retrieves a tenant id from HTTP request headers:

public interface ITenantService
{
    Guid? GetCurrentTenantId();
}

public class TenantService : ITenantService
{
    private readonly Guid? _currentTenantId;

    public TenantService(IHttpContextAccessor accessor)
    {
        var headers = accessor.HttpContext?.Request.Headers;

        _currentTenantId = headers.TryGetValue("Tenant-Id", out var value) is true
            ? Guid.Parse(value.ToString())
            : null;
    }

    public Guid? GetCurrentTenantId() => _currentTenantId;
}
Enter fullscreen mode Exit fullscreen mode

Now let’s create a corresponding minimal API endpoint:

app.MapGet("/api/tenant-books", async (ApplicationDbContext dbContext) =>
{
    var tenantBooks = await dbContext.Books.ToListAsync();
    return Results.Ok(tenantBooks);
});
Enter fullscreen mode Exit fullscreen mode

On every read query a tenant id global query filter is applied ensuring the data integrity. As a result, when calling this endpoint, each customer can only retrieve their own books.

Summary

Global query filters in EF Core is a powerful feature that enforces a data access rules consistently across the application. They are particularly useful in multi-tenant architectures and scenarios like soft deletion , ensuring that filter queries are automatically applied during all read operations. By applying these filters to EF Core entity models, you can significantly simplify your data access code, ensure data integrity and reduce the risk of forgetting to apply important filters to the read operations.

Hope you find this blog post useful. Happy coding!

Originally published at https://antondevtips.com.

After reading the post consider the following:

  • Subscribe to receive newsletters with the latest blog posts
  • Download the source code for this post from my github (available for my sponsors on BuyMeACoffee and Patreon)

If you like my content —  consider supporting me

Unlock exclusive access to the source code from the blog posts by joining my Patreon and Buy Me A Coffee communities!

Top comments (0)