diff --git a/integration_test/cases/repo.exs b/integration_test/cases/repo.exs index 5b3e39360f..9037675921 100644 --- a/integration_test/cases/repo.exs +++ b/integration_test/cases/repo.exs @@ -2275,4 +2275,52 @@ defmodule Ecto.Integration.RepoTest do defp uuid_module(Ecto.Adapters.Tds), do: Tds.Ecto.UUID defp uuid_module(_), do: Ecto.UUID end + + describe "transaction_with/2" do + test "return ok" do + assert {:ok, [post1, post2]} = + TestRepo.transaction_with(fn -> + post1 = TestRepo.insert!(%Post{title: "1"}) + post2 = TestRepo.insert!(%Post{title: "2"}) + {:ok, [post1, post2]} + end) + + assert TestRepo.all(Post) |> Enum.sort() == [post1, post2] + end + + test "return error" do + assert {:error, :oops} = + TestRepo.transaction_with(fn -> + TestRepo.insert!(%Post{title: "1"}) + TestRepo.insert!(%Post{title: "2"}) + {:error, :oops} + end) + + assert TestRepo.all(Post) == [] + end + + test "rollback" do + assert {:error, :oops} = + TestRepo.transaction_with(fn -> + TestRepo.insert!(%Post{title: "1"}) + TestRepo.insert!(%Post{title: "2"}) + TestRepo.rollback(:oops) + raise "unreachable" + end) + + assert TestRepo.all(Post) == [] + end + + test "raise error" do + assert_raise RuntimeError, "oops", fn -> + TestRepo.transaction_with(fn -> + TestRepo.insert!(%Post{title: "1"}) + TestRepo.insert!(%Post{title: "2"}) + raise "oops" + end) + end + + assert TestRepo.all(Post) == [] + end + end end diff --git a/lib/ecto/repo.ex b/lib/ecto/repo.ex index 2a1d2017ad..87ac096527 100644 --- a/lib/ecto/repo.ex +++ b/lib/ecto/repo.ex @@ -290,6 +290,16 @@ defmodule Ecto.Repo do ) end + def transaction_with(fun, opts \\ []) do + repo = get_dynamic_repo() + + Ecto.Repo.Transaction.transaction_with( + repo, + fun, + Ecto.Repo.Supervisor.tuplet(repo, prepare_opts(:transaction, opts)) + ) + end + def in_transaction? do Ecto.Repo.Transaction.in_transaction?(get_dynamic_repo()) end @@ -2076,6 +2086,8 @@ defmodule Ecto.Repo do A successful transaction returns the value returned by the function wrapped in a tuple as `{:ok, value}`. + See also `c:transaction_with/2`. + ### Nested transactions If `c:transaction/2` is called inside another transaction, the function @@ -2132,6 +2144,8 @@ defmodule Ecto.Repo do Explore the `Ecto.Multi` documentation to learn more and find detailed examples. + See also `c:transaction_with/2`. + ## Aborted transactions When an operation inside a transaction fails, the transaction is aborted in the database. @@ -2200,6 +2214,82 @@ defmodule Ecto.Repo do | {:error, any} | Ecto.Multi.failure() + @doc """ + Runs the given function inside a transaction. + + The return value is the same as of the given `fun` which must be + `{:ok, result}` or `{:error, reason}`. + + If this function returns `{:ok, result}`, it means the transaction + was successfully committed. On the other hand, if it returns `{:error, reason}`, + it means the transaction was rolled back. + + If an Elixir exception occurs the transaction will be rolled back + and the exception will bubble up from the transaction function. + If no exception occurs, the transaction is committed if the function + returns `{:ok, result}`. Returning `{:error, result}` will rollback the transaction + and this function will return `{:error, result}` as well. + A transaction can be explicitly rolled back + by calling `c:rollback/1`, this will immediately leave the function + and return the value given to `rollback` as `{:error, value}`. + + See `c:transaction/2` for more information on transactions. + + ## Options + + See the ["Shared options"](#module-shared-options) section at the module + documentation for more options. + + ## Examples + + This function is commonly used with `with/1`: + + Repo.transaction_with(fn -> + with {:ok, alice} <- Repo.insert(alice_changeset), + {:ok, bob} <- Repo.insert(bob_changeset) do + {:ok, [alice, bob]} + end + end) + + If the transaction was successful, `{:ok, result}` is returned: + + iex> Repo.transaction_with(fn -> + ...> Repo.insert(changeset) + ...> end) + {:ok, %User{}} + + If the transaction failed, `{:error, reason}` is returned: + + iex> Repo.transaction_with(fn -> + ...> Repo.insert(changeset) + ...> end) + {:error, #Ecto.Changeset<...>} + + Transaction can be aborted by returning `{:error, reason}`, calling `c:rollback/1`, + or raising from the given `fun`: + + iex> Repo.transaction_with(fn -> + ...> Repo.insert!(%User{}) # will be rolled back + ...> {:error, :oops} + ...> end) + {:error, :oops} + + iex> Repo.transaction_with(fn -> + ...> Repo.insert!(%User{}) # will be rolled back + ...> Repo.rollback(:oops) + ...> end) + {:error, :oops} + + iex> Repo.transaction_with(fn -> + ...> Repo.insert!(%User{}) # will be rolled back + ...> raise "oops" + ...> end) + ** (RuntimeError) oops + """ + @doc group: "Transaction API" + @callback transaction_with(fun :: (-> result), opts :: Keyword.t()) :: result + when result: {:ok, any()} | {:error, any()} + @doc """ Returns true if the current process is inside a transaction. diff --git a/lib/ecto/repo/transaction.ex b/lib/ecto/repo/transaction.ex index 2daa0cb2b7..8b2c1e9396 100644 --- a/lib/ecto/repo/transaction.ex +++ b/lib/ecto/repo/transaction.ex @@ -5,7 +5,7 @@ defmodule Ecto.Repo.Transaction do def transaction(_repo, _name, fun, {adapter_meta, opts}) when is_function(fun, 0) do adapter_meta.adapter.transaction(adapter_meta, opts, fun) end - + def transaction(repo, _name, fun, {adapter_meta, opts}) when is_function(fun, 1) do adapter_meta.adapter.transaction(adapter_meta, opts, fn -> fun.(repo) end) end @@ -16,13 +16,13 @@ defmodule Ecto.Repo.Transaction do return = &adapter.rollback(adapter_meta, &1) case Ecto.Multi.__apply__(multi, repo, wrap, return) do - {:ok, values} -> + {:ok, values} -> {:ok, values} - {:error, {key, error_value, values}} -> + {:error, {key, error_value, values}} -> {:error, key, error_value, values} - {:error, operation} -> + {:error, operation} -> raise """ operation #{inspect operation} is rolling back unexpectedly. @@ -35,6 +35,22 @@ defmodule Ecto.Repo.Transaction do end end + def transaction_with(repo, fun, {adapter_meta, opts}) do + adapter_meta.adapter.transaction(adapter_meta, opts, fn -> + case fun.() do + {:ok, result} -> + result + + {:error, reason} -> + rollback(repo, reason) + + other -> + raise ArgumentError, + "expected to return {:ok, _} or {:error, _}, got: #{inspect(other)}" + end + end) + end + def in_transaction?(name) do %{adapter: adapter} = meta = Ecto.Repo.Registry.lookup(name) adapter.in_transaction?(meta)