🦓

[Rails]ポリモーフィック関連付け①:コメント

2023/07/19に公開

はじめに

投稿タイプを増やしてIdeaモデルを作りたいと思います。
Ideaにもコメント機能を入れていきたいですが、モデルごとにコメント機能を作るのがテーブルやコードの重複につながります。
複数のモデルに紐つけるような仕組みポリモーフィック関連付けがあります。

CommentモデルがIdeaモデルとArticleモデルの両方と関連付けられる場合、ポリモーフィックアソシエーションを使用して1つのcommentsテーブルを介して関連付けを行うことができます。
Commentモデルをポリモーフィック関連付け(Polymorphic Association)を持たせて実装していきます。

https://railsguides.jp/association_basics.html#ポリモーフィック関連付け

環境

Rails 7.0.4
ruby 3.2.2

ポリモーフィック関連付けとは

ポリモーフィックアソシエーション(Polymorphic Association)は、データベースのリレーショナルモデルにおいて、1つのアソシエーション(関連付け)を複数のモデル間で共有する方法です。ポリモーフィックアソシエーションを使用すると、1つのテーブルの中で異なるモデルとの関連付けを行うことができます。

多対多の関連付け: モデル間で多対多の関係があり、中間テーブルを介して関連付ける必要がある場合、ポリモーフィックアソシエーションを使用することができます。

関連付けるモデルを識別するためにポリモーフィックキー(Polymorphic Key)と呼ばれるカラムを使用します。このキーは関連付けられるモデルのタイプ(クラス名)とIDを格納します。これにより、関連付けの対象となるモデルが一意に識別されます。

Ideaモデルを作成する

bin/rails generate model Idea title:string raw_contnet:text user:references 
      invoke  active_record
      create    db/migrate/20230719033518_create_ideas.rb
      create    app/models/idea.rb
db/migrate/xxx_create_ideas.rb
class CreateIdeas < ActiveRecord::Migration[7.0]
  def change
    create_table :ideas do |t|
      t.string :title, null: false
      t.text :raw_contnet
      t.references :user, null: false, foreign_key: true

      t.timestamps
    end
  end
end
== 20230719033518 CreateIdeas: migrating ======================================
-- create_table(:ideas)
   -> 0.0032s
== 20230719033518 CreateIdeas: migrated (0.0033s) =============================

関連付けを設定する

app/models/idea.rb
class Idea < ApplicationRecord
  belongs_to :user

  validates :title, presence: true
end
app/models/user.rb
class User < ApplicationRecord
  has_many :ideas, dependent: :destroy
end

IdeaのCRUDを作成する

Ideaのリソース、コントローラーとビューを作成していきます。

article_idカラムを削除する

Articleモデルと関連付けしてましたが削除します。

bin/rails generate migration RemoveArticleIdFromComments article_id:integer
   invoke  active_record
   create    db/migrate/20230719064810_remove_article_id_from_comments.rb
bin/rails db:migrate
== 20230719064810 RemoveArticleIdFromComments: migrating ======================
-- remove_column(:comments, :article_id, :integer)
   -> 0.0147s
== 20230719064810 RemoveArticleIdFromComments: migrated (0.0147s) =============

Commentモデルにポリモーフィックアソシエーションを持たせる

Commentモデルがすでにあるのでポリモーフィック関連付けを持つマイグレーションを作成します。

bin/rails generate migration AddCommentableToComments commentable:references{polymorphic}
      invoke  active_record
      create    db/migrate/20230719063423_add_commentable_to_comments.rb
db/migrate/xxx_add_commentable_to_comments.rb
class AddCommentableToComments < ActiveRecord::Migration[7.0]
  def change
    add_reference :comments, :commentable, polymorphic: true, null: false
  end
end
bin/rails db:migrate
== 20230719063423 AddCommentableToComments: migrating =========================
-- add_reference(:comments, :commentable, {:polymorphic=>true, :null=>false})
   -> 0.0294s
