【Rails】タグ付け機能を実装する

皆様こんにちは、もしくははじめまして。IDEです。
今回の記事は、タグ付け機能について書いていきます。
タグ付け機能については検索すると記事がたくさん出てきますが、この記事では私が理解するのに時間がかかった部分を添えながら書いていきたいと思います。
コードをたくさん載せているのでかなり長文の記事になってしまっていますが、お付き合いいただける方はこのまま読み進めていただければと思います。

目次

  1. 機能実装の目的・背景
  2. 実際の挙動
  3. サンプルコード
    ・DB設計
    ・マイグレーションファイル
    ・モデル
    ・Formオブジェクト
    ・コントローラー
    ・ビュー
  4. 個人的ひっかかったポイント
  5. 今後の課題
  6. まとめ
  7. 参考記事

機能実装の目的・背景

  • 目的:投稿機能にタグを設定し、ユーザーがキーワード検索した際に関連の投稿を探しやすくするため
  • 作成の背景:肉、魚など、特定のワードを設定することで投稿をより探しやすくするために実装したかったと思ったことがきっかけです。
    ただ、セレクトボックスなどで運営側でカテゴリーを設定してしまうと、ユーザーの欲しいカテゴリーになかったり、使いたいワードがたくさんあっても本文に盛り込みきれなかったりする場合が考えられました。
    タグ機能はユーザーごとに好きなワードに設定できること、単語単位で設定できるので本文に使いたいワードを盛り込めなくてもタグで設定できることができます。
    実装したいことが一番解決できる方法がタグ機能だったので、今回の実装に至りました。

実際の挙動

タグは,で区切って投稿が可能です。 Image from Gyazo 設定したタグは、詳細ページの下部に表示されます。 Image from Gyazo タグは編集することも可能です Image from Gyazo


サンプルコード

①DB設計

f:id:programmingnuoh:20210606193336p:plain 多対多の関係になるため、中間テーブルを作成しました。

②マイグレーションファイル

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

