[Rails]ポリモーフィック関連付け①:コメント
はじめに
投稿タイプを増やしてIdea
モデルを作りたいと思います。
Idea
にもコメント機能を入れていきたいですが、モデルごとにコメント機能を作るのがテーブルやコードの重複につながります。
複数のモデルに紐つけるような仕組みポリモーフィック関連付けがあります。
Comment
モデルがIdea
モデルとArticle
モデルの両方と関連付けられる場合、ポリモーフィックアソシエーションを使用して1つのcommentsテーブルを介して関連付けを行うことができます。
Comment
モデルをポリモーフィック関連付け(Polymorphic Association)を持たせて実装していきます。
環境
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
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) =============================
関連付けを設定する
class Idea < ApplicationRecord
belongs_to :user
validates :title, presence: true
end
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
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_type
とcommentable_id
が追加されました。
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
モデルのアソシエーションを設定する
class Comment < ApplicationRecord
belongs_to :user
belongs_to :commentable, polymorphic: true
end
class Article < ApplicationRecord
has_many :comments, as: :commentable, dependent: :destroy
end
class Idea < ApplicationRecord
belongs_to :user
has_many :comments, as: :commentable, dependent: :destroy
validates :title, presence: true
end
class User < ApplicationRecord
has_many :articles, dependent: :destroy
has_many :ideas, dependent: :destroy
has_many :comments, dependent: :destroy
end
routes.rb
を編集する
コメント用のurlを追加します。
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コントローラーを作成する
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|
: リクエストのパラメータをイテレートして、name
とvalue
を取得します。
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_type
やcommentable_id
といった形式の文字列から親モデルの情報を抽出するために使用されす。
return $1.classify.constantize.find(value)
: 親モデルのクラス名を$1
(xxx)から取得し、classify
メソッドを使用してクラス名をキャメルケース化します。そして、constantize
メソッドを使用して文字列からクラスオブジェクトに変換し、find(value)
で親モデルのIDに該当するレコードを取得しています。
ArticleとIdeaの詳細ページにコメントフォームを入れたいのでそれぞれのコントローラーに@comment
を渡します。
@comment
を渡す
コントローラーに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
を渡します。
詳細ページに入れるコメントフォームとコメントビューを作成していきます。
コメントフォームビュー
<%= 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
コメントビュー
<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
が用意しているのでそれを使います。
コメントフォームとコメントを読み込む
<h2 class="mt-3">コメントフォーム</h2>
<%= render 'comments/form', { commentable: @idea, comment: @comment } %>
<br>
<%= render 'comments/comments', { comments: @comments } %>
Articlesの詳細ページにも読み込みます。
コメント作成、削除のajax化
コメントの作成と削除を行うたびにページをリロードされますが、コメントの部分だけ更新させることができます。
ajaxを使うにはcreate.js.erb
とdestroy.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 %>
})();
var commentElement = document.getElementById('comment-<%= @comment.id %>');
if (commentElement) {
commentElement.parentNode.removeChild(commentElement);
}
また、rails7ではturboによってjavascriptを書かずにajaxを効かせるようになっています。
こちらの記事を参考にしてみてください。
コメントの作成と削除を確かめます。
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
モデルがポリモーフィックに関連付けられる子モデルであり、Article
とIdea
がポリモーフィック関連付けの親モデルとなります。
ポリモーフィック関連を用いて新しい親モデルを作成する際に簡単にコメントを持つようになりますね。
コメント以外にも活用していきたいと思います。
Discussion