Named Query Filters (EF Core 10)

Combine soft-delete and tenant filters on one entity with EF Core 10 named filters, and switch off one of them without leaking the other.

Syntaxb.Entity<Order>() .HasQueryFilter("SoftDelete", o => !o.IsDeleted) .HasQueryFilter("Tenant", o => o.TenantId == TenantId); db.Orders.IgnoreQueryFilters(["SoftDelete"])

A global query filter is a predicate EF Core adds to every query for an entity type: LINQ queries, navigations loaded with Include, FromSql results, Find, and the WHERE clause of ExecuteUpdate and ExecuteDelete. The two classic uses are soft delete and tenant isolation, and most multi-tenant systems need both on the same entity.

Why names matter

Before EF Core 10 an entity had one filter. A second HasQueryFilter call silently replaced the first, so teams combined everything into one lambda with &&. Then the "recycle bin" endpoint called IgnoreQueryFilters() to show deleted orders, which removed the tenant condition too, and every tenant's deleted orders appeared. EF Core 10 lets each filter have a name: HasQueryFilter("SoftDelete", ...) and HasQueryFilter("Tenant", ...). IgnoreQueryFilters(["SoftDelete"]) switches off only that one; IgnoreQueryFilters() with no argument still removes all of them. Name every filter on an entity: EF Core rejects an entity that mixes an anonymous filter with named ones when it builds the model, so migrate an existing anonymous filter to a name in the same change.

QueryFilters appliedWho should use it
db.OrdersSoftDelete and TenantEvery normal endpoint
IgnoreQueryFilters(["SoftDelete"])Tenant onlyRecycle bin, restore, audit views
IgnoreQueryFilters()NonePlatform jobs that are cross-tenant by design

Per-request values

The model is built once per context type and cached for the life of the process. A filter that refers to a property or field of the DbContext, such as o.TenantId == TenantId, is re-evaluated for each context instance and sent as a parameter. A filter that captures a local variable or a constant in OnModelCreating is baked into the cached model: every request gets the tenant of the first one. Inject a tenant accessor into the context and expose it through a property. With AddDbContextPool the constructor cannot take scoped services, so set the property after leasing the context, from a scoped factory.

Surprises in production

  • Required navigations. If Customer is soft-deleted and Order.Customer is required, Include(o => o.Customer) uses an INNER JOIN and the orders of deleted customers vanish. EF warns about filtered principals on required relationships; make the navigation optional or filter both sides the same way.
  • Unique indexes. A soft-deleted product still owns its SKU, so recreating it violates the unique index. Use a filtered index: HasIndex(p => p.Sku).IsUnique().HasFilter("\"IsDeleted\" = false").
  • Only EF sees them. Dapper, SqlQuery over unmapped types, reporting tools and a psql session ignore the filters. For tenancy, add a second barrier such as PostgreSQL row-level security.
  • Indexes. Every query now filters on TenantId and IsDeleted; put TenantId first in composite indexes.

Example

Example · csharp
using System.Security.Claims;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantContext, ClaimsTenantContext>();
builder.Services.AddDbContext<ShopDb>(o => o.UseNpgsql(builder.Configuration.GetConnectionString("Shop")));
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
var app = builder.Build();

var orders = app.MapGroup("/orders").RequireAuthorization();

// Both filters: this tenant's live orders.
orders.MapGet("/", async (ShopDb db, CancellationToken ct) =>
    TypedResults.Ok(await db.Orders.AsNoTracking()
        .OrderByDescending(o => o.Id).Take(50)
        .Select(o => new OrderRow(o.Id, o.Total, o.IsDeleted))
        .ToListAsync(ct)));

// Recycle bin: soft delete off, tenant isolation still on.
orders.MapGet("/deleted", async (ShopDb db, CancellationToken ct) =>
    TypedResults.Ok(await db.Orders.IgnoreQueryFilters([ShopDb.SoftDeleteFilter]).AsNoTracking()
        .Where(o => o.IsDeleted)
        .Select(o => new OrderRow(o.Id, o.Total, o.IsDeleted))
        .ToListAsync(ct)));

