学習日記

学習記録

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_articleArticleモデルに属している
  • ArticleMentionモデルのtarget_articleArticleモデルに属している

ということを明示しています。

モデルに属しているというのはどういうことかというと属しているモデルに依存しているということですので、今回はカラムがモデルに依存しているので、そのカラムが依存しているモデルそのものを指します。

小難しくなりましたが、要するにsource_articletarget_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_articleselfのデータを簡単に取り出せます
    • 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_mentionsactive_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行目とほぼ一緒で逆のことを定義している(つまり自身へ言及している記事のデータを取得)だけなので割愛します、お疲れ様でした。