Using Ecto.Multi To Group Database Operations

January 08, 2017 7 minutes read

Have you ever had a situation in which you had multiple database calls that relied on the success of the previous call? Normally it would be a pain to check each database success/failure status and then continue on in the chain. This can lead to many nested statements which can get nasty really fast. Beyond that, if a deeply-nested operation fails, all previous operations have to be rolled back.

One of the cool things to come out of Ecto 2.0 is the module Ecto.Multi. It is designed to be a way to group database calls into a single transaction so that the group fails if one fails. In other words, it’s all or nothing. This takes care of the problem we see above. There are no multiple levels of nesting and the API is actually really nice to work with. Let’s take a look!

The Situation

Let’s say for this example that you have an application running that tracks the life of a door. This door may be guarding some multi-million dollar server room or it could be your front door. Every person that has the clearance to open the door has some sort of passkey that identifies them and allows them to unlock the door and enter.

For the sake of a simple demonstration of Ecto.Multi, we’ll also say that this app ins’t the best designed app you’ve ever seen and the way it utilizes the database is pretty dreadful. I put this disclaimer here because this example is very contrived and the calls we are making are strictly to demonstrate Ecto.Multi and not how to successfully design and architect an app.

With that out of the way, let’s dig deeper.

What do we want to track?

Our application and database know about a few things:

  1. Doors
  2. People
  3. Entries (a Person entering a Door)
  4. Logs (a very simple secondary log of Entries)

For our simple demo app, whenever a Person attempts to unlock and enter a Door, we want to record the Entry, update a cached count of how many entries a Person has made, and then make a Log record about the Entry. If any one of those fails, we don’t want to save any of them. If we can’t record the Log, we don’t want to record the Entry nor update the Person’s entry count.

The Old Way

One version of what the pre-Ecto.Multi way of doing things might look like is the following (simplified):