一つ一つのメソッドについて説明していきますね!

  • include ActiveModel::Model…Formオブジェクトは手動でモデルを作成するため、rails gで生成したモデルと違い、そのままでは何のメソッドも使えません。
    ActiveModel::Modelincludeすることで、form_withrenderが使えるようになったり、バリデーションが設定できるようになります。

  • attr_accessor…記述したクラスにゲッター(取得するメソッド)とセッター(更新するメソッド)を設定してくれます。モデルに対応するテーブルのカラム以外のカラム(属性)を使いたい時にこのメソッドを使用していきます。

  • delegate :persisted?, to: :delidelegateは変数の定義を簡単に記述できるメソッド(という認識です)。今回は後半にto: :deliという記述をしているので、deliモデルに関しての記述という分けですね。また、persisted?は保存済みかチェックするメソッドです。(Formオブジェクト内最後に定義されています。コメントアウトされているので意味ないですが…)
    つまり、この記述ではdeliが保存されているかいないかでcreateアクションかupdateアクションのどちらを実行すべきかを判断してくれます。このメソッドは、to_modelと一緒に使っていきます。
    delegateについてはコチラのサイトを参考にいたしました。
    persisted?についてはコチラを参考にいたしました。

  • to_model…記述順と異なりますが、上記delegateと関連があるので先に記載していきます。これはモデルであるために定義するメソッド。delegateで判断した値の有無によってHTTPメソッドを切り替えてくれます。
    しかし見ていただくとわかるのですが…コメントアウトしているので現在は発動できてません😭😭
    実装当初はこのメソッドについてが作動していなかった(と思っている)のでビューに直接HTTPメソッドについて記述しています。(後述ビューセクションをご覧ください)

  • initialize…Classを生成(new)したと同時に発動するメソッドですね。ここでは、attributesnilなら新規生成を、すでに値があるならその値を代入するようにしています。(コントローラー内のupdateアクション内の引数でdeli: @deliと記述することで、探した@deli情報を引き出せているのですね。)
    default_attributesで実際の値をattributes内に値を代入しています。
    superメソッドを使用することで、前述のattributesを上書き(オーバーライド)することができます。
    superについてはコチラを参考にいたしました。

  • attr_readerattr_accessorと異なり、読み取りだけをすることができるメソッドです。後述のdefault_attributes用に読み込みます。

  • default_attributesattr_readerで読み込んだdeliモデル及びtagモデルの情報を入れていきます。これを先のinitialize内で定義していくわけですね。
    ちなみに、現状imageカラムについては読み込めていません。なので編集の際に再度画像アップロードをしなければならない、大変面倒臭い仕様になってしまっています。ここは改善していかないと…。

  • save(tag_list)…今回の機能実装ではsaveメソッドとupdateメソッドを分けて記述しました。なぜかというと、私の理解が追いついていないため、まずは一つ一つのメソッドで何をすべきかというの把握したかったためです。冗長なコードですが理解してきたらここの記述もまとめて可読性を高めたいと思います。
    さて、このsaveメソッドではDeliモデル及びDeliTagRelationモデルへの保存、Tagモデルはfind_or_create_byを使ってタグの新規登録及び該当タグの探し出しを行っています。引数のtag_listは、コントローラーでsplitを用いて配列と化したものを設定しています。

  • update(tag_list)…まず、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で探し出しもしくはタグ生成を行う。タグを投稿情報に入れたら、中間テーブルを更新。

  • destroy…削除するためのメソッド。Deliモデル内で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で定義しています。そちらも前提におきながら読み進めていただけたらと思います。

  • create…通常の投稿機能と違う点はtag_listの部分ですかね。今回は,で区切ることで複数のタグを設定できるようにしたので、コントローラー内でsplitを用いて配列に変換しています。

  • edit…該当のDeli情報を見つけた後、DelisTag.new(deli:@deli)することですでにある情報をFormオブジェクト内のattributesに代入出来ます。
    @tag_listはビューの編集ページで現状のタグ情報を表示するために取得しています。

  • updatedeli_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は複数のモデルを扱えるのですが、今回のタグ込みで投稿を編集しようとすると、エラーになってしまいます。

  • モデルにDeli情報を定義→値の保存ができなくなる
  • 値保存のための変数を定義→Deli情報を持ち出せない(text_fieldにはvalueで設定することで代入が可能でしたが、ラジオボタンやセレクトボックスの情報が持って来れませんでした。)

    上記の問題が発生するため、Formオブジェクト内で事前に読み込むことが必要だったのですね。


    今後の課題

    今後の課題は以下の2点です。

    • 編集機能でimageカラムの情報を取得すること
    • 可読性を高めること

    一つ目はいろいろ試しましたが未だ解決方法が見つかっていない課題です。attachメソッドを使う?定義するところはモデルなのかコントローラーなのか?今は視野がどうしても狭くなってしまっているので、少し距離を置いて頭を冷やして再チャレンジしていきます。
    二つ目はFormオブジェクト内の記述がメインになるのですが、コメントアウトしてしまっている必要な記述が残っています。これらのメソッドの挙動を確認して、より簡潔なコーディングを目指していきます。


    まとめ

    今回はFormオブジェクトを利用してタグ付け機能を実装していきました。
    フレームワークの脛をかじりまくっている私にとってはFormオブジェクトが大変難しい。
    今回それぞれの挙動をするにあたって、どの情報が必要なのか、どのメソッドがどこに関わっているのかなど細かく分析するように心がけました。
    これは全体を通して言えることだと思うので、アプリケーション内でどれが、何を、何のためにを意識して今後もコーディングしていきたいと思います。
    今回は機能のボリューム感、そしてコードが冗長になってしまっていることから記事が非常に長くなってしまいました。
    最後までお付き合いいただいた方、ありがとうございました。


    参考記事

    今回の記事を書くに当たり、以下の記事を参考にさせていただきました。
    ありがとうございました。
    タグ付け機能について

  • Railsのデザインパターン: Formオブジェクト
  • [Ruby on Rails] タグ付機能を実装してみた
  • formオブジェクトで複数テーブルへ値の保存
    各メソッドについて
  • Rubyのdelegateについて整理する
  • Railsドキュメント
  • Rubyの継承とオーバーライドについてまとめてみた
  • 【Ruby】 attr_readerメソッドの使い方を基礎から学んで整理しよう
  • 【Rails】 便利なpluckメソッドをマスターしよう!
  • Rails: Form Objectと#to_modelを使ってバリデーションをモデルから分離する(翻訳)