lib/quarto.ex

defmodule Quarto do
  @external_resource "./README.md"
  @moduledoc """
  #{File.read!(@external_resource) |> String.split("---", parts: 2) |> List.last()}
  """

  defmacro __using__(opts) do
    quote do
      @defaults unquote(opts)

      def paginate(queryable, opts \\ [], repo_opts \\ []) do
        opts = Keyword.merge(@defaults, opts)

        Quarto.paginate(queryable, opts, __MODULE__, repo_opts)
      end
    end
  end

  @spec paginate(Ecto.Query.t(), nil | maybe_improper_list, Ecto.Repo.t(), Keyword.t()) ::
          Quarto.Page.t()
  @doc """
  Paginate/4

  Fetches all the results matching the query within the cursors.

  Options

    * `:after` - Fetch the records after this cursor.
    * `:before` - Fetch the records before this cursor.
    * `:coalesce` - Function that receives the field
    * `:cursor` - Module to use for encoding/decoding the cursor
    * `:cursor_fields` - Module to use for building the cursor from a record
    * `:include_total_count` - Set this to true to return the total number of records matching the query. Note that this number will be capped by :total_count_limit. Defaults to false.
    * `:include_entries` - Set this to false to skip fetching the records. Useful if you only need the total counts. Defaults to true.
    * `:total_count_primary_key_field` - Running count queries on specified column of the table
    * `:limit` - Limits the number of records returned per page. Note that this number will be capped by :maximum_limit. Defaults to `50`.
    * `:maximum_limit` - Sets a maximum cap for :limit. This option can be useful when :limit is set dynamically (e.g from a URL param set by a user) but you still want to enfore a maximum. Defaults to 500.
    * `:total_count_limit` - Running count queries on tables with a large number of records is expensive so it is capped by default. Can be set to `:infinity` in order to count all the records. Defaults to 10,000.

  Repo options

  This will be passed directly to Ecto.Repo.all/2, as such any option supported by this function can be used here.
  """
  def paginate(queryable, opts, repo, repo_opts \\ []) do
    config = Quarto.Config.new([queryable: queryable] ++ opts)

    sorted_entries = entries(queryable, config, repo, repo_opts)
    paginated_entries = paginate_entries(sorted_entries, config)

    {total_count, total_count_cap_exceeded} =
      Quarto.Ecto.QueryTotal.total_count(queryable, config, repo, repo_opts)

    %Quarto.Page{
      entries: paginated_entries,
      metadata: %Quarto.Page.Metadata{
        after: after_cursor(paginated_entries, sorted_entries, config),
        before: before_cursor(paginated_entries, sorted_entries, config),
        limit: config.limit,
        total_count: total_count,
        total_count_cap_exceeded: total_count_cap_exceeded
      }
    }
  end

  @doc """
  Build the cursor for a given entry

  It's a helper function for taking the (nested) fields used for ordering and constructing the
  list that can be passed to the cursor encoder.

  In addition to this it's also possible to pass in the queryable used to generate the original query and cursors
  and derive the (nested) fields from that.

  Building the opaque cursor from the list `cursor_for_entry/3` generates can be done by e.g. the `Quarto.Cursor.Base64` module
  or any other module that implements the `Quarto.Cursor` behaviour.

      iex> Quarto.cursor_for_entry(%User{id: 1}, :id)
      [1]
      iex> Quarto.cursor_for_entry(%User{id: 1}, [:id, :name])
      [1, nil]
      iex> Quarto.cursor_for_entry(%User{id: 1, profile: %Profile{title: "A profile"}}, {:profile, :title})
      ["A profile"]
      iex> Quarto.cursor_for_entry(%User{id: 1, profile: %Profile{title: "A profile"}}, [[:profile, :title], :id])
      ["A profile", 1]
      iex> Quarto.cursor_for_entry(%User{id: 1, profile: %Profile{title: "A profile"}}, [:id, {:profile, :title}])
      [1, "A profile"]
      iex> cursor = Quarto.cursor_for_entry(%Post{id: 2, user: %User{id: 1, profile: %Profile{title: "A profile"}}}, {:user, {:profile, :title}})
      ["A profile"]
      iex> Quarto.Cursor.Base64.encode!(cursor)
      "g2wAAAABbQAAAAlBIHByb2ZpbGVq"

      iex> queryable = Post |> order_by({:desc, :position})
      iex> Quarto.cursor_for_entry(%Post{id: 2, position: 3}, queryable)
      [3]

      iex> queryable = Quarto.Post
      ...> |> join(:left, [p], u in assoc(p, :user), as: :user)
      ...> |> preload([p, u], user: u)
      ...> |> order_by([p, u], desc: u.name)
      ...> |> order_by({:desc, :position})
      iex> cursor = Quarto.cursor_for_entry(%Post{id: 2, position: 3, user: %User{name: "A. Hamilton"}}, queryable)
      ["A. Hamilton", 3]
      iex> Quarto.Cursor.Base64.encode!(cursor)
      "g2wAAAACbQAAAAtBLiBIYW1pbHRvbmEDag=="
  """
  def cursor_for_entry(entry, queryable, opts \\ [])

  def cursor_for_entry(entry, %Ecto.Query{} = queryable, opts) do
    %{cursor_builder: {m, f, _}} = config = Quarto.Config.new([queryable: queryable] ++ opts)

    Kernel.apply(m, f, [entry, config])
  end

  def cursor_for_entry(entry, cursor_fields, _opts)
      when is_list(cursor_fields) do
    Enum.map(cursor_fields, &cursor_for_entry_path(entry, &1))
  end

  def cursor_for_entry(entry, cursor_fields, opts) do
    cursor_for_entry(entry, [cursor_fields], opts)
  end

  defp cursor_for_entry_path(entry, field) when is_atom(field) do
    Map.get(entry, field)
  end

  defp cursor_for_entry_path(entry, {field, key}) do
    path = to_list({field, key})
    cursor_for_entry_path(entry, path)
  end

  defp cursor_for_entry_path(entry, path) when is_list(path) do
    path = Enum.map(path, &Access.key/1)
    get_in(entry, path)
  end

  defp to_list(nest) when is_tuple(nest) do
    case Tuple.to_list(nest) do
      [field, {a, b}] -> [field | to_list({a, b})]
      [field, value] -> [field, value]
    end
  end

  defp build_cursor_value(entry, %{cursor_builder: {m, f, _}} = config) do
    Kernel.apply(m, f, [entry, config]) |> config.cursor.encode!(config)
  end

  defp after_cursor([], [], _config), do: nil

  defp after_cursor(paginated_entries, _sorted_entries, %{before: c_before} = config)
       when not is_nil(c_before) do
    last_or_nil(paginated_entries, config)
  end

  defp after_cursor(paginated_entries, sorted_entries, config) do
    if last_page?(sorted_entries, config) do
      nil
    else
      last_or_nil(paginated_entries, config)
    end
  end

  defp before_cursor([], [], _config), do: nil

  defp before_cursor(_paginated_entries, _sorted_entries, %{after: nil, before: nil}),
    do: nil

  defp before_cursor(paginated_entries, _sorted_entries, %{after: c_after} = config)
       when not is_nil(c_after) do
    first_or_nil(paginated_entries, config)
  end

  defp before_cursor(paginated_entries, sorted_entries, config) do
    if first_page?(sorted_entries, config) do
      nil
    else
      first_or_nil(paginated_entries, config)
    end
  end

  defp first_or_nil(entries, config) do
    if first = List.first(entries) do
      build_cursor_value(first, config)
    else
      nil
    end
  end

  defp last_or_nil(entries, config) do
    if last = List.last(entries) do
      build_cursor_value(last, config)
    else
      nil
    end
  end

  defp first_page?(sorted_entries, %{limit: limit}) do
    Enum.count(sorted_entries) <= limit
  end

  defp last_page?(sorted_entries, %{limit: limit}) do
    Enum.count(sorted_entries) <= limit
  end

  defp entries(_, %{include_entries: false}, _, _) do
    []
  end

  defp entries(queryable, config, repo, repo_opts) do
    queryable
    |> Quarto.Ecto.Query.paginate(config)
    |> repo.all(repo_opts)
  end

  defp paginate_entries(sorted_entries, %{after: nil, before: before, limit: limit})
       when not is_nil(before) do
    sorted_entries
    |> Enum.take(limit)
    |> Enum.reverse()
  end

  defp paginate_entries(sorted_entries, %{limit: limit}) do
    Enum.take(sorted_entries, limit)
  end
end