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.
| Pain | This package’s answer |
|---|---|
Hand-rolled ?status=...&sort=... parsing per endpoint | One generic MapGranitQuery<T> route — same shape everywhere |
| Frontend grid reimplements which columns are filterable | GET /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 pagination | HMAC-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 data | Same QueryDefinition → OData v4 EntitySet (ADR-050) |
Package structure
Section titled “Package structure”DirectoryGranit.QueryEngine.Abstractions/ Contracts:
QueryDefinition<T>,QueryRequest,PagedResult<T>,GroupedResult<T>,IQueryEngine<T>,IQueryableSource<T>, column descriptorsDirectoryGranit.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 + bindableQueryRequest
- Granit.QueryEngine.EntityFrameworkCore EF Core
- Granit.QueryEngine.AI NL →
QueryRequesttranslation (the AI/NLQ bridge)
| Package | Role | Depends on |
|---|---|---|
Granit.QueryEngine.Abstractions | QueryRequest, PagedResult<T>, GroupedResult<T>, QueryDefinition<T>, ColumnDescriptor, IQueryEngine<T> | Granit |
Granit.QueryEngine | IQueryDefinitionDescriptor registry, DI wiring, null-object defaults | Granit.QueryEngine.Abstractions |
Granit.QueryEngine.EntityFrameworkCore | EF Core query executor — translates QueryRequest to IQueryable<T> and projects to DTOs | Granit.QueryEngine, Granit.Persistence |
Granit.QueryEngine.AspNetCore | MapGranitQuery<T>(), BindableQueryRequest, OpenAPI transformer emitting $ref for paged/grouped schemas | Granit.QueryEngine |
Granit.QueryEngine.AI | INaturalLanguageQueryTranslator (LLM → QueryRequest) | Granit.QueryEngine.Abstractions, Granit.AI |
Endpoints
Section titled “Endpoints”MapGranitQuery<TEntity>("/api/{module}/{entity}", ...) mounts two routes on the
target group:
| Method | Route | Returns |
|---|---|---|
GET | / | PagedResult<TEntity> (or GroupedResult<TEntity> when ?groupBy=...) |
GET | /meta | QueryMetadata — 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).
Column display metadata
Section titled “Column display metadata”A column carries two independent display hints, both surfaced on GET /meta so the
grid renders a value without a per-app lookup table.
.ValueKind(...)— what the value means. A semantic display type (Currency,Percentage,Url,Email,Bytes, …) the frontend maps to a cell renderer:mailto:links, humanized byte sizes, currency symbols, percent suffixes..Format(string)— how precise it is rendered. A raw format hint ("dd/MM/yyyy","N2") that refines the chosen renderer. Orthogonal to value-kind: aCurrencycolumn can still carry.Format("N0")to drop the decimals.
public sealed class InvoiceQueryDefinition : QueryDefinition<Invoice>{ protected override void Configure(QueryDefinitionBuilder<Invoice> builder) => builder .Column(i => i.Total, c => c.Currency("EUR").Sortable()) // ValueKind.Currency + CurrencyCode .Column(i => i.TaxRate, c => c.Percentage()) // ratio in [0,1] → "20 %" .Column(i => i.PublicUrl, c => c.Url()) // clickable link .Column(i => i.ContactEmail, c => c.Email()) // mailto: link .Column(i => i.AttachmentSize, c => c.Bytes()) // "1.4 MB" .Column(i => i.IssuedOn, c => c.ValueKind(ValueKind.Date).Format("dd/MM/yyyy"));}ColumnBuilder helpers
Section titled “ColumnBuilder helpers”.Currency(isoCode) sets both ValueKind.Currency and the ISO 4217 CurrencyCode
(per-column, so AmountEur / AmountUsd can differ on the same entity). The other
one-liners — .Percentage(), .Url(), .Email(), .Phone(), .Bytes() — are thin
wrappers over .ValueKind(...). For any kind without a dedicated helper, call the
general form .ValueKind(ValueKind.X).
The ValueKind enum (in Granit.QueryEngine.Abstractions, namespace
Granit.QueryEngine) ships in three tiers:
| Tier | Members |
|---|---|
1 — core (shared with the Analytics MetricValueKind vocabulary) | Count, Number, Currency, Percentage, Duration, Date |
| 2 — high-value | Url, Email, Phone, Bytes, DateTime, Boolean, Enum |
| 3 — rich renderers | RelativeTime, Time, Color, Image, Json, Markdown, Tags, Rating, Identifier |
/meta wire shape
Section titled “/meta wire shape”ColumnDefinition on the metadata endpoint gained two optional fields, both PascalCase
enum names on the wire (JsonStringEnumConverter), both metadata-only — never persisted:
| Field | Type | Meaning |
|---|---|---|
valueKind | enum name or null | Semantic display type. null ⇒ fall back to the CLR type, preserving pre-existing behaviour. |
currencyCode | ISO 4217 string or null | Companion to valueKind: "Currency"; null for every other kind. |
Security hardening
Section titled “Security hardening”The query engine applies defence in depth across multiple layers — each one is the default, not an opt-in.
Authentication by default
Section titled “Authentication by default”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 catalogendpoints.MapGranitQuery<Product>("/api/products", sp => ..., options =>{ options.AllowAnonymous = true;});LIKE wildcard escaping
Section titled “LIKE wildcard escaping”Search terms and string filter values (Contains, StartsWith, EndsWith) have
SQL LIKE metacharacters (%, _, [) escaped before reaching EF Core — preventing
wildcard injection probing (CWE-943).
Cursor integrity (HMAC signing)
Section titled “Cursor integrity (HMAC signing)”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.
Resource exhaustion protection
Section titled “Resource exhaustion protection”| Control | Default | Option |
|---|---|---|
| Max page size | 100 | MaxPageSize |
| Max stream size | 100 000 | MaxStreamSize |
| Max group-by cardinality | 1 000 | MaxGroupCount |
| Max filter entries | 50 | Validator constant |
| Max filter key length | 200 chars | Validator constant |
| Max filter value length | 2 000 chars | Validator constant |
Operator-type validation
Section titled “Operator-type validation”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.
PII-safe logging
Section titled “PII-safe logging”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.
OpenAPI integration
Section titled “OpenAPI integration”Each MapGranitQuery<TEntity> registration plays well with the
ApiDocumentation module:
PagedResult<TEntity>andGroupedResult<TEntity>are emitted as$refschemas incomponents.schemasinstead 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 onint32properties are stripped byInt32SchemaTransformer(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.
OData bridge (read-only)
Section titled “OData bridge (read-only)”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).
Natural language queries
Section titled “Natural language queries”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.
See also
Section titled “See also”- Persistence — EF Core interceptors, query filters,
IQueryableSource<T> - Entity Definition — the write-side twin:
FieldBuilder.ValueKind()steers form edit inputs the same way column value-kind steers cell renderers - ADR-041 — the form-field component catalog value-kind hints into
- API Documentation —
$refschema emission, OpenAPI transformers - Data Exchange — peer building block: CSV / Excel import / export
- TextExtraction — peer building block: bytes → plain text for indexing / RAG
- Indexing — peer building block: full-text + hybrid semantic search (Postgres
tsvector/ Elasticsearch). QueryEngine drives structured admin grids; Indexing drives unstructured search. - OData feed (BI) —
QueryDefinition<T>→ Power BI / Excel / Tableau - AI: Natural Language Query — phrase →
QueryRequest - ADR-047 — why SavedView was removed in favour of
EntityView - Mentions — the
@mentionpicker shares the same lookup sources as QueryEngine filters