Skip to content

Query Engine — Declarative filters for API + grid

Every admin grid eventually grows the same shape: a search box, a few quick filters, sortable columns, paged results, maybe groupBy with aggregates. The dumb way is to hand-roll a parser per endpoint: ?status=active&from=...&to=...&sort=-date, plus a matching DTO on the frontend, plus an OpenAPI annotation, plus a column metadata endpoint so the grid knows which fields are filterable. Five places to keep in sync, two of them not type-checked.

Granit.QueryEngine flips it: one strongly-typed QueryDefinition<TEntity> describes columns, filter groups, quick filters, search, and sort options. That single declaration drives the search endpoint, the metadata endpoint the frontend data grid binds to, the OpenAPI schema for code generation, and — via Granit.Http.ODataExposure — a full OData v4 feed for Power BI. Zero duplication, SQL-injection-safe by construction, framework filters (tenant, soft-delete) layered before user input always.

PainThis package’s answer
Hand-rolled ?status=...&sort=... parsing per endpointOne generic MapGranitQuery<T> route — same shape everywhere
Frontend grid reimplements which columns are filterableGET /meta endpoint returns the filter/sort/groupBy schema
LIKE wildcard injection (%, _)Metacharacters auto-escaped before EF Core
Tenant-leaky filter (forgot WHERE tenant_id = …)IQueryableSource<T> arrives pre-filtered by ApplyGranitConventions
Cursor forgery for keyset paginationHMAC-signed cursors via QueryEngineOptions.CursorHmacKey
Fat OpenAPI doc (PagedResult inlined per endpoint)Schemas emitted as $ref (PR #1651) — generated clients stay slim
BI tool wants tabular dataSame QueryDefinition → OData v4 EntitySet (ADR-050)
  • DirectoryGranit.QueryEngine.Abstractions/ Contracts: QueryDefinition<T>, QueryRequest, PagedResult<T>, GroupedResult<T>, IQueryEngine<T>, IQueryableSource<T>, column descriptors
    • DirectoryGranit.QueryEngine/ DI wiring, definitions registry, null-object defaults
      • Granit.QueryEngine.EntityFrameworkCore EF Core IQueryEngine<T> implementation
      • Granit.QueryEngine.AspNetCore Generic MapGranitQuery<T>() endpoint + metadata endpoint + bindable QueryRequest
    • Granit.QueryEngine.AI NL → QueryRequest translation (the AI/NLQ bridge)
PackageRoleDepends on
Granit.QueryEngine.AbstractionsQueryRequest, PagedResult<T>, GroupedResult<T>, QueryDefinition<T>, ColumnDescriptor, IQueryEngine<T>Granit
Granit.QueryEngineIQueryDefinitionDescriptor registry, DI wiring, null-object defaultsGranit.QueryEngine.Abstractions
Granit.QueryEngine.EntityFrameworkCoreEF Core query executor — translates QueryRequest to IQueryable<T> and projects to DTOsGranit.QueryEngine, Granit.Persistence
Granit.QueryEngine.AspNetCoreMapGranitQuery<T>(), BindableQueryRequest, OpenAPI transformer emitting $ref for paged/grouped schemasGranit.QueryEngine
Granit.QueryEngine.AIINaturalLanguageQueryTranslator (LLM → QueryRequest)Granit.QueryEngine.Abstractions, Granit.AI

MapGranitQuery<TEntity>("/api/{module}/{entity}", ...) mounts two routes on the target group:

MethodRouteReturns
GET/PagedResult<TEntity> (or GroupedResult<TEntity> when ?groupBy=...)
GET/metaQueryMetadata — fields, operators, sort options, defaults; what the frontend binds to

POST /query is NOT a separate route — the body-style query endpoint is achieved by passing a serialized QueryRequest in ?q= (the BindableQueryRequest binder accepts both query-string and form-encoded shapes for large request payloads).

The query engine applies defence in depth across multiple layers — each one is the default, not an opt-in.

Every endpoint registered via MapGranitQuery<T>() requires authentication. Use AuthorizationPolicy for a named permission policy; use AllowAnonymous only when the data is genuinely public (countries, currencies, marketing catalog):

endpoints.MapGranitQuery<Invoice>("/api/invoices", sp => ..., options =>
{
options.AuthorizationPolicy = "Invoices.Read"; // named policy
});
// Rare: anonymous catalog
endpoints.MapGranitQuery<Product>("/api/products", sp => ..., options =>
{
options.AllowAnonymous = true;
});

Search terms and string filter values (Contains, StartsWith, EndsWith) have SQL LIKE metacharacters (%, _, [) escaped before reaching EF Core — preventing wildcard injection probing (CWE-943).

Keyset pagination cursors can be signed with HMAC-SHA256 to prevent forgery. Configure a 256-bit key in QueryEngineOptions.CursorHmacKey (base64):

{
"QueryEngine": {
"CursorHmacKey": "BASE64_ENCODED_32_BYTE_KEY"
}
}

Tampered cursors silently fall back to a fresh paging window — never disclose the tampering reason to the caller.

ControlDefaultOption
Max page size100MaxPageSize
Max stream size100 000MaxStreamSize
Max group-by cardinality1 000MaxGroupCount
Max filter entries50Validator constant
Max filter key length200 charsValidator constant
Max filter value length2 000 charsValidator constant

Filter operators are validated at runtime against the column’s CLR type. Contains is only accepted on string columns; Between is only accepted on numeric/date types. Invalid operator/type combinations are silently ignored — the filter is dropped instead of escalating into an error that leaks schema details.

Filter values, cursor payloads, and natural language inputs are never written to log messages. Only field names, target types, and input lengths are logged. The contract is enforced by an architecture test on every CI run.

Each MapGranitQuery<TEntity> registration plays well with the ApiDocumentation module:

  • PagedResult<TEntity> and GroupedResult<TEntity> are emitted as $ref schemas in components.schemas instead of being inlined per endpoint (PR #1651) — code generators (openapi-typescript, NSwag, Kiota) emit a single shared type.
  • The grouped result projects to the entity DTO rather than the EF entity (PR #1653) — the OpenAPI shape matches the API shape.
  • Spurious ["integer", "string"] fallbacks on int32 properties are stripped by Int32SchemaTransformer (same PR).

Net effect: the OpenAPI document for a host with 50 query endpoints shrinks by ~250 KB, and the generated client has one PagedResultOfInvoice instead of fifty.

If you also reference Granit.Http.ODataExposure, the same QueryDefinition<TEntity> registered for the admin grid powers an OData v4 EntitySet that Power BI, Excel, and Tableau consume natively. No second definition, no parallel maintenance — the EDM whitelist is derived from EntityDefinition (ADR-050).

Granit.QueryEngine.AI ships INaturalLanguageQueryTranslator — pass a phrase like “unpaid invoices from last quarter over €5 000”, get back a QueryRequest that runs through the same validators as a manually-crafted one. See AI: Natural Language Query for the full prompt structure, sanitization, and per-tenant cost ceiling.