Railsのaccepts_nested_attributes_for
というヘルパーについて、日本語で分かりやすい記事がなかなか見つからないのでアウトプットしておきます。
特に関係は無いですが、viewファイルはhamlで記載していきます。
環境
ruby 2.6.5
Rails 5.2.4.2
概要
そもそもaccepts_nested_attributes_forとは何なのか?
Rail APIガイド
一番上の解説をそのまま翻訳してみます。
入れ子になった属性を使用すると、親を介して関連するレコードに属性を保存することができます。デフォルトでは、入れ子になった属性の更新はオフになっており、accept_nested_attributes_forクラス・メソッドを使用して有効にすることができます。入れ子になった属性を有効にすると、モデル上に属性ライターが定義されます。
属性ライターの名前はアソシエーションにちなんで付けられます。
入れ子になった属性
はNested attributes
と書かれているのでそういう名称だと捉えておきましょう。
要点を絞ると「親を介して関連するレコードに属性を保存することができる」ようです。
これだけだと分かりにくいですが、すぐ下のOne-to-oneのところにあるコードを読むと何となく意味が分かります。
class Member < ActiveRecord::Base
has_one :avatar
accepts_nested_attributes_for :avatar
end
params = { member: { name: 'Jack', avatar_attributes: { icon: 'smiling' } } }
member = Member.create(params[:member])
member.avatar.id # => 2
member.avatar.icon # => 'smiling'
Memberモデル(親)にAvatarモデル(子)がネストしていて、Member.create
の処理だけでAvatarの保存が完了しています(member.avatar.idがある=avatarのレコードが作成されている)
つまりモデルAの保存時に、それに紐づくモデルBをまとめて保存できるというのがこのヘルパーの役割のようです。
モデルの実装
teamモデルに多数のmemberが属している想定でコードを記載していきます。
モデルを作るとかルーティングを書くとか本記事に関係無い部分は省略してます。
モデルではアソシエーションの記述に加えて、親モデルに以下の記述を追記します。
has_many :members
accepts_nested_attributes_for :members
この記述によって、Teamモデルのレコード保存時にMemberモデルのレコードをまとめて保存ができるようになります。
また、デフォルトで更新はできるけど削除できず、削除したかったらallow_destroy
オプションをつけるようです。
accepts_nested_attributes_for :members, allow_destroy: true
Memberモデル側はbelongs_to :team
とアソシエーションを書くだけで完了です。
ビューの実装
ビューではfields_for
というヘルパーを使い、team用のform_with(またはform_for)の中にmembers用の入力フィールドを用意します。
Railsガイド
= form_with model: @team, local: true do |f|
%div team用のフォーム
= f.label :name
= f.text_field :name
%hr
ここからmembers
= f.fields_for :members do |m|
%div
= m.label :nickname
= m.text_field :nickname
%div
= m.label :age
= m.number_field :age
ここまでmembers
%hr
= f.submit
このように@team用のform_with
の中にmembers用のfields_for
が入っている形で記述します。
しかしこのフォームには問題点があります。コントローラで@team
を作ってみましょう。
def new
@team = Team.new
end
するとなぜかmembers用のフィールドが消えてしまいました・・・
実はfields_for
というのは親モデルに紐づく子モデルの数だけ回るeach文のような動きをします。
今回の場合だと@team.members
の数だけfields_for
の中身は実行されるということです。
@team
を作ったことでフォームが適切に機能し始め、@team.members
が無いのでfields_for
の中身が実行されなかったということです。
Railsガイドにもコントローラで最低1つの子モデルのインスタンスを作成しておくのが常套手段だと書かれています。
def new
@team = Team.new
@team.members.new
end
こうしておくとmembers入力用のフィールドが最初から1つ生成された状態になります。
times
メソッドなどを使って複数回@team.members.new
を実行すれば、その数だけfields_for
が入力フィールドを生成してくれます。
コントローラの実装
コントローラの実装は非常にシンプルです。
@team
を保存する処理と、fields_forの値を受け取ることができるストロングパラメータを実装すれば完成です。
def create
@team = Team.new(team_params)
if @team.save
redirect_to root_path
else
render :new
end
end
private
def team_params
params.require(:team).permit(:name, members_attributes: [:nickname, :age])
end
ストロングパラメータの形が独特です。
これを理解するために、ビューから送られてくるparamsがどうなっているか見てみましょう。
"team"=>{
"name"=>"Tema1",
"members_attributes"=>{
"0"=>{
"nickname"=>"Member1",
"age"=>"22"
}
}
}
team
の中にmembers_attributes
がネストして、
その中に"0"
がネストして、
さらにその中にmembers用のパラメータが入っています。
ここでRailsガイドの解説を引用します
:addresses_attributesハッシュのキーはここでは重要ではありません。各アドレスのキーが重複していなければそれでよいのです。
この記事ではaddressesではなくmembersですが、要するに**"0"の部分はmembersが増えるにつれて重複なく設定できれば何でも良い**ということですね。
そう考えれば"0"
を無視して、送られてくるparamsとストロングパラメータの形(ネストの仕方)が一致していることが分かります。
ここまでの実装で、実際にフォームを使って送信してみると・・・
@team.save
しか書いてないのにMemberもcreateされてます!
membersテーブルのteam_idカラムにもきちんと今作ったteamのidが入っています。
編集できるようにする
ここでRailsガイドもAPIリファレンスも解説が少なく、途方に暮れそうですが実装は至ってシンプルです。
まずはedit用のビューファイルを作成し、newと使い回せるようにしておきます。
= render "form"
= render "form"
= form_with model: @team, local: true do |f|
%div team用のフォーム
= f.label :name
= f.text_field :name
%hr
ここからmembers
= f.fields_for :members do |m|
%div
= m.label :nickname
= m.text_field :nickname
%div
= m.label :age
= m.number_field :age
ここまでmembers
%hr
= f.submit
コントローラもRailsの基本的なものを書いておきます。
def edit
@team = Team.find(params[:id])
end
def update
@team = Team.find(params[:id])
if @team.update(team_params)
redirect_to root_path
else
render :edit
end
end
ここで既存レコード更新について、Rails APIリファレンスから引用します。
このリファレンスではmemberが親モデルでpostsが子モデルです。
If the hash contains an id key that matches an already associated record, the matching record will be modified:
member.attributes = {
name: 'Joe',
posts_attributes: [
{ id: 1, title: '[UPDATED] An, as of yet, undisclosed awesome Ruby documentation browser!' },
{ id: 2, title: '[UPDATED] other post' }
]
}
member.posts.first.title # => '[UPDATED] An, as of yet, undisclosed awesome Ruby documentation browser!'
member.posts.second.title # => '[UPDATED] other post'
要するにposts_attributes
の中にid
というキーが含まれていて、そのidのレコードが実際にpostsテーブルにあれば更新される、と。
そして今度はRailsガイドから引用します。
関連付けられたオブジェクトが既に保存されている場合、fields_forメソッドは、保存されたレコードのidを持つ隠し入力を自動的に作成します。
関連付けられたオブジェクトが既に保存されている場合
=>つまりeditでは
保存されたレコードのidを持つ隠し入力を自動的に作成します
=>APIリファレンスで必要と書かれていたid
キー用のinputを自動生成します。
実際にeditの画面で検証ツールを開くと、作った覚えの無いinputができてます。
必要なキーが自動生成されてるってことはもしかしてもう実装終わり?Rails凄すぎない?
と思って送信してみたら期待とは違ったことになりました。
Teamはupdateですが、Memberはcreateとなっています。実際に既存レコードの更新ではなく1件増えてしまいました。
しかし、こうなってしまった原因もこの中に書いていますね。
Unpermitted parameter: :id
idというキーでparamsは送られてきたが、permitできなかった。
つまりストロングパラメータでidを許可していないことが原因です。
def team_params
params.require(:team).permit(:name, members_attributes: [:nickname, :age, :id])
end
:age
の後ろに:id
を追加しました。
そして再度editからフォームを送信してみると・・・
Memberもupdateになりました!やっぱりRails凄すぎない?(10分ぶり2回目)
実際にDBの値を確認すると更新されていたのでこれで実装完了です。
削除できるようにする
Railsガイドに丁寧に書いてあったのでそのまま引用&実装していきます。
accepts_nested_attributes_forにallow_destroy: trueを渡すと、関連付けられたオブジェクトをユーザーが削除することを許可できるようになります。
これはモデルの実装で書いたので大丈夫ですね。
あるオブジェクトの属性のハッシュに、キーが_destroyで値が1またはtrueの組み合わせがあると、そのオブジェクトは削除されます。
要するに_destroy
というキーの入力欄を作り、1かtrueが送られてくれば削除できる、と。
数字の入力欄でも良いですが、公式に倣いチェックボックスで作ってみます。
= f.fields_for :members do |m|
%div
= m.label :nickname
= m.text_field :nickname
%div
= m.label :age
= m.number_field :age
# ここから下を追記
%div
= m.check_box :_destroy
updateの時の失敗でレコードが2件に増えてますが、fields_for
が2周動いて入力欄とチェックボックスが2個ずつ生成できているのが分かります。
これにチェックを入れればそのmemberは削除される、という寸法です。
そして最後に、ストロングパラメータに_destroyを追加します。
updateの時の失敗は繰り返さない!
def team_params
params.require(:team).permit(:name, members_attributes: [:nickname, :age, :id, :_destroy])
end
試しにチェックボックスにチェックを入れて送信してみると・・・
削除されました。Rails凄すぎない?(20分ぶり3回目)
削除機能も実装完了です。
編集画面で新規追加もできるようにする
今のままだとeditページでは新規メンバーの追加ができません。
既存のteamに新しいメンバーを追加するためのフィールドも用意したいところです。
fields_for
は子レコードの数だけ回るeach文だと最初の方で書きました。
editでは子レコードの数=既存メンバーの数なので、追加でもう1つインスタンスを作っておけば良さそうですね。
def edit
@team = Team.find(params[:id])
@team.members.new # ここを追記
end
editの定番処理@team = Team.find(params[:id])
に加えて、newの時に書いたmemberのインスタンスを作る処理を追加しました。
既存メンバーが1人のteamのeditでこのようになりました。いい感じです。
実際に既存メンバーのupdate+新規メンバーのcreateができています。解決!
...と思ったのですが。こんな落とし穴がありました
editで新規メンバーを追加したくないので空けたまま送信します。
名無しさんがcreateされてしまいました。
これは良くないですね。バリデーションをきちんと実装した場合、そもそも保存に失敗しそうです。
どうしようかと思ったらRailsガイドに書いてありました。
ユーザーが何も入力しなかったフィールドを無視できれば何かと便利です。これは、:reject_if procをaccepts_nested_attributes_forに渡すことで制御できます。このprocは、フォームから送信された属性のハッシュ1つ1つについて呼び出されます。このprocがfalseを返す場合、Active Recordはそのハッシュに関連付けられたオブジェクトを作成しません。
※falseと書いてますが参考コード的にtrueだと思われます
procというのはブロックのようなものです(厳密には違います)
つまり
-
accepts_nested_attributes_for
に -
reject_if
オプションを追加し、 -
空だったらtrueを返す式
をproc(ブロック)で書いておく
以上で、空のレコードが生成されないようにできるようです。
ブロックの渡し方は色々あるようですが、個人的にメソッド化する方法がしっくり来たのでその方針で実装します。
(Rails APIガイドをreject_if
で検索すると書き方がいくつか載ってます)
accepts_nested_attributes_for :members, allow_destroy: true, reject_if: :reject_members
private
def reject_members(attributes)
attributes[:nickname].blank? || attributes[:age].blank?
end
reject_members
メソッドを実装し、:nickname
もしくは:age
が空だったらtrueを返すようにしました。
今回はnicknameとageの両方を入力して欲しいのでこの形にしています。
メソッド化しておけばこの辺りの条件をカスタマイズする時に読みやすそうなのでこの方法を選択しました。
これでage
に100を入力すると、paramsは送信できていますがmemberのcreateはされず・・・
nicknameとageが揃っていればmemberがcreateされるようになりました!
最低限やりたいことが実装できたため、今回の実装はここまでにします。
終わりに
この先はチェックボックスを消した状態で操作したり、新規追加用のフィールドを動的に追加したり・・といったことをJSで実装していくケースが多いかと思います。
本記事は一切JSに触れていませんが、結局JSで書くべきコードはaccepts_nested_attributes_for
を使うための操作をJSで実現する、というだけです。
ここの理解を固めてからJSを書かないと、エラーが出てもデバッグが進まないと思います。
そしてここまで書いて気づきましたが、APIリファレンスとRailsガイドで完結してしまいました。
公式で完結するから参考記事が少ないんでしょうね。やっぱり一次ソースは大切です。