lib/actions.ex

defmodule EctoShorts.Actions do
  @moduledoc """
  Actions for CRUD in ecto, these can be used by all schemas/queries
  """

  @type query :: Ecto.Query | Ecto.Schema
  @type filter_params :: Keyword.t | map
  @type opts :: Keyword.t
  @type aggregate_options :: :avg | :count | :max | :min | :sum
  @type schema_list :: list(Ecto.Schema.t)
  @type schema_res :: {:ok, Ecto.Schema.t} | {:error, any}
  @type id :: pos_integer
  @type schema :: Ecto.Schema.t() | module()
  @type schema_data :: Ecto.Schema.t()
  @type updates :: map() | Keyword.t()


  alias EctoShorts.{CommonFilters, Actions.Error, Config}

  @doc """
  Gets a schema from the database

  ## Examples

      iex> user = create_user()
      iex> %{id: id} = EctoSchemas.Actions.get(EctoSchemas.Accounts.User, user.id)
      iex> id === user.id
      true
      iex> EctoSchemas.Actions.get(EctoSchemas.Accounts.User, 2504390) # ID nonexistant
      nil
  """
  @spec get(queryable :: query, id :: term, options :: Keyword.t) :: Ecto.Schema.t | nil
  @spec get(queryable :: query, id :: term) :: Ecto.Schema.t | nil
  def get(schema, id, opts \\ []) do
    replica!(opts).get(schema, id, opts)
  end

  @doc """
  Gets a collection of schemas from the database

  ## Examples

      iex> EctoSchemas.Actions.all(EctoSchemas.Accounts.User)
      []
  """
  @spec all(queryable :: query) :: schema_list
  def all(query) do
    all(query, default_opts())
  end

  @doc """
  Gets a collection of schemas from the database but allows for a filter or
  an options list.

  ## Options
    * `:repo` - A module that uses the Ecto.Repo Module.

  ## Examples

      iex> Enum.each(1..4, fn _ -> create_user() end)
      iex> length(EctoSchemas.Actions.all(EctoSchemas.Accounts.User, first: 3)) === 3
      true

      iex> Enum.each(1..4, fn _ -> create_user() end)
      iex> length(EctoSchemas.Actions.all(EctoSchemas.Accounts.User, repo: MyApp.MyRepoModule.Repo)) === 3
      true
  """
  @spec all(queryable :: query, filter_params | opts) :: schema_list
  def all(query, params) when is_map(params) do
    all(query, params, default_opts())
  end

  def all(query, opts) do
    query_params = Keyword.drop(opts, [:repo, :replica])

    if Enum.any?(query_params) do
      all(query, query_params, default_opts())
    else
      all(query, %{}, Keyword.take(opts, [:repo, :replica]))
    end
  end

  @doc """
  Similar to `all/2` but can also accept a keyword options list.

  ## Options
    * `:repo` - A module that uses the Ecto.Repo Module.

  ## Examples

    iex> Enum.each(1..4, fn _ -> create_user() end)
    iex> length(EctoSchemas.Actions.all(EctoSchemas.Accounts.User, first: 3, repo: MyApp.MyRepoModule.Repo)) === 3
    true
  """
  @spec all(queryable :: query, params :: filter_params, opts) :: schema_list
  def all(query, params, opts)  do
    order_by = Keyword.get(opts, :order_by, nil)

    params = if order_by, do: Map.put(params || %{}, :order_by, order_by), else: params

    replica!(opts).all(
      CommonFilters.convert_params_to_filter(query, params),
      opts
    )
  end

  @doc """
  Finds a schema with matching params. Can also accept a keyword options list.

  ## Options
    * `:repo` - A module that uses the Ecto.Repo Module.

  ## Examples

      iex> user = create_user()
      iex> {:ok, schema} = EctoSchemas.Actions.find(EctoSchemas.Accounts.User, first_name: user.first_name)
      iex> schema.first_name === user.first_name
      true

      iex> user = create_user()
      iex> {:ok, schema} = EctoSchemas.Actions.find(EctoSchemas.Accounts.User, first_name: user.first_name, repo: MyApp.MyRepoModule.Repo)
      iex> schema.first_name === user.first_name
      true
  """
  @spec find(queryable :: query, params :: filter_params, opts) :: schema_res | {:error, any}
  @spec find(queryable :: query, params :: filter_params) :: schema_res | {:error, any}
  def find(query, params, opts \\ [])
  def find(query, params, _options) when params === %{} and is_atom(query) do
    {:error, Error.call(:not_found, "no records found", %{
      query: query,
      params: params
    })}
  end
  def find(query, params, opts) do
    order_by = Keyword.get(opts, :order_by, nil)

    params = if order_by, do: Map.put(params || %{}, :order_by, order_by), else: params

    query
    |> CommonFilters.convert_params_to_filter(params)
    |> replica!(opts).one(opts)
    |> case do
      nil ->
        {:error, Error.call(:not_found, "no records found", %{
          query: query,
          params: params
        })}
      schema -> {:ok, schema}
    end
  end

  @doc """
  Creates a schema with given params. Can also accept a keyword options list.

  ## Options
    * `:repo` - A module that uses the Ecto.Repo Module.

  ## Examples

      iex> {:ok, schema} = EctoSchemas.Actions.create(EctoSchemas.Accounts.User, user_params(first_name: "TEST"))
      iex> schema.first_name
      "TEST"
      iex> {:error, changeset} = EctoSchemas.Actions.create(EctoSchemas.Accounts.User, Map.delete(user_params(), :first_name))
      iex> "can't be blank" in errors_on(changeset).first_name
      true

  ## Examples

      iex> {:ok, schema} = EctoSchemas.Actions.create(EctoSchemas.Accounts.User, user_params(first_name: "TEST"), repo: MyApp.MyRepoModule.Repo)
      iex> schema.first_name
      true
  """
  @spec create(schema :: Ecto.Schema.t, params :: filter_params, opts) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
  @spec create(schema :: Ecto.Schema.t, params :: filter_params) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
  def create(schema, params, opts \\ []) do
    repo!(opts).insert(create_changeset(params, schema), opts)
  end

  @doc """
  Finds a schema by params or creates one if it isn't found.
  Can also accept a keyword options list.

  ***Note: Relational filtering doesn't work on this function***

  ## Options
    * `:repo` - A module that uses the Ecto.Repo Module.
    * `:replica` - If you don't want to perform any reads against your Primary, you can specify a replica to read from.

  ## Examples
      iex> {:ok, schema} = EctoSchemas.Actions.find_or_create(EctoSchemas.Accounts.User, %{name: "great name"})

      iex> {:ok, schema} = EctoSchemas.Actions.find_or_create(EctoSchemas.Accounts.User, %{name: "great name"}, repo: MyApp.MyRepoModule.Repo, replica: MyApp.MyRepoModule.Repo.replica())
  """
  @spec find_or_create(Ecto.Schema.t, map, opts) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
  @spec find_or_create(Ecto.Schema.t, map) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
  def find_or_create(schema, params, opts \\ []) do
    find_params = Map.drop(params, schema.__schema__(:associations))

    with {:error, %{code: :not_found}} <- find(schema, find_params, opts) do
      create(schema, params, opts)
    end
  end

  @doc """
  Finds a schema by params and updates it or creates with results of
  params/update_params merged. Can also accept a keyword options list.

  ***Note: Relational filtering doesn't work on this function***

  ## Options
    * `:repo` - A module that uses the Ecto.Repo Module.
    * `:replica` - If you don't want to perform any reads against your Primary, you can specify a replica to read from.

  ## Examples
      iex> {:ok, schema} = EctoSchemas.Actions.find_and_update(EctoSchemas.Accounts.User, %{email: "some_email"}, %{name: "great name"})

      iex> {:ok, schema} = EctoSchemas.Actions.find_and_update(EctoSchemas.Accounts.User, %{email: "some_email"}, %{name: "great name}, repo: MyApp.MyRepoModule.Repo, replica: MyApp.MyRepoModule.Repo.replica())
  """
  @spec find_and_update(Ecto.Schema.t(), map, map, opts) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
  def find_and_update(schema, params, update_params, opts \\ []) do
    find_params = Map.drop(params, schema.__schema__(:associations))

    with {:ok, transaction} <- find(schema, find_params, opts) do
      update(schema, transaction, update_params, opts)
    end
  end

  @doc """
  Finds a schema by params and updates it or creates with results of
  params/update_params merged. Can also accept a keyword options list.

  ***Note: Relational filtering doesn't work on this function***

  ## Options
    * `:repo` - A module that uses the Ecto.Repo Module.
    * `:replica` - If you don't want to perform any reads against your Primary, you can specify a replica to read from.

  ## Examples
      iex> {:ok, schema} = EctoSchemas.Actions.find_and_upsert(EctoSchemas.Accounts.User, %{email: "some_email"}, %{name: "great name"})

      iex> {:ok, schema} = EctoSchemas.Actions.find_and_upsert(EctoSchemas.Accounts.User, %{email: "some_email"}, %{name: "great name}, repo: MyApp.MyRepoModule.Repo, replica: MyApp.MyRepoModule.Repo.replica())
  """
  @spec find_and_upsert(Ecto.Schema.t(), map, map, opts) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
  def find_and_upsert(schema, params, update_params, opts \\ []) do
    find_params = Map.drop(params, schema.__schema__(:associations))

    case find(schema, find_params, opts) do
      {:ok, transaction} -> update(schema, transaction, update_params, opts)
      {:error, %{code: :not_found}} -> create(schema, Map.merge(params, update_params), opts)
      e -> e
    end
  end


  @doc """
  Updates a schema with given updates. Can also accept a keyword options list.

  ## Options
    * `:repo` - A module that uses the Ecto.Repo Module.
    * `:replica` - If you don't want to perform any reads against your Primary, you can specify a replica to read from.

  ## Examples

      iex> user = create_user()
      iex> {:ok, schema} = EctoSchemas.Actions.update(EctoSchemas.Accounts.User, user, first_name: user.first_name)
      iex> schema.first_name === user.first_name
      true

      iex> user = create_user()
      iex> {:ok, schema} = EctoSchemas.Actions.update(EctoSchemas.Accounts.User, 1, first_name: user.first_name, repo: MyApp.MyRepoModule.Repo, replica: MyApp.MyRepoModule.Repo.replica())
      iex> schema.first_name === user.first_name
      true
  """

  @spec update(schema, id, updates) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
  @spec update(schema, id, updates, opts) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
  @spec update(schema, schema_data, updates) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
  @spec update(schema, schema_data, updates, opts) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}

  def update(schema, schema_data, updates, opts \\ [])


  def update(schema, schema_id, updates, opts) when is_integer(schema_id) or is_binary(schema_id) do
    case get(schema, schema_id, opts) do
      nil ->
        {:error, Error.call(
          :not_found,
          "No item found with id: #{schema_id}",
          %{
            schema: schema,
            schema_id: schema_id,
            updates: updates
          }
        )}
      schema_data -> update(schema, schema_data, updates, opts)
    end
  end


  def update(schema, schema_data, updates, opts) when is_list(updates) do
    update(schema, schema_data, Map.new(updates), opts)
  end



  def update(schema, schema_data, updates, opts) do
    with {:ok, schema_data} <- repo!(opts).update(schema.changeset(schema_data, updates), opts) do
      {:ok, schema_data}
    end
  end

  @doc """
  Deletes a schema

  ## Examples

      iex> user = create_user()
      iex> {:ok, schema} = EctoSchemas.Actions.delete(user)
      iex> schema.first_name === user.first_name
      true
  """
  def delete(%_{} = schema_data) do
    delete(schema_data, default_opts())
  end
  def delete(schema_data) when is_list(schema_data) do
    delete(schema_data, default_opts())
  end

  @doc """
  Similar to `delete/1` but can also accept a keyword options list.

  ## Options
    * `:repo` - A module that uses the Ecto.Repo Module.

  ## Examples

      iex> user = create_user()
      iex> {:ok, schema} = EctoSchemas.Actions.delete(user, repo: MyApp.MyRepoModule.Repo)
      iex> schema.first_name === user.first_name
      true
  """
  @spec delete(schema_data :: Ecto.Schema.t, opts) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
  @spec delete(schema_data :: Ecto.Schema.t) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
  def delete(%schema{} = schema_data, opts) do
    case repo!(opts).delete(schema_data, opts) do
      {:error, changeset} ->
        {:error, Error.call(
          :internal_server_error,
          "Error deleting #{inspect(schema)}",
          %{changeset: changeset, schema_data: schema_data}
        )}
      ok -> ok
    end
  end

  def delete(schema_data, opts) when is_list(schema_data) do
    schema_data |> Enum.map(&delete(&1, opts)) |> reduce_status_tuples
  end

  @spec delete(schema :: Ecto.Schema.t, id :: integer) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
  def delete(schema, id) when is_atom(schema) and (is_binary(id) or is_integer(id)) do
    delete(schema, id, default_opts())
  end
  @doc """
  Deletes a schema. Can also accept a keyword options list.

  ## Options
    * `:repo` - A module that uses the Ecto.Repo Module.

  ## Examples

      iex> user = create_user()
      iex> {:ok, schema} = EctoSchemas.Actions.delete(EctoSchemas.Accounts.User, user.id)
      iex> schema.first_name === user.first_name
      true

      iex> user = create_user()
      iex> {:ok, schema} = EctoSchemas.Actions.delete(EctoSchemas.Accounts.User, user.id)
      iex> schema.first_name === user.first_name
      true
  """
  @spec delete(schema :: Ecto.Schema.t, id :: integer, opts) :: {:ok, Ecto.Schema.t} | {:error, Ecto.Changeset.t}
  def delete(schema, id, opts) when is_atom(schema) and (is_integer(id) or is_binary(id)) do
    with {:ok, schema_data} <- find(schema, %{id: id}, opts) do
      repo!(opts).delete(schema_data, opts)
    end
  end

  @spec stream(queryable :: query, params :: filter_params, opts) :: Enum.t
  @spec stream(queryable :: query, params :: filter_params) :: Enum.t
  @doc "Gets a collection of schemas from the database but allows for a filter"
  def stream(query, params, opts \\ []) do
    repo!(opts).stream(
      CommonFilters.convert_params_to_filter(query, params),
      opts
    )
  end

  @spec aggregate(
    queryable :: query,
    params :: filter_params,
    agg_opts :: aggregate_options,
    field :: atom,
    opts
  ) :: term
  @spec aggregate(
    queryable :: query,
    params :: filter_params,
    agg_opts :: aggregate_options,
    field :: atom
  ) :: term
  def aggregate(schema, params, aggregate, field, opts \\ []) do
    repo!(opts).aggregate(
      CommonFilters.convert_params_to_filter(schema, params),
      aggregate,
      field,
      opts
    )
  end

  @doc """
  Accepts a list of schemas and attempts to find them in the DB. Any missing Schemas will be created.
  Can also accept a keyword options list.

  ***Note: Relational filtering doesn't work on this function***

  ## Options
    * `:repo` - A module that uses the Ecto.Repo Module.
    * `:replica` - If you don't want to perform any reads against your Primary, you can specify a replica to read from.

  ## Examples
    iex> {:ok, records} = EctoSchemas.Actions.find_or_create_many(EctoSchemas.Accounts.User, [%{name: "foo"}, %{name: "bar}])
    iex> length(records) === 2
  """
  @spec find_or_create_many(
    Ecto.Schema.t(),
    list(map),
    opts
  ) :: {:ok, list(Ecto.Schema.t())} | {:error, list(Ecto.Changeset.t())}
  def find_or_create_many(schema, param_list, opts) do
    find_param_list = Enum.map(param_list, &Map.drop(&1, schema.__schema__(:associations)))

    {create_params, found_results} = find_many(schema, find_param_list, opts)

    schema
    |> multi_insert(param_list, create_params)
    |> repo!(opts).transaction()
    |> case do
      {:ok, created_map} -> {:ok, merge_found(created_map, found_results)}
      error -> error
    end
  end


  defp find_many(schema, param_list, opts) do
    param_list
    |> Enum.map(fn params ->
      case find(schema, params, opts) do
        {:ok, result} -> result
        _ -> nil
      end
    end)
    |> Enum.with_index()
    |> Enum.split_with(fn {result, _index} -> is_nil(result) end)
  end

  defp multi_insert(schema, param_list, create_params) do
    Enum.reduce(create_params, Ecto.Multi.new(), fn {nil, i}, multi ->
      Ecto.Multi.insert(multi, i, fn _ ->
        param_list
        |> Enum.at(i)
        |> create_changeset(schema)
      end)
    end)
  end

  defp create_changeset(params, schema) do
    if function_exported?(schema, :create_changeset, 1) do
      schema.create_changeset(params)
    else
      schema.changeset(struct(schema, %{}), params)
    end
  end

  defp merge_found(created_map, found_results) do
    created_map
    |> Enum.map(fn {index, result} -> {result, index} end)
    |> Kernel.++(found_results)
    |> Enum.sort(&(elem(&1, 1) >= elem(&2, 1)))
    |> Enum.map(&elem(&1, 0))
  end

  defp reduce_status_tuples(status_tuples) do
    {status, res} =
      Enum.reduce(status_tuples, {:ok, []}, fn
        {:ok, _}, {:error, _} = e -> e
        {:ok, record}, {:ok, acc} -> {:ok, [record | acc]}
        {:error, error}, {:ok, _} -> {:error, [error]}
        {:error, e}, {:error, error_acc} -> {:error, [e | error_acc]}
      end)

    {status, Enum.reverse(res)}
  end

  defp repo!(opts) do
    with nil <- repo(opts) do
      raise ArgumentError, message: "ecto shorts must be configured with a repo. For further guidence consult the docs. https://hexdocs.pm/ecto_shorts/EctoShorts.html#module-config"
    end
  end

  # `replica!/1` will attempt to retrieve a repo from the replica key and default to
  # returning the value under the repo: key if no replica is found. If no repos are configured
  # an ArgumentError will be raised.
  defp replica!(opts) do
    with nil <- Keyword.get(opts, :replica, repo(opts)) do
      raise ArgumentError, message: "ecto shorts must be configured with a repo. For further guidence consult the docs. https://hexdocs.pm/ecto_shorts/EctoShorts.html#module-config"
    end
  end

  defp repo([]) do
    Config.repo()
  end

  defp repo(opts) do
    default_opts()
      |> Keyword.merge(opts)
      |> Keyword.get(:repo)
  end

  defp default_opts, do: [repo: Config.repo()]
end