lib/chunkr/opts.ex

defmodule Chunkr.Opts do
  @moduledoc """
  Options for paginating

  ## Fields

    * `:repo` — The `Ecto.Repo` for the query.
    * `:planner` — The module implementing the pagination strategy.
    * `:query` — The non-paginated query to be extended for pagination purposes.
    * `:strategy` — The name of the pagination strategy to use.
    * `:sort_dir` — The primary sort direction used for the query. Note that this
      aligns with the very first `sort` clause registered in the named pagination strategy.
      Any subsequent sort directions within the strategy will always be automatically
      adjusted to maintain the overall strategy.
    * `:paging_dir` — Either `:forward` or `:backward` depending on whether gathering
      results from the start or the end of the result set (i.e. whether the limit was
      specified as `:first` or `:last`).
    * `:cursor` — The `:after` or `:before` cursor beyond which results are retrieved.
    * `:max_limit` — The maximum allowed page size.
    * `:limit` — The requested page size (as specified by `:first` or `:last`).
  """

  @type sort_dir :: :asc | :desc

  @type t :: %__MODULE__{
          repo: atom(),
          planner: atom(),
          query: Ecto.Query.t(),
          strategy: atom(),
          sort_dir: sort_dir(),
          paging_dir: :forward | :backward,
          cursor: Chunkr.Cursor.opaque_cursor() | nil,
          max_limit: pos_integer(),
          limit: pos_integer()
        }

  defstruct [
    :repo,
    :planner,
    :query,
    :strategy,
    :sort_dir,
    :paging_dir,
    :cursor,
    :max_limit,
    :limit
  ]

  @spec new(any, any, sort_dir, keyword) :: {:invalid_opts, String.t()} | {:ok, struct}
  @doc """
  Validate provided options and return a `Chunkr.Opts` struct
  """
  def new(query, strategy, sort_dir, opts) do
    case validate_options(strategy, opts) do
      {:ok, opts} -> {:ok, struct!(%__MODULE__{query: query, sort_dir: sort_dir}, opts)}
      {:error, message} -> {:invalid_opts, message}
    end
  end

  defp validate_options(strategy, opts) do
    with {:ok, limit, cursor, paging_direction} <- validate(opts),
         {:ok, _limit} <- validate_limit(limit, opts) do
      {:ok,
       %{
         repo: Keyword.fetch!(opts, :repo),
         planner: Keyword.fetch!(opts, :planner),
         strategy: strategy,
         paging_dir: paging_direction,
         max_limit: Keyword.fetch!(opts, :max_limit),
         limit: limit,
         cursor: cursor
       }}
    end
  end

  @valid_keys [
    [:first],
    [:first, :after],
    [:last],
    [:last, :before]
  ]

  @valid_sets Enum.map(@valid_keys, &MapSet.new/1)

  @valid_combos @valid_keys
                |> Enum.map(&Enum.join(&1, ", "))
                |> Enum.map(&"[#{&1}]")
                |> Enum.join(" | ")

  defp validate(opts) do
    provided_keys = opts |> Keyword.take([:first, :last, :after, :before]) |> Keyword.keys()
    provided_key_set = MapSet.new(provided_keys)

    case MapSet.new(@valid_sets) |> MapSet.member?(provided_key_set) do
      true -> {:ok, get_limit(opts), get_cursor(opts), get_paging_direction(opts)}
      false -> {:error, pagination_args_error(provided_keys)}
    end
  end

  defp get_limit(opts) do
    Keyword.get(opts, :first) || Keyword.get(opts, :last)
  end

  defp get_cursor(opts) do
    Keyword.get(opts, :after) || Keyword.get(opts, :before)
  end

  defp get_paging_direction(opts) do
    if Keyword.get(opts, :first), do: :forward, else: :backward
  end

  defp pagination_args_error(provided_keys) do
    ~s(Invalid pagination params: [#{Enum.join(provided_keys, ", ")}]. Valid combinations are: #{@valid_combos}.)
  end

  defp validate_limit(limit, opts) do
    max_limit = Keyword.fetch!(opts, :max_limit)

    cond do
      limit < 0 ->
        {:error, "Page size of #{limit} was requested, but page size must be at least 0."}

      limit <= max_limit ->
        {:ok, limit}

      true ->
        {:error, "Page size of #{limit} was requested, but maximum page size is #{max_limit}."}
    end
  end
end