UniversalQueryBuilder.Endpoints
10.0.13-beta
dotnet add package UniversalQueryBuilder.Endpoints --version 10.0.13-beta
NuGet\Install-Package UniversalQueryBuilder.Endpoints -Version 10.0.13-beta
<PackageReference Include="UniversalQueryBuilder.Endpoints" Version="10.0.13-beta" />
<PackageVersion Include="UniversalQueryBuilder.Endpoints" Version="10.0.13-beta" />
<PackageReference Include="UniversalQueryBuilder.Endpoints" />
paket add UniversalQueryBuilder.Endpoints --version 10.0.13-beta
#r "nuget: UniversalQueryBuilder.Endpoints, 10.0.13-beta"
#:package UniversalQueryBuilder.Endpoints@10.0.13-beta
#addin nuget:?package=UniversalQueryBuilder.Endpoints&version=10.0.13-beta&prerelease
#tool nuget:?package=UniversalQueryBuilder.Endpoints&version=10.0.13-beta&prerelease
QueryBuilder.Endpoints
Minimal API endpoints for Universal Query Builder. Provides ready-to-use HTTP endpoints for query execution, schema introspection, and validation.
Installation
dotnet add package UniversalQueryBuilder.Endpoints
Registration
// 1. Register services
builder.Services.AddQueryBuilder(); // Core QueryBuilder services
builder.Services.AddQueryBuilderEndpoints(options =>
{
options.RoutePrefix = "/api/query"; // Default: "/api/query"
options.RegistryRoutePrefix = "/api/registry"; // Default: "/api/registry"
options.DefaultPageSize = 50; // Default: 50
options.MaxPageSize = 1000; // Default: 1000
options.DefaultBulkBatchSize = 1000; // Default: 1000
options.MaxBulkKeysPerRequest = 50000; // Default: 50000
options.MaxTotalResultRows = 100000; // Default: 100000
options.BulkOperationTimeoutSeconds = 120; // Default: 120
options.EnableValidation = true; // Default: true
options.EnableSchemaIntrospection = true; // Default: true
options.IncludeDebugInfo = false; // Default: false
});
// 2. Map endpoints
app.MapQueryBuilderEndpoints();
Endpoints
All query and validation endpoints are rooted at RoutePrefix (default /api/query). Registry endpoints are rooted at RegistryRoutePrefix (default /api/registry).
Query Execution
POST {RoutePrefix}/{sourceName} - Execute a query against a data source
Request Properties
| Property | Type | Description |
|---|---|---|
query |
string | Shorthand filter syntax (e.g., "status:active AND age:>18") |
where |
FilterDefinition | Structured JSON filter (see below) |
select |
object | Hierarchical field selection |
groupBy |
string[] | Explicit grouping fields for aggregation |
limit |
int | Maximum results (clamped between 1 and MaxPageSize) |
offset |
int | Pagination offset (minimum 0) |
includeTotalCount |
bool | Include total count (default: true) |
includeDebug |
bool | Include debug block when server IncludeDebugInfo is enabled (default: false) |
orderBy |
OrderingDefinition[] | Sort specification for main result set (see Ordering) |
searchGroup |
string | Restrict bare-term search to a named field group (see Search Groups) |
When limit is not specified, DefaultPageSize is used. Values exceeding MaxPageSize are clamped down; values below 1 are clamped up. offset is floored at 0.
Filter Combination
When both where (structured) and query (shorthand) are provided, they combine with AND logic:
whereacts as the baseline filter (like a page-level filter)queryacts as a refinement (like a user search filter)- Effective filter:
where AND parsedQuery
Example: Shorthand Only
{
"query": "isActive:true AND age:>18",
"select": { "id": true, "firstName": true },
"limit": 50
}
Example: Structured Filter Only
{
"where": {
"logicalOperator": "and",
"expressions": [
{ "field": "departmentId", "operator": "in", "values": [1, 2, 3] },
{ "field": "createdAt", "operator": "gte", "value": "2024-01-01" }
]
},
"select": { "id": true, "firstName": true },
"limit": 50
}
Example: Combined (Baseline + Refinement)
{
"where": {
"field": "departmentId",
"operator": "in",
"values": [1, 2, 3]
},
"query": "status:pending",
"select": { "id": true, "firstName": true, "status": true },
"limit": 50
}
Effective filter: departmentId IN (1,2,3) AND status = 'pending'
@ Function Resolution
Filter values starting with @ (e.g., @this_week, @today, @last_7_days) are resolved to concrete values before expression building. Date macros resolve to Between operations with computed date ranges. This applies to both structured (where) and shorthand (query) filters.
{
"where": {
"field": "startDate",
"operator": "eq",
"value": "@this_week"
}
}
The @this_week value resolves at execution time to a Between filter spanning the current week's start and end dates.
Ordering
The orderBy property controls the sort order of the main result set. When provided, ordering is applied before pagination.
{
"query": "department:engineering",
"orderBy": [
{ "field": "lastName", "direction": "asc" },
{ "field": "firstName", "direction": "asc" }
],
"limit": 50
}
Each ordering item specifies:
| Property | Type | Description |
|---|---|---|
field |
string | Column name or dot-notation navigation path (must be an exposed field in the data source schema) |
direction |
string | "asc" or "desc" (default: "asc") |
nulls |
string | "first" or "last" (optional) |
Navigation property ordering: The field property supports dot-notation paths for scalar (reference) navigation properties. For example, "address.city" or "company.headquartersAddress.city". Direct field-based ordering by collection navigation properties (one-to-many) is not supported because it is semantically ambiguous — use an aggregate function instead.
{
"orderBy": [
{ "field": "address.city", "direction": "asc" },
{ "field": "lastName", "direction": "asc" }
]
}
Aggregate function ordering: Use the function property to order by an aggregate over a collection navigation. Supported functions: count, sum, avg, min, max.
{
"orderBy": [
{
"function": { "name": "min", "arguments": [{ "field": "onboardings.startDate" }] },
"direction": "asc"
},
{
"function": { "name": "count", "arguments": [{ "field": "orders" }] },
"direction": "desc"
}
]
}
Each ordering item specifies exactly one of field or function (not both). The function.arguments[0].field must reference a path through a collection navigation property in the data source schema.
Grouped (GROUP BY) ordering: In a grouped query, ordering is validated against the grouped projection, not the source columns — the result rows are keyed by the select aliases. Order by a select alias that projects a scalar value: the GROUP BY key or a top-level count alias, using the bare field form.
{
"groupBy": ["lanId"],
"select": { "lanId": true, "count": true },
"orderBy": [{ "field": "count", "direction": "desc" }],
"limit": 50
}
This returns the top groups by count — ordering is applied to the groups before the page limit. Three forms are rejected with a 400: a source column that the projection does not include (it is not a result key); a per-column aggregate (e.g. { "salary": { "avg": true } } projects a nested object, not a sortable scalar); and the aggregate-function ordering form (use the aggregate's select alias instead — the function form is for collection-navigation ordering on non-grouped queries).
Deterministic pagination: When orderBy is provided, the data source's primary key fields are automatically appended as tiebreakers if not already present. This ensures stable page boundaries when sorting by non-unique fields.
Default ordering: When orderBy is omitted, the data source's configured default ordering is used (typically the primary key).
Validation: For a non-grouped query, all fields must exist in the data source schema; for a grouped query, they must be sortable projection aliases (see Grouped ordering above). Invalid fields return a 400 response with the field name and the available/sortable fields listed.
Search Groups
The searchGroup property narrows bare-term expansion to a named subset of searchable fields. When set, only the group's fields participate in search, regardless of the group's declared exclusivity mode (consumer selection is always exclusive).
The group name must match a group registered via .SearchGroup() in the data source configuration. If the name does not match any registered group, the request returns a 400 error with the available group names.
{
"query": "john",
"searchGroup": "name",
"limit": 50
}
Without searchGroup, bare terms expand across all searchable fields (subject to any auto-activating exclusive groups). With searchGroup, expansion is restricted to only that group's fields.
Structured Filter Operators
| Category | Operators |
|---|---|
| Comparison | eq, ne, gt, gte, lt, lte |
| String | like, startsWith, endsWith, contains |
| Collection | in, notIn |
| Range | between, notBetween |
| Null | isNull, isNotNull |
| Logical | and, or, not |
Response
{
"items": [...],
"totalCount": 150,
"page": 1,
"pageSize": 50,
"orderBy": [
{ "field": "lastName", "direction": "asc" },
{ "field": "firstName", "direction": "asc" },
{ "field": "id", "direction": "asc" }
],
"debug": {
"parsedQuery": { ... },
"efDebugSql": "SELECT ...",
"warnings": []
}
}
totalCount is nullable — omitted from JSON when null. It is null when includeTotalCount=false or when the execution strategy does not compute a count.
orderBy shows the effective ordering applied, including any tiebreaker fields appended for deterministic pagination. Omitted when no ordering was applied.
debug is optional and only returned when both includeDebug=true and server option IncludeDebugInfo=true.
Bulk Lookup By Key
POST {RoutePrefix}/{sourceName}/bulk/by-key - Lookup many records by a key field in one request
Request body:
{
"keys": ["SN-001", "SN-002", "SN-003"],
"keyField": "SerialNumber",
"select": { "serialNumber": true, "status": true },
"orderBy": [{ "field": "Status", "direction": "asc" }],
"includeDebug": false
}
| Property | Type | Description |
|---|---|---|
keys |
string[] | Required. Input keys (deduplicated server-side after type conversion). For composite keys, use tilde-delimited format (e.g., ["SAP~42", "FOO~1"]) |
keyField |
string | Optional. Canonical exposed top-level scalar field; defaults to primary key. When keyField is explicitly provided on a composite-key source, a single-column IN-based lookup is used instead of composite key matching |
select |
object | Optional projection selection |
orderBy |
OrderingDefinition[] | Optional ordering applied to rows within each key's results array |
includeDebug |
bool | Include debug payload when server IncludeDebugInfo is enabled |
Single Key Example
{
"keys": ["SN-001", "SN-002", "SN-003"],
"keyField": "SerialNumber",
"select": { "serialNumber": true, "status": true }
}
Composite Key Example
When keyField is omitted and the source has a composite primary key, keys use the same tilde-delimited format as get-by-ID:
{
"keys": ["SAP~42", "FOO~1"],
"select": { "sourceName": true, "id": true, "status": true }
}
Each key string is deserialized into typed parts matching the composite key definition. The response includes a keyFields array listing the composite key property names.
Buffered JSON response (single key):
{
"sourceName": "devices",
"keyField": "SerialNumber",
"keyFields": ["SerialNumber"],
"items": [
{ "key": "SN-001", "found": true, "results": [{ "serialNumber": "SN-001", "status": "Active" }] }
],
"summary": {
"requestedCount": 3,
"distinctCount": 2,
"foundCount": 1,
"missingCount": 1,
"matchedRecordCount": 1,
"batchCount": 1,
"effectiveBatchSize": 1000
},
"executionTimeMs": 18,
"executionStrategy": "EntityFramework"
}
Buffered JSON response (composite key):
{
"sourceName": "orderItems",
"keyField": "SourceName,Id",
"keyFields": ["SourceName", "Id"],
"items": [
{ "key": "SAP~42", "found": true, "results": [{ "sourceName": "SAP", "id": 42, "status": "Active" }] }
],
"summary": { "..." : "..." },
"executionTimeMs": 22,
"executionStrategy": "EntityFramework"
}
The keyFields property is always present — single-element for simple keys, multi-element for composite keys. keyField is derived as string.Join(",", keyFields).
Batch sizing: For composite keys, batch sizes are dynamically calculated to stay within SQL Server parameter limits (2000 parameters per batch). The effective batch size is min(DefaultBulkBatchSize, max(1, 2000 / keyPartCount)).
For streaming, send Accept: application/x-ndjson (or application/jsonl). The endpoint emits NDJSON events:
item(one per distinct key)progress(after each completed batch)debug(when enabled)summary(final aggregate event)error(runtime failure after stream start)
Notes:
- Duplicate input keys are deduplicated server-side.
- Response ordering is not guaranteed; correlate by
key. Vary: Acceptis returned because response format depends onAccept.- String keys correlate and deduplicate case-insensitively to align with SQL Server's default case-insensitive collation (
SQL_Latin1_General_CP1_CI_AS). Requesting"zisk"matches a row stored as"ZISK", and submitting["zisk", "ZISK"]produces a single distinct key. Numeric,Guid, and other non-string key types use their default equality.
Get By ID
POST {RoutePrefix}/{sourceName}/{id} - Retrieve a single record by primary key
Request body (optional):
{
"select": {
"id": true,
"username": true,
"department": { "select": { "name": true } }
},
"includeDebug": true
}
| Property | Type | Description |
|---|---|---|
select |
object | Hierarchical field selection (optional) |
includeDebug |
bool | Include debug block when server IncludeDebugInfo is enabled (default: false) |
Single Key
POST /api/query/users/42
Composite Key
Composite keys use tilde-delimited format in the URL path. Each key part is separated by a single tilde (~). A literal tilde in a key value is escaped as ~~.
POST /api/query/orderItems/SAP~42
This resolves to SourceName = "SAP" and Id = 42 for a composite key with parts (SourceName, Id).
Escaping example: a key value containing a literal tilde (e.g., A~B and 7) is encoded as A~~B~7.
Example with projection:
POST /api/query/users/42
Content-Type: application/json
{
"select": {
"id": true,
"email": true
}
}
Response (200):
{
"result": {
"id": 42,
"username": "jdoe",
"email": "jdoe@example.com"
},
"sourceName": "users",
"executionTimeMs": 12,
"executionStrategy": "EntityFramework",
"debug": {
"parsedQuery": { ... },
"efDebugSql": "SELECT ...",
"warnings": []
}
}
debug is optional and only returned when both includeDebug=true and server option IncludeDebugInfo=true.
Response (404 - Record not found):
{
"type": "https://www.rfc-editor.org/rfc/rfc9457",
"title": "Record not found",
"status": 404,
"detail": "Record with ID '999' was not found.",
"traceId": "0HN7Q4E8M63KJ:00000001"
}
Response (400 - No primary key configured):
{
"type": "https://www.rfc-editor.org/rfc/rfc9457",
"title": "Primary key not configured",
"status": 400,
"detail": "This data source does not support record lookup by ID.",
"traceId": "0HN7Q4E8M63KJ:00000001"
}
Requirements:
- Data source must have a primary key configured (via
[Key]attribute orWithPrimaryKey()) - Supports both single and composite primary keys
- The ID is automatically converted to the correct CLR type (int, Guid, string, etc.)
- For composite keys, each tilde-delimited segment is independently converted to its corresponding part's CLR type
- Projection semantics are identical to
POST {RoutePrefix}/{sourceName}(query-many) for the sameselectpayload - When
selectis omitted, endpoints build a schema-default recursive selection using canonical schema field casing
ID coercion behavior:
- Conversion is delegated to
TypeCoercer.TryCoercefrom theTypeCoercionNuGet package. - Coercion is strict (no silent fallback conversions).
- For single keys, invalid ID values raise
QueryBuilder.Core.Exceptions.QueryValidationException, producing a 400 ProblemDetails response with the coercion error message keyed to"id". - For composite keys, segment count mismatches or type conversion failures produce a 400 ProblemDetails response identifying the expected key parts and the failing segment.
Validation
POST {RoutePrefix}/validate - Validate shorthand query syntax without executing
Both query and sourceName are required. The source name determines which schema to validate field names against.
Request body:
{
"query": "isActive:true AND age:>18",
"sourceName": "users"
}
Response (success):
{
"valid": true,
"query": "isActive:true AND age:>18",
"parsedQuery": { ... },
"executionTimeMs": 2
}
Response (failure):
{
"type": "https://www.rfc-editor.org/rfc/rfc9457",
"title": "Query validation failed",
"status": 400,
"errors": {
"query": ["Syntax error at position 15"]
},
"traceId": "0HN7Q4E8M63KJ:00000001"
}
POST {RoutePrefix}/{sourceName}/validate - Validate a full query request against the data source schema
Validates structured filters, shorthand queries, ordering, select projections, and groupBy fields without executing. Returns all validation errors at once — does not fail on the first error.
Request body (same shape as the execution endpoint):
{
"query": "isActive:true",
"where": { "field": "status", "operator": "in", "values": ["Active"] },
"orderBy": [{ "field": "lastName", "direction": "asc" }],
"select": { "id": true, "firstName": true }
}
Response (valid):
{
"valid": true,
"query": "isActive:true",
"parsedQuery": { ... },
"executionTimeMs": 3
}
Response (invalid — returns all errors):
{
"valid": false,
"query": "isActive:true",
"errors": [
{
"code": "FieldNotFound",
"fieldName": "orderBy[0].field",
"title": "Invalid ordering specification",
"detail": "Field 'lastName' is not a sortable field. Available fields: ..."
}
],
"executionTimeMs": 2
}
Schema Registry Introspection
Registry endpoints use RegistryRoutePrefix (default /api/registry), separate from the query prefix.
GET {RegistryRoutePrefix}/sources - List all registered data sources
GET {RegistryRoutePrefix}/sources/{sourceName} - Get specific data source
GET {RegistryRoutePrefix}/sources/{sourceName}/columns - Get column metadata (hierarchical tree structure)
GET {RegistryRoutePrefix}/sources/{sourceName}/columns/{columnName} - Get specific column metadata
Column metadata fields
| Field | Type | Description |
|---|---|---|
columnName |
string | Column segment name (not full dotted path) |
displayName |
string? | User-friendly display name |
description |
string? | Optional description |
dataType |
string? | CLR type name ("int", "string", "OrderStatus", "Collection<OrderItem>") |
isNullable |
bool | Whether the column allows NULL values |
isCollection |
bool | Whether the column is a collection/array type |
isEnum |
bool | Whether the column's CLR type is an enum (including nullable enums). Used by UI builders to render dropdown/checkbox list editors |
isRecommended |
bool | Whether this column is a suggested/recommended filter for UI builders |
collectionElementType |
string? | Element type name for navigation collections |
maxLength |
int? | Maximum length for character or binary columns |
numericPrecision |
int? | Numeric precision for decimal/numeric columns |
numericScale |
int? | Numeric scale for decimal/numeric columns |
isPrimaryKey |
bool | Whether this column is part of the primary key |
aliases |
string[] | Field aliases for shorthand query support (always non-null; empty when none) |
children |
List<ColumnMetadataDto> | Nested child columns for navigation properties |
isRestricted |
bool | Whether the current user lacks permission to query this column. When true, the column is visible in discovery but aliases, metadata, and children are suppressed and querying it returns 403 with errorCode column_permission_denied. Always false unless an IColumnAuthorizationService is registered. |
valueSource |
ColumnValueSourceKind |
Indicates how the column's value is produced. One of Property (direct CLR property access), JsonScalar (SQL Server JSON_VALUE extraction from a backing JSON column), or Coalesce (null-coalescing composition of one or more sources with an optional terminal literal fallback). Mirrors the closed ColumnAccessor variant set in the SchemaRegistry. |
GET {RegistryRoutePrefix}/sources/{sourceName}/templates - Get configured template metadata for a data source
GET {RegistryRoutePrefix}/sources/{sourceName}/templates/{templateName} - Get metadata for a specific configured template
GET {RegistryRoutePrefix}/sources/{sourceName}/columns/{columnPath}/values - Get distinct values for a scalar column
Query parameters:
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| search | string? | null | Case-insensitive contains filter (string columns only, ignored for enums) |
| limit | int | 50 | Maximum values to return (1-200) |
Response:
{
"values": ["Active", "Cancelled", "Pending", "Shipped"],
"hasMore": false
}
Enum columns return all member names via CLR reflection (no database hit). Non-enum columns execute a synthetic DISTINCT query through the strategy pipeline. Null values are excluded — use the column's isNullable metadata to inform the UI whether null is a possible value.
This endpoint uses query-level authorization (CanQueryAsync), not discover-level (CanDiscoverAsync), because it reads data values. Column-level authorization runs identically to query execution — a request for distinct values of a restricted column returns 403 with errorCode: column_permission_denied.
GET {RegistryRoutePrefix}/summary - Get registry overview with counts
Registry endpoints enforce source-level authorization via ISourceAuthorizationFilter. The behavior depends on the endpoint type:
- List endpoints (
/sources,/summary) — filter results, returning only sources whereCanDiscoverAsyncreturns true. Non-discoverable sources are silently omitted. The/summaryendpoint adjustsTotalDataSourcesto reflect the filtered count. - Single-source endpoints (
/sources/{sourceName},/sources/{sourceName}/columns,/sources/{sourceName}/columns/{columnName},/sources/{sourceName}/templates,/sources/{sourceName}/templates/{templateName}) — return 404 ifCanDiscoverAsyncreturns false for the requested source, preventing source enumeration. - Distinct values endpoint (
/sources/{sourceName}/columns/{columnPath}/values) — usesCanQueryAsync(notCanDiscoverAsync) for query-level authorization. Returns 404 if unauthorized.
Example response (GET {RegistryRoutePrefix}/sources/users/templates):
[
{
"templateName": "searchUsers",
"description": "Search users by role and age",
"pattern": "role:{roleName} AND age:>={minAge}",
"parameters": [
{
"name": "roleName",
"description": "Role name",
"expectedType": "string",
"defaultValue": "Engineer",
"isRequired": false,
"position": 0,
"validationRules": [
{
"kind": "regex",
"ruleType": "RegexValidationRule",
"pattern": "^[A-Za-z]+$"
}
]
},
{
"name": "minAge",
"description": "Minimum age",
"expectedType": "int",
"defaultValue": null,
"isRequired": true,
"position": 1,
"validationRules": [
{
"kind": "range",
"ruleType": "RangeValidationRule",
"minimum": 18,
"maximum": 120
},
{
"kind": "custom",
"ruleType": "LambdaValidationRule"
}
]
}
]
}
]
See Source Authorization Filtering.
Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
RoutePrefix |
string | /api/query |
Base path for query and validation endpoints |
RegistryRoutePrefix |
string | /api/registry |
Base path for registry introspection endpoints |
EnableSchemaIntrospection |
bool | true |
Enable registry endpoints |
EnableValidation |
bool | true |
Enable /validate endpoint |
IncludeDebugInfo |
bool | false |
Enables debug metadata for get-by-id responses, query responses, and bulk lookup responses when includeDebug=true |
DefaultPageSize |
int | 50 |
Default limit when not specified in request |
MaxPageSize |
int | 1000 |
Upper bound for limit; values exceeding this are clamped |
DefaultBulkBatchSize |
int | 1000 |
Server-side batch size for bulk key lookup IN queries (must be >= 1) |
MaxBulkKeysPerRequest |
int | 50000 |
Maximum number of input keys accepted by /bulk/by-key (must be >= 1) |
MaxTotalResultRows |
int | 100000 |
Maximum total matched rows returned across all bulk batches (must be >= 1) |
BulkOperationTimeoutSeconds |
int | 120 |
Maximum wall-clock time for an entire bulk lookup operation (must be >= 1) |
QueryEndpointTag |
string | Query |
OpenAPI tag for query endpoints |
RegistryEndpointTag |
string | Schema Registry |
OpenAPI tag for registry endpoints |
The four bulk options (DefaultBulkBatchSize, MaxBulkKeysPerRequest, MaxTotalResultRows, BulkOperationTimeoutSeconds) validate at assignment time and throw ArgumentOutOfRangeException for values less than 1.
Debug Info
IncludeDebugInfo applies to optional debug metadata in get-by-id success responses, query success responses, and bulk lookup responses when includeDebug=true is requested.
Enable in development only:
builder.Services.AddQueryBuilderEndpoints(options =>
{
options.IncludeDebugInfo = builder.Environment.IsDevelopment();
});
Empty Parse Results
When the shorthand parser returns IsEmpty = true (e.g., UnmatchedBareTermBehavior.EmptyResult mode in QueryBuilder.Shorthand), the query execution service short-circuits with an empty PagedResult:
- Zero items,
TotalCount = 0,Page = 1 PageSizereflects the requested limit (orDefaultPageSize), clamped to[1, MaxPageSize]- No database query is executed
This enables live-search UIs where partial input naturally produces no matches without incurring a database round-trip.
Parse failure wrapping: When parsing fails, GetQueryOrThrow() throws a CoreException with ParseFailure error code, and the execution service wraps it as a validation error (400 response with the parse error message keyed to "query"). Other CoreException types propagate unmodified.
Error Responses
| Status | Condition |
|---|---|
| 200 | Query executed successfully (includes empty results from IsEmpty parse results) |
| 400 | Invalid query syntax, bulk key validation errors, no execution strategy, execution failure, no primary key configured, invalid ID format, invalid composite key format, bulk timeout, or exceeded MaxTotalResultRows |
| 403 | Source-level denial (user lacks permission to query the source, or a subquery references a source the user cannot query — errorCode: source_permission_denied); column-level denial (user lacks permission for a referenced column — errorCode: column_permission_denied) |
| 404 | Data source not found in schema registry, user cannot discover the source (intentionally indistinguishable from not-found), record not found by ID, or CoreException(ResourceNotFound) (e.g., a row-level filter references an unregistered subquery source) |
| 500 | Authorization configuration fault (e.g., source-auth filter bypassed, permission evaluator threw — diagnostic message preserved in body); CoreException from the row-filter injector (factory throw, cyclical config) with sourceName and other ex.Context entries surfaced as ProblemDetails extensions; unhandled exception |
All endpoint/filter errors use RFC9457 problem details:
{
"type": "https://www.rfc-editor.org/rfc/rfc9457",
"title": "Data source not found",
"status": 404,
"detail": "Data source 'users' was not found.",
"traceId": "0HN7Q4E8M63KJ:00000001"
}
Validation failures use RFC9457 validation problem details with keyed errors:
{
"type": "https://www.rfc-editor.org/rfc/rfc9457",
"title": "Query validation failed",
"status": 400,
"errors": {
"query": ["Syntax error at position 5"]
},
"traceId": "0HN7Q4E8M63KJ:00000001"
}
Validation Pipeline
QueryRequestValidator.ValidateAsync is the single orchestrator for POST {RoutePrefix}/{sourceName} and POST {RoutePrefix}/{sourceName}/validate. It runs these steps in order:
- Validate the structured filter (
request.Where) against the source schema, returning errors keyed towhere.*. - Parse shorthand (
request.Query) and combine:effectiveFilter = request.Where ∧ shorthand. When shorthand short-circuits toIsEmpty, downstream steps that would do work for nothing are skipped. - Inject row-level filter via
IRowFilterInjector.InjectAsync(queryDef, ct)for the outer source and every reachable subquery that references a row-filtered source. The injector mutatesqueryDef.Wherein place and resolves the outer source internally fromqueryDef.From. Runs before@-resolution so injected references (e.g., a filter that AND-composesOwnerId = @me) resolve in the same pass as the caller's WHERE. Skipped when the shorthand parser short-circuited to an empty result OR when shorthand parsing failed (a partial WHERE would surface a row-filter throw as 500, masking the underlying 400).CoreExceptionfrom the injector (cyclical config, factory throw, unregistered subquery source) propagates to the endpoint filter, which maps it to 500/404 withex.Contextpreserved as ProblemDetails extensions. - Materialize per-request features via
IFunctionExecutionFeatureCollector.CollectAsync(ct)(invoked unconditionally, once per request). The features feed@-resolution in step 9 — they are collected here so the row filter's injected references are covered without re-running providers. - Validate and resolve ordering, materializing the default ordering when the caller supplied none. For a non-grouped query, order fields are validated against the source schema; for a grouped query (
groupBypresent), they are validated against the grouped projection's sortable output keys — the GROUP BY key and top-levelcountaliases classified by the sharedGroupedSelectClassifier— so a groupedorderByoncountvalidates and the aggregate-functionordering form (silently ignored after GROUP BY) is rejected.groupBy/selectare validated for their own correctness in steps 6–7; here they only scope what "sortable" means. - Validate groupBy, ensuring at most one grouping field is supplied (multiple fields fail with
InvalidQueryStructure) and that every grouping field is exposed. This runs before select so the grouped-select walk can rely on the group-by field resolving. A request withgroupBybut noselectis rejected withRequiredFieldMissing— grouped queries require an explicit projection. - Validate select, dispatching on grouping state:
- Grouped (
groupBypresent): the projection is checked against the grouped-select contract viaGroupedSelectClassifier(the SSOT shared with the EF aggregation builder). The group key must be projected (by its canonical name or any alias) and every other entry must be an aggregate (count, or per-columnsum/avg/average/min/max). Bare non-grouped columns, unsupported aggregates, excluded leaves, and a select that omits the group key entirely are rejected with coded errors. The grouped-select walk only runs for a single, resolved group-by field — a multi-field or unresolvedgroupByreports its own error without piling on spurious shape errors. - Non-grouped: a stray top-level aggregate keyword that does not resolve to a real column is rejected with
InvalidQueryStructure(notFieldNotFound); otherwise the projection routes through the nested-selection validator, ensuring every projected field is exposed and the caller can read it.
- Grouped (
- Apply column authorization — walks every column reference (including those introduced by the row filter and those reached through subqueries) and enforces per-column permissions. Row-filter field references are checked here as defense in depth. Also materializes the auth-aware default selection when the caller supplied no explicit SELECT/GROUP BY.
- Reject
@in expression operands, then resolve@references over the fully assembled graph. Resolution is deferred to here — after column authorization — so it sees the final graph (WHERE + injected row filter + ORDER BY + SELECT + GROUP BY + the auth-aware default selection); resolving at the WHERE-only stage would silently never resolve a projection-only or ordering-only@me. Resolution recurses into every filter-bearing clause (WHERE/HAVING, nestedSelectionConfig.Where, ordering function filters, CASE-WHEN conditions, subqueries) via the sharedQueryDefinitionWalker. Two guards run first: (a) an@placed in an expression operand (CASE/COALESCE/CAST/arithmeticExpressionDefinition.Value) or a function-argument value (FunctionArgument.Value) — slots the resolver never rewrites — is rejected with anInvalidFormaterror rather than leaking the literal"@…"to the provider; (b) resolution is gated onerrors.Count == 0, so a query already known invalid is never resolved into a partially-@-rewritten graph, and resolver-time failures (e.g.@mewith noUserContextFeature) surface as a 400wherevalidation error rather than a 5xx. The operand reject is request (untrusted-input) validation only; trusted in-process callers that build aQueryDefinitiondirectly are not guarded.
The single-field-groupBy and groupBy ⇒ select rules are endpoint-only guards; validation is strategy-blind by design. A direct QueryDefinition/EF caller that bypasses QueryRequestValidator is not subject to them — the EF strategy uses the first group-by field and the default-selection path.
BulkLookupService and DistinctValuesService build their own QueryDefinition instances and bypass the validator. They invoke IRowFilterInjector.InjectAsync themselves before strategy dispatch:
- BulkLookupService invokes the injector once per request on a skeleton query (no WHERE, no SELECT) and AND-composes the resulting predicate onto each batch's caller-built WHERE. The composed row filter is also AND-composed into the representative WHERE that drives column authorization, so a row filter referencing a restricted column denies the request rather than silently bypassing column auth — matching the validator's inject-then-auth ordering. The skeleton trick keeps the injector's contract intact: any row filter that itself contains subqueries against other row-filtered sources is fully composed during the single invocation, so the per-batch work is just an
AND. - DistinctValuesService also runs the injector once on a skeleton query, AND-composes the result into the auth query for column-auth validation, and AND-composes the same predicate into the synthetic DISTINCT query before strategy dispatch. The factory runs exactly once per request even when the enum-reflection path is taken.
Both services share the same IFunctionExecutionFeatureCollector, so @me and other contextual @ functions resolve consistently with the main /query pipeline.
QueryExceptionEndpointFilter is the single source of truth for exception → HTTP response mapping across all query builder endpoints. It maps CoreException(ResourceNotFound) to 404 and other CoreException instances to 500 (preserving ex.Context — notably sourceName from row-filter injector faults — as ProblemDetails extensions). See the Error Responses table for the full mapping.
Column-Level Authorization
In addition to source-level checks, a column-authorization pipeline runs after request validation. It walks every column reference produced by the request — including those reached through subqueries, nested Where, OrderBy, Select, GroupBy, and Having clauses — and enforces the schema's per-column policies (configured via RequireColumnPermission(c => c.PersonnelNumber, "read:pii") in the schema registry; see QueryBuilder.SchemaRegistry/README.md).
The default IColumnAuthorizationService registration is a permit-all OpenColumnAuthorizationService — it never denies and reports no restricted paths, so the column-auth pipeline is a no-op until UseMetadataSourceAuthorization(...) swaps in MetadataColumnAuthorizationService. Consumers therefore never need to null-check the dependency or special-case "auth disabled" code paths.
Default-selection auto-skip
When a request supplies no Select and no GroupBy, the validator builds the default projection from the source's columns and omits any column the user lacks access to. The resulting query succeeds (200 OK) and returns just the columns the caller is allowed to see.
When the request supplies an explicit Select referencing a restricted column, the request is rejected with 403 — the caller asked for something they cannot have, so the rejection is explicit.
Column denial response
A column-level denial returns HTTP 403 with this RFC 9457 problem details shape:
{
"type": "https://www.rfc-editor.org/rfc/rfc9457",
"title": "Column access denied",
"status": 403,
"detail": "Access to column 'PersonnelNumber' on source 'coworkers' requires permission 'read:pii'.",
"errorCode": "column_permission_denied",
"sourceName": "coworkers",
"columnPath": "PersonnelNumber",
"permission": "read:pii",
"traceId": "0HN7Q4E8M63KJ:00000001"
}
Fields:
errorCode: "column_permission_denied"— discriminator for client-side handlingsourceName— the data source whose column triggered the denial. May differ from the outerFromsource when the denial originates inside a subquerycolumnPath— canonical dotted path to the restricted column (e.g.,"Address.Zip"). When an ancestor is restricted, this is the ancestor path, not a deeper descendantpermission— the permission key the user lacked
Subquery source denial
A subquery referencing a foreign data source the caller cannot access at the source level fails with 403 and a different errorCode:
{
"type": "https://www.rfc-editor.org/rfc/rfc9457",
"title": "Source access denied",
"status": 403,
"errorCode": "source_permission_denied",
"sourceName": "orders",
"traceId": "0HN7Q4E8M63KJ:00000001"
}
The source-level endpoint filter only checks the outer From source; foreign sources reachable via Subquery are independently authorized inside the validator. A bypassed outer-source filter (one that lets a request through despite source policy denying it) is surfaced as a 500 QueryAuthorizationConfigurationException whose diagnostic message survives in the response body, because the only path to that state is a configuration bug.
Discovery isRestricted flag
GET /sources/{name}/columns includes an isRestricted boolean on every column. When true:
- Type-shape facts (
dataType,isNullable,isCollection,isEnum,isPrimaryKey,displayName,collectionElementType) are preserved so UI builders can still render placeholders of the correct shape. aliases,metadata, andchildrenare suppressed (empty/null) — block-cascade defense so alternate paths and consumer hints are not leaked through a denied ancestor.
A query that filters or projects by a restricted column is rejected with the column denial response above. A query that does NOT touch any restricted column proceeds normally even if the schema declares restricted columns elsewhere.
IUserAccessor for non-HTTP consumers
Column authorization and row-level filters use IUserAccessor (registered by default) to read the current ClaimsPrincipal. AddQueryBuilderEndpoints() registers HttpContextUserAccessor (which pulls the principal from IHttpContextAccessor) and removes any AnonymousUserAccessor fallback that an earlier AddQueryBuilderEntityFramework() registered, so the HTTP-aware accessor wins regardless of helper order. A consumer that registers its own IUserAccessor keeps it — only the anonymous fallback is removed, never a custom accessor. Background jobs, admin tools, or other non-HTTP consumers that invoke IQueryExecutionService directly should register a custom IUserAccessor so a deterministic principal flows through column auth and row filters.
services.AddScoped<IUserAccessor, BackgroundJobUserAccessor>();
When no principal is available, the default behavior is to use an anonymous ClaimsPrincipal(). This typically denies any restricted column with 403.
Authorization
MapQueryBuilderEndpoints() returns IEndpointConventionBuilder, enabling standard ASP.NET Core authorization chaining.
Group-Level Authorization
Secure all UQB endpoints with a single call:
app.MapQueryBuilderEndpoints().RequireAuthorization();
Per-Source and Endpoint-Group Authorization
Use the configurator overload when you need different authorization behavior for:
- specific query sources
- unconfigured query sources (fallback)
- schema registry endpoints
- query validation endpoint
app.MapQueryBuilderEndpoints(
endpoints =>
{
endpoints.RequireQueryPolicyForSource("coworkers", "coworkers:read");
endpoints.RequireQueryPolicyForSource("devices", "devices:read");
endpoints.RequireDefaultQueryPolicy("query:read"); // Fallback for unconfigured sources
endpoints.ConfigureRegistryEndpoints(e => e.RequireAuthorization("schema:read"));
endpoints.ConfigureValidationEndpoint(e => e.RequireAuthorization());
},
options =>
{
// Optional: override DI-registered options for this mapping
});
What each configurator method does
| Method | Applies To | Typical Use |
|---|---|---|
RequireQueryPolicyForSource(sourceName, policyName) |
Query endpoints for one source: POST {RoutePrefix}/{sourceName}, POST {RoutePrefix}/{sourceName}/bulk/by-key, and POST {RoutePrefix}/{sourceName}/{id} |
Require a source-specific policy such as "devices:read" |
RequireDefaultQueryPolicy(policyName) |
Query endpoints for sources not explicitly mapped with RequireQueryPolicyForSource |
Provide a safe fallback, such as "query:read" |
ConfigureRegistryEndpoints(configure) |
Registry group endpoints under {RegistryRoutePrefix} |
Apply endpoint conventions like .RequireAuthorization("schema:read") |
ConfigureValidationEndpoint(configure) |
Validation endpoint POST {RoutePrefix}/validate |
Require auth for query syntax validation |
Query policy resolution order
For query execution endpoints, policy selection is:
- Source-specific policy from
RequireQueryPolicyForSource(...) - Fallback from
RequireDefaultQueryPolicy(...) - No configurator policy (group/global authorization may still apply if configured elsewhere)
sourceName matching for RequireQueryPolicyForSource(...) is case-insensitive. Calling RequireQueryPolicyForSource(...) or RequireDefaultQueryPolicy(...) multiple times for the same key overwrites the previous value (last-wins).
When to use each method
- Use
RequireQueryPolicyForSource(...)when different domains need different policies. - Use
RequireDefaultQueryPolicy(...)when most sources share one baseline policy. - Use
ConfigureRegistryEndpoints(...)when schema discovery should be locked behind separate permissions. - Use
ConfigureValidationEndpoint(...)when query syntax checks should not be publicly callable.
ConfigureRegistryEndpoints(...) and ConfigureValidationEndpoint(...) are composable. Calling them multiple times chains conventions in registration order.
Source Authorization Filtering
ISourceAuthorizationFilter provides business-logic authorization for source discovery and query access:
public interface ISourceAuthorizationFilter
{
ValueTask<bool> CanDiscoverAsync(string sourceName, ClaimsPrincipal user, CancellationToken cancellationToken);
ValueTask<bool> CanQueryAsync(string sourceName, ClaimsPrincipal user, CancellationToken cancellationToken);
}
Where each method is enforced
| Method | Enforced On | Behavior |
|---|---|---|
CanDiscoverAsync |
Registry endpoints (/sources, /sources/{sourceName}, /sources/{sourceName}/columns/*, /summary) |
Effective discovery = CanDiscoverAsync \|\| CanQueryAsync. List/summary endpoints filter results; single-source endpoints return 404 |
CanQueryAsync |
Query endpoints (/{sourceName}, /{sourceName}/bulk/by-key, /{sourceName}/{id}) |
Returns 403 if user can discover the source; 404 otherwise |
CanQuery implies CanDiscover
The framework enforces that CanQueryAsync returning true implicitly grants discovery access. Implementors of ISourceAuthorizationFilter do not need to ensure consistency between the two methods — the effective discoverability check is CanDiscoverAsync || CanQueryAsync. This means a source is visible in registry endpoints whenever either method returns true.
CanDiscoverAsync |
CanQueryAsync |
Registry | Query |
|---|---|---|---|
| true | true | Visible | Allowed |
| true | false | Visible | 403 Forbidden |
| false | true | Visible (implicit) | Allowed |
| false | false | Hidden / 404 | 404 Not Found |
Dual-layer authorization on query endpoints
Query endpoints evaluate authorization in two sequential layers. Both must pass:
- ASP.NET Core policy evaluation — If a policy is configured for the source (via
RequireQueryPolicyForSourceorRequireDefaultQueryPolicy), that policy is evaluated usingIPolicyEvaluator. TheHttpContextis passed as the authorization resource, enabling resource-based policy handlers. - Business-logic authorization —
CanQueryAsyncis always called, regardless of whether a policy is configured. This provides a hook for custom domain-specific access control (e.g., tenant isolation, row-level security decisions).
If no configurator is provided or no policy maps to the source, only CanQueryAsync is evaluated. Authorization failures return 403 Forbidden when the user can effectively discover the source (via CanDiscoverAsync or implied by CanQueryAsync). Authorization failures return 404 Not Found when the source is unknown or the user cannot discover it, preventing source enumeration.
Implementing a custom filter
public class TenantSourceAuthorizationFilter(ITenantContext tenantContext) : ISourceAuthorizationFilter
{
public async ValueTask<bool> CanDiscoverAsync(
string sourceName, ClaimsPrincipal user, CancellationToken cancellationToken)
{
return await tenantContext.CanAccessSourceAsync(sourceName, cancellationToken);
}
public async ValueTask<bool> CanQueryAsync(
string sourceName, ClaimsPrincipal user, CancellationToken cancellationToken)
{
return await tenantContext.CanAccessSourceAsync(sourceName, cancellationToken);
}
}
Register a custom implementation before AddQueryBuilderEndpoints():
services.AddScoped<ISourceAuthorizationFilter, TenantSourceAuthorizationFilter>();
services.AddQueryBuilderEndpoints(); // TryAddScoped won't overwrite your registration
The default AllowAllSourceAuthorizationFilter returns true for both methods on all sources.
Metadata-Based Source Authorization
UseMetadataSourceAuthorization(...) on QueryBuilderEndpointsOptions replaces the default AllowAllSourceAuthorizationFilter with MetadataSourceAuthorizationFilter, which reads access policies from DataSourceDefinition.AccessPolicy at runtime.
Registration
// Basic: uses access policies from DataSourceBuilder without permission evaluation
services.AddQueryBuilderEndpoints(options =>
{
options.UseMetadataSourceAuthorization();
});
// With permission evaluator: enables RequirePermission policies
services.AddQueryBuilderEndpoints(options =>
{
options.UseMetadataSourceAuthorization<MyPermissionEvaluator>();
});
// With options
services.AddQueryBuilderEndpoints(options =>
{
options.UseMetadataSourceAuthorization(auth =>
{
auth.UnconfiguredSourceBehavior = UnconfiguredSourceBehavior.AllowAuthenticated;
auth.ValidationMode = AuthorizationValidationMode.FailFast;
});
});
If a custom ISourceAuthorizationFilter or IColumnAuthorizationService is already registered, UseMetadataSourceAuthorization(...) throws InvalidOperationException by default. Pass overrideExistingFilter: true to replace either.
Access policy evaluation
The filter evaluates DataSourceDefinition.AccessPolicy in order:
- Unknown source (not in schema registry) — deny
- Unconfigured (
AccessPolicy.IsExplicit == false) — fallback toUnconfiguredSourceBehavioroption - Explicit policies — matched by
AccessRequirementtype:
DataSourceBuilder method |
AccessRequirement |
Behavior |
|---|---|---|
| (none — default) | Deny (non-explicit) |
Governed by UnconfiguredSourceBehavior |
DenyAccess() |
Deny (explicit) |
Always denied, ignores fallback |
RequireAuthenticated() |
Authenticated |
Requires user.Identity.IsAuthenticated |
RequirePermission("key") |
PermissionRequired |
Delegates to IPermissionEvaluator |
Implementing IPermissionEvaluator
Sources configured with RequirePermission() delegate to your IPermissionEvaluator:
public class MyPermissionEvaluator(IMyAuthService authService) : IPermissionEvaluator
{
public async ValueTask<bool> HasPermissionAsync(
PermissionEvaluationContext context,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
// context.SourceName — the source being accessed
// context.Permission — the permission key (e.g., "devices:read")
// context.Operation — Discover or Query
return await authService.HasPermissionAsync(
user, context.Permission, cancellationToken);
}
}
If no IPermissionEvaluator is registered and a source requires a permission, access is denied (fail-closed) and a rate-limited warning is logged.
MetadataAuthorizationOptions
| Property | Type | Default | Description |
|---|---|---|---|
UnconfiguredSourceBehavior |
UnconfiguredSourceBehavior |
Deny |
How to handle sources without an explicit AccessPolicy. Deny blocks all access; AllowAuthenticated permits authenticated users. |
ValidationMode |
AuthorizationValidationMode |
Warn |
Startup validation behavior. None disables checks; Warn logs issues; FailFast throws on misconfiguration. |
Startup validation
MetadataAuthorizationValidator runs as an IHostedService at startup (unless ValidationMode is None). It creates a DI scope to resolve ISchemaRegistry before running checks. It checks:
- Missing evaluator — Sources or columns require permissions but no
IPermissionEvaluatoris registered - Fallback in use —
UnconfiguredSourceBehavioris notDeny(logs a warning as a reminder) - Unconfigured sources — Sources without explicit access policies exist
In FailFast mode, condition 1 or 3 throws InvalidOperationException to prevent startup.
QueryableBuilder Extensions
The QueryBuilder.Endpoints.Extensions namespace provides an extension method on IQueryableBuilder that accepts an ExecuteQueryRequest directly, applying the same filter logic as the API endpoint:
using QueryBuilder.Endpoints.Extensions;
var queryable = await queryableBuilder.BuildQueryableAsync<Order>(
"orders", request, cancellationToken);
Behavior
The extension method maps ExecuteQueryRequest fields to IQueryableBuilder parameters:
| Request Field | Mapped To |
|---|---|
Query |
Shorthand filter |
Where |
Structured filter |
OrderBy |
Ordering definitions |
The structured filter (Where) is validated before being passed to the builder, using the same validation as the API endpoint.
Fields intentionally ignored: Select, GroupBy, Limit, Offset, IncludeTotalCount, IncludeDebug. The caller handles those downstream on the returned IQueryable<T>.
Exception Handling
BuildQueryableAsync throws QueryValidationException and other query builder exceptions when input is invalid. The built-in query endpoints catch and map these to ProblemDetails responses. Custom endpoints using BuildQueryableAsync should add the QueryExceptionEndpointFilter to get the same behavior:
using QueryBuilder.Endpoints.Filters;
group.MapPost("/summary", GetSummary)
.AddEndpointFilter<QueryExceptionEndpointFilter>();
The filter maps:
QueryValidationException→ 400 ValidationProblemValidationException→ 400 ValidationProblemDataSourceNotFoundException→ 404 Not FoundNoExecutionStrategyException→ 400 Bad RequestQueryExecutionException→ 400 Bad Request
Use Case
Apply the same filters as the API endpoint, then perform custom domain operations on the queryable:
public class OrderAnalyticsService(IQueryableBuilder queryableBuilder)
{
public async Task<List<RegionRevenue>> GetRevenueByRegionAsync(
ExecuteQueryRequest request, CancellationToken ct)
{
var queryable = await queryableBuilder.BuildQueryableAsync<Order>(
"orders", request, ct);
return await queryable
.GroupBy(o => o.Region)
.Select(g => new RegionRevenue
{
Region = g.Key,
Total = g.Sum(o => o.Total),
Count = g.Count()
})
.ToListAsync(ct);
}
}
Service Interfaces
The package provides nine service interfaces that can be overridden:
IQueryExecutionService- Query execution against registered data sourcesIRecordLookupService- Single record retrieval by primary keyIBulkLookupService- Bulk key lookup with batching and optional NDJSON streamingIQueryValidationService- Shorthand query syntax validationISchemaIntrospectionService- Schema registry metadata retrievalIDistinctValuesService- Distinct column value retrieval (enum reflection or synthetic query)ISourceAuthorizationFilter- Controls source discoverability and query accessIColumnAuthorizationService- Column-level permission checks (default permit-allOpenColumnAuthorizationService; metadata-backed implementation registered byUseMetadataSourceAuthorization)IUserAccessor- Resolves the currentClaimsPrincipalfor auth checks (defaultHttpContextUserAccessor; replace for non-HTTP scopes)
All services are registered with TryAddScoped, allowing consumer overrides when registered before AddQueryBuilderEndpoints().
Unscoped (in-process) execution — ExecuteUnscopedAsync
IQueryExecutionService.ExecuteUnscopedAsync runs the same pipeline as ExecuteAsync but as the
system, for in-process callers that run queries on the system's behalf rather than an end user's. HTTP
endpoint code always uses ExecuteAsync.
It skips: source-level authorization, column-level authorization, and all row-level filter
injection — including principal-independent filters such as soft-delete or tenant scoping. A query run
this way therefore returns rows an HTTP caller would never see. The caller owns any scoping it still
wants and must add it to the request Where; the injected filters (delegate RowFilterFactory or
typed IRowFilter) are opaque to inspection, so there is no API to enumerate "what would have been
applied." Do not point
ExecuteUnscopedAsync at a source whose row filter encodes data partitioning (e.g. tenant isolation)
unless you re-add that predicate yourself — otherwise a system query crosses the partition.
It keeps everything else: shorthand parsing, structured-filter validation, ordering/select/group-by
validation, @-function resolution, and unregistered-source detection (an unregistered source — outer
or subquery — still throws DataSourceNotFoundException). The full default selection is returned (no
restricted-column omission).
@me and other principal-dependent functions resolve against whatever the host's feature providers
supply for the current (typically anonymous) principal; with no UserContextFeature they surface a
validation error.
// The service is scoped. An in-process caller with no HttpContext (e.g. a hosted/background
// service) creates its own scope; the resolved principal is anonymous, which is fine because
// ExecuteUnscopedAsync skips principal-dependent authorization. (A caller-supplied `@me` in the
// request still has no UserContextFeature to resolve against and surfaces a validation error.)
using var scope = serviceScopeFactory.CreateScope();
var queryExecutionService = scope.ServiceProvider.GetRequiredService<IQueryExecutionService>();
var results = await queryExecutionService.ExecuteUnscopedAsync("orders", request, ct);
Prerequisites
Requires QueryBuilder core services registered via:
builder.Services.AddQueryBuilder();
See QueryBuilder.Extensions for core service registration options.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- UniversalQueryBuilder.Core (>= 10.0.13-beta)
- UniversalQueryBuilder.EntityFramework (>= 10.0.13-beta)
- UniversalQueryBuilder.SchemaRegistry (>= 10.0.13-beta)
- UniversalQueryBuilder.Shorthand (>= 10.0.13-beta)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 10.0.13-beta | 38 | 6/3/2026 |
| 10.0.12-beta | 48 | 6/1/2026 |
| 10.0.11-beta | 46 | 5/31/2026 |
| 10.0.10-beta | 51 | 5/28/2026 |
| 10.0.9-beta | 49 | 5/27/2026 |
| 10.0.8-beta | 57 | 5/18/2026 |
| 10.0.7-beta | 53 | 5/16/2026 |
| 10.0.6-beta | 53 | 5/11/2026 |
| 10.0.5-beta | 52 | 4/30/2026 |
| 10.0.4-beta | 54 | 4/23/2026 |
| 10.0.3-beta | 74 | 4/23/2026 |
| 10.0.2-beta | 61 | 4/10/2026 |
| 10.0.1-beta | 55 | 4/10/2026 |
| 10.0.0-beta | 57 | 4/9/2026 |