LoginSignup
10
11

More than 3 years have passed since last update.

accepts_nested_attributes_forを理解する

Last updated at Posted at 2020-03-26

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のところにあるコードを読むと何となく意味が分かります。

member.rb
class Member < ActiveRecord::Base
  has_one :avatar
  accepts_nested_attributes_for :avatar
end
controller
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が属している想定でコードを記載していきます。
モデルを作るとかルーティングを書くとか本記事に関係無い部分は省略してます。

モデルではアソシエーションの記述に加えて、親モデルに以下の記述を追記します。

team.rb
has_many :members
accepts_nested_attributes_for :members

この記述によって、Teamモデルのレコード保存時にMemberモデルのレコードをまとめて保存ができるようになります。
また、デフォルトで更新はできるけど削除できず、削除したかったらallow_destroyオプションをつけるようです。

team.rb
accepts_nested_attributes_for :members, allow_destroy: true

Memberモデル側はbelongs_to :teamとアソシエーションを書くだけで完了です。

ビューの実装

ビューではfields_forというヘルパーを使い、team用のform_with(またはform_for)の中にmembers用の入力フィールドを用意します。
Railsガイド

new.html.haml

= 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が入っている形で記述します。

as1.jpg
しかしこのフォームには問題点があります。コントローラで@teamを作ってみましょう。

controller

def new
  @team = Team.new
end

するとなぜかmembers用のフィールドが消えてしまいました・・・
as2.jpg
実はfields_forというのは親モデルに紐づく子モデルの数だけ回るeach文のような動きをします。
今回の場合だと@team.membersの数だけfields_forの中身は実行されるということです。
@teamを作ったことでフォームが適切に機能し始め、@team.membersが無いのでfields_forの中身が実行されなかったということです。
Railsガイドにもコントローラで最低1つの子モデルのインスタンスを作成しておくのが常套手段だと書かれています。

controller

def new
  @team = Team.new
  @team.members.new
end

こうしておくとmembers入力用のフィールドが最初から1つ生成された状態になります。
timesメソッドなどを使って複数回@team.members.newを実行すれば、その数だけfields_forが入力フィールドを生成してくれます。

コントローラの実装

コントローラの実装は非常にシンプルです。
@teamを保存する処理と、fields_forの値を受け取ることができるストロングパラメータを実装すれば完成です。

controller

  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がどうなっているか見てみましょう。

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とストロングパラメータの形(ネストの仕方)が一致していることが分かります。

ここまでの実装で、実際にフォームを使って送信してみると・・・
as3.jpg
@team.saveしか書いてないのにMemberもcreateされてます!
membersテーブルのteam_idカラムにもきちんと今作ったteamのidが入っています。

編集できるようにする

ここでRailsガイドもAPIリファレンスも解説が少なく、途方に暮れそうですが実装は至ってシンプルです。
まずはedit用のビューファイルを作成し、newと使い回せるようにしておきます。

new.html.haml
= render "form"
edit.html.haml
= render "form"
_form.html.haml
= 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の基本的なものを書いておきます。

controller
  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ができてます。
as4.jpg
必要なキーが自動生成されてるってことはもしかしてもう実装終わり?Rails凄すぎない?
と思って送信してみたら期待とは違ったことになりました。
as5.jpg
Teamはupdateですが、Memberはcreateとなっています。実際に既存レコードの更新ではなく1件増えてしまいました。
しかし、こうなってしまった原因もこの中に書いていますね。
Unpermitted parameter: :id
idというキーでparamsは送られてきたが、permitできなかった。
つまりストロングパラメータでidを許可していないことが原因です。

controller
def team_params
  params.require(:team).permit(:name, members_attributes: [:nickname, :age, :id])
end

:ageの後ろに:idを追加しました。
そして再度editからフォームを送信してみると・・・
as6.jpg
Memberもupdateになりました!やっぱりRails凄すぎない?(10分ぶり2回目)
実際にDBの値を確認すると更新されていたのでこれで実装完了です。

削除できるようにする

Railsガイドに丁寧に書いてあったのでそのまま引用&実装していきます。

