Ecto 2.0から導入されたEcto.Multi
は、複数のタグ付けされたオペレーションを一つのトランザクション内で実行出来る機能です。
José Valimさんのこちらのアナウンスにおけるコードをそのままコピペさせて頂きますと、下記のようになります。
user_changeset = User.changeset(%User{}, params)
log_changeset = Log.changeset(%Log{}, event: "user updated")
multi =
Ecto.Multi.new
|> Ecto.Multi.update(:user, user_changeset)
|> Ecto.Multi.insert(:log, log_changeset)
|> Ecto.Multi.run(:charge_credit_card, fn changes_so_far -> {:ok, _} | {:error _} end)
case Repo.transaction(multi) do
{:ok, %{user: user, log: log}} ->
...
{:error, key_that_errored, %{user: user_changeset}} ->
...
end
複数のModelに対して一つのトランザクション内で処理を行う上では色々とメリットの大きい機能なので、既にEcto 2.0を使ってらっしゃる方は重宝してらっしゃると思うのですが、下記のように**Ecto.Multi
自体が既に複数定義されている場合**に、それぞれのオペレーションを一つのトランザクション内で実行するにはどうすればよいでしょう?
defmodule MyApp.EntryService do
def insert(params) do
Ecto.Multi.new
|> Ecto.Multi.insert(:entry, Entry.changeset(%Entry{}, params))
|> Ecto.Multi.insert(:log, Log.changeset(%Log{}, event: "entry inserted"))
end
end
defmodule MyApp.SectionService do
def insert(params) do
Ecto.Multi.new
|> Ecto.Multi.insert(:section, Entry.changeset(%Section{}, params))
|> Ecto.Multi.insert(:log, Log.changeset(%Log{}, event: "section inserted"))
end
end
考察1
Ecto.Multi
にはappend
やprepend
という、複数のEcto.Multi
構造体を一つのEcto.Multi
構造体にまとめることが出来る関数が用意されています。
しかし、ドキュメントを読んでみると
All names must be unique between both structures.
という記述があります。要するに各オペレーションのタグ名は他と重複してはいけないというルールになっています。
上記のコードの場合、例えば:log
という重複するタグ名を:entry_log
と:section_log
に変更すればうまくいきそうですが、この方式はそれぞれのMultiを一回だけ実行する場合にしか適用出来ません。
例えば複数のSectionモデルのinsert処理を一つのトランザクション内で実行しようとすると、append
やprepend
ではタグ名が重複してしまいますので、この方式ではそういったケースに対応することが出来ません。
タグ名を動的に変更する(連番を付加する)等の荒技もあり得ますが、タグ名はatom
で指定することが必要なため、連番数がいくつになるか不明な場合には大量のatom
を動的に生成してしまうことになり非常に危険です。
we should never convert user input to atoms. This is because atoms are not garbage collected. Once an atom is created, it is never reclaimed. Generating atoms from user input would mean the user can inject enough different names to exhaust our system memory!
考察2
もう一つ別の手法として、Ecto.Repoによるトランザクションをネストするという方式が用意されています。
If transaction/2 is called inside another transaction, the function is simply executed, without wrapping the new transaction call in any way. If there is an error in the inner transaction and the error is rescued, or the inner transaction is rolled back, the whole outer transaction is marked as tainted, guaranteeing nothing will be comitted.
上記はEcto 1.1のドキュメントになりますが、Ecto 2.0でもこの記述は有効です。
ということで、上述のEntryService
とSectionService
のinsert/1
関するから返される2種類のEcto.Multiを、Ecto.Repo.transaction/2
関数のネストに含めてみると、下記のようなコードになります。
Repo.transaction(fn ->
case Repo.transaction(EntryService.insert(entry_params)) do
{:ok, _} ->
Enum.each(entry_params["sections"], fn(section_params) ->
case Repo.transaction(SectionService.insert(section_params)) do
{:ok, _} -> :ok
{:error, _, _, _} -> raise "section save failed."
end
end)
{:error, _, _, _} -> raise "entry save failed."
end
end)
説明のためにかなり簡略化してあります。外側のトランザクションではEcto.Repo.transaction/2
関数の第一引数に無名関数を渡し、内側のトランザクションではEcto.Multi
構造体を渡しているところがポイントです。内側のトランザクション内で何らかのエラーが発生すれば、外側のトランザクションもロールバックされます。
まとめ
少々ゴチャついたコードになってしまうのが難点ですが、色々調べてみた限り、とりあえず現時点では考察2のような方法で対応しておくしかないかな〜という印象です。
なにか他に良い方法をご存知の方がいらっしゃいましたら、コメント等頂けますと大変有り難いですm(__)m