== 20230719063423 AddCommentableToComments: migrated (0.0295s) ================

commentable_typecommentable_idが追加されました。

db/schema.rb
  create_table "comments", force: :cascade do |t|
    t.text "body", null: false
    t.integer "user_id", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.string "commentable_type", null: false
    t.integer "commentable_id", null: false
    t.index ["commentable_type", "commentable_id"], name: "index_comments_on_commentable"
    t.index ["user_id"], name: "index_comments_on_user_id"
  end

モデルのアソシエーションを設定する

app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :user
  belongs_to :commentable, polymorphic: true
end
app/models/article.rb
class Article < ApplicationRecord
  has_many :comments, as: :commentable, dependent: :destroy
end
app/models/idea.rb
class Idea < ApplicationRecord
  belongs_to :user
  has_many :comments, as: :commentable, dependent: :destroy

  validates :title, presence: true
end
app/models/user.rb
class User < ApplicationRecord
   has_many :articles, dependent: :destroy
   has_many :ideas, dependent: :destroy
   has_many :comments, dependent: :destroy
end

routes.rbを編集する

コメント用のurlを追加します。

config/routes.rb
Rails.application.routes.draw.do
  resources :articles do
    resources :comments, only: %i[create destroy]
  end

  resources :ideas do
    resources :comments, only: %i[create destroy]
  end
end
article_comments POST     /articles/:article_id/comments(.:format)                                                          comments#create
article_comment DELETE   /articles/:article_id/comments/:id(.:format)                                                      comments#destroy
idea_comments POST     /ideas/:idea_id/comments(.:format)                                                                comments#create
idea_comment DELETE   /ideas/:idea_id/comments/:id(.:format)                                                            comments#destroy

Commentsコントローラーを作成する

app/controllers/comments_controller.rb
class CommentsController < ApplicationController
    before_action :authenticate_user!, only: %i[create destroy]

    def create
        @commentable = find_commentable
        @comment = @commentable.comments.build(comment_params)

      respond_to do |format|
        if @comment.save
          format.js
          format.html { redirect_to @comment.commentable, flash: { success: t('defaults.message.created', item: Comment.model_name.human) } }
        else
          format.js
          format.html { redirect_to @comment.commentable, flash: { danger: t('defaults.message.not_created', item: Comment.model_name.human) } }
        end
      end
    end

    def destroy
      @comment = current_user.comments.find(params[:id])
      respond_to do |format|
        @comment.destroy
          format.js
          format.html { redirect_to @comment.commentable, flash: { success: t('defaults.message.destroyed', item: Comment.model_name.human) } }
      end
    end
    
    private

    def comment_params
        params.require(:comment).permit(:body).merge(user_id: current_user.id)
    end

    def find_commentable
      params.each do |name, value|
        if name =~ /(.+)_id$/
          return $1.classify.constantize.find(value)
        end
      end
      nil
    end
end

find_commentableメソッドは、ポリモーフィック関連付けの親モデル(commentableとなるモデル)を特定するためのメソッドです。このメソッドは、リクエストのパラメータをチェックして親モデルのクラス名とIDを抽出し、最終的に親モデルのインスタンスを返します。

params.each do |name, value|: リクエストのパラメータをイテレートして、namevalueを取得します。

if name =~ /(.+)_id$/: nameが"xxx_id"というパターンにマッチする場合、xxxに対応する親モデルのクラス名とIDを含むパラメータであると判定します。この場合、$1にはxxxに該当する部分が入ります。

=~は正規表現とマッチングするための演算子です。この演算子は、左側の文字列を右側に指定した正規表現パターンとマッチングし、一致する場合は一致した位置(インデックス)を返します。一致しない場合はnilを返します。

