Rails
ActiveRecord

Rails(ActiveRecord)でN:Nの自己結合を実装する

More than 3 years have passed since last update.

Twitterみたいなユーザ同士をフォローする仕組みを実装するにあたり、ActiveRecordでN:Nの自己結合を実装するのにちょっとハマったので書いておきます。

前提

  • usersテーブルがある
  • User同士のフォロー関係を登録するfollowsテーブルがある

具体的なmigrationは以下。

20130825021654_create_users.rb
class CreateUsers < ActiveRecord::Migration
  def change
    create_table :users do |t|
      t.string :name, null: false
      t.string :email
      # :
      # :
      t.timestamps
    end
  end
end
20140303134930_create_follows.rb
class CreateFollows < ActiveRecord::Migration
  def change
    create_table :follows do |t|
      t.integer :from_user_id, null: false
      t.integer :to_user_id, null: false

      t.timestamps

      t.index :from_user_id
      t.index :to_user_id
      t.index [:from_user_id, :to_user_id], unique: true
    end
  end
end

やりたいこと

下記のようなインターフェイスで実装したいとします。

User.find(1).following # フォローしているUserの配列が返る
User.find(1).followed  # フォローされているUserの配列が返る

ハマりポイント

  • リレーションがN:N
  • Railsのデフォルトでは user_id を結合に使うが、今回は同じテーブルを結合するので使えない

実装

まずUserからFollow、FollowからUserに1:Nのリレーションをはります。

user.rb
has_many :follows, foreign_key: :from_user_id
follow.rb
belongs_to :from_user, class_name: User, foreign_key: :from_user_id

こうすると、 User.find(1).follows でFollowが、 Follow.find(1).from_user でフォロー元のUserというリレーションができます。次に、これら1:Nのリレーションを結合する設定をUserに追加します。

user.rb
has_many :follows, foreign_key: :from_user_id
has_many :following, through: :follows, source: :to_user # ← 追加

これで、 User.find(1).following でフォロー先のUserの配列が取得できるようになりました。followed側も同様に実装できます。

最終形

最終的に下記のようになりました。条件の異なる2つの has_many :follows が必要になるのでリネームしました。

user.rb
has_many :follows_from, class_name: Follow, foreign_key: :from_user_id, dependent: :destroy
has_many :follows_to,   class_name: Follow, foreign_key: :to_user_id,   dependent: :destroy
has_many :following, through: :follows_from, source: :to_user
has_many :followed,  through: :follows_to,   source: :from_user
follow.rb
belongs_to :from_user, class_name: User, foreign_key: :from_user_id
belongs_to :to_user, class_name: User,   foreign_key: :to_user_id

おまけ

下記のようなインターフェイスを追加したくなりました。

user.rb
user.following?(User.find(1))
user.followed_by?(User.find(1))

最初は下記のように any? にブロックを渡して評価していました。

user.rb
def following?(user)
  self.following.any? { |u| u.id == user.id }
end

しかしこれでは普通にSELECT文が走って全件インスタンス化しているようだったので下記のようにしてみたところ、 SELECT COUNT(*) が走るようになったのでこちらのほうがパフォーマンスがよいでしょう。

user.rb
# 指定ユーザをフォローしているかどうかを返す
def following?(user)
  self.following.where(id: user.id).any?
end

# 指定ユーザにフォローされているかどうかを返す
def followed_by?(user)
  self.followed.where(id: user.id).any?
end