はじめに
掲示板詳細画面の追加と、掲示板に対してコメント機能を持たせます。
- 掲示板一覧から掲示板詳細画面へ遷移できるようにしましょう。
- 掲示板詳細画面から、コメントを書き込めるようにしましょう。
- コメントの文字数は65535字までにモデルで制限しましょう。
- 書き込んだコメントは、掲示板詳細画面の下部に降順に表示されるようにしましょう。
- コメントした本人だけ削除・編集ボタンを表示するよう制限しましょう。
作業の流れ
- Commentモデルを作成
- ルーティングの設定
- commentsコントローラを作成
- boards_controllerへ追記
- 掲示板の編集と削除のボタンは部分パーシャル化する
- 個々の掲示板表示パーシャル内で編集と削除ボタンのパーシャルを組み込む
- 掲示板詳細画面の作成
- コメントフォームのパーシャルを作成
- コメント一覧部分のパーシャルを作成
- 各コメント部分のパーシャルを作成
Commentモデルを作成
UserとBoardモデルに関連付けさせるには、references
で指定します。
なぜなら、普通の$ bundle exec rails generate model Comment body:text user_id:integer board_id:integer
という書き方では、
- インデックスが貼られない。
- 外部キー制約が貼られない。
などの問題が生じるからです。
外部キーを作るときは基本的にはreferences型を使うということを肝に銘じておきましょう。
$ bundle exec rails generate model Comment body:text user:references board:references
マイグレーションファイルにNOT NULL制約をつける
作成されたマイグレーションファイルを確認し、
- 空で投稿できないようにNOT NULL制約を追記する。
- 外部キー制約が付与されている事を確認
class CreateComments < ActiveRecord::Migration[5.2]
def change
create_table :comments do |t|
t.text :body, null: false
t.references :user, foreign_key: true
t.references :board, foreign_key: true
t.timestamps
end
end
end
外部キー制約とは
主キーと外部キーを使った制約で利用した場合、下記の制限が入る。
- 存在しない値を外部キーとして登録することはできない
- 子テーブルの外部キーに値が登録されている親テーブルのレコードは削除できない
外部キー制約のメリット・デメリット
メリット
- 存在しない値が外部キーとして登録されることを防ぐことができる
- データの整合性が保てる
- うっかり親テーブルのレコードを消しちゃった。なんてことがなくなり、子テーブルのレコードの親子関係がバグることがない
デメリット
- 親テーブルのレコード削除がめんどくさいかも
- Railsでは
dependent
オプションがあるのでそんなに大変ではない印象
- Railsでは
- 設定を間違えた際に、意図せず重要なレコードが消える可能性がある
- 大量のデータを抱えた親レコードがあっても、外部キー制約があると分割して消すことが難しいため、削除の負荷を分散できない
- DBを跨いだ制約はかけることができない
マイグレ-ションを実行する
$ bundle exec rails db:migrate
各モデルの関連付け
class User < ApplicationRecord
has_many :boards, dependent: :destroy
has_many :comments, dependent: :destroy
end
class Board < ApplicationRecord
belongs_to :user
has_many :comments, dependent: :destroy
end
commentモデルには空が投稿できないようにと、文字制限するためのバリデーションを追加します。
class Comment < ApplicationRecord
belongs_to :user
belongs_to :board
validates :body, presence: true, length: { maximum: 65_535 }
end
ルーティングの設定
ルーティングをネストして、boards
とcomments
との親子関係を作ります。
今回必要なのはcreateアクションだけなので、%i[create]
とします。
Rails.application.routes.draw do
root 'static_pages#top'
get 'login', to: 'user_sessions#new'
post 'login', to: 'user_sessions#create'
delete 'logout', to: 'user_sessions#destroy'
resources :users, only: %i[new create]
resources :boards, only: %i[index new create show] do
resources :comments, only: %i[create], shallow: true
end
end
shallow
オプションは生成されるルーティングをスッキリさせる事ができます。詳しくはこちらを確認しておきましょう。
https://railsguides.jp/routing.html#「浅い」ネスト
今回はcreateアクションしか無いのでshallowオプションの恩恵は無いと言えますね。
commentsコントローラを作成
以下のコマンドを入力することでcomments_controller
が生成されます
$ bundle exec rails g controller comments
comments_controllerへ追記
createアクションにはview画面が無いので、インスタンス変数にしてません。
今回は失敗時にもredirect
を採用しています。
render :new
のようにするときは、new.html.erbにインスタンス変数を渡す必要がありました。
今回はコントローラ内で処理が完結している為、ローカル変数を採用しています。
class CommentsController < ApplicationController
def create
comment = current_user.comments.build(comment_params)
if comment.save
redirect_to board_path(comment.board), success: t('defaults.message.created', item: Comment.model_name.human)
else
redirect_to board_path(comment.board), danger: t('defaults.message.not_created', item: Comment.model_name.human)
end
private
def comment_params
params.require(:comment).permit(:body).merge(board_id: params[:board_id])
end
end
コードを紐解く
comment = current_user.comments.build(comment_params)
「ログインしているユーザ」に紐付いた「コメントデータ」を初期化し、comment
に代入しています。
すなわち、current_user
のid
(ログインしているユーザに対するuser_id
) も初期化して代入していることになる。
comment = Comment.new(comment_params.merge(uesr_id: current_user.id))
こちらの書き方も同じ意味になりますが、前者のほうがRailsっぽい書き方になります。
前者を使うようにしましょう。
参考
# GOOD アソシエーション活用
comment = current_uesr.comments.build(comment_params)
# BAD 初期化した後に値を代入
comment = Comment.new(comment_params)
comment.user_id = current_user.id
# BAD 初期化する際のパラメータをmerge
comment = Comment.new(comment_params.merge(user_id: current_user.id))
※ Build = new
buildはnewのエイリアス
慣習的にアソシエーション先のモデルを作成する時はbuildが使われる
def comment_params
params_require(:commeent).permit(:body).merge(board_id: params[:board_id])
end
コメントを保存する際は、関連付けしているuser_id
とboard_id
も一緒に保存する必要があります。
ここの部分は、ストロングパラメータとして生成される際に、入力した値(body)と一緒に、board_idも保存するという内容です。
board_id: params[board_id]
はurlから取得しています。
commentsコントローラのcreateアクションに対するルーティング設定は、POSTメソッドのboards/:board_id/comments
で、ヘルパーメソッドはboards_comments_path
となります。
ここからboard_id
をパラーメータとして取得しています。
boards_controllerへ追記
掲示板詳細画面(show.html.erb)にて、@boardと@comment、@commentsを渡します。
class BoardsController < ApplicationController
def show
@board = Board.find(params[:id])
@comment = Comment.new
@comments = @board.comments.includes(:user).order(created_at: :desc)
end
end
includesメソッドで関連付けを一括読み込みをする
includesは、関連付けられたテーブルのデータを参照するメソッドです。このメソッドを使うと、あるモデルからデータを取得する際に、関連付けされたモデルのデータも一緒に取得してくれます。
# GOOD
@comments = @board.comments.includes(:user).order(created_at: :desc)
# BAD N+1問題が起こる書き方
@comments = @board.comment.order(created_at: :desc)
掲示板の編集と削除のボタンは部分パーシャル化する
掲示板の編集と削除のボタンは、掲示板の一覧と詳細ページで同じものを表示するので、部分テンプレートとして作成しておきます。
<ul class='crud-menu-btn list-inline float-right'>
<li class="list-inline-item">
<%= link_to '#', id: "button-edit-#{board.id}" do %>
<%= icon 'fa', 'pen' %>
<% end %>
</li>
<li class="list-inline-item">
<%= link_to '#', id: "button-delete-#{board.id}" do %>
<%= icon 'fas', 'trash' %>
<% end %>
</li>
</ul>
個々の掲示板表示パーシャル内で編集と削除ボタンのパーシャルを組み込む
<%= image_tag board.board_image_url, class: 'card-img-top', size: '300x200' %>
<div class="card-body">
<h4 class="card-title">
<%= link_to board.title, board_path(board) %>
</h4>
<%= render 'crud_menus', board: board %>
<ul class="list-inline">
<li class="list-inline-item">
<%= icon 'far', 'user' %>
掲示板詳細画面の作成
<% content_for(:title, @board.title) %>
<div class="container pt-5">
<div class="row mb-3">
<div class="col-lg-8 offset-lg-2">
<h1><%= t('.title') %></h1>
<!-- 掲示板内容 -->
<article class="card">
<div class="card-body">
<div class='row'>
<div class='col-md-3'>
<%= image_tag @board.board_image.url, class: 'card-img-top img-fluid', size: '300x200' %>
</div>
<div class='col-md-9'>
<h3 class="d-inline"><%= @board.title %></h3>
<%= render 'crud_menus', board: @board %>
<ul class="list-inline">
<li class="list-inline-item">by <%= @board.user.decorate.full_name %></li>
<li class="list-inline-item"><%= l @board.created_at, format: :long %></li>
</ul>
</div>
</div>
<p><%= simple_format(@board.body) %></p>
</div>
</article>
</div>
</div>
<!-- コメントフォーム -->
<%= render 'comments/form', { board: @board, comment: @comment } %>
<!-- コメントエリア -->
<%= render 'comments/comments', { comments: @comments } %>
</div>
掲示板詳細画面は大まかに3つに分かれています。
- 掲示板内容表示
- コメントフォーム
- コメントエリア
なお、コメントフォームとコメントエリアはパーシャルを呼び出します。
コードを紐解く
<% content_for(:title, @board.title) %>
content_for
でタイトルを個別に指定することができます。
application.html.erbで以下の設定をする必要があります。
<title><%= yield(:title) %></title>
詳しくはこちらの記事を参照してください。

