Packages

Pretty patterns for writing the Elixir you want to write.

Current section

Files

Jump to
patterns lib patterns experimental pagination.ex
Raw

lib/patterns/experimental/pagination.ex

defmodule Patterns.Experimental.Pagination do
@moduledoc """
Makes `c:Ecto.Repo.all/2` aware of pagination metadata.
Add this after `use Ecto.Repo` in a repo module:
defmodule MyApp.Repo do
use Ecto.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.Postgres
use Patterns.Experimental.Pagination
end
Queries without pagination metadata keep normal `c:Ecto.Repo.all/2` behavior. Queries
with `:pagination` metadata return a Relay-style keyset connection.
Post
|> Patterns.Experimental.Pagination.put(%{first: 10})
|> MyApp.Repo.all()
`unique_column` controls the stable keyset tie-breaker:
Slug
|> Patterns.Experimental.Pagination.put(%{first: 10}, :slug)
|> MyApp.Repo.all()
## Sharp Edges
This module only handles repo execution. Query builders still need to attach
pagination metadata with `put/3`.
The internal keyset backend is not part of the public contract. This module
currently delegates query shaping to `AbsintheRelayKeysetConnection`, but that
dependency may be replaced by a Patterns-owned Ecto query builder without
changing this API.
"""
@behaviour Patterns.Middleware
use Patterns.Middleware
alias Patterns.Queryable.Metadata
@max_limit 100
@default_limit @max_limit
@pagination_keys [:first, :last, :after, :before, :order_by]
# .:*~*:._ Filter Extraction _.:*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*:.
@doc """
Moves cursor pagination filters into query metadata.
Pagination activates when at least one cursor boundary key is present:
`:first`, `:last`, `:after`, or `:before`. When active, `:order_by` and
`:unique_column` are also moved into pagination metadata. Without a cursor
boundary key, filters are returned unchanged so `:order_by` remains a normal
query filter.
"""
@spec apply_filters(map() | keyword()) :: keyword()
def apply_filters(filters) when is_map(filters) do
filters |> Keyword.new() |> apply_filters()
end
def apply_filters(filters) when is_list(filters) do
if Enum.any?([:first, :last, :after, :before], &Keyword.has_key?(filters, &1)) do
unique_column = Keyword.get(filters, :unique_column, :id)
pagination_args = filters |> Keyword.take(@pagination_keys) |> Map.new()
filters
|> Keyword.drop([:first, :last, :after, :before, :order_by, :unique_column])
|> Kernel.++(pagination: fn query -> put(query, pagination_args, unique_column) end)
else
filters
end
end
# .:*~*:._ Metadata _.:*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*:.
@doc """
Splits cursor pagination args from regular query args.
Prefer `apply_filters/1` when using `Patterns.Queryable`.
"""
@spec pop_args!(map()) :: {map(), map()}
def pop_args!(args) do
pagination_args = Map.take(args, @pagination_keys)
args = Map.drop(args, @pagination_keys)
{pagination_args, args}
end
@doc """
Stores cursor pagination metadata on `queryable`.
"""
@spec put(Ecto.Queryable.t(), map(), atom()) :: Ecto.Query.t()
def put(queryable, args, unique_column \\ :id) when is_map(args) and is_atom(unique_column) do
Metadata.put(queryable, :pagination, {args, unique_column})
end
# .:*~*:._ Keyset Execution _.:*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*:.
@doc """
Paginates `query` with Relay-style keyset pagination.
"""
@spec paginate(module(), Ecto.Queryable.t(), map(), atom()) :: map()
def paginate(repo, query, args \\ %{}, unique_column \\ :id) when is_atom(repo) and is_map(args) do
sorts = normalize_order_by(args[:order_by])
args =
args
|> Map.update(:first, nil, &cap_limit/1)
|> Map.update(:last, nil, &cap_limit/1)
|> default_limit()
|> Map.put(:sorts, sorts)
fields_to_drop =
args
|> Enum.filter(&(match?({:order_by, _value}, &1) || match?({_key, nil}, &1)))
|> Enum.map(fn {key, _value} -> key end)
case AbsintheRelayKeysetConnection.from_query(query, &repo.all/1, Map.drop(args, fields_to_drop), %{
unique_column: unique_column
}) do
{:ok, connection} -> connection
{:error, _reason} -> empty_connection()
end
end
@doc """
Returns an empty connection.
"""
@spec empty_connection() :: map()
def empty_connection do
%{
edges: [],
page_info: %{
start_cursor: nil,
end_cursor: nil,
has_previous_page: false,
has_next_page: false
}
}
end
# .:*~*:._ Repo Middleware _.:*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*:.
@doc false
defmacro __using__(_opts) do
quote do
use Patterns.Middleware
alias Patterns.Queryable.Metadata
defoverridable all: 2
@middleware Patterns.Experimental.Pagination
def all(queryable, opts) do
super(queryable, opts)
end
def prepare_query(operation, query, opts) do
super(operation, Metadata.drop(query), opts)
end
defoverridable prepare_query: 3
end
end
@impl Patterns.Middleware
def process([queryable, opts], resolution) do
{query, pagination} = Metadata.pop(queryable, :pagination)
if is_nil(pagination) do
yield([query, opts], resolution)
else
{args, unique_column} = pagination
{paginate(resolution.module, query, args, unique_column), resolution}
end
end
# .:*~*:._ Argument Normalization _.:*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*:.
defp normalize_order_by(nil) do
[]
end
defp normalize_order_by(order_by) do
Enum.flat_map(order_by, fn
%{field: field, direction: direction} ->
[%{field => direction}]
map when is_map(map) ->
Enum.map(map, fn {field, direction} -> %{field => direction} end)
end)
end
defp cap_limit(nil) do
nil
end
defp cap_limit(value) do
min(value, @max_limit)
end
defp default_limit(%{first: nil, last: nil} = args) do
%{args | first: @default_limit}
end
defp default_limit(args) do
args
end
end