EF Core Filters

lxalln

Alex Allen

Posted on June 15, 2021

EF Core Filters

Entity Framework core supports Global Query Filter which are a great way to enforce part of a query globally through your app.

Unfortunately, it comes with this note:

It is currently not possible to define multiple query filters on the same entity - only the last one will be applied. However, you can define a single filter with multiple conditions using the logical AND operator (&& in C#).

So, because you have to combine your filters into 1 big filter, this means that turning filters off using .IgnoreQueryFilters() turns off all filters. Which is a little dangerous, if you ask me.

Controlling filters 1 by 1

Wouldn't it be nice if you could turn off filters, one by one, or adjust them at run time. Well, you can, kinda. You just have to remember, that we can't really turn the filters off, so we have to adjust our filter to get the behaviour we want.

Multi-tenanted

We'll start with a simple tenanting filter. This is common across a lot of Saas apps:

In this example, we are building an Ordering system for multiple tenants.

///... order map
builder.HasQueryFilter(o => o.TenantId == DbContext.TenantId);
Enter fullscreen mode Exit fullscreen mode

All good so far. How your TenantId is set on the db context, is beyond the scope of this post, but it will likely be setup early in your request pipeline.

At runtime, we can control this TenantId and override it. Your reasons for doing this are your own, I'm not going to judge you!

A typical query might look like this:

var latestOrders = await _dbContext.Orders
                                   .OrderByDescending(o => o.CreatedOn)
                                   .Take(10)
                                   .ToListAsync();                          
Enter fullscreen mode Exit fullscreen mode

We can control the tenant Id at runtime, in the controller, and scope it with a using block:

using(_dbContext.OverrideTenantIdFilter(newTenantId))
{
    var latestOrders = await _dbContext.Orders
                                       .OrderByDescending(o => o.CreatedOn)
                                       .Take(10)
                                       .ToListAsync();       
}
Enter fullscreen mode Exit fullscreen mode

This will allow us to change the filter that gets used to build the query, but only during our scope. After wards it'll return to it's previous value. Very handy.

We need a couple of things to do this. A method on our DbContext and a new class.

First up, we'll create a new class to handle this for us:

public class TenantIdFilterOverrider : IDisposable
{
    private readonly AppDbContext _dbContext;
    private readonly Guid? _previousTenantIdOverride;

    public TenantIdFilterOverrider(AppDbContext dbContext, Guid overrideId)
    {
        _dbContext = dbContext;

        _previousTenantIdOverride = _dbContext.TenantIdOverride;

        _dbContext.TenantIdOverride = overrideId;
    }

    public void Dispose()
    {
        _dbContext.TenantIdOverride = _previousTenantIdOverride;
    }
}
Enter fullscreen mode Exit fullscreen mode

Then best part about these filters, is the use of the Dispose() method, to return the context back to its previous state when we are done with it.

Next we'll add a method to our DbContext that let's us use this:

public TenantIdFilterOverrider OverrideTenantIdFilter(Guid tenantId)
{
    return new TenantIdFilterOverrider(this, tenantId);
}
Enter fullscreen mode Exit fullscreen mode

We'll take a quick look at how the TenantId property works:

internal Guid? TenantIdOverride { get; set; }

internal Guid TenantId
{
    get
    {
        var tenantId = TenantIdOverride ?? _initialTenantId;

        return tenantId.Value;
    }
}
Enter fullscreen mode Exit fullscreen mode

This allows control over the tenant Id in a scoped way.

Turning a filter off

The above example is great, when you always have a value as part of your filter, but sometimes you might want to turn a filter off.

For example, we may have a second filter as part of our orders filter. Imagine we have a flag against the database to control visibilty of deleted orders:

builder.HasQueryFilter(o => o.TenantId == DbContext.TenantId
                            && DbContext.DeletedFilter.Contains(o.IsDeleted));
Enter fullscreen mode Exit fullscreen mode

Now, this is written in a slightly perculiar way. Remember that I said you cannot really turn the filters off? Well, in order to get a performant query, we need to support 3 states:

  • Deleted (1)
  • NotDeleted (0)
  • Either (nothing)

So, on our DbContext we have a new property, DeletedFilter:

internal IEnumerable<bool> OrderModeFilter
{
    get
    {
        if (DisableDeletedFilter)
        {
            return new []{ false, true };
        }

        return new [] { true };
    }
}
Enter fullscreen mode Exit fullscreen mode

When DisableDeletedFilter is true, this will produce SQL that is something akin to:

select * from Orders
where TenantId = '<GUID>'
and IsDeleted in (0, 1)
Enter fullscreen mode Exit fullscreen mode

or when the filter is not disabled:

select * from Orders
where TenantId = '<GUID>'
and IsDeleted in (1)
Enter fullscreen mode Exit fullscreen mode

This allows SQL to optimise the query in a performant way, without us having to disable all of our filters, and remember to add back on the TenantId filter!

So, just to complete this part, I'll show you how we disable that filter too:

//DbContext.cs
internal bool DisableDeletedFilter { get; set; }

public DeletedFilterDisabler IgnoreDeletedFilter()
{
    return new DeletedFilterDisabler(this);
}
Enter fullscreen mode Exit fullscreen mode
// DeletedFilterDisabler.cs
public class TestModeFilterDisabler : IDisposable
{
    private readonly AppDbContext _dbContext;
    private readonly bool _previousValue;

    public TestModeFilterDisabler(AppDbContext dbContext)
    {
        _dbContext = dbContext;
        _previousValue = _dbContext.DisableTestModeFilter;
        _dbContext.DisableTestModeFilter = true;
    }

    public void Dispose()
    {
        _dbContext.DisableTestModeFilter = _previousValue;
    }
}
Enter fullscreen mode Exit fullscreen mode
using(_dbContext.IgnoreDeletedFilter())
{
    var latestOrders = await _dbContext.Orders
                                       .OrderByDescending(o => o.CreatedOn)
                                       .Take(10)
                                       .ToListAsync();     
}
Enter fullscreen mode Exit fullscreen mode

This works really well with dependency injection, meaning a scoped DbContext may have it's tenant, or filters controlled early on in the pipeline, and the lower levels don't need to care about what filters they are running.

💖 💪 🙅 🚩
lxalln
Alex Allen

Posted on June 15, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related