👻

【Rails】検索タグ機能の実装 多対多

2022/02/06に公開

要件

  • 募集(job)と検索タグ(search_tag)は多対多の関係
  • モデルバリデーション
    • 重複したタグNG
    • 空白登録NG
    • 募集に同じタグを複数設定NG
  • 募集(job)に検索タグ(search_tag)を設定
    • 検索タグ(search_tag)は複数設定可能
    • 募集作成画面からタグを設定することが出来る
    • 募集編集画面からタグを編集することが出来る
  • 登録時のバリデーション
    • DB(search_tag)に重複したタグNG(既にある場合は既存のものを使用)
    • DB(search_tag)に空白登録NG
    • 募集(job)に同じタグを複数設定NG
    • 英語は全て小文字で登録
      • DB登録時の表記揺れをなくすため(jAVa、javAなど)
  • 検索タグ登録用のフォーム入力時、DBに存在するタグを予測変換

最低限の機能はこれぐらい。

テーブル作成

  1. 検索タグ(search_tag)テーブルを作成
    • カラム:nameのみ
  2. 多対多なので中間テーブル(search_tag_bind_job)の作成
    • カラム:  job_idsearch_tag_id
  3. 募集(job)テーブルの作成は割愛

検索タグ(search_tag)のマイグレーションファイル

db/migrate/XXXX_create_search_tags.rb
class CreateSearchTags < ActiveRecord::Migration[6.0]
  def change
    create_table :search_tags do |t|
      t.string :name
      t.timestamps
    end
  end
end

検索タグ(search_tag)のモデルファイル

app/models/search_tag
class SearchTag < ApplicationRecord
  # search_tag削除時、中間テーブルも削除
  has_many :search_tag_bind_jobs, dependent: :destroy
  has_many :jobs, through: :search_tag_bind_jobs
  # 空白登録はNG
  validates :name, presence: true
  # 重複登録NG
  validates :name, uniqueness: true
end

中間テーブル(search_tag_bind_job)のマイグレーションファイル

db/migrate/XXXX_create_search_tag_bind_jobs.rb
class CreateSearchTagBindJobs < ActiveRecord::Migration[6.0]
  def change
    create_table :search_tag_bind_jobs do |t|
      t.references :job, null: false, foreign_key: true
      t.references :search_tag, null: false, foreign_key: true

      t.timestamps
    end
    # 募集に同じタグが複数設定出来ない複合ユニークキー制約を設定
    add_index :search_tag_bind_jobs, [:job_id, :search_tag_id], unique: true
  end
end

参考:railsで複合uniqueのvalidationをつけてやる

↓↓以下の部分が完了

  • 募集と検索タグは多対多
    • モデルバリデーション
      • 重複したタグNG
      • 空白登録NG
      • 募集に同じタグを複数設定NG

募集に検索タグを設定

  • やりたいこと
    • 大元のフォームは以下のように募集(job)になっているので、このフォームの中で検索タグ(search_tag)のparametarを送信出来るようにする。
<%= form_with model: @job do |form| %>
  • 必要なこと
    • 関連付けしたモデルを一緒にデータ保存できるようにする
      • accepts_nested_attributes_forを設定
      • ストロングパラメーターの修正

以下のようなパラメーターを目指します。
募集(job)の中に検索タグ(search_tag)をネストしている。

パラメーター
Parameters: { "job"=>{省略 , "search_tags_attributes"=>{"1644084312487"=>{"name"=>"検索タグ"}, "1644084320258"=>{"name"=>"呪術廻戦"}, "1644084324062"=>{"name"=>"ワンピース"}}},}
  • 関連付けしたモデルを一緒にデータ保存できるようにする

jobモデルにaccepts_nested_attributes_forを設定する

app/models/job.rb
  # 検索タグ
  has_many :search_tag_bind_jobs, dependent: :destroy
  has_many :search_tags, through: :search_tag_bind_jobs
  accepts_nested_attributes_for :search_tag_bind_jobs, allow_destroy: true
  accepts_nested_attributes_for :search_tags

これだけで、複数テーブルへの同時保存が可能。

次に、一般的にストロングパラメーターを設定していると思うので追加します。(設定していない場合は必要なし)

記述の仕方は、関連名_attributesです。
(これは、accepts_nested_attributes_forのルール)

  def job_params
    params.require(:job).permit(:catch_copy, search_tags_attributes: [:name])
  end
  • accepts_nested_attributes_forを設定
  • ストロングパラメーターの修正

