Grid Filtering
Source-generated grid adapters for DevExpress, PrimeNG, and custom grid frameworks — no runtime reflection, type-safe at compile time.
The Problem
Section titled “The Problem”Modern web applications use data grids (DevExpress, PrimeNG, AG Grid) that send filter/sort/page requests in their own proprietary JSON formats. Converting these to LINQ queries typically requires:
- Parsing the JSON format — different per framework, each with its own quirks
- Mapping field names to entity properties — string-based, no compile-time validation
- Building dynamic
Where/OrderByexpressions — runtime reflection, expression trees by hand - Handling operator logic — contains, equals, between, greaterThan, each with type coercion
This code is tedious, brittle, and repeated for every grid in the application. A typo in a field name compiles fine but fails at runtime. Adding a property to the entity does not warn you about outdated grid mappings.
The Solution
Section titled “The Solution”Pragmatic offers two complementary approaches for grid scenarios, both source-generated:
| Approach | When to Use |
|---|---|
[GridFilter<TEntity>] | You own the filter DTO. The UI sends typed properties with dynamic operators. |
[GridAdapter<TEntity>] | You consume an external grid framework’s JSON format (DevExpress, PrimeNG). The SG generates a compile-time bridge from string field names to typed expressions. |
Both produce zero-reflection LINQ at compile time. The difference is where the dynamism lives: in your DTO properties (GridFilter) or in the external JSON field names (GridAdapter).
[GridFilter<TEntity>] — Typed Dynamic Filtering
Section titled “[GridFilter<TEntity>] — Typed Dynamic Filtering”A GridFilter is a class you write with explicit properties for each filterable/sortable field. Unlike [Query<T, R>] where each property has a fixed operator (e.g., “Name always uses Contains”), GridFilter supports runtime operator selection — the UI tells the backend how to compare, not just what to compare.
Defining a GridFilter
Section titled “Defining a GridFilter”[GridFilter<Order>]public partial class OrderGridFilter{ // String filter with dynamic operator selection [Filterable(Operators = FilterOps.String)] public string? OrderNumber { get; set; } public StringOperator? OrderNumberOperator { get; set; } // UI picks: Contains, StartsWith, etc.
// Numeric range filter (Min/Max map to same entity property) [Filterable(Operators = FilterOps.Range, MapTo = "Total")] public decimal? MinTotal { get; set; }
[Filterable(Operators = FilterOps.Range, MapTo = "Total")] public decimal? MaxTotal { get; set; }
// Simple equality filter (enum/bool -- no operator needed) [Filterable] public OrderStatus? Status { get; set; }
// Sort with default direction [Sort(DefaultDirection = 0, MapTo = "OrderNumber")] public SortDirection? OrderNumberSort { get; set; }
[Sort(MapTo = "CreatedAt")] public SortDirection? CreatedAtSort { get; set; }
// Pagination public int Page { get; set; } = 1; public int PageSize { get; set; } = 20;}What the Source Generator Produces
Section titled “What the Source Generator Produces”The SG generates an Apply() method on the partial class that converts each non-null property into a typed LINQ expression:
public partial class OrderGridFilter{ public IQueryable<Order> Apply(IQueryable<Order> query) { // String filter with dynamic operator if (OrderNumber != null) { var op = OrderNumberOperator ?? StringOperator.Contains; query = op switch { StringOperator.Equals => query.Where(e => e.OrderNumber == OrderNumber), StringOperator.Contains => query.Where(e => e.OrderNumber.Contains(OrderNumber)), StringOperator.StartsWith => query.Where(e => e.OrderNumber.StartsWith(OrderNumber)), StringOperator.EndsWith => query.Where(e => e.OrderNumber.EndsWith(OrderNumber)), StringOperator.NotEquals => query.Where(e => e.OrderNumber != OrderNumber), _ => query }; }
// Range filter if (MinTotal != null) query = query.Where(e => e.Total >= MinTotal); if (MaxTotal != null) query = query.Where(e => e.Total <= MaxTotal);
// Equality filter if (Status != null) query = query.Where(e => e.Status == Status);
// Sorting and paging... return query; }}All expressions are strongly typed. No Expression.Property(parameter, "fieldName"), no Convert.ChangeType, no runtime reflection.
Using GridFilter in an Endpoint
Section titled “Using GridFilter in an Endpoint”[Endpoint(HttpVerb.Post, "/grid", Group = typeof(OrdersGroup))]public partial class GridSearchOrdersEndpoint : Endpoint<PagedResult<OrderDto>>{ private IReadRepository<Order, Guid> _orders = null!;
[FromBody] public required OrderGridFilter Filter { get; init; }
public override async Task<Result<PagedResult<OrderDto>>> HandleAsync(CancellationToken ct = default) { var query = _orders.Query().AsNoTracking(); query = Filter.Apply(query); // Source-generated, type-safe
var totalCount = await query.CountAsync(ct).ConfigureAwait(false);
var items = await query .Skip((Filter.Page - 1) * Filter.PageSize) .Take(Filter.PageSize) .Select(OrderDto.Projection!) .ToListAsync(ct) .ConfigureAwait(false);
return PagedResult<OrderDto>.Success(items, totalCount, Filter.Page, Filter.PageSize); }}[Filterable] Attribute
Section titled “[Filterable] Attribute”Marks a property as filterable. Controls which operators the UI can use.
| Property | Type | Default | Description |
|---|---|---|---|
Operators | FilterOps | FilterOps.All | Which operators are allowed (flags enum) |
MapTo | string? | null | Target entity property path (if different from property name) |
Handler | Type? | null | Custom filter handler for complex logic (JSON columns, full-text search) |
HandlerArgs | string? | null | Arguments passed to the custom handler |
FilterOps Flags Enum
Section titled “FilterOps Flags Enum”[Flags]public enum FilterOps{ None = 0, Equality = 1, // Equals, NotEquals String = 2, // Contains, StartsWith, EndsWith Compare = 4, // GreaterThan, GreaterOrEqual, LessThan, LessOrEqual Range = 8, // In, Between All = Equality | String | Compare | Range}Use FilterOps to restrict what the UI can do:
[Filterable(Operators = FilterOps.String)] // Only string operations[Filterable(Operators = FilterOps.Equality)] // Only equals/not-equals[Filterable(Operators = FilterOps.Compare | FilterOps.Range)] // Numeric comparisons + range[Filterable] // All operators (default)StringOperator Enum
Section titled “StringOperator Enum”For string properties, a companion {Name}Operator property of type StringOperator? allows the UI to select the comparison mode at runtime:
public enum StringOperator{ Equals, // property == value Contains, // property.Contains(value) -- default StartsWith, // property.StartsWith(value) EndsWith, // property.EndsWith(value) NotEquals // property != value}If the operator property is null, the SG uses Contains as the default for string filters.
[Sort] Attribute
Section titled “[Sort] Attribute”Marks a property as a sort option. Works in both [Query] and [GridFilter] contexts.
| Property | Type | Default | Description |
|---|---|---|---|
MapTo | string? | null | Target entity property (derives from property name minus “Sort” suffix if not set) |
DefaultDirection | int | -1 | Default sort direction: 0 = Ascending, 1 = Descending, -1 = no default |
Priority | int | 0 | Sort priority when multiple sorts are active (lower = applied first) |
// Dynamic sort -- user chooses direction[Sort]public SortDirection? NameSort { get; set; }
// Fixed default sort -- always applied as secondary sort[Sort(Priority = 1, DefaultDirection = 1)] // 1 = Descendingpublic SortDirection? CreatedAtSort { get; set; }[GridAdapter<TEntity>] — External Grid Framework Bridge
Section titled “[GridAdapter<TEntity>] — External Grid Framework Bridge”When you consume an external grid framework (DevExpress, PrimeNG, AG Grid), the frontend sends filter/sort/page requests in the framework’s proprietary JSON format with string field names. A GridAdapter generates a compile-time mapping from those string names to entity properties.
Defining a GridAdapter
Section titled “Defining a GridAdapter”[GridAdapter<Order>(Framework = GridFramework.Both)][GridField("orderNo", Property = "OrderNumber")][GridField("customer.name", Property = "CustomerName")][GridExclude("InternalNotes")]public partial class OrderGridAdapter { }| Attribute Property | Type | Default | Description |
|---|---|---|---|
Framework | GridFramework | Both | Which adapters to generate: DevExpress, PrimeNG, or Both |
SupportNestedFilters | bool | true | Generate nested AND/OR filter tree support |
SupportGrouping | bool | true | Generate grouping support |
[GridField] — Custom Field Mapping
Section titled “[GridField] — Custom Field Mapping”Maps a JSON field name (sent by the frontend) to an entity property (used in LINQ).
[GridField("orderNo", Property = "OrderNumber")]| Property | Type | Default | Description |
|---|---|---|---|
JsonField | string | (required) | The field name in the JSON payload from the grid UI |
Property | string? | PascalCase of JsonField | The entity property path to map to |
Filterable | bool | true | Whether this field can be filtered |
Sortable | bool | true | Whether this field can be sorted |
Groupable | bool | true | Whether this field can be grouped |
AllowedOperators | string[]? | null | Restrict which operators are allowed for this field |
[GridExclude] — Hide Properties from the Grid
Section titled “[GridExclude] — Hide Properties from the Grid”Prevents an entity property from being filterable, sortable, or groupable through the grid adapter:
[GridExclude("InternalNotes")] // Not exposed to the grid UI[GridExclude("PasswordHash")] // Security: never filter/sort by sensitive fieldsGrid Adapter Pipeline
Section titled “Grid Adapter Pipeline”The pipeline has three steps. Each step is decoupled, and the canonical format in the middle allows mixing and matching adapters and bridges.
External Grid JSON ──> Adapter ──> GridFilterRequest ──> SG Bridge ──> IQueryable<T> (runtime) (runtime) (canonical) (compile-time) (LINQ)Step 1: Grid Sends JSON
Section titled “Step 1: Grid Sends JSON”Each grid framework has its own request format:
DevExpress sends LoadOptions with nested filter arrays:
{ "filter": [["name", "contains", "Rome"], "and", ["starRating", ">=", 3]], "sort": [{ "selector": "name", "desc": false }], "skip": 0, "take": 20}PrimeNG sends LazyLoadEvent with field-based filter metadata:
{ "first": 0, "rows": 20, "sortField": "name", "sortOrder": 1, "filters": { "name": { "value": "Rome", "matchMode": "contains" }, "starRating": { "value": 3, "matchMode": "gte" } }}Step 2: Adapter Converts to GridFilterRequest
Section titled “Step 2: Adapter Converts to GridFilterRequest”The canonical format is framework-agnostic. All external formats are converted into it:
public record GridFilterRequest{ public IReadOnlyList<FilterClause> Filters { get; init; } = []; public IReadOnlyList<SortClause> Sorts { get; init; } = []; public IReadOnlyList<GroupClause> Groups { get; init; } = []; public int? Page { get; init; } // 1-based, null = no paging public int? PageSize { get; init; }}
public record FilterClause(string Field, FilterOperator Operator, object? Value, FilterLogic Logic = FilterLogic.And);public record SortClause(string Field, SortDirection Direction);public record GroupClause(string Field, SortDirection? SortDirection = null);FilterOperator covers all common comparison types:
| Operator | Description |
|---|---|
Equals | property == value |
NotEquals | property != value |
Contains | property.Contains(value) — default for strings |
StartsWith | property.StartsWith(value) |
EndsWith | property.EndsWith(value) |
GreaterThan | property > value |
GreaterOrEqual | property >= value |
LessThan | property < value |
LessOrEqual | property <= value |
In | values.Contains(property) |
Between | property >= min && property <= max |
FilterLogic controls how clauses combine: And (all must match) or Or (any must match).
Step 3: SG Bridge Converts to LINQ
Section titled “Step 3: SG Bridge Converts to LINQ”The source generator produces a typed ApplyCanonical extension method that uses a switch on field names — no Expression.Property reflection:
public static class OrderGridAdapterExtensions{ public static IQueryable<Order> ApplyCanonical( this IQueryable<Order> query, GridFilterRequest request) { // Compile-time switch per field -- no reflection foreach (var filter in request.Filters) { query = filter.Field switch { "orderNo" => ApplyFilter(query, e => e.OrderNumber, filter), "customer.name" => ApplyFilter(query, e => e.CustomerName, filter), // ... all mapped fields _ => query // Unknown fields are safely ignored }; } // Sort and paging logic... return query; }}Use [GenerateGridBridge] on the entity directly if you want the bridge without a GridFilter DTO:
[GenerateGridBridge][GridAdapter<Order>]public partial class OrderGridAdapter { }Built-In Adapters
Section titled “Built-In Adapters”Pragmatic ships two runtime adapters for the most common grid frameworks. These are extension methods on QueryBuilder<T> and work via runtime expression trees (not SG) because the external format uses string field names that cannot be validated at compile time.
DevExpress Adapter
Section titled “DevExpress Adapter”Handles DevExpress DataSourceLoadOptions: nested filter arrays (["!", ["field", "op", value]]), composite AND/OR, all comparison and string operators, value coercion from JsonElement.
var builder = new QueryBuilder<Order>() .AsNoTracking() .WithFilter(OrderSpecs.Active) .FromDevExpress(loadOptions) .WithPaging(page, pageSize);
var query = builder.Build(repository.Query());DevExpress filter format: ["field", "operator", value] arrays with "and"/"or" connectives. Supports negation via ["!", [subfilter]].
[["name", "contains", "Rome"], "and", ["starRating", ">=", 3]]PrimeNG Adapter
Section titled “PrimeNG Adapter”Handles PrimeNG LazyLoadEvent: field-based filters with match modes, global filter across multiple fields, single and multi-column sorting, offset-based paging.
var builder = new QueryBuilder<Order>() .AsNoTracking() .FromPrimeNG(lazyLoadEvent);
var query = builder.Build(repository.Query());PrimeNG match modes: equals, notEquals, contains, notContains, startsWith, endsWith, lt, lte, gt, gte, in, between.
Global filter: Applies a Contains search across all specified GlobalFilterFields (string properties only), combined with OR logic.
Custom Adapters
Section titled “Custom Adapters”For other grid frameworks (AG Grid, Kendo, Syncfusion), implement IGridFilterAdapter<TExternalFormat>:
public class AgGridAdapter : IGridFilterAdapter<AgGridRequest>{ public GridFilterRequest Adapt(AgGridRequest input) { return new GridFilterRequest { Filters = input.FilterModel .Select(f => new FilterClause(f.Field, MapOperator(f.Type), f.Filter)) .ToList(), Sorts = input.SortModel .Select(s => new SortClause(s.ColId, s.Sort == "asc" ? SortDirection.Ascending : SortDirection.Descending)) .ToList(), Page = input.StartRow / input.PageSize + 1, PageSize = input.PageSize }; }}The adapter is a pure mapping function — no DI state, no SG involvement. Once you have a GridFilterRequest, use it with ApplyCanonical() (SG bridge) or convert manually.
[GridFilter] vs [GridAdapter] — When They Combine
Section titled “[GridFilter] vs [GridAdapter] — When They Combine”You can use both on the same entity. The GridFilter is for your own typed API. The GridAdapter is for consuming an external grid framework’s native format. They share the same entity and produce complementary LINQ pipelines.
| Scenario | Type | Approach |
|---|---|---|
| Your API receives typed filter properties | Typed | [GridFilter<Order>] with [Filterable] properties |
| Your API receives DevExpress/PrimeNG JSON | Dynamic | QueryBuilder.FromDevExpress(options) or FromPrimeNG(event) |
Your API receives a canonical GridFilterRequest | Canonical | [GridAdapter<Order>] + ApplyCanonical(request) |
| Custom grid framework | Custom | IGridFilterAdapter<T> + canonical pipeline |
When to Use What
Section titled “When to Use What”Pragmatic offers multiple filtering mechanisms. Here is how to choose:
| Scenario | Attribute | Why |
|---|---|---|
| Fixed filters, known at compile time | [Query<T, R>] with [Filter] | Simplest. Each property has one fixed operator. Best for REST query parameters. |
| Reusable filter logic across endpoints | [FilterDto<T>] | Shared filter DTO, applied via .Apply(). Operators fixed at compile time. |
| Domain-specific filter groups | [ComplexFilter<T>] | Named filter groups (e.g., “ByLocation”, “ByDateRange”) with explicit domain semantics. |
| UI grid with dynamic operator selection | [GridFilter<T>] | The UI picks the operator at runtime (Contains, StartsWith, etc.). Typed, SG-generated. |
| DevExpress/PrimeNG native format | Built-in adapters | FromDevExpress(options) / FromPrimeNG(event) on QueryBuilder. Runtime expression trees. |
| Custom grid + compile-time bridge | [GridAdapter<T>] | Maps string field names to typed expressions via SG. Use with ApplyCanonical(). |
Showcase Example
Section titled “Showcase Example”The Showcase project demonstrates all three approaches for the Property entity in the Catalog module:
// 1. [Query] -- fixed filters, REST parameters// GET /api/v1/properties?name=Rome&minStarRating=3[Query<Property, PropertySummaryDto>]public partial class SearchPropertiesQuery { ... }
// 2. [GridFilter] -- typed DTO with dynamic operators// POST /api/v1/properties/grid[GridFilter<Property>]public partial class PropertyGridFilter{ [Filterable(Operators = FilterOps.String)] public string? Name { get; set; } public StringOperator? NameOperator { get; set; }
[Filterable(Operators = FilterOps.Range, MapTo = "StarRating")] public int? MinStarRating { get; set; }
[Filterable(Operators = FilterOps.Range, MapTo = "StarRating")] public int? MaxStarRating { get; set; }
[Sort(DefaultDirection = 0, MapTo = "Name")] public SortDirection? NameSort { get; set; }
public int Page { get; set; } = 1; public int PageSize { get; set; } = 20;}
// 3. DevExpress adapter -- external format// POST /api/v1/properties/devexpressvar builder = new QueryBuilder<Property>() .WithFilter(PropertySpecs.Active) .FromDevExpress(loadOptions);Design Rationale
Section titled “Design Rationale”Why two approaches instead of one?
GridFilter and GridAdapter solve different problems. A GridFilter is your own API contract — you control the shape, the types, and the allowed operators. A GridAdapter consumes someone else’s format and maps it to your entities. The canonical GridFilterRequest sits in the middle, allowing adapters to feed into SG bridges.
Why source-generated instead of runtime expression builders?
Runtime expression builders (like the built-in DevExpress/PrimeNG adapters) use Expression.Property(parameter, fieldName) which resolves properties by string name at runtime. This works but has three drawbacks: (1) no compile-time validation of field names, (2) runtime reflection overhead, (3) no opportunity for the compiler to catch type mismatches. The SG approach (GridFilter.Apply(), GridAdapter.ApplyCanonical()) produces typed LINQ that is validated at compile time and has zero reflection overhead.
Why keep runtime adapters alongside SG?
The built-in DevExpress and PrimeNG adapters use runtime expression trees because the external format is inherently dynamic — the field names come from JSON at runtime. The SG bridge (GridAdapter + ApplyCanonical) pre-validates the field mapping at compile time, but the initial JSON parsing is still runtime. The runtime adapters are simpler for quick integration; the SG bridge is better for production systems where you want compile-time guarantees on every field mapping.