2
4

More than 1 year has passed since last update.

いつも忘れてしまうRailsの多対多 + 自己(関連先が自身のテーブル)参照 + 条件付き関連の書き方をフォロー・フォロワーを例にまとめる

Last updated at Posted at 2022-01-07

概要

Railsでは has_many, belongs_to の関連をよく書くのでしっかり脳で覚えているのですが、
has_many through を使った多対多の関連はたまにしか書かないためくよく忘れてしまいます。

なので個人の備忘録としても、理解をしてより覚えるためにも図でまとめました。

  • Railsチュートリアルでもよく使われている、Twitterのようなサービスを例にした「ユーザの相互フォロー」を記載していきます
  • ユーザAさんは複数のユーザをフォローできます
  • ユーザAさんは複数のユーザにフォローされます

テーブル構造

  • ユーザ情報(users)
  • ユーザのフォロー関連(user_follow_statuses)

スクリーンショット 2022-01-07 12.18.36.png

コード

user.rb
class User < ApplicationRecord
  has_many :follow_statuses, class_name: UserFollowStatus.to_s,
                             foreign_key: :follower_id,
  # 自分がフォローしているユーザたち
  has_many :following, through: :follow_statuses, source: :followed

  has_many :followed_statuses, class_name: UserFollowStatus.to_s,
                               foreign_key: :followed_id,
  # 自分がフォローされているユーザたち
  has_many :followers, through: :followed_statuses, source: :follower
end
user_follow_status.rb
class UserFollowStatus < ApplicationRecord
  belongs_to :follower, class_name: User.to_s
  belongs_to :followed, class_name: User.to_s
end

AさんがBさんをフォローしている

  • 以下のような関係になる

スクリーンショット 2022-01-07 12.24.07.png

このとき以下のようなプログラムでそれぞれフォローしている相手、されている相手を取得できるようになる。

# Aさんがフォローしているユーザたち
user(A).following
#=> [UserB, UserC]

# Bさんがフォローされているユーザたち
user(B).followers
#=> [UserA]

なるけど、これを今暗記・コードを使い回す状態なのでもう少し整理する。

プログラムの関連

プログラムを並べるとこういう関係性になる。ちょっとわかるようで分からない…。

スクリーンショット 2022-01-07 13.11.07.png

順序よく考える

AさんがB,Cさんをフォローしているという状況

AさんがB,Cさんをフォローしているという状況を考える。
データは以下のように作成される。

スクリーンショット 2022-01-07 12.24.07.png

これを has_many, belongs_to の2つだけを使ってまとめると以下になる

スクリーンショット 2022-01-07 12.24.17.png

※緑色の字はRailsの命名規則に従っていれば省略可能なものですが、理解を深めるためにあえて記載しています

このときAさんがフォローしているB,Cさんを取得するには以下のプログラムを書けば良い

# 自分がフォロー側になっている関連データを取得
user.follow_statuses.map do |follow_status|
  # 各データのフォロー先のユーザのデータを取得
  follow_status.followed
end
#=> [UserB, UserC]

この処理で発行されるSQLは下記

