Skip to content

Recipes

AmirHosseinMp02 edited this page Aug 1, 2026 · 1 revision

Recipes

Working patterns for the things people actually build with QueryForge.


Data grids

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 CriteriaAnd / 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

DevExtreme dxDataGrid

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.

AG Grid

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.

Kendo UI

fieldcolumnName, dirsortOrder, and Kendo's filter.logic maps directly onto Logic.


Tenant isolation

Never rely on the client sending a tenant filter. Compose the restriction server-side.

EF Core — a Where before the query

await db.Users
    .Where(u => u.TenantId == currentTenant)   // survives everything the client sends
    .AsNoTracking()
    .ToQueryResultAsync<User>(query);

EF Core — a global query filter

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.

Dapper — scope the target

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: SilentStrip may remove it, and an Or-joined group would widen rather than narrow the result.


A search box

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)
    ]);

Contains produces a leading-wildcard LIKE, 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.


Exports

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.


Dashboards

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.


Drill-down

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.


Reusable validation policies

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);

An endpoint filter that validates automatically

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));

Multi-database applications

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.


Caching an expensive query

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.


Falling back to in-memory for small reference data

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.


Logging what callers actually ask for

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);

Clone this wiki locally