Query System
Declarative queries, filter DTOs, and paged results — all source-generated from attributes.
The Problem
Section titled “The Problem”Querying data in a typical .NET application involves writing repetitive IQueryable chains: filter by this, sort by that, include these navigations, project to this DTO, page the results. For 30 entities, you write 30 nearly identical query classes — each with the same pattern of Where, OrderBy, Select, Skip, Take.
// The typical approach — manual query buildingpublic async Task<PagedResult<OrderDto>> SearchOrders( string? customerName, OrderStatus? status, DateTimeOffset? fromDate, SortDirection? orderDateSort, int page = 1, int pageSize = 20){ var query = db.Orders.AsNoTracking();
if (customerName is not null) query = query.Where(o => o.CustomerName.Contains(customerName)); if (status is not null) query = query.Where(o => o.Status == status); if (fromDate is not null) query = query.Where(o => o.OrderDate >= fromDate);
if (orderDateSort == SortDirection.Ascending) query = query.OrderBy(o => o.OrderDate); else if (orderDateSort == SortDirection.Descending) query = query.OrderByDescending(o => o.OrderDate);
var total = await query.CountAsync(); var items = await query .Skip((page - 1) * pageSize) .Take(pageSize) .Select(o => new OrderDto { /* map every property */ }) .ToListAsync();
return PagedResult<OrderDto>.Success(items, total, page, pageSize);}For every query endpoint, you repeat the same null-check-then-filter pattern. For every DTO, you hand-write the projection. For every sortable field, you write the if ascending / else descending branch. With 30 entities and multiple query endpoints each, this becomes thousands of lines of mechanical code.
The Solution: Declarative Queries
Section titled “The Solution: Declarative Queries”A query is a class that declares what to filter, sort, and project — the source generator produces the Apply() method and the interface implementation.
// ═══ What YOU write ═══[Query<Order, OrderDto>]public partial class GetOrders{ [Filter(Operator = FilterOperator.Contains)] public string? CustomerName { get; init; }
[Filter] public OrderStatus? Status { get; init; }
[Sort(DefaultDirection = 1)] // 1 = Descending by default public SortDirection? OrderDateSort { get; init; }
public int Page { get; init; } = 1; public int PageSize { get; init; } = 20;}The source generator reads the [Query], [Filter], and [Sort] attributes and produces the Apply() method, the Projection expression, and the correct interface implementation:
// ═══ What the SOURCE GENERATOR produces ═══public partial class GetOrders : IPagedQuery<Order, OrderDto>{ public Expression<Func<Order, OrderDto>>? Projection => OrderDto.FromEntity;
public IQueryable<Order> Apply(IQueryable<Order> query) { if (CustomerName is not null) query = query.Where(e => e.CustomerName.Contains(CustomerName)); if (Status is not null) query = query.Where(e => e.Status == Status);
// DefaultDirection = 1 → Descending applied even when OrderDateSort is null var orderDateDir = OrderDateSort ?? SortDirection.Descending; query = orderDateDir == SortDirection.Ascending ? query.OrderBy(e => e.OrderDate) : query.OrderByDescending(e => e.OrderDate);
return query; }}The SG detects Page and PageSize properties by convention and implements IPagedQuery<Order, OrderDto> (which provides Skip and Take automatically). If those properties are absent, it implements IQuery<Order, OrderDto> instead.
Required filters
Section titled “Required filters”Properties marked required are always applied — they cannot be null:
[Query<Order, OrderDto>]public partial class GetOrdersForCustomer{ // Required → always included in the WHERE clause public required Guid CustomerId { get; init; }
// Optional → applied only when not null [Filter] public OrderStatus? Status { get; init; }
public int Page { get; init; } = 1; public int PageSize { get; init; } = 20;}// ═══ Generated Apply ═══public IQueryable<Order> Apply(IQueryable<Order> query){ query = query.Where(e => e.CustomerId == CustomerId); // Always applied if (Status is not null) query = query.Where(e => e.Status == Status); return query;}Query Without Projection
Section titled “Query Without Projection”When you don’t need to project to a DTO, use the single-type-argument form. This returns the entity itself:
[Query<Order>]public partial class FindOrders{ [Filter] public Guid? CustomerId { get; init; }}The SG implements IQuery<Order> (no projection). The executor returns entities directly.
Using Queries
Section titled “Using Queries”Queries are executed through IQueryExecutor:
public class OrderService(IQueryExecutor executor, IRepository<Order, Guid> orders){ public async Task<PagedResult<OrderDto>> SearchOrders(GetOrders query, CancellationToken ct) { var source = orders.Query(); // IQueryable<Order> return await executor.ExecuteAsync(query, source, ct); }}When combined with an [Endpoint] attribute, the query is executed automatically by the endpoint pipeline — you don’t need to write the service method at all:
// This is a fully functional API endpoint. No service class needed.[Query<Reservation, ReservationSummaryDto>][Endpoint(HttpVerb.Get, "api/v1/reservations/search")]public partial class SearchReservationsQuery{ [Filter] public Guid? GuestId { get; init; }
[Filter] public ReservationStatus? Status { get; init; }
[Sort(DefaultDirection = 1)] public SortDirection? CheckInSort { get; init; }
public int Page { get; init; } = 1; public int PageSize { get; init; } = 20;}[Filter] — Filter Properties
Section titled “[Filter] — Filter Properties”The [Filter] attribute marks a property as a filter condition. When the property value is null, the filter is skipped. When it has a value, the corresponding Where clause is added.
// Basic filter — Equals operator (default for non-strings)[Filter]public OrderStatus? Status { get; init; }
// String filter — Contains operator (default for strings)[Filter]public string? Name { get; init; }
// Explicit operator[Filter(Operator = FilterOperator.GreaterOrEqual, MapTo = "CreatedAt")]public DateTimeOffset? FromDate { get; init; }
// Collection-based filter — IN operator[Filter(Operator = FilterOperator.In)]public List<OrderStatus>? Statuses { get; init; }
// Case-insensitive string comparison[Filter(IgnoreCase = true)]public string? Email { get; init; }
// Nested property path[Filter(MapTo = "Customer.Name")]public string? CustomerName { get; init; }Filter Properties
Section titled “Filter Properties”| Property | Type | Default | Description |
|---|---|---|---|
Operator | FilterOperator | Equals (non-string), Contains (string) | The comparison operator |
MapTo | string? | Same as property name | Target property path on the entity |
IgnoreCase | bool | false | Case-insensitive string comparison |
Available Operators
Section titled “Available Operators”| Operator | SQL Equivalent | Typical Use |
|---|---|---|
Equals | = value | Exact match (ID, status, boolean) |
NotEquals | != value | Exclusion filter |
Contains | LIKE '%value%' | Text search |
StartsWith | LIKE 'value%' | Prefix search |
EndsWith | LIKE '%value' | Suffix search |
GreaterThan | > value | Range (dates, amounts) |
GreaterOrEqual | >= value | Range inclusive |
LessThan | < value | Range (dates, amounts) |
LessOrEqual | <= value | Range inclusive |
In | IN (values) | Multi-select (statuses, categories) |
Between | >= min AND <= max | Date ranges, price ranges |
[FilterOn<TEntity>] — Filtering on Joined Entities
Section titled “[FilterOn<TEntity>] — Filtering on Joined Entities”When you need to filter on a property from a joined entity rather than the root entity, use [FilterOn<T>] together with [Join<T>]:
[Query<Order, OrderDetailDto>][Join<Customer>(Via = "Customer")]public partial class GetOrdersByCustomerName{ // Filter on the root entity (Order) [Filter] public OrderStatus? Status { get; init; }
// Filter on the joined entity (Customer) [FilterOn<Customer>(MapTo = "Name")] public string? CustomerName { get; init; }}The [FilterOn<T>] attribute has the same properties as [Filter] (Operator, MapTo, IgnoreCase) but targets the specified joined entity instead of the root.
[FilterGroup] — Grouped Filter Logic
Section titled “[FilterGroup] — Grouped Filter Logic”By default, all filters are combined with AND logic. When you need OR logic between a set of filters, group them using [FilterGroup]:
[Query<Product, ProductDto>]public partial class SearchProducts{ [Filter] public bool? IsActive { get; init; }
// Creates: WHERE IsActive = X AND (Name LIKE '%..%' OR Description LIKE '%..%') [FilterGroup(FilterLogic.Or)] public ProductTextSearch? Search { get; init; }
public int Page { get; init; } = 1; public int PageSize { get; init; } = 20;}
[FilterDto<Product>]public partial class ProductTextSearch{ [Filter(Operator = FilterOperator.Contains)] public string? Name { get; init; }
[Filter(Operator = FilterOperator.Contains)] public string? Description { get; init; }}The [FilterGroup] property points to a [FilterDto<T>] class. Filters inside the group are combined with the specified logic (And or Or). The group itself is always combined with AND to the parent filters.
This generates: WHERE (IsActive = @p0) AND (Name LIKE '%@p1%' OR Description LIKE '%@p1%').
[ComplexFilter] — JSON Complex Filters
Section titled “[ComplexFilter] — JSON Complex Filters”For rich client UIs that need to send structured filter objects, use [ComplexFilter]. The property value is deserialized from a JSON query parameter:
[Query<Property, PropertySummaryDto>][Endpoint(HttpVerb.Get, "api/properties/search")]public partial class SearchPropertiesQuery{ [Filter(Operator = FilterOperator.Contains)] public string? Name { get; init; }
/// <summary> /// Sent as JSON: ?Location={"cityGroup":{"city":"Rome"},"maxStarRating":4} /// </summary> [ComplexFilter] public PropertyLocationFilter? Location { get; init; }
public int Page { get; init; } = 1; public int PageSize { get; init; } = 20;}
[FilterDto<Property>]public partial class PropertyLocationFilter{ [FilterGroup(FilterLogic.Or)] public CityGroupFilter? CityGroup { get; init; }
[Filter(Operator = FilterOperator.LessOrEqual, MapTo = "StarRating")] public int? MaxStarRating { get; init; }}The SG generates an Apply() that delegates to the filter DTO’s generated ApplyFilter() extension method:
// Inside the generated Apply()if (Location is not null) query = PropertyLocationFilterExtensions.ApplyFilter(query, Location);The JSON deserialization is handled by JsonQueryConverter<T>, which is wired automatically for [ComplexFilter] properties.
[Sort] — Sorting
Section titled “[Sort] — Sorting”The [Sort] attribute declares sortable fields. The property type is SortDirection? — null means “don’t sort by this field”:
// Dynamic sort — user chooses direction, no sorting if null[Sort]public SortDirection? CustomerNameSort { get; init; }
// Default sort — always applied (Descending), user can override[Sort(DefaultDirection = 1)]public SortDirection? CreatedAtSort { get; init; }
// Map to a different entity property[Sort(MapTo = "CreatedAt")]public SortDirection? DateSort { get; init; }
// Multi-sort — Priority controls the order (lower = applied first)[Sort(Priority = 0, DefaultDirection = 0)] // Primary: Name Ascendingpublic SortDirection? NameSort { get; init; }
[Sort(Priority = 1, DefaultDirection = 1)] // Secondary: Date Descendingpublic SortDirection? DateSort { get; init; }Sort Properties
Section titled “Sort Properties”| Property | Type | Default | Description |
|---|---|---|---|
MapTo | string? | Derived from property name (removes “Sort” suffix) | Target entity property |
DefaultDirection | int | -1 (no default) | 0 = Ascending, 1 = Descending, -1 = none |
Priority | int | 0 | Sort order when multiple sorts are active (lower = first) |
The SG derives the entity property name from the query property name by removing the “Sort” suffix: CustomerNameSort maps to CustomerName, CreatedAtSort maps to CreatedAt. Use MapTo to override this convention.
[Join<TTarget>] — Declaring Joins
Section titled “[Join<TTarget>] — Declaring Joins”Use [Join<T>] on a query class to declare entities that should be joined:
// With Domain Entity navigation (auto-detected from [Relation.*])[Query<Order, OrderSummaryDto>][Join<Customer>(Via = "Customer")][Join<OrderLine>(Via = "Lines")]public partial class GetOrderWithDetails{ [FilterOn<Customer>(MapTo = "Name")] public string? CustomerName { get; init; }}
// With explicit keys (for POCO entities without navigation properties)[Query<Order, OrderSummaryDto>][Join<Customer>(ForeignKey = "CustomerId", TargetKey = "Id")]public partial class GetOrderWithCustomer { }
// Left join (includes orders even if no matching record on the joined side)[Query<Order, OrderSummaryDto>][Join<Shipment>(Via = "Shipment", Type = JoinType.Left)]public partial class GetOrderWithShipment { }Join Properties
Section titled “Join Properties”| Property | Type | Default | Description |
|---|---|---|---|
Via | string? | — | Navigation property name (auto-detected from [Relation.*] attributes) |
ForeignKey | string? | — | FK property name on the source entity (for POCO entities) |
TargetKey | string | "Id" | Target property to join on |
Type | JoinType | Inner | Inner, Left, Right, Full, Cross |
Alias | string? | Type name | Alias for disambiguation when joining the same type twice |
[FilterDto<TEntity>] — Standalone Filter DTOs
Section titled “[FilterDto<TEntity>] — Standalone Filter DTOs”When you need reusable filter logic without a full query — for example, to share the same filter across multiple queries or to use it directly in service methods — use [FilterDto<T>]:
[FilterDto<Order>]public partial class OrderFilter{ [Filter] public OrderStatus? Status { get; init; }
[Filter(Operator = FilterOperator.GreaterOrEqual, MapTo = "Total")] public decimal? MinTotal { get; init; }}The SG generates an extension method:
// ═══ Generated: OrderFilterExtensions.g.cs ═══public static class OrderFilterExtensions{ public static IQueryable<Order> ApplyFilter( this IQueryable<Order> query, OrderFilter filter) { if (filter.Status is not null) query = query.Where(e => e.Status == filter.Status); if (filter.MinTotal is not null) query = query.Where(e => e.Total >= filter.MinTotal); return query; }}Use it anywhere you have an IQueryable<Order>:
var filter = new OrderFilter { Status = OrderStatus.Pending };var pending = await db.Orders.ApplyFilter(filter).ToListAsync(ct);Filter DTOs are also the building block for [FilterGroup] and [ComplexFilter] — the nested DTO must be a [FilterDto<T>].
Query Interfaces
Section titled “Query Interfaces”The query system is built on a small interface hierarchy. The SG picks the right combination based on what your query class declares:
| Interface | Purpose |
|---|---|
IQuery<TEntity> | Base: has Apply(IQueryable<TEntity>) that builds the pipeline |
IQuery<TEntity, TResult> | Adds Projection expression for Select() |
IPagedQuery<TEntity> | Adds Page, PageSize, Skip, Take |
IPagedQuery<TEntity, TResult> | Paged query with projection |
IIncludableQuery<TEntity> | Declares eager-loading paths via IncludePaths |
IQueryHints | Execution hints: NoTracking, SplitQuery, IgnoreGlobalFilters |
How the SG Chooses the Interface
Section titled “How the SG Chooses the Interface”| Your Query Class Has | Generated Interface |
|---|---|
[Query<Order>] | IQuery<Order> |
[Query<Order, OrderDto>] | IQuery<Order, OrderDto> |
[Query<Order>] + Page/PageSize | IPagedQuery<Order> |
[Query<Order, OrderDto>] + Page/PageSize | IPagedQuery<Order, OrderDto> |
Combining Mixin Interfaces
Section titled “Combining Mixin Interfaces”IIncludableQuery<T> and IQueryHints are mixin interfaces — you implement them alongside the base query:
[Query<Order, OrderDto>]public partial class GetOrders : IQueryHints{ public bool NoTracking => true; // Default: true (read-only) public bool SplitQuery => true; // Avoid cartesian explosion public bool IgnoreGlobalFilters => false;
[Filter] public OrderStatus? Status { get; init; }
public int Page { get; init; } = 1; public int PageSize { get; init; } = 20;}IQueryHints uses default interface members — you only override what you need:
| Hint | Default | Description |
|---|---|---|
NoTracking | true | Entities are not tracked (read-only queries are the common case) |
SplitQuery | false | Use split queries for collection navigations to avoid cartesian explosion |
IgnoreGlobalFilters | false | Bypass global query filters (soft-delete, tenant, etc.) |
Include Paths
Section titled “Include Paths”IIncludableQuery<TEntity> declares navigation paths for eager loading using dot notation:
[Query<Order>]public partial class GetOrderWithDetails : IIncludableQuery<Order>{ public IReadOnlyList<string> IncludePaths => ["Customer", "Lines.Product"]; // ↑ Translates to: .Include(o => o.Customer).Include(o => o.Lines).ThenInclude(l => l.Product)}IQueryExecutor
Section titled “IQueryExecutor”The runtime component that takes a query object and a source IQueryable, then executes it:
public interface IQueryExecutor{ Task<PagedResult<TEntity>> ExecuteAsync<TEntity>( IPagedQuery<TEntity> query, IQueryable<TEntity> source, CancellationToken cancellationToken = default) where TEntity : class;
Task<PagedResult<TResult>> ExecuteAsync<TEntity, TResult>( IPagedQuery<TEntity, TResult> query, IQueryable<TEntity> source, CancellationToken cancellationToken = default) where TEntity : class where TResult : class;
Task<IReadOnlyList<TEntity>> ExecuteAllAsync<TEntity>( IQuery<TEntity> query, IQueryable<TEntity> source, CancellationToken cancellationToken = default) where TEntity : class;}The EF Core implementation (EfCoreQueryExecutor) automatically:
- Applies query hints —
AsNoTracking(),AsSplitQuery()based onIQueryHints - Applies global filters — soft-delete, tenant, temporal via
IQueryFilterProvider(see Query Filters) - Applies navigation filters — via
FilterMapComposer+PragmaticQueryFilterVisitor - Calls
Apply()— your generated filter/sort pipeline - Handles projection — applies the
Projectionexpression viaSelect() - Counts total items — a separate
CountAsync()for paging metadata - Pages the results —
Skip()+Take()fromIPagedQuery
The result is a PagedResult<T> that follows the Result pattern:
var result = await executor.ExecuteAsync(query, source, ct);
// Pattern matchingreturn result.Match( success: (items, total) => Ok(new { items, total, result.Page, result.TotalPages }), failure: error => BadRequest(error.Message));
// Or direct accessif (result.IsSuccess){ var items = result.Items; // IReadOnlyList<T> var total = result.TotalCount; // Total across all pages var pages = result.TotalPages; // Calculated page count var hasNext = result.HasNextPage;}PagedResult<T>
Section titled “PagedResult<T>”The paged result carries both the data and paging metadata:
| Property | Type | Description |
|---|---|---|
Items | IReadOnlyList<T> | Items for the current page |
TotalCount | int | Total items across all pages |
Page | int | Current page number (1-based) |
PageSize | int | Items per page |
TotalPages | int | Calculated: ceil(TotalCount / PageSize) |
HasPreviousPage | bool | Page > 1 |
HasNextPage | bool | Page < TotalPages |
PagedResult<T> defaults to QueryError as its error type. For custom error types, use PagedResult<T, TError>.
How It All Fits Together
Section titled “How It All Fits Together”Here is the full flow from HTTP request to response:
HTTP GET /api/reservations/search?Status=Confirmed&CheckInSort=Descending&Page=2
1. Model binding → SearchReservationsQuery { Status = Confirmed, CheckInSort = Descending, Page = 2 } 2. Endpoint pipeline invokes IQueryExecutor 3. Executor gets IQueryable<Reservation> from repository 4. Executor applies IQueryHints (AsNoTracking) 5. Executor applies global query filters (soft-delete, tenant) 6. Executor calls query.Apply(source) → adds WHERE + ORDER BY 7. Executor counts total (SELECT COUNT) 8. Executor pages (SKIP 20 TAKE 20) 9. Executor projects (SELECT → ReservationSummaryDto) 10. Returns PagedResult<ReservationSummaryDto>
HTTP 200 { items: [...], totalCount: 47, page: 2, pageSize: 20, totalPages: 3 }You wrote 15 lines of C#. The source generator and runtime did the rest.