Keyset pagination for Ecto queries.
A cursor holds a sort order and the position of the last row read. Each page
is fetched with a WHERE clause that continues right after that position,
so pages stay consistent when rows are inserted or deleted between calls.
With an index matching the sort order, the database seeks to the position in
the index, so deep pages stay fast.
Create a cursor with a sort order and a page size, then fetch pages with a base query and a repo. Each page carries the cursor for the next one.
cursor = Ark.EctoCursor.new(order_by: [desc: :inserted_at, asc: :id], page_size: 20)
%Ark.EctoCursor.Page{entries: posts, next_cursor: next} =
Ark.EctoCursor.fetch_page(cursor, MyApp.Post, MyApp.Repo)
%Ark.EctoCursor.Page{entries: more_posts} =
Ark.EctoCursor.fetch_page(next, MyApp.Post, MyApp.Repo)The :has_next_page field of Ark.EctoCursor.Page tells whether more rows
follow the page.
To go through every row, stream/4 fetches the pages lazily as the stream
is consumed.
Ark.EctoCursor.new(order_by: :id, page_size: 500)
|> Ark.EctoCursor.stream(MyApp.Post, MyApp.Repo)
|> Enum.each(&MyApp.Search.index_post/1)Sort order
The :order_by option lists the fields that define the order of the rows. A
plain field name sorts in ascending order, and {:asc, field} or {:desc, field} give an explicit direction, as in Ecto.Query.order_by/3.
Choose the sort fields with these rules in mind:
- Together, the fields must identify a single row. End the list with a
unique field, usually the primary key, to break ties between rows that
share the other values. For instance
[desc: :inserted_at, asc: :id]. - The fields must be non-nullable columns. Rows with a
NULLsort value can be skipped from the pages, and a row with anilsort value raises anArgumentErrorwhen it becomes the position of the next cursor. - An index on the same fields, in the same order and directions (or with all
directions reversed), lets the database read each page directly from the
index. For the example above, that is
create index(:posts, ["inserted_at DESC", :id])in a migration.
With several sort fields, the query also bounds the first field with a
redundant >= (or <=) condition, which lets query planners seek the index
from the position. The :use_index option of fetch_page/4, stream/4 and
query/3 controls this behaviour.
Base queries
The base query can be any Ecto.Queryable: a schema, a {source, schema}
tuple, or a query with where, join, select and distinct: true
clauses. The cursor adds the order_by, the limit, and the conditions on
the position, which shapes the base query:
- The base query leaves out
limit,offsetandorder_by. The limit comes from the:page_sizeoption, and the order from the cursor. - Combinations such as
Ecto.Query.union/2go inside a subquery:from(s in subquery(union_all(q1, ^q2))). distinctusestruerather than a list of expressions.
Each row returned by the query must be a map or a struct that holds every
sort field, under an atom or string key. Rows selected as schema structs or
with Ecto.Query.API.map/2 meet this naturally. With a custom select,
include the sort fields in the selected map.
By default, sort fields are read from the root source of the query. To sort
on a joined source, give the cursor a :binding naming a binding of the
query, defined with the :as option. A joined binding needs an explicit
select that returns the sort fields of that binding, under their field
names. This example lists the members of a group, sorted by user name.
query =
from m in MyApp.Membership,
where: m.group_id == ^group_id,
join: u in assoc(m, :user),
as: :user,
select: %{id: u.id, name: u.name, role: m.role}
cursor = Ark.EctoCursor.new(order_by: [:name, :id], page_size: 20, binding: :user)Persisting cursors
A cursor is an embedded schema, so it can be stored in a column of a parent
schema with Ecto.Schema.embeds_one/3. This is useful to resume a long
running job, or to keep polling a table for new rows. The cursor of the last
page still points after the last row, so with a sort order that follows
insertion, such as :id or :inserted_at, later calls return the rows
inserted since then. Concurrent transactions can commit rows out of that
order though, and a row committed after a later row was read is skipped.
defmodule MyApp.Export do
use Ecto.Schema
schema "exports" do
embeds_one :cursor, Ark.EctoCursor, on_replace: :delete
end
endThe database column holds a map, declared with add :cursor, :map in the
migration that creates the parent table.
Only the sort order and the position are stored, so resume!/2 turns a
stored cursor back into a working one, with the page size and binding given
again. It also accepts nil, which starts a new cursor for a parent that
has none yet.
@cursor_opts [order_by: [asc: :inserted_at, asc: :id], page_size: 500]
def export_next_batch(%MyApp.Export{cursor: stored} = export) do
cursor = Ark.EctoCursor.resume!(stored, @cursor_opts)
%Ark.EctoCursor.Page{entries: posts, next_cursor: next} =
Ark.EctoCursor.fetch_page(cursor, MyApp.Post, MyApp.Repo)
:ok = MyApp.Storage.write_posts(posts)
export
|> Ecto.Changeset.change()
|> Ecto.Changeset.put_embed(:cursor, next)
|> MyApp.Repo.update!()
endStored as JSON, position values take their JSON form, a datetime becomes a
string for instance. Ecto casts them back to the type of the sort fields
when building the query, using the schema of the queried source. Use a base
query with a schema, such as MyApp.Post or {"posts", MyApp.Post}, with
cursors loaded from storage.
Ecto.embedded_dump/2 and Ecto.embedded_load/3 convert a cursor to and
from JSON data for storage outside of a schema.
Summary
Functions
Runs the query for the page that follows the cursor position and returns an
Ark.EctoCursor.Page.
Returns a new cursor positioned before the first row.
Returns the query for the page that follows the cursor position, without running it.
Prepares a stored cursor for a new page, or creates a new cursor from nil.
Same as resume/2, but returns the cursor directly and raises an
Ark.EctoCursor.MismatchError when the sort orders differ.
Returns a lazy stream of all the rows that follow the cursor position.
Types
@type executor() :: module() | (Ecto.Query.t() -> [map()])
@type new_opts() :: [ order_by: atom() | [atom() | {:asc | :desc, atom()}], page_size: pos_integer() | nil, binding: atom() | nil ]
@type query_opts() :: [page_size: pos_integer(), use_index: boolean()]
@type t() :: %Ark.EctoCursor{ binding: atom() | nil, order_by: Ark.EctoCursor.OrderBy.t(), page_size: pos_integer() | nil, position: [term()] | nil }
Functions
@spec fetch_page(t(), Ecto.Queryable.t(), executor(), query_opts()) :: Ark.EctoCursor.Page.t()
Runs the query for the page that follows the cursor position and returns an
Ark.EctoCursor.Page.
%Ark.EctoCursor.Page{entries: posts, has_next_page: more?, next_cursor: next} =
Ark.EctoCursor.fetch_page(cursor, MyApp.Post, MyApp.Repo)The page entries are the rows returned by the query, in the cursor order. The next cursor points after the last entry, and keeps the page size and binding of the given cursor. When the page is empty, the next cursor is the given cursor.
The query selects one row more than the page size to tell whether another
page follows, so :has_next_page is exact even when the last page is full.
The third argument runs the query. It is either an Ecto.Repo module, on
which Ecto.Repo.all/2 is called, or a function that receives the query
and returns a list of rows. A function gives control over the repo options
or the shape of the rows.
Ark.EctoCursor.fetch_page(cursor, MyApp.Post, fn query ->
MyApp.Repo.all(query, prefix: "tenant_42")
end)Each row must be a map or a struct holding the sort fields, under atom or string keys.
The options are the same as for query/3. Raises an ArgumentError for
invalid options, a base query that does not fit the cursor, or when the last
row of the page does not hold the sort fields or holds a nil sort value.
Returns a new cursor positioned before the first row.
The sort order is normalized to a list of {direction, field} tuples.
iex> Ark.EctoCursor.new(order_by: [:score, desc: :id], page_size: 10)
%Ark.EctoCursor{order_by: [asc: :score, desc: :id], position: nil, page_size: 10, binding: nil}See the "Sort order" section of the module documentation to choose the sort fields.
Options
:order_by- Required. A field name, or a list of field names and{direction, field}tuples where the direction is:ascor:desc. Field names without a direction sort in ascending order.:page_size- A positive integer, the number of rows per page. This option can also be given toquery/3,fetch_page/4andstream/4.:binding- The named binding of the query that holds the sort fields. Defaults to the root source of the query.
Raises an ArgumentError for invalid or unknown options.
@spec query(t(), Ecto.Queryable.t(), query_opts()) :: Ecto.Query.t()
Returns the query for the page that follows the cursor position, without running it.
The returned query adds the sort order, a limit of one page, and the conditions on the cursor position to the base query. It is useful to inspect the generated SQL or the query plan.
query = Ark.EctoCursor.query(cursor, MyApp.Post)
Ecto.Adapters.SQL.explain(MyApp.Repo, :all, query)To read pages, use fetch_page/4, which also computes the next cursor.
Options
:page_size- A positive integer that overrides the page size of the cursor. Required when the cursor has no page size.:use_index- A boolean. Whentrue, and the cursor has a position and several sort fields, the query bounds the first sort field with a redundant>=or<=condition so the database can seek an index on the sort fields. Defaults totrue.
Raises an ArgumentError for invalid options or when the base query does
not fit the cursor, see the "Base queries" section of the module
documentation.
@spec resume(t() | nil, new_opts()) :: {:ok, t()} | {:error, Ark.EctoCursor.MismatchError.t()}
Prepares a stored cursor for a new page, or creates a new cursor from nil.
{:ok, cursor} = Ark.EctoCursor.resume(stored_cursor, order_by: :id, page_size: 100)The options are the same as for new/1. The :order_by option must match
the sort order of the stored cursor, and the returned cursor keeps the
stored position. The :page_size and :binding of the returned cursor come
from the options, and default to nil when they are not given.
Returns {:error, %Ark.EctoCursor.MismatchError{}} when the sort orders
differ, for instance after a change of the sort fields in the code that
stored the cursor. Raises an ArgumentError for invalid or unknown options.
Same as resume/2, but returns the cursor directly and raises an
Ark.EctoCursor.MismatchError when the sort orders differ.
cursor = Ark.EctoCursor.resume!(stored_cursor, order_by: :id, page_size: 100)
@spec stream(t(), Ecto.Queryable.t(), executor(), query_opts()) :: Enumerable.t(map())
Returns a lazy stream of all the rows that follow the cursor position.
Ark.EctoCursor.new(order_by: :id, page_size: 500)
|> Ark.EctoCursor.stream(MyApp.Post, MyApp.Repo)
|> Stream.map(&MyApp.Search.to_document/1)
|> Enum.each(&MyApp.Search.index/1)The stream fetches one page at a time with fetch_page/4 as it is consumed,
and ends after the last page. Each page runs as its own query, so rows
inserted while the stream runs are included when they sort after the current
position.
The arguments and options are the same as for fetch_page/4. They are
validated when stream/4 is called, before any query runs.