Using Ecto.Multi To Group Database Operations
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:
- Doors
- People
- Entries (a Person entering a Door)
- 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 name
d 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!
Buy my book—Phoenix in Action
I've been working hard on the first book on the Phoenix framework from Manning Publications, Phoenix in Action. If you like what you've been reading and/or you have an interest in learning Phoenix, please purchase the book today! Want to know more, check out my blog post announcing the book or the one announcing its completion.