|
|
@@ -2,6 +2,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
Patterns extracted from Ecto's `Ecto.Multi` source code.
|
|
|
|
Patterns extracted from Ecto's `Ecto.Multi` source code.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
## Contents
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1. [`Multi.new() |> Multi.insert/update/delete` — Named Operation Pipeline](#1-multinew--multiinsertupdatedelete--named-operation-pipeline)
|
|
|
|
|
|
|
|
2. [`Multi.run/3` — Arbitrary Code in a Transaction](#2-multirun3--arbitrary-code-in-a-transaction)
|
|
|
|
|
|
|
|
3. [Dependent Operations with Function Variants](#3-dependent-operations-with-function-variants)
|
|
|
|
|
|
|
|
4. [`Multi.merge/2` — Dynamic Transaction Composition](#4-multimerge2--dynamic-transaction-composition)
|
|
|
|
|
|
|
|
5. [`Multi.append/2` / `Multi.prepend/2` — Static Multi Composition](#5-multiappend2--multiprepend2--static-multi-composition)
|
|
|
|
|
|
|
|
6. [Tuple Keys — Dynamic Collections of Operations](#6-tuple-keys--dynamic-collections-of-operations)
|
|
|
|
|
|
|
|
7. [`Multi.to_list/1` — Testing Without a Database](#7-multito_list1--testing-without-a-database)
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 1. `Multi.new() |> Multi.insert/update/delete` — Named Operation Pipeline
|
|
|
|
## 1. `Multi.new() |> Multi.insert/update/delete` — Named Operation Pipeline
|
|
|
@@ -17,18 +27,18 @@ def reset(account, params) do
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
# Execute:
|
|
|
|
# Execute:
|
|
|
|
case Repo.transact(PasswordManager.reset(account, params)) do
|
|
|
|
case Repo.transaction(PasswordManager.reset(account, params)) do
|
|
|
|
{:ok, %{account: account, log: log}} -> # success
|
|
|
|
{:ok, %{account: account, log: log}} -> # success
|
|
|
|
{:error, :account, changeset, _} -> # account step failed
|
|
|
|
{:error, :account, changeset, _} -> # account step failed
|
|
|
|
end
|
|
|
|
end
|
|
|
|
```
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**Why:** Each operation is named. On success, `Repo.transact` returns `{:ok, results_map}` where each key is the name given to that operation. On failure, it returns `{:error, failed_name, failed_value, changes_so_far}`, making it immediately clear which step aborted the transaction and why. This is more precise than a bare transaction function where you'd have to inspect the return value to guess which step failed.
|
|
|
|
**Why:** Each operation is named. On success, `Repo.transaction` returns `{:ok, results_map}` where each key is the name given to that operation. On failure, it returns `{:error, failed_name, failed_value, changes_so_far}`, making it immediately clear which step aborted the transaction and why. This is more precise than a bare transaction function where you'd have to inspect the return value to guess which step failed.
|
|
|
|
|
|
|
|
|
|
|
|
**Anti-pattern:** Using an anonymous function with bare `case` statements inside a transaction, where failure attribution is implicit:
|
|
|
|
**Anti-pattern:** Using an anonymous function with bare `case` statements inside a transaction, where failure attribution is implicit:
|
|
|
|
```elixir
|
|
|
|
```elixir
|
|
|
|
# BAD — no way to know which operation failed from the return value alone
|
|
|
|
# BAD — no way to know which operation failed from the return value alone
|
|
|
|
Repo.transact(fn ->
|
|
|
|
Repo.transaction(fn ->
|
|
|
|
case Repo.update(Account.password_reset_changeset(account, params)) do
|
|
|
|
case Repo.update(Account.password_reset_changeset(account, params)) do
|
|
|
|
{:ok, account} ->
|
|
|
|
{:ok, account} ->
|
|
|
|
case Repo.insert(Log.password_reset_changeset(account, params)) do
|
|
|
|
case Repo.insert(Log.password_reset_changeset(account, params)) do
|
|
|
@@ -50,7 +60,7 @@ end)
|
|
|
|
**Example — before:**
|
|
|
|
**Example — before:**
|
|
|
|
```elixir
|
|
|
|
```elixir
|
|
|
|
def create_user_with_profile(params) do
|
|
|
|
def create_user_with_profile(params) do
|
|
|
|
Repo.transact(fn ->
|
|
|
|
Repo.transaction(fn ->
|
|
|
|
case Repo.insert(User.changeset(params)) do
|
|
|
|
case Repo.insert(User.changeset(params)) do
|
|
|
|
{:ok, user} ->
|
|
|
|
{:ok, user} ->
|
|
|
|
case Repo.insert(Profile.changeset(user, params)) do
|
|
|
|
case Repo.insert(Profile.changeset(user, params)) do
|
|
|
@@ -71,7 +81,7 @@ def create_user_with_profile(params) do
|
|
|
|
|> Multi.insert(:profile, fn %{user: user} ->
|
|
|
|
|> Multi.insert(:profile, fn %{user: user} ->
|
|
|
|
Profile.changeset(user, params)
|
|
|
|
Profile.changeset(user, params)
|
|
|
|
end)
|
|
|
|
end)
|
|
|
|
|> Repo.transact()
|
|
|
|
|> Repo.transaction()
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
# Caller:
|
|
|
|
# Caller:
|
|
|
@@ -86,7 +96,7 @@ end
|
|
|
|
|
|
|
|
|
|
|
|
**Don't use this when:**
|
|
|
|
**Don't use this when:**
|
|
|
|
- You have a single database operation (just call `Repo.insert/update/delete` directly)
|
|
|
|
- You have a single database operation (just call `Repo.insert/update/delete` directly)
|
|
|
|
- Operations are simple and sequential with no branching (a plain `Repo.transact(fn -> ... end)` is more readable)
|
|
|
|
- Operations are simple and sequential with no branching (a plain `Repo.transaction(fn -> ... end)` is more readable)
|
|
|
|
- The overhead of building a Multi struct is not justified by the number of operations
|
|
|
|
- The overhead of building a Multi struct is not justified by the number of operations
|
|
|
|
|
|
|
|
|
|
|
|
**Over-application example:**
|
|
|
|
**Over-application example:**
|
|
|
@@ -94,7 +104,7 @@ end
|
|
|
|
# Overkill for a single operation
|
|
|
|
# Overkill for a single operation
|
|
|
|
Multi.new()
|
|
|
|
Multi.new()
|
|
|
|
|> Multi.insert(:user, User.changeset(params))
|
|
|
|
|> Multi.insert(:user, User.changeset(params))
|
|
|
|
|> Repo.transact()
|
|
|
|
|> Repo.transaction()
|
|
|
|
```
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**Better alternative:**
|
|
|
|
**Better alternative:**
|
|
|
@@ -102,7 +112,7 @@ Multi.new()
|
|
|
|
Repo.insert(User.changeset(params))
|
|
|
|
Repo.insert(User.changeset(params))
|
|
|
|
```
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**Why:** `Ecto.Multi` introduces indirection. For simple cases, calling Repo functions directly or using `Repo.transact(fn -> ... end)` is clearer. The named-pipeline form pays off when 3+ operations are involved and failure attribution matters.
|
|
|
|
**Why:** `Ecto.Multi` introduces indirection. For simple cases, calling Repo functions directly or using `Repo.transaction(fn -> ... end)` is clearer. The named-pipeline form pays off when 3+ operations are involved and failure attribution matters.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
@@ -144,7 +154,7 @@ end)
|
|
|
|
**Example — before:**
|
|
|
|
**Example — before:**
|
|
|
|
```elixir
|
|
|
|
```elixir
|
|
|
|
def register_user(params) do
|
|
|
|
def register_user(params) do
|
|
|
|
Repo.transact(fn ->
|
|
|
|
Repo.transaction(fn ->
|
|
|
|
{:ok, user} = Repo.insert(User.changeset(params))
|
|
|
|
{:ok, user} = Repo.insert(User.changeset(params))
|
|
|
|
# This runs outside Multi — if it fails, user was already inserted
|
|
|
|
# This runs outside Multi — if it fails, user was already inserted
|
|
|
|
case ExternalService.provision(user.id) do
|
|
|
|
case ExternalService.provision(user.id) do
|
|
|
@@ -163,7 +173,7 @@ def register_user(params) do
|
|
|
|
|> Multi.run(:provision, fn _repo, %{user: user} ->
|
|
|
|
|> Multi.run(:provision, fn _repo, %{user: user} ->
|
|
|
|
ExternalService.provision(user.id)
|
|
|
|
ExternalService.provision(user.id)
|
|
|
|
end)
|
|
|
|
end)
|
|
|
|
|> Repo.transact()
|
|
|
|
|> Repo.transaction()
|
|
|
|
end
|
|
|
|
end
|
|
|
|
```
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
@@ -496,7 +506,7 @@ Enum.reduce(accounts, Multi.new(), fn account, multi ->
|
|
|
|
end)
|
|
|
|
end)
|
|
|
|
|
|
|
|
|
|
|
|
# Error pattern-matching:
|
|
|
|
# Error pattern-matching:
|
|
|
|
case Repo.transact(multi) do
|
|
|
|
case Repo.transaction(multi) do
|
|
|
|
{:ok, results} -> Map.keys(results) # [{:account, 1}, {:account, 2}, ...]
|
|
|
|
{:ok, results} -> Map.keys(results) # [{:account, 1}, {:account, 2}, ...]
|
|
|
|
{:error, {:account, id}, changeset, _} -> "account #{id} failed"
|
|
|
|
{:error, {:account, id}, changeset, _} -> "account #{id} failed"
|
|
|
|
end
|
|
|
|
end
|
|
|
@@ -547,7 +557,7 @@ def reset_passwords(accounts, params) do
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
# Caller knows exactly which account failed:
|
|
|
|
# Caller knows exactly which account failed:
|
|
|
|
case Repo.transact(reset_passwords(accounts, params)) do
|
|
|
|
case Repo.transaction(reset_passwords(accounts, params)) do
|
|
|
|
{:ok, _} -> :ok
|
|
|
|
{:ok, _} -> :ok
|
|
|
|
{:error, {:account, id}, changeset, _} ->
|
|
|
|
{:error, {:account, id}, changeset, _} ->
|
|
|
|
Logger.error("Failed to reset account #{id}: #{inspect(changeset.errors)}")
|
|
|
|
Logger.error("Failed to reset account #{id}: #{inspect(changeset.errors)}")
|
|
|
@@ -609,7 +619,7 @@ test "password reset changeset is valid" do
|
|
|
|
account = %Account{password: "letmein"}
|
|
|
|
account = %Account{password: "letmein"}
|
|
|
|
{:ok, %{account: updated}} =
|
|
|
|
{:ok, %{account: updated}} =
|
|
|
|
PasswordManager.reset(account, valid_params)
|
|
|
|
PasswordManager.reset(account, valid_params)
|
|
|
|
|> Repo.transact()
|
|
|
|
|> Repo.transaction()
|
|
|
|
|
|
|
|
|
|
|
|
# The changeset validity was the whole point, not the DB state
|
|
|
|
# The changeset validity was the whole point, not the DB state
|
|
|
|
assert updated.password != account.password
|
|
|
|
assert updated.password != account.password
|
|
|
@@ -630,7 +640,7 @@ end
|
|
|
|
test "creates user with profile" do
|
|
|
|
test "creates user with profile" do
|
|
|
|
{:ok, %{user: user, profile: profile}} =
|
|
|
|
{:ok, %{user: user, profile: profile}} =
|
|
|
|
UserRegistration.multi(valid_params())
|
|
|
|
UserRegistration.multi(valid_params())
|
|
|
|
|> Repo.transact()
|
|
|
|
|> Repo.transaction()
|
|
|
|
|
|
|
|
|
|
|
|
assert user.email == "test@example.com"
|
|
|
|
assert user.email == "test@example.com"
|
|
|
|
assert profile.user_id == user.id
|
|
|
|
assert profile.user_id == user.id
|
|
|
@@ -685,7 +695,7 @@ test "comment changeset is valid given a post" do
|
|
|
|
end
|
|
|
|
end
|
|
|
|
```
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**Why:** `Multi.to_list/1` reflects the state of the pipeline at build time. Deferred values (function variants, `Multi.run` callbacks, `Multi.merge` functions) are not evaluated until `Repo.transact` runs them. Test those deferred pieces independently rather than trying to inspect them through `to_list`.
|
|
|
|
**Why:** `Multi.to_list/1` reflects the state of the pipeline at build time. Deferred values (function variants, `Multi.run` callbacks, `Multi.merge` functions) are not evaluated until `Repo.transaction` runs them. Test those deferred pieces independently rather than trying to inspect them through `to_list`.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
@@ -697,6 +707,6 @@ end
|
|
|
|
- If you have reusable Multi fragments to combine → `Multi.append/2` or `Multi.prepend/2`
|
|
|
|
- If you have reusable Multi fragments to combine → `Multi.append/2` or `Multi.prepend/2`
|
|
|
|
- If you're updating a dynamic collection → tuple keys `{:operation, id}`
|
|
|
|
- If you're updating a dynamic collection → tuple keys `{:operation, id}`
|
|
|
|
- If you want to validate changesets without hitting the DB → `Multi.to_list/1` in tests
|
|
|
|
- If you want to validate changesets without hitting the DB → `Multi.to_list/1` in tests
|
|
|
|
- If operations are simple and static (no dynamic branching) → consider `Repo.transact(fn -> ... end)` instead
|
|
|
|
- If operations are simple and static (no dynamic branching) → consider `Repo.transaction(fn -> ... end)` instead
|
|
|
|
|
|
|
|
|
|
|
|
<!-- PATTERN_COMPLETE -->
|
|
|
|
<!-- PATTERN_COMPLETE -->
|
|
|
|