Tanaka on Rails

行動・努力・俯瞰

単純なアソシエーションと第3のモデルを利用したアソシエーション

何をまとめたいか

本投稿は単純なアソシエーションとブックマーク機能やお気に入り機能などを作れるような第3のモデルを利用したアソシエーションについてまとめていきます。
まだまだ知識浅薄であるため間違いがあったらご教示いただけると幸いです。

前提

猫型ロボット(nekogata_robot)は道具(item)を持っています。
また猫型ロボットは道具の保管方法としてポケット(pocket)を使用している時もあります。

それに伴い、モデルはnekogata_robotitempocketにて考えていきます。それぞれ
$ rails g model Nekogata_robot name:string
$ rails g model Item nekogata_robot:references name:string#1
$ rails g model pocket nekogata_robot:references item:references#2
でモデルを作っておきます#1と#2は後から触れていきますがアソシエーションにおいて非常に重要です。

キホン!

様々な関連付けがありますが、まずはキホン的なものから。

猫型ロボット(nekogata_robot)は道具(item)を持っているためアソシエーションが成り立ちます。
nekogata_robotモデルとitemモデルを使用してアソシエーションを行なっていきます。

まず外部キー制約を考慮してitemモデルを作っていきます。
(既にnekogata_robotモデルは作成されている前提)
$ rails g model Item nekogata_robot:referencesを行うと…

class CreateItems < ActiveRecord::Migration[6.1]
  def change
    create_table :items do |t|
      t.references :nekogata_robot, null: false, foreign_key: true
      t.string :name

      t.timestamps
    end
  end
end

というマイグレーションファイルができます。これで$ rails db:migrateをすると外部キーnekogata_robot_idがカラムとして追加されます。(もちろんnameも追加されます)
これによってnekogata_robotモデルのid(主キー)とitemモデルのnekogata_robot_id(外部キー)によってデータベースにおける関連付けがなされます。

次にアソシエーションの関係をコードに書いていきます。
「一匹の猫型ロボットがたくさんの道具を持っている」
つまりnekogata_robotモデルに対してitemモデルは「1対多」の関係。
models/nekogata_robot.rb

has_many :items

models/item.rb

belongs_to :nekogata_robot, dependent: :destroy

猫型ロボットが壊れてしまったら道具の保有者がいなくなるので、そうした際は保有していた道具ごと削除する必要があります。
そのような場合にdependent: :destroyは親モデルに連動して削除されるという機能をもたらします。

こうしてとりあえずのアソシエーションが完成したわけですが、実際に使用する際には
nekogata_robot.items.name(猫型ロボットの持っている道具の名前を探す)
items.nekogata_robot.name(道具を持っている猫型ロボットの名前を探す)
といった呼び出し方ができます。

以上がアソシエーションの基本です。

第3のモデルを利用したアソシエーション

ただこの猫型ロボットが持っている道具は単純に家に保管しているかもしれないですが、持ち運びをする場合にはポケットに保管していつでも道具を使えるようにしているかもしれないですよね?
また、猫型ロボットは一匹だけではなく、他にもたくさんの種類がいるかもしれないですよね?

すなわち以下の図のような関係をどのようにアソシエーションするかということです。
Image from Gyazo

この「多対多」の考え方についてもアソシエーションによって関連づけをすることができます。
「猫型ロボットはポケットに道具を持っている」
「道具は猫型ロボットのポケットに収納されている」
すなわちnekogata_robotモデルとitemモデルの間には「pocketモデル」があるのでこれをhas_many :throughを使って関連づけをしていきます。
(この章の表題の第3のモデルとはpocketモデルのことだったのです!)

pocketモデルを作成するためには…
$ rails g model Pocket nekogata_robot:references item:references を行います。

class CreatePockets < ActiveRecord::Migration[6.1]
  def change
    create_table :pockets do |t|
      t.references :nekogata_robot, null: false, foreign_key: true
      t.references :item, null: false, foreign_key: true

      t.timestamps
    end
  end
end

というマイグレーションファイルができるので、例の如く$ rails db:migrateをします。
するとnekogata_robot_idカラムとitem_idカラムが作成されます。

このpocketモデル内のnekogata_robot_idにnekogata_robotモデルのid(猫型ロボットを特定するid)、item_idにitemモデルのid(道具を特定するid)を入れた状態でcreateなどをすることで保存していくと、データベースのpocketモデルにどの猫型ロボットがどの道具をポケットに収納しているのかというデータが保管されます。

