Railsで自己参照 self-reference を使いコメント＆返信機能を実装する ~modelからviewまで~

最近RORのプロジェクトでコメント機能を実装する機会があり、自己参照（SQLでの自己結合）のロジックを作ったのですが、思いの外詳細に記述してくれている記事がなく、これから実装の機会がある誰かにとって参考になればと思いました。

# TL;DR
Railsで自己参照を用い、コメント機能を実装する。

完成図
<img width="908" alt="スクリーンショット 2018-11-14 23.21.21.png" src="https://qiita-image-store.s3.amazonaws.com/0/214860/f9461858-44a3-dc2f-21fa-58a3c19989ca.png">

## model
ER図
<img width="368" alt="スクリーンショット 2018-11-11 20.13.51.png" src="https://qiita-image-store.s3.amazonaws.com/0/214860/d71313bc-f9f2-4b45-588b-a254d97e6875.png">
(DBeaver早速使ってます、めちゃいいです! ref:[TeamSQL もいいけど、 DBeaver もいいぞ](https://qiita.com/nanasess/items/609c7cda4adee344221c))

このように親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](https://github.com/ctran/annotate_models)）をくっつけて（annotationは不要ならしなくてもOK）
さらにちょっと手直しして（下で説明します!）、、、

```ruby:comment.rb
# == 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との親子関係はご覧の通りかと思います。ここで２点、

- `has_many :replies　, class_name: 'Comment', foreign_key: 'comment_id', dependent: :destroy` について
  - 自己の子供をreplyと名付け、class_name、 foreign_keyとともに記述しています。`dependent: :destroy`　は親のコメントが削除された際、その返信も削除されたほうがいいよね、ということでつけてます。

- `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になる](https://qiita.com/iguchi1124/items/218e35a145f372062ea4))

これで

```
> 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を定義して、あれこれやって（下で説明します!）、、、

```ruby:comments_controller.rb
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](https://github.com/plataformatec/devise)で用意されているメソッドです。`only:`のあとにホワイトリストでactionのsymbolを指定しています。
- `before_action :set_comment, only: :destroy`について
  - #destroyの前に対象のcommentを取得する`before_action`callbackを設定してます。`set_comment`はprivateメソッドで定義してます。
- `notice: I18n.t('activerecord.flash.comment.actions.create.success')`や`alert: I18n.t('activerecord.flash.comment.actions.create.failure')`について
  - i18n(internationalization)の設定です。ref:[rails-i18n](https://github.com/svenfuchs/rails-i18n)。ここでは触れないですが例えばこんなyamlファイルを読み込んで、`コメントの登録を完了しました。`や`入力を修正してください。`が表示できるようになります。

```yaml:config/locales/models/comment.ja.yml
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ページにリダイレクトするように設定しています。
- `# POST /comments`などについて
  - 前バイト先で書く習慣があってそれを僕が真似続けているものです。なんかrest　resourceを意識できていいなと思ったので書いてます。必要はないです。
- `comment_params`について
  - strong parameterに外部キーの`:comment_id`を入れるのを忘れないでください!

これでcontrollerの実装は終了です。

## routes
```ruby:routes.rb
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`のみで実装してます!

```slim:videos/show.html.slim
~~
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](https://github.com/jgthms/bulma)を使用しているのでいろいろとclass名がごちゃごちゃしていますが、formの部分だけに注目ください。(興味のある方はちょっと独力でお願いいたします :bow: )

### まとめ
1ヶ月ほど前でしょうか？[Action Textなるもの](https://weblog.rubyonrails.org/2018/10/3/introducing-action-text-for-rails-6/)(github:[rails/actiontext](https://github.com/rails/actiontext), youtube:[Alpha preview: Action Text for Rails 6](https://youtu.be/HJZ9TnKrt7Q))がでましたよね。コメントの実装ついでに試してみるのもよいかもしれません。乗るしかないこのRails wayに! 質問・ご意見等あればコメントお願いいたします。

### references
- [Active Record の関連付け (アソシエーション)](https://railsguides.jp/association_basics.html)
- [form_forとform_tagのform_withへの統合](https://railsguides.jp/5_1_release_notes.html#form-for%E3%81%A8form-tag%E3%81%AEform-with%E3%81%B8%E3%81%AE%E7%B5%B1%E5%90%88)
- [自己関連(自己結合) – おじさんたちの反乱](https://www.c-and-d.org/model_self_relation/)