Skip to content

Query Filters

Query filters are one of the main reasons this persistence stack exists.

They let you define data visibility rules once and apply them consistently, instead of relying on every query author to remember the same Where(...) clauses.

For novice users, the most important mental model is:

  • filters are safety rails
  • they run automatically on generated repository reads
  • you disable them only on purpose, in a clearly scoped block

In a real application, many rows should not be visible all the time:

  • soft-deleted records should usually stay hidden
  • tenant data should not leak across tenants
  • temporal data may need “currently valid only” behavior
  • row-level authorization may depend on the current user

Without query filters, each repository or handler must remember those conditions manually.

That is not a good reliability model.

There are three pieces:

  1. Generated filters Produced from persistence attributes such as [SoftDelete].
  2. Runtime filter services IQueryFilterProvider, IQueryFilterToggle, and related infrastructure.
  3. Generated repositories They call the filter provider automatically on read paths.

In EF Core, navigation filtering is also applied through the filter-map pipeline so Include(...) paths do not accidentally bypass the same rules.

Generated query filters are registered by a generated extension method.

Typical startup:

builder.Services.AddSalesDbContext(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Sales")));
builder.Services.AddSalesRepositories();
builder.Services.AddMyAppQueryFilters();

Important:

  • the query-filter registration method name is derived from the common namespace prefix
  • if no common prefix can be derived, the fallback method is AddGeneratedQueryFilters()
  • repositories can still resolve without this registration, but filter behavior will not match production expectations
SourceGenerated effectTypical purpose
[SoftDelete]Adds a soft-delete filterHide deleted rows from normal reads
Tenant-aware entity shapeAdds a tenant filterRestrict rows to the current tenant
Temporal relation metadataAdds a temporal filterHide inactive historical rows by default

You do not call these filters directly. The repository and query pipeline apply them for you.

Your code:

var orders = await ordersRepository.FindAsync(
Spec<Order>.Where(o => o.Total > 1000),
ct);

What the repository effectively does:

  • starts from the DbSet<Order>
  • asks IQueryFilterProvider for the combined filter
  • applies the filter before executing the query

That is why soft-delete and tenant rules stay consistent across the application.

IQueryFilterToggle: temporary, scoped overrides

Section titled “IQueryFilterToggle: temporary, scoped overrides”

Sometimes you really do need to bypass a filter:

  • admin recovery screens
  • support tooling
  • data repair scripts
  • migration workflows

IQueryFilterToggle is the safe mechanism for that.

Example:

public sealed class AdminOrdersService(
IRepository<Order, Guid> orders,
IQueryFilterToggle filterToggle)
{
public Task<List<Order>> GetIncludingDeleted(CancellationToken ct)
{
using (filterToggle.Disable<SoftDeleteFilter_Order>())
{
return orders.Query().ToListAsync(ct);
}
}
}

Why the using block matters:

  • the override is temporary
  • the previous state is restored automatically
  • the change stays local to the current async flow

Under the hood, QueryFilterToggle uses AsyncLocal, so the scope works correctly with await.

If you need a broader behavioral switch, use FilterMode.

using (filterToggle.UseMode(FilterMode.Admin))
{
// Skip visibility and permission-based filters.
}
using (filterToggle.UseMode(FilterMode.Background))
{
// Skip tenant, visibility, and permission-based filters.
}
using (filterToggle.UseMode(FilterMode.Raw))
{
// Bypass all automatic filters.
}

Current modes:

ModeMeaning
NormalDefault behavior. All normal filters can apply.
AdminSkip visibility and permission-based filters.
BackgroundSkip tenant, visibility, and permission-based filters; keep soft-delete.
RawBypass all automatic filters.

For novice teams, use Raw sparingly. It is the sharpest tool in the box.

Both can result in “show me everything”, but they communicate slightly different intent:

  • DisableAll() means “turn off all filters in this scope”
  • UseMode(FilterMode.Raw) means “run this query path in raw mode”

If you are writing application code, UseMode(...) is often easier to reason about because it expresses a mode change rather than a collection of disabled filters.

At runtime, filters evaluate against a FilterContext.

It carries information such as:

  • current tenant
  • current user ID
  • current timestamp
  • disabled filter types
  • current mode

That is how the same filter infrastructure can support:

  • tenant filtering
  • time-aware filtering
  • permission-aware behavior
  • admin or migration bypasses

When a filter depends on the current user’s permissions, implement IPermissionBasedFilter<T>.

Example:

using System.Linq.Expressions;
using Pragmatic.Identity;
using Pragmatic.Persistence.Query.Filters;
public sealed class TeamOrdersFilter(ICurrentUser user) : IPermissionBasedFilter<Order>
{
public string BypassPermission => "orders.view_all";
public int Priority => 300;
public Expression<Func<Order, bool>> GetFilter()
{
var teamId = user.Claims.TryGetValue("team_id", out var value) ? value : "";
return order => order.TeamId == teamId;
}
}

Important behavior built into DefaultQueryFilterProvider:

  • if no ICurrentUser is available, permission-based filters are skipped
  • if the user is not authenticated, permission-based filters are skipped
  • if the user has the bypass permission, the filter is skipped

That design prevents background jobs and maintenance paths from crashing just because there is no authenticated request user.

Register custom filters in DI as IQueryFilter implementations:

services.AddScoped<IQueryFilter, TeamOrdersFilter>();

Some filters cannot be generated at compile time because they depend on runtime data from another service.

That is what IVisibilityFilterProvider is for.

Example:

using System.Linq.Expressions;
using Pragmatic.Persistence.Query.Filters;
public sealed class TeamVisibilityProvider(ITeamService teams) : IVisibilityFilterProvider
{
public IReadOnlyDictionary<Type, LambdaExpression> GetFilters(FilterContext context)
{
if (context.SkipVisibility)
return new Dictionary<Type, LambdaExpression>();
var teamIds = teams.GetVisibleTeamIds(context.UserId);
return new Dictionary<Type, LambdaExpression>
{
[typeof(Project)] = (Expression<Func<Project, bool>>)(project => teamIds.Contains(project.TeamId))
};
}
}

Register it like any other scoped runtime service:

services.AddScoped<IVisibilityFilterProvider, TeamVisibilityProvider>();

FilterMapComposer merges these runtime filters with the source-generated filter map used for navigation filtering.

A common novice question is:

“If I include child collections, do filters still apply there?”

In this stack, yes. That is one of the main benefits of the EF Core integration.

Example:

var order = await orderRepository.GetByIdAsync(
orderId,
query => query.Include(o => o.Items),
ct);

With the filter pipeline active, child collections can still respect filters such as soft-delete instead of loading everything blindly.

This is handled by the generated filter map plus FilterMapComposer in the EF Core layer.

Forgetting to register generated query filters

Section titled “Forgetting to register generated query filters”

If you skip AddMyAppQueryFilters() or AddGeneratedQueryFilters(), the repository may still run but the expected filtering contract will not be fully active.

Prefer the narrowest possible scope:

  • disable one filter if that is enough
  • use Admin or Background mode when the behavior matches the use case
  • reserve Raw for migrations, support, or very explicit low-level paths

Treating filter bypass as a normal business path

Section titled “Treating filter bypass as a normal business path”

If a screen or endpoint always needs raw data, that is often a modeling smell. Revisit the business rule before normalizing widespread filter bypass.

Assuming interface methods expose everything

Section titled “Assuming interface methods expose everything”

Filters affect repository reads, but helper methods like GetBySkuAsync(...) still live on the concrete generated repository, not on IRepository<TEntity, TId>.

When testing filter behavior:

  • register generated query filters in the test DI container
  • resolve IQueryFilterToggle from DI
  • assert both the default filtered behavior and the explicitly disabled behavior

For a full test harness, see Testing Generated Persistence.