Skip to content

Data Filtering

The Data Filtering pattern automatically applies global filters to EF Core queries based on marker interfaces implemented by entities. Filters are enabled by default and can be temporarily disabled via an IDisposable scope.

Granit supports three filters:

  • ISoftDeletableWHERE IsDeleted = false
  • IActiveWHERE IsActive = true
  • IMultiTenantWHERE TenantId = @currentTenantId
flowchart TD
    Q[EF Core query] --> FB{Active filters?}

    FB -->|ISoftDeletable| F1["WHERE IsDeleted = false<br/>(or bypass if disabled)"]
    FB -->|IActive| F2["WHERE IsActive = true<br/>(or bypass if disabled)"]
    FB -->|IMultiTenant| F3["WHERE TenantId = @tid<br/>(or bypass if disabled)"]

    F1 --> COMB["Combined expression<br/>AND"]
    F2 --> COMB
    F3 --> COMB

    COMB --> SQL[Final SQL]

    subgraph DataFilter
        DF["AsyncLocal of ImmutableDictionary"]
        EN[Enable/Disable scopes]
        DF --> EN
    end

    subgraph FilterProxy
        FP["Boolean properties<br/>for EF Core"]
    end

    DataFilter --> FB
    FilterProxy --> FB
ComponentFileRole
IDataFiltersrc/Granit.Core/DataFiltering/IDataFilter.csInterface: IsEnabled<T>(), Disable<T>(), Enable<T>()
DataFiltersrc/Granit.Core/DataFiltering/DataFilter.csImplementation using AsyncLocal<ImmutableDictionary<Type, bool>>
FilterProxysrc/Granit.Persistence/Extensions/ModelBuilderExtensions.csProxy exposing properties for EF Core
ApplyGranitConventions()src/Granit.Persistence/Extensions/ModelBuilderExtensions.csBuilds Expression Trees for HasQueryFilter()

ApplyGranitConventions() uses Expression Trees to build a single filter per entity combining all applicable filters:

// Pseudo-code of the generated filter for a FullAuditedEntity + IMultiTenant
entity => (!proxy.SoftDeleteEnabled || !entity.IsDeleted)
&& (!proxy.MultiTenantEnabled || entity.TenantId == proxy.CurrentTenantId)

The FilterProxy is essential: EF Core cannot call methods inside a query filter. The proxy exposes simple properties that EF Core translates into SQL parameters.

Thread-safe state via AsyncLocal + ImmutableDictionary

Section titled “Thread-safe state via AsyncLocal + ImmutableDictionary”

The DataFilter uses AsyncLocal<ImmutableDictionary<Type, bool>>:

  • AsyncLocal: state is isolated per async/await flow
  • ImmutableDictionary: SetItem() creates a new dictionary (copy-on-write)
  • IDisposable scope: Disable<T>() returns a scope that restores the previous state on Dispose()
ProblemSolution
Forgetting a WHERE IsDeleted = false in a queryFilter is automatic, applied to all queries
Multi-tenant isolation: a query leaking another tenant’s dataWHERE TenantId = @tid automatic on every IMultiTenant entity
Admin need to view deleted datadataFilter.Disable<ISoftDeletable>() in a limited scope
Thread safety in async scenariosAsyncLocal + ImmutableDictionary (copy-on-write)
// Filters are automatic -- nothing to do
List<Patient> activePatients = await db.Patients.ToListAsync(ct);
// SQL: SELECT ... WHERE IsDeleted = 0 AND TenantId = @tid
// Temporarily disable the soft delete filter
using (dataFilter.Disable<ISoftDeletable>())
{
List<Patient> allPatients = await db.Patients.ToListAsync(ct);
// SQL: SELECT ... WHERE TenantId = @tid (no IsDeleted filter)
}
// Filter is automatically re-enabled here