この二つの作業で、関連付けしたモデルを一緒にデータ保存できるようになりました。

次に、view側ですが、僕が作成したviewは以下のような動きです。

  • やっている内容
    • フォームに値を入力し、追加ボタンを押すと、入力した値を持ったチェックボックスが追加される
    • 複数追加OK(複数の値をサーバーに渡す)
    • 追加後、フォームの値はリセット
    • 空欄NG
    • 重複NG
    • ※関連モデルを同時保存する際は、fields_forを使用することが多いと思いますが、今回は使っていません。(上記のようなUI UXを実現するのに必要なかったため。)

↓↓別記事で書いてます(処理の内容のみ)
参考:【Rails、JQuery】フォーム入力した値を動的にチェックボックスで追加する方法【メモ】

↓↓以下の部分まで完了

  • 募集と検索タグは多対多

    • モデルバリデーション
      • 重複したタグNG
      • 空白登録NG
      • 募集に同じタグを複数設定NG
  • 募集(job)に検索タグ(search_tag)を設定

    • 検索タグ(search_tag)は複数設定可能
    • 募集作成画面からタグを設定することが出来る

募集編集画面からタグを設定、削除することが出来る

編集ページでは以下のような記述をしています。

viewファイル
<%= f.collection_check_boxes(:search_tag_ids, @job.search_tags, :id, :name) do |tag| %>
  <%= tag.label do %>
    <%= tag.check_box + tag.text%>
  <% end %>
<% end %>
  • ○○_ids
    • has_manyで関連づけされたモデルでは、モデル名の単数形_idsという記述で、従属するモデルのid(主キー)の配列を返すメソッドが使用可能
    • 参考:Railsドキュメント
  • collection_check_boxes
  def job_params
    params.require(:job).permit(:catch_copy, search_tags_attributes: [:name], search_tag_ids: [])
  end

編集時、上記画像の状態で登録した場合、
以下のようにidが1の検索タグと、2の呪術廻戦のみが渡されて、3のワンピースとの関連は削除されます。

パラメータ(編集)
 Parameters: { "job"=>{省略 , "search_tag_ids"=>["", "1", "2"]},}
sql
SearchTagBindJob Destroy (1.1ms)  DELETE FROM `search_tag_bind_jobs` WHERE `search_tag_bind_jobs`.`job_id` = 149559 AND `search_tag_bind_jobs`.`search_tag_id` = 3

↓↓以下の部分まで完了

  • 募集と検索タグは多対多

    • モデルバリデーション
      • 重複したタグNG
      • 空白登録NG
      • 募集に同じタグを複数設定NG
  • 募集(job)に検索タグ(search_tag)を設定

    • 検索タグ(search_tag)は複数設定可能
    • 求人作成画面からタグを設定することが出来る
    • 求人編集画面からタグを編集することが出来る

登録時のバリデーション

  • バリデーション
    • DB(search_tag)に重複したタグNG
    • DB(search_tag)に空白登録NG
    • 募集(job)に同じタグを複数設定NG

登録前に処理を入れたい場合(今回でいう上記の三点)、コントローラに記述するか、以下のようにjobモデル内で、def search_tags_attributes=(search_tag_attributes)メソッドを定義することで可能。
今回はjobモデル内に記述しています。