同様に第3のモデルを考慮した際のコードも追加していきます。それぞれ
models/nekogata_robot.rb

has_many :pockets, dependent: :destroy
has_many :pocket_items_boards, through: :pockets, source: :item

models/item.rb

has_many :pockets, dependent: :destroy
has_many :pocket_items_boards, through: :pockets, source: :item

models/pocket.rb

belongs_to :nekogata_robot  
belongs_to :items  

となります。上2つは全く同じコードです。

has_many :pockets, dependent: :destroy
当然アソシエーションを作るためにこの記述は必要となり、猫型ロボットか道具のいずれかが消えてしまえばポケットは存在する意味がないので消すべきです。
ただこの記述だけだと、それぞれがpocketメソッドとアソシエーションを結んでいるが、第3のモデルを使ったアソシエーションは依然使うことができない状態です。

そして次の記述が今回のポイント!

has_many :pocket_items_boards, through: :pockets, source: :item

2つ重要な要素があるのでそれぞれ確認をしていきます。

through: :pockets
第3のモデルであるpocketsモデルを通じてそれぞれアソシエーションを組む際にはこの記述が必要不可欠です。この記述によって以下のようなコードでポケットを絡めたデータの呼び出しが可能になります。
nekogata_robot.pocket_items.name(猫型ロボットがポケットに収納している道具の名前を探す)

ただこれだけでは物足りないと思います。安心してください、まだ特殊能力が付与されてます!

それがcollection << (object, …)というメソッド!
Railsガイドには以下のような説明があります。

collection<<メソッドは、1つ以上のオブジェクトをコレクションに追加します。このとき、追加されるオブジェクトの外部キーは、呼び出し側モデルの主キーに設定されます。

Railsガイドだけ見てもわかりにくいので実際に使ってみるとわかりやすいです。

models/nekogata_robot.rb

def pocket_create(item)
  pocket_items << item
end

このようなメソッドを作成し、コントローラでnekogata_robotitemをそれぞれ定義してあげた上で…

nekogata_robot.pocket_items(item)

のようなコードを使えば、レシーバのnekogata_robotよりnekogata_robot_idが、引数のitemよりitem_idがそれぞれ値が入力されてpocketモデルに新しいデータが作り出されます。これは

pocekt.create(nekogata_robot: params[:nekogata_robot], item: params[:item])

を行っていることと等しいため、コードの省略に貢献するでしょう!

has_many :pocket_items, ~ source: :items
sourceは基本的にアソシエーションを行う際に使います。
具体的にいうとsourceでは実際にthroughで指定したモデルを通じて関連づけをされる小モデルが指定されます。
なのでここではpocketモデルを通じて関連づけをされる子モデルitemモデルが入ります。なお複数形なので注意しましょう。

そして結論なのですが、has_many :pocket_itemsではsource: :itemsで指定したitemsの代わりに、実際に使用する際にはpocket_itemsを使うようにするという言い換えを定義しています。

なぜこんなことする必要があるのでしょうか?
それはこの言い換えを行わないとitemsが重複するからです。

キホン!でも説明したように、既にnekogata_robotと単純なアソシエーション関係にあるため…
nekogata_robot.items.name(猫型ロボットの持っている道具の名前を探す)
のような使い方をされていました。

そして今またここでitemsを定義してしまうと、itemsは単純なアソシエーションによるものなのか?はたまた第三者のモデルを使ったアソシエーションによるものなのか?
2つの意味を持ってしまうため訳が分からなくなります。

そういった事態を避けるためにこのsourceを使用しています。なのでthroughを使ったアソシエーションではsourceは非常に重要な意味を持つのです。

結論

単純なアソシエーションなら
has_many :〇〇belongs_to :〇〇

第3のモデルを使う場合は単純なアソシエーションに加えて
has_many :〇〇, through: :〇〇, source: :〇〇 を書いてあげればOK!

なお今回の事例ではド〇えもんのポケットを例にして記入したため、22世紀まではこの機能を実装することはまずあり得ないと思います。
実際にはブックマーク機能やお気に入り機能を作る際に非常に役に立つっぽいです!

参考

https://qiita.com/imotan/items/036ceffb79e294d8a063
https://qiita.com/tomoharutt/items/e548186c763079327ed1#through
https://railsguides.jp/association_basics.html#has-many%E3%81%A7%E8%BF%BD%E5%8A%A0%E3%81%95%E3%82%8C%E3%82%8B%E3%83%A1%E3%82%BD%E3%83%83%E3%83%89-collection