orders.MapPost("/{id:int}/restore", async Task<Results<NoContent, NotFound>> (int id, ShopDb db, CancellationToken ct) =>
{
    var restored = await db.Orders.IgnoreQueryFilters([ShopDb.SoftDeleteFilter])
        .Where(o => o.Id == id && o.IsDeleted)
        .ExecuteUpdateAsync(s => s.SetProperty(o => o.IsDeleted, false), ct);
    return restored == 1 ? TypedResults.NoContent() : TypedResults.NotFound();
});

app.Run();

public record OrderRow(int Id, decimal Total, bool IsDeleted);

public interface ITenantContext
{
    Guid TenantId { get; }
}

public sealed class ClaimsTenantContext(IHttpContextAccessor accessor) : ITenantContext
{
    public Guid TenantId =>
        Guid.TryParse(accessor.HttpContext?.User.FindFirstValue("tenant_id"), out var id)
            ? id
            : throw new InvalidOperationException("The request has no tenant_id claim.");
}

public class Order
{
    public int Id { get; set; }
    public Guid TenantId { get; set; }
    public decimal Total { get; set; }
    public bool IsDeleted { get; set; }
}

public class ShopDb(DbContextOptions<ShopDb> options, ITenantContext tenant) : DbContext(options)
{
    public const string SoftDeleteFilter = "SoftDelete";
    public const string TenantFilter = "Tenant";

    // Read per query from this instance, so each request is filtered by its own tenant.
    public Guid TenantId => tenant.TenantId;

    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>(order =>
        {
            order.HasQueryFilter(SoftDeleteFilter, o => !o.IsDeleted);
            order.HasQueryFilter(TenantFilter, o => o.TenantId == TenantId);
            order.HasIndex(o => new { o.TenantId, o.IsDeleted });
        });
    }
}

When to use it

  • Show a tenant's recycle bin of deleted orders with IgnoreQueryFilters(["SoftDelete"]) while the tenant filter stays in force.
  • Let a support user recreate a product whose SKU belonged to a soft-deleted product, using a filtered unique index.
  • Run a nightly platform job that counts orders across all tenants with IgnoreQueryFilters(), kept out of any HTTP endpoint.

More examples

Soft delete with an interceptor and a filtered unique index

Register it with <code>options.AddInterceptors(new SoftDeleteInterceptor(TimeProvider.System))</code>. Callers keep using <code>db.Products.Remove(product)</code> and the interceptor turns it into an UPDATE. Two limits: <code>ExecuteDeleteAsync</code> skips interceptors and really deletes, and EF's cascade handling still sees the original Deleted state for dependents tracked in the same context, so soft-delete children explicitly. The partial index uses PostgreSQL syntax; on SQL Server the filter is <code>[IsDeleted] = 0</code>.

Example · csharp
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;

public interface ISoftDeletable
{
    bool IsDeleted { get; set; }
    DateTimeOffset? DeletedAt { get; set; }
}

public class Product : ISoftDeletable
{
    public int Id { get; set; }
    public string Sku { get; set; } = "";
    public string Name { get; set; } = "";
    public bool IsDeleted { get; set; }
    public DateTimeOffset? DeletedAt { get; set; }
}

public sealed class SoftDeleteInterceptor(TimeProvider clock) : SaveChangesInterceptor
{
    public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
    {
        ConvertDeletes(eventData.Context);
        return result;
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
    {
        ConvertDeletes(eventData.Context);
        return ValueTask.FromResult(result);
    }

    private void ConvertDeletes(DbContext? context)
    {
        if (context is null) return;
        foreach (var entry in context.ChangeTracker.Entries<ISoftDeletable>().Where(e => e.State == EntityState.Deleted))
        {
            // Remove() becomes an UPDATE that hides the row.
            entry.State = EntityState.Modified;
            entry.Entity.IsDeleted = true;
            entry.Entity.DeletedAt = clock.GetUtcNow();
        }
    }
}

public class CatalogDb(DbContextOptions<CatalogDb> options) : DbContext(options)
{
    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>(product =>
        {
            product.HasQueryFilter("SoftDelete", p => !p.IsDeleted);
            // Only live rows compete for the SKU, so a deleted product can be recreated.
            product.HasIndex(p => p.Sku).IsUnique().HasFilter("\"IsDeleted\" = false");
        });
    }
}

