Ruby on Railsの備忘録

エンジニアになるために覚えたことを記録していきます。

中間テーブルを使ったお気に入り機能の実装!

目次

実装したいこと

  • 掲示板にブックマーク機能を追加したい。

  • ユーザーがブックマークした掲示板を一覧できるページを実装したい。

これだけの機能なのに、めちゃくちゃ難しい。いろんなことを調べるいい機会になりました。 かなり多くのものを調べたので順序立てて説明をしていきます。

実装の大まかな流れ

  • 中間テーブルとなるBookmarkモデルの実装
  • UserモデルとBoardモデルとのアソシエーションを実装
  • Userモデルにお気に入り登録のギミックを定義
  • BookmarkControllerの実装
  • Routingの設定
  • Viewの実装

Bookmarkモデルの仕組み(多対多)

で、いざお気に入り機能をつけよう!となってもどうやって実装するの・・・?となります。なので、まずどうUserモデルとBoardモデルに紐づけていくのか考えて見ましょう。

UserとBookmarkとBoardの関係

ユーザーと掲示板とブックマークの関係を見ていきましょう。

ユーザーはたくさんの掲示板をブックマークすることができます。反対に、掲示板はたくさんのユーザーにフォローされることができます。

つまり、UserもBoardもBookmarkをたくさん持っているということになります。このような関係を多対多の関係と言われています。

中間モデル

多対多のモデルを実装するにはお互いのforeign_keyを知っている必要があります。そのために、お互いのidを格納するテーブル、中間テーブルを実装する必要があります。

Bookmarkモデルの作成

中間テーブルとなるBookmarkモデルを作成していきます。

ターミナル

rails g model Bookmark user:references board:references

migrateファイル

class CreateBookmarks < ActiveRecord::Migration[6.0]
  def change
    create_table :bookmarks do |t|
      t.references :user, null: false, foreign_key: true
      t.references :board, null: false, foreign_key: true

      t.timestamps
    end
    add_index :bookmarks, [:user_id, :board_id], unique: :true
  end
end

ユーザーが同じ掲示板をお気に入り登録しないようにunique: :trueをつける必要があります。

add_index :bookmarks, [:user_id, :board_id], unique: :trueでuser_idとboard_idの組み合わせがuniqueであることを設定します。 これでrails db:migrateを行います。

bookmark.rb

class Bookmark < ApplicationRecord
  belongs_to :user
  belongs_to :board

  validates :user_id, uniqueness: { scope: :board_id}
end

migrationにもunique: :trueを付けたので、モデルにもバリデーションを記載します。

uniquenessとscopeについて

validates :user_id, uniqueness: { scope: :board_id} end

上記は各掲示板idと同じユーザーidがお気に入り関係にならないように一意性制約を付けています。 rails cで確認して見ます。

irb(main):001:0> user = User.first
irb(main):002:0> board = Board.first

# userが掲示板をお気に入り登録する。
irb(main):003:0> user.bookmark(board)
   (0.1ms)  begin transaction
  Bookmark Exists? (0.9ms)  
  Bookmark Create (3.2ms)  
   (0.7ms)  commit transaction
  
=> #<ActiveRecord::Associations::CollectionProxy [#<Board id: 1, title: "hogehoge", body: "3333333", user_id: 1, created_at: "2020-11-19 10:27:20", updated_at: "2020-11-19 10:27:20", board_image: "8965-1-20151228165149_b5680ea1512891.jpg">]>


# もう一度同じユーザーで掲示板のお気にいり登録を試みる。
irb(main):004:0> user.bookmark(board)

# すでにお気に入り登録されているので、バリデーションに引っ掛かりrollbackされる。
(0.1ms)  begin transaction
  Bookmark Exists? (0.2ms)  
   (0.1ms)  rollback transaction
