Railsアプリにコメント機能を追加

今回は、既存のRailsアプリにコメント機能を追加しました。

  • ポリモーフィック関連付け(Polymorphic Association)...異なる種類のモデル同士を関連づける方法の一つ。通常、Railsでは、あるモデルと別のモデルを関連付ける際に、外部キー(foreign key)を使用。ポリモーフィック関連付けを使用すると、1つのモデルが複数の他のモデルと関連付けることができる。1つのモデルに対して複数の他のモデルが関連付けられる場合に使用される。
関連付け概要
  1. マイグレーションファイルでポリモーフィックカラムを作成

    • ポリモーフィック関連性を実現するためには、関連するモデルを示すためのカラムが必要
  2. モデルを設定

    • コメントを投稿可能なモデルにhas_manyを追加し、コメントのモデルにbelongs_toを追加することで、ポリモーフィック関連性を設定。
  3. ビューを設定する

    • コメントフォームを表示するビューに、投稿可能なモデルのIDとタイプを送信するフォームを追加する。

学んだこと✏️

1.Railsアプリにreports(日報)のCRUD(一覧、個別、作成、編集、削除)の機能を追加。
$ rails g scaffold report title:string content:text
2. migrationファイルを生成

$ rails g migration Addカラム名RefToテーブル名 カラム名:型名

$ rails g migration AddUserRefToReports user:references

上記のコマンドを実行すると、以下のようなmigrationファイルが生成される。

[timestamp]_add_user_ref_to_reports.rb

class AddUserRefToReports < ActiveRecord::Migration[6.0]
  def change
    add_reference :reports, :user, null: false, foreign_key: true
  end
end

このmigrationファイルでは、reportsテーブルに対して、userテーブルに対する外部キーを追加するための処理を定義している。

3. $ rails db:migrateを実行

これにより、新しいカラムがreportsテーブルに追加され、userテーブルとの関連が設定される。

4.Userモデルにhas_many :reportsの関連付けを追加
class User < ApplicationRecord
  has_many :reports, dependent: :destroy
  # 他のコード...
end
5.Reportモデルにbelongs_to :userの関連付けを追加
class Report < ApplicationRecord
  belongs_to :user
  validates :user_id, presence: true
  validates :title, presence: true
  validates :content, presence: true
  # 他のコード...
end

これで、UserとReportの関連付けが完了。関連付けを使用して、ユーザーによる日報の一覧を作成したり、ユーザーによる日報の作成を制限したりすることができる。

6.ログインしているユーザーが自分の日報のみ編集・削除できるようにする

ReportsControllerで、以下のようにbefore_actionを設定。

class ReportsController < ApplicationController
  before_action :set_report, only: %i[show]
  before_action :check_user, only: %i[edit, update, destroy]

# ...

private

def check_user
  @report = current_user.reports.find_by!(id: params[:id])
end

find_by!メソッドは、指定された条件に合致するレコードが存在しない場合には、ActiveRecord::RecordNotFound例外を発生させる。つまり、ログインしているユーザーに属する日報の中から、params[:id]で指定されたIDを持つ日報が見つからない場合には、エラーが発生する。

以上のように実装することで、ログインしているユーザーが自分が投稿した日報のみ編集・削除できるようになる。

views/reports/show.html.erbに以下を追加

<% if @report.user == current_user %>
  <%= link_to 'Edit', edit_report_path(@report) %>
  <%= link_to 'Destroy', @report, method: :delete, data: { confirm: 'Are you sure?' } %>
<% end %>

編集・削除ボタンについて、ログインしているユーザーが、自分が投稿した日報の場合のみ表示されるようになる。 これにより、ログインしているユーザーは自分が投稿した日報のみ編集・削除ができ、他のユーザーの日報を編集・削除することができなくなる。

7.コメントモデルの作成(本題はここから)

BookモデルとReportモデルに従属するCommentモデルを作成する。

