Query-building pieces shared by the contexts. Right now: paging.
Why this exists
Seven contexts each carried a private paginate/2, in four different
behaviours. Three (quests, economy, inventory) read :page_size
straight out of the options and applied it, so list_ledger(page_size: 1_000_000) was a single unbounded read. Two (groups, lobbies) clamped —
to a hard-coded @max_page_size 1000 that ignored the configurable
max_page_size limit entirely. Two (matchmaking, ready_checks) clamped
correctly through Gamend.Limits.
GamendWeb.Pagination already fixed the same drift one layer up, and
mix gamend.api.lint keeps controllers honest — but nothing was watching
the contexts, and a plugin calls them directly. Both functions here clamp
through Gamend.Limits, so the configured maximum is the maximum wherever
the call came from.
Which one
page/2 always windows: the caller is paging, and no :page just means the
first one. maybe_page/2 windows only when asked, and otherwise bounds the
read at unpaginated_limit/0 — for a listing whose callers legitimately want
"all of them", where "all" must still not mean an unbounded Repo.all over a
growing table.
Summary
Functions
The rows whose field is exactly the id value.
Narrows a query over a table with a user_id column to one user, found by
exact id or by a substring of their username or display name — so an admin
can filter a list without knowing the raw id.
Applies the window when :page is given, and otherwise caps the read at
unpaginated_limit/0.
Applies :page and :page_size, both clamped, defaulting to the first page.
The ceiling applied to a read that requested no page.
Functions
@spec filter_id(Ecto.Queryable.t(), atom(), String.t() | nil) :: Ecto.Queryable.t()
The rows whose field is exactly the id value.
For a caller holding an id rather than a search term -- a plugin scoping a
read by user or lobby, a link from another page. nil and "" leave the
query alone. A value that is not a UUID matches nothing: it arrives in
request params, where comparing it raised Ecto.Query.CastError, and
ignoring it instead would answer every row to a filter plainly meant to
narrow them. KV did the first and push the second.
See filter_user/2 for the admin search that also matches names.
@spec filter_user(Ecto.Queryable.t(), String.t() | nil) :: Ecto.Queryable.t()
Narrows a query over a table with a user_id column to one user, found by
exact id or by a substring of their username or display name — so an admin
can filter a list without knowing the raw id.
nil leaves the query alone. Economy, inventory and quests each carried an
identical private copy of this.
@spec maybe_page(Ecto.Queryable.t(), keyword()) :: Ecto.Query.t()
Applies the window when :page is given, and otherwise caps the read at
unpaginated_limit/0.
@spec page(Ecto.Queryable.t(), keyword()) :: Ecto.Query.t()
Applies :page and :page_size, both clamped, defaulting to the first page.
Query.page(query, page: 2, page_size: 50)
@spec unpaginated_limit() :: pos_integer()
The ceiling applied to a read that requested no page.