Named Query Filters support in Entity Framework Core has been requested for a long time (as far back as 2017), and it is finally coming in EF Core 10. The PR with this feature has been recently merged into the main branch of the EF Core repository on GitHub, and will be available with the next major release. I’m pretty excited about this one, as it will grant developers more control over shared behavior of their queries, making it easier to manage complex filtering logic.
What are global query filters?
Global query filters are a powerful feature in EF Core that allow you to define a filter applied to all queries for a specific entity type. This is useful in scenarios like soft deletes or multi-tenancy, where you want to apply shared filtering logic across your application instead of duplicating it in every database query.
Right now, if you want to apply a global filter in EF Core, you can use the HasQueryFilter
method on your entities configuration, like this:
1modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted);
You can also create a base class or interface (like ISoftDeletable
or ITenantable
) for your entities that will share the same filtering logic and apply the filter to all entities that inherit from it, during the model configuration phase. It involves more sophisticated logic, but it allows you to keep your code DRY and maintainable. Here’s an example of how you can do that:
1public class MyDbContext : DbContext 2{ 3 public MyDbContext(DbContextOptions<MyDbContext> options) : base(options) 4 { 5 } 6 7 protected override void OnModelCreating(ModelBuilder modelBuilder) 8 { 9 var softDeletableEntities = modelBuilder.Model 10 .GetEntityTypes() 11 .Where(t => typeof(ISoftDeletable).IsAssignableFrom(t.ClrType)); 12 foreach (var entityType in softDeletableEntities) 13 { 14 modelBuilder.Entity(entityType.ClrType) 15 .HasQueryFilter(ConvertFilterExpression<ISoftDeletable>(e => !e.IsDeleted, entityType.ClrType)); 16 } 17 } 18 19 // Helper method to create the query filter expression 20 private static LambdaExpression ConvertFilterExpression<TInterface>( 21 Expression<Func<TInterface, bool>> filterExpression, 22 Type entityType) 23 { 24 var parameter = Expression.Parameter(entityType); 25 var body = ReplacingExpressionVisitor.Replace( 26 filterExpression.Parameters[0], 27 parameter, 28 filterExpression.Body); 29 30 return Expression.Lambda(body, parameter); 31 } 32}
Current limitations
This approach works well, but it has some restrictions. As written in the documentation: Calling HasQueryFilter with a simple filter overwrites any previous filter, so multiple filters cannot be defined on the same entity type. This means that if you want to apply multiple filters, you need to combine them into a single expression, which can lead to complex and hard-to-read code.
What's more, if you want to disable the global filter for a specific query, you need to use the IgnoreQueryFilters
method, but it will disable all global filters for that query - you have no control over it. So even if you have multiple filters defined (by combining them into a single expression), you cannot disable just one of them, which can lead to unexpected results.
1public async Task<List<Product>> GetProductsAsync(bool includeDeleted = false) 2{ 3 IQueryable<Product> query = _context.Products; 4 5 // We want to fetch deleted/archived products 6 if (includeDeleted) 7 { 8 // This will include soft-deleted products 9 // but if there is also a multi-tenancy filter, 10 // it will be ignored as well. 11 // We don't want that! 12 query = query.IgnoreQueryFilters(); 13 } 14 15 return await query.ToListAsync(); 16}
Fine-grained control
Those issues are addressed with the new named query filters feature. It will allow you to define multiple filters for the same entity type, without overwriting existing ones. But keep in mind that if you use old syntax (without filter naming), only the last filter will be applied. To make it work, it requires query filters to be named, like in the following example:
1public static class GlobalFilters 2{ 3 public static readonly string SoftDeleteFilter = "SoftDeleteFilter"; 4 public static readonly string TenantFilter = "TenantFilter"; 5} 6 7modelBuilder.Entity<Product>().HasQueryFilter(GlobalFilters.SoftDeleteFilter, p => !p.IsDeleted); 8modelBuilder.Entity<Product>().HasQueryFilter(GlobalFilters.TenantFilter, p => p.TenantId == _currentTenantId);
What's also cool is that you will be able to selectively disable specific filters by name, instead of disabling all of them at once! Merged changes include overload of IgnoreQueryFilters
method, that accepts a collection of filter names to disable. For example, you can disable the soft delete filter while keeping the tenant filter in place:
1public async Task<List<Product>> GetProductsAsync(bool includeDeleted = false) 2{ 3 IQueryable<Product> query = _context.Products; 4 5 // We want to fetch deleted/archived products as well 6 if (includeDeleted) 7 { 8 // This will include soft-deleted products 9 // but the tenant filter will still be applied 10 query = query.IgnoreQueryFilters([GlobalFilters.SoftDeleteFilter]); 11 } 12 13 return await query.ToListAsync(); 14}
Keep the good stuff coming
I will always welcome new features like this one with open arms - it's not breaking any existing code, it just gives more control over your logic. You don't have to use it if you don't need it, but if you do, it will make your code cleaner and easier to maintain.
Also, I highly recommend to check out pull request with this feature and go through the code changes to see how it all works under the hood. It is a great example of how to implement a new feature in EF Core, and it shows how much effort is put into making it work seamlessly with existing functionality.