Skip to content

Specification (Declarative Query DSL)

The Specification pattern encapsulates a business rule in a reusable, composable object. Granit implements it as a declarative query DSL (whitelist-first) that builds Expression<Func<TEntity, bool>> from filtered, sorted, and paginated criteria — all translated to SQL by EF Core.

flowchart LR
    subgraph Declaration["Declaration (whitelist)"]
        QD["QueryDefinition of Patient"]
        QB["QueryDefinitionBuilder<br/>.Column() .GlobalSearch()<br/>.FilterGroup() .DefaultSort()"]
    end

    subgraph Execution["Execution (expression trees)"]
        QR["QueryRequest<br/>(page, sort, filter, presets)"]
        FEB["FilterExpressionBuilder<br/>Expression of Func T bool"]
        QE["QueryEngine<br/>ApplyFilters then Sort then Paginate"]
    end

    subgraph Output["Result"]
        PR["PagedResult of T"]
        Meta["QueryMetadata<br/>(columns, operators, presets)"]
    end

    QD --> QB
    QB --> QE
    QR --> QE
    QE --> FEB
    QE --> PR
    QE --> Meta

    style Declaration fill:#e8f4fd,stroke:#1a73e8
    style Execution fill:#fef3e0,stroke:#e8a317
    style Output fill:#e8fde8,stroke:#2d8a4e
PackageRole
Granit.QueryingInterfaces, fluent builder, DTOs (QueryRequest, PagedResult<T>)
Granit.Querying.EntityFrameworkCoreExecution engine: expression trees, dynamic sorting, pagination
Granit.Querying.EndpointsREST binding (filter[field.op]=value), metadata + saved views endpoints

QueryDefinition — the declarative specification

Section titled “QueryDefinition — the declarative specification”

Each entity declares its query capabilities via a QueryDefinition<TEntity> class. Nothing is exposed by default — every filterable, sortable, or groupable column must be explicitly whitelisted.

public sealed class PatientQueryDefinition : QueryDefinition<Patient>
{
public override string Name => "Acme.Patients";
protected override void Configure(QueryDefinitionBuilder<Patient> builder) =>
builder
.Column(p => p.Niss, c => c.Label("NISS").Filterable().Sortable())
.Column(p => p.Email, c => c.Label("Email").Filterable())
.GlobalSearch(p => p.Niss, p => p.Email, p => p.LastName)
.FilterGroup("Status", g => g
.Preset("Active", p => p.Status == PatientStatus.Active, isDefault: true)
.Preset("Inactive", p => p.Status == PatientStatus.Inactive))
.DefaultSort("-LastName")
.DefaultPageSize(25);
}
C# typeAvailable operators
stringEq, Contains, StartsWith, EndsWith, In
int, decimal, doubleEq, Gt, Gte, Lt, Lte, In, Between
DateTime, DateTimeOffsetEq, Gt, Gte, Lt, Lte, Between
boolEq
enumEq, In
GuidEq, In

The QueryEngine applies filters in this order:

  1. Filters (filter[field.op]=value) — validated against the whitelist, compiled to Expression<Func<T, bool>> via FilterExpressionBuilder
  2. Presets (presets[group]=name1,name2) — OR within a group, AND between groups
  3. Quick filters (quickFilters=MyItems,Unread) — independent AND
  4. Global search (search=Alice) — OR across declared properties
  5. Dynamic sort (sort=-createdAt,lastName) — OrderBy/ThenBy via expressions
  6. Pagination — offset (page, pageSize) or keyset (cursor)

Fields not declared in the QueryDefinition are silently ignored. No SQL injection is possible: all values pass through typed Expression objects, never through string concatenation.

FileRole
src/Granit.Querying/QueryDefinition.csAbstract specification class
src/Granit.Querying/QueryDefinitionBuilder.csFluent builder (Column, FilterGroup, GlobalSearch)
src/Granit.Querying/Filtering/FilterCriteria.csRecord (Field, Operator, Value)
src/Granit.Querying/Filtering/FilterOperator.csOperator enum
src/Granit.Querying/QueryRequest.csQuery DTO (page, sort, filter, presets)
src/Granit.Querying.EntityFrameworkCore/Internal/FilterExpressionBuilder.csFilter to Expression compilation
src/Granit.Querying.EntityFrameworkCore/Internal/QueryableFilterExtensions.csFilter application on IQueryable
src/Granit.Querying.EntityFrameworkCore/Internal/QueryableSortExtensions.csDynamic sorting via expressions
src/Granit.Querying.EntityFrameworkCore/Internal/QueryablePaginationExtensions.csOffset + keyset pagination
src/Granit.Querying.Endpoints/Binding/QueryRequestBinder.csREST query string parsing
ProblemSpecification solution
Exposing all fields for filtering (injection, perf)Whitelist-first: only declared fields are filterable
Dynamic SQL construction via concatenationTyped expression trees, translated by EF Core
Filtering logic duplicated in every endpointQueryDefinition<T> centralizes declaration per entity
Frontend does not know which filters are available/meta endpoint returns columns, operators, and presets
Preset filters duplicated between frontend and backendPresets declared server-side, exposed via metadata
// --- Declaration (once per entity) ---
public sealed class InvoiceQueryDefinition : QueryDefinition<Invoice>
{
public override string Name => "Billing.Invoices";
protected override void Configure(QueryDefinitionBuilder<Invoice> builder) =>
builder
.Column(i => i.Number, c => c.Label("Invoice #").Filterable().Sortable())
.Column(i => i.Amount, c => c.Label("Amount").Filterable().Sortable())
.Column(i => i.IssuedAt, c => c.Label("Issued").Sortable())
.DateFilter(i => i.IssuedAt, DatePeriod.Last30Days)
.FilterGroup("Status", g => g
.Preset("Draft", i => i.Status == InvoiceStatus.Draft)
.Preset("Sent", i => i.Status == InvoiceStatus.Sent, isDefault: true)
.Preset("Paid", i => i.Status == InvoiceStatus.Paid))
.GlobalSearch(i => i.Number, i => i.CustomerName)
.DefaultSort("-IssuedAt")
.SupportsCursorPagination(i => i.IssuedAt);
}
// --- Usage (endpoint) ---
// GET /api/invoices?filter[amount.gte]=1000&presets[status]=Sent,Paid&sort=-issuedAt&page=1
group.MapQueryEndpoints<Invoice>("/api/invoices",
sp => sp.GetRequiredService<BillingDbContext>().Invoices.AsNoTracking());