正規表現/(.+)_id$/は、文字列の末尾が"_id"で終わるかをチェックし、その前にある文字列をキャプチャします。(.+)は1つ以上の任意の文字に一致するグループを表します。$は文字列の末尾を表します。

name = "article_id"
if name =~ /(.+)_id$/
  puts $1  # "article"
else
  nil
end

このように、=~演算子と正規表現を使うことで、文字列のパターンに一致するかを簡単にチェックできます。ポリモーフィック関連付けの例で登場した/(.+)_id$/は、commentable_typecommentable_idといった形式の文字列から親モデルの情報を抽出するために使用されす。

return $1.classify.constantize.find(value): 親モデルのクラス名を$1(xxx)から取得し、classifyメソッドを使用してクラス名をキャメルケース化します。そして、constantizeメソッドを使用して文字列からクラスオブジェクトに変換し、find(value)で親モデルのIDに該当するレコードを取得しています。

ArticleとIdeaの詳細ページにコメントフォームを入れたいのでそれぞれのコントローラーに@commentを渡します。

コントローラーに@commentを渡す

app/controllers/ideass_controller.rb
class IdeasController < ApplicationController
  before_action :authenticate_user!, only: %i[new create edit update destroy]
  before_action :set_idea, only: %i[show edit update destroy]

  def show
    @comment = Comment.new
    @comments = @idea.comments.includes(:user).order(created_at: :desc)
  end
end

Articlesコントローラーも同じく@commentを渡します。
詳細ページに入れるコメントフォームとコメントビューを作成していきます。

コメントフォームビュー

app/views/comments/_form.html.erb
<%= form_with model: [commentable, comment], local: false, id: "#{dom_id(comment)}_form" do |form| %>
  <% commentable.errors.full_messages.each do |message| %>
    <div class="alert alert-danger alert-dismissible fade show" role="alert">
      <%= message %>
      <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
    </div>
  <% end %>
  <div class="mb-3">
    <%= form.label :body %>
    <%= form.text_area :body, class: 'form-control', id: 'js-new-comment-body' %>
  </div>
  <div class="text-end">
    <%= form.submit class: 'btn btn-primary btn-sm' %>
  </div>
<% end %>

model: [commentable, Comment.new(commentable: commentable)]: modelオプションで指定したモデルオブジェクトがフォームの対象となります。ここでは、commentableオブジェクトと新しいCommentオブジェクトを配列として渡しています。

commentable: ポリモーフィック関連付けを持つコメント(Comment)モデルの親モデルを表します。例えば、commentableがArticleモデルのインスタンスであれば、コメントはArticleに関連付けられることを意味します。これにより、新しいコメントが作成される際に、関連付ける親モデルをフォームに含めることができます。

Comment.new(commentable: commentable): 新しいCommentモデルのインスタンスを作成しています。このとき、関連付ける親モデルをcommentableオブジェクトとして指定しています。これにより、新しいコメントが作成される際に、関連付ける親モデルを指定できるようになります。

irb(main):006:0> @comment = Comment.first
  Comment Load (0.2ms)  SELECT "comments".* FROM "comments" ORDER BY "comments"."id" ASC LIMIT ?  [["LIMIT", 1]]                                                                     
=>                                                                            
#<Comment:0x0000000113f9afb0                                                  
...                                                                           
irb(main):008:0> @comment.commentable
  Article Load (0.5ms)  SELECT "articles".* FROM "articles" WHERE "articles"."id" = ? LIMIT ?  [["id", 20], ["LIMIT", 1]]                                                            
=>                                                                            
#<Article:0x00000001139979d8                                          
 id: 20,                                                              
 title: "タイトル19",                                                 
 body: "本文19",                                                      
 created_at: Sat, 15 Jul 2023 00:12:26.663105000 JST +09:00,          
 updated_at: Sat, 15 Jul 2023 00:12:26.663105000 JST +09:00,          
 user_id: 8,                                                          
 image: nil>   
