Server-Side Query Filters
Server-side query filters restrict which rows a client can ever access — at the SQL query level. They are applied to every GET request (list, by-id, count, pagedresult, custom routes) before any client-supplied parameters. A record that fails the filter behaves exactly as if it does not exist.
Filter types
| Type | Method | When evaluated |
|---|---|---|
| Static | .WithQueryFilter(predicate) |
Compiled once at startup — constant values only |
| Scoped | .WithScopedQueryFilter(factory) |
Resolved on every request — can read from HTTP context, JWT, headers |
Multiple filters of either type can be chained. All are composed with AND semantics.
Static Query Filters
.WithQueryFilter(predicate) registers a permanent server-side row filter.
The predicate must use only EF-translatable operations and cannot reference request-scoped data.
// Only expose available products — unavailable ones are completely invisible
options.Entity<Product>("products")
.WithQueryFilter(p => p.IsAvailable);
The effect on all operations for this entity:
| Request | Behaviour |
|---|---|
GET /api/products | Returns only IsAvailable == true |
GET /api/products/10 (IsAvailable = false) | 404 — record is hidden, not revealed |
GET /api/products/count | Counts only available products |
GET /api/products/pagedresult | total and items reflect only available products |
PUT /api/products/10 (IsAvailable = false) | 404 — query filter applied before update |
PATCH /api/products/10 (IsAvailable = false) | 404 |
Chaining multiple static filters
Each .WithQueryFilter() call adds another predicate. All predicates are ANDed — a row must satisfy all of them.
// A product must be available AND have stock > 0
options.Entity<Product>("products")
.WithQueryFilter(p => p.IsAvailable)
.WithQueryFilter(p => p.Stock > 0);
Compound conditions in a single predicate
// Hide cancelled and pending orders
options.Entity<Order>("orders")
.WithQueryFilter(o => o.Status != OrderStatus.Cancelled
&& o.Status != OrderStatus.Pending);
// Expose items from tenant 1 OR tenant 2
options.Entity<Report>("reports")
.WithQueryFilter(r => r.TenantId == 1 || r.TenantId == 2);
Static filter limitations
- The predicate must use only EF-translatable operations (field comparisons,
&&,||,!, constants). Calling arbitrary C# methods that cannot be converted to SQL will throw at runtime. - Compiled once at startup — cannot reference request-scoped data (current user, HTTP headers, etc.).
Scoped Query Filters (per-request)
.WithScopedQueryFilter(factory) registers a dynamic filter resolved on every request
via IServiceProvider. Use this when the filter depends on runtime data — the current
user ID, tenant ID from a JWT token, or any HTTP context value.
services.AddHttpContextAccessor() if reading from IHttpContextAccessor.options.Entity<Order>("orders")
.WithScopedQueryFilter(sp =>
{
var http = sp.GetRequiredService<IHttpContextAccessor>();
var userId = http.HttpContext?.User.FindFirst("sub")?.Value ?? "";
return o => o.UserId == userId;
});
options.Entity<Product>("products")
.WithScopedQueryFilter(sp =>
{
var http = sp.GetRequiredService<IHttpContextAccessor>();
var tenantId = int.Parse(http.HttpContext?.User.FindFirst("tenantId")?.Value ?? "0");
return p => p.TenantId == tenantId;
});
Combining static and scoped filters
Both types can be freely mixed on the same entity — all are composed with AND:
options.Entity<Product>("products")
.WithQueryFilter(p => p.IsActive) // static — always applied
.WithScopedQueryFilter(sp => // dynamic — per-request
{
var http = sp.GetRequiredService<IHttpContextAccessor>();
var tenantId = int.Parse(http.HttpContext!.User.FindFirst("tenantId")!.Value);
return p => p.TenantId == tenantId;
});
Combining with client-supplied filters
Server-side query filters and client-supplied filter[...] parameters are all composed
with AND. The server filter is always applied first:
# Server filter: IsAvailable == true
# Client filter: CategoryId == 1
# Result: available products in category 1
GET /api/products?filter[categoryId]=1