フォロー機能の設計から実装までの情報を整理しました。
実現したいこと
次の2つの機能を実装したい。
- 自分以外のユーザーをフォローできる
フォローしているユーザー
、フォローされているユーザー
を取得できる
フォロー機能のテーブル設計
上記のフォロー機能は次の2つのテーブルで実現できます。
- ユーザーテーブル: 全ユーザーのIDを保存
- フォロー関係テーブル:
フォローしてる人
、フォローされる人
にそれぞれユーザーテーブルのIDを保存
フォロー関係テーブルは中間テーブルと呼ばれ、多:多を表現するのに使われます。
フォローしてる人
、フォローされる人
の関係性をこのフォロー関係テーブルに保存します。
中間テーブルについては下記の記事が非常に分かりやく解説されています。
やさしい図解で学ぶ 中間テーブル 多対多 概念編 - Qiita
1. フォローする関係
フォローする関係はフォローしてる人
が自分、フォローされる人
が相手として表現できます。
フォローする関係は能動的なフォロー関係なのでactive_friendships
とします。
フォローしてる人
:follow_source
フォローされる人
:follow_target
2. フォローされる関係
フォローされる関係はフォローされる人
が自分、フォローしてる人
が相手として表現できます。
フォローする関係は受動的なフォロー関係なのでpassive_friendships
とします。
フォローしてる人
:follow_source
フォローされる人
:follow_target
3. フォローしているユーザー
フォローしているユーザー
はフォロー関係テーブルからフォローしてる人
を経由してフォローされる人
を探すことで取得できます。
フォローしているユーザーはfollowings
とします。
4. フォローされているユーザー
フォローされているユーザー
はフォロー関係テーブルからフォローされる人
を経由してフォローしてる人
を探すことで取得できます。
フォローされているユーザーはfollowers
とします。
5. 設計の全体像
上記を踏まえた上でフォローしているユーザーfollowings
とフォローされているユーザーfollowers
を取得する処理の流れは次のようになります。
フォローする関係active_friendships
とフォローされる関係passive_friendships
はフォローしているユーザーfollowings
とフォローされているユーザーfollowers
を取得する為に必要な関係性だということが分かります。
実際のER図は次のようになります。
フォロー機能を実装する
上記のテーブル設計を元に実際に実装します。
1. DB作成
Usersテーブルのみ作成されている既存のアプリにフォロー機能を追加するという前提で解説します。 Friendshipsテーブルを作成するためのマイグレーションファイルを作成します。 references型を使った外部キー制約付きカラムの作成はこちらの記事で詳しく解説しています。
# マイグレーションファイル class CreateFriendships < ActiveRecord::Migration[6.1] def change create_table :friendships do |t| t.references :follow_source, foreign_key: { to_table: :users }, null: false t.references :follow_target, foreign_key: { to_table: :users }, null: false t.timestamps end add_index :friendships, %i[follow_source_id follow_target_id], unique: true end end
null: false
: 空データの保存を禁止するunique: true
: テーブル内で重複するデータを禁止する
add_index :friendships, %i[follow_source_id follow_target_id], unique: true
の部分はfollow_source_id
とfollow_target_id
の組み合わせが必ずユニークにする為に指定します(複合キーインデックス)。
このあたりはまだ理解が曖昧なので解説しません笑
2. User、Friendshipモデルへの関連付け
Usersテーブル、Friendshipsテーブルが準備できたので作成されたuserモデルとfriendshipモデルを関連付けます。
# user.rb class User < ApplicationRecord has_many :active_friendships, class_name: 'Friendship', foreign_key: 'follow_source_id', dependent: :destroy, inverse_of: :follow_source has_many :passive_friendships, class_name: 'Friendship', foreign_key: 'follow_target_id', dependent: :destroy, inverse_of: :follow_target has_many :followings, through: :active_friendships, source: :follow_target has_many :followers, through: :passive_friendships, source: :follow_source end
# friendship.rb class Friendship < ApplicationRecord belongs_to :follow_source, class_name: 'User' belongs_to :follow_target, class_name: 'User' end
下記のオプションについて調べました。
class_name
foreign_key
dependent
inverse_of
through
、source
class_name
関連付けの相手となるオブジェクト名を関連付け名から生成できない事情がある場合、class_nameオプションを用いてモデル名を直接指定できます。
Active Record の関連付け - Railsガイド
アソシエーションにおけるclass_nameの定義! - Qiita
userモデルで関連先のfriendshipモデルのオブジェクト名をactive_friendships
、passive_friendships
として元のモデル名と異なる関連名に変更する為にclass_name
で関連先のモデルを指定します。
class_name
オプションが無いとactive_friendships
、passive_friendships
と同名のモデルを探してしまうのでclass_name: 'Friendship'
が必要です。
# user.rb class User < ApplicationRecord has_many :active_friendships, class_name: 'Friendship', ... # 関連名のモデル名をFriendshipに指定 has_many :passive_friendships, class_name: 'Friendship', ... # 関連名のモデル名をFriendshipに指定 ... end
userモデルと同様にfriendshipモデルでは関連先のuserモデルのオブジェクト名をfollow_source
、follow_target
として元のモデル名と異なる名前に指定するのでclass_name: 'User'
として関連先のモデルを指定します。
# friendship.rb class Friendship < ApplicationRecord belongs_to :follow_source, class_name: 'User' # 関連名のモデル名をUserに指定 belongs_to :follow_target, class_name: 'User' # 関連名のモデル名をUserに指定 end
foreign_key
規約名以外の外部キーカラム名をつかうときは、モデルでhas_many, belongs_toを書くときにオプションを追加する必要があります。
[ActiveRecord] foreign_key, class_name
userモデルから参照するfriendshipモデルの外部キーをfollow_source
、follow_target
に変更しているのでforeign_key
オプションで参照するカラム名を指定します。
Friendshipsテーブルに保存されたレコードから自分を探す対象のカラム名を下記のように設定します。
active_friendships
:follow_source
の中から自分を探す外部キー指定するのでforeign_key: 'follow_source_id'
passive_friendships
:follow_target
の中から自分を探す外部キーを指定するのでforeign_key: 'follow_target_id'
# user.rb class User < ApplicationRecord has_many :active_friendships, ..., foreign_key: 'follow_source_id', ... # 外部キーを指定 has_many :passive_friendships, ..., foreign_key: 'follow_target_id', ... # 外部キーを指定 ... end
inverse_of
ActiveRecordは規約通りであればこのように自動で双方向関連付けを行いますが、規約から外れた名前をつかうとき、たとえば:foreign_keyや:throughオプションをつかうときには、双方向関連付けを自動認識しません。
has_many, has_one, belongs_to で:foreign_keyオプションなどをつかって規約とは違う命名をするときには、:inverse_ofにつづけて関連先モデルからの関連名を書くことで双方向の関連をActiveRecordへ教えることができます。
[ActiveRecord] 双方向関連付けとinverse_of
Active Record の関連付け - Railsガイド
foreign_key
やthrough
オプションを使用した双方向の関連付けの場合はinverse_of
オプションで関連先モデルの関連名を明示的に記述することが必要。inverse_of
オプションを付与しない場合、rubocopでRails/InverseOf: Specify an :inverse_of option.
の警告が出る。
userモデルのactive_friendships
とpassive_friendships
はforeign_key
で外部キーを指定しているのでinverse_of
を使って関連先モデルの関連名を明示的に記述する必要があります。
userモデルからfriendshipモデルのfollow_source
、follow_target
カラムを参照したいので下記を設定します。
active_friendships
:フォローしてる人
と関連付けたいのでfollow_source
に設定passive_friendships
:フォローされる人
と関連付けたいのでfollow_target
に設定
# user.rb class User < ApplicationRecord has_many :active_friendships, class_name: 'Friendship', ..., inverse_of: :follow_source # 関連先モデルの関連名を指定 has_many :passive_friendships, class_name: 'Friendship', ..., inverse_of: :follow_target # 関連先モデルの関連名を指定 ...
inverse_of
とforeign_key
でカラムと外部キーを設定することで最終的に次のことができるようになります。
active_friendships
:follow_source
カラムから外部キーfollow_source_id
でフォローしてる人
を取得passive_friendships
:follow_target
カラムから外部キーfollow_target_id
でフォローされる人
を取得
dependent
オーナーオブジェクトがdestroyされたときに、オーナーに関連付けられたオブジェクトの扱いを制御します。
Active Record の関連付け - Railsガイド
userオブジェクトが削除された場合に関連したfriendshipオブジェクトの扱いを指定しています。
dependent: :destroy
とすることで親オブジェクトであるuserオブジェクトが削除されると子オブジェクトのfriendshipオブジェクトも削除されるようになります。
# user.rb class User < ApplicationRecord has_many :active_friendships, class_name: 'Friendship',..., dependent: :destroy, ... # 関連したfriendshipオブジェクトも削除 has_many :passive_friendships, class_name: 'Friendship',..., dependent: :destroy, ... # 関連したfriendshipオブジェクトも削除 ... end
dependent
については次の記事がとても参考になります。
dependentオプションまとめ[Ruby on Rails] - Qiita
through、source
多対多の関連をつくるときは、中間テーブルおよびモデルと、has_many :through をつかいます。
:source オプションには:throughで指定したモデルから、取得したいモデルへたどるための関連名を書きます。
:sourceオプションはhas_many関連と、has_one関連で:throughオプションを書いていて、かつ、規約から外れた命名をしているときに指定します。
N:Nの関連付けで中間テーブルやモデルを通して関連先の値を取得する際に次のことを指定します。
- 探す対象の中間テーブルやモデルを
through
で指定 - 取得したいカラムを
source
で指定
through
オブジェクトを取得する際に経由する関連名をthrough
で指定します。
userモデルのfollowings
とfollowers
はそれぞれ次のような流れでオブジェクトを取得します。
followings
:active_friendships
を経由し、follow_target
を取得followers
:passive_friendships
を経由し、follow_source
を取得
userモデルでは関連するfriendshipの関連名をactive_friendships
(フォローしている関係)、passive_friendship
(フォローされている関係) の2つに分けて関連付けています。
なのでfollowings
とfollowers
のthrough
に指定する関連名はfriendship
ではなく、active_friendships
、passive_friendships
の2つの関連名を指定することに注意してください。
# user.rb class User < ApplicationRecord has_many :active_friendships, class_name: 'Friendship', ... has_many :passive_friendships, class_name: 'Friendship', ... has_many :followings, through: :active_friendships, ... # 経由する関連名を指定 has_many :followers, through: :passive_friendships, ... # 経由する関連名を指定 end
source
through
を経由し、取得したいカラムをsource
で指定します。
userモデルのfollowings
とfollowers
はそれぞれ次のような流れでオブジェクトを取得します。
followings
:active_friendships
を経由し、follow_target
を取得followers
:passive_friendships
を経由し、follow_source
を取得
それぞれの取得したいカラム名をsource
で指定します。
# user.rb class User < ApplicationRecord ... has_many :followings, through: :active_friendships, source: :follow_target # 取得したいカラム名を指定 has_many :followers, through: :passive_friendships, source: :follow_source # 取得したいカラム名を指定 end
上記のようにthrough
とsource
で経由する関連名と取得するカラムを設定することで下記画像の流れでfollowings
とfollowers
を取得することができるようになります。
Railsコンソールで動かしてみる
Railsコンソールを下記の動作を確認してみます。
- フォロー関係の作成(create)
- フォロー関係の削除(destroy)
- フォローしているユーザーの取得(followings)
- フォローされてるユーザーの取得(followers)
1. フォロー関係の作成(create)
フォローする関係(active_friendships
)のレコードを作成(create
)します。
フォローするユーザーをfollow_source
、フォローされるユーザーをfollow_target
とします。
下記の条件でレコードを作成します。
follow_source_id
: 51follow_target_id
: 1
# フォローするユーザーの取得 > follow_source = User.find(51) (0.7ms) SELECT sqlite_version(*) TRANSACTION (0.1ms) begin transaction User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 51], ["LIMIT", 1]] => #<User id: 51, email: "karlley.jp@gmail.com", created_at: "2023-01-27 07:08:33.658026000 +0000", updated_at: "2023-01-27 07:08:33.658026000 +0000", name: "kar... # フォローされるユーザーの取得 > follow_target = User.find(1) User Load (1.6ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] => #<User id: 1, email: "sample-0@example.com", created_at: "2023-01-27 07:07:30.723455000 +0000", updated_at: "2023-01-27 07:07:30.723455000 +0000", name: "丸山... # フォロー関係の作成 > follow_source.active_friendships.create(follow_target: follow_target) TRANSACTION (0.1ms) SAVEPOINT active_record_1 Friendship Create (0.5ms) INSERT INTO "friendships" ("follow_source_id", "follow_target_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["follow_source_id", 51], ["follow_target_id", 1], ["created_at", "2023-01-27 07:26:43.494427"], ["updated_at", "2023-01-27 07:26:43.494427"]] TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1 => #<Friendship:0x000000010de1f830 id: 1, follow_source_id: 51, follow_target_id: 1, created_at: Fri, 27 Jan 2023 07:26:43.494427000 UTC +00:00, updated_at: Fri, 27 Jan 2023 07:26:43.494427000 UTC +00:00> # フォロー関係の作成を確認 > friendship = Friendship.all Friendship Load (0.6ms) SELECT "friendships".* FROM "friendships" => [#<Friendship:0x000000010e28ef88 ... > friendship => [#<Friendship:0x000000010e28ef88 id: 1, follow_source_id: 51, follow_target_id: 1, created_at: Fri, 27 Jan 2023 07:26:43.494427000 UTC +00:00, updated_at: Fri, 27 Jan 2023 07:26:43.494427000 UTC +00:00>]
ちなみにcreate
メソッドの引数はuserオブジェクトでなくidを渡すことでもレコードを作成することもできます。
# createの引数にidを渡す follow_source.active_friendships.create(follow_target_id: follow_target.id)
この場合に指定するカラム名はfollow_target_id:
になることに注意してください。
2. フォロー関係の削除(destroy)
フォローする関係(active_friendships
)のレコードを削除(destroy
)します。
フォロー解除するユーザーをfollow_source
、フォロー解除されるユーザーをfollow_target
とします。
下記のレコードを検索し、削除します。
follow_source_id
: 51follow_target_id
: 1
# フォロー解除を行うユーザーの取得(createと同じ) > follow_source = User.find(51) (0.7ms) SELECT sqlite_version(*) TRANSACTION (0.1ms) begin transaction User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 51], ["LIMIT", 1]] => #<User id: 51, email: "karlley.jp@gmail.com", created_at: "2023-01-27 07:08:33.658026000 +0000", updated_at: "2023-01-27 07:08:33.658026000 +0000", name: "kar... # フォロー解除されるユーザーの取得(createと同じ) > unfollow_target = User.find(1) User Load (1.6ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] => #<User id: 1, email: "sample-0@example.com", created_at: "2023-01-27 07:07:30.723455000 +0000", updated_at: "2023-01-27 07:07:30.723455000 +0000", name: "丸山... # フォロー解除を行うフォロー関係の検索 > friendship = follow_source.active_friendships.find_by(follow_target: unfollow_target) Friendship Load (0.2ms) SELECT "friendships".* FROM "friendships" WHERE "friendships"."follow_source_id" = ? AND "friendships"."follow_target_id" = ? LIMIT ? [["follow_source_id", 51], ["follow_target_id", 1], ["LIMIT", 1]] => #<Friendship:0x000000010e3c5348 ... # フォロー関係の削除 > friendship.destroy TRANSACTION (0.2ms) SAVEPOINT active_record_1 Friendship Destroy (0.2ms) DELETE FROM "friendships" WHERE "friendships"."id" = ? [["id", 1]] TRANSACTION (0.1ms) RELEASE SAVEPOINT active_record_1 => #<Friendship:0x000000010e3c5348 id: 1, follow_source_id: 51, follow_target_id: 1, created_at: Fri, 27 Jan 2023 07:44:37.779486000 UTC +00:00, updated_at: Fri, 27 Jan 2023 07:44:37.779486000 UTC +00:00> # フォロー関係の削除を確認 > friendship = follow_source.active_friendships.find_by(follow_target: follow_target) Friendship Load (0.6ms) SELECT "friendships".* FROM "friendships" WHERE "friendships"."follow_source_id" = ? AND "friendships"."follow_target_id" = ? LIMIT ? [["follow_source_id", 51], ["follow_target_id", 1], ["LIMIT", 1]] => nil
上記の条件のレコードが削除されました。
3. フォローしているユーザーの取得(followings)
フォローしているユーザーをfollowings
を取得します。
followings
を取得するユーザーをuser
とします。
下記の条件でfollowings
が取得できるか確認します。
user
: 51followings
: 1, 2, 3
# followingsを取得するユーザーを取得 > user = User.find(51) (0.6ms) SELECT sqlite_version(*) TRANSACTION (0.0ms) begin transaction User Load (0.6ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 51], ["LIMIT", 1]] => #<User id: 51, email: "karlley.jp@gmail.com", created_at: "2023-01-27 07:08:33.658026000 +0000", updated_at: "2023-01-27 07:08:33.658026000 +0000", name: "kar... # followingsの取得 > user.followings User Load (0.7ms) SELECT "users".* FROM "users" INNER JOIN "friendships" ON "users"."id" = "friendships"."follow_target_id" WHERE "friendships"."follow_source_id" = ? [["follow_source_id", 51]] => [#<User id: 1, email: "sample-0@example.com", created_at: "2023-01-27 07:07:30.723455000 +0000", updated_at: "2023-01-27 07:07:30.723455000 +0000", name: "丸山 翔", postal_code: "123-0000", address: "2859 碧, 美桜村, 42 029-4442", self_introduction: "こんにちは、丸山 翔です。">, #<User id: 2, email: "sample-1@example.com", created_at: "2023-01-27 07:07:30.966294000 +0000", updated_at: "2023-01-27 07:07:30.966294000 +0000", name: "中野 悠人", postal_code: "123-0001", address: "35116 愛美, 横山区, 6 626-0017", self_introduction: "こんにちは、中野 悠人です。">, #<User id: 3, email: "sample-2@example.com", created_at: "2023-01-27 07:07:31.209164000 +0000", updated_at: "2023-01-27 07:07:31.209164000 +0000", name: "青木 大輝", postal_code: "123-0002", address: "Suite 955 98423 山田, 西今井村, 31 080-0861", self_introduction: "こんにちは、青木 大輝です。">]
フォローしているユーザーfollowings
が取得できました。
4. フォローされてるユーザーの取得(followers)
フォローされてるユーザーfollowers
を取得します。
followers
を取得するユーザーをuser
とします。
下記の条件でfollowers
が取得できるか確認します。
user
: 1followings
: 51
# followersを取得するユーザーを取得 > user = User.find(1) User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] => #<User id: 1, email: "sample-0@example.com", created_at: "2023-01-27 07:07:30.723455000 +0000", updated_at: "2023-01-27 07:07:30.723455000 +0000", name: "丸山... # followersの取得 > user.followers User Load (2.0ms) SELECT "users".* FROM "users" INNER JOIN "friendships" ON "users"."id" = "friendships"."follow_source_id" WHERE "friendships"."follow_target_id" = ? [["follow_target_id", 1]] => [#<User id: 51, email: "karlley.jp@gmail.com", created_at: "2023-01-27 07:08:33.658026000 +0000", updated_at: "2023-01-27 07:08:33.658026000 +0000", name: "karlley", postal_code: "0000000", address: "", self_introduction: "">]
フォローされてるユーザーfollowers
が取得できました。
まとめ
ややこしくて複雑だったフォロー関係のリレーションについての情報が整理できたので良かった。 理解が曖昧な部分は追って調べていこうと思います。
参照
やさしい図解で学ぶ 中間テーブル 多対多 概念編 - Qiita
Active Record の関連付け - Railsガイド
アソシエーションにおけるclass_nameの定義! - Qiita
[ActiveRecord] foreign_key, class_name
[ActiveRecord] 双方向関連付けとinverse_of
Active Record の関連付け - Railsガイド
Active Record の関連付け - Railsガイド