Filter Pipeline
The Problem
Section titled “The Problem”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))How It Works
Section titled “How It Works”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.
Step 2: FilterMapComposer
Section titled “Step 2: FilterMapComposer”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);| Source | Origin | When Used |
|---|---|---|
| Static filters | FilterMapRegistry (generated) | Always — soft-delete, temporal |
| Visibility filters | IVisibilityFilterProvider (custom) | When you implement row-level security |
| Disabled filters | IQueryFilterToggle | Checked to skip disabled filters |
Step 3: PragmaticQueryFilterVisitor
Section titled “Step 3: PragmaticQueryFilterVisitor”An expression tree visitor that walks the LINQ expression tree and injects .Where() into collection navigation expressions.
What it does:
- Detects collection navigation expressions (
ICollection<T>,List<T>) - Looks up a filter for the element type in the
FilterMap - Wraps the navigation with
.Where(filter) - Detects and skips already-filtered navigations (prevents double-filtering)
Step 4: Integration with Repository
Section titled “Step 4: Integration with Repository”The generated repository applies navigation filters automatically when you use GetByIdAsync with includes:
// Inside the generated repositorypublic 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);}EfCoreQueryExecutor
Section titled “EfCoreQueryExecutor”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, PageSizeFeatures
Section titled “Features”| Feature | Description |
|---|---|
| Root filters | Applied via IQueryFilterProvider |
| Navigation filters | Applied via FilterMapComposer |
| Paging | Returns PagedResult<T> with total count |
| Caching | Optional via ICacheStack for queries implementing ICacheable |
| Error mapping | EF Core exceptions → typed QueryError (Timeout, Connection, Database) |
| Query options | AsNoTracking, AsSplitQuery, IgnoreQueryFilters |
Respecting IQueryFilterToggle
Section titled “Respecting IQueryFilterToggle”The entire pipeline respects IQueryFilterToggle:
// Disable soft-delete filter — affects BOTH root queries AND navigation filteringusing (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.