lib/quarry.ex

defmodule Quarry do
  @moduledoc """
  A data-driven ecto query builder for nested associations.

  Quarry allows you to interact with your database thinking only about your data, and generates queries
  for exactly what you need. You can specify all the filters, loads, and sorts with any level of granularity
  and at any association level, and Quarry will build a query for you that optimizes for joining just the data
  that is necessary and no more. To optimize has_many associations, subqueries are used for preloading the entity.
  This is generally more optimal than joining and selecting all the data because it avoids pulling n*m
  records into memory.
  """
  require Ecto.Query

  alias Quarry.{From, Filter, Load, Sort}

  @type operation :: :lt | :gt | :lte | :gte | :starts_with | :ends_with
  @type filter_param :: String.t() | number
  @type tuple_filter_param :: {operation(), filter_param()}
  @type filter :: %{optional(atom()) => filter_param() | tuple_filter_param()}
  @type load :: atom() | [atom() | keyword(load())]
  @type sort :: atom() | [atom() | [atom()] | {:asc | :desc, atom() | [atom()]}]
  @type opts :: [
          filter: filter(),
          load: load(),
          sort: sort(),
          limit: integer(),
          offset: integer()
        ]

  @type error :: %{type: :filter | :load, path: [atom()], message: String.t()}

  @doc """
  Builds a query for an entity type from parameters


  ## Examples

  ```elixir
  # Top level attribute
  iex> Quarry.build!(Quarry.Post, filter: %{title: "Value"})
  #Ecto.Query<from p0 in Quarry.Post, as: :post, where: as(:post).title == ^"Value">

  # Field on nested belongs_to relationship
  iex> Quarry.build!(Quarry.Post, filter: %{author: %{publisher: "Publisher"}})
  #Ecto.Query<from p0 in Quarry.Post, as: :post, join: a1 in assoc(p0, :author), as: :post_author, where: as(:post_author).publisher == ^"Publisher">

  # Field on nested has_many relationship
  iex> Quarry.build!(Quarry.Post, filter: %{comments: %{body: "comment body"}})
  #Ecto.Query<from p0 in Quarry.Post, as: :post, join: c1 in assoc(p0, :comments), as: :post_comments, where: as(:post_comments).body == ^"comment body">


  # Can filter by explicit operation
  iex> Quarry.build!(Quarry.Post, filter: %{author: %{user: %{login_count: {:eq, 1}}}})
  iex> Quarry.build!(Quarry.Post, filter: %{author: %{user: %{login_count: {:lt, 1}}}})
  iex> Quarry.build!(Quarry.Post, filter: %{author: %{user: %{login_count: {:gt, 1}}}})
  iex> Quarry.build!(Quarry.Post, filter: %{author: %{user: %{login_count: {:lte, 1}}}})
  iex> Quarry.build!(Quarry.Post, filter: %{author: %{user: %{login_count: {:gte, 1}}}})
  iex> Quarry.build!(Quarry.Post, filter: %{title: {:starts_with, "How to"}})
  iex> Quarry.build!(Quarry.Post, filter: %{title: {:ends_with, "learn vim"}})
  ```

  ### Load examples

  ```elixir
  # Single atom
  iex> Quarry.build!(Quarry.Post, load: :author)
  #Ecto.Query<from p0 in Quarry.Post, as: :post, join: a1 in assoc(p0, :author), as: :post_author, preload: [author: a1]>

  # List of atoms
  iex> Quarry.build!(Quarry.Post, load: [:author, :comments])
  #Ecto.Query<from p0 in Quarry.Post, as: :post, join: a1 in assoc(p0, :author), as: :post_author, preload: [comments: #Ecto.Query<from c0 in Quarry.Comment, as: :post_comment>], preload: [author: a1]>

  # Nested entities
  iex> Quarry.build!(Quarry.Post, load: [comments: :user])
  #Ecto.Query<from p0 in Quarry.Post, as: :post, preload: [comments: #Ecto.Query<from c0 in Quarry.Comment, as: :post_comment, join: u1 in assoc(c0, :user), as: :post_comment_user, preload: [user: u1]>]>

  # List of nested entities
  iex> Quarry.build!(Quarry.Post, load: [author: [:user, :posts]])
  #Ecto.Query<from p0 in Quarry.Post, as: :post, join: a1 in assoc(p0, :author), as: :post_author, join: u2 in assoc(a1, :user), as: :post_author_user, preload: [author: [posts: #Ecto.Query<from p0 in Quarry.Post, as: :post_author_post>]], preload: [author: {a1, user: u2}]>

  # Use Quarry on nested has_many association
  iex> Quarry.build!(Quarry.Post, load: [comments: [filter: %{body: "comment"}, load: :user]])
  #Ecto.Query<from p0 in Quarry.Post, as: :post, preload: [comments: #Ecto.Query<from c0 in Quarry.Comment, as: :post_comment, join: u1 in assoc(c0, :user), as: :post_comment_user, where: as(:post_comment).body == ^"comment", preload: [user: u1]>]>
  ```

  ### Sort examples

  ```elixir
  # Single field
  iex> Quarry.build!(Quarry.Post, sort: :title)
  #Ecto.Query<from p0 in Quarry.Post, as: :post, order_by: [asc: as(:post).title]>

  # Multiple fields
  iex> Quarry.build!(Quarry.Post, sort: [:title, :body])
  #Ecto.Query<from p0 in Quarry.Post, as: :post, order_by: [asc: as(:post).title], order_by: [asc: as(:post).body]>

  # Nested fields
  iex> Quarry.build!(Quarry.Post, sort: [[:author, :publisher], :title, [:author, :user, :name]])
  #Ecto.Query<from p0 in Quarry.Post, as: :post, join: a1 in assoc(p0, :author), as: :post_author, join: u2 in assoc(a1, :user), as: :post_author_user, order_by: [asc: as(:post_author).publisher], order_by: [asc: as(:post).title], order_by: [asc: as(:post_author_user).name]>

  # Descending sort
  iex> Quarry.build!(Quarry.Post, sort: [:title, desc: :body, desc: [:author, :publisher]])
  #Ecto.Query<from p0 in Quarry.Post, as: :post, join: a1 in assoc(p0, :author), as: :post_author, order_by: [asc: as(:post).title], order_by: [desc: as(:post).body], order_by: [desc: as(:post_author).publisher]>
  ```

  ### Limit example

  ```elixir
  iex> Quarry.build!(Quarry.Post, limit: 10)
  #Ecto.Query<from p0 in Quarry.Post, as: :post, limit: ^10>
  ```

  ### Offset example

  ```elixir
  iex> Quarry.build!(Quarry.Post, limit: 10, offset: 20)
  #Ecto.Query<from p0 in Quarry.Post, as: :post, limit: ^10, offset: ^20>
  ```

  """
  @spec build!(atom(), opts()) :: Ecto.Query.t()
  def build!(schema, opts \\ []) do
    {query, _errors} = build(schema, opts)
    query
  end

  @spec build(atom(), opts()) :: {Ecto.Query.t(), [error()]}
  def build(schema, opts \\ []) do
    default_opts = %{
      binding_prefix: nil,
      load_path: [],
      filter: %{},
      load: [],
      sort: [],
      limit: nil,
      offset: nil
    }

    opts = Map.merge(default_opts, Map.new(opts))

    {schema, []}
    |> From.build(opts.binding_prefix)
    |> Filter.build(opts.filter, opts.load_path)
    |> Load.build(opts.load, opts.load_path)
    |> Sort.build(opts.sort, opts.load_path)
    |> limit(opts.limit)
    |> offset(opts.offset)
  end

  defp limit({query, errors}, value) when is_integer(value),
    do: {Ecto.Query.limit(query, ^value), errors}

  defp limit(token, _limit), do: token

  defp offset({query, errors}, value) when is_integer(value),
    do: {Ecto.Query.offset(query, ^value), errors}

  defp offset(token, _value), do: token
end