-- AさんのユーザIDである 1 が follower_id に設定されているデータ一覧を取得
UserFollowStatus Load (0.6ms)  SELECT `user_follow_statuses`.* FROM `user_follow_statuses` WHERE `user_follow_statuses`.`follower_id` = 1
  -- 取得されたデータの followed_id に入っているユーザID(Bさんの2, Cさんの3)でユーザデータを取得
  User Load (0.7ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 2 LIMIT 1
  User Load (0.7ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 3 LIMIT 1

※N+1問題が発生していますがわかりやすさのためそのままにしています

これを has_many through を利用すると以下のようにまとめられる

スクリーンショット 2022-01-07 12.30.27.png

つまり has_many で定義している follow_statuses を経由(through)して、
そこからさらに各データの followed で定義されている先のデータを取得している、といえる。

この定義により

# Aさんがフォローしているユーザたち
user(A).following
#=> [UserB, UserC]

という簡潔にプログラムが記載できるようになる。
このとき発行されるSQLは下記のようになる。

 SELECT `users`.* FROM `users` INNER JOIN `user_follow_statuses` ON `users`.`id` = `user_follow_statuses`.`followed_id` WHERE `user_follow_statuses`.`follower_id` = 1

これは単純に 簡潔になる だけではなく、以下のようなメリットも受けることができる

  • N+1問題など気にしなくてもSQLの発行がすくなくなりDBのアクセス負荷を減らせる
  • 戻り値が ActiveRecord_Associations_CollectionProxy になり、クエリのメソッドチェーンができる

BさんがA,Cさんにフォローされているという状況

次にフォローされている状況を考える。
BさんはA,Cさんにフォローされている、とする。

データは以下のように作成される。

スクリーンショット 2022-01-07 12.37.20.png

これを has_many, belongs_to の2つだけを使って考えると以下になる

スクリーンショット 2022-01-07 13.16.11.png

※緑色の字はRailsの命名規則に従っていれば省略可能なものですが、理解を深めるためにあえて記載しています

このときBさんがフォローされているA,Cさんを取得するには以下のプログラムを書けば良い

# Bさんがフォローされている側の関連テーブルのデータを取得
user.followed_statuses.map do |followed_status|
  # フォローしているユーザのデータを取得
  follow_status.follower
end
#=> [UserA, UserC]

この処理で発行されるSQLは下記

-- BさんのユーザIDである 2 が followed_id に設定されているデータ一覧を取得
UserFollowStatus Load (0.9ms)  SELECT `user_follow_statuses`.* FROM `user_follow_statuses` WHERE `user_follow_statuses`.`followed_id` = 2
  -- 取得されたデータの follower_id に入っているユーザID(Aさんの1, Cさんの3)でユーザデータを取得
    User Load (1.0ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
    User Load (0.9ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 3 LIMIT 1

※N+1問題が発生していますがわかりやすさのためそのままにしています

これも同様に has_many through を利用すると以下のようにまとめられる

スクリーンショット 2022-01-07 13.17.50.png

よって

# Bさんがフォローされているユーザたち
user(B).followers
#=> [UserA, UserC]

という簡潔にプログラムが記載できるようになる。
このとき発行されるSQLは下記のようになる。

 SELECT `users`.* FROM `users` INNER JOIN `user_follow_statuses` ON `users`.`id` = `user_follow_statuses`.`follower_id` WHERE `user_follow_statuses`.`followed_id` = 2

こちらもフォローのときと同じようにメリットを受けられる

ということでこれらをひっくるめて冒頭のコードが出来上がるし、以下のような関連の図ができあがる

スクリーンショット 2022-01-07 13.11.07.png

応用: 条件を付ける

さらに応用としてユーザのフォローに種別を追加する。
例えばこのフォローに「名前のみ閲覧可能」「投稿も閲覧可能」というタイプを持たせる。

以下がデータベースのテーブル構造。

スクリーンショット 2022-01-07 13.19.01.png

  • follow_type: 0=名前のみ閲覧可能
  • follow_type: 1=投稿も閲覧可能

とする。

このとき「名前のみ閲覧可能なフォローしているユーザ一覧」の関連を定義する

コード

user.rb
class User < ApplicationRecord
  has_many :name_only_follow_statuses, -> { where(follow_type: 0) },
                                       class_name: UserFollowStatus.to_s,
                                       foreign_key: :follower_id,
                                       primary_key: :id
  has_many :name_only_following, through: :name_only_follow_statuses, source: :followed
end

順序よく考える

  • AさんがB,Cさんをフォローしている
    • Bさんは名前のみ閲覧可能(typeが0)
    • Cさんは投稿も閲覧可能(typeが1)

データは以下のように作成される。

スクリーンショット 2022-01-07 13.19.10.png

これも同様に has_many, belongs_to の2つだけを使ってこの関連を考えると以下になる

スクリーンショット 2022-01-07 13.34.18.png

※has_many には条件をブロックオブジェクトで渡すことができる
※belongs_to 側は応用前と変更がないので省略

同様にこれをそのままプログラムで書くと以下のようになる

user.name_only_follow_statuses.map do |followed_status|
  followed_status.followed
end
#=> [UserB]

発行されるSQLは下記。

-- ユーザAのidである1が follower_id にあるものを検索
-- かつ follow_typeが0(=名前だけ閲覧可能)のデータだけを検索
UserFollowStatus Load (1.0ms)  SELECT `user_follow_statuses`.* FROM `user_follow_statuses` WHERE `user_follow_statuses`.`follower_id` = 1 AND `user_follow_statuses`.`follow_type` = 0
  -- 取得した結果であるユーザB(idが2)のユーザデータを取得
  User Load (0.7ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 2 LIMIT 1

これを has_many through を利用すると以下のようにまとめられる

スクリーンショット 2022-01-07 13.24.33.png

よって

# Aさんが名前のみ閲覧可能でフォローしているユーザたち
user(A).name_only_following
#=> [UserB]

という簡潔にプログラムが記載できるようになる。
このとき発行されるSQLは下記のようになる。

SELECT `users`.* FROM `users` INNER JOIN `user_follow_statuses` ON `users`.`id` = `user_follow_statuses`.`followed_id` WHERE `user_follow_statuses`.`follower_id` = 1 AND `user_follow_statuses`.`follow_type` = 0

ということで、以下の2ステップで条件付きの多:多も可能となる

  • 1.関連テーブルになる関連先に条件を付けた、新しい has_many を定義
  • 2.上記1で定義した関連で has_many through を定義する

Tips

  • この関連テーブルのリレーションは基本的に dependent: :destroy を付けておいた方が良いよ!

まとめ

  • 多:多はそんなに怖くない!(と思う)
2
4
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
2
4