mmag

ハマったことメモなど

Ecto 2.0.0-betaを試す (3)

今回はEcto.Multiです。

なにそれ

Ecto.Multiとはそもそも何かというと、1つのトランザクション内で行われるべき複数Repo操作をまとめるデータ構造です。DBを操作する前にどのような変更がなされるのか覗き見することができます。これの導入に伴い、before_insertなどのcallbackが全てdeprecatedになり、callback地獄が解消されるそうです。

使う

使い方を簡単に見てみます。Ecto.Multi.new/0で構造体を初期化します。そしてEcto.Multi.insert/4Ecto.Multi.update/4を使って操作を追加していきます。追加した順に実行されますので、下の例ではUserをつくってからログに記録します。

multi = Ecto.Multi.new
  |> Ecto.Multi.insert(:new_user, User.changeset(%User{}, user_params))
  |> Ecto.Multi.insert(:log, Log.changeset(%Log{}, log_params))

トランザクション内で実行するには、Repo.transaction/2に渡します。

Repo.transaction(multi)

変更を取得する

ドキュメントには

We can introspect changesets and query to see if everything is as expected

と書かれており、どんな変更が行われるのか、つまりchangesetが見れるとあります(そもそもchangesetをこちらから渡しているので当然といえば当然ですが)。これにはEcto.Multi.to_list/1を使います。直接%Ecto.Multi{}を扱うのは推奨されないので、こういう関数を使いましょう。前々回で作ったUserを例にしてみます。

alias Ecto.Multi

user1 = %User{} |> User.changeset(%{name: "Jack", password: "password"})
user2 = %User{} |> User.changeset(%{name: "",     password: "password"})

multi = Multi.new
  |> Multi.insert(:user1, user1)
  |> Multi.insert(:user2, user2)

operations = Multi.to_list(multi)

Userを2人追加します。第2引数の:user1:user2は操作に名前を与えているだけなので、一意であれば何でもいいです。で、Ecto.Multi.to_list/1はその名前をキーにしたキーワードリストを返してくれるので、どの操作がエラーになるのかがわかるという感じです。

[user1: {
  :insert,
  #Ecto.Changeset<
    action: :insert,
    changes: %{
      name: "Jack",
      password: "password",
      password_hash: "hogehogefugafuga"
    },
    errors: [],
    data: #PlayEcto.User<>,
    valid?: true
  >,
  []},
 user2: {
  :insert,
  #Ecto.Changeset<
    action: :insert,
    changes: %{
      name: "",
      password: "password",
      password_hash: "hogehogefugafuga"
    },
    errors: [name: "can't be blank"],
    data: #PlayEcto.User<>,
    valid?: false
  >,
  []}
]

任意の処理を挟む

Ecto.Multi.run/3,5を使うと、insertやupdate以外の処理をトランザクションの内側で行うことができます。あんまり良い例が思いつかないですが、例えば正しく受注情報がinsertされたらメールを送ることを考えると以下のようになります。

Ecto.Multi.new
|> Ecto.Multi.insert(:order, Order.changeset(%Order{}, order_params))
|> Ecto.Multi.run(:send_mail, fn changes ->
  case send_mail(changes.order) do
    :ok -> {:ok, changes}
    _ -> {:error, changes}
  end
)

第3引数の無名関数には、runより前のinsertupdateで行われる変更が渡ってきます(changesetのリスト)。orderのinsertが失敗すればメールは飛びませんし、メール送信が失敗して無名関数が{:error, changes}を返すと、insertがロールバックされて注文が無かったことになります。この挙動はどうなんだという感じですが、例なので。

組み合わせる

Ecto.Multi.append/2, prepend/2を使うと、2つの%Ecto.Multi{}をガッチャンコできます。ただ繋げるだけです。 Ecto.Multi.append(m1, m2)Ecto.Multi.prepend(m2, m1)は等価です。

サービス

最後に、Ecto.Multiのmoduledocにおすすめの使い方が書いてあったので紹介。Serviceという層を設けます。

defmodule Service do
  alias Ecto.Multi
  import Ecto
  
  def password_reset(account, params) do
    Multi.new
    |> Multi.update(:account, Account.password_reset_changeset(account, params))
    |> Multi.insert(:log, Log.password_reset_changeset(account, params))
    |> Multi.delete_all(:sessions, assoc(account, :sessions))
  end
end

この層では%Ecto.Multi{}を作るまでを担当し、Repo.transaction/2に渡すのは別の場所でやるとのこと。例えばPhoenixアプリなら、lib/servicesディレクトリを切るかモデルにサービス層を書き、SQL発行はコントローラで、という分け方でしょうか。このようにRepo.transactionは呼ばずに%Ecto.Multi{}を返す関数を作ると、DBに触らないので単体テストがしやすくなるという利点があります。また、Ecto.Multi.append/2, prepend/2で組み合わせやすくなってcomposableになるという点も特徴の1つです。

ではでは、この辺で。