11
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Ectoでhas_many through

Last updated at Posted at 2017-10-08

概要

タグ情報的なn : mの関係を作るだけなら、many_to_manyを使えば多分楽に作れるんだろうけど、「中間テーブルにも追加で属性値もたせたりしたいんだい」、という場合、まぁhas_many throughを使うんだと思うんですが、Railsに慣れていると若干ハマったのでメモ。
あ、ちなみに書いてる人は、Rails使いですが、Elixir/Phoenixはまだまだ慣れてません。
もっとエレガントに書けんだぜ?的なご意見ご感想は、歓迎いたします。

前提

usersteamsn : mです。
中間テーブルはuser_belongingsとしています。

migration

users

メールアドレスが、ユニークな毎度のやつです。

defmodule App.Repo.Migrations.CreateUsers do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :email, :string
      add :first_name, :string
      add :last_name, :string
      timestamps
    end

    create index(:users, [:email], unique: true)
  end
end

teams

こっちは名前でユニークです。
そういや、Ectoのmigrationでスキーマコメントってどうするんだ?

defmodule Nta.Repo.Migrations.CreateTeams do
  use Ecto.Migration

  def change do
    create table(:teams) do
      add :name, :string
      timestamps
    end

    create index(:teams, [:name], unique: true)
  end
end

user_belongings

デフォルトのプライマリキーは外して、user_idteam_idを複合プライマリキーとしています。
今の所は、他に属性値がないですが、あとで追加される予定です。

defmodule App.Repo.Migrations.CreateUserBelongings do
  use Ecto.Migration

  def change do
    create table(:user_belongings, primary_key: false) do
      add :user_id, references(:users)
      add :team_id, references(:teams)

      timestamps
    end

    create index(:user_belongings, [:user_id, :team_id], primary_key: true)
    create index(:user_belongings, [:user_id])
    create index(:user_belongings, [:team_id])
  end
end

model

User

web/models/user.ex
defmodule App.User do
  use Ecto.Schema
  
  @primary_key {:id, :id, autogenerate: true}

  schema "users" do
    field :email, :string
    field :first_name, :string
    field :last_name, :string

    timestamps()

    has_many :belongings, App.UserBelonging
    has_many :teams, through: [:belongings, :team]
  end
end

Team

web/models/team.ex
defmodule App.Team do
  use Ecto.Schema
  
  @primary_key {:id, :id, autogenerate: true}

  schema "teams" do
    field :name, :string

    timestamps()

    has_many :belongings, App.UserBelonging
    has_many :users, through: [:belongings, :user]
  end
end

UserBelonging

web/models/user_belonging.ex
defmodule App.UserBelonging do
  use Ecto.Schema
  
  @primary_key false

  schema "user_belongings" do
    timestamps()
    belongs_to :user, App.User, primary_key: :user_id
    belongs_to :team, App.Team, primary_key: :team_id
  end
end

複合プライマリキーはこんな感じで書くらしい。

実行

iex> {:ok, user} = %User{email: "hoge@hoge.com", last_name: "hoge", first_name: "hoge" } |> insert
iex> user |> preload(:teams)
%App.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, belongings: [],
 email: "hoge@hoge.com", first_name: "hoge", id: 1,
 inserted_at: ~N[2017-10-08 08:45:10.973353], last_name: "hoge", teams: [],
 updated_at: ~N[2017-10-08 08:45:11.106633]}
iex> user |> Ecto.assoc([:belongings, :team]) |> all
[]
iex> user |> Ecto.assoc(:teams) |> all
[]

とりあえず、user経由での:teamsは、どうやら取得できているようです。
まぁまだ一つも関連がないので、空リストですが。
では、teamを作って、userと関連づけてみます。

iex> {:ok, team1} = %Team{name: "fuge1"} |> insert 
iex> user |> preload(:teams) |> Ecto.Changeset.change |> Ecto.Changeset.put_assoc(:teams, [team1])
** (ArgumentError) cannot put assoc `teams`, assoc `teams` not found. Make sure it is spelled correctly and properly pluralized (or singularized)
    (ecto) lib/ecto/changeset.ex:751: Ecto.Changeset.relation!/4
    (ecto) lib/ecto/changeset.ex:1089: Ecto.Changeset.put_relation/5

teamsなんてassoc定義されてねーよ、と怒られました……。
どうも、この辺りを読むと、

In fact, given :through associations are read-only, using the Ecto.assoc/2 format is the preferred mechanism for working with through associations.

まぁちゃんと読んでないですが(ぉ)、ダメっぽい雰囲気は感じられました。
Railsは勝手に色々やってくれるのですが、それに慣れてるとこういうところで詰まります。
というわけでUserBelongingを直に操作しないといけないらしいです。