defmodule Guard do
  def enter_door(person, door) do
    case Repo.insert(Entry.changeset(%Entry{}, %{door_id: door.id, person_id: person.id}) do
      {:ok, entry} ->
        case Repo.update(Person.increase_entry_count_changeset(person)) do
          {:ok, person} ->
            case Repo.insert(Log.changeset(%Log{}, %{text: "entry"})) do
              {:ok, log} ->
                "Success on all three!"
              {:error, _changeset} ->
                # rollback Person and Entry database operations
                "Failed to save Log"
            end
          {:error, _changeset} ->
            # rollback Entry database operation
            "Failed to save Person"
        end
      {:error, _changeset} ->
        "Failed to save Entry"
    end
  end
end

Have you ever written code like this before, perhaps in a Phoenix controller? I have. I admit that the example is contrived in order to increase the nesting, but I have a similar nesting structure running in a production app right now (don’t judge).

The Ecto.Multi Way

This is where Ecto.Multi swoops in to save the day. Let’s take a look at what it would look like and then we’ll break it down a bit.

defmodule Guard do
  def enter_door(person, door) do
    case entry_transaction(person, door) do
      {:ok, %{entry: entry, log: log, person: person}} ->
        Logger.debug("Success on all three!")
      {:error, :log, _failed_value, _changes_successful} ->
        Logger.debug("Failed to save Log")
      {:error, :person, _failed_value, _changes_successful} ->
        Logger.debug("Failed to save Person")
      {:error, :entry, _failed_value, _changes_successful} ->
        Logger.debug("Failed to save Entry")
    end
  end
  
  def entry_transaction(person, door) do
    Multi.new
    |> Multi.insert(:entry, Entry.changeset(%Entry{}, %{door_id: door.id, person_id: person.id}})
    |> Multi.update(:person, Person.increase_entry_count_changeset(person))
    |> Multi.insert(:log, Log.changeset(%Log{}, %{text, "entry"}))
    |> Repo.transaction()
  end
end

First, let’s tackle the entry_transaction function. The Ecto.Multi module has functions available that correspond with Ecto.Repo functions like insert, delete, and update (to name just a few). However, these accept a new second argument that is identified in the docs as name. The name argument lets you name a particular portion of the grouped transactions in order to pattern match on it later. In my example above, I’ve simply named them after the record that is being dealt with. We’ll come back to this later when we check out our enter_door function. Note that these have to be unique within the transaction grouping.

Each step of the transaction passes the Multi.new result down through the chain until eventually, after building up our transaction a piece at a time, we tell our Repo to actually make the calls.

If at any point in the steps a changeset has errors or the database call itself fails for one reason or another, none of the database calls will be persisted. The particular call that fails the transaction will be returned in a tuple with the form of {:error, name_of_call, failed_value, changes_that_succeeded}. The failed_value will contain the changeset errors (if using a changeset) or any other error values the call returned. changes_that_succeeded will contain the results of any previous operations that were successful. However, as the docs state,

any successful operations would have been rolled back

because the transaction itself failed.

This is now where we can take a look at the enter_door function. If the transaction fails, we’ve already seen how it will return the {:error, ...} tuple for us to deal with how we’d like. If it succeeds, it will return a tuple with {:ok, map}. In map, we can access the success values of each of the individual operations from the value of what we named that particular operation. So in our example the :entry key in map would correspond with the result of the operation:

Multi.new
|> Multi.insert(:entry, Entry.changeset(%Entry{}, %{...}))
#               ^^^^^^ where we named our operation
...

This gives us enormous power to ensure that database calls that belong together as a whole unit live and die together as a unit.

Using Ecto.Multi.run To Execute Arbitrary Functions

Another thing we can do with Ecto.Multi is utilize the run/3 function to execute arbitrary code within which we will have the result of any previously-run and successful operations. Let’s take a look at an example.

Pretend we want to ensure that a Person doesn’t enter a Door more than 10 times. Something like that would normally (and probably should still) be done inside a changeset for Person, but in our case, let’s utilize run/3 to check whether our Person now has more than 10 entries:

defmodule Guard do
  alias Ecto.Multi

  def enter_door(person, door) do
    case entry_transaction(person, door) do
      {:ok, %{entry: entry, log: log, person: person}} ->
        Logger.debug("Success on all four!")
      {:error, :allowed, _failed_value, _changes_successful} ->
        Logger.debug("Failed to pass allowed check")
      # ... other pattern matched failures
    end
  end
  
  def entry_transaction(person, door) do
    Multi.new
    |> Multi.insert(:entry, Entry.changeset(%Entry{}, %{door_id: door.id, person_id: person.id}})
    |> Multi.update(:person, Person.increase_entry_count_changeset(person))
    |> Mutli.run(:allowed, fn(%{person: person}) ->     # NEW CODE
      if person.entry_count > 10 do                     # 
        {:error, "Person entered more than 10 times"}   # 
      else                                              # 
        {:ok, "continue"}                               # 
      end                                               # 
    end)                                                # 
    |> Multi.insert(:log, Log.changeset(%Log{}, %{text, "entry"}))
    |> Repo.transaction()
  end
end

Ecto.Multi.run expects to receive a tuple containing either {:ok, message} or {:error, message} in order to determine whether to continue the transaction.

We can see above that we pattern match on the Person which has the result of successfully running the update operation above it. So if before the operation the Person had 10 entries, after the successful update, it would have 11 and trigger the failure. The error message would be passed on into the pattern-matched failure in the case statement.

A Powerful Feature With An Easy API

Overall, I’m very impressed with Ecto.Multi and what it can do. It allows you to stop worrying about rolling back previous successful operations if another dependent operation fails for some reason. Beyond that, being able to pattern match on the errorred transaction is huge and allows you to take action depending on what actually failed.

I know that I will be using this extensively in the future and will likely go back to my old code and update it to use it as well.

As always, I’d love questions or any other feedback you have on this post. I’m @geolessel on Twitter and @geo in the Elixir slack group. Also, I’d be grateful if you signed up for my email list below. I’m brainstorming some neat things that I’d like to release in the near future and I’d love to let you be the first to know about it. Thanks!

Updated: