-
Notifications
You must be signed in to change notification settings - Fork 0
Recipes
Working patterns for the things people actually build with QueryForge.
QueryForge is shaped to be the backend for server-side data grids. The grids send rich load options;
a thin frontend mapper turns them into a Query.
| Grid feature | QueryForge |
|---|---|
| Nested filter groups |
Criteria — And / Or / AndNot / OrNot, any depth |
| Multi-level grouping |
GroupByColumns, producing key / count / items trees |
| Server-side paging |
Paging, with accurate totals on rows or on top-level groups |
| Multi-column sorting |
SortColumns, independent direction per column |
| Column chooser | SelectColumns |
function toQuery(loadOptions: any): Query {
return {
criteria: { logic: 0, groups: [ { logic: 0, conditions: mapFilter(loadOptions.filter) } ] },
paging: {
size: loadOptions.take ?? 20,
number: Math.floor((loadOptions.skip ?? 0) / (loadOptions.take ?? 20)) + 1
},
sortColumns: (loadOptions.sort ?? []).map((s: any) => ({
columnName: s.selector,
sortOrder: s.desc ? 1 : 0
})),
groupByColumns: (loadOptions.group ?? []).map((g: any) => ({
columnName: g.selector,
sortOrder: g.desc ? 1 : 0
}))
};
}DevExtreme's group response shape — key, count, items — is already what HierarchyNode
produces, so the response usually needs no mapping at all.
getRows gives startRow/endRow, sortModel, filterModel and rowGroupCols:
const size = request.endRow - request.startRow;
const query: Query = {
paging: { size, number: Math.floor(request.startRow / size) + 1 },
sortColumns: request.sortModel.map(s => ({ columnName: s.colId, sortOrder: s.sort === 'desc' ? 1 : 0 })),
groupByColumns: request.rowGroupCols.map(g => ({ columnName: g.id, sortOrder: 0 })),
criteria: { logic: 0, groups: [ { logic: 0, conditions: mapFilterModel(request.filterModel) } ] }
};For AG Grid's lazy group expansion, send one grouping level at a time and add a condition pinning the already-expanded keys — rather than sending all levels and discarding what you do not show.
field → columnName, dir → sortOrder, and Kendo's filter.logic maps directly onto Logic.
Never rely on the client sending a tenant filter. Compose the restriction server-side.
await db.Users
.Where(u => u.TenantId == currentTenant) // survives everything the client sends
.AsNoTracking()
.ToQueryResultAsync<User>(query);modelBuilder.Entity<User>().HasQueryFilter(u => u.TenantId == _tenantProvider.Current);Applied by EF Core to every query, including QueryForge's, with no code at the call site.
The target is the boundary. Point at a view or table-valued function that is already scoped:
.ForObject("tvf_UsersForTenant", "dbo", DapperObjectType.TVF,
new Dictionary<string, object?> { ["TenantId"] = currentTenant })A criteria-based tenant filter is not equivalent:
SilentStripmay remove it, and anOr-joined group would widen rather than narrow the result.
One term across several columns is an Or group:
static QueryCriteria Search(string term) => new(
[
new ConditionGroup(
logic: Logic.Or,
conditions:
[
new Condition("FirstName", ConditionOperator.Contains, term),
new Condition("LastName", ConditionOperator.Contains, term),
new Condition("Email", ConditionOperator.Contains, term)
])
]);Wildcards inside term are escaped, so a user typing 50% searches for that literal text.
To combine a search box with facet filters, use two groups joined by And:
var criteria = new QueryCriteria(
logic: Logic.And,
groups:
[
new ConditionGroup(logic: Logic.Or, conditions: searchConditions),
new ConditionGroup(logic: Logic.And, conditions: facetConditions)
]);
Containsproduces a leading-wildcardLIKE, which cannot use a normal index. For a real search box over a large table, use your database's full-text search and apply QueryForge to the result set — for example through a view.
Paging exists to protect the server, so an export needs a deliberate, bounded exception.
app.MapPost("/api/users/export", async (Query query, IDapperQueryService svc) =>
{
query.Validate(rules => rules.PageSize(p => p.Max(50_000)), QueryValidationMode.SilentStrip);
var dq = DapperQueryBuilder.FromBase(query)
.Page(50_000, 1) // one big page, chosen by the server
.ForObject("Users", "dbo")
.Build();
var result = await svc.QueryAsync<User>(dq, commandTimeout: 300);
return Results.File(ToCsv(result.Models), "text/csv", "users.csv");
});For genuinely large exports, loop pages rather than raising the cap:
var page = 1;
while (true)
{
var dq = DapperQueryBuilder.FromBase(query)
.Sort(new SortDescriptor("UserId")) // a unique sort — required for stable paging
.Page(5_000, page++)
.ForObject("Users", "dbo")
.Build();
var result = await svc.QueryAsync<User>(dq);
if (result.Models.Count == 0) break;
await WriteAsync(result.Models);
}The unique sort is not optional — without it, rows can repeat or be skipped between pages.
A grouped query is a dashboard in one call:
var dashboard = QueryBuilder.New()
.Where(new QueryCriteria([ new ConditionGroup([
new Condition("PlacedOn", ConditionOperator.Between, from, to) ]) ]))
.GroupBy(new GroupByDescriptor("Country"), new GroupByDescriptor("Status"))
.Sort(new SortDescriptor("Amount", SortOrder.Descending))
.Page(size: 10, number: 1)
.Build();Ten countries, each broken down by status, with a truthful row count at every node — and the top orders inside each. Filter tightly first; see the note on why every row comes back.
Send the grouped query for the overview, then a flat query with the drilled key pinned:
// Level 1 — the overview
var overview = QueryBuilder.New()
.GroupBy(new GroupByDescriptor("Country"))
.Page(10, 1)
.Build();
// Level 2 — inside one country, flat and paged normally
var drill = QueryBuilder.New()
.Where(new QueryCriteria([ new ConditionGroup([
new Condition("Country", ConditionOperator.Equals, selectedCountry) ]) ]))
.Sort(new SortDescriptor("Amount", SortOrder.Descending))
.Page(25, 1)
.Build();This keeps each response bounded, which a single deep grouped query would not.
QueryValidationRules holds no per-request state, so build once and share:
public static class QueryPolicies
{
public static readonly QueryValidationRules PublicUsers = new QueryValidationRules()
.PageSize(p => p.Max(100))
.Select(c => c.Allow("UserId", "FirstName", "LastName", "Country", "Score"))
.Where(c => c.Allow("Country", "Department", "Score", "IsActive", "CreatedOn"))
.Sort(c => c.Allow("LastName", "Score", "CreatedOn"))
.GroupBy(c => c.Allow("Country", "Department"));
}
query.Validate(QueryPolicies.PublicUsers, QueryValidationMode.SilentStrip);Per-role:
var policy = user.IsInRole("Admin") ? QueryPolicies.Internal : QueryPolicies.PublicUsers;
var mode = user.IsInRole("Admin") ? QueryValidationMode.ThrowException : QueryValidationMode.SilentStrip;
query.Validate(policy, mode);public sealed class ValidateQueryFilter(QueryValidationRules rules) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
{
if (ctx.Arguments.OfType<Query>().FirstOrDefault() is { } query)
{
try
{
query.Validate(rules, QueryValidationMode.ThrowException);
}
catch (QueryValidationException ex)
{
return Results.ValidationProblem(
ex.InvalidProperties.Distinct().ToDictionary(p => p, p => new[] { "Not permitted" }));
}
}
return await next(ctx);
}
}
app.MapPost("/api/users/query", Handler)
.AddEndpointFilter(new ValidateQueryFilter(QueryPolicies.PublicUsers));One registration serves every engine; the connection decides:
builder.Services.AddQueryForgeDapper();
app.MapPost("/api/reports/{source}", async (string source, Query q, IDapperQueryService svc) =>
{
using IDbConnection conn = source switch
{
"warehouse" => new NpgsqlConnection(pgConnectionString),
"crm" => new SqlConnection(mssqlConnectionString),
_ => throw new ArgumentException("Unknown source")
};
var dq = DapperQueryBuilder.FromBase(q).ForObject("Reports").Build();
return await svc.QueryAsync<Report>(conn, dq);
});Note the switch — the caller sends a key, not a connection string or a table name.
Leave Schema empty in ForObject so each dialect supplies its own default, and the same call works
against both.
Because a Query serializes cleanly, it is its own cache key:
var key = $"users:{JsonSerializer.Serialize(query)}";
var result = await cache.GetOrCreateAsync(key, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
return await svc.QueryAsync<User>(dq);
});Validate before building the key, so two requests that differ only in a stripped column share a cache entry. Include the tenant or user identity in the key whenever the result is scoped to them.
public sealed class CountryQueries(IMemoryCache cache, IDapperQueryService svc)
{
public async Task<QueryResult<Country>> QueryAsync(Query query)
{
var all = await cache.GetOrCreateAsync("countries", async _ =>
{
var dq = DapperQueryBuilder.Page(10_000, 1).ForObject("Countries", "dbo").Build();
return (await svc.QueryAsync<Country>(dq)).Models;
});
return all!.ToQueryResult(query); // same contract, no round trip
}
}The consuming code cannot tell the difference, which is the point of the shared result contract.
app.MapPost("/api/users/query", async (Query query, IDapperQueryService svc, ILogger<Program> log) =>
{
log.LogInformation("QueryForge request {Query}", JsonSerializer.Serialize(query));
// …
});The Query object is more useful than the SQL when investigating usage or abuse — it is the caller's
intent, before anything was stripped.
To detect probing, run validation in ThrowException mode on a copy and log the result while still
serving the request in SilentStrip mode:
try { Clone(query).Validate(policy, QueryValidationMode.ThrowException); }
catch (QueryValidationException ex) { log.LogWarning("Stripped: {Props}", ex.InvalidProperties); }
query.Validate(policy, QueryValidationMode.SilentStrip);QueryForge · part of the PepperX Ecosystem · MIT licensed · packages 2.0.0, .NET 10
Foundations
- Getting Started
- Architecture
- Query Model
- Query Semantics
- Results and Metadata
- JSON Contract
- Fluent Builders
Behaviour
Providers
- Dapper Provider
- Dapper: Generated SQL
- Dapper: Dialects
- EF Core Provider
- EF Core: Joins & Includes
- In-Memory Provider
Practice
Reference