lib/uber_multi.ex

defmodule UberMulti do
  @moduledoc """
  Provides the wrapper function for `Ecto.Multi`.

  There are two differences when using UberMulti compared to the normal Multi methods. The first is the addition of the
  'keys' argument. The keys provided in this argument are used in two ways. First, each key is used to try and extract
  a result from the list of previous changes in the execution of the Multi. If a result is not found, the key is
  instead used as-is as the parameter. In this way you can mix and match between providing your own arguments and
  automatically fetching them from previous Multi results.

  The second difference is in the function that will be run when the Multi is executed. Given that the changes list is
  being preprocessed into a list of parameters to pass to the function, it will not need to take in the list of changes
  as normal, but simply take in the parameters it expects to perform its function.

  So an UberMulti call which uses the following function:
  ```
  def add_things(thing1, thing2) do
    {:ok, thing1 + thing2}
  end
  ```

  Might look like:
  ```
  UberMulti.run(multi, :add_things, [:get_thing1, :get_thing2], &add_things/2)
  ```

  Obviously this is very contrived, but shows how you can easily combine non-multi and multi-designed functions without
  having to wrap all the calls yourself and manually extract the results for the next one in the chain.

  Another change is that the 'run' method will also automatically wrap the responses from any methods called that
  do not conform to the two element tuple response pattern. If a method returns '{:ok, term()}' or '{:error, term()}'
  the response will be used as is. By default any non-conforming response will be wrapped in a success tuple, see
  `run/4` for configuration options.
  """

  alias Ecto.Multi

  @typedoc "An '%Ecto.Multi{}' struct. See 'Ecto.Multi' for detailed specs."
  @type multi :: Ecto.Multi.t()

  @typedoc "A name to tag the multi function with. Must be unique within a single multi chain."
  @type name :: any()

  @typedoc "One or more keys, with a single key optionally being in a list."
  @type keys :: key | [key]

  @typedoc "The key to extract the parameters from using the changes map, or a value to pass directly to the callback."
  @type key :: any()

  @typedoc "A callback function to call with the parameters extracted from the changes map."
  @type run :: function()

  @typedoc "Whether or not to trust the result by default if it does not come wrapped in a result tuple."
  @type trust_result :: boolean() | fun()

  @doc """
  Adds a function to run as part of the Multi.

  By default, functions which do not wrap their results in success tuples have their results returned as a successful
  call. By providing `false` as the last argument this behaviour is changed and results will be wrapped in an error
  tuple instead. Optionally, for finer grained control, a function which takes the result and returns a tagged value
  can be passed in as the last parameter instead of a boolean value.

  ## Examples

      UberMulti.run(multi, :send_email, [:get_hot_news_items, :get_top_forum_post_headlines], fn(news, posts) ->
        ...do stuff...
      end)
      #Ecto.Multi{}

      Multi.new()
      |> UberMulti.run(:list_stars, ["Milky Way"], &Astronomy.list_stars/1)
      |> UberMulti.run(:reverse, [:list_stars], &Enum.reverse/1)
      |> UberMulti.run(:classify,
        [:reverse],
        &Astronomy.classify_stars/1,
        # Assume result is ok for brevity
        & {:ok, Enum.all?(&1, fn star -> star.class in ~w"O B A F G K M")})
      |> Repo.transaction()
  """
  @spec run(multi, name, keys, run, trust_result) :: multi
  def run(multi, name, keys, run, trust_result \\ false) do
    Multi.run(multi, name, fn _, changes ->
      extracted_args = extract_args(keys, changes)

      apply(run, extracted_args)
      |> maybe_wrap_response(trust_result)
    end)
  end

  @spec maybe_wrap_response(response :: term, trust_result) ::
          {:ok, term()} | {:error, term()}
  defp maybe_wrap_response(response, fun) when is_function(fun), do: fun.(response)
  defp maybe_wrap_response({:ok, _} = response, _), do: response
  defp maybe_wrap_response({:error, _} = response, _), do: response
  defp maybe_wrap_response(response, true), do: {:ok, response}
  defp maybe_wrap_response(response, false), do: {:error, response}

  @spec extract_args(keys :: term() | [term()], changes :: map()) :: [term()]
  defp extract_args(keys, changes) do
    keys
    |> List.wrap()
    |> Enum.map(fn key ->
      Map.get(changes, key, key)
    end)
  end
end