最近RORのプロジェクトでコメント機能を実装する機会があり、自己参照(SQLでの自己結合)のロジックを作ったのですが、思いの外詳細に記述してくれている記事がなく、これから実装の機会がある誰かにとって参考になればと思いました。
TL;DR
Railsで自己参照を用い、コメント機能を実装する。
model
ER図
(DBeaver早速使ってます、めちゃいいです! ref:TeamSQL もいいけど、 DBeaver もいいぞ)
このように親modelとしてUserとVideoが存在し、子modelであるCommentが自己参照をしているという形をとります。ここではComment modelの自己参照に主眼をおいているので、UserとVideoは実装済みとします。ではさくっと実装していきましょう!
shellへ行って
$ ~/project_dir rails g model Comment content:text comment:references user:references video:references
$ ~/project_dir rails db:migrate
DBへの反映もしちゃいましょう。
comment.rb
が生成されたので、ここにコメント内容のvalidation
とtableのschemaのannotation(ref:annotate)をくっつけて(annotationは不要ならしなくてもOK)
さらにちょっと手直しして(下で説明します!)、、、
# == Schema Information
#
# Table name: comments
#
# id :bigint(8) not null, primary key
# content :text(65535)
# comment_id :bigint(8)
# user_id :bigint(8)
# video_id :bigint(8)
# created_at :datetime not null
# updated_at :datetime not null
#
class Comment < ApplicationRecord
has_many :replies, class_name: 'Comment', foreign_key: 'comment_id', dependent: :destroy
belongs_to :original_comment, class_name: 'Comment', foreign_key: 'comment_id', optional: true
belongs_to :user
belongs_to :video
validates :content, presence: true
end
UserとVideoとの親子関係はご覧の通りかと思います。ここで2点、
-
has_many :replies , class_name: 'Comment', foreign_key: 'comment_id', dependent: :destroy
について- 自己の子供をreplyと名付け、class_name、 foreign_keyとともに記述しています。
dependent: :destroy
は親のコメントが削除された際、その返信も削除されたほうがいいよね、ということでつけてます。
- 自己の子供をreplyと名付け、class_name、 foreign_keyとともに記述しています。
-
belongs_to :original_comment, class_name: 'Comment', foreign_key: 'comment_id', optional: true
について- 自己の親コメントをoriginal_commentと名付け、class_name、foreign_keyとともに記述しています。
optional: true
を指定することで、親コメントは任意にしています、元のコメントは親を持つはずないので。Rails5ではデフォルトが、required: true
となっているため、このオプションを抜くとvalidationで引っかかります。(ref:Rails5からbelongs_to関連はデフォルトでrequired: trueになる)
- 自己の親コメントをoriginal_commentと名付け、class_name、foreign_keyとともに記述しています。
これで
> com1 = Comment.first
> com1.replies
> com1.original_comment
などでオブジェクトを取得できるようになります。
modelの実装は終了です。
controller
video#showに表示するので、#create, #destroyのみを実装することにします。(簡単なコメントを想定しているので#updateは今回はいいかな)
まずはshellに行って
$ ~/project_dir rails g controller comments create destroy
これでcomments_controller.rb
が生成されたので、それぞれのactionを定義して、あれこれやって(下で説明します!)、、、
class CommentsController < ApplicationController
before_action :authenticate_user!, only: %i[create destroy]
before_action :set_comment, only: :destroy
# POST /comments
def create
@comment = Comment.new(comment_params)
if @comment.errors.empty? && @comment.save
redirect_to video_path(@comment.video_id), notice: I18n.t('activerecord.flash.comment.actions.create.success')
else
redirect_to video_path(@comment.video_id), alert: I18n.t('activerecord.flash.comment.actions.create.failure')
end
end
# DELETE /comments/:id
def destroy
if @comment.destroy
redirect_to video_path(@comment.video_id), notice: I18n.t('activerecord.flash.comment.actions.destroy.success')
else
redirect_to video_path(@comment.video_id), alert: I18n.t('activerecord.flash.comment.actions.destroy.failure')
end
end
private
def comment_params
params.require(:comment).permit(:content, :comment_id, :user_id, :video_id)
end
def set_comment
@comment = Comment.find(params[:id])
end
end
ここで数点、別の書き方でもOKなものばかりですがきれいな書き方だとは思うのでとりあえず解説を
-
before_action :authenticate_user!, only: %i[create destroy]
について- #create, #destroyの前にuser認証の
before_action
callbackを設定してます。:authenticate_user!
はdeviseで用意されているメソッドです。only:
のあとにホワイトリストでactionのsymbolを指定しています。
- #create, #destroyの前にuser認証の
-
before_action :set_comment, only: :destroy
について- #destroyの前に対象のcommentを取得する
before_action
callbackを設定してます。set_comment
はprivateメソッドで定義してます。
- #destroyの前に対象のcommentを取得する
-
notice: I18n.t('activerecord.flash.comment.actions.create.success')
やalert: I18n.t('activerecord.flash.comment.actions.create.failure')
について- i18n(internationalization)の設定です。ref:rails-i18n。ここでは触れないですが例えばこんなyamlファイルを読み込んで、
コメントの登録を完了しました。
や入力を修正してください。
が表示できるようになります。
- i18n(internationalization)の設定です。ref:rails-i18n。ここでは触れないですが例えばこんなyamlファイルを読み込んで、
ja:
activerecord:
models:
comment: コメント
attributes:
comment:
id:
content: コメント
flash:
comment:
actions:
create:
success: コメントの登録を完了しました。
failure: 入力を修正してください。
destroy:
success: コメントの削除を完了しました。
failure: コメントを削除できませんでした。
- それぞれのactionについて
- video#showに表示するので
redirect_to
ですべてそのcommentが紐付いたvideoのshowページにリダイレクトするように設定しています。
- video#showに表示するので
-
# POST /comments
などについて- 前バイト先で書く習慣があってそれを僕が真似続けているものです。なんかrest resourceを意識できていいなと思ったので書いてます。必要はないです。
-
comment_params
について- strong parameterに外部キーの
:comment_id
を入れるのを忘れないでください!
- strong parameterに外部キーの
これでcontrollerの実装は終了です。
routes
Rails.application.routes.draw do
resources :videos, only: %i[index show]
resources :comments, only: %i[create destroy]#追加行!!
~~
end
commentのrestfulなresourceに対してcreate, destroyをホワイトリストで指定しています。
view!!
実は今回ここに悩みました。僕が実装したプロジェクトはフロントにVue.jsを使用しているのでそっちと結合させて実装しようかと思ったのですが、いやform_with
でも十分か、と思い、徒然に実装していったのですがちょっと時間がかかってしまいました。今回はform_with
のみで実装してます!
~~
section.section
.container.has-text-left
h2.title[style="color: #DC5A2F"]
| #{Comment.model_name.human}
- unless @video.comments.size.zero?
// コメントの表示(where句で親コメントに絞り込んでいる)
- @video.comments.where(comment_id: nil).each do |video_comment|
.columns[style="padding: 2rem"]
.column.is-9
.content
article.post
.media
.media-left
// Active Storageを用いているので、こんな感じでコメントに紐付いたuserのimageがあるかを検証
- if video_comment.user.image.attached?
p.image.is-64x64
= image_tag video_comment.user.image, class: 'is-rounded'
- else
p.image.is-64x64
// no_image.jpgはpublic dir直下に配置
= image_tag '/no_image.jpg', class: 'is-rounded'
.media-content
.content
b
= video_comment.user.username
p
= video_comment.content
.media-right
span.has-text-grey-light
i class="fa fa-comments" Reply
// 返信作成用のform
= form_with(model: Comment.new, url: comments_path, local: true, class: 'control') do |f|
.field.is-horizontal
.field-body
.field
p.control= f.text_area :content, class: 'textarea'
// 登録していないユーザーがコメントを眺めているときなどcurrent_userがいるはずもないのでnilに対するmethod呼び出しエラーが発生しないようにとりあえずさっと&operatorで対応
= f.hidden_field :user_id, value: current_user&.id
= f.hidden_field :video_id, value: @video.id
= f.hidden_field :comment_id, value: video_comment.id
.field
.field-label
.field-body
.field
.control.has-text-centered
= f.submit "Reply to #{video_comment.user.username}", class: 'button is-small is-outlined',
style: 'border-color: #DC5A2F; color: #DC5A2F'
// コメントしたユーザーがコメントを削除できるように
- if video_comment.user == current_user
= link_to t('misc.destroy'), comment_path(video_comment), class: 'button is-small is-danger', method: :delete
// 返信コメントの表示(このloopで子・孫・ひ孫・・・と持ってこれる)
- video_comment.replies.each do |video_comment_reply|
.columns
.column.is-3
.column.is-9
.content
article.post
.media
.media-left
- if video_comment_reply.user.image.attached?
p.image.is-64x64
= image_tag video_comment_reply.user.image, class: 'is-rounded'
- else
p.image.is-64x64
= image_tag '/no_image.jpg', class: 'is-rounded'
.media-content
.content
b
= video_comment_reply.user.username
p
= video_comment_reply.content
.media-right
span.has-text-grey-light
i class="fa fa-comments" Reply
// 返信に対する返信作成用のform
= form_with(model: Comment.new, url: comments_path, local: true, class: 'control') do |f|
.field.is-horizontal
.field-body
.field
p.control= f.text_area :content, class: 'textarea'
= f.hidden_field :user_id, value: current_user&.id
= f.hidden_field :video_id, value: @video.id
= f.hidden_field :comment_id, value: video_comment.id
.field
.field-label
.field-body
.field
.control.has-text-centered
= f.submit "Reply to #{video_comment_reply.user.username}", class: 'button is-small is-outlined',
style: 'border-color: #DC5A2F; color: #DC5A2F'
// 返信したユーザーがコメントを削除できるように
- if video_comment_reply.user == current_user
= link_to t('misc.destroy'), comment_path(video_comment_reply), class: 'button is-small is-danger', method: :delete
- else
p コメントがありません
// コメント作成用のform
= form_with(model: Comment.new, url: comments_path, local: true, class: 'control') do |f|
.field.is-horizontal
.field-label= f.label :content, class: 'label'
.field-body
.field
p.control= f.text_area :content, class: 'textarea'
= f.hidden_field :user_id, value: current_user&.id
= f.hidden_field :video_id, value: @video.id
= f.hidden_field :comment_id, value: nil
.field
.field-label
.field-body
.field
.control.has-text-centered
= f.submit nil, class: 'button is-large is-outlined',
style: 'border-color: #DC5A2F; color: #DC5A2F'
スタイリングにwebpackerにのせてbulma.cssを使用しているのでいろいろとclass名がごちゃごちゃしていますが、formの部分だけに注目ください。(興味のある方はちょっと独力でお願いいたします )
まとめ
1ヶ月ほど前でしょうか?Action Textなるもの(github:rails/actiontext, youtube:Alpha preview: Action Text for Rails 6)がでましたよね。コメントの実装ついでに試してみるのもよいかもしれません。乗るしかないこのRails wayに! 質問・ご意見等あればコメントお願いいたします。