環境:
ruby 2.7.3
rails 6.1.4.4
やりたいこと
form_withとfields_forを使って、関連性のない複数のテーブルに、複数のレコードを、ボタン1クリックで保存する。
猫のコメントはCatテーブル、犬のコメントはDogテーブルに保存する。さらに、それぞれの種類に対するコメントをボタン1つで一括登録しようというのが今回のお題。
全体の流れ
- 入力ページにアクセスすると、コントローラー側でモデルCatとモデルDogをまとめた擬似モデルのインスタンス(=@formとする)が作成される
- 擬似モデルのinitializeメソッドが走り、モデルCatに対応するattributesのインスタンス、モデルDogに対応するattributesのインスタンスが作成される
- コントローラー側で、ビューのフォームを表示させるためのインスタンスが作成される
- フォームからpost送信されたデータがパラメータに格納される
- モデルCat,Dogに対応するそれぞれのパラメータの中身を保存する
1. 入力ページにアクセスすると、コントローラー側でモデルAとモデルBをまとめた擬似モデルのインスタンス(=@formとする)が作成される
コントローラー
@form = Form::Collection.new
ここでは、Form::Collection
というクラスのインスタンスを作成している。このクラスは、モデルCatとモデルDogのデータをまとめて保存するための擬似モデル(直接DBと関連しないけれどモデルとして扱えるようにしたクラス)を自作する。
ダブルコロン「::」の意味についてはこちら
https://qiita.com/hatorijobs/items/87a2bd93f8666d77d711
次に、この疑似モデルの中身を少し見ていく。
(collection.rb)
class Form::Collection < Form::Base
attr_accessor :cats, :dogs
まず、Form:Base
というクラスを継承させている。このForm:Base
の中身は以下のようになっている。
class Form::Base
include ActiveModel::Model
include ActiveModel::Callbacks
include ActiveModel::Validations
include ActiveModel::Validations::Callbacks
end
つまるところ、このクラスをあたかもモデルのように扱うためにActiveModelをincludeしている。
そして、モデルCatとモデルDogに対応した変数を定義する。
attr_accessor :cats, :dogs
attr_accessorについてはこちら
https://qiita.com/k-penguin-sato/items/5b75be386be4c55e3abf
2.擬似モデルのinitializeメソッドが走り、モデルCatに対応するattributesのインスタンス、モデルDogに対応するattributesのインスタンスが作成される
Form::Collection.new
をすると、まずinitializeメソッドが走る
def initialize(attributes = {})
super attributes
self.cats = [] unless cats.present?
self.dogs = [] unless dogs.present?
end
まず、attributesがnilなら入れ物(=空っぽのハッシュ)を用意する。
newしたときに、擬似モデルで設定した変数(cats, dogs)がnilなら、それにも入れ物を用意する。
3.コントローラー側で、ビューのフォームを表示させるためのインスタンスが作成される
def new
@form = Form::Collection.new
cats = Animal.where(animal_type: 'cat')
dogs = Animal.where(animal_type: 'dog')
@cat_name = []
@dog_name = []
# viewにわたすフォームのインスタンス(猫用)を作成する
cats.each do |cat|
@cat_name.push(cat.cat_name)
comment = Cat.new(cat_comment_id: cat.animal_id)
@form.cats.push(comment)
end
# viewにわたすフォームのインスタンス(犬用)を作成する
dogs.each do |dog|
@dog_name.push(dog.dog_name)
comment = Dog.new(dog_comment_id: dog.animal_id)
@form.dogs.push(comment)
end
end
ポイントは以下
comment = Dog.new(dog_comment_id: dog.animal_id)
@form.dogs.push(comment)
これで、@formに格納されている2つの変数に対して、モデルCatとモデルDogのインスタンスが挿入された。インスタンスを複数入れたい場合(=レコードを複数入れたい場合)は.eachや.timesなどでインスタンスを@form.xxx(変数名)に複数個詰め込んでおく。
※モデルに対応したインスタンスを@form.xxxに詰め込んで置かないと、後述するfields_forを使う際に(中身が何もないので)何も表示されなくなってしまう。
4.フォームからpost送信されたデータがパラメータに格納される
<%= form_with(model: @form, url: { :controller => 'comment', :action => 'create' }, method: :post) do |f| %>
<%= f.fields_for :cats do |i| %>
#フォームの内容
<% end %>
<%= f.fields_for :dogs do |i| %>
#フォームの内容
<% end %>
<%= submit_tag 'この内容で登録する' %>
<% end %>
form_withのmodelには、@formを渡す。つまり、モデルAとモデルBを両方扱うForm::Collection
に対応するフォームとしている。
次のf.fields_for には、擬似モデルで設定した変数をそれぞれ渡す。複数あるインスタンスを1個1個ブロック変数iに渡すので、コントローラーで詰め込んだインスタンスの数だけフォームが生成される。
ただ、この状態だと複数個のレコードを送れないので、複数個送れるようにForm::Collection
で設定しておく必要がある。
def cats_attributes=(attributes)
self.cats = attributes.map { |_, v| Cat.new(v) }
end
def dogs_attributes=(attributes)
self.dogs = attributes.map { |_, v| Dog.new(v) }
end
(処理の中身)
まず、xxx_attributesという名前で送られてきたパラメータを一旦引数で指定したattributesという変数に代入する。
@form.xxx に対して、attributesの個数だけモデルAのインスタンスとして.mapで中身を代入する。
mapメソッド
https://www.sejuku.net/blog/58958
補足: |_, v|のアンダースコアは、「使わないけど一旦代入しておく変数」です。
https://qiita.com/jnchito/items/3cce0c057f54afa29d0a
フォームを送信すると、以下のようなパラメータが送られてくる。
Parameters: {"authenticity_token"=>"[FILTERED]",
"form_collection"=>
{"cats_attributes"=>
{"0"=>{"cat_comment_id"=>"それぞれの猫のID","comment"=>"入れたコメント"},
{"1"=>{"cat_comment_id"=>"それぞれの猫のID","comment"=>"入れたコメント"},
{"2"=>{"cat_comment_id"=>"それぞれの猫のID","comment"=>"入れたコメント"}},
{"dogs_attributes"=>
{"0"=>{"dog_comment_id"=>"それぞれの犬のID","comment"=>"入れたコメント"},
{"1"=>{"dog_comment_id"=>"それぞれの犬のID","comment"=>"入れたコメント"},
{"2"=>{"dog_comment_id"=>"それぞれの犬のID","comment"=>"入れたコメント"}},
"commit"=>"この内容で登録する"}
ポイントは、「xxx_attributes」というキー。このキーは、「(疑似モデルで設定した変数名)_attributes」という形になっている。
5.モデルCat,Dogに対応するそれぞれのパラメータの中身を保存する
パラメータの中に、複数のテーブルに対応した複数のレコードが詰め込まれているので、あとはそれをバラして個別に保存していく。
コントローラー
def create
@form = Form::Collection.new(comment_collection_params)
# それぞれのレコードに対して必要な情報を一括で入れ込む
@form.cats.each do |cat|
cat.animal_type = 'cat'
end
@form.dogs.each do |dog|
dog.animal_type = 'dog'
end
end
if @form.cats.map(&:save) and @form.dogs.map(&:save)
redirect_to action: :complete
end
private
def comment_collection_params
params
.require(:form_collection)
.permit(cats_attributes: %i[許可するカラムの名前],
dogs_attributes: %i[許可するカラムの名前])
end
まず、Form::Collection
のインスタンスを作成し、ストロングパラメータ化したcomment_collection_paramsを渡している。
@formの中身はこんな感じ
↓@form.catsの中身↓
[
#<Cat:0x00000001086a19f0
カラム: データ
カラム: データ
カラム: データ>,
#<Cat:0x00000001086a14c8
カラム: データ
カラム: データ
カラム: データ>,
#<Cat:0x00000001086a1018
カラム: データ
カラム: データ
カラム: データ>,
]
ストロングパラメータについてはこちら
https://qiita.com/ozackiee/items/f100fd51f4839b3fdca8
一括して同じデータを入れたいカラムについては、それぞれeachでバラして代入していく形。
さらにその後、
@form.cats.map(&:save) and @form.dogs.map(&:save)
それぞれのモデルに対して複数個あるレコードを保存する。
(&:save)はmapメソッドの省略形
参考
1つのフォームで複数のテーブルを扱う
https://www.petitmonte.com/ruby/multiple_models.html
複数レコードを一括で保存する