accepts_nested_attributes_for :search_tagsを定義することで、
Jobモデル内にsearch_tags_attributes=(search_tag_attributes)`というセッターメソッドが生成されて、フォームから送られてくるパラメータのsearch_tag_attributesによりセッターメソッドが呼ばれることで、Job.create()でJobモデルのレコードも同時に作成されるというカラクリ。

引用:https://zenn.dev/murakamiiii/articles/5ecefb7a58d1ef

app/model/job.rb
 # 検索タグ登録時の処理
  def search_tags_attributes=(search_tag_attributes)
    # 送られてきたタグパラメータの重複排除後、タグ追加の処理を行う
    search_tag_attributes.values.uniq.each do |tag_params|
      if tag_params["name"].present?
        # DBに入る値を全て小文字にして統一(jAva、JAVaなどの乱立を防ぐため。)
        tag_params["name"] = tag_params["name"].humanize(capitalize: false)
        # DBに重複がない場合は作成、重複している場合は既に登録されているデータを使用
        tag = SearchTag.find_or_create_by(tag_params)

        # 募集に同じタグが紐づいていない場合、募集とタグを紐付け。(複合ユニークキー制約)
        self.search_tags << tag if self.search_tags.where(name: tag["name"]).blank?
      end
    end
  end

↓↓別記事のjs側でもある程度の制御はしているが、不十分なのでRails側でも処理を入れている。
参考:【Rails、JQuery】フォーム入力した値を動的にチェックボックスで追加する方法【メモ】

↓↓以下の部分まで完了

  • 募集と検索タグは多対多

    • モデルバリデーション
      • 重複したタグNG
      • 空白登録NG
      • 募集に同じタグを複数設定NG
  • 募集(job)に検索タグ(search_tag)を設定

    • 検索タグ(search_tag)は複数設定可能
    • 求人作成画面からタグを設定することが出来る
    • 求人編集画面からタグを編集することが出来る
  • 登録時のバリデーション

    • DB(search_tag)に重複したタグNG(既にある場合は既存のものを使用)
    • DB(search_tag)に空白登録NG
    • 募集(job)に同じタグを複数設定NG
    • 英語は全て小文字で登録
      • DB登録時の表記揺れをなくすため(jAVa、javAなど)

検索タグ登録用のフォーム入力時、DBに存在するタグを予測変換

手順

  1. 検索タグ入力フォームのkeyupイベント時、ajax通信を行えるロジックを作成
  2. ルーティングを作成
  3. DBから取得するロジックを作成

1. 入力フォーム(.search-tag-field)を対象に、keyupイベントで通信が走るように設定

javascriptファイル
var jobs = jobs || {};
// DB(SearchTag)に保存されている検索タグを入力値から予測変換する関数
jobs.searchTag = function (target) {
  target.on("keyup", function () {
    var input_word = $(this).val(); // inputフィールドに入力された値
    const dataList = function(request, response) {
        $.ajax({
            url: '/ajax/get_search_tag', // 検索タグを取得するメソッドに飛ばす
            type: 'post',
            data: {name: input_word}, // コントローラに渡すパラメータ(入力値を設定)
          success: function (data) {
            response(data);
            },
          });
        }
        target.autocomplete({
          source: dataList, // 返却データ
          autoFocus: true, // 自動的に先頭の項目にフォーカスするか
          delay: 1000, // 入力してからサジェストが動くまでの時間(ms)
    })
  })
}
$(function () {
  // 検索タグ入力フォーム
  var search_tag_field = '.search-tag-field'
  // 検索タグ予測変換イベント登録
  jobs.searchTag($(search_tag_field))
  }

2. ルーティングを設定

config/routes.rb
# 検索タグの取得
post 'get_search_tag'

3. 入力値を使用してDBに登録済みの値を取得

app/controllers/ajax_controller.rb
  # 検索タグを取得(入力された文字列と学校IDで曖昧検索)
  def get_search_tag
    if input_words = params[:name].presence
    # 入力された文字から検索タグを取得
    # ヒット率を上げるため、【大文字・小文字】、【全角・半角】、【ひらがな・カタカナ】、【濁点・半濁点】を区別せず検索。
      registered_tag_list = SearchTag.where("REPLACE(REPLACE(name, '゙', ''), '゚', '') collate utf8_unicode_ci like ?", "%#{input_words.strip}%").pluck(:name)
    end
    render json: registered_tag_list
  end

ここまでで、最初に設定した要件を全てクリアできたと思います

  • 募集と検索タグは多対多

    • モデルバリデーション
      • 重複したタグNG
      • 空白登録NG
      • 募集に同じタグを複数設定NG
  • 募集(job)に検索タグ(search_tag)を設定

    • 検索タグ(search_tag)は複数設定可能
    • 求人作成画面からタグを設定することが出来る
    • 求人編集画面からタグを編集することが出来る
  • 登録時のバリデーション

    • DB(search_tag)に重複したタグNG(既にある場合は既存のものを使用)
    • DB(search_tag)に空白登録NG
    • 募集(job)に同じタグを複数設定NG
    • 英語は全て小文字で登録
      • DB登録時の表記揺れをなくすため(jAVa、javAなど)
  • 検索タグ登録用のフォーム入力時、DBに存在するタグを予測変換

まとめ

改めてまとめてみると、
同じバリデーションをかけていたりする部分はいいのかな?とか、変数名とか直した方がいいなと感じました。笑

Discussion