Railsにおける自己結合の関連付け
前書き
多対多の自己結合で10時間くらい溶かしたので、同じようなユーザー、見返したくなった自分に向けてまとめておきます。
方針
多対多の自己結合がわからなく調べましたが、理解力が足りないので例を出されても納得できるまでものすごい時間がかかったので、出来るだけ自身が躓いた言葉を使わないように進めていきます。
モデルケースとして、ブログを題材にします。
記事内に他の記事(同サイト内)のURLが含まれていた(言及されていた)場合、その関連をデータベースに保存したい、そしてメソッド一つで言及した記事を呼び出したいというケースにおいてどのように関連付ければいいのかを解説します。
関連付けに対する部分以外を説明してしまうと、より解り難くなってしまうので関連付けに必要ない部分はあえて触れません。
あくまでも関連付けの部分のみを説明します。
本題
今回は自己結合の説明のみ行いますので、記事を作成するためのArticle
モデルと言及情報を管理するための中間テーブルであるArticleMention
モデルがあったとして進めていきます
実際にアプリケーション制作の形で説明する訳ではありませんが、説明の都合上モデルを新しく作成すると仮定して説明していきます。
rails g model Article title:text content:text
としてArticle
モデルを作成します
次にArticleMention
モデルですが source_article_id
というカラムを持たせて、これには言及元の記事のidを target_source_id
というカラムを持たせて、これには言及先の記事のidを保持させることにします。
rails generate model ArticleMention source_article:references target_article:references
として作成します。
この時生成されるモデルは
class ArticleMention < ApplicationRecord belongs_to :source_article belongs_to :target_article end
ですが、これを編集して
class ArticleMention < ApplicationRecord belongs_to :source_article, class_name: 'Article' belongs_to :target_article, class_name: 'Article' end
とします。
この時何を設定しているか説明します。
belongs_to
とはそのモデルが何かに属していることを示します。
1:多の関係であれば、belongs_to :article
とするだけで、ArticleMention
モデルはArticle
モデルに属しているので、外部キーにarticle_id
を持つ ということを明示できます。
しかし今回の場合は多:多の自己結合なので上記のように書くことで
ArticleMention
モデルのsource_article
はArticle
モデルに属しているArticleMention
モデルのtarget_article
はArticle
モデルに属している
ということを明示しています。
モデルに属しているというのはどういうことかというと属しているモデルに依存しているということですので、今回はカラムがモデルに依存しているので、そのカラムが依存しているモデルそのものを指します。
小難しくなりましたが、要するにsource_article
もtarget_article
も元をただせばReport
モデルのインスタンスのidだよってことを回りくどく書いています。
なんでこんなことわざわざ書くんだよって話ですが、これはArticle
側の関連付けとその結果を見て頂ければわかります。
さっそくArticle側の関連付けをしていきます
class Article < ApplicationRecord has_many :active_mentions, class_name: 'ArticleMention', foreign_key: :source_article_id, dependent: :destroy, inverse_of: :source_article has_many :mentioning_article, through: :active_mentions, source: :target_article has_many :passive_mentions, class_name: 'ArticleMention', foreign_key: :target_article_id, dependent: :destroy, inverse_of: :target_article has_many :mentioned_article, through: :passive_mentions, source: :source_article end
ではこれが何を書いているのかっていう話ですが、一つ一つみていきましょう
2行目のactive_mentions
関連から行きます
has_many :active_mentions
これはArticle
モデルがactive_mentions
という関連を0以上持っていることを示します。class_name: 'ArticleMention'
これはこの関連はArticleMention
テーブルを参照するということを示します。foreign_key: :source_article_id
これは参照するテーブルのsource_article_id
が呼び出したArticle
モデルのインスタンスであるデータを取得することを示していますdependent: :destroy
これはArticle
モデルのインスタンスが消えた時、そのインスタンスが含まれるactive_mentions
(言い換えればArticleMention
モデル)のデータが消えることを示します。inverse_of: :source_article
これは少し難しいですが、この記述によりオブジェクトの相互参照、データベースクエリの最適化、整合性の維持の効果が期待できますArticle
モデルのインスタンスがArticleMention
モデルのインスタンスにアクセスする際、self.source_article
とすることでArticleMention
テーブルのsource_article
がself
のデータを簡単に取り出せますArticleMention
モデルのインスタンスからArticleモデルの
インスタンスにアクセスする際も、同様にsource_article
属性を使用できます。Article
モデルのインスタンスをロードする際に、関連付けられたArticleMention
モデルのインスタンスも同時にロードされ、N+1問題を回避できます。Article
モデルのインスタンスが変更された場合、ArticleMention
モデルのインスタンスも自動的に更新されます- 逆の場合も同じく自動的に更新されます。
つまり2行目ではactive_mentions
という関連を定義しています。
その関連とは呼び出したArticle
モデルのインスタンスに対してArticleMention
テーブルの中のsource_article_id
が自身のデータということになります。
inverse_of
を利用することで、その逆の関連付けも同時にしています。
つまりどういうことだよって言うと
article = Article.find(1)
article.active_mentions
とした時にArticleMention
テーブルの中のsource_article_id
が1のデータが配列となって出てきます。
source_article_id
とは言及元の記事のidなので、言及元が自身である中間テーブルのデータを配列として取得するということになります。
さて前書きでもお話させて頂いた通り、目標はメソッド一つで言及した記事を呼び出したいです。
このままでは中間テーブルを呼び出しているだけなので、さらに関連を追加します。
それが3行目になります。
has_many :mentioning_article
は2行目の時と同じく関連名を明示しています。through: :active_mentions
はactive_mentions
関連を通してデータを取得することを明示しています。source: :target_article
は先ほど明示した関連の中からtarget_article
が示すインスタンスを取得することを明示しています。
つまり纏めると
Article
モデルはmentioning_article
関連を0以上持っていて、その関連とはactive_mentions
関連の中のtarget_article
が示すインスタンスである
ということになります。
これにより何が出来るかというと、先ほど説明したactive_mentions
の中からtarget_article_id
が示すインスタンスを取り出すので、自身が言及したArticle
モデルのインスタンスを直接呼び出せるということになります。
もっと具体的にいえば
article = Article.find(1)
article.mentioning_article
でarticle
が言及した記事を配列として直接取得できるようになります。
これで当初の目標を達成できました。
4行目と5行目は2行目、3行目とほぼ一緒で逆のことを定義している(つまり自身へ言及している記事のデータを取得)だけなので割愛します、お疲れ様でした。