【Rails】お気に入り機能の実装

お気に入り機能の実装をしたので、手順のアウトプットと言語化をしていく。 前提として、deviseを使ったユーザー管理機能が実装済みで、お気に入りの対象となる本が一覧表示されている状態とする。

実装したいこと

  • 投稿された本を「お気に入り」すると★マーク、「お気に入り解除」すると☆マークに変わる。
  • ユーザーは自分の投稿もお気に入りすることができるが、1冊の本に対して複数回お気に入りすることはできない。
  • ユーザーがお気に入りした本のみを集めた、お気に入り一覧ページがあること。

実装の流れ

  • モデルの設定
  • 各モデルのアソシエーション設定
  • ルーティング設定
  • コントローラーの設定
  • ビューファイル記述

モデルの設定

UserモデルとBookモデルがある。今回のポイントとしては、「お気に入り」は「誰が」というユーザー情報と、「どの本」にという対象となる本の情報を持つことなので、ユーザーと本の情報がセットで保存される中間テーブルを用意すること。 Image from Gyazo

Favoriteモデルを生成

% rails g model favorite

マイグレーションファイルに外部キー制約をつけてテーブル生成

class CreateFavorites < ActiveRecord::Migration[6.0]
  def change
    create_table :favorites do |t|
      t.references :user, null: false, foreign_key: true
      t.references :book, null: false, foreign_key: true
      t.timestamps
      t.index [:user_id, :book_id], unique: true
    end
  end
end
% rails db:migrate

中間テーブルには、ユーザーと本それぞれのidのみを保存。

 t.index [:user_id, :book_id], unique: true

という記述は、1人のユーザーが1つの本に複数回お気に入りすることを防ぐため。つまり、userとbookのセットの情報が一意性を持つようにして被らせないようにする。

各モデルのアソシエーション設定

app/models/user.rb

class User < ApplicationRecord
  has_many         :books, dependent: :destroy
  has_many         :favorites, dependent: :destroy
  has_many         :favorite_books, through: :favorites, source: :book
# 省略
end

app/models/book.rb

class Book < ApplicationRecord
  belongs_to :user
  has_many :favorites, dependent: :destroy
  has_many :favorite_books, through: :favorites
# 省略
end

app/models/favorite.rb

class Favorite < ApplicationRecord
  belongs_to :user
  belongs_to :book

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

先に説明しておくと、Favoriteモデルのバリデーションにuser_id, uniqueness: { scope: :book_id }という記述をしているが、これもさっきの情報の一意性を保つため。 uniquenessというヘルパーがデータを保存する前に、すでにDBにそのデータがないかを確認していくれている。 scope は範囲を指定して、一意性をチェックしている。例えば、

validates :user_id, uniqueness:  true

だけだと、1つの本に対して1人のユーザーしか「お気に入り」できないため、早いものがちになってしまう。 お気に入りいっぱいしてもらうためにも、user_idで範囲を限定してあげる。 こうすると、ユーザーは被ることはあってはならないけど、1つの本に対する「お気に入り」はいくらでも被っていいよ。ってできる。

Userモデルの

has_many         :favorite_books, through: :favorites, source: :book

について。 UserモデルとBookモデルは1対多の関係で直接結びついてはいるが、その関係は「Userが投稿したBook」であり、「Userがお気に入りしたBook」ではない。 つまりここでは、「Userがお気に入りしたBook」の関係を作るために、Favoriteモデルを通してBookモデルからデータを参照するというアソシエーションを記述をしている。

dependentオプションは親モデルが削除されると子モデルも同時に削除されるように設定している。

中間テーブルのアソシエーションについては以下の記事

kyoro1210.hatenablog.com

ルーティング設定

Rails.application.routes.draw do
  devise_for :users
  root to:  'books#index'
  resources :users, only: [:show, :edit, :update]
  resources :books, shallow: true do
    resource :favorites, only: [:create, :destroy]
    collection do
      get :favorites
    end
  end
