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