accepts_nested_attributes_forを理解する

Railsの`accepts_nested_attributes_for`というヘルパーについて、日本語で分かりやすい記事がなかなか見つからないのでアウトプットしておきます。
特に関係は無いですが、viewファイルはhamlで記載していきます。

## 環境
ruby 2.6.5
Rails 5.2.4.2

## 概要
そもそも**accepts_nested_attributes_for**とは何なのか？
[Rail APIガイド](https://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html)
一番上の解説をそのまま翻訳してみます。
>入れ子になった属性を使用すると、親を介して関連するレコードに属性を保存することができます。デフォルトでは、入れ子になった属性の更新はオフになっており、accept_nested_attributes_forクラス・メソッドを使用して有効にすることができます。入れ子になった属性を有効にすると、モデル上に属性ライターが定義されます。
>
>属性ライターの名前はアソシエーションにちなんで付けられます。

`入れ子になった属性`は`Nested attributes`と書かれているのでそういう名称だと捉えておきましょう。
要点を絞ると「親を介して関連するレコードに属性を保存することができる」ようです。
これだけだと分かりにくいですが、すぐ下の**One-to-one**のところにあるコードを読むと何となく意味が分かります。

```ruby:member.rb
class Member < ActiveRecord::Base
  has_one :avatar
  accepts_nested_attributes_for :avatar
end
```

```ruby: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が属している想定でコードを記載していきます。
モデルを作るとかルーティングを書くとか本記事に関係無い部分は省略してます。

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

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

```ruby:team.rb
accepts_nested_attributes_for :members, allow_destroy: true
```
Memberモデル側は`belongs_to :team`とアソシエーションを書くだけで完了です。

## ビューの実装
ビューでは`fields_for`というヘルパーを使い、team用のform_with(またはform_for)の中にmembers用の入力フィールドを用意します。
[Railsガイド](https://railsguides.jp/form_helpers.html#%E8%A4%87%E9%9B%91%E3%81%AA%E3%83%95%E3%82%A9%E3%83%BC%E3%83%A0%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B)

```ruby: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/346814/a7e16c04-6804-b305-a972-f424f311b9e9.jpeg)
しかしこのフォームには問題点があります。コントローラで`@team`を作ってみましょう。