end

shallow: true do~end という記述は、入れ子などで冗長になりがちなURLを"浅く"することができ、取得できるパラメータをシンプルにして見やすくするオプション。

「お気に入り」はcreate

「お気に入り解除」はdestroy


collection do
      get :favorites
    end

という記述は、特定のid指定なしでfavoriteアクションにルーティングを結んでいる。 ターミナルでルーティングを確認すると:idがついていない。

Prefix Verb   URI Pattern
# 省略
favorites_books GET    /books/favorites(.:format)           books#favorites

コントローラーの設定

favorites_controllerを生成

% rails g controller favorites

生成できたら、createアクションとdestroyアクションを用意

class FavoritesController < ApplicationController
  def create
    @book = Book.find(params[:book_id])
    favorite = current_user.favorites.create(book_id: params[:book_id])
 redirect_to root_path
  end

  def destroy
    @book = Book.find(params[:book_id])
    favorite = Favorite.find_by(book_id: params[:book_id], user_id: current_user.id)
    favorite.destroy
  end
end

createアクションの翻訳 Booksテーブルの中からparamsでbook_idを取得して特定の本に対して、現ユーザーがその本に「お気に入り」する。お気に入りするとトップページに戻るという処理。 destroyアクションの翻訳 Booksテーブルの中からparamsでbook_idを取得。Favoriteテーブルの中から、現ユーザーと前に取得した本のデータセットを取得して、その本の「お気に入り解除」する。お気に入り解除するとトップページに戻るという処理。

また、Userモデルに「お気に入り」する処理とユーザーが「お気に入り」したかを判別する処理を記述する。

class User < ApplicationRecord
# 省略
  def favorite(book)
    favorite_books << book
  end

  def unfavorite(book)
    favorite_books.destroy(book)
  end

  def favorite?(book)
    favorite_books.include?(book)
  end
end

コントローラーではなくモデルにこれらの処理を記述した理由は、オブジェクト指向の考え方より。 コントローラーはリクエストを受けっ取ってレスポンスを返すと言う役割を持つオブジェクトなため、具体的な「お気に入り」するとか「お気に入り」したかしてないかといった判別処理はコントローラーではなくモデルで行なう。それぞれのオブジェクトが持つ役割を考慮したから。

ビューファイル

部分テンプレートファイルを作成して書き出し app/views/books/_favorite.html.erb

<% if book.favorite_by?(current_user) %>
  <%= link_to icon('fas', 'star'), book_favorites_path(book.id), method: :delete %>
<% else %>
  <%= link_to icon('far', 'star'), book_favorites_path(book.id), method: :post %>
<% end %>
<span class='star-count'><%= book.favorites.count %></span>

ログインユーザーであれば、お気に入り機能を使える app/views/books/index.html.erb

<% if user_signed_in? %>
  <div class='star-btn' >
    <%= render "favorite" ,locals{ book: book } %>
  </div>
<% end %>

localsオプションでさっきの部分テンプレートで変数bookが使えるようにする。

<span class='star-count'><%= book.favorites.count %></span>

という記述でお気に入り数をカウントして、表示するようにしている。

お気に入り一覧画面

app/views/books/favorites.html.erb

<% if @favorite_books.present? %> 
  <%= render @favorite_books %>
<% else %>
  <p>お気に入りはありません</p>
<% end %>

現ユーザーがお気に入りしている本があれば表示する

Font Awesome
<%= icon 'fas', 'star' %>

という記述ではFont Awesomeを導入している。

終わりに

これでお気に入り機能のサーバーサイドの実装はできたため、お気に入りしたユーザーや本の情報は保存されるが、フロント部分の実装はこれでは不十分。また、お気に入りしてもリロードしないと結果が反映されない。Ajax(非同期通信)を使ってお気に入り機能をより使いやすくしていきたい。