多対多の関係で【collection_check_boxes】を使用し複数のカラムを表示・保存・更新・削除できる様にする。


 はじめに

フォーム関連のcollection_check_boxesを使用する際に
わからないことがたくさんあったので記録のためにこちらに記述して残しておきます。
多対多の関係を構築するモデルの作成から潜影蛇手していきます。

 環境

ruby 2.5.7
Rails 5.2.4.2

やりたいこと

shopに対して複数のgenre(和食・洋食など)を保存・更新できる様にする。

多対多の関係(モデル)

今回はジャンル検索でお店を検索するという機能を作りたいため、
shopに様々なgenreを持たせる。
お店の情報をもつShopモデルとジャンルの情報を持つGenreモデル、その2つを繋ぐ中間テーブルとしてShopGenreモデルを作成します。

モデル作成

model
$rails g model Shop shop_name:string
$rails g model Genre name:string

# 中間テーブル
$rails g model ShopGenre shop:references genre:references

:referencesこれは外部キーを追加する際に使用します。
上図のように作成を行えば自動的に以下のようにそれぞれのモデルとの紐付けされた状態で作成されます。

models/shop_genre.rb
class ShopGenre < ApplicationRecord
  belongs_to :shop
  belongs_to :genre
end
db/migrate/create_shop_genres.rb
class CreateShopGenres < ActiveRecord::Migration[5.2]
  def change
    create_table :shop_genres do |t|
      t.references :shop, foreign_key: true
      t.references :genre, foreign_key: true

      t.timestamps
    end
  end
end

shop_genreモデルには自動的にbelongs_toと設定されますが、
shopモデルとgenreモデルには自分で記述する必要があります。

models/shop.rb
class Shop < ApplicationRecord
    has_many :shop_genres
    has_many :genres, through: :genres
end

models/genre.rb
class Genre < ApplicationRecord
    has_many :shop_genres
    has_many :shops, through: :genres
end

Shop 1:n ShopGenre n:1 Genreの状態

今回、多対多の関係でshop_genre(中間テーブル)を通して
shopテーブル及び genreテーブルを(through)、その先を参照することをしたいため
中間テーブルとのhas_many関係の記述と、
もう一つその中間テーブルを通したモデルとの紐付け、
今回だとshopとgenreモデルにhas_many :throughを使って紐付ける必要があります。

以上でモデルの実装は完了。

collection_check_boxesの記述(view)

今回は既にshopは作られており、後から編集でジャンルを追加できるようにしていますので
以下のようになっております。予めご理解ください。

views/shops/edit.html.erb
<%= form_for(@shop, url:admins_shop_path(@shop),method: :patch) do |f| %>

# 〜〜省略〜〜

       <div class="form-group">
          <%= f.label :genres %>
            <div class="checkbox">
              <%= collection_check_boxes(:shop, :genre_ids, Genre.all, :id, :name, include_hidden: false) do |g| %>
                <%= g.label {g.check_box + g.text} %>
              <% end %>
            </div>
        </div>
<% end %>

:genre_idsは更新対象のオブジェクト、つまり@shopが持つメソッド。チェックボックスの各チェックにIDが関連付けられます。create,updateアクションを実行する時には、ストロングパラメータの設定が必要です(※1後述)

collection_check_boxesの構造に関しての詳細は
こちらのQiita記事を見ていただければ分かりやすいかと思います。

今回の実装ではcheckboxのデータを配列で受けとるが、以下のように
[""]が入ってしまうため
オプションとしてinclude_hidden: falseを設けました。

console
[] pry(#<Admins::ShopsController>)> shop_params[:genre_ids]
=> ["", "1", "2", "3"]

⬆️このようになってしまう。

コントローラー実装

中間テーブルにもデータを保存できるようにコードを書いていきます。

controllers/shops/shops_controller.rb
class Admins::ShopsController < ApplicationController

# 〜〜省略〜〜

  def edit
    @shop = Shop.find(params[:id])
  end

  def update
    @shop = Shop.find(params[:id])
    if @shop.update(shop_params)
       shop_params[:genre_ids].each do | shopg |
          genres = @shop.genres.pluck(:genre_id)
          unless genres.include?(shopg.to_i)
            genre = ShopGenre.new(genre_id: shopg)
            genre.shop_id = @shop.id
            genre.save
          end
        end
          redirect_to admins_shop_path
    else
       render 'edit'
    end
  end


# 〜〜省略〜〜

  private
  def shop_params
    params.require(:shop).permit(:name, genre_ids: [])
  end
end

※1)ストロングパラメータにgenre_ids: []を記述。(配列としてデータを抽出)
genres.include?(shopg.to_i)と書いていますが、
配列で抽出したidは上記コンソールで見たように["", "1", "2", "3"]と文字列として
扱われるため.to_iを潜影蛇手してintegerに直す必要があります。

 
あと、shopモデルに以下の記述を追記

models/shop.rb
attr_accessor :genre_ids

attr_accessor : (インスタンス変数) とすれば、指定されたインスタンス変数が外部からでも変更できるようになるので必要に応じて設定してください。

ただ、上記controllerの記述だけでは同じid(ジャンル)は保存されないようになっていますが
不十分なところが複数あります。

・ 既に保存済みのジャンルをチェックボックス(view/edit.html.erb)に反映させる
・ チェックを外した状態で更新すると消されるようにするなど、、

その他動作させる上でジャンルを全て削除した状態など色々考慮しコードを追記。
※もう少し工夫できるところはあると思いますが以下が最終結果です。

controllers/shops/shops_controller.rb
class Admins::ShopsController < ApplicationController

  def edit
    @shop = Shop.find(params[:id])
    @shop.genre_ids = @shop.genres.pluck(:shop_genre_id)
  end

  def update
    @shop = Shop.find(params[:id])
    old_genres = @shop.genres.pluck(:genre_id)
    if @shop.update(shop_params)
      if shop_params[:genre_ids].nil?
        genres = ShopGenre.where(shop_id: @shop.id)
        genres.destroy_all
      else
        destroy_genres = old_genres - shop_params[:genre_ids].map(&:to_i)
        destroy_genres.each do | destroy_genre |
          genre = ShopGenre.find_by(shop_id: @shop.id, genre_id: destroy_genre)
          genre.destroy
        end
        genres = @shop.genres.pluck(:genre_id)
        shop_params[:genre_ids].each do | shopg |
          unless genres.include?(shopg.to_i)
            genre = ShopGenre.new(genre_id: shopg)
            genre.shop_id = @shop.id
            genre.save
          end
        end
      end
      redirect_to admins_shop_path
    else
       render 'edit'
    end
  end

  private
  def shop_params
    params.require(:shop).permit(:name,  genre_ids: [])
  end
end

以上で【collection_check_boxes】を使用し複数のカラムを
表示・保存・更新・削除できる様になりました。