```ruby:controller

def new
  @team = Team.new
end
```
するとなぜかmembers用のフィールドが消えてしまいました・・・
![as2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/346814/0363d0cc-8a0c-0ab1-d775-8cab9b10f134.jpeg)
実は`fields_for`というのは**親モデルに紐づく子モデルの数だけ回るeach文**のような動きをします。
今回の場合だと`@team.members`の数だけ`fields_for`の中身は実行されるということです。
`@team`を作ったことでフォームが適切に機能し始め、`@team.members`が無いので`fields_for`の中身が実行されなかったということです。
[Railsガイド](https://railsguides.jp/form_helpers.html#%E3%83%8D%E3%82%B9%E3%83%88%E3%81%97%E3%81%9F%E3%83%95%E3%82%A9%E3%83%BC%E3%83%A0)にもコントローラで最低1つの子モデルのインスタンスを作成しておくのが常套手段だと書かれています。

```ruby:controller

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

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

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

```ruby:params
"team"=>{
  "name"=>"Tema1",
  "members_attributes"=>{
    "0"=>{
      "nickname"=>"Member1",
      "age"=>"22"
    }
  }
}
```
`team`の中に`members_attributes`がネストして、
その中に`"0"`がネストして、
さらにその中にmembers用のパラメータが入っています。
ここでRailsガイドの解説を引用します
>:addresses_attributesハッシュのキーはここでは重要ではありません。各アドレスのキーが重複していなければそれでよいのです。

この記事ではaddressesではなくmembersですが、要するに**"0"の部分はmembersが増えるにつれて重複なく設定できれば何でも良い**ということですね。
そう考えれば`"０"`を無視して、送られてくるparamsとストロングパラメータの形(ネストの仕方)が一致していることが分かります。

ここまでの実装で、実際にフォームを使って送信してみると・・・
![as3.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/346814/887566fd-b735-e1be-06c2-e2cd0b888195.jpeg)
`@team.save`しか書いてないのにMemberもcreateされてます！
membersテーブルのteam_idカラムにもきちんと今作ったteamのidが入っています。

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

```ruby:new.html.haml
= render "form"
```

```ruby:edit.html.haml
= render "form"
```

```ruby:_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の基本的なものを書いておきます。

```ruby: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リファレンス](https://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html)から引用します。
このリファレンスではmemberが親モデルでpostsが子モデルです。

>If the hash contains an id key that matches an already associated record, the matching record will be modified:

```ruby
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ガイド](https://railsguides.jp/form_helpers.html#%E3%83%8D%E3%82%B9%E3%83%88%E3%81%97%E3%81%9F%E3%83%95%E3%82%A9%E3%83%BC%E3%83%A0)から引用します。

>関連付けられたオブジェクトが既に保存されている場合、fields_forメソッドは、保存されたレコードのidを持つ隠し入力を自動的に作成します。

`関連付けられたオブジェクトが既に保存されている場合`=>つまりeditでは
`保存されたレコードのidを持つ隠し入力を自動的に作成します`=>APIリファレンスで必要と書かれていた`id`キー用のinputを自動生成します。

実際にeditの画面で検証ツールを開くと、作った覚えの無いinputができてます。
![as4.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/346814/ca258557-17a8-f0db-0ea2-9ac9723f0c1b.jpeg)
必要なキーが自動生成されてるってことはもしかしてもう実装終わり？Rails凄すぎない？
と思って送信してみたら期待とは違ったことになりました。
![as5.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/346814/9d153b76-8abe-5a9f-1809-49ec97f278c7.jpeg)
Teamはupdateですが、Memberはcreateとなっています。実際に既存レコードの更新ではなく1件増えてしまいました。
しかし、こうなってしまった原因もこの中に書いていますね。
`Unpermitted parameter: :id`
idというキーでparamsは送られてきたが、permitできなかった。
つまり**ストロングパラメータでidを許可していない**ことが原因です。

```ruby:controller
def team_params
  params.require(:team).permit(:name, members_attributes: [:nickname, :age, :id])
end
```
`:age`の後ろに`:id`を追加しました。
そして再度editからフォームを送信してみると・・・
![as6.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/346814/1f4290a3-3644-e3c9-9c30-2957d8e5cf87.jpeg)
Memberもupdateになりました！やっぱりRails凄すぎない？(10分ぶり2回目)
実際にDBの値を確認すると更新されていたのでこれで実装完了です。

## 削除できるようにする
[Railsガイド](https://railsguides.jp/form_helpers.html#%E3%82%AA%E3%83%96%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%82%92%E5%89%8A%E9%99%A4%E3%81%99%E3%82%8B)に丁寧に書いてあったのでそのまま引用＆実装していきます。

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

これは[モデルの実装](#モデルの実装)で書いたので大丈夫ですね。

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

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

```ruby:_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個ずつ生成できているのが分かります。
これにチェックを入れればそのmemberは削除される、という寸法です。
![as7.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/346814/53155a43-f6d6-ddc8-cb56-31396b18f535.jpeg)
そして最後に、**ストロングパラメータに_destroyを追加**します。
updateの時の失敗は繰り返さない！

```ruby:controller
  def team_params
    params.require(:team).permit(:name, members_attributes: [:nickname, :age, :id, :_destroy])
  end
```
試しにチェックボックスにチェックを入れて送信してみると・・・
![as8.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/346814/cf24e50f-e7a9-fd03-ba66-44bb3010900a.jpeg)
削除されました。Rails凄すぎない？(20分ぶり3回目)
削除機能も実装完了です。

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

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

```ruby:controller

def edit
  @team = Team.find(params[:id])
  @team.members.new # ここを追記
end
```
editの定番処理`@team = Team.find(params[:id])`に加えて、newの時に書いたmemberのインスタンスを作る処理を追加しました。
![as9.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/346814/38a2fd93-6bd9-d057-1407-baa3d1110d88.jpeg)
既存メンバーが1人のteamのeditでこのようになりました。いい感じです。
![as10.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/346814/ccd8fff3-ba74-de88-219f-2a854a63a765.jpeg)
実際に既存メンバーのupdate＋新規メンバーのcreateができています。解決！

...と思ったのですが。こんな落とし穴がありました
![as11.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/346814/a99f366c-486f-cbdb-6e23-5fdca9cf74ae.jpeg)
editで新規メンバーを追加したくないので空けたまま送信します。
![as12.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/346814/dadb77ef-c36b-af78-0174-4036a705ff3a.jpeg)
名無しさんがcreateされてしまいました。
これは良くないですね。バリデーションをきちんと実装した場合、そもそも保存に失敗しそうです。
どうしようかと思ったら[Railsガイド](https://railsguides.jp/form_helpers.html#%E7%A9%BA%E3%81%AE%E3%83%AC%E3%82%B3%E3%83%BC%E3%83%89%E3%81%8C%E3%81%A7%E3%81%8D%E3%81%AA%E3%81%84%E3%82%88%E3%81%86%E3%81%AB%E3%81%99%E3%82%8B)に書いてありました。

>ユーザーが何も入力しなかったフィールドを無視できれば何かと便利です。これは、: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ガイド](https://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html)を`reject_if`で検索すると書き方がいくつか載ってます）

```ruby: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/346814/d4a08390-a94c-676f-2c9b-ab6e812a3809.jpeg)
これで`age`に100を入力すると、paramsは送信できていますがmemberのcreateはされず・・・
![as14.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/346814/7ec1889a-9bf0-319c-3f8e-e59598d2ca6e.jpeg)
nicknameとageが揃っていればmemberがcreateされるようになりました！
最低限やりたいことが実装できたため、今回の実装はここまでにします。

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

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

## 参考サイト
[Rails APIリファレンス](https://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html)
[Railsガイド](https://railsguides.jp/form_helpers.html)