Bier.Pagination (bier v0.1.0)

Copy Markdown View Source

Pagination semantics for the read pipeline: the Range/Range-Unit request headers, the Prefer: count= modes, and the resulting Content-Range response header and HTTP status (200/206/416).

PostgREST resolves an effective {offset, limit} window by INTERSECTING the window the limit/offset query parameters describe with the one the Range header describes (headerAndLimitRange = rangeIntersection headerRange limitRange, ApiRequest.hs#L185), requests a row count according to Prefer: count=, then renders a Content-Range of <first>-<last>/<total> (or */<total> for an empty window) and picks the status from whether the window covers the whole set.

The Range REQUEST header is read for GET only — headerRange = if method == "GET" then rangeRequested hdrs else allRange (ApiRequest.hs#L183, under a comment citing RFC 9110's "the Range header must be ignored for all methods other than GET"). method there is the raw request method, so HEAD is not folded into GET and a POST /rpc/<fn> ignores the header too.

Summary

Types

A row window as PostgREST's NonnegRange: the inclusive lower and upper row indexes, with nil for an unbounded upper end.

A NonnegRange: nil is allRange (unconstrained), :empty is emptyRange, anything else a concrete bounds/0 pair. A pair whose upper bound falls below its lower bound is empty too — ranged-sets defines rangeIsEmpty (Range lower upper) = upper <= lower and makes every empty range Eq-equal, which is why emptiness is a semantic test here rather than a structural one.

Functions

Apply the Range header and the db-max-rows cap to a parsed plan.

The count mode a function call (/rpc/<fn>) actually honors.

Render the Content-Range header value.

Resolve the count mode from a Prefer: count=<mode> header. Defaults to :none (PostgREST's default; total is rendered as *).

Whether a requested window is out of bounds: a non-zero offset that lands at or past the last row, with a known total and no rows returned. PostgREST renders this as 416 PGRST103 (OutOfBounds) — but only when a count is known (i.e. Prefer: count= was honored).

Parse the Range header (when Range-Unit is items/absent) into the range/0 it denotes.

HTTP status for a successful read given the window and (optional) total.

Types

bounds()

@type bounds() :: {non_neg_integer(), non_neg_integer() | nil}

A row window as PostgREST's NonnegRange: the inclusive lower and upper row indexes, with nil for an unbounded upper end.

count_mode()

@type count_mode() :: Bier.Preferences.count_mode()

range()

@type range() :: nil | :empty | bounds()

A NonnegRange: nil is allRange (unconstrained), :empty is emptyRange, anything else a concrete bounds/0 pair. A pair whose upper bound falls below its lower bound is empty too — ranged-sets defines rangeIsEmpty (Range lower upper) = upper <= lower and makes every empty range Eq-equal, which is why emptiness is a semantic test here rather than a structural one.

Functions

apply_window(plan, conn, max_rows)

@spec apply_window(map(), Plug.Conn.t(), pos_integer() | nil) ::
  {:ok, map()} | {:error, {:invalid_range, :lower_gt_upper | :negative_limit}}

Apply the Range header and the db-max-rows cap to a parsed plan.

The Range header window is INTERSECTED with the window the limit/offset query parameters describe — it does not override them. ?limit=2 describes rows 0..1, so Range: 0-5 still yields two rows; conversely ?limit=3 with Range: 0-1 yields two. max_rows (PostgREST db-max-rows, nil for uncapped) then bounds the effective limit.

The intersection is the top-level range, and isInvalidRange = topLevelRange == emptyRange && not (hasLimitZero limitRange) (ApiRequest.hs#L190) rejects it with 416 PGRST103 whenever it came out EMPTY — no matter which input emptied it. ?offset=3 with Range: 0-1 intersects to nothing and is a 416, not an empty 200. ?limit=0 is the sole exception: it is rewritten to the limitZeroRange sentinel upstream and answers 200 with zero rows.

Returns {:ok, plan} or {:error, {:invalid_range, reason}}, where reason picks the details string exactly as InvalidRange (if rangeIsEmpty headerRange then LowerGTUpper else NegativeLimit) does (ApiRequest.hs#L177) — on the emptiness of the RANGE HEADER, not of whichever input emptied the result.

call_count_mode(conn)

@spec call_count_mode(Plug.Conn.t()) :: count_mode()

The count mode a function call (/rpc/<fn>) actually honors.

A function call plans as a CallReadPlan, whose query is built with mempty in the explain-query position (Query.hs#L54) — unlike the table/view branch at Query.hs#L50 there is no EXPLAIN result to substitute — and whose MainTx.hs#L161 clause returns the result set without rewriting the table total. shouldCount PlannedCount is False, so the counting CTE is skipped too and the SQL total stays null::bigint. Prefer: count=planned therefore has no effect at all on an RPC: the total stays * and the status stays 200. count=exact/count=estimated do run the counting CTE and still yield a total.

content_range(offset, rows, total)

@spec content_range(non_neg_integer(), non_neg_integer(), non_neg_integer() | nil) ::
  String.t()

Render the Content-Range header value.

  • offset — the effective offset (lower bound) of the window.
  • rows — number of rows actually returned.
  • total — the total count (integer) when known, or nil for *.

count_mode(conn)

@spec count_mode(Plug.Conn.t()) :: count_mode()

Resolve the count mode from a Prefer: count=<mode> header. Defaults to :none (PostgREST's default; total is rendered as *).

Bier.Preferences owns the Prefer vocabulary — which count= tokens exist and which are invalid — and this module only consumes the resolved mode. That single ownership is what keeps count=none consistent: it is NOT a PreferCount value, so it resolves to the default :none here because it is unrecognized, while Bier.Preferences simultaneously reports it in invalidPrefs (a handling=strict request carrying it is a 400 PGRST122).

out_of_bounds?(offset, rows, total)

@spec out_of_bounds?(non_neg_integer(), non_neg_integer(), non_neg_integer() | nil) ::
  boolean()

Whether a requested window is out of bounds: a non-zero offset that lands at or past the last row, with a known total and no rows returned. PostgREST renders this as 416 PGRST103 (OutOfBounds) — but only when a count is known (i.e. Prefer: count= was honored).

range_window(conn)

@spec range_window(Plug.Conn.t()) :: range()

Parse the Range header (when Range-Unit is items/absent) into the range/0 it denotes.

The header is honored for GET only; every other method (including HEAD and a POST /rpc/<fn>) reads as "no Range header" (allRange).

status(offset, rows, total)

@spec status(non_neg_integer(), non_neg_integer(), non_neg_integer() | nil) ::
  200 | 206

HTTP status for a successful read given the window and (optional) total.

Returns 206 when a count is known and the returned window does not cover the whole set (a non-zero offset, or fewer rows than the total). Otherwise 200.