accepts_nested_attributes_forにallow_destroy: trueを渡すと、関連付けられたオブジェクトをユーザーが削除することを許可できるようになります。

これはモデルの実装で書いたので大丈夫ですね。

あるオブジェクトの属性のハッシュに、キーが_destroyで値が1またはtrueの組み合わせがあると、そのオブジェクトは削除されます。

要するに_destroyというキーの入力欄を作り、1かtrueが送られてくれば削除できる、と。
数字の入力欄でも良いですが、公式に倣いチェックボックスで作ってみます。

_form.html.haml
= 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は削除される、という寸法です。
as7.jpg
そして最後に、ストロングパラメータに_destroyを追加します。
updateの時の失敗は繰り返さない!

controller
  def team_params
    params.require(:team).permit(:name, members_attributes: [:nickname, :age, :id, :_destroy])
  end

試しにチェックボックスにチェックを入れて送信してみると・・・
as8.jpg
削除されました。Rails凄すぎない?(20分ぶり3回目)
削除機能も実装完了です。

編集画面で新規追加もできるようにする

今のままだとeditページでは新規メンバーの追加ができません。
既存のteamに新しいメンバーを追加するためのフィールドも用意したいところです。

fields_forは子レコードの数だけ回るeach文だと最初の方で書きました。
editでは子レコードの数=既存メンバーの数なので、追加でもう1つインスタンスを作っておけば良さそうですね。

controller

def edit
  @team = Team.find(params[:id])
  @team.members.new # ここを追記
end

editの定番処理@team = Team.find(params[:id])に加えて、newの時に書いたmemberのインスタンスを作る処理を追加しました。
as9.jpg
既存メンバーが1人のteamのeditでこのようになりました。いい感じです。
as10.jpg
実際に既存メンバーのupdate+新規メンバーのcreateができています。解決!

...と思ったのですが。こんな落とし穴がありました
as11.jpg
editで新規メンバーを追加したくないので空けたまま送信します。
as12.jpg
名無しさんがcreateされてしまいました。
これは良くないですね。バリデーションをきちんと実装した場合、そもそも保存に失敗しそうです。
どうしようかと思ったらRailsガイドに書いてありました。

ユーザーが何も入力しなかったフィールドを無視できれば何かと便利です。これは、:reject_if procをaccepts_nested_attributes_forに渡すことで制御できます。このprocは、フォームから送信された属性のハッシュ1つ1つについて呼び出されます。このprocがfalseを返す場合、Active Recordはそのハッシュに関連付けられたオブジェクトを作成しません。

※falseと書いてますが参考コード的にtrueだと思われます

procというのはブロックのようなものです(厳密には違います)
つまり
1. accepts_nested_attributes_for
2. reject_ifオプションを追加し、
3. 空だったらtrueを返す式をproc(ブロック)で書いておく
以上で、空のレコードが生成されないようにできるようです。
ブロックの渡し方は色々あるようですが、個人的にメソッド化する方法がしっくり来たのでその方針で実装します。
Rails APIガイドreject_ifで検索すると書き方がいくつか載ってます)

team.rb
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の両方を入力して欲しいのでこの形にしています。
メソッド化しておけばこの辺りの条件をカスタマイズする時に読みやすそうなのでこの方法を選択しました。
as13.jpg
これでageに100を入力すると、paramsは送信できていますがmemberのcreateはされず・・・
as14.jpg
nicknameとageが揃っていればmemberがcreateされるようになりました!
最低限やりたいことが実装できたため、今回の実装はここまでにします。

終わりに

この先はチェックボックスを消した状態で操作したり、新規追加用のフィールドを動的に追加したり・・といったことをJSで実装していくケースが多いかと思います。
本記事は一切JSに触れていませんが、結局JSで書くべきコードはaccepts_nested_attributes_forを使うための操作をJSで実現する、というだけです。
ここの理解を固めてからJSを書かないと、エラーが出てもデバッグが進まないと思います。

そしてここまで書いて気づきましたが、APIリファレンスとRailsガイドで完結してしまいました。
公式で完結するから参考記事が少ないんでしょうね。やっぱり一次ソースは大切です。

参考サイト

Rails APIリファレンス
Railsガイド

10
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
11