<%= simple_format(@board.body) %>
simple_format
はRailsのヘルパーメソッドです。
改行文字を含むテキストをブラウザ上で表示させるときに使います。
コメントフォームのパーシャルを作成
原則としてパーシャルは再利用性を高めるためにローカル変数を使用する。
インスタンス変数を使うとなると、コントローラと関連付いてしまうため、再利用性が低くなります。
エラーメッセージを呼び込むパーシャルも実装しましょう。
<div class="row mb-3">
<div class="col-lg-8 offset-lg-2">
<%= form_with model: comment, url: [board, comment], local: true do |f| %>
<%= render 'shared/error_messages', object: f.object %>
<%= f.label :body %>
<%= f.text_area :body, class: 'form-control mb-3', row: 4, placeholder: Comment.human_attribute_name(:body) %>
<%= f.submit t('defaults.post'), class: 'btn btn-primary' %>
<% end %>
</div>
</div>
コードを紐解く
<%= form_with model: comment, url: [board, comment], local: true do |f| %>
model: comment
Commentモデルと紐付けます。url: [board, comment]
データの送信先を指定しています。- 今回の場合、ルーティングを親子関係でネスト構造になっているので、パスが
board/board_id/comments
となっています。 - よって、データの送信先として
board
も指定してやる必要があります。 - また、親である
board
を先に書かないといけません。 - なぜなら、先に
board
,後にcomment
と指定することで初めてデータ送信先としてboard_comments_path
となるからです。
- 今回の場合、ルーティングを親子関係でネスト構造になっているので、パスが
locale: true
:locale
オプションは、送信時にAjaxを使わなくします。- 普通にHTMLとしてフォームを送信する場合に
local: true
が必要になります。
- 普通にHTMLとしてフォームを送信する場合に
コメント一覧部分のパーシャルを作成
コメント一覧部分は、show.html.erbから呼び出される形になっています。
ファイル名はcommentsと複数形になっていることに注意が必要です。
ここではさらに_comment.html.erb
を呼び出しています。
<div class="row">
<div class="col-lg-8 offset-lg-2">
<table id="js-table-comment" class="table">
<%= render comments %>
</table>
</div>
</div>
各コメント部分のパーシャルを作成
<tr id="comment-<%= comment.id %>">
<td style="width: 60px">
<%= image_tag 'sample.jpg', class: 'rounded-circle', size: '50x50' %>
</td>
<td>
<h3 class="small"><%= comment.user.decorate.full_name %></h3>
<div id="js-comment-id-<%= comment.id %>">
<%= simple_format(comment.body) %>
</div>
<div id="js-textarea-comment-box-<%= comment.id %>" style="display: none;">
<textarea id="js-textarea-comment-<%= comment.id %>" class="form-control mb-1"><%= comment.body %></textarea>
<button class="btn btn-light js-button-edit-comment-cancel" data-comment-id="<%= comment.id %>">キャンセル</button>
<button class="btn btn-success js-button-comment-update" data-comment-id="<%= comment.id %>">更新</button>
</div>
</td>
<% if current_user.own?(comment) %>
<td class="action">
<ul class="list-inline justify-content-center" style="float: right;">
<li class="list-inline-item">
<a href="#" class='js-edit-comment-button' data-comment-id="<%= comment.id %>">
<%= icon 'fa', 'pen' %>
</a>
</li>
<li class="list-inline-item">
<a href="#" class='js-edit-comment-button' data-comment-id="<%= comment.id %>">
<%= icon 'fas', 'trash' %>
</a>
</li>
</ul>
</td>
<% end %>
</tr>
なぜ二重にパーシャルを呼び込む必要があるのか?
結論を言えば、each文を使わずにテンプレートを繰り返し表示するためです。
<%= render comments %>
上の書き方は省略形になっています。
書き換えると、
<%= render partial: 'comment', collection: comments %>
となります。
ファイル名と複数形sを除いたオブジェクト名が一致しており、かつeach文のように繰り返し表示した場合は、上記のように省略することができます。
今回だと_comment.html.erb
とcomments
なので、一致していると言えます。
繰り返しになりますが、collectionオプションは、渡されたオブジェクトを、そのテンプレートを繰り返し表示することができるオプションです。
さらに表現を変えると、
<%= render comments %>
という省略形は、
<% comments.each do |comment| %>
<%= render partial: 'comment', locals: { comment: comment } %>
<% end %>
上記の形に書き換えることもできます。
少しややこしいですが、renderの書き方はいろいろあるので整理してみてください。
コメントした本人だけ削除・編集ボタンを表示する
- コメントの編集・削除ボタン表示の判定ロジックは、Userモデルにクラスメソッドではなく、インスタンスメソッドとして記載します。
- ロジックをControllerやViewではなく、Modelに記載して呼び出すことで、メンテナンス時にModelだけの変更で住むようになるだけでなく、Fat Controllerを防ぐことにもなります。
- また、Userモデルにインスタンスメソッドとして書くことで、レシーバ(メソッドを実行している側のオブジェクトを指す用語)がUserモデルのインスタンスを対象にしていることが分かりますね。
- 「判定ロジックはモデルに記載する」などとルールを決めていくと、後から参照しやすくまとまっているので良いですね。
- 自分のコメントかどうかの比較は、アソシエーション先のモデルのオブジェクト比較ではなく、idの値で比較するようにしましょう。
comment.user == current_user
のように、Userオブジェクトの比較を行うと、commentモデルにあるuser_idを使って、Usersテーブルからアソシエーション先のUserオブジェクトを探してきてしまい、無駄にSQLを発行させてしまいます。comment.user_id == [self.id](<http://self.id>)
だと、commentインスタンスが持っているuser_idを使って比較しているだけなのでSQLが発行されません。- self(呼び出し元のオブジェクトのこと)レシーバは省略するのが一般的な記載方法になります。
user.rb
def my_comment?(comment)
comment.user_id == id
# comment.user_id == self.id
end
# より汎用的な形がこちら。
def own?(object)
object.user_id == id
end
なお、セッター以外はself.
が省略されていると考えるとよいでしょう。
今回、id
はセッターではない為、self.id
はUserインスタンスのid
を指します。
セッターの使用例は以下のようなケースになります。
class User < ApplicationRecord
def change_email(email_after)
self.email = email_after
end
end
emailに代入する形になっています。
終わりに
非常にボリューミーな実装でしたが、理解を深める一助になれたら幸いです。
参考文献


コメント