lib/endon.ex

defmodule Endon do
  @moduledoc ~S"""
  Endon is an Elixir library that provides helper functions for [Ecto](https://hexdocs.pm/ecto/getting-started.html#content),
  with some inspiration from Ruby on Rails' [ActiveRecord](https://guides.rubyonrails.org/active_record_basics.html).

  It's designed to be used within a module that is an `Ecto.Schema` and provides helpful [functions](`Endon.Functions`).

  See the [overview](readme.html) and [features page](features.html) for examples.
  """

  alias Endon.Helpers

  defmacro __using__(opts \\ []) do
    repo = Keyword.get(opts, :repo, Application.get_env(:endon, :repo))

    quote bind_quoted: [repo: repo] do
      @repo repo

      @typedoc "Query conditions to use when selecting or updating records."
      @type where_conditions() :: Ecto.Query.t() | keyword() | map()

      @doc """
      Calculate the given aggregate over the given column.

      `conditions` are anything accepted by `where/2` (including a `t:Ecto.Query.t/0`).
      """
      @spec aggregate(atom(), :avg | :count | :max | :min | :sum, where_conditions()) ::
              term() | nil
      def aggregate(column, aggregate, conditions \\ []),
        do: Helpers.aggregate(@repo, __MODULE__, column, aggregate, conditions)

      @doc """
      Fetches all entries from the data store matching the given query.

      Limit results to those matching these conditions.  Value can be
      anything accepted by `where/2` (including a `t:Ecto.Query.t/0`).

      ## Options

        * `:order_by` - By default, orders by primary key ascending
        * `:preload` - A list of fields to preload, much like `c:Ecto.Repo.preload/3`
        * `:offset` - Number to offset by

      """
      @spec all(opts :: keyword()) :: list(Ecto.Schema.t())
      def all(opts \\ []),
        do: Helpers.all(@repo, __MODULE__, opts)

      @doc """
      Get the average of a given column.

      `conditions` are anything accepted by `where/2` (including a `t:Ecto.Query.t/0`).
      """
      @spec avg(String.t() | atom(), where_conditions()) :: float() | nil
      def avg(column, conditions \\ []),
        do: aggregate(column, :avg, conditions)

      @doc """
      Get a count of all records matching the conditions.

      You can give an optional column;
      if none is specified, then it's the equivalent of a `select count(*)`.

      `conditions` are anything accepted by `where/2` (including a `t:Ecto.Query.t/0`).
      """
      @spec count(atom() | nil, where_conditions()) :: integer()
      def count(column \\ nil, conditions \\ [])

      def count(nil, conditions), do: Helpers.count(@repo, __MODULE__, conditions)

      def count(column, conditions), do: aggregate(column, :count, conditions)

      @doc """
      Insert a new record into the data store.

      `params` can be either a `Keyword` list, a `Map` of attributes and values, or a struct
      of the same type being used to invoke `create/1`

      Returns `{:ok, struct}` if one is created, or `{:error, changeset}` if there is
      a validation error.
      """
      @spec create(Ecto.Schema.t() | keyword() | map()) ::
              {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
      def create(params),
        do: Helpers.create(@repo, __MODULE__, params)

      @doc """
      Insert a new record into the data store.

      `params` can be either a `Keyword` list, a `Map` of attributes and values, or a struct
      of the same type being used to invoke `create!/1`

      Returns the struct if created, or raises a `Ecto.InvalidChangesetError` if there was
      a validation error.
      """
      @spec create!(Ecto.Schema.t() | keyword() | map()) :: Ecto.Schema.t()
      def create!(params),
        do: Helpers.create!(@repo, __MODULE__, params)

      @doc """
      Delete a record in the data store.

      The `struct` must be a `t:Ecto.Schema.t/0` (your module that uses `Ecto.Schema`).

      Returns `{:ok, struct}` if the record is deleted, or `{:error, changeset}` if there is
      a validation error.
      """
      @spec delete(Ecto.Schema.t()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
      def delete(%{} = struct),
        do: Helpers.delete(@repo, __MODULE__, struct)

      @doc """
      Delete a record in the data store.

      The `struct` must be a `t:Ecto.Schema.t/0` (your module that uses `Ecto.Schema`).

      Returns the struct if it was deleted, or raises a `Ecto.InvalidChangesetError` if there was
      a validation error.
      """
      @spec delete!(Ecto.Schema.t()) :: Ecto.Schema.t()
      def delete!(%{} = struct),
        do: Helpers.delete!(@repo, __MODULE__, struct)

      @doc """
      Delete multiple records in the data store based on conditions.

      Delete all the records that match the given `conditions` (the same as for `where/2`).

      **Note:** If you don't supply any conditions, _all_ records will be deleted.

      It returns a tuple containing the number of entries and any returned result as second element.
      The second element is nil by default unless a select is supplied in the update query.

      ## Examples

          # this line using Ecto.Repo
          from(p in Post, where: p.user_id == 123) |> MyRepo.delete_all

          # is the same as this line in Endon
          Post.delete_where(user_id: 123)

      """
      @spec delete_where(where_conditions()) :: {integer(), nil | [term()]}
      def delete_where(conditions \\ []),
        do: Helpers.delete_where(@repo, __MODULE__, conditions)

      @doc """
      Checks if there exists an entry that matches the given query.

      `conditions` are the same as those accepted by `where/2`.
      """
      @spec exists?(where_conditions()) :: boolean()
      def exists?(conditions),
        do: Helpers.exists?(@repo, __MODULE__, conditions)

      @doc """
      Fetches one or more structs from the data store based on the primary key(s) given.

      If one primary key is given, then one struct will be returned (or `:error` if not found)

      If more than one primary key is given in a list, then all of the structs with those ids
      will be returned (and `:error` will be returned if any one of the primary
      keys can't be found).

      ## Options

        * `:preload` - A list of fields to preload, much like `c:Ecto.Repo.preload/3`

      """
      @spec fetch(integer() | list(integer()), keyword()) ::
              {:ok, list(Ecto.Schema.t())} | {:ok, Ecto.Schema.t()} | :error
      def fetch(module_or_ids, opts \\ [])

      # this is necessary to not break the implementation for the Access protocol
      def fetch(%{} = container, key) when is_atom(key),
        do: Map.fetch(container, key)

      def fetch(id_or_ids, opts),
        do: Helpers.fetch(@repo, __MODULE__, id_or_ids, opts)

      @doc """
      Fetches one or more structs from the data store based on the primary key(s) given.

      Much like `fetch/2`, except an error is raised if the record(s) can't be found.

      If one primary key is given, then one struct will be returned (or a `Ecto.NoResultsError`
      raised if a match isn't found).

      If more than one primary key is given in a list, then all of the structs with those ids
      will be returned (and a `Ecto.NoResultsError` will be raised if any one of the primary
      keys can't be found).

      ## Options

        * `:preload` - A list of fields to preload, much like `c:Ecto.Repo.preload/3`

      """
      @spec find(integer() | list(integer()), keyword()) ::
              list(Ecto.Schema.t()) | Ecto.Schema.t()
      def find(id_or_ids, opts \\ []),
        do: Helpers.find(@repo, __MODULE__, id_or_ids, opts)

      @doc """
      Find a single record based on given conditions.

      If a record can't be found, then `nil` is returned.

      ## Options

        * `:preload` - A list of fields to preload, much like `c:Ecto.Repo.preload/3`

      """
      @spec find_by(where_conditions(), keyword()) :: Ecto.Schema.t() | nil
      def find_by(conditions, opts \\ []),
        do: Helpers.find_by(@repo, __MODULE__, conditions, opts)

      @doc """
      Find or create a record based on specific attributes values.

      Similar to `find_by`, except that if a record cannot be found with the given attributes
      then a new one will be created.

      Returns `{:ok, struct}` if one is found/created, or `{:error, changeset}` if there is
      a validation error.
      """
      @spec find_or_create_by(where_conditions()) :: Ecto.Schema.t()
      def find_or_create_by(params),
        do: Helpers.find_or_create_by(@repo, __MODULE__, params)

      @doc """
      Get the first `count` records.

      If you ask for one thing (`count` of 1),
      you will get back the first record or `nil` if none are found.  If you ask for more
      than one thing (`count` > 1), you'll get back a list of 0 or more records.

      If no order is defined it will order by primary key ascending.

      ## Options

        * `:order_by` - By default, orders by primary key descending
        * `:conditions` - Limit results to those matching these conditions.  Value can be
          anything accepted by `where/2` (including a `t:Ecto.Query.t/0`).

      ## Examples

           # get the first 3 posts, will return a list
           posts = Post.first(3)

           # get the first post, will return one item (or nil if none found)
           post = Post.first()

           # get the first 3 posts by author id 1
           posts = Post.first(3, conditions: [author_id: 1])

      """
      @spec first(integer(), keyword()) :: [Ecto.Schema.t()] | Ecto.Schema.t() | nil
      def first(count \\ 1, opts \\ [])

      def first(count, opts),
        do: Helpers.first(@repo, __MODULE__, count, opts)

      @doc """
      Get the last `count` records.

      If you ask for one thing (`count` of 1),
      you will get back the last record or `nil` if none are found.  If you ask for more
      than one thing (`count` > 1), you'll get back a list of 0 or more records.

      If no order is defined it will order by primary key descending.

      ## Options

        * `:order_by` - By default, orders by primary key descending
        * `:conditions` - Limit results to those matching these conditions.  Value can be
          anything accepted by `where/2` (including a `t:Ecto.Query.t/0`).

      ## Examples

           # get the last 3 posts, will return a list
           posts = Post.last(3)

           # get the last post, will return one item
           post = Post.last()

           # get the last 3 posts by author id 1
           posts = Post.last(3, conditions: [author_id: 1])

      """
      @spec last(integer(), keyword()) :: [Ecto.Schema.t()] | Ecto.Schema.t() | nil
      def last(count \\ 1, opts \\ [])

      def last(count, opts),
        do: Helpers.last(@repo, __MODULE__, count, opts)

      @doc """
      Get the maximum value of a given column.

      `conditions` are anything accepted by `where/2` (including a `t:Ecto.Query.t/0`).
      """
      @spec max(String.t() | atom(), where_conditions()) :: number() | nil
      def max(column, conditions \\ []),
        do: aggregate(column, :max, conditions)

      @doc """
      Get the minimum value of a given column.

      `conditions` are anything accepted by `where/2` (including a `t:Ecto.Query.t/0`).
      """
      @spec min(String.t() | atom(), where_conditions()) :: number() | nil
      def min(column, conditions \\ []),
        do: aggregate(column, :min, conditions)

      @doc """
      Take a query and add conditions (the same as `where/2` accepts).

      This will not actually run the query, so you will need
      to pass the result to `where/2` or `c:Ecto.Repo.all/2`/`c:Ecto.Repo.one/2`.

      For instance:

          existing_query = from x in Post
          Post.scope(existing_query, id: 1) |> Post.first()

      This is just a helpful function to make adding conditions easier to an existing query.
      """
      @spec scope(Ecto.Query.t() | module(), where_conditions()) :: Ecto.Query.t()
      def scope(query, conditions),
        do: Helpers.scope(query, conditions)

      @doc """
      Create a query with the given conditions (the same as `where/2` accepts).

      This will not actually run the query, so you will need
      to pass the result to `where/2` or `c:Ecto.Repo.all/2`/`c:Ecto.Repo.one/2`.

      For instance, this will just run one query to find a record with id 1 with name Bill.

          Post.scope(id: 1) |> Post.scope(name: 'Bill') |> Post.first()

      This is just a helpful function to make adding conditions easier to an existing `Ecto.Schema`
      """
      @spec scope(where_conditions()) :: Ecto.Query.t()
      def scope(conditions),
        do: scope(__MODULE__, conditions)

      @doc """
      Create a `Stream` that queries the data store in batches for matching records.

      This is useful for paginating through a very large result set in chunks.  The `Stream`
      is a composable, lazy enumerable that allows you to iterate through what could be a
      very large number of records efficiently.

      The `conditions` are anything accepted by `where/2` (including a `t:Ecto.Query.t/0`).
      This function will only work for types that have a primary key that is an integer.

      ## Options

        * `:batch_size` - Specifies the size of the batch. Defaults to 1000.
        * `:start` - Specifies the primary key value to start from, inclusive of the value.
        * `:finish` - Specifies the primary key value to end at, inclusive of the value.

      ## Examples

          iex> Enum.each(User.stream_where(), &User.do_some_processing/1)

          iex> query = from u in User, where: u.id > 100
          iex> Enum.each(User.stream_where(query, batch_size: 10), fn user ->
          iex>   User.do_some_processing(user)
          iex> end)

      """
      @spec stream_where(where_conditions(), keyword()) :: Enumerable.t()
      def stream_where(conditions \\ [], opts \\ []),
        do: Helpers.stream_where(@repo, __MODULE__, conditions, opts)

      @doc """
      Get the sum of a given column.

      `conditions` are anything accepted by `where/2` (including a `t:Ecto.Query.t/0`).
      """
      @spec sum(String.t() | atom(), where_conditions()) :: number() | nil
      def sum(column, conditions \\ []),
        do: aggregate(column, :sum, conditions)

      @doc """
      Update a record in the data store.

      The `struct` must be a `t:Ecto.Schema.t/0` (your module that uses `Ecto.Schema`).
      `params` can be either a `Keyword` list or `Map` of attributes and values.

      Returns `{:ok, struct}` if one is created, or `{:error, changeset}` if there is
      a validation error.
      """
      @spec update(Ecto.Schema.t(), keyword() | map()) ::
              {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
      def update(%{} = struct, params),
        do: Helpers.update(@repo, __MODULE__, struct, params)

      @doc """
      Update a record in the data store.

      The `struct` must be a `t:Ecto.Schema.t/0` (your module that uses `Ecto.Schema`).
      `params` can be either a `Keyword` list or `Map` of attributes and values.

      Returns the struct if it was updated, or raises a `Ecto.InvalidChangesetError` if there was
      a validation error.
      """
      @spec update!(Ecto.Schema.t(), keyword() | map()) :: Ecto.Schema.t()
      def update!(%{} = struct, params),
        do: Helpers.update!(@repo, __MODULE__, struct, params)

      @doc """
      Update multiple records in the data store based on conditions.

      Update all the records that match the given `conditions`, setting the given `params`
      as attributes.  `params` can be either a `Keyword` list or `Map` of attributes and values,
      and `conditions` is the same as for `where/2`.

      It returns a tuple containing the number of entries and any returned result as second element.
      The second element is nil by default unless a select is supplied in the update query.
      """
      @spec update_where(keyword() | map(), where_conditions()) :: {integer(), nil | [term()]}
      def update_where(params, conditions \\ []),
        do: Helpers.update_where(@repo, __MODULE__, params, conditions)

      @doc """
      Fetch all entries that match the given conditions.

      The conditions can be a `t:Ecto.Query.t/0` or a `t:Keyword.t/0`.

      ## Options

        * `:order_by` - By default, orders by primary key ascending
        * `:preload` - A list of fields to preload, much like `c:Ecto.Repo.preload/3`
        * `:offset` - Number to offset by
        * `:limit` - Limit results to the given count

      ## Examples

          iex> User.where(id: 1)
          iex> User.where(name: "billy", age: 23)
          iex> User.where([name: "billy", age: 23], limit: 10, order_by: [desc: :id])
          iex> query = from u in User, where: u.id > 10
          iex> User.where(query, limit: 1)
      """
      @spec where(where_conditions(), keyword()) :: list(Ecto.Schema.t())
      def where(conditions, opts \\ []),
        do: Helpers.where(@repo, __MODULE__, conditions, opts)
    end
  end
end