It seems that all of this got quite complex really fast due to my obscure example.
I’m trying to make it as specific as possible.
in_transaction/1
is defined in MyApp.Repo
:
defmodule MyApp.Repo do
use Ecto.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.Postgres
alias Ecto.Multi
alias MyApp.Repo
def in_transaction(fun) do
with {:ok, %{model: result}} <- run_in_transaction(fun) do
{:ok, result}
else
{:error, :model, error, _} ->
{:error, error}
end
end
defp run_in_transaction(fun) do
Multi.new
|> Multi.run(
:model,
fn _repo, _ ->
fun.()
end
)
|> Repo.transaction
end
end
create_item/1
is pretty standard to insert an Ecto model into the database:
def create_item(attrs) do
changeset(%Item{}, attrs)
|> MyApp.Repo.insert # returns either {:ok, %Item{} = item} or {:error, %Ecto.Changeset{valid?: false, errors: [...]}} under normal circumstances
end
This abstraction hides Ecto.Multi
logic into one module and allows to choose to use a transaction between multiple inserts or not by not changing any Ecto.Schema
models.
These building blocks allow me to write code like this:
MyApp.Repo.in_transaction(fn ->
create_item(%{foo: "bar"})
end)
Code above will either commit %Item{}
to the database or roll changes back if there are some problems and will return these problems to the in_transaction
caller. Of course inserting one entity does not need a separate transaction and code like this makes more sense:
MyApp.Repo.in_transaction(fn ->
with {:ok, _item1} <- create_item(%{foo: "bar"}),
{:ok, _item2} <- create_item(%{foo: "baz"}) do
{:ok, :all_good} # just so that Ecto.Multi would not roll back
end
end)
Now, if _item2
creation fails then _item1
will be rolled back as well and this is what we want by using database transactions. So far so good.
Or if I don’t care about transactions/rolling back then code like this works too:
with {:ok, _item1} <- create_item(%{foo: "bar"}),
{:ok, _item2} <- create_item(%{foo: "baz"}) do
{:ok, :all_good}
end
However, if I have an arbitrary amount of items then the original question became apparent.
For example, this doesn’t work:
def create_all_items(items) do
Enum.each(items, fn item ->
create_item(item)
end) # always traverses whole list and even if some item failed to be created then this information will not be passed up to the caller of `in_transaction/1`
{:ok, :all_good} # just so that Ecto.Multi would not roll back, but I'm not sure anymore if anything gets committed in case of any errors inside any `create_item/1`.
end
I tried something like this:
def create_all_items(items) do
Enum.each(items, fn item ->
with {:ok, item} <- create_item(item) do
{:ok, item}
end
end) # still returns always :ok and traverses whole list even on non-matches?
end
Then I got to this, which kind of works, but looks really verbose and unexpected by Enum.find/2
to have any side-effect via calling create_item/1
:
def create_all_items(items) do
failed_created_item = Enum.find(items, fn item ->
!match?({:ok, _item}, create_item(item))
end)
if failed_created_item do
failed_created_item # most of the time returns {:error, %Ecto.Changeset{valid?: false, errors: [...]}}
else
{:ok, items} # just so that Ecto.Multi would not roll back
end
end
Basically this stops calling create_item/1
as soon as some item failed to be created, e.g. returned something else than {:ok, item}
and the validation error will be passed up to the Ecto.Multi
and to the caller of in_transaction/1
. I guess the suggested Enum.reduce_while
would be as weird and not less verbose.
I ended up doing like this:
def create_all_items(items) do
Enum.each(items, fn item ->
{:ok, item} = create_item(item) # this will raise MatchError on errors, but MyApp.Repo.in_transaction needs modifying
end)
{:ok, :all_good} # just so that Ecto.Multi would not roll back
end
However this requires me to modify run_in_transaction/1
to the following:
defp run_in_transaction(fun) do
try do
Multi.new
|> Multi.run(
:model,
fn _repo, _ ->
fun.()
end
)
|> Repo.transaction
rescue
error in MatchError ->
with %MatchError{term: {:error, %Ecto.Changeset{}} = validation_error} <- error do
validation_error
else
_ ->
reraise error, __STACKTRACE__
end
end
end
It looks really ugly due to the fact that I need to have extra with
statement inside rescue
clause to match only Ecto.Changeset
errors and to use reraise
to raise any other errors. It’s also very error-prone since it will not work as expected when I’m going to write with
statement using <-
instead of a regular match operator =
. It does not feel something that a real Elixir dev would write. Prove me wrong.
PS! This got me to the next more abstract question about quitting Enum
iterator early on from any function (each
, map
, reduce
- you call it). For example in Ruby you can do something like this:
items.each do |item|
break if item.foo
end
Is there some equivalent pattern/way in Elixir?