Ectoのassoc関数を整理してみる
EctoおよびEcto.Changesetには、~assoc
という命名の関数が複数存在します。
assoc
という単語が入っているように、それぞれレコードのリレーションに関する処理を行う関数なのですが、ちょいと違いが分かりにくかったのでまとめておきます。
Ecto.build_assoc/3
build_assocはリレーション元の構造体を入力として、リレーション先の構造体を生成する関数です。まさにbuildですね。
次のように、has_manyの構造体を作りたいときに利用します。
# リレーション元の親テーブルの構造体を取得
iex> post = Repo.get(Post, 13)
%Post{id: 13}
# リレーション先の子テーブルの構造体にIDがセットされる
iex> build_assoc(post, :comments)
%Comment{id: nil, post_id: 13}
逆に、belongs_toの関係の場合には何も作用しないので注意が必要です。
# 子テーブルの構造体を取得
iex> comment = Repo.get(Comment, 13)
%Comment{id: 13, post_id: 25}
# build_assocでリレーション先の親テーブルの構造体を作ろうとするも、idはセットされない
iex> build_assoc(comment, :post)
%Post{id: nil}
Ecto.Changeset.cast_assoc/3
cast_assocはhas_manyやmany_to_manyなどのmany系のリレーションにおいて、リレーション先のChangesetを一括で生成する関数です。
公式ドキュメントの例では以下のように書かれています。
params = %{"name" => "john doe", "addresses" => [
%{"street" => "somewhere", "country" => "brazil", "id" => 1},
%{"street" => "elsewhere", "country" => "poland"},
]}
User
|> Repo.get!(id)
|> Repo.preload(:addresses) # Only required when updating data
|> Ecto.Changeset.cast(params, [])
|> Ecto.Changeset.cast_assoc(:addresses, with: &MyApp.Address.changeset/2) ・・・(1)
元のDBの想定がわからないので厳密ではないですが、仮にid=1の住所レコードが1つだけある状態だったとして、(1)のタイミングでは次のような値になります。
%Ecto.Changeset{
action: nil,
changes: %{
addresses: [
%Ecto.Changeset{
action: :update, # idが指定されているのでupdateになっている
changes: %{street: "somewhere", country: "brazil"},
errors: [],
data: %MyApp.Address{},
valid?: true
},
%Ecto.Changeset{
action: :insert, # idが指定されていないのでinsertになっている
changes: %{country: "poland", street: "elsewhere"},
errors: [],
data: %MyApp.Address{},
valid?: true
}
],
name: "john doe"
},
errors: [],
data: %MyApp.User{},
valid?: true
}
idを含んでいれば更新(:update)に、含んでいなければ新規作成(:insert) というルールでChangesetを生成してくれます。
また、すでにリレーション先のレコードがあるのにもかかわらず
params = %{"name" => "john doe", "addresses" => []}
のように実行した場合には、Changesetには :replace
というアクションが設定されます。
%Ecto.Changeset{
action: nil,
changes: %{
addresses: [
%Ecto.Changeset{action: :replace, changes: %{}, errors: [],
data: %MyApp.Address{}, valid?: true},
%Ecto.Changeset{action: :replace, changes: %{}, errors: [],
data: %MyApp.Address{}, valid?: true}
],
name: "john doe"
},
errors: [],
data: %MyApp.User{},
valid?: true
}
このreplaceになったときの制御はhas_manyなどのリレーションを記述している箇所で可能です。
defmodule MyApp.User do
use Ecto.Schema
import Ecto.Changeset
alias MyApp.Address
schema "users" do
field :name, :string
has_many :addresses, Address, on_replace: :delete # replaceになった場合には、レコードを削除する
timestamps()
end
@doc false
def changeset(user, attrs) do
user
|> cast(attrs, [:name])
|> validate_required([:name])
end
end
上記のようにon_replace: :delete
を設定した状態でRepo.update()
を実行すると、params
に含まれなかった既存レコードは自動で削除されます。
tagやlabelなど、そこそこフランクに付け替えするものに対しては:delete
を設定し、投稿に対するコメントなど一括で消さないようなものには例外をだす:raise
を設定しておくと良さそうです。
on_replaceの設定については公式ドキュメントのこちらをご覧ください。
Ecto.Changeset.put_assoc/4
put_assocについては、すでに構造体ができている場合のcast_assocと考えるとしっくりきます。
cast_assocとput_assocの違いについて、joseさんがコメントしているのを見つけました。
To answer your question, you use cast_assoc when you want to cast external parameters, like the ones from a form, into an asociation. You use put_assoc when you already have an association struct.
ざっくり訳すと、「フォームからのPOSTのような外部から受け取る値についてはcast_assoc
を使い、すでに関連する構造体がある場合はput_assoc
を使おう」とのこと。
なので、
tags = Repo.all(from t in Tag, where: t.name in ^params["tags"])
post
|> Repo.preload(:tags)
|> Ecto.Changeset.cast(params, [:title]) # No need to allow :tags as we put them directly
|> Ecto.Changeset.put_assoc(:tags, tags)
のように、Repo.all
するなどしてすでにstructが得られている場合でcast_assoc
と同じようなことをしたいときに使えそうです。
まとめ
~assocと命名されている関数についてまとめてみました。
-
build_assoc/3
はhas_many先の構造体を生成するときに利用する -
cast_assoc/3
はmany系の関連を持つレコードを一括で作るときに利用する -
put_assoc/4
は構造体版cast_assoc
というイメージで良いと思います。
Discussion
とある案件で実装する際にめちゃくちゃに参考にさせてもらいました(非常に助かりました!)。
心ばかりのサポートをば...!
ありがとうございます!参考になって良かったです!