A Brief Guide to Ecto.Multi

post image
Photo by @fischli on Unsplash

Published on: 9 October 2017

This post was originally published on Medium, and later on ElixirSchool. This the latest version which contains some minor changes.

One of the many exciting additions to Ecto 2.0, which was released sometime ago, was Ecto.Multi. It’s a set of utilities aimed at composing and executing atomic operations, usually (but not always, as you’ll see below) performed against the database. Furthermore, it handles rollbacks, provides results on either success or error, flattens-out nested code and saves multiple round trips to the database. Basically you need some Ecto.Multi in your Elixir life.

Surprisingly, many people I’ve spoken to seem to have missed it and have no idea all this functionality exists. If you haven’t used Ecto.Multi—keep reading! If you have, then you might discover a trick or two.

Creating a Multi

Everything starts with a %Multi{} struct. Regardless of what you’re doing, you always have to provide a new or an existing Multi to most of the functions, which you can easily create by calling new/0:

iex> Ecto.Multi.new()
%Ecto.Multi{names: #MapSet<[]>, operations: []}

Executing Multi operations

You run a Multi by passing it to Repo.transaction(multi):

iex> Ecto.Multi.new() |> Repo.transaction()
{:ok, %{}}

We just ran an empty Multi, which was successful since nothing was performed. The second element of the {:ok, return} tuple is just an empty map, which means there we no results. To make Multis useful, you need to add operations to them. Let’s see how we can do that.

Working with individual changesets

The most common and easy scenario: multiple changesets. Instead of using the usual Repo.insert/1 et al functions, you can use their Multi equivalent. They also accept an %Ecto.Changeset{}, so it is an easy change to group them into a single database transaction:

Ecto.Multi.new()
|> Ecto.Multi.insert(:team, team_changeset)
|> Ecto.Multi.update(:user, user_changeset)
|> Ecto.Multi.delete(:foo, foo_changeset)
|> Repo.transaction()

The atoms used—:user , :team and :foo—are chosen by you. You can pass anything, or use a string, instead of an atom, as long as it’s unique for the current Multi. The changeset variables are %Ecto.Changeset{} structs.

Result of a previous operation

Operations will be run in the order they’re added to the Multi. Often you need the result of a previous operation, which you can get by running a custom Multi operation, like so:

Ecto.Multi.new()
|> Ecto.Multi.insert(:team, team_changeset)
|> Ecto.Multi.run(:user, fn repo, %{team: team} ->
  # Use the inserted team.
  repo.update(user_changeset)
end)

Ecto.Multi.run/3 also needs a name for its first parameter, which we called :user; the second is a function, which provides you the results of previous operations. The results are just a map, and you can use the unique key to pattern-match and get the result for a specific operation, in this case :team.

Notice that here we call Repo.update/1—you need to return an {:ok, val} or a {:error, val} tuple from Multi.run/3. Using Repo.update/1 will give us just that.

Custom operations

Multi.run/3 could be used for non-database operations as well. As long as you return a success/error tuple, it will become part of the same atomic transaction:

Ecto.Multi.new()
|> Ecto.Multi.insert_all(:users, MyApp.User, users)
|> Ecto.Multi.run(:pro_users, fn _repo, %{users: users} ->
  result = Enum.filter(users, &(&1.role == "pro"))
  {:ok, result}
end)

Here :pro_users will be available to use for subsequent operations and in the result returned by Repo.transaction/1. It’s a great way to ensure code is run together with the rest of the database operations. If the :users operation fails, we’ll never get to filter them. However, unlike database operations, you are responsible for rolling them if there is an error. You will see how to handle errors in a moment.

Working with multiple Multis and dynamic data

The beauty of Ecto.Multi is that it’s just a data structure, which you can pass around. It is easy to dynamically generate data and combine different multis together, before executing everything as a single transaction:

posts_multi = 
  posts
  |> Stream.filter(fn post ->
    # Filter old posts...
  end)
  |> Stream.map(fn post ->
    # Create changesets.
    Ecto.Changeset.change(post, %{category: "new"})
  end)
  |> Stream.map(fn post_cs ->
    # Create a Multi with a single update
    # operation, generating a unique key for the op.
    key = String.to_atom("post_#{post_cs.data.id})
    Ecto.Multi.update(Ecto.Multi.new(), key, post_cs)
  end)
  |> Enum.reduce(Multi.new(), &Multi.append/2)

Using Multi.append/2 we now have a single Multi with all update operations in order. There’s also mergeand prepend.

Handling transaction results

Once you call Repo.transaction/1, you can pattern-match the result tuple.

In the case of success, you will receive all {:ok, result} with result being all operations and their successful results.

If it fails, all database operations will be rolled back, and you will be given {:error, failed_operation, failed_value, changes_so_far} which allows to handle errors from specific operations individually and inspect them. Note that changes_so_far simply means “operations that wen’t well until this one failed” and no data is actually left in the database.

Ecto.Multi.new()
|> Ecto.Multi.insert(:team, team_changeset)
|> Ecto.Multi.update(:user, user_changeset)
|> Ecto.Multi.delete(:foo, foo_changeset)
|> Repo.transaction()
|> case do
  {:ok, %{user: user, team: team, foo: foo}} ->
    # Yay, success!

  {:error, :foo, value, _} ->
    # Tsk tsk, :foo failed.

  {:error, op, res, others} ->
    # One of the others failed!
end

Final words

Hopefully this was useful as a quick-start guide and gives you an idea what’s possible with Ecto.Multi. This brief guide covers most of the functions available, but as always, refer to the official documentation for the most up-to-date information.

This work is licensed under the Creative Commons BY-SA 4.0 International License