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

というイメージで良いと思います。