irb(main):009:0> @comment.commentable_type
=> "Article"
irb(main):010:0> @comment.commentable_id
=> 20

コメントビュー

app/views/comments/_comment.html.erb
<div class="card mb-4" id="comment-<%= comment.id %>">
  <div class="card-body">
    <div class="d-flex justify-content-between mb-3">
      <p><%= comment.body %></p>
      <% if current_user&.id == comment.user_id %>
        <ul class='crud-menu-btn list-inline float-right'>
          <li class="list-inline-item">
            <%= link_to edit_polymorphic_path([comment.commentable, comment]), 
	    id: "button-edit-#{comment.id}" do %>
              <%= icon 'fa', 'pen' %>
            <% end %>
          </li>
          <li class="list-inline-item">
            <%= link_to polymorphic_path([comment.commentable, comment]), 
            class: 'js-delete-comment-button', 
            turbo_method: :delete, 
            id: "button-delete-#{comment.id}", 
            data: { turbo_confirm: t('defaults.message.delete_confirm') }, 
            remote: true do %>
              <%= icon 'fas', 'trash' %>
            <% end %>
          </li>
        </ul>
      <% end %>
    </div>
    <div class="d-flex justify-content-between">
      <div class="d-flex flex-row align-items-center">
        <% if comment.user.profile.present? %>
          <%= image_tag comment.user.profile.thumb,size:'25x25' %>
        <% else %>
          <%= image_tag 'user.png',size:'25x25' %>
        <% end %>
        <p class="small mb-0 ms-2"><%= comment.user.user_name %></p>
      </div>
      <div class="d-flex flex-row align-items-center">
        <p class="small text-muted mb-0"><%= l(comment.created_at, format: :long) %></p>
      </div>
    </div>
  </div>
</div>

railsがポリモーフィック用のurlヘルパーpolymorphic_pathが用意しているのでそれを使います。
https://api.rubyonrails.org/classes/ActionDispatch/Routing/PolymorphicRoutes.html

コメントフォームとコメントを読み込む

app/views/ideas/show.html.erb
<h2 class="mt-3">コメントフォーム</h2>
<%= render 'comments/form', { commentable: @idea, comment: @comment } %>
<br>
<%= render 'comments/comments', { comments: @comments } %>

Articlesの詳細ページにも読み込みます。

コメント作成、削除のajax化

コメントの作成と削除を行うたびにページをリロードされますが、コメントの部分だけ更新させることができます。
ajaxを使うにはcreate.js.erbdestroy.js.erbを作成します。

app/views/comments/create.js.erb
(function() {
// Code within this IIFE will have its own scope
// Clear existing commentErrors and commentTemplate
const commentErrors = document.querySelector('.error_messages');
if (commentErrors) {
commentErrors.remove();
}
<% if @comment.errors.any? %>
  // Handle error case
  const newCommentBody = document.getElementById('new_comment');
  const errorMessages = '<%= j(render('shared/error_messages', object: @comment)) %>';
  newCommentBody.insertAdjacentHTML('afterbegin', errorMessages);
<% else %>
  // Render comment template
  const commentTemplate = '<%= j(render('comments/comment', comment: @comment)) %>';
  const commentContainer = document.getElementById('js-comment-container');
  commentContainer.insertAdjacentHTML('afterbegin', commentTemplate);
  // Clear comment form
  const newCommentBody = document.getElementById('js-new-comment-body');
  newCommentBody.value = '';
<% end %>
})();
app/views/comments/destroy.js.erb
var commentElement = document.getElementById('comment-<%= @comment.id %>');
if (commentElement) {
commentElement.parentNode.removeChild(commentElement);
}

また、rails7ではturboによってjavascriptを書かずにajaxを効かせるようになっています。
こちらの記事を参考にしてみてください。
https://zenn.dev/redheadchloe/articles/0bb87cda39c952

コメントの作成と削除を確かめます。