$ rails g model comment user:references commentable:references{polymorphic} content:text --no-test-framework
  • user:referencesは、user_idという名前の外部キーを作成するためのオプション。:referencesは、生成するマイグレーションファイルで外部キー制約を作成することを指示する。これは、このコメントがどのユーザーによって投稿されたかを追跡するために使用される。

  • commentable:references{polymorphic}は、ポリモーフィック関連を設定するためのオプション。これは、コメントがどのモデルに対しても関連付けられる可能性があることを意味する。つまり、コメントがBook(本)、Report(日報)、または他の何かに対して投稿されることができる。

  • content:textは、contentという名前のカラムを作成するためのオプション。:textは、データベースのデータ型を指定するためのもので、この場合は、テキストデータを保存するために使用。

  • --no-test-frameworkは、自動的にモデル用のテストファイルを生成しないように指示するオプション。

app/models/comment.rb

class Comment < ApplicationRecord
  belongs_to :user
  belongs_to :commentable, polymorphic: true

  validates :content, presence: true
end
  • belongs_toは、Active Recordの関連付けを設定するためのメソッドで、この場合、CommentモデルがUserモデルと多対1の関係を持つことを示す。また、belongs_toの第2引数にpolymorphic: trueを指定することで、Commentモデルが他のモデルとも多対1の関係を持つことを可能にする。

  • validatesは、Active Recordのバリデーションを設定するためのメソッドで、この場合、Commentモデルのcontent属性が空でないことを要求している。 つまり、このコードは、Commentモデルを定義し、Userモデルとの多対1の関係、他のモデルとの多対1の関係、そしてcontent属性が空でないことを要求するバリデーションを設定している。

db/migrate/[timestamp]_create_comments.rb

class CreateComments < ActiveRecord::Migration[6.1]
  def change
    create_table :comments do |t|
      t.references :user, null: false, foreign_key: true
      t.references :commentable, polymorphic: true, null: false
      t.text :content

      t.timestamps
    end
  end
end

マイグレーションを実行。

$ rails db:migrate

db/schema.rb

  create_table "comments", force: :cascade do |t|
    t.integer "user_id", null: false
    t.string "commentable_type", null: false
    t.integer "commentable_id", null: false
    t.text "content"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["commentable_type", "commentable_id"], name: "index_comments_on_commentable"
    t.index ["user_id"], name: "index_comments_on_user_id"
  end
  • commentable_typeは、多態性ポリモーフィズム)を実現するためのカラム。commentable_typeは、コメントが関連するモデルのクラス名を文字列として格納。今回のケースだと、commentable_typeカラムのセットされるのは参照する親モデルのクラス名(BookorReport)。commentable_idは、多態性を実現するためのカラムの一つで、コメントが関連するモデルのIDを格納。commentable_typeと一緒に使用することで、commentable_idがどのモデルのIDかを識別することができる。 このように、commentable_typecommentable_idを組み合わせることで、1つのコメントが複数のモデルに関連付けられる多態性の実現が可能になる。

app/models/book.rb

class Book < ApplicationRecord
  has_many :comments, as: :commentable, dependent: :destroy # 追記
end

app/models/report.rb

class Report < ApplicationRecord
  belongs_to :user
  has_many :comments, as: :commentable, dependent: :destroy # 追記

  validates :title, presence: true
  validates :content, presence: true
end
  • as: :commentableは、多態性を実現するためのオプションで、Commentモデルが他のモデルとも多対1の関係を持つことを可能にする。また、dependent: :destroyは、BookまたはReportが削除された場合、関連するCommentも削除されるように設定。
8. コメントの作成

コメントの新規投稿や削除などを行うためのコントローラを作成。

$ rails g controller Comments --no-assets --no-helper --no-test-framework

app/controllers/comments_controller.rb

