railsのアソシエーションを学ぶ その2(多対多のアソシエーションと中間テーブル)

こんにちは!
前回の記事の続きです。

前回の記事はこちら↓
kojiprg.hatenablog.com


この記事では、ツイッターもどきを作成しています。
前回は、どのユーザが投稿したツイートかわかるように、ユーザとツイートのテーブルに1対多のアソシエーションを実装しました。
1対多の関係とは、以下のような関係でした。

  • ユーザ一人が、複数のツイートをできる
  • ツイートは、一人のユーザに属する

今回は、多対多と呼ばれるアソシエーションを使用して、いいね機能の実装をしていきます。

いいね機能の概要

まず、今回実装するいいね機能について定義します。

  1. 各ツイートにいいねボタンを表示する
  2. いいねボタンの横にそのツイートにいいねをしたユーザの数を表示する
  3. いいねボタンの色で、ログインしているユーザがどのツイートにいいねをしたかがわかる
  4. まだいいねをしていないツイートのいいねボタンを押すことで、そのツイートにいいねをすることができる
  5. いいね済みのツイートのいいねボタンを押すことで、そのツイートへのいいねを取り消すことができる

本家Twitterで、ハートをクリックすると、ハートがついたり消えたりする動作をイメージしています。
ただし、今回のアプリではJavaScriptによる非同期での更新機能は実装せずに、ボタンをクリックするたびに画面を更新させる仕様にします。

f:id:kojiprg:20201031104659g:plain
今回実装するいいね機能

いいねボタンをクリックすると、色が変化するのと、右隣にある数字が増減します。

テーブルの設計

上記機能を実現するためには、ユーザがどのツイートにいいねをしたかが分かるようなデータを保存する必要があります。
ここでは、どうやってテーブルに保存するかを考えます。

まず、ユーザテーブルに保存することを考えます。
ユーザは複数のツイートにいいねできるため、どのツイートにいいねしたかを記録するためには、ユーザテーブルにツイートの数だけカラムを用意しておく必要があります。
ツイートの数が、5個や10個くらいに制限されていればこれでもいいかもしれませんが、拡張性がありません。

次に、ツイートテーブルに保存することを考えます。
この方法でも、どのユーザがいいねをしたかを記録しておくために、ユーザごとにカラムを準備する必要があるので、拡張性がありません。

ユーザやツイートの数に合わせて、カラムを増やしていくのは無理があるので、レコードを増やす方向で考えます。
新しくテーブルを作成すればレコードは簡単に増やすことができます。
そこで、いいねテーブルを作成します。
このテーブルに、いいねしたユーザと、いいねされたツイートのidを保存していくことで、実現できそうです。

今回の状況をまとめます。

  • ユーザが複数のツイートにいいねができる
  • ツイートが複数のユーザにいいねされる

つまり、ユーザから見た時も、ツイートから見た時も相手が複数いる状況です。
このような関係を多対多の関係とよび、新しくテーブルを作ることで、テーブル同士の関係性を保存することができます。
このテーブルを中間テーブルと呼びます。

いいねテーブルを作成した後の、テーブル構成は以下のようになります。

f:id:kojiprg:20201030191739p:plain
中間テーブルとして、いいね(Favorites)テーブルを作成

references型でuserと、tweetのidを保存します。

機能の実装は以下のようにすればできそうです。

  • ツイートにいいねをしたユーザの数を表示する

→いいねテーブルで、調べたいツイートのtweet_idがいくつ保存されているかを調べる。

  • ログインしているユーザがどのツイートにいいねをしたかがわかる

→いいねテーブルで、調べたいツイートのtweet_idで検索をかけ、その中にログイン中のuser_idを含むものがあるかどうを調べる。

アソシエーションの実装

実装はポイントに絞って解説し、rails db:migrateなんかは端折ります。(読み直したら、もっと端折ってました。)
まず、モデルへのアソシエーションの記述です。
基本的には、1対多のアソシエーションと同じで、has_manyとbelongs_toを使用します。

  • favorite.rb
class Favorite < ApplicationRecord
  belongs_to :user
  belongs_to :tweet
end
  • user.rb
(省略)
  has_many :favorites, dependent: :destroy
(省略)
(省略)
  has_many :favorites
  has_many :users, through: :favorites
(省略)