Started POST "/articles/20/comments" for ::1 at 2023-07-19 18:15:03 +0900
Processing by CommentsController#create as JS
  Parameters: {"authenticity_token"=>"[FILTERED]", "comment"=>{"body"=>"コメント"}, "commit"=>"登録する", "article_id"=>"20"}
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? ORDER BY "users"."id" ASC LIMIT ?  [["id", 15], ["LIMIT", 1]]
  Article Load (0.4ms)  SELECT "articles".* FROM "articles" WHERE "articles"."id" = ? LIMIT ?  [["id", 20], ["LIMIT", 1]]
  ↳ app/controllers/comments_controller.rb:36:in `block in find_commentable'
  TRANSACTION (0.0ms)  begin transaction
  ↳ app/controllers/comments_controller.rb:8:in `block in create'
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 15], ["LIMIT", 1]]
  ↳ app/controllers/comments_controller.rb:8:in `block in create'
  Comment Create (0.3ms)  INSERT INTO "comments" ("body", "user_id", "created_at", "updated_at", "commentable_type", "commentable_id") VALUES (?, ?, ?, ?, ?, ?)  [["body", "コメント"], ["user_id", 15], ["created_at", "2023-07-19 18:15:04.248954"], ["updated_at", "2023-07-19 18:15:04.248954"], ["commentable_type", "Article"], ["commentable_id", 20]]
  ↳ app/controllers/comments_controller.rb:8:in `block in create'
  TRANSACTION (0.7ms)  commit transaction
  ↳ app/controllers/comments_controller.rb:8:in `block in create'
  Rendering comments/create.js.erb
  Rendered comments/_comment.html.erb (Duration: 2.0ms | Allocations: 1060)
  Rendered comments/create.js.erb (Duration: 2.2ms | Allocations: 1369)
Completed 200 OK in 31ms (Views: 3.0ms | ActiveRecord: 2.1ms | Allocations: 24518)
Started DELETE "/ideas/1/comments/3" for ::1 at 2023-07-19 21:48:57 +0900
Processing by CommentsController#destroy as TURBO_STREAM
  Parameters: {"idea_id"=>"1", "id"=>"3"}
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? ORDER BY "users"."id" ASC LIMIT ?  [["id", 15], ["LIMIT", 1]]
  Comment Load (0.1ms)  SELECT "comments".* FROM "comments" WHERE "comments"."user_id" = ? AND "comments"."id" = ? LIMIT ?  [["user_id", 15], ["id", 3], ["LIMIT", 1]]
  ↳ app/controllers/comments_controller.rb:20:in `destroy'
  TRANSACTION (0.0ms)  begin transaction
  ↳ app/controllers/comments_controller.rb:22:in `block in destroy'
  Comment Destroy (0.3ms)  DELETE FROM "comments" WHERE "comments"."id" = ?  [["id", 3]]
  ↳ app/controllers/comments_controller.rb:22:in `block in destroy'
  TRANSACTION (1.0ms)  commit transaction
  ↳ app/controllers/comments_controller.rb:22:in `block in destroy'
  Idea Load (0.1ms)  SELECT "ideas".* FROM "ideas" WHERE "ideas"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/controllers/comments_controller.rb:24:in `block (2 levels) in destroy'
Redirected to http://localhost:3000/ideas/1
Completed 302 Found in 11ms (ActiveRecord: 1.6ms | Allocations: 7017)

おわり

ポリモーフィック関連付けを使うと、ある1つのモデルが他の複数のモデルに属していることを、1つの関連付けだけで表現できます。

ポリモーフィック関連付けを持つ場合は、Commentモデルがポリモーフィックに関連付けられる子モデルであり、ArticleIdeaがポリモーフィック関連付けの親モデルとなります。

ポリモーフィック関連を用いて新しい親モデルを作成する際に簡単にコメントを持つようになりますね。
コメント以外にも活用していきたいと思います。

Discussion