lib/query_builder.ex

defmodule QueryBuilder do
  require Ecto.Query
  alias Ecto.Query

  defmacro __using__(opts) do
    quote do
      require QueryBuilder.Schema
      QueryBuilder.Schema.__using__(unquote(opts))
    end
  end

  def new(ecto_query) do
    %QueryBuilder.Query{ecto_query: ensure_query_has_binding(ecto_query)}
  end

  def paginate(%QueryBuilder.Query{} = query, repo, opts \\ []) do
    page_size = Keyword.get(opts, :page_size, default_page_size())
    cursor_direction = Keyword.get(opts, :direction, :after)

    unless cursor_direction in [:after, :before] do
      raise ArgumentError, "cursor direction #{inspect cursor_direction} is invalid"
    end

    page_size =
      if max_page_size = Keyword.get(opts, :max_page_size) do
        min(max_page_size, page_size)
      else
        page_size
      end

    cursor =
      case Keyword.get(opts, :cursor) || %{} do
        cursor when is_map(cursor) ->
          cursor

        cursor ->
          with {:ok, decoded_string} <- Base.url_decode64(cursor),
               {:ok, cursor} <- Jason.decode(decoded_string) do
              cursor
          else
            _ -> %{}
          end
      end

    query = limit(query, page_size + 1)

    already_sorting_on_id? =
      Enum.any?(query.operations, fn
        %{type: :order_by, args: [keyword_list]} ->
          Enum.member?(Keyword.values(keyword_list), :id)

        _ ->
          false
      end)

    query =
      if already_sorting_on_id? do
        query
      else
        order_by(query, asc: :id)
      end

    # reverse sorting order if direction is before

    operations =
      if cursor_direction == :before do
        query.operations
        |> Enum.map(fn
          %{type: :order_by, args: [keyword_list]} = operation ->
            updated_keyword_list =
              Enum.map(keyword_list, fn {direction, field} ->
                if String.contains?(to_string(field), "@") do
                  {direction, field}
                else
                  case direction do
                    :asc -> {:desc, field}
                    :desc -> {:asc, field}
                  end
                end
              end)

            Map.put(operation, :args, [updated_keyword_list])

          operation ->
            operation
        end)
      else
        query.operations
      end

    query = Map.put(query, :operations, operations)

    order_by_list =
      query.operations
      |> Enum.filter(&match?(%{type: :order_by}, &1))
      |> Enum.reverse()
      |> Enum.flat_map(&Map.fetch!(&1, :args))
      |> Enum.flat_map(&Enum.reject(&1, fn {_direction, field} -> String.contains?(to_string(field), "@") end))
      |> Enum.uniq_by(fn {_direction, field} -> field end)

    cursor_fields = Map.keys(cursor)
    valid_cursor? = Enum.all?(Keyword.values(order_by_list), &Enum.member?(cursor_fields, to_string(&1)))

    query =
      if valid_cursor? do
        {_, filters} =
          Enum.reduce(order_by_list, {[], []}, fn {order_direction, field}, {prev_fields, filters} ->
            operator =
              cond do
                order_direction == :desc && cursor_direction == :after ->
                  :lt

                order_direction == :asc && cursor_direction == :after ->
                  :gt

                # we reversed the sorting order when the cursor direction is :before

                order_direction == :desc && cursor_direction == :before ->
                  :lt

                order_direction == :asc && cursor_direction == :before ->
                  :gt
              end

            filter =
              Enum.map(prev_fields, &{&1, cursor[to_string(&1)]})
              |> Enum.concat([{field, operator, cursor[to_string(field)]}])

            {prev_fields ++ [field], filters ++ [filter]}
          end)

        [first_filter | rest_filters] = filters

        or_filters = Enum.map(rest_filters, &{:or, &1})

        where(query, [], first_filter, or_filters)
      else
        query
      end

    entries = repo.all(query)

    entries =
      if cursor_direction == :before do
        Enum.reverse(entries)
      else
        entries
      end

    has_more? = length(entries) == page_size + 1

    entries =
      if has_more? do
        case cursor_direction do
          :before ->
            tl(entries)

          :after ->
            List.delete_at(entries, -1)
        end
      else
        entries
      end

    first_entry = List.first(entries)
    last_entry = List.last(entries)

    build_cursor = fn entry ->
      if entry do
        order_by_list
        |> Enum.map(fn {_, field} -> {field, Map.get(entry, field)} end)
        |> Enum.into(%{})
        |> Jason.encode!()
        |> Base.url_encode64()
      end
    end

    %{
      pagination: %{
        cursor_direction: cursor_direction,
        cursor_for_entries_before: build_cursor.(first_entry),
        cursor_for_entries_after: build_cursor.(last_entry),
        has_more_entries: has_more?,
        max_page_size: page_size
      },
      paginated_entries: entries
    }
  end

  def default_page_size() do
    Application.get_env(:query_builder, :default_page_size, 100)
  end

  @doc ~S"""
  Preloads the associations.

  Bindings are automatically set if joins have been made, or if it is preferable to
  join (i.e. one-to-one associations are preferable to include into the query result
  rather than emitting separate DB queries).

  Example:
  ```
  QueryBuilder.preload(query, [role: :permissions, articles: [:stars, comments: :user]])
  ```
  """
  def preload(%QueryBuilder.Query{} = query, assoc_fields) do
    %{query | operations: [%{type: :preload, assocs: assoc_fields, args: []} | query.operations]}
  end

  def preload(ecto_query, assoc_fields) do
    ecto_query = ensure_query_has_binding(ecto_query)
    preload(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields)
  end

  @doc ~S"""
  An AND where query expression.

  Example:
  ```
  QueryBuilder.where(query, firstname: "John")
  ```
  """
  def where(query, filters) do
    where(query, [], filters)
  end

  @doc ~S"""
  An AND where query expression.

  Associations are passed in second argument; fields from these associations can then
  be referenced by writing the field name, followed by the "@" character and the
  association name, as an atom. For example: `:name@users`.

  Example:
  ```
  QueryBuilder.where(query, [role: :permissions], name@permissions: :write)
  ```

  OR clauses may be passed through last argument `opts`. For example:

  ```elixir
  QueryBuilder.where(query, [], [firstname: "John"], or: [firstname: "Alice", lastname: "Doe"], or: [firstname: "Bob"])
  ```
  """
  def where(query, assoc_fields, filters, or_filters \\ [])

  def where(%QueryBuilder.Query{} = query, _assoc_fields, [], []) do
    query
  end

  def where(%QueryBuilder.Query{} = query, assoc_fields, filters, or_filters) do
    %{query | operations: [%{type: :where, assocs: assoc_fields, args: [filters, or_filters]} | query.operations]}
  end

  def where(ecto_query, assoc_fields, filters, or_filters) do
    ecto_query = ensure_query_has_binding(ecto_query)
    where(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields, filters, or_filters)
  end

  @doc ~S"""
  Run `QueryBuilder.where/2` only if given condition is met.
  """
  def maybe_where(query, true, filters) do
    where(query, [], filters)
  end

  def maybe_where(query, false, _), do: query

  def maybe_where(query, condition, assoc_fields, filters, or_filters \\ [])

  @doc ~S"""
  Run `QueryBuilder.where/4` only if given condition is met.
  """
  def maybe_where(query, true, assoc_fields, filters, or_filters) do
    where(query, assoc_fields, filters, or_filters)
  end

  def maybe_where(query, false, _, _, _), do: query

  @doc ~S"""
  An order by query expression.

  Example:
  ```
  QueryBuilder.order_by(query, asc: :lastname, asc: :firstname)
  ```
  """
  def order_by(query, value) do
    order_by(query, [], value)
  end

  @doc ~S"""
  An order by query expression.

  For more about the second argument, see `where/3`.

  Example:
  ```
  QueryBuilder.order_by(query, :articles, asc: :title@articles)
  ```
  """
  def order_by(%QueryBuilder.Query{} = query, _assoc_fields, []) do
    query
  end

  def order_by(%QueryBuilder.Query{} = query, assoc_fields, value) do
    %{query | operations: [%{type: :order_by, assocs: assoc_fields, args: [value]} | query.operations]}
  end

  def order_by(ecto_query, assoc_fields, value) do
    ecto_query = ensure_query_has_binding(ecto_query)
    order_by(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields, value)
  end

  @doc ~S"""
  Run `QueryBuilder.order_by/2` only if given condition is met.
  """
  def maybe_order_by(query, true, value) do
    order_by(query, [], value)
  end

  def maybe_order_by(query, false, _), do: query

  @doc ~S"""
  Run `QueryBuilder.order_by/3` only if given condition is met.
  """
  def maybe_order_by(query, true, assoc_fields, value) do
    order_by(query, assoc_fields, value)
  end

  def maybe_order_by(query, false, _, _), do: query

  @doc ~S"""
  A limit query expression.
  If multiple limit expressions are provided, the last expression is evaluated

  Example:
  ```
  QueryBuilder.limit(query, 10)
  ```
  """
  def limit(%QueryBuilder.Query{} = query, value) do
    # Limit order must be maintained, similar to Ecto:

    # - https://hexdocs.pm/ecto/Ecto.Query-macro-limit.html

    %{query | operations: [%{type: :limit, assocs: [], args: [value]} | query.operations]}
  end

  def limit(ecto_query, value) do
    limit(%QueryBuilder.Query{ecto_query: ecto_query}, value)
  end

  @doc ~S"""
  A offset query expression.
  If multiple offset expressions are provided, the last expression is evaluated

  Example:
  ```
  QueryBuilder.offset(query, 10)
  ```
  """
  def offset(%QueryBuilder.Query{} = query, value) do
    # Offset order must be maintained, similar to Ecto:

    # - https://hexdocs.pm/ecto/Ecto.Query.html#offset/3

    %{query | operations: [%{type: :offset, assocs: [], args: [value]} | query.operations]}
  end

  def offset(ecto_query, value) do
    offset(%QueryBuilder.Query{ecto_query: ecto_query}, value)
  end

  @doc ~S"""
  A join query expression.

  Example:
  ```
  QueryBuilder.left_join(query, :articles, title@articles: "Foo", or: [title@articles: "Bar"])
  ```
  """
  def left_join(query, assoc_fields, filters \\ [], or_filters \\ [])

  def left_join(%QueryBuilder.Query{} = query, assoc_fields, filters, or_filters) do
    %{query | operations: [%{type: :left_join, assocs: assoc_fields, join_filters: [List.wrap(filters), List.wrap(or_filters)]} | query.operations]}
  end

  def left_join(ecto_query, assoc_fields, filters, or_filters) do
    ecto_query = ensure_query_has_binding(ecto_query)
    left_join(%QueryBuilder.Query{ecto_query: ecto_query}, assoc_fields, filters, or_filters)
  end

  @doc ~S"""
  Allows to pass a list of operations through a keyword list.

  Example:
  ```
  QueryBuilder.from_list(query, [
    where: [name: "John", city: "Anytown"],
    preload: [articles: :comments]
  ])
  ```
  """
  def from_list(query, []), do: query

  def from_list(query, [{operation, arguments} | tail]) do
    arguments =
      cond do
        is_tuple(arguments) -> Tuple.to_list(arguments)
        is_list(arguments) -> [arguments]
        true -> List.wrap(arguments)
      end

    apply(__MODULE__, operation, [query | arguments])
    |> from_list(tail)
  end

  defp ensure_query_has_binding(query) do
    schema = QueryBuilder.Utils.root_schema(query)

    unless Query.has_named_binding?(query, schema._binding()) do
      schema._query()
    else
      query
    end
  end
end