class CommentsController < ApplicationController
  def create
    @comment = @commentable.comments.build(comment_params)
    @comment.user = current_user
    if @comment.save
      redirect_to @commentable, notice: t('controllers.common.notice_create', name: Comnent.model_name.human)
    else
    end
  end

  private

  def comment_params
    params.require(:comment).permit(:content)
  end
end
  • コメントオブジェクトの内容は、comment_paramsメソッドで許可されたパラメーターから取得。 ここでは、:contentパラメーターのみを許可。 私的なメソッドであるcomment_paramsメソッドは、Strong Parametersを使用して、予期しないパラメーターがデータベースに保存されることを防止するために使用。

コメントのルーティングの設定

config/routes.rb

Rails.application.routes.draw do
       :
  resources :reports do
    resources :comments, only: :create, module: :reports
  end
  resources :books do
    resources :comments, only: :create, module: :books
  endend

2.6 コントローラの名前空間とルーティング

HTTP動詞パスコントローラ#アクション名前付きルーティングヘルパー
POST/books/:book_id/commentsbooks/comments#createbook_comments_path
POST/reports/:report_id/commentsreports/comments#createreport_comments_path

これにより、上記URLでコメントの新規投稿と削除が可能になる。

Books::Commentsコントローラーの作成

$ rails generate controller Books::Comments --no-assets --no-helper --no-test-framework

app/controllers/books/comments_controller.rb

class Books::CommentsController < ApplicationController
  before_action :set_commentable

  private

  def set_commentable
    @commentable = Book.find(params[:book_id])
  end
end

Reports::Commentsコントローラーの作成

$ rails generate controller Reports::Comments --no-assets --no-helper --no-test-framework

app/controllers/reports/comments_controller.rb

class Reports::CommentsController < ApplicationController
  before_action :set_commentable

  private

  def set_commentable
    @commentable = Report.find(params[:report_id])
  end
end

ビューでコメントの投稿フォームを作成。

app/views/comments/_form.html.erb

<%= form_with model: [commentable, comment] do |f| %>
  <% if comment.errors.any? %>
    <div id="error_explanation">
      <h2>
        <%= I18n.t("errors.messages.not_saved",
                  count: comment.errors.count,
                  resource: comment.class.model_name.human.downcase) %>
      </h2>
      <ul>
        <% comment.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= f.label :content %>
    <%= f.text_area :content, required: true %>
  </div>

  <div class="actions">
    <%= f.submit %>
  </div>
<% end %>
  • 2つのプレースホルダー(テンプレート内に置かれ、後で置換される変数のこと。同じメッセージの翻訳を複数の場所で再利用できる。)があり、それぞれ以下のように解釈
    • %{count} - コメントに関連するエラーの数
    • %{resource} - コメントのクラス名を人間が読みやすい形式で表示したもの

例えば、コメントが保存できなかった場合には「1件のコメントを保存できませんでした」というように表示される。

  • form_withはURLを生成するのにpolymorphic_pathを使用。
  • polymorphic_pathは与えられたオブジェクトに対する正しいURLを自動的に生成するヘルパーメソッド。Railsはコントローラー名やアクション名を直接指定する必要がなく、オブジェクトに基づいて正しいURLを自動的に生成することができる。これにより、コードの再利用性が向上し、コードの保守性が高まる。

BookとReportの詳細画面からコメントを投稿

app/controllers/books_controller.rb

class BooksController < ApplicationControllerdef show
    @comment = Comment.new
  end
end
  • @commentでフォームに渡す。

app/controllers/reports_controller.rb

class ReportsController < ApplicationControllerdef show
    @comment = Comment.new
  end
end
  • @commentでフォームに渡す。

app/views/books/show.html.erb

<%= render 'comments/form', commentable: @book, comment: @comment %>
  • フォーム側でbook_comments_pathを生成

app/views/reports/show.html.erb

<%= render 'comments/form', commentable: @report, comment: @comment %>
  • フォーム側でreport_comments_pathを生成
9.コメントの表示

コメント一覧パーシャルの作成

