Skip to content

Expression Trees

Expression Trees allow building queries or filters at runtime as a syntax tree, instead of writing them statically in source code. EF Core translates these trees into SQL.

In Granit, ApplyGranitConventions() dynamically builds EF Core query filters for each entity by combining ISoftDeletable, IActive, and IMultiTenant filters into a single expression.

flowchart TD
    AGC["ApplyGranitConventions()"] --> SCAN[Scan DbContext entities]
    SCAN --> CHECK{Which interfaces implemented?}

    CHECK -->|ISoftDeletable| E1["Expression: !e.IsDeleted<br/>or !proxy.SoftDeleteEnabled"]
    CHECK -->|IActive| E2["Expression: e.IsActive<br/>or !proxy.ActiveEnabled"]
    CHECK -->|IMultiTenant| E3["Expression: e.TenantId == proxy.CurrentTenantId<br/>or !proxy.MultiTenantEnabled"]

    E1 --> COMBINE["Expression.AndAlso()<br/>Combine all conditions"]
    E2 --> COMBINE
    E3 --> COMBINE

    COMBINE --> LAMBDA["Expression.Lambda of Func T bool"]
    LAMBDA --> HQF["entity.HasQueryFilter(lambda)"]
ComponentFileLines
ApplyGranitConventions()src/Granit.Persistence/Extensions/ModelBuilderExtensions.cs54-126
FilterProxysrc/Granit.Persistence/Extensions/ModelBuilderExtensions.cs133-140

EF Core cannot translate arbitrary method calls (like dataFilter.IsEnabled<ISoftDeletable>()) in a query filter. The FilterProxy exposes simple properties that EF Core extracts as SQL parameters:

// FilterProxy exposes properties that EF Core understands
internal sealed class FilterProxy(IDataFilter? dataFilter, ICurrentTenant? tenant)
{
public bool SoftDeleteEnabled => dataFilter?.IsEnabled<ISoftDeletable>() ?? true;
public bool ActiveEnabled => dataFilter?.IsEnabled<IActive>() ?? true;
public bool MultiTenantEnabled => dataFilter?.IsEnabled<IMultiTenant>() ?? true;
public Guid? CurrentTenantId => tenant?.Id;
}

EF Core 10 introduced named query filters: one HasQueryFilter(name, expr) call per interface per entity. Each filter is independent and bypassable individually via IgnoreQueryFilters([filterKey]). Before EF Core 10, HasQueryFilter silently overwrote the previous filter — requiring a combined AndAlso expression as a workaround. Named filters are the idiomatic EF Core 10 solution.

Dynamic construction handles all combinations of interfaces automatically (an entity can implement 0, 1, 2, or 3 marker interfaces) without writing specific code for each combination (2^3 = 8 cases).

// The application calls a single line in OnModelCreating
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyGranitConventions(serviceProvider);
// -> Dynamically builds query filters for all entities
}
// The filters are automatic and transparent
List<Patient> patients = await db.Patients.ToListAsync(ct);
// Generated SQL (both named filters applied as AND):
// SELECT * FROM Patients
// WHERE (@SoftDeleteEnabled = 0 OR IsDeleted = 0)
// AND (@MultiTenantEnabled = 0 OR TenantId = @CurrentTenantId)
// Bypass only soft delete for a single query (EF Core 10):
List<Patient> all = await db.Patients
.IgnoreQueryFilters([GranitFilterNames.SoftDelete])
.ToListAsync(ct);