Sometimes, in the context of constructing an EF query, it is not possible to know if any given item should be returned in the results. For example when performing authorization where the rules are pulled from a different system, and that information does not exist in the database.
Filters allows a custom function to be executed after the EF query execution and determine if any given node should be included in the result.
Notes:
- When evaluated on nodes of a collection, excluded nodes will be removed from collection.
- When evaluated on a property node, the value will be replaced with null.
- When doing paging or counts, there is currently no smarts that adjust counts or pages sizes when items are excluded. If this is required submit a PR that adds this feature, or don't mix filters with paging.
- The filter is passed the current User Context and the node item instance.
- Filters will not be executed on null item instance.
- A Type.IsAssignableFrom check will be performed to determine if an item instance should be filtered based on the
<TItem>.
public class Filters<TDbContext>
where TDbContext : DbContext
{
public delegate bool Filter<in TEntity>(object userContext, TDbContext data, ClaimsPrincipal? userPrincipal, TEntity input);
public delegate Task<bool> AsyncFilter<in TEntity>(object userContext, TDbContext data, ClaimsPrincipal? userPrincipal, TEntity input);All filters are added using the For<TEntity>() fluent API, which automatically infers the projection type. This provides a consistent interface regardless of whether filtering on a single field, multiple fields with anonymous types, or using named projection classes.
var filters = new Filters<MyDbContext>();
filters.For<EntityType>().Add(
projection: entity => /* projection expression */,
filter: (userContext, dbContext, userPrincipal, projected) => /* filter logic */);- Call
For<TEntity>()to specify the entity type - Call
Add()with a projection expression and filter function - The compiler automatically infers the projection type from the expression
- Filter projection expression is analyzed to extract accessed property names
- GraphQL query executes and loads entities (including properties needed by the filter)
- For each loaded entity, the projection expression is compiled and executed in-memory
- The projected data is passed to the filter function
- Entities that fail the filter are excluded from results
Note: The projection is executed in-memory on entities that have already been loaded from the database by the GraphQL query. It is not a separate database query.
For filtering based on a single property value, project directly to that property:
public class Product
{
public Guid Id { get; set; }
public string? Name { get; set; }
public int Stock { get; set; }
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
public Guid CategoryId { get; set; }
}var filters = new Filters<MyDbContext>();
// Filter using a string property
filters.For<Product>().Add(
projection: _ => _.Name!,
filter: (_, _, _, name) => name != "Discontinued");
// Filter using an int property
filters.For<Product>().Add(
projection: _ => _.Stock,
filter: (_, _, _, stock) => stock > 0);
// Filter using a bool property
filters.For<Product>().Add(
projection: _ => _.IsActive,
filter: (_, _, _, isActive) => isActive);
// Filter using a DateTime property
filters.For<Product>().Add(
projection: _ => _.CreatedAt,
filter: (_, _, _, createdAt) => createdAt >= new DateTime(2024, 1, 1));
EfGraphQLConventions.RegisterInContainer<MyDbContext>(
services,
resolveFilters: _ => filters);For filtering based on multiple fields, use anonymous types without needing to define projection classes:
var filters = new Filters<MyDbContext>();
filters.For<MyEntity>().Add(
projection: _ => new
{
_.Property,
_.Quantity,
_.IsActive
},
filter: (userContext, dbContext, userPrincipal, projected) =>
projected.Property != "Ignore" &&
projected.Quantity > 0 &&
projected.IsActive);
EfGraphQLConventions.RegisterInContainer<MyDbContext>(
services,
resolveFilters: _ => filters);Anonymous types provide a concise way to combine multiple fields for filtering logic.
For reusable filter logic or complex projections, define a named projection class:
public class ChildEntity
{
public Guid Id { get; set; }
public Guid? ParentId { get; set; }
public string? Property { get; set; }
}var filters = new Filters<MyDbContext>();
filters.For<ChildEntity>().Add(
projection: _ => new
{
_.ParentId
},
filter: (userContext, data, userPrincipal, projected) =>
{
var allowedParentId = GetAllowedParentId(userContext);
return projected.ParentId == allowedParentId;
});
EfGraphQLConventions.RegisterInContainer<MyDbContext>(
services,
resolveFilters: _ => filters);Named types are useful when:
- The same projection is used in multiple filters
- The projection includes nested objects or computed properties
- A descriptive type name aids code documentation
Filters fully support nullable types for both value types and reference types:
public class Order
{
public Guid Id { get; set; }
public int? Quantity { get; set; }
public bool? IsApproved { get; set; }
public DateTime? ShippedAt { get; set; }
public string? Notes { get; set; }
public decimal TotalAmount { get; set; }
public Customer Customer { get; set; } = null!;
}
public class Customer
{
public Guid Id { get; set; }
public bool IsActive { get; set; }
}
public class Category
{
public Guid Id { get; set; }
public bool IsVisible { get; set; }
}var filters = new Filters<MyDbContext>();
// Filter nullable int - only include if has value and meets condition
filters.For<Order>().Add(
projection: _ => _.Quantity,
filter: (_, _, _, quantity) => quantity is > 0);
// Filter nullable bool - only include if explicitly approved
filters.For<Order>().Add(
projection: _ => _.IsApproved,
filter: (_, _, _, isApproved) => isApproved == true);
// Filter nullable DateTime - only include if shipped after date
filters.For<Order>().Add(
projection: _ => _.ShippedAt,
filter: (_, _, _, shippedAt) =>
shippedAt.HasValue && shippedAt.Value >= new DateTime(2024, 1, 1));
// Filter nullable string - only include non-null values
filters.For<Order>().Add(
projection: _ => _.Notes,
filter: (_, _, _, notes) => notes != null);
// Filter nullable int - only include null values
filters.For<Order>().Add(
projection: _ => _.Quantity,
filter: (_, _, _, quantity) => !quantity.HasValue);
EfGraphQLConventions.RegisterInContainer<MyDbContext>(
services,
resolveFilters: _ => filters);Common nullable patterns:
- Has value check:
quantity.HasValue && quantity.Value > 0 - Null check:
?quantity.HasValue - Exact match:
isApproved == true(not null or false)
Filters can be asynchronous when they need to perform database lookups or other async operations:
var filters = new Filters<MyDbContext>();
filters.For<Product>().Add(
projection: _ => _.CategoryId,
filter: async (_, dbContext, _, categoryId) =>
{
var category = await dbContext.Categories.FindAsync(categoryId);
return category?.IsVisible == true;
});
EfGraphQLConventions.RegisterInContainer<MyDbContext>(
services,
resolveFilters: _ => filters);Filters can project through navigation properties to access related entity data:
var filters = new Filters<MyDbContext>();
filters.For<Order>().Add(
projection: _ => new { _.TotalAmount, _.Customer.IsActive },
filter: (_, _, _, x) => x.TotalAmount >= 100 && x.IsActive);
EfGraphQLConventions.RegisterInContainer<MyDbContext>(
services,
resolveFilters: _ => filters);For boolean properties, a simplified syntax is available where only the filter expression is needed:
var filters = new Filters<MyDbContext>();
// Simplified syntax for boolean properties
filters.For<Product>().Add(filter: _ => _.IsActive);
// Equivalent to:
// filters.For<Product>().Add(
// projection: _ => _.IsActive,
// filter: (_, _, _, isActive) => isActive);
EfGraphQLConventions.RegisterInContainer<MyDbContext>(
services,
resolveFilters: _ => filters);This shorthand is useful when:
- Filtering on a single boolean property
- The filter condition checks if the property is true
- A concise syntax is preferred
The expression filter: _ => _.IsActive is automatically expanded to use the boolean property as both the projection and the filter condition.
For filters that don't need entity data (such as authorization checks based only on user context), a projection-less syntax is available:
var filters = new Filters<MyDbContext>();
// Filter without projection - eg for authorization checks
filters.For<Product>().Add(
filter: (_, _, user) => user!.HasClaim("Permission", "ViewProducts"));
// Equivalent to:
// filters.For<Product>().Add(
// projection: null, // No projection needed
// filter: (_, _, user, _) => user!.HasClaim("Permission", "ViewProducts"));
EfGraphQLConventions.RegisterInContainer<MyDbContext>(
services,
resolveFilters: _ => filters);This overload is useful when:
- Checking user permissions or claims
- Applying access control based on user context
- No entity data is needed for the filter decision
The filter receives userContext, dbContext, and userPrincipal but does not receive any entity data.
Filters without projection can also be asynchronous for operations that require database lookups:
var filters = new Filters<MyDbContext>();
// Async filter without projection - eg for database permission checks
filters.For<Product>().Add(
filter: async (_, dbContext, user) =>
{
var userId = user?.FindFirst("UserId")?.Value;
if (userId == null)
return false;
var permissions = await dbContext.UserPermissions
.Where(_ => _.UserId == userId)
.AnyAsync(_ => _.Permission == "ViewProducts");
return permissions;
});
EfGraphQLConventions.RegisterInContainer<MyDbContext>(
services,
resolveFilters: _ => filters);This is useful when:
- User permissions are stored in the database
- Filter logic requires async operations
- Complex checks involve multiple data sources
For filters that only need to access primary key or foreign key properties, a simplified API is available that eliminates the need to specify a projection:
public class Accommodation
{
public Guid Id { get; set; }
public Guid? LocationId { get; set; }
public string? City { get; set; }
public int Capacity { get; set; }
}var filters = new Filters<MyDbContext>();
// VALID: Simplified API with primary key access
filters.For<Accommodation>().Add(
filter: (_, _, _, a) => a.Id != Guid.Empty);
// VALID: Simplified API with foreign key access
var allowedLocationId = Guid.NewGuid();
filters.For<Accommodation>().Add(
filter: (_, _, _, a) => a.LocationId == allowedLocationId);
// VALID: Simplified API with nullable foreign key check
filters.For<Accommodation>().Add(
filter: (_, _, _, a) => a.LocationId != null);
// INVALID: Simplified API accessing scalar property (will cause runtime error!)
// filters.For<Accommodation>().Add(
// filter: (_, _, _, a) => a.City == "London"); // ERROR: City is not a key
// INVALID: Simplified API accessing scalar property (will cause runtime error!)
// filters.For<Accommodation>().Add(
// filter: (_, _, _, a) => a.Capacity > 10); // ERROR: Capacity is not a key
// For non-key properties, use the full API with projection:
filters.For<Accommodation>().Add(
projection: a => a.City,
filter: (_, _, _, city) => city == "London");
filters.For<Accommodation>().Add(
projection: a => new { a.City, a.Capacity },
filter: (_, _, _, x) => x.City == "London" && x.Capacity > 10);
// COMPARISON: These are equivalent when filter only accesses keys
filters.For<Accommodation>().Add(
filter: (_, _, _, a) => a.Id != Guid.Empty);
// Equivalent to:
filters.For<Accommodation>().Add(
projection: _ => _, // Identity projection
filter: (_, _, _, a) => a.Id != Guid.Empty);
EfGraphQLConventions.RegisterInContainer<MyDbContext>(
services,
resolveFilters: _ => filters);Use filters.For<TEntity>().Add(filter: (_, _, _, e) => ...) when the filter only accesses:
- Primary keys:
Id,EntityId,CompanyId(matching the entity type name) - Foreign keys: Properties ending with
IdlikeParentId,CategoryId,LocationId
The simplified API uses identity projection (_ => _) internally, which in EF projections only guarantees that key properties are loaded.
A property is considered a primary key if it is:
- Named
Id - Named
{TypeName}Id(e.g.,CompanyIdforCompanyentity) - Named
{TypeName}Idwhere TypeName has suffix removed:Entity,Model,Dto- Example:
CompanyIdinCompanyEntityclass
- Example:
A property is considered a foreign key if:
- Name ends with
Id(but is not solelyId) - Not identified as a primary key
- Type is
int,long,short, orGuid(nullable or non-nullable)
IMPORTANT: Do not access scalar properties (like Name, City, Capacity) or navigation properties (like Parent, Category) with the simplified API. These properties are not loaded by identity projection and will cause runtime errors.
For non-key properties, use the full API with explicit projection:
// INVALID - Will cause runtime error
filters.For<Accommodation>().Add(
filter: (_, _, _, a) => a.City == "London"); // City is NOT a key
// VALID - Explicit projection for scalar properties
filters.For<Accommodation>().Add(
projection: a => a.City,
filter: (_, _, _, city) => city == "London");The simplified API is syntactic sugar for the identity projection pattern:
// Simplified API
filters.For<Accommodation>().Add(
filter: (_, _, _, a) => a.Id ?= Guid.Empty);
// Equivalent full API
filters.For<Accommodation>().Add(
projection: _ => _, // Identity projection
filter: (_, _, _, a) => a.Id ?= Guid.Empty);Three analyzer diagnostics help ensure correct usage:
- GQLEF004 (Info): Suggests using the simplified API when identity projection only accesses keys
- GQLEF005 (Error): Prevents accessing non-key properties with simplified API
- GQLEF006 (Error): Prevents accessing non-key properties with identity projection
Existing code using identity projection with filters that only access keys can be migrated to the simplified API:
Before:
filters.For<Product>().Add(
projection: _ => _,
filter: (_, _, _, p) => p.CategoryId == allowedCategoryId);After:
filters.For<Product>().Add(
filter: (_, _, _, p) => p.CategoryId == allowedCategoryId);The simplified API makes intent clearer and reduces boilerplate while maintaining the same runtime behavior
When working with filters that access properties through abstract navigation properties, special care must be taken to avoid performance issues.
Abstract types cannot be instantiated in SQL projections. When EF Core encounters an abstract navigation in a projection, it falls back to using Include() which loads all columns from the navigation table, even when only one or two fields are required.
// Given:
public abstract class BaseRequest // Abstract class with many fields
{
public Guid Id { get; set; }
public Guid GroupOwnerId { get; set; }
public RequestStatus HighestStatusAchieved { get; set; }
// ... 30+ more columns ...
}
public class Accommodation
{
public Guid Id { get; set; }
public Guid TravelRequestId { get; set; }
public BaseRequest? TravelRequest { get; set; } // Navigation to abstract type
}
// ❌ INEFFICIENT - Loads all 34 columns from BaseRequest:
filters.For<Accommodation>().Add(
projection: _ => _, // Identity projection
filter: (_, _, _, a) => a.TravelRequest?.GroupOwnerId == groupId);
// ✅ EFFICIENT - Only loads Id, GroupOwnerId, HighestStatusAchieved:
filters.For<Accommodation>().Add(
projection: a => new {
a.Id,
RequestOwnerId = a.TravelRequest?.GroupOwnerId,
RequestStatus = a.TravelRequest?.HighestStatusAchieved
},
filter: (_, _, _, proj) => proj.RequestOwnerId == groupId);The library provides both compile-time and runtime detection:
Compile-Time (Analyzer GQLEF007)
The analyzer detects when filters use identity projections with abstract navigation access:
// This will show GQLEF007 error in the IDE:
filters.For<Child>().Add(
projection: _ => _,
filter: (_, _, _, c) => c.AbstractParent?.Property == "value");The code fixer can automatically convert this to an explicit projection.
Runtime (Exception)
If the analyzer is bypassed, runtime validation will throw an exception when the filter is registered:
// Throws InvalidOperationException:
// "Filter for 'Child' uses identity projection '_ => _' to access properties
// of abstract navigation 'Parent' (BaseEntity). This forces Include() to load
// all columns from BaseEntity. Extract only the required properties..."- Always use explicit projections when accessing abstract navigations
- Extract only required properties from the abstract navigation
- Flatten navigation properties in the projection (e.g.,
ParentPropertyinstead of nested access) - Update the filter to use the flattened property names
This issue only affects abstract navigation types. Concrete navigation types work fine with identity projections:
// ✅ WORKS - ConcreteParent is not abstract:
filters.For<Child>().Add(
projection: _ => _,
filter: (_, _, _, c) => c.ConcreteParent?.Property == "value");