app/views/comments/_comments.html.erb

<div class="comments-container">
  <strong></strong>
  <% if comments.any? %>
    <ul>
      <% @comments.each do |comment| %>
        <% if comment.persisted? %>
          <li>
            <%= comment.content %></p>
            <small>
              (<%= link_to comment.user.name_or_email, comment.user %> - <%= l comment.created_at, format: :short %>)
            </small>
          </li>
        <% end %>
      <% end %>
    </ul>
  <% else %>
    (<%= t('.no_comments') %>)
  <% end %>
</div>
  • if comments.any?

    • コメントが存在する場合にコメントを表示。コメントが存在しなければt('.no_comments')
    • Rails API: any?
  • if comment.persisted?

  • link_to comment.user.name_or_email, comment.userで、コメントの投稿者の名前またはメールアドレスをリンクにして表示。

  • l comment.created_at, format: :short

app/models/user.rb

class User < ApplicationRecorddef name_or_email
    name.empty? ? email : name
  end
end
  • name_or_email
    • ユーザー名が入力されていればユーザー名、未入力ならメールアドレスを表示

app/controllers/books_controller.rb

class BooksController < ApplicationControllerdef show
    @comment = Comment.new
    @comments = @book.comments # 追記
  end
end
  • コメント一覧パーシャルに渡す@commentsを定義

app/controllers/reports_controller.rb

class ReportsController < ApplicationControllerdef show
    @comment = Comment.new
    @comments = @report.comments # 追記
  end
end
  • コメント一覧パーシャルに渡す@commentsを定義

app/views/books/show.html.erb

<%= render 'comments/comments', comments: @comments %>

app/views/reports/show.html.erb

<%= render 'comments/comments', comments: @comments %>
10.コメント作成失敗時の処理

コメント作成失敗時に本または日報の詳細画面に遷移する

app/controllers/comments_controller.rb

class CommentsController < ApplicationController
  def create
    @comment = @commentable.comments.build(comment_params)
    @comment.user = current_user
    if @comment.save
      redirect_to @commentable, notice: t('controllers.common.notice_create', name: Comment.model_name.human)
    else
      # コメント作成失敗時の処理を追記
      @comments = @commentable.CommentsController
      render_commentable_show
    end
  endend
  • create アクションでは、コメント可能な対象(@commentable)に紐づくコメントを新規作成し、ログイン中のユーザーをコメントのユーザーとして設定。そして、コメントの保存に成功した場合は、@commentable にリダイレクトして成功メッセージを表示。コメントの保存に失敗した場合は、@commentable に関連するコメント一覧を取得し、コメント可能な対象の show ページをレンダリング

app/controllers/books/comments_controller.rb

class Books::CommentsController < CommentsController
  before_action :set_commentable

  privatedef render_commentable_show
    @book = @commentable
    render 'books/show'
  end
end
  • Books::CommentsControllerは、CommentsController を継承。render_commentable_showメソッドは、Books::CommentsControllerでオーバーライドされている。render_commentable_showメソッドは、コメントが作成された書籍の show ページをレンダリングするために使用される。Books::CommentsControllerでは、before_actionメソッドで set_commentableを実行し、コメントが作成される対象となる書籍を設定。

app/controllers/reports/comments_controller.rb

class Reports::CommentsController < CommentsController
  before_action :set_commentable

  privatedef render_commentable_show
    @report = @commentable
    render 'reports/show'
  end
end
11.コメント削除・編集

ルーティングの設定

config/routes.rb

Rails.application.routes.draw do
       :
  resources :reports do
    resources :comments, only: %i[create destroy edit update], module: :reports
  end
  resources :books do
    resources :comments, only: %i[create destroy edit update], module: :books
  end
end

コメントの投稿と削除の処理 comments_controller.rb にコメントの新規投稿と削除の処理を実装。