ポイントはtweetモデルの記述です。

  has_many :users, through: :favorites

throughオプションを使用することで、ツイートにいいねをしたユーザの一覧をtweet.usersのような形で取得することができます。
流れにすると以下のようになります。
tweetのidを取得
→favoritesテーブルでtweet_idと一致するいいねの一覧を取得。これで、ツイートにいいねしたユーザのidが分かる
→usersテーブルからtweetにいいねしたユーザの情報が取得できる

サンプルコード

ビューとコントローラのコードです。

  • index.html.erb
(省略、以下メッセージ部分)
<div class="messages">
  <% @tweets.each do |tweet| %>
    <div class="message">
      <div class="message_head">
        <div class="message_user_name">
          投稿者:<%= tweet.user.user_name %>
        </div>
        <div class="message_created_at">
          <%= time_ago_in_words(tweet.created_at) %>
        </div>
        <% if tweet.users.ids.include?(current_user.id) %>
          <%= button_to "いいね", favorite_path(tweet.id), method: :delete, style: "background-color: pink; border-radius: 50vh;" %>
        <% else %>
          <%= button_to "いいね", favorites_path, params: {tweet_id: tweet.id}, style: "background-color: white; border-radius: 50vh;" %>
        <% end %>
        <div class="favorite_count">
          <%= tweet.favorites.size %>
        </div>
      </div>
      <div class="message_content">
        <%= simple_format(tweet.message) %>
      </div>
    </div>
  <% end %>
</div>
  • favorites_controller.rb
class FavoritesController < ApplicationController
  def create
    @favorite = Favorite.new(favorite_params)
    @favorite.save
    redirect_to root_path
  end

  def destroy
    @favorite = Favorite.find_by(user_id: current_user.id, tweet_id: params[:id])
    @favorite.destroy
    redirect_to root_path
  end

  private

  def favorite_params
    params.permit(:tweet_id).merge(user_id: current_user.id)
  end
end
  • tweets_controller.rb(indexアクション)
class TweetsController < ApplicationController
  def index
    @tweet = Tweet.new
    @tweets = Tweet.includes(:user, :users).reverse_order
  end
(省略)

ビューのいいねボタンの実装部分は以下です。

        <% if tweet.users.ids.include?(current_user.id) %>
          <%= button_to "いいね", favorite_path(tweet.id), method: :delete, style: "background-color: pink; border-radius: 50vh;" %>
        <% else %>
          <%= button_to "いいね", favorites_path, params: {tweet_id: tweet.id}, style: "background-color: white; border-radius: 50vh;" %>
        <% end %>

一行目のif文の条件文で、アソシエーションの記述を使用して、ツイートにいいねをしたユーザのidを取得しています。
ポイントは、idも複数形のidsになっている点と、
whereやfind_byを使用せずに、簡潔にかけている点です。
あとは、include?メソッドを使用して、その中にログイン中のユーザのidが含まれているかどうかを判定しています。
また、button_toのデフォルトのメソッドはpostのため、いいねを取り消すdestroyアクションを作動させる場合は、method: :deleteを指定しています。
微妙な点としては、直接favoriteのidを取得することが難しかったため、tweet.idをパスに放り込んでいます。
いいねコントローラの記述を見るとわかりますが、直接favoriteのidで検索するのではなく、find_byでuser_idとtweet_idを検索して削除しています。

    @favorite = Favorite.find_by(user_id: current_user.id, tweet_id: params[:id])

続いて、いいねされた数を表示する機能の実装部分です。

          <%= tweet.favorites.size %>

アソシエーションとsizeメソッドを使用することで、レコードの数を表示しています。
countでも同様のことができますが、SQL文が毎回発行されてしまいます。
sizeメソッドではすでにロード済みの場合、その結果を使用するためSQL文が発行されません。
今回は、ツイートコントローラで以下のように:usersをincludesで呼び出しています。

    @tweets = Tweet.includes(:user, :users).reverse_order

この時に、throughオプションによってfavoritesも同時にロードしているため、sizeメソッドを使用することで、SQL文の発行を回避できています。

まとめ

今回は多対多のアソシエーションと中間テーブルでした。
アソシエーションを使用することで、異なるテーブルの情報を簡単に取得することができました。
後半の実装部分はややパフォーマンスも意識してみたので、わかりにくくなっていたらすみません。
最後までありがとうございました!