追加


iex> belongings = user |> Ecto.assoc(:belongings) |> all
iex> new_belonging = team1 |> Ecto.build_assoc(:belongings)
iex> belongings = belongings ++ [new_belonging]
iex> user |> preload(:belongings) |> Ecto.Changeset.change |> Ecto.Changeset.put_assoc(:belongings, belongings) |> update
{:ok,
 %App.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
  belongings: [%App.UserBelonging{__meta__: #Ecto.Schema.Metadata<:loaded, "user_belongings">,
    inserted_at: ~N[2017-10-08 09:17:17.943882],
    team: #Ecto.Association.NotLoaded<association :team is not loaded>,
    team_id: 1, updated_at: ~N[2017-10-08 09:17:17.943894],
    user: #Ecto.Association.NotLoaded<association :user is not loaded>,
    user_id: 1}], email: "hoge@hoge.com", first_name: "hoge", id: 1,
  inserted_at: ~N[2017-10-08 08:45:10.973353], last_name: "hoge",
  teams: #Ecto.Association.NotLoaded<association :teams is not loaded>,
  updated_at: ~N[2017-10-08 08:45:11.106633]}}

iex> user |> Ecto.assoc(:teams) |> all
[%App.Team{__meta__: #Ecto.Schema.Metadata<:loaded, "teams">,
  belongings: #Ecto.Association.NotLoaded<association :belongings is not loaded>,
  id: 1, inserted_at: ~N[2017-10-08 08:45:11.000000], 
  name: "fuge1", updated_at: ~N[2017-10-08 08:45:11.000000]
  users: #Ecto.Association.NotLoaded<association :users is not loaded>}]

無事、関連の保存と取得が出来ているようです。

さらに追加

では、次に関連をさらに追加してみます。

iex> {:ok, team2} = %Team{name: "fuge2"} |> insert
iex> belongings = user |> Ecto.assoc(:belongings) |> all
iex> new_belonging = team2 |> Ecto.build_assoc(:belongings)
iex> belongings = belongings ++ [new_belonging]
iex> user |> preload(:belongings) |> Ecto.Changeset.change |> Ecto.Changeset.put_assoc(:belongings, belongings) |> update
iex> user |> Ecto.assoc(:teams) |> all
[%App.Team{__meta__: #Ecto.Schema.Metadata<:loaded, "teams">,
  belongings: #Ecto.Association.NotLoaded<association :belongings is not loaded>,
  id: 1, inserted_at: ~N[2017-10-08 08:45:11.000000], name: "fuge1", updated_at: ~N[2017-10-08 08:45:11.000000], users: #Ecto.Association.NotLoaded<association :users is not loaded>},
 %App.Team{__meta__: #Ecto.Schema.Metadata<:loaded, "teams">,
  belongings: #Ecto.Association.NotLoaded<association :belongings is not loaded>,
  id: 2, inserted_at: ~N[2017-10-08 08:45:11.000000], name: "fuge2", updated_at: ~N[2017-10-08 08:45:11.000000], users: #Ecto.Association.NotLoaded<association :users is not loaded>}]

問題なしですね。

削除

では、最後に関連を削除してみます。

iex> user |> preload(:belongings) |> Ecto.Changeset.change |> Ecto.Changeset.put_assoc(:belongings, []) |> update
** (RuntimeError) you are attempting to change relation :belongings of
App.User, but there is missing data.

If you are attempting to update an existing entry, please make sure
you include the entry primary key (ID) alongside the data.

If you have a relationship with many children, at least the same N
children must be given on update. By default it is not possible to
orphan embed nor associated records, attempting to do so results in
this error message.

If you don't desire the current behavior or if you are using embeds
without a primary key, it is possible to change this behaviour by
setting `:on_replace` when defining the relation. See `Ecto.Changeset`'s
section on related data for more info.

    (ecto) lib/ecto/changeset/relation.ex:176: Ecto.Changeset.Relation.on_replace/2
    (ecto) lib/ecto/changeset/relation.ex:299: Ecto.Changeset.Relation.reduce_delete_changesets/5
    (ecto) lib/ecto/changeset.ex:1092: Ecto.Changeset.put_relation/5

怒られました。
どうやら、アソシエーション定義時にon_replaceオプションを渡してやらないといけないようです。

というわけで、UserとTeamの両方で、
has_many :belongings, App.UserBelonging
on_replace: :deleteの記述を足してやります。

iex> user |> preload(:belongings) |> Ecto.Changeset.change |> Ecto.Changeset.put_assoc(:belongings, []) |> update
iex> user |> Ecto.assoc(:teams) |> all
[]

無事、削除もできるようになりました。

11
9
0

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
11
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?