HTTP動詞パスコントローラ#アクション名前付きルーティングヘルパー
POST/books/:book_id/commentsbooks/comments#createbook_comments_path
GET/books/:book_id/comments/:id/editbooks/comments#editedit_book_comment_path
PATCH/books/:book_id/comments/:idbooks/comments#updatebook_comment_path
DELETE/books/:book_id/comments/:idbooks/comments#destroybook_comment_path

edit・update・destroyアクションの追加

app/controllers/comments_controller.rb

class CommentsController < ApplicationController
  before_action :set_comment, only: %i[edit update destroy]

  def edit; end

  def createend

  def update
    if @comment.update(comment_params)
      redirect_to @commentable, notice: t('controllers.common.notice_update', name: Comment.model_name.human)
    else
      render :edit
    end
  end

  def destroy
    if @comment.user == current_user
      @comment.destroy
      redirect_to @commentable, notice: t('controllers.common.notice_destroy', name: Comment.model_name.human)
    else
      redirect_to @commentable, alert: t('controllers.common.alert_not_authorized')
    end
  end

  private

  def set_comment
    @comment = Comment.find(params[:id])
  endend
  • コメントの削除について:
    • クライアントサイドのコード(JavaScriptやHTML)はユーザーのブラウザ上で実行されるため、ユーザーは開発者ツールを使用してこれらのコードを変更することができる。

      • 例えば、開発者ツールを使ってブラウザのJavaScriptコンソールを開き、JavaScriptのコードを直接編集することで、動的なWebページの動作を変更することができる。 これにより、不正な操作を行うユーザーが、例えば本来許可されていない削除操作を実行したり、権限を超えたアクセスを行ったりする可能性がある。例えば、コメントを削除するページのURLを直接変更して他のユーザーのコメントを削除する、あるいは管理者権限のないユーザーが管理者用のページにアクセスする、といったような攻撃が考えられる。
    • 上記のような不正な操作を防ぐために

      • サーバーサイドでも適切な権限の制御を行う必要がある。例えば、コメントの削除処理を実行する前に、作成者のリクエストかどうかを確認する、ログインしているユーザーのみにアクセスを許可する、管理者権限を持つユーザーのみに特定の操作を許可する、などのサーバーサイドのロジックを実装することで、不正な操作を防止することができる。
      • クライアントサイドでの制御はあくまで見た目や動作の改善などを目的とし、セキュリティ的な制御はサーバーサイドで行うべき。サーバーサイドでの適切な権限の制御は、システムのセキュリティを確保する上で重要な対策となる。

コメント削除・編集リンクの追加

app/views/comments/_comments.html.erb

<div class="comments-container">
  <strong><%= Comment.model_name.human %>:</strong>
  <% if comments.any? %>
    <ul>
      <% comments.each do |comment| %>
        <% if comment.persisted? %>
          <li>
            <%= comment.content %>
            <small>
              (<%= link_to comment.user.name_or_email, comment.user %> - <%= l comment.created_at, format: :short %>)
            </small>
            <% if current_user == comment.user %>
              <%= link_to t('views.common.edit'), edit_polymorphic_path([commentable, comment]) %> |
              <%= link_to t('views.common.destroy'), polymorphic_path([commentable, comment]), method: :delete, data: { confirm: t('views.common.delete_confirm') } %>
            <% end %>
          </li>
        <% end %>
      <% end %>
    </ul>
  <% else %>
    (<%= t('.no_comments') %>)
  <% end %>
</div>

app/views/books/show.html.erb

<%= render 'comments/comments', commentable: @book, comments: @comments %>
  • commentable: @bookを追記

app/views/reports/show.html.erb

<%= render 'comments/comments', commentable: @report, comments: @comments %>
  • commentable: @reportを追記

app/views/comments/edit.html.erbを作成

<h1><%= t('views.common.title_edit', name: Comment.model_name.human) %></h1>

<%= render 'comments/form', commentable: @commentable, comment: @comment %>

<%= link_to t('views.common.back'), @commentable %>