The captured-variable leak, and the fix

The two contexts differ by one local variable and behave completely differently. In the correct one the SQL contains a parameter (EF names it after the member, for example <code>@ef_filter__CurrentTenantId</code>) whose value is read from the context executing the query. In the leaky one the value is a constant inside the cached model. Integration tests that run requests for two tenants against the same host catch this; tests that build a fresh host per tenant do not.

Example · csharp
using Microsoft.EntityFrameworkCore;

public class Invoice
{
    public int Id { get; set; }
    public Guid TenantId { get; set; }
    public decimal Amount { get; set; }
}

public interface ITenantContext
{
    Guid TenantId { get; }
}

// Wrong: OnModelCreating runs once per process. The first request's tenant is captured
// into the cached model and every later request, for every tenant, is filtered by it.
public class LeakyBillingDb(DbContextOptions<LeakyBillingDb> options, ITenantContext tenant) : DbContext(options)
{
    public DbSet<Invoice> Invoices => Set<Invoice>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        var tenantId = tenant.TenantId;
        modelBuilder.Entity<Invoice>().HasQueryFilter("Tenant", i => i.TenantId == tenantId);
    }
}

// Right: the filter reads a member of the context, which EF parameterises per instance.
public class BillingDb(DbContextOptions<BillingDb> options, ITenantContext tenant) : DbContext(options)
{
    private Guid CurrentTenantId => tenant.TenantId;

    public DbSet<Invoice> Invoices => Set<Invoice>();

    protected override void OnModelCreating(ModelBuilder modelBuilder) =>
        modelBuilder.Entity<Invoice>().HasQueryFilter("Tenant", i => i.TenantId == CurrentTenantId);
}

A test that proves tenants stay isolated

The seed context has <code>TenantId</code> left at <code>Guid.Empty</code>, which is fine because inserts are not filtered. The second assertion is the one that matters: with only the soft-delete filter switched off, Acme sees its own deleted order but not Globex's. If someone replaces the named call with <code>IgnoreQueryFilters()</code> in the recycle-bin code, a test like this against the real endpoint goes red. SQLite is enough here because the filters are plain LINQ.

Example · csharp
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Xunit;

public class Order
{
    public int Id { get; set; }
    public Guid TenantId { get; set; }
    public bool IsDeleted { get; set; }
}

public class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
    public Guid TenantId { get; set; }
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder) =>
        modelBuilder.Entity<Order>()
            .HasQueryFilter("SoftDelete", o => !o.IsDeleted)
            .HasQueryFilter("Tenant", o => o.TenantId == TenantId);
}

public class TenantFilterTests
{
    private static readonly Guid Acme = Guid.Parse("0b6c9f4e-5d1a-4c2b-9a51-6f0e2d3c4b11");
    private static readonly Guid Globex = Guid.Parse("7d2e8a10-3f4b-4e6c-8b9d-1a2b3c4d5e66");

    [Fact]
    public async Task Ignoring_soft_delete_keeps_the_tenant_filter()
    {
        var ct = TestContext.Current.CancellationToken;
        await using var connection = new SqliteConnection("Data Source=:memory:");
        await connection.OpenAsync(ct);
        var options = new DbContextOptionsBuilder<ShopDb>().UseSqlite(connection).Options;

        await using (var seed = new ShopDb(options))
        {
            await seed.Database.EnsureCreatedAsync(ct);
            seed.Orders.AddRange(
                new Order { TenantId = Acme },
                new Order { TenantId = Acme, IsDeleted = true },
                new Order { TenantId = Globex, IsDeleted = true });
            await seed.SaveChangesAsync(ct);
        }

        await using var db = new ShopDb(options) { TenantId = Acme };

        Assert.Equal(1, await db.Orders.CountAsync(ct));
        Assert.Equal(2, await db.Orders.IgnoreQueryFilters(["SoftDelete"]).CountAsync(ct));
        Assert.Equal(3, await db.Orders.IgnoreQueryFilters().CountAsync(ct));
    }
}

Discussion

  • Be the first to comment on this lesson.