【Rails】タグ付け機能を実装する
皆様こんにちは、もしくははじめまして。IDEです。 今回の記事は、タグ付け機能について書いていきます。 タグ付け機能については検索すると記事がたくさん出てきますが、この記事では私が理解するのに時間がかかった部分を添えながら書いていきたいと思います。 コードをたくさん載せているのでかなり長文の記事になってしまっていますが、お付き合いいただける方はこのまま読み進めていただければと思います。
目次
- 機能実装の目的・背景
- 実際の挙動
- サンプルコード ・DB設計 ・マイグレーションファイル ・モデル ・Formオブジェクト ・コントローラー ・ビュー
- 個人的ひっかかったポイント
- 今後の課題
- まとめ
- 参考記事
機能実装の目的・背景
- 目的:投稿機能にタグを設定し、ユーザーがキーワード検索した際に関連の投稿を探しやすくするため
- 作成の背景:肉、魚など、特定のワードを設定することで投稿をより探しやすくするために実装したかったと思ったことがきっかけです。 ただ、セレクトボックスなどで運営側でカテゴリーを設定してしまうと、ユーザーの欲しいカテゴリーになかったり、使いたいワードがたくさんあっても本文に盛り込みきれなかったりする場合が考えられました。 タグ機能はユーザーごとに好きなワードに設定できること、単語単位で設定できるので本文に使いたいワードを盛り込めなくてもタグで設定できることができます。 実装したいことが一番解決できる方法がタグ機能だったので、今回の実装に至りました。
実際の挙動
タグは,で区切って投稿が可能です。 設定したタグは、詳細ページの下部に表示されます。 タグは編集することも可能です
サンプルコード
①DB設計
多対多の関係になるため、中間テーブルを作成しました。
②マイグレーションファイル
deliモデルのマイグレーションファイル
class CreateDelis < ActiveRecord::Migration[6.0] def change create_table :delis do |t| t.string :name, null:false, default:"" t.text :text, null:false t.integer :supermarket_id, null:false t.references :user, null:false, foreign_key: true t.references :category, null:false, foreign_key: true t.timestamps end end end
deli_tag_relationモデルのマイグレーションファイル
class CreateDeliTagRelations < ActiveRecord::Migration[6.0] def change create_table :deli_tag_relations do |t| t.references :deli, foreign_key: true t.references :tag, foreign_key: true t.timestamps end end end
tagモデルのマイグレーションファイル
class CreateTags < ActiveRecord::Migration[6.0] def change create_table :tags do |t| t.string :tagname, null:false, uniqueness: true t.timestamps end end end
先にマイグレーションの情報から。 投稿(deli)の項目は全て入力して欲しかったのでnull:falseにしています。 tagnameはnameが二つあるとごちゃごちゃしてしまうのでわかりにくいな、と思って別の名前にしました。(今考えたらdeliのnameをtitleとかにすればよかった、、) 中間テーブルは言わずもがな、references型のみの定義です。
③モデル
deli.rb
class Deli < ApplicationRecord belongs_to :user belongs_to :category has_one_attached :image has_many :deli_tag_relations, dependent: :destroy has_many :tags, through: :deli_tag_relations, dependent: :destroy extend ActiveHash::Associations::ActiveRecordExtensions belongs_to_active_hash :supermarket end
deli_tag_relation.rb
class DeliTagRelation < ApplicationRecord belongs_to :deli belongs_to :tag end
tag.rb
class Tag < ApplicationRecord validates :tagname, uniqueness: true has_many :deli_tag_relations has_many :delis, through: :deli_tag_relations end
バリデーション等はこの後に記載するFormオブジェクト内に記述しているので、ここでの各モデルの記述はアソシエーションに関する記述のみです。 ただし、tagに関してのバリデーションはタグの登録をする際にtagnameだけはこちらのモデルに記載しました。 Formオブジェクトにまとめた方がよかったかな?
④Formオブジェクト
delis_tag.rb
class DelisTag include ActiveModel::Model attr_accessor :name, :text, :category_id, :supermarket_id, :image, :tagname, :user_id, :deli_id, :tag_id with_options presence: true do validates :name validates :text validates :supermarket_id end delegate :persisted?, to: :deli def initialize(attributes = nil, deli: Deli.new) @deli = deli attributes ||= default_attributes super(attributes) end def save(tag_list) deli = Deli.create(name: name, text: text, category_id: category_id, supermarket_id: supermarket_id, image:image, user_id:user_id) tag_list.each do |new_tag| deli_tag = Tag.find_or_create_by(tagname: new_tag) deli.tags << deli_tag end DeliTagRelation.create(deli_id: deli.id, tag_id: tag_id) end def update(tag_list) ActiveRecord::Base.transaction do # @deli = Deli.where(id: deli_id) @deli.update(name: name, text: text, category_id: category_id, supermarket_id: supermarket_id, image: image, user_id: user_id) current_tags = @deli.tags.pluck(:tagname) unless @deli.tags.nil? old_tags = current_tags - tag_list new_tags = tag_list - current_tags old_tags.each do |old_name| @deli.tags.delete Tag.find_by(tagname: old_name) end new_tags.each do |new_name| deli_tag = Tag.find_or_create_by(tagname: new_name) @deli.tags << deli_tag deli_tag_relation = DeliTagRelation.where(deli_id: @deli.id, tag_id: deli_tag.id).first_or_initialize deli_tag_relation.update(deli_id: @deli.id, tag_id: deli_tag.id) end end end def destroy form = Deli.where(id: deli_id) form.destroy end # def to_model # deli # end private attr_reader :deli, :tag def default_attributes { name: deli.name, text: deli.text, category_id: deli.category_id, supermarket_id: deli.supermarket_id, # image: deli.image.attach(deli[:image]), tagname: deli.tags.pluck(:tagname).join(',') } end # def persisted? # @deli.persisted? # end end
一つ一つのメソッドについて説明していきますね!
rails g
で生成したモデルと違い、そのままでは何のメソッドも使えません。
ActiveModel::Model
をinclude
することで、form_with
やrender
が使えるようになったり、バリデーションが設定できるようになります。
delegate
は変数の定義を簡単に記述できるメソッド(という認識です)。今回は後半にto: :deli
という記述をしているので、deliモデルに関しての記述という分けですね。また、persisted?
は保存済みかチェックするメソッドです。(Formオブジェクト内最後に定義されています。コメントアウトされているので意味ないですが…)
つまり、この記述ではdeliが保存されているかいないかでcreateアクションかupdateアクションのどちらを実行すべきかを判断してくれます。このメソッドは、to_model
と一緒に使っていきます。
delegateについてはコチラのサイトを参考にいたしました。
persisted?についてはコチラを参考にいたしました。
delegate
と関連があるので先に記載していきます。これはモデルであるために定義するメソッド。delegate
で判断した値の有無によってHTTPメソッドを切り替えてくれます。
しかし見ていただくとわかるのですが…コメントアウトしているので現在は発動できてません😭😭
実装当初はこのメソッドについてが作動していなかった(と思っている)のでビューに直接HTTPメソッドについて記述しています。(後述ビューセクションをご覧ください)
attributes
がnil
なら新規生成を、すでに値があるならその値を代入するようにしています。(コントローラー内のupdateアクション内の引数でdeli: @deli
と記述することで、探した@deli
情報を引き出せているのですね。)
default_attributes
で実際の値をattributes内に値を代入しています。
super
メソッドを使用することで、前述のattributes
を上書き(オーバーライド)することができます。
superについてはコチラを参考にいたしました。
attr_accessor
と異なり、読み取りだけをすることができるメソッドです。後述のdefault_attributes
用に読み込みます。
attr_reader
で読み込んだdeliモデル及びtagモデルの情報を入れていきます。これを先のinitialize内で定義していくわけですね。
ちなみに、現状imageカラムについては読み込めていません。なので編集の際に再度画像アップロードをしなければならない、大変面倒臭い仕様になってしまっています。ここは改善していかないと…。
find_or_create_by
を使ってタグの新規登録及び該当タグの探し出しを行っています。引数のtag_list
は、コントローラーでsplit
を用いて配列と化したものを設定しています。
ActiveRecord::Base.transaction
を定義することによって一連の処理をAll or Nothing状態にしていきます。(処理が失敗したら途中の処理もすべてなかったことにするということですね。)
コメントアウトで@deli = Deli.where(id: deli_id)
と記述していますが、これはupdateのときは該当するモデルの情報を持ってきなさいよ、と自分に対しての念押しコメントアウトです。この動作はinitialize
で出来ているのでここで改めて記述する必要はまったくありません。
Deliモデルへの情報更新、及びタグの情報更新を行っています。タグについては少し複雑になっていますね。
- ①現状のタグ情報を持ってくる(current_tags)
- ②現タグ情報から更新したいタグ情報(tag_list)をマイナスして消したいタグを洗い出す(old_tags)
- ③更新したいタグ情報から現タグ情報を引いてプラスしたいタグを洗い出す(new_tags)
- ④消したいタグを投稿のタグから削除する
- ⑤加えたいタグを投稿のタグに付け加える。ここでもfind_or_create_byで探し出しもしくはタグ生成を行う。タグを投稿情報に入れたら、中間テーブルを更新。
dependent: :destroy
を定義しており、投稿を削除すると関連するTag情報、中間テーブル情報が削除されることになっています。なのでここではどのDeliを消すのかを探し出して消す、という記述のみです。
⑤コントローラー
deli_controller.rb
class DelisController < ApplicationController before_action :deli_find, only: [:show, :edit, :update, :destroy, :confirm] 〜中略〜 def new @form = DelisTag.new end def create @form = DelisTag.new(deli_params) tag_list = params[:delis_tag][:tagname].split(",") if @form.valid? @form.save(tag_list) redirect_to root_path else render :new end end def edit @tag_list = @deli.tags.pluck(:tagname).join(",") @form = DelisTag.new(deli: @deli) end def update @form = DelisTag.new(deli_update_params, deli: @deli) tag_list = params[:delis_tag][:tagname].split(",") if @form.update(tag_list) redirect_to deli_path(@deli.id) else render :edit end end 〜中略〜 def destroy @deli.destroy end 〜中略〜 private def deli_params params.require(:delis_tag).permit(:name, :text, :category_id, :supermarket_id, :image, tagname:[]).merge(user_id: current_user.id) end def deli_update_params params.require(:delis_tag).permit(:name, :text, :category_id, :supermarket_id, :image, :tagname).merge(user_id: current_user.id, deli_id: params[:id]) end def deli_find @deli = Deli.find(params[:id]) end end
上記コードは、今回の機能実装に関連のある記述を抜粋しています。
また、可読性を高めるためにDeli.find
はbefore_actionで定義しています。そちらも前提におきながら読み進めていただけたらと思います。
tag_list
の部分ですかね。今回は,で区切ることで複数のタグを設定できるようにしたので、コントローラー内でsplit
を用いて配列に変換しています。
DelisTag.new(deli:@deli)
することですでにある情報をFormオブジェクト内のattributes
に代入出来ます。
@tag_list
はビューの編集ページで現状のタグ情報を表示するために取得しています。
deli_update_params
で更新したいDeli情報を、deli:@deli
で現状のDeli情報を指定しています。
⑥ビュー
new.html.erb
<%= render "shared/header" %> <div class="deli-new-form"> <%= form_with model: @form, url:delis_path, local: true do |f|%> <div class="field"> <div class="post-label"> <%= f.label :name, "お惣菜名" %> </div> <%= f.text_field :name, class:"deli-form" %> </div> <div class="field"> <div class="post-label"> <%= f.label :text, "説明" %> </div> <%= f.text_area :text, class:"deli-form" %> </div> <div class="field"> <div class="post-label"> <%= f.label :category_id, "カテゴリ" %> </div> <div class="categories-radio"> <div class="category-radio"> <%= f.radio_button :category_id, '1' %> <%= f.label :category_id, '惣菜紹介', value: 1 %> </div> <div class="category-radio"> <%= f.radio_button :category_id, '2' %> <%= f.label :category_id, 'アレンジレシピ', value: 2 %> </div> </div> </div> <div class="field"> <div class="post-label"> <%= f.label :supermarket_id, "購入した店舗" %> </div> <%= f.collection_select(:supermarket_id, Supermarket.all, :id, :name, {prompt:'--------'},{class:"select-box", id:"deli-supermarket"}) %> </div> <div class="field"> <div class="post-label"> <%= f.label :image, "画像" %> </div> <%= f.file_field :image %> </div> <div class="field"> <div class="post-label"> <%= f.label :tagname, "タグネーム" %> </div> <%= f.text_area :tagname, placeholder: ",で区切って入力してください", class:"deli-form" %> </div> <div class="actions"> <%= f.submit "投稿する", class:"form__btn" %> </div> <% end %> </div> <%= render "shared/footer" %>
edit.html.erb
<%= render "shared/header" %> <div class="deli-new-form"> <%= form_with model: @form, url: deli_path, method: :patch, local: true do |f|%> <div class="field"> <div class="post-label"> <%= f.label :name, "お惣菜名" %> </div> <%= f.text_field :name, value: @deli.name, class:"deli-form" %> </div> <div class="field"> <div class="post-label"> <%= f.label :text, "説明" %> </div> <%= f.text_area :text, value: @deli.text, class:"deli-form" %> </div> <div class="field"> <div class="post-label"> <%= f.label :category_id, "カテゴリ" %> </div> <div class="categories-radio"> <div class="category-radio"> <%= f.radio_button :category_id, '1' %> <%= f.label :category_id, '惣菜紹介', value: 1 %> </div> <div class="category-radio"> <%= f.radio_button :category_id, '2' %> <%= f.label :category_id, 'アレンジレシピ', value: 2 %> </div> </div> </div> <div class="field"> <div class="post-label"> <%= f.label :supermarket_id, "購入した店舗" %> </div> <%= f.collection_select(:supermarket_id, Supermarket.all, :id, :name, {prompt:'--------'},{class:"select-box", id:"deli-supermarket", value:@deli.supermarket_id}) %> </div> <div class="field"> <div class="post-label"> <%= f.label :image, "画像(もう一度アップロードをお願いします)" %> </div> <%= f.file_field :image %> <%# <%= image_tag @deli.image %> </div> <div class="field"> <div class="post-label"> <%= f.label :tagname, "タグネーム" %> </div> <%= f.text_area :tagname, value: @tag_list, placeholder: ",で区切って入力してください", class:"deli-form" %> </div> <div class="actions"> <%= f.submit "投稿する", class:"form__btn", id:"form__btn" %> </div> <% end %> </div> <%= render "shared/footer" %>
ほぼ同じ内容の記述なのですが、form_with
内だけ少し違っています。
まずはurlの指定を変えていること。もう一つはedit.html.erb内のみmethod: :patch
を指定していること。
Formオブジェクト内にメソッドを定義すれば部分テンプレートを使用することができるのですが、現状このような状態になってしまっています。
これも改善の必要がありますね。
個人的引っかかったポイント
今回疑問に思ったのが、なぜFormオブジェクト内でinitialize
メソッドを用いて値の生成をわざわざするのか?ということです。
編集機能でDeli情報は事前にfind
しているのでform_with
でそのまま定義すればいいのではないか??と。
結論としては、form_with
では原則モデルの定義は一つまでのためです。
正確にはform_with
は複数のモデルを扱えるのですが、今回のタグ込みで投稿を編集しようとすると、エラーになってしまいます。
text_field
にはvalue
で設定することで代入が可能でしたが、ラジオボタンやセレクトボックスの情報が持って来れませんでした。)
上記の問題が発生するため、Formオブジェクト内で事前に読み込むことが必要だったのですね。
今後の課題
今後の課題は以下の2点です。
- 編集機能でimageカラムの情報を取得すること
- 可読性を高めること
一つ目はいろいろ試しましたが未だ解決方法が見つかっていない課題です。attachメソッドを使う?定義するところはモデルなのかコントローラーなのか?今は視野がどうしても狭くなってしまっているので、少し距離を置いて頭を冷やして再チャレンジしていきます。 二つ目はFormオブジェクト内の記述がメインになるのですが、コメントアウトしてしまっている必要な記述が残っています。これらのメソッドの挙動を確認して、より簡潔なコーディングを目指していきます。
まとめ
今回はFormオブジェクトを利用してタグ付け機能を実装していきました。 フレームワークの脛をかじりまくっている私にとってはFormオブジェクトが大変難しい。 今回それぞれの挙動をするにあたって、どの情報が必要なのか、どのメソッドがどこに関わっているのかなど細かく分析するように心がけました。 これは全体を通して言えることだと思うので、アプリケーション内でどれが、何を、何のためにを意識して今後もコーディングしていきたいと思います。 今回は機能のボリューム感、そしてコードが冗長になってしまっていることから記事が非常に長くなってしまいました。 最後までお付き合いいただいた方、ありがとうございました。
参考記事
今回の記事を書くに当たり、以下の記事を参考にさせていただきました。 ありがとうございました。 タグ付け機能について
#to_model
を使ってバリデーションをモデルから分離する(翻訳)