Skip to content

Query parameters

A filter selects from what a read source returns. But some logic lives inside the source, where a result filter can't reach: a window function, a CTE, a lateral join, a security-barrier view. When a runtime value has to drive that internal logic — rank a leaderboard as of a date, pick a pricing tier before the rollup — you can't express it as a filter on the rows that come back.

Query parameters close that gap. A read resource declares a typed parameter contract; a handler binds a value through with_parameters(...); the backend applies it as a query-scoped session setting the relation reads internally. The source stays a normal relation, so the whole reading-data surface — filters, sorts, pagination, projection, codec, tenancy — composes on top, unchanged.

When to reach for it

You need Use
Select from the rows a source returns a filter
Feed a value to logic inside the source query parameters
Bound SQL → pages of rows, no document DSL analytics
Run a command / compute step procedures

The test is reachability. If an outer WHERE can reproduce the result, use a filter — it's simpler and the planner likes it. Reach for a query parameter only when the value must drive logic the filter can't express.

The shape in code

The read resource declares its parameter contract as a typed model. The relation name and the mechanism stay in the wiring; the spec leaks neither.

class Standing(BaseModel):
    region: str
    player: str
    score: int
    rank: int  # computed by a window function *inside* the view, over the as-of set


class AsOf(BaseModel):
    as_of: date  # the parameter the view reads internally to bound its ranking


# The read resource declares its query-parameter contract; the relation name and the GUC stay in
# the wiring. The full read DSL still applies on top of the parametrized source.
STANDINGS_SPEC = DocumentSpec(
    name="standings",
    read=Standing,
    write=None,
    query_params=AsOf,
)

A handler binds the parameter once, then reads with the full document DSL on top. Here the leaderboard ranks per region as of a date — and the region filter and sort are the ordinary read surface applied over the bound source:

async def regional_top(
    ctx: ExecutionContext, region: str, as_of: date
) -> list[Standing]:
    # Bind the parameter once, then read with the full document DSL on top: the view ranks over the
    # as-of set internally; here we just filter to one region and order by the rank it computed.
    page = await (
        ctx.document.query(STANDINGS_SPEC)
        .with_parameters(AsOf(as_of=as_of))
        .find_many(filters={"$values": {"region": region}}, sorts={"rank": "asc"})
    )
    return list(page.hits)

with_parameters(model) validates the model against the spec's query_params, checks the backend supports the channel, and returns a param-bound clone of the query port — same find / get / project / paginate interface, the parameter carried along.

Mapping it to Postgres

On Postgres the relation is a plain view that reads the parameter through a custom GUC, deep inside — where an outer filter can't follow. The leaderboard's rank has to be computed over the as-of-filtered set, so the date lives in the view's own WHERE:

CREATE VIEW standings AS
SELECT region, player, score,
       rank() OVER (PARTITION BY region ORDER BY score DESC) AS rank
FROM results
WHERE recorded_on <= current_setting('forze.as_of')::date;   -- the bound parameter

When a read carries bound parameters, the adapter opens or joins a transaction and emits SET LOCAL forze.as_of = '2026-03-01' before the governed SELECT. The view reads current_setting('forze.as_of'); nothing about the FROM, the projection, or the DSL changes.

A few rules follow from the mechanism:

  • A transaction is required. SET LOCAL only lives inside one, and a bare SET would leak across a pooled connection. The adapter auto-wraps — joining an outer transaction (read-your-writes preserved) or opening a short one. This is a backend concern; the core knows nothing of it.
  • Values are escaped, names are trusted. The GUC name comes from the declared contract; the value is sql.Literal-escaped. A handler value can never become SQL text.
  • Settings are text. GUCs are strings, so parameters serialize to strings and the view casts (current_setting('forze.as_of')::date). List and dict parameters are JSON-encoded, so a view reads them with current_setting('forze.ids')::jsonb. The GUC prefix (forze by default) is configurable per route on the Postgres document config.
  • Optional parameters skip the GUC. A None value is not set at all (rather than serialized to '', which would fail a typed cast). A view reading an optional parameter must use the missing_ok form — current_setting('forze.as_of', true)::date — so an unset parameter reads as SQL NULL instead of raising.

Fails closed

The channel is capability-gated, and every misuse raises rather than silently returning wrong rows:

  • Reading a query_params spec without with_parameters raises — the source depends on the setting, so an unbound read is a bug, not an empty page.
  • with_parameters on a spec with no query_params raises.
  • A backend that doesn't support the channel raises at with_parameters. Postgres documents support it today; search and Mongo do not (no native mechanism), and analytics already binds its own parameters.

Testing without a database

The in-memory mock supports the channel too: register a per-resource source that receives the bound parameters and produces the rows the view would, and the document DSL composes over its output exactly as against Postgres. The recipe runs the leaderboard this way — no database required:

# The raw results a parametrized view would rank over (region, player, score, recorded_on).
_RESULTS: list[tuple[str, str, int, date]] = [
    ("eu", "ana", 30, date(2026, 1, 10)),
    ("eu", "bo", 50, date(2026, 2, 20)),
    ("eu", "cy", 40, date(2026, 4, 5)),  # after a March as-of cutoff
    ("us", "dot", 70, date(2026, 1, 15)),
    ("us", "el", 60, date(2026, 3, 1)),
]


def _standings_as_of(params: BaseModel, state: MockState) -> list[Standing]:
    """Model what the parametrized view yields: filter results to the as-of date, then rank per
    region by score — the ranking lives *inside* the source, so it sees only the as-of set.
    """

    as_of = params.as_of  # type: ignore[attr-defined]  # bound AsOf, validated against the spec

    eligible = [r for r in _RESULTS if r[3] <= as_of]
    rows: list[Standing] = []
    for region in {r[0] for r in eligible}:
        ranked = sorted(
            (r for r in eligible if r[0] == region), key=lambda r: r[2], reverse=True
        )
        # SQL ``rank()`` semantics: tied scores share a rank, and the next rank skips the gap —
        # so the mock matches the view's window function rather than a plain row number.
        rank = 0
        prev_score: int | None = None
        for position, (reg, player, score, _) in enumerate(ranked, start=1):
            if score != prev_score:
                rank = position
                prev_score = score
            rows.append(Standing(region=reg, player=player, score=score, rank=rank))
    return rows

The handler code does not change between the mock and Postgres — which keeps the mock a faithful stand-in for the real read path.