Skip to content

Query composers

Two extension methods that build the query the engine would run and hand it back unexecuted. Everything else in this library composes onto your IQueryable and then executes in the same call; these stop one step short, so you can look at the query, run it yourself, or reuse the matching set for something that is not a page of rows.

csharp
PaginateComposedQuery<TEntity> ApplyPaginateFilters<TEntity>(
    this IQueryable<TEntity> source, PaginateQuery request, PaginateConfig<TEntity> config);

PaginateComposedQuery<TEntity> ApplyPagination<TEntity>(
    this IQueryable<TEntity> source, PaginateQuery request, PaginateConfig<TEntity> config);

Both are in the core package (Janzen.Pagination.EntityFrameworkCore), and both carry [RequiresUnreferencedCode] / [RequiresDynamicCode] like the four entry points — the engine builds expression trees either way.

The composed query is the executed query

All six paths — the four Paginate*Async entry points and these two composers — compose through the same internal step, so a stage added on one is added on all of them. The library's own suite pins that: it captures the command the engine runs for PaginateMapAsync and compares it against ApplyPagination(...).Query.ToQueryString(), modulo whitespace and ToQueryString's .param set preamble.

Read that for what it is. PaginateMapAsync is the one entry point that adds no SQL-side projection, which is exactly why it is the one the assertion can make; the other three replace the SELECT list with their projection, so their executed statement is not what ApplyPagination prints and comparing the two will mislead you. What is shared is everything before the projection — the filters, the search, the order, the LIMIT/OFFSET.

ApplyPaginateFilters — the matching set

Filters and search applied; no ordering, no Skip/Take, no count, no projection. What you get back is "every row this request matches", which is the input to anything computed over the match set rather than over the page:

csharp
var matching = db.Products.ApplyPaginateFilters(request, config).Query;

var facets = await matching
    .GroupBy(p => p.Status)
    .Select(g => new { Status = g.Key, Count = g.Count() })
    .ToDictionaryAsync(r => r.Status, r => r.Count, ct);

var revenue = await matching.SumAsync(p => p.Price, ct);

Before this existed the only way to do that was to translate the filters a second time by hand, in the consumer, and keep the two copies in step.

ApplyPagination — the page query

Filters, search, ordering (tie-breaker included) and Skip/Take. No count is issued, no projection is added, and — unlike PaginateAsync — a page past the last row is not short-circuited to an empty result: the composer describes what would run, it does not optimize it away.

csharp
var composed = db.Products.ApplyPagination(request, config);

string sql = composed.Query.ToQueryString();                       // diagnostics, or an assertion in a test
var rows   = await composed.Query.Select(Dto.From).ToListAsync(ct); // your own execution and envelope

PaginateComposedQuery<TEntity>

Both composers return it, and every member is resolved on both. The only difference is Query — the page query from one, the matching set from the other.

MemberTypeWhat it holds
QueryIQueryable<TEntity>The composed query, unexecuted.
PageintThe 1-based page requested. Not clamped.
LimitintThe effective page size: the requested limit, or the config's DefaultLimit.
SortByIReadOnlyList<string>The effective order in "field:DIR" form, tie-breaker excluded. [] when the request asked for none and the config declares no DefaultSortBy.
Searchstring?The search term that ran, or null.
SearchByIReadOnlyList<string>The effective fields it ran over. [] when no search ran.
FilterIReadOnlyDictionary<string, IReadOnlyList<string>>The request's filters, verbatim per field.

These are the same values that reach meta on the normal path, from the same resolution — so a caller building its own envelope reports the effective request without re-deriving it. It is a class rather than a record: value equality over an IQueryable and three collections would compare by reference and answer a question it cannot actually answer.

SortBy was nullable before 10.1.0

ApplyPaginateFilters used to leave it null, because resolving the sort could refuse a configuration that had nothing to order by — and rejecting a facet count over a request that never wanted an order would have been wrong. Requiring WithTieBreaker at build time removed that refusal, so the filtered composer now resolves and validates sortBy like every other stage and reports the ordering that would apply. [] means the request asked for none and the config declares no DefaultSortBy; the tie-breaker orders the query either way and is never listed.

What each one validates

Both reject exactly what PaginateAsync rejects, at compose time instead of execute time, with the same messages — see Errors.

CheckedApplyPaginateFiltersApplyPagination
page, limit rangeyesyes
Unknown / disallowed filter field and operator, filter guardsyesyes
search length, unknown or repeated searchBy fieldyesyes
Unknown sortBy field, sortBy grammar, MaxSortFieldsyesyes

Since 10.1.0 there is no difference: both validate everything, with the same messages.

The one exception used to be sortBy, left unchecked by the filtered composer because resolving the sort could refuse a configuration that had nothing to order by — and a config you can only ever count, never page, was a legitimate thing to build. WithTieBreaker is required now, so no such configuration exists, the refusal is gone, and validating sortBy costs a caller nothing while stopping the two composers from disagreeing about what a valid request is.

Asserting SQL in your own tests

The composers make the emitted SQL a thing a consumer test can assert on, without a database and without reading logs:

csharp
[Fact]
public void Filtering_by_status_uses_the_index_column() {

    var request = new PaginateQuery { Filters = new Dictionary<string, IReadOnlyList<string>> {
        ["status"] = ["$eq:Active"]
    } };

    string sql = _db.Products.ApplyPagination(request, ProductConfig.Instance).Query.ToQueryString();

    Assert.Contains("\"Status\" = ", sql);
    Assert.Contains("LIMIT", sql);

}

ToQueryString() needs a real provider (it is what turns the expression tree into SQL), but not a reachable server — an unopened connection string is enough. Over a plain List<T>.AsQueryable() the composers still work and still return a usable IQueryable; there is simply no SQL to print.

Not covered

  • No count. Neither composer issues one, so neither can tell you totalItems. Ask the matching set yourself: await db.Products.ApplyPaginateFilters(request, config).Query.CountAsync(ct).

  • No row ceiling for limit=-1. On a resource with AllowUnlimited(maxRows), ApplyPagination composes the query bounded at maxRows + 1 — exactly what PaginateAsync fetches, so that "at the ceiling" and "over it" can be told apart. Applying the ceiling is the part only execution can do, so the caller executing the composed query owns that check: expect the extra row, and refuse the read when it turns up. Limit comes back as -1 rather than a row count for the same reason — there are no items here to count.

  • No past-the-end short-circuit. PaginateAsync skips the page query entirely when the count says you are past the last row. ApplyPagination cannot know that without issuing a count of its own — which a method whose whole point is "do not touch the database yet" must not do — so an out-of-range page composes a real query that returns nothing.

    What that costs is easy to overestimate. OFFSET cannot skip rows that do not exist, so the work is bounded by the size of the matching set, not by how large the page number is: page 9999 over eight rows costs eight rows, same as page 4 does. It is only a real cost when the matching set is large — and then it is exactly the cost of legitimate deep paging into that same set, which Performance and indexing covers. If it matters for your traffic, count the matching set first and skip the fetch yourself; you need totalItems for the envelope anyway.

  • No envelope. PaginatedMeta and PaginatedLinks are built by the entry points; a caller composing by hand builds its own response shape, with PaginateComposedQuery supplying the effective request half.

Released under the MIT License.