Traceback (most recent call last):
        2: from (irb):4
        1: from app/models/user.rb:27:in `bookmark'
ActiveRecord::RecordInvalid (バリデーションに失敗しました: Userはすでに存在します)
# 違うユーザーを指定
irb(main):005:0> user_2 = User.second

# 違うユーザーで掲示板をお気に入り登録を試みると成功する。
irb(main):006:0> user_2.bookmark(board)
(0.1ms)  begin transaction
  Bookmark Exists? (0.2ms)  
  Bookmark Create (0.8ms)  
   (1.4ms)  commit transaction
  
=> #<ActiveRecord::Associations::CollectionProxy [#<Board id: 1, title: "hogehoge", body: "3333333", user_id: 1, created_at: "2020-11-19 10:27:20", updated_at: "2020-11-19 10:27:20", board_image: "8965-1-20151228165149_b5680ea1512891.jpg">]>

bookmarkメソッドはUserモデルに定義しています。また後で紹介しますがconsoleで出てくるので載せておきます。

user.rb

  # お気に入りにしている掲示板を取得する
  has_many :bookmarks_boards, through: :bookmarks, source: :board


  # お気に入り追加
  # <<で引数で渡した掲示板の情報がbookmark_boardsに入っている
  def bookmark(board)
    bookmarks_boards << board
  end
参考記事

uniqueness: scope を使ったユニーク制約方法の解説 - Qiita

Active Record バリデーション - Railsガイド

【初心者向け】丁寧すぎるRails『アソシエーション』チュートリアル【幾ら何でも】【完璧にわかる】🎸 - Qiita

UserモデルとBoardモデルのアソシエーションの設定

Bookmarkモデルの実装が終わったので、他のモデルにもアソシエーションなどの設定を行っていきます。

Boardモデル

board.rb

class Board < ApplicationRecord
  belongs_to :user
  has_many :comments, dependent: :destroy
  has_many :bookmarks, dependent: :destroy # 追記
  mount_uploader :board_image, BoardImageUploader

  validates :title, length: { maximum: 255 }, presence: true
  validates :body, length: { maximum: 65535 }, presence: true
end

掲示板はたくさんのBookmarkを持つことができるのでhas_manyを使います。

Userモデル

user.rb

  has_many :bookmarks, dependent: :destroy
  # お気に入りにしている掲示板を取得する
  has_many :bookmarks_boards, through: :bookmarks, source: :board

UserもたくさんのBookmarkを持つことができるのでhas_manyを使っていきます。

ちょっと待って、has_many :bookmarks_boardsって何?throughとかsourceも使っているけどよくわからない・・・。 というわけでこれからhas_many throughについて調べたことをまとめていきます。

has_many throuth

まずhas_many throughはUserモデルでどういう働きをしているのかというと、ユーザーがお気に入りしている掲示板を取得することができるようになります。

Twitterでいいね一覧が表示できる機能がありますよね。それと同じようにお気に入り登録した掲示板を一覧で表示できるページを作るために必要となってきます。

has_many throughを使わずにユーザーのお気に入りした掲示板を取得したいとなると、このようなコードになります。

# user.bookmarksでuserがお気に入り登録した掲示板のidが入っているレコードの集合を取得することができる。
irb(main):014:0> user.bookmarks

=> #<ActiveRecord::Associations::CollectionProxy [#<Bookmark id: 7, user_id: 1, board_id: 1, created_at: "2020-11-20 00:50:02", updated_at: "2020-11-20 00:50:02">, #<Bookmark id: 10, user_id: 1, board_id: 3, created_at: "2020-11-20 01:57:05", updated_at: "2020-11-20 01:57:05">, #<Bookmark id: nil, user_id: 1, board_id: 1, created_at: nil, updated_at: nil>]>

# userが最初にお気に入り登録した掲示板のレコードを取得
irb(main):005:0> user.bookmarks.first
  Bookmark Load (0.5ms)  
=> #<Bookmark id: 7, user_id: 1, board_id: 1, created_at: "2020-11-20 00:50:02", updated_at: "2020-11-20 00:50:02">

# 上記で取得したレコードにboardメソッドを実行するとお気に入り登録した掲示板の内容が取得できる!
irb(main):006:0> user.bookmarks.first.board
  Bookmark Load (0.1ms)  
  Board Load (0.2ms)  
=> #<Board id: 1, title: "hogehoge", body: "3333333", user_id: 1, created_at: "2020-11-19 10:27:20", updated_at: "2020-11-19 10:27:20", board_image: "8965-1-20151228165149_b5680ea1512891.jpg">

# つまり、user.bookmarksのひとつひとつのレコードにboardメソッドを実行すればユーザーがお気に入りにした掲示板の内容の集合を取得することができる!

# なのでmapメソッドを使ってユーザーがお気に入り登録した掲示板のレコードにboardメソッドを実行し、それを配列に組み込んでいく。
irb(main):007:0> user.bookmarks.map{|bookmark| bookmark.board}
  Bookmark Load (0.4ms)  
  Board Load (0.2ms) 
  Board Load (0.1ms)  
=> [#<Board id: 1, title: "hogehoge", body: "3333333", user_id: 1, created_at: "2020-11-19 10:27:20", updated_at: "2020-11-19 10:27:20", board_image: "8965-1-20151228165149_b5680ea1512891.jpg">, #<Board id: 3, title: "aaaaaaaaaa", body: "aaaaaaaaaaa", user_id: 2, created_at: "2020-11-20 01:56:08", updated_at: "2020-11-20 01:56:08", board_image: nil>]

# 上記の式を(&:)を使って書き換えます。
irb(main):008:0> user.bookmarks.map(&:board)
=> [#<Board id: 1, title: "hogehoge", body: "3333333", user_id: 1, created_at: "2020-11-19 10:27:20", updated_at: "2020-11-19 10:27:20", board_image: "8965-1-20151228165149_b5680ea1512891.jpg">, #<Board id: 3, title: "aaaaaaaaaa", body: "aaaaaaaaaaa", user_id: 2, created_at: "2020-11-20 01:56:08", updated_at: "2020-11-20 01:56:08", board_image: nil>]

つまり、user.bookmarks.map(&:board)を使えばユーザーのお気に入り登録している掲示板の情報の集合を取得できるというわけです!

このコードをcontrollerなどに書いて実装するのも一つの方法だと思いますが、あまり直接的ではないのと、このコードをビューに落とし込むのも大変です。

そこでhas_many throughの登場です。 これを使えばuser.bookmarks.map(&:board)をモデル内に簡単に実装できちゃいます。

has_many :bookmarks_boards, through: :bookmarks, source: :board

:bookmarks_boardsと定義することでメソッド化して使うことができます。

user.bookmarks.map(&:board)このコードを見ながら解説していくと

Userのインスタンスにbookmarksメソッド(through:で定義)を実行し、それで得られたBookmarksのインスタンスデータのひとつひとつの要素に対してboardメソッド(source:で定義)を実行する

ということです。

なので、多対多のモデルを作った時に必ずと言っていいほど活躍するというわけです!

参考記事

実はRailsチュートリアルの第14章の動画を見るとすごくわかりやすいです。

Ruby on Rails チュートリアル:プロダクト開発の0→1を学ぼう

Railsガイドの文献

Active Record の関連付け - Railsガイド

Bookmarks_Controllerの実装

よし、モデルのアソシエーションも終わったしcontroller作ろう! ちょっと待ってください。controllerの可読性を上げるためにまずはモデルにお気に入り登録のギミックを定義していきましょう。すると驚くほどにcontrollerの実装が完結になりますよ!

controllerを作る前にモデルにBookmarkのギミックを定義する。

Userモデルにお気に入り登録のギミックとなるメソッドを定義していきましょう。

user.rb

  # お気に入り追加
  # <<で引数で渡した掲示板の情報がbookmark_boardsに入っている
  def bookmark(board)
    bookmarks_boards << board
  end

  # お気に入りを外す
  def unbookmark(board)
    bookmarks_boards.delete(board)
  end

  # お気に入り登録しているか判定するメソッド
  def bookmark?(board)
    bookmarks_boards.include?(board)
  end

早速先ほど定義したbookmarks_boardsが使われていますね。 一つずつメソッドを見ていきます。

bookmarkメソッド

  # お気に入り追加
  # <<で引数で渡した掲示板の情報がbookmark_boardsに入っている
  def bookmark(board)
    bookmarks_boards << board
  end

掲示板の情報のレコードが引数boardに格納されbookmarks_boards<<で追加されています。

<<は指定されたオブジェクトの末尾に破壊的に追加できるメソッドです。 強制的に追加されて保存もされているのでsaveメソッドなどは必要ありません。

<<メソッドについて詳しくはこちら

Array#<< (Ruby 2.7.0 リファレンスマニュアル)

unbookmarkメソッド

  # お気に入りを外す
  def unbookmark(board)
    bookmarks_boards.delete(board)
  end

bookmarks_boardsからboardの引数に入っている掲示板idが入ったレコードを探し出して削除(delete)するメソッド。

bookmark?メソッド

  # お気に入り登録しているか判定するメソッド
  def bookmark?(board)
    bookmarks_boards.include?(board)
  end

bookmarks_boardsにboardの引数に入っている掲示板idが含まれているレコードがあるかどうか判定するメソッド。

def bookmark?(board)
    Bookmark.where(user_id: id, board_id: board.id).exist?
end

このように書くこともできますが、include?の方が直感的でわかりやすいです。

bookmarks_controllerの実装

ここまできたら、bookmarks_controllerを作っていきましょう。 viewも必要ないのでcontrollerファイルだけ作って記載します。

bookmarks_controller.rb

class BookmarksController < ApplicationController


  def create
    board = Board.find(params[:board_id])
    current_user.bookmark(board)
    redirect_back fallback_location: root_path, success: 'ブックマークしました'
  end

  def destroy
    board = current_user.bookmarks.find_by(params[:id]).board
    current_user.unbookmark(board)
    redirect_back fallback_location: root_path, success: 'ブックマークを外しました'
  end
end

お気に入り登録のギミックをモデルに定義したことによって、かなり直感的なcontrollerになりました!

redirect_back fallback_location : root_path

redirect_backはユーザーが直前にリクエストを送ったページに戻すことができます。

fallback_locationは直前にリクエストを送ったページがない場合のデフォルトのリダイレクト先を指定しています。

Routingの設定

controllerも書けたので、次はRoutingの設定を行っていきます。

routes.rb

Rails.application.routes.draw do
  root 'static_pages#top'

  resources :users

  get 'login', to: 'user_sessions#new'
  post 'login', to: 'user_sessions#create'
  delete 'logout', to: 'user_sessions#destroy'

  resources :boards, shallow: true do
    resources :comments, only: %i[create destroy]
    resource :bookmarks, only: [:create, :destroy]
    collection do
      get :bookmarks
    end
  end
end

BoardとBookmarkは親子の関係なのでbookmarks_controllerのRoutingはboards_controllerにネストするように記載しています。

collectionルーティング

railsの基本的なアクションはresourcesで定義した時に作られる7つのアクションですが、更に別のアクションを追加したい時があります。

その時に使えるのがcollectionルーティングです。boards_controllerに新しくbookmarksというアクションを追加することができます。 ちなみに、controllerのメンバーに対してアクションを追加する場合(idが伴う場合)はmemberルーティングを使います。

collection以外を抜いたルーティングがこちら。

resources :boards, shallow: true do
    collection do
      get :bookmarks
    end
  end

bookmarks_boards GET /boards/bookmarks(.:format) boards#bookmarks 上記でこのようなルーティングが出来上がります。 このルーティングとアクションはお気に入りされた掲示板一覧を表示するページとして使っていきます。

Boards_controllerにbookmarksアクションを追記

ルーティングが書けましたので、Boards_controllerにbookmarksアクションを追加していきます。

boards_controller.rb

def bookmarks
    @bookmark_boards = current_user.bookmarks_boards.includes(:user).order(created_at: :desc)
end

無駄にSQL文を発行させない様にincludes(:user)を記載して関連するuserの情報も取得しています。(n + 1問題の解消)

irb(main):001:0> user = User.first

irb(main):002:0> user.bookmarks_boards
  Board Load (2.2ms)  SELECT "boards".* FROM "boards" INNER JOIN "bookmarks" ON "boards"."id" = "bookmarks"."board_id" WHERE "bookmarks"."user_id" = ? LIMIT ?  [["user_id", 1], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Board id: 1, title: "hogehoge", body: "3333333", user_id: 1, created_at: "2020-11-19 10:27:20", updated_at: "2020-11-19 10:27:20", board_image: "8965-1-20151228165149_b5680ea1512891.jpg">, #<Board id: 3, title: "aaaaaaaaaa", body: "aaaaaaaaaaa", user_id: 2, created_at: "2020-11-20 01:56:08", updated_at: "2020-11-20 01:56:08", board_image: nil>]>

irb(main):003:0> user.bookmarks_boards.includes(:user)
  Board Load (0.3ms)  SELECT "boards".* FROM "boards" INNER JOIN "bookmarks" ON "boards"."id" = "bookmarks"."board_id" WHERE "bookmarks"."user_id" = ? LIMIT ?  [["user_id", 1], ["LIMIT", 11]]
  User Load (0.5ms)  SELECT "users".* FROM "users" WHERE "users"."id" IN (?, ?)  [["id", 1], ["id", 2]]
=> #<ActiveRecord::AssociationRelation [#<Board id: 1, title: "hogehoge", body: "3333333", user_id: 1, created_at: "2020-11-19 10:27:20", updated_at: "2020-11-19 10:27:20", board_image: "8965-1-20151228165149_b5680ea1512891.jpg">, #<Board id: 3, title: "aaaaaaaaaa", body: "aaaaaaaaaaa", user_id: 2, created_at: "2020-11-20 01:56:08", updated_at: "2020-11-20 01:56:08", board_image: nil>]>

Viewの実装

ここまで仕組みを実装したら、あとはViewを作るだけです! お気に入りボタンとお気に入りした掲示板一覧ページを作成していきます。

お気に入りボタンの作成

まずはパーシャルでブックマークするボタンとブックマーク解除ボタンを切り替えるページを作ります。

bookmarks/_bookmark_area.html.erb

<% if current_user.bookmark?(board) %>
  <%= render 'bookmarks/unbookmark', board: board %>
<% else %>
  <%= render 'bookmarks/bookmark', board: board %>
<% end %>

user.rbで定義されたbookmark?メソッドがここで使われます。 掲示板がブックマークされていたら掲示板解除ボタン、掲示板がブックマークされていなかったらブックマーク登録ボタンに切り替わる仕組みです。

次に、お気に入り登録ボタンを実装していきます。 bookmarks/_bookmark.html.erb

<%= link_to board_bookmarks_path(board_id: board.id), id: "js-bookmark-button-for-board-#{board.id}", method: :post do %>
  <%= icon 'far', 'star' %>
<% end%>

次に、お気に入り解除ボタンを実装していきます。 bookamrks/_unbookmarks.html.erb

<%= link_to board_bookmarks_path(current_user.bookmarks.find_by(board_id: board.id)), id: "js-bookmark-button-for-board-#{board.id}", method: :delete do %>
  <%= icon 'fas', 'star' %>
<% end %>

current_userに紐づいているbookmarkインスタンスの中から掲示板idが含まれているものを探し、取得しています。

これで、お気に入りボタンが完成しました。これを掲示板のパーシャルに組み込んでいきます。

boards/_board.html.erb

<% if current_user.own?(board) %>
   <div class='mr10 float-right'>
      <%= render 'crud_menus', board: board %>
<% else %>
     <%= render 'bookmarks/bookmark_area', board: board %>
   </div>
<% end %>

掲示板がログインしているユーザーのものだったらcrud_menusボタンが表示され、ユーザーのものではなかった場合はお気に入りボタンに切り替わる様になっています。

お気に入り掲示板一覧機能ページの作成

最後に、お気に入りした掲示板一覧ページを作成していきます。 掲示板一覧ページとレイアウトはあまり変わらないため、ほとんど掲示板一覧ページから引っ張ってきています。

boards/bookmarks.html.erb

<% content_for(:title, t('.title')) %>
<div class="container pt-3">
  <div class="row">
    <div class="col-lg-10 offset-lg-1">
      <!-- 検索フォーム -->
      <form>
        <div class="input-group mb-3"><input class="form-control" placeholder="検索ワード" type="search"/>
          <div class="input-group-append"><input type="submit" value="検索" class="btn btn-primary"/></div>
        </div>
      </form>
    </div>
  </div>

  <div class="row">
    <div class="col-12">
      <div class="row">
      <% if @bookmark_boards.present? %>
        <%= render partial: "board", collection: @bookmark_boards %>
      <% else %>
        <p>ブックマーク中の掲示板がありません</p>
      <% end%>
      </div>
    </div>
  </div>
</div>

これで完成です!お疲れ様でした!

おまけ

n + 1問題を解消するためにコードをリファクタリングしていきます。

boards_controller.rb

def index
# userのみキャッシュしている。
  @boards = Board.all.includes(:user).order(created_at: :desc)

 #bookmarkも取得できる様になる。
  @boards = Board.all.includes([:user, :bookmarks]).order(created_at: :desc)
  end

user.rb

# userを起点にしてSQLを走らせてしまっているためレコードを取得する時に毎回SQLが走ってしまう。
def bookmark?(board)
  bookmarks_boards.include?(board)
end

# boardを起点にしてSQLが走り、検索をかける。無駄なSQLが走らない。
def bookmark?(board)
  bookmarks_boards.pluck(:user_id).include?(id)
end

_unbookmark.html.erb

<%= link_to bookmark_path(board.bookmarks.find { |b| b.user_id == current_user.id }),
            id: "js-bookmark-button-for-board-#{board.id}",
            class:"float-right",
            method: :delete,
            remote: true do %>
  <%= icon 'fas', 'star' %>
<% end %>

なるべくfindを使う様にして無駄にSQLを走らせない様にする。