Skip to content

Filter Pipeline

Query filters (see Query Filters) handle root-level filtering — when you query repo.Query(), soft-deleted records are automatically excluded.

But what about navigation properties? Consider:

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

If LineItem has [SoftDelete], should order.Items include soft-deleted line items? No — but EF Core’s Include() loads all related records. You’d need to write:

q.Include(o => o.Items.Where(i => !i.IsDeleted))

…on every Include, for every soft-deletable navigation, everywhere in your code. Forget one, and you leak deleted data.

The Solution: FilterMapComposer + Expression Visitor

Section titled “The Solution: FilterMapComposer + Expression Visitor”

The filter pipeline automatically injects .Where() clauses into Include and ThenInclude expressions. You write normal Include() calls, and the pipeline adds the filters.

// What you write:
q.Include(o => o.Items)
// What the pipeline transforms it to:
q.Include(o => o.Items.Where(i => !i.IsDeleted))

Step 1: FilterMapRegistry (Source-Generated)

Section titled “Step 1: FilterMapRegistry (Source-Generated)”

The SG generates a static registry mapping entity types to navigation filter expressions:

// ═══ Generated: _Infra.Persistence.FilterMap.g.cs ═══
public static class FilterMapRegistry
{
// Enumerable.Where<T> resolved once, through an expression tree instead of GetMethods()
private static readonly Dictionary<Type, MethodInfo> WhereMethods = new()
{
[typeof(LineItem)] = _where_LineItem,
[typeof(UserRole)] = _where_UserRole,
};
// Stateless filters, built once and shared by every context
private static readonly FilterMap SoftDeleteFilters = new(
new Dictionary<Type, LambdaExpression>
{
[typeof(LineItem)] = (Expression<Func<LineItem, bool>>)(e => !e.IsDeleted),
},
WhereMethods);
public static FilterMap CreateForContext(FilterContext context)
{
if (context.IsRaw)
return FilterMap.Empty; // Raw mode: the map is empty, not merely permissive
var map = SoftDeleteFilters;
// …context-dependent filters (tenant, temporal) composed on top
return map;
}
}

The registry is generated from your entity attributes: every [SoftDelete] entity contributes a soft-delete entry, every [TemporalRelation] a temporal one. The FilterMap itself is immutable — built from a dictionary in the constructor, with no Add — which is what lets the stateless part be allocated once and reused across requests.

Composes the final FilterMap from multiple sources:

var composer = new FilterMapComposer(
filterToggle, // For checking disabled filters
visibilityProviders, // Custom runtime filters
staticMapFactory: FilterMapRegistry.CreateForContext // Generated registry
);
SourceOriginWhen Used
Static filtersFilterMapRegistry (generated)Always — soft-delete, temporal
Visibility filtersIVisibilityFilterProvider (custom)When you implement row-level security
Disabled filtersIQueryFilterToggleChecked to skip disabled filters

An expression tree visitor that walks the LINQ expression tree and injects .Where() into collection navigation expressions.

What it does:

  1. Detects collection navigation expressions (ICollection<T>, List<T>)
  2. Looks up a filter for the element type in the FilterMap
  3. Wraps the navigation with .Where(filter)
  4. Detects and skips already-filtered navigations (prevents double-filtering)

The generated repository applies navigation filters automatically when you use GetByIdAsync with includes:

// Inside the generated repository
public async Task<Order?> GetByIdAsync(
Guid id,
Func<IQueryable<Order>, IQueryable<Order>> includes,
CancellationToken ct)
{
var query = ApplyFilters(Set.AsQueryable()); // Root filters
query = includes(query); // User's Include() calls
query = ApplyNavigationFilters(query); // ← Navigation filters injected here
return await query.FirstOrDefaultAsync(e => e.PersistenceId == id, ct);
}

The EfCoreQueryExecutor provides the same filter integration for custom queries (paging, projections):

var executor = new EfCoreQueryExecutor(
filterProvider, // IQueryFilterProvider? — root filters
filterMapComposer, // FilterMapComposer? — navigation filters
filterToggle, // IQueryFilterToggle? — disabled state
cacheStack, // ICacheStack? — caching for ICacheable queries
logger, // ILogger?
tenantContext, // ITenantContext? — also prefixes cache keys with "t:{tenant}:"
currentUser, // ICurrentUser?
cacheStackResolver // ICacheStackResolver? — the stack named by ICacheable.CacheCategory
);
var pagedResult = await executor.ExecuteAsync(myPagedQuery, db.Orders, ct);
// Returns: PagedResult<Order> with Items, TotalCount, Page, PageSize
FeatureDescription
Root filtersApplied via IQueryFilterProvider
Navigation filtersApplied via FilterMapComposer
PagingReturns PagedResult<T> with total count
CachingOptional via ICacheStack for queries implementing ICacheable
Error mappingEF Core exceptions → typed QueryError (Timeout, Connection, Database)
Query optionsAsNoTracking, AsSplitQuery, IgnoreQueryFilters

The entire pipeline respects IQueryFilterToggle:

// Disable soft-delete filter — affects BOTH root queries AND navigation filtering
using (filterToggle.Disable<LineItem.SoftDeleteFilter>())
{
var order = await repo.GetByIdAsync(orderId,
q => q.Include(o => o.Items),
ct);
// order.Items includes soft-deleted line items
}

When a filter is disabled via IQueryFilterToggle, the FilterMapRegistry checks context.DisabledFilters and excludes it from the FilterMap. The visitor then has no filter to inject for that type.