コメントに対する返信機能 モデルの親子関係を自己結合で実装

この記事はプログラミング学習者がアプリ開発中に躓いた内容を備忘録として記事におこしたものです。内容に不備などあればご指摘頂けると助かります。

## 記事投稿の背景
ここではXのクローンサイトを作成中に実装した機能についてご紹介します。投稿に対してコメントをする機能までは実装していたのですが、コメントに対する返信機能を実装する時に実装方法が分からずに調べた内容となります。

## 実装したコードの紹介と解説
```ruby:マイグレーションファイル
class AddParentIdToComments < ActiveRecord::Migration[7.0]
  def change
    add_reference :comments, :parent, foreign_key: { to_table: :comments }
  end
end
```
- `parent_id`というカラムをcommentsテーブルに追加します。
- `foreign_key: {to_table: :comments}`の部分でこのカラムにforeign_keyとしての制約を与え、そのキーがcommentsテーブルのidカラムを参照するようにします。

```ruby:comment.rb
class Comment < ApplicationRecord
  belongs_to :user
  belongs_to :tweet
  belongs_to :parent, class_name: 'Comment', optional: true # 親コメント(返信先) Nillも許容
  has_many :replies, class_name: 'Comment', foreign_key: 'parent_id', dependent: :destroy # 返信
  validates :sentence, presence: true, length: { in: 1..140 }
  has_many_attached :images
end
```
- `belongs_to :parent, class: 'Comment', optional: true`では
Commentモデルが他のComment(親コメント)に属することを示しています。

`belongs_to :parent`の部分でCommentモデルには`parent_id`というカラムが存在し、このカラムが親コメント(返信先)を指しています。`:parent`は`parent_id`カラムを使用して関連付けを行うためのエイリアスです。実際にbelongs_toで関連付けされるのは`parent_id`というカラムでこのカラムに親コメントの`id`が入ります。

`class_name: 'Comment'`を入れることにより、`:parent`がCommentモデルを指していることを表しています。

`optional: true`では`parent`(親コメント)が存在しなくても(`parent_id`がnilでも)保存できることを意味しています。トップレベルのコメントは親コメントを持たないため、`parent_id`がnilでも保存できるようにしています。

- `has_many :replies, class_name: 'Comment', foreign_key: 'parent_id', dependent: :destroy` ではCommentモデルが複数の返信(コメント)を持つことを示しています。

`has_many :replies`の部分ではCommentモデルが複数の`replies`(返信)を持つという関連付けをしています。ここでの`replies`はCommentモデルの別名(エイリアス)として使われています。但し、実際には`replies`というカラムはcommentsテーブルには存在しません。
`replies`はあくまで「Commentの集合」であり、データベースの集合ではありません。実際の関連付けは、`parent_id`を通じて行われます。`replies`は親コメントに`parent_id`を通して関連づけられたコメント(返信)の集合を意味しています。

`class_name: 'Comment'`では`replies`がCommentモデルのインスタンスであることを意味しています。

`foreign_key: 'parent_id`では`parent_id`という外部キーを使って関連付けを行います。あるコメントが他のコメントに対する返信の場合、そのコメントの`parent_id`には親コメントの`id`が入ります。


```ruby:comments_controller.rb
  def new
    @comment = Comment.new
  end

  def create
    @comment = Comment.new(comment_params)
    @comment.user_id = current_user.id
    @comment.tweet_id = params[:tweet_id]
    @comment.parent_id = params[:id] if params[:id].present? && Comment.exists?(params[:id]) # 親コメントに対して返信する場合
    if @comment.save
      redirect_to home_index_path, notice: 'コメントを投稿しました。'
    else
      flash.now[:alert] = 'コンテンツの投稿に失敗しました。'
      @tweets = Tweet.all.includes(:user,
                                   { images_attachments: :blob }).order(created_at: 'DESC').page(params[:recommend])
      @followers_tweets = Tweet.includes(:user,
                                         { images_attachments: :blob }).where(user_id: current_user.followers.pluck(:user_id)).page(params[:follow])
      render template: 'home/index', status: :unprocessable_entity
    end
  end

  private

  def comment_params
    params.require(:comment).permit(:sentence, images: [])
  end
```
`@comment.parent_id = params[:id] if params[:id].present? && Comment.exists?(params[:id])`の所で`params[:id]が取得できる場合は親コメントが存在するという認識で`parent_id`に値を入れるようにしています。

```ruby:show.html.slim
.comment
  = form_with model: @comment, scope: :comment, url: tweet_comments_path(@tweet, id: @comment.id), local: true, data: { turbo: false }, method: 'post' do |f|
    ul
      li
        - if user_signed_in?
          - if current_user.icon.attached?
            = link_to image_tag(current_user.icon, alt: "Icon image", class: "icon_image", size: '50x50'), user_path(current_user.id)
      li
        .form-group
          = f.text_field :sentence, placeholder: '返信をポスト', class: "form-group form-control", style: "border: none", value: ''
    = f.label :images do
      = image_tag('photo.png', size: '35x35', class: 'image')
      = f.file_field :images, style: 'display:none', multiple: true
    end
    = f.submit '返信', class: 'reply btn btn-primary'
  hr
```
`form-with`ヘルパーの所でurlを`tweet_comments_path(@tweet, id: @comment.id)としています。
これにより`form_with`ヘルパーより値を受け取る時、paramsにtweet(投稿)のidとcommentのidを取得して、controllerのcreateメソッドで必要な値を準備できます。

最後まで読んで頂き有難うございます。
同じ様なところで躓いた方の一助になれば幸いです。

## 今回参考にしたサイト
[Railsでコメントに対する返信機能を実装した！](https://qiita.com/rockguitar67/items/4fd28cba0c243a8d0ba5?utm_content=bufferca093&utm_medium=social&utm_source=twitter.com&utm_campaign=buffer)

[【Ruby on Rails】双方向関連付けを宣言するinverse_of](https://qiita.com/trafford_777/items/27f1c0b2681f57c0e531)