Skip to content

PagedQuery: Stateless Paginated Queries

PagedQuery<T> is a thread-safe, stateless paginated query executor. It is the server-side counterpart to PagedList: both use the same underlying query engine (automatic JOINs through FK traversal, per-facet filter/sort semantics, raw SQL expressions, fixed constraints, enum/date handling), but PagedQuery exposes it through an API designed for REST handlers, RPC methods, background workers — any context that is stateless per request.

Quick Start

Configure a PagedQuery once, typically at application startup — then share the instance across arbitrarily many concurrent requests.

import onl.ycode.stormify.biglist.Facet
import onl.ycode.stormify.biglist.PageSpec
import onl.ycode.stormify.biglist.PagedQuery
import onl.ycode.stormify.biglist.SortDir

// One-time setup (startup, DI container, etc.)
val customers = PagedQuery<Customer>().apply {
    addFacet("search", "name", "email", "city").isSortable = false   // search is filter-only
    addFacet("name", "name")                                         // filter + sort
    addFacet("city", "city")
    setConstraints("customer.tenant_id = ?", currentTenantId)
}

// Per request:
fun list(req: HttpRequest): HttpResponse {
    val page = customers.execute(
        PageSpec(
            filters = mapOf("search" to req.q),
            sorts = mapOf("name" to SortDir.ASC),
            page = req.page,
            pageSize = req.size,
        )
    )
    return HttpResponse.json(mapOf(
        "items" to page.rows.map { it.toDto() },
        "total" to page.total,
        "page" to page.page,
        "totalPages" to page.totalPages,
    ))
}
import onl.ycode.stormify.biglist.Facet;
import onl.ycode.stormify.biglist.PageSpec;
import onl.ycode.stormify.biglist.PagedQuery;
import onl.ycode.stormify.biglist.SortDir;

// One-time setup (startup, DI container, etc.)
PagedQuery<Customer> customers = new PagedQuery<>(Customer.class);
customers.addFacet("search", "name", "email", "city").setSortable(false);   // search is filter-only
customers.addFacet("name", "name");                                          // filter + sort
customers.addFacet("city", "city");
customers.setConstraints("customer.tenant_id = ?", currentTenantId);

// Per request:
public HttpResponse list(HttpRequest req) {
    Page<Customer> page = customers.execute(new PageSpec(
        req.page(), req.size(),
        Map.of("search", req.q()),
        Map.of("name", SortDir.ASC)
    ));
    return HttpResponse.json(Map.of(
        "items", page.getRows().stream().map(Customer::toDto).toList(),
        "total", page.getTotal(),
        "page", page.getPage(),
        "totalPages", page.getTotalPages()
    ));
}

Stormify instance

PagedQuery resolves its Stormify instance from the default instance on first access. If you run multiple Stormify instances in the same process, bind the query explicitly at configuration time with stormify.attach(query). With neither an attached nor a default instance, any data access throws a SQLException telling you to call stormify.attach(query) or Stormify.asDefault() first.

Aliases are the whole contract

Every facet in a PagedQuery has a string alias. The alias is the only identifier the outside world ever sees — it is the key in PageSpec.filters, PageSpec.sorts, PageSpec.caseSensitive, and the only name by which the client can reference the facet.

Aliases are passed explicitly when a facet is added:

query.addFacet("search", "name", "email", "city")
//              ^ alias   ^ fields the alias maps to (OR between them)
query.addFacet("search", "name", "email", "city");
//              ^ alias   ^ fields the alias maps to (OR between them)

Never use field paths or SQL expressions as aliases — that would defeat the whole point. Choose stable business-level names ("name", "total", "search") so the wire contract does not churn when the schema does.

Per-facet capability flags

Two flags on Facet control what a client is allowed to do with an alias:

query.addFacet("search", "name", "email").isSortable = false     // search is filter-only
query.addFacet("created", "createdAt").isFilterable = false      // sortable header, no filter input
query.addFacet("search", "name", "email").setSortable(false);    // search is filter-only
query.addFacet("created", "createdAt").setFilterable(false);     // sortable header, no filter input
  • A filter in a PageSpec against a facet whose isFilterable = false throws.
  • A sort against a facet whose isSortable = false throws.

Facets that are neither filterable nor sortable should simply not be registered — there is nothing to gain by exposing them.

The spec and the result

PageSpec and Page are plain data — no annotations, no companions, no custom type adapters — so they round-trip through any serializer (Kotlinx Serialization, Jackson, Gson, Parcelable, …).

data class PageSpec(
    val page: Int,
    val pageSize: Int,
    val filters: Map<String, String> = emptyMap(),
    val sorts: Map<String, SortDir> = emptyMap(),
    val caseSensitive: Map<String, Boolean> = emptyMap(),
) {
    // Non-paginated overload — delegates to PageSpec(0, 15, filters, sorts, caseSensitive).
    // Use for forEachStreaming / getAggregator where pagination is ignored anyway.
    constructor(
        filters: Map<String, String> = emptyMap(),
        sorts: Map<String, SortDir> = emptyMap(),
        caseSensitive: Map<String, Boolean> = emptyMap(),
    )
}

data class Page<T>(
    val rows: List<T>,
    val total: Long,
    val page: Int,
    val pageSize: Int,
) {
    val totalPages: Int
}

Both constructors are @JvmOverloads-annotated, so Java can pick whichever form matches its needs — new PageSpec(page, pageSize, filters, …) for paginated queries or new PageSpec(filters, …) for non-paginated use cases — without having to spell out the other defaults.

Aliases not present in the configured facet set are silently dropped from filters, sorts, and caseSensitive. A sort whose value is neither SortDir.ASC nor SortDir.DESC cannot be constructed (the enum prevents it).

An incoming JSON request body matching the shape above can be deserialized into a PageSpec directly via PageSpec.fromJson(String).

Fixed constraints

Use setConstraints to pin a baseline WHERE that the client cannot override — tenant scoping, soft-delete filters, permission gates, etc:

customers.setConstraints("customer.tenant_id = ? AND customer.deleted = 0", tenantId)
customers.setConstraints("customer.tenant_id = ? AND customer.deleted = 0", tenantId);

Any per-request spec filter then composes with this via AND. There is no way for a client to bypass it through a crafted alias or spec value — aliases are opaque lookups, not SQL.

See PagedList · Constraints.

Raw facets

Raw SQL facets work the same way as on PagedList, but are always configured server-side and addressed by alias only:

query.addSqlFacet("total", "SUM(order_total)", Facet.NUMERIC)
// client filter `{"total": ">=1000"}` becomes SUM(order_total) >= 1000
query.addSqlFacet("total", "SUM(order_total)", Facet.NUMERIC);
// client filter `{"total": ">=1000"}` becomes SUM(order_total) >= 1000

The raw expression is never derived from user input — it is part of the server's configuration and the client never sees or sends SQL.

See PagedList · Raw Facets.

Streaming

For bulk exports, report generation, or any case where you need to visit every matching row, forEachStreaming(spec, action) opens a database cursor and forwards rows to the action in chunks that match Stormify's sibling-batch size, so FK touches inside the callback resolve in one query per chunk. The page and pageSize fields on PageSpec are ignored — streaming always covers every matching row, so callers are responsible for bounding the result set via filters and constraints.

val spec = PageSpec(filters = mapOf("active" to "true"))
customers.forEachStreaming(spec) { c -> writeCsvRow(c) }
PageSpec spec = new PageSpec(Map.of("active", "true"));
customers.forEachStreaming(spec, c -> writeCsvRow(c));

The lock described under Concurrency is held only around the (cheap) SQL-plan stage — the iteration itself runs outside the lock, so concurrent callers do not block on each other's cursor lifetime.

See PagedList · Streaming.

Aggregations

getAggregator(spec) returns an aggregation builder bound to this query and the filter/constraint state derived from spec. Page and page size on the spec are ignored — aggregations always consider the entire matching set. Chain any combination of sum, avg, min, max, count, countDistinct or raw, then call execute<T>() for a single scalar or execute() for a Map<String, Any?> keyed by each added aggregation's alias.

val total: BigDecimal? = orders.getAggregator(spec)
    .sum(Order_.amount)
    .execute<BigDecimal>()

val summary: Map<String, Any?> = orders.getAggregator(spec)
    .sum(Order_.amount, "total")
    .avg(Order_.amount, "average")
    .count("*", "cnt")
    .execute()
BigDecimal total = orders.getAggregator(spec)
    .sum(Order_.amount)
    .execute(BigDecimal.class);

Map<String, Object> summary = orders.getAggregator(spec)
    .sum(Order_.amount, "total")
    .avg(Order_.amount, "average")
    .count("*", "cnt")
    .execute();

See PagedList · Aggregations.

Facet values

For facet pickers / autocomplete dropdowns, filterValues(alias, spec) returns a Page<String> of distinct values for the named facet. The facet's own filter is excluded from the query, so the picker keeps showing every value the user could select — the active filters of other facets still apply. filterValuesWithCounts(alias, spec) returns the same values paired with row counts, facet-navigation style.

val cities: Page<String> =
    customers.filterValues("city", spec)

val byCategory: Page<FilterCountedValue> =
    products.filterValuesWithCounts("category", spec)
Page<String> cities = customers.filterValues("city", spec);

Page<FilterCountedValue> byCategory =
    products.filterValuesWithCounts("category", spec);

The page and pageSize fields of the supplied spec paginate the distinct values themselves.

See PagedList · Filter Values.

Table refs

Raw SQL in setConstraints or in a raw facet sometimes needs to reference a joined table by its SQL alias. Register a TableRef with addTableRef(...) (root, dotted string path, or KSP typed ReferencePath) and use its alias so the engine's JOIN aliasing stays opaque to your expressions.

val query = PagedQuery<Customer>().apply {
    val root    = addTableRef()                       // the root entity table
    val address = addTableRef("address")              // FK-traversed
    val hq      = addTableRef(Customer_.company.hq)   // KSP typed path

    addFacet("name", Customer_.name)
    addSqlFacet("sameCity",
        "CASE WHEN $address.city = $hq.city THEN 1 ELSE 0 END",
        Facet.NUMERIC)
    setConstraints("$root.tenant_id = ?", currentTenantId)
}
PagedQuery<Customer> query = new PagedQuery<>(Customer.class);
TableRef root    = query.addTableRef();                          // the root entity table
TableRef address = query.addTableRef("address");                 // FK-traversed
TableRef hq      = query.addTableRef(Customer_.company().hq);    // KSP typed path

query.addFacet("name", Customer_.name);
query.addSqlFacet("sameCity",
    "CASE WHEN " + address.getAlias() + ".city = "
        + hq.getAlias() + ".city THEN 1 ELSE 0 END",
    Facet.NUMERIC);
query.setConstraints(root.getAlias() + ".tenant_id = ?", currentTenantId);

See PagedList · Table Refs.

Concurrency

PagedQuery.execute() is thread-safe. The engine holds a per-instance lock around the (cheap) SQL-plan stage, because the underlying join-tree activation mutates per-node flags; the lock is released before the database roundtrip so that the expensive work runs in parallel.

A PagedQuery is auto-sealed the first time it reads data from the database. After that, any attempt to change its configuration — adding facets, table refs or constraints, toggling flags, or mutating any setup-time property on a registered Facet or TableRef — throws IllegalArgumentException.

The seal enforces the contract that PagedQuery is a configure-once-share- forever object: concurrent callers never observe a half-reconfigured engine because there is no legal way for configuration to change once requests are in flight. If you need a different shape, construct a fresh instance.

Why not just use PagedList over REST?

PagedList is a UI model. Its per-facet filter and sort are mutable fields shared across every caller of the list — a second request racing against the first would corrupt its state. Its cached page, _size and selected entity persist across calls and have no meaning in a stateless request/response cycle. Its saveState() keys are opaque facet aliases — auto-assigned creation indices unless renamed — never paths or SQL, so publishing it would leak nothing about the schema. The problem is the opposite: the mapping from key to facet lives only in the list's own configuration and shifts whenever facets are added or reordered. It is designed for local round-trip persistence, never as a wire contract.

PagedQuery is the right tool whenever you find yourself either instantiating a fresh PagedList per HTTP request, or mapping request parameters to facet.filter and facet.sort by hand. The stateless engine does both for you.

Scenario Use
Server renders a desktop grid via ZK / JavaFX / Swing / Compose Desktop. PagedList
Embedded UI keeps a single list instance alive across user interactions. PagedList
REST endpoint returns a page of rows per request. PagedQuery
RPC / gRPC method with pagination. PagedQuery
Anything where a fresh instance is constructed per incoming request. PagedQuery

If the answer is "both" — a backend that serves both a desktop client and a REST façade over the same data — configure one PagedQuery for the REST side and let the desktop client use its own PagedList locally. They are independent views, sharing nothing but your entity classes.