lib/chunkr.ex

defmodule Chunkr do
  @external_resource "README.md"
  @moduledoc "README.md"
             |> File.read!()
             |> String.split("<!-- MDOC !-->")
             |> Enum.fetch!(1)

  require Ecto.Query
  alias Chunkr.{Cursor, Opts, Page}

  @doc false
  defmacro __using__(opts) do
    quote do
      @default_opts [{:repo, __MODULE__}, {:max_limit, 100} | unquote(opts)]

      def paginate!(queryable, query_name, opts) do
        unquote(__MODULE__).paginate!(queryable, query_name, @default_opts ++ opts)
      end

      def paginate(queryable, query_name, opts) do
        unquote(__MODULE__).paginate(queryable, query_name, @default_opts ++ opts)
      end
    end
  end

  @doc """
  Same as `paginate/3`, but raises an error for invalid input.
  """
  def paginate!(queryable, query_name, opts) do
    case paginate(queryable, query_name, opts) do
      {:ok, page} -> page
      {:error, message} -> raise ArgumentError, message
    end
  end

  @doc """
  Paginates an `Ecto.Queryable`.

  Extends the provided `Ecto.Queryable` with the necessary filtering, ordering, and cursor field
  selection for the sake of pagination, then executes the query and returns a `Chunkr.Page` or
  results.

  ## Opts

  See `Chunkr.Opts`
  """

  def paginate(queryable, query_name, opts) do
    case Opts.new(queryable, query_name, opts) do
      {:ok, opts} ->
        extended_rows =
          opts.query
          |> apply_where(opts)
          |> apply_order(opts.paging_dir, opts)
          |> apply_select(opts)
          |> apply_limit(opts.limit + 1)
          |> opts.repo.all()

        requested_rows = Enum.take(extended_rows, opts.limit)

        rows_to_return =
          case opts.paging_dir do
            :forward -> requested_rows
            :backward -> Enum.reverse(requested_rows)
          end

        {:ok,
         %Page{
           raw_results: rows_to_return,
           has_previous_page: has_previous?(opts, extended_rows, requested_rows),
           has_next_page: has_next?(opts, extended_rows, requested_rows),
           start_cursor: List.first(rows_to_return) |> row_to_cursor(),
           end_cursor: List.last(rows_to_return) |> row_to_cursor(),
           opts: opts
         }}

      {:invalid_opts, message} ->
        {:error, message}
    end
  end

  defp has_previous?(%{paging_dir: :forward} = opts, _, _), do: !!opts.cursor
  defp has_previous?(%{paging_dir: :backward}, rows, requested_rows), do: rows != requested_rows

  defp has_next?(%{paging_dir: :forward}, rows, requested_rows), do: rows != requested_rows
  defp has_next?(%{paging_dir: :backward} = opts, _, _), do: !!opts.cursor

  defp row_to_cursor(nil), do: nil
  defp row_to_cursor({cursor_values, _record}), do: Cursor.encode(cursor_values)

  defp apply_where(query, %{cursor: nil}), do: query

  defp apply_where(query, opts) do
    cursor_values = Cursor.decode!(opts.cursor)
    opts.queries.beyond_cursor(query, cursor_values, opts.name, opts.paging_dir)
  end

  defp apply_order(query, :forward, opts) do
    opts.queries.apply_order(query, opts.name)
  end

  # TODO: move this
  defp apply_order(query, :backward, opts) do
    apply_order(query, :forward, opts)
    |> Ecto.Query.reverse_order()
  end

  defp apply_select(query, opts) do
    opts.queries.apply_select(query, opts.name)
  end

  # TODO: Move this
  defp apply_limit(query, limit) do
    Ecto.Query.limit(query, ^limit)
  end
end