Chunkr (Chunkr v0.2.1) View Source
Keyset-based query pagination for Elixir and Ecto.
Use cases
Chunkr helps you implement:
- pagination for GraphQL
- pagination per the Relay spec
- paginated REST APIs
- APIs supporting infinite scroll-style UIs
- and more…
Why Keyset pagination? 😍
The alternative—offset-based pagination—has a couple of serious drawbacks:
It's inefficient. The further you "page" into the result set, the less efficient your database queries will be because the database has to query for all the results, then count through and discard rows until it reaches the desired offset.
It's inconsistent. Records created or deleted while your user paginates can cause some results to either be duplicated or to be entirely missing from what is returned. Depending on your use case, this could significantly undermine your user's trust or just be awkward.
Offset-based pagination is generally simpler to implement because, based on the desired page number and page size, the offset of the next batch can be trivially calculated and incorporated into the next query.
With keyset-based pagination, rather than tracking how far into the result set you've traversed (and
then hoping records don't change out from under you), we instead track the value of one or more
fields in the first and last record of the batch. Then, to get the next (or previous) batch, we
query with a WHERE
clause that excludes records up to those values. With appropriate indexing,
the database does not have to pull irrelevant records (only to count through them and discard
many of them!). Furthermore, this approach isn't negatively affected by records being created
or removed during pagination.
All of this makes keyset pagination far more appealing than offset-based pagination. The gotcha is that it can be much more troublesome and error-prone to set up the necessary keyset-based queries by hand. Fortunately, we've now made it easy to incorporate keyset pagination into your Elixir/Ecto apps. You're going to love it.
One thing keyset-based pagination cannot provide is direct access to an arbitrary "page" of results. In other words, it's not possible to jump directly from page 2 to page 14—you'd need offset-based pagation for that. However, that's not necessarily a design pattern we'd encourage anyway (how is the user to know that the results they want might be on page 14?).
For more about the benefits of keyset pagination, see https://use-the-index-luke.com/no-offset.
What about Cursor-based pagination? 🤔
Keyset pagination is sometimes referred to as "cursor-based" pagination, which is valid. However, keyset pagination is not the only type of pagination that can be implemented with cursors.
In Keyset-based pagination, one or more values from the records being paginated are used to create a cursor. Then, to paginate past any given cursor, the system must generate a query that looks for records just beyond the record represented by those cursor values. The cursor is generally obfuscated (for example, using Base64 encoding) in order to discourage clients from relying directly on the particular cursor implementation.
As previously mentioned, "cursor-based" pagination does not necessarily imply "keyset" pagination; offset-based pagination can also be implemented using cursors. For example, the current page size and offset can be encoded into an opaque cursor that the system can decode and use in order to determine what the next or previous page of results would be.
Therefore, Chunkr is indeed cursor-based pagination. But more specificially, it is keyset-based.
Why Chunkr?
Chunkr took inspiration from both Paginator and Quarto. However, those libraries had some limitations.
Quarto already addressed the deal-breaking need to reliably sort by columns that might contain
NULL
values. However, other limitations remained. E.g. it wasn't easy to paginate in reverse
from the end of a result set to the beginning. Also, pagination was broken when sorting by fields on
associations that might be missing (say, for example, sorting users by their preferred
address—specifically when not all users have specified a "preferred" address). Furthermore, the
existing libraries didn't allow for sorting by Ecto fragments, which is problematic because it’s
often desirable to sort by calculated values—e.g. to provide case-insensitive sorts of people's
names via an Ecto fragment such as lower(last_name)
.
Chunkr:
- provides a simple DSL to declare your pagination strategies
- implements the necessary supporting functions for your pagination strategies at compile time
- automatically implements inverse sorts for you
- enables paginating forwards or backwards through a result set
- honors Ecto fragments
Installation
Add chunkr
to your list of dependencies in mix.exs
:
def deps do
[
{:chunkr, "~> 0.2.1"}
]
end
Usage
1. Set up your pagination strategies…
defmodule MyApp.PaginationPlanner do
use Chunkr.PaginationPlanner
paginate_by :username do
sort :asc, as(:user).username
end
paginate_by :user_created_at do
sort :desc, as(:user).inserted_at
sort :asc, as(:user).id
end
end
The Chunkr.PaginationPlanner.paginate_by/2
macro sets up a named pagination strategy
and automatically implements the necessary supporting functions for that strategy at compile time.
Each call to sort
establishes a field to sort by when using this strategy. Results
will be ordered by the first specified sort
clause, with each subsequent clause acting
as a tie-breaker. The final sort field must be unique.
Setting up these pagination strategies enables you to call the paginate function—e.g.
paginate(some_query, :user_created_at, :desc, opts)
(using the sort direction of the first
sort
clause). However, you'll also be able to call
paginate(some_query, :user_created_at, :asc, opts)
and Chunkr will automatically invert all
of the sort orders for you. You always call paginate()
with the strategy name and the sort
order of the first field, and the rest of the sort orders will flip as needed.
See more docs in the Chunkr.PaginationPlanner
module.
2. Add chunkr
to your Ecto.Repo
:
defmodule MyApp.Repo do
use Ecto.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.Postgres
use Chunkr, planner: MyApp.PaginationPlanner
end
This adds the convenience functions paginate/4
and paginate!/4
to your Repo.
3. Paginate your queries!
# Provide a query implementing all named bindings referenced in your previously-established strategy
iex> query = from u in User, as: :user, join: ap in assoc(u, :account_profile), as: :profile
# Fetch the first page of results using that strategy
iex> first_page = MyApp.Repo.paginate!(query, :username, :asc, first: 25)
# Extract records
iex> records = Chunkr.Page.records(first_page)
# Fetch subsequent pages…
iex> next_page = MyApp.Repo.paginate!(query, :username, :asc, first: 25, after: first_page.end_cursor)
See further documentation at Chunkr.Pagination
and Chunkr.Page
.