LoginSignup
17
18

More than 5 years have passed since last update.

複数のEcto.Multiを一つのトランザクション内で実行するには?

Posted at

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にはappendprependという、複数のEcto.Multi構造体を一つのEcto.Multi構造体にまとめることが出来る関数が用意されています。

しかし、ドキュメントを読んでみると

All names must be unique between both structures.

という記述があります。要するに各オペレーションのタグ名は他と重複してはいけないというルールになっています。

上記のコードの場合、例えば:logという重複するタグ名を:entry_log:section_logに変更すればうまくいきそうですが、この方式はそれぞれのMultiを一回だけ実行する場合にしか適用出来ません。

例えば複数のSectionモデルのinsert処理を一つのトランザクション内で実行しようとすると、appendprependではタグ名が重複してしまいますので、この方式ではそういったケースに対応することが出来ません。

タグ名を動的に変更する(連番を付加する)等の荒技もあり得ますが、タグ名は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によるトランザクションをネストするという方式が用意されています。

Ecto.Repo.transaction/2

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でもこの記述は有効です。

ということで、上述のEntryServiceSectionServiceinsert/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

17
18
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
18