【Rails】accepts_nested_attributes_forを使わないとbefore_saveで孫モデルのオブジェクトをビルドしても保存できない

## はじめに

Railsで関連モデルを保存する際の挙動に詰まったため、備忘録としてまとめます。

※この実装は不採用としたので、あまり細かくは調査していません。

この記事におけるバージョンは **Rails 7.2.0** です。

## 問題

実装方針の1つの選択肢として、諸々の事情から下記のように親モデルの`before_save`で子モデルと孫モデルをビルドする方法を検討していました（コールバックは本当は避けたい、、、）

```ruby:app/models/parent.rb
class Parent < ApplicationRecord
  has_many :children, dependent: :destroy
  validates :title, presence: true

  before_save :build_child

  # 管理者ID
  ADMIN_USER_ID = 1

  # 子モデルと孫モデルのオブジェクトをビルド
  def build_child
    child = children.build(
      title: "#{self.title} の Child",
      description: "#{self.title} の Child です"
    )

    child.grandchildren.build(
      title: "#{child.title} の Grandchild",
      description: "#{child.title} の Grandchild です",
      another_child_id: ADMIN_USER_ID  # 固定のIDを登録
    )
  end
end
```

そして次のように親モデルを作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2325281/235abb1f-ad36-b917-6e81-7a58510410a3.png)

```ruby:app/controllers/parents_controller.rb
class ParentsController < ApplicationController
  def new
    @parent = Parent.new
  end

  def create
    parent = Parent.new(parent_params)
    if parent.save
      flash[:success] = "Parent created successfully!"
    else
      render :new
    end
  end

  private

  def parent_params
    params.require(:parent).permit(:title, :description)
  end
end
```

`parent.save`を実行したタイミングで、子モデル・孫モデルのレコードも一緒に作成されることを期待していました。

しかし実際には、処理が失敗しトランザクションはロールバックされてしまいます。

```bash
  TRANSACTION (0.0ms)  begin transaction
  ↳ app/models/parent.rb:9:in `build_child'
  Parent Create (0.4ms)  INSERT INTO "parents" ("title", "description", "created_at", "updated_at") VALUES (?, ?, ?, ?) RETURNING "id"  [["title", "parent1"], ["description", "parent1"], ["created_at", "2024-09-02 03:43:28.429034"], ["updated_at", "2024-09-02 03:43:28.429034"]]
  ↳ app/controllers/parents_controller.rb:8:in `create'
  AnotherChild Load (0.2ms)  SELECT "another_children".* FROM "another_children" WHERE "another_children"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/controllers/parents_controller.rb:8:in `create'
  TRANSACTION (0.0ms)  rollback transaction
  ↳ app/controllers/parents_controller.rb:8:in `create'
```

エラー内容から`AnotherChild`テーブルに`id = 1`のレコードが存在していないのかと思いましたが、確認してみるとたしかに存在していました。

```bash
sample> AnotherChild.find(1)
  AnotherChild Load (0.3ms)  SELECT "another_children".* FROM "another_children" WHERE "another_children"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<AnotherChild:0x000000013327dbf0 id: 1, title: "Test1", description: "test1", created_at: "2024-09-01 00:13:50.955950000 +0000", updated_at: "2024-09-01 00:13:50.955950000 +0000">
```

### 子モデルだけを保存する場合は処理が成功する

子モデルだけを一緒に保存する場合はうまくいきます。

```ruby:app/models/parent.rb
class Parent < ApplicationRecord
  has_many :children, dependent: :destroy
  validates :title, presence: true

  before_save :build_child

  # 子モデルのオブジェクトだけをビルドした場合は保存される
  def build_child
    children.build(
      title: "#{self.title} の Child",
      description: "#{self.title} の Child です"
    )
  end
end
```

```bash
  TRANSACTION (0.1ms)  begin transaction
  ↳ app/models/parent.rb:9:in `build_child'
  Parent Create (0.1ms)  INSERT INTO "parents" ("title", "description", "created_at", "updated_at") VALUES (?, ?, ?, ?) RETURNING "id"  [["title", "parent1"], ["description", "parent1"], ["created_at", "2024-09-02 03:46:09.747510"], ["updated_at", "2024-09-02 03:46:09.747510"]]
  ↳ app/controllers/parents_controller.rb:8:in `create'
  Child Create (0.1ms)  INSERT INTO "children" ("title", "description", "parent_id", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?) RETURNING "id"  [["title", "parent1 の Child"], ["description", "paent1 の Child です"], ["parent_id", 5], ["created_at", "2024-09-02 03:46:09.752423"], ["updated_at", "2024-09-02 03:46:09.752423"]]
  ↳ app/controllers/parents_controller.rb:8:in `create'
  TRANSACTION (0.6ms)  commit transaction
  ↳ app/controllers/parents_controller.rb:8:in `create'
```

↑のログのように、きちんとトランザクションがコミットされています。

そのため、なぜ孫モデルを追加した時だけ処理が失敗するのかが分かりませんでした。

なお子モデル・孫モデルのファイルは次のとおりです。

```ruby:app/models/child.rb
class Child < ApplicationRecord
  belongs_to :parent
  has_many :grandchildren, dependent: :destroy

  validates :title, presence: true
  validates :parent_id, presence: true
end
```

```ruby:app/models/grandchild.rb
class Grandchild < ApplicationRecord
  belongs_to :child
  belongs_to :another_child

  validates :title, presence: true
  validates :child_id, presence: true
  validates :another_child_id, presence: true
end
```

## 解決策

結論として、親モデルに`accepts_nested_attributes_for`を付与することで孫モデルも一緒に保存されました。

```ruby:app/models/parent.rb
class Parent < ApplicationRecord
  has_many :children, dependent: :destroy
  # 孫モデルも保存させる
  accepts_nested_attributes_for :children, allow_destroy: true
  validates :title, presence: true

  # 管理者ID
  ADMIN_USER_ID = 1

  before_save :build_child

  def build_child
    child = children.build(
      title: "#{self.title} の Child",
      description: "#{self.title} の Child です"
    )

    child.grandchildren.build(
      title: "#{child.title} の Grandchild",
      description: "#{child.title} の Grandchild です",
      another_child_id: ADMIN_USER_ID
    )
  end
end
```

Railsはデフォルトでは子モデルの関連付けまでしか自動で保存しないようです。

`accepts_nested_attributes_for`を親モデルに付与することで、孫モデルの保存までを成功させることができました。

## 注意点

`accepts_nested_attributes_for`は否定的な意見も多いです。

モデルの複雑性が増してしまうからです。

Railsの生みの親であるDHHも`accepts_nested_attributes_for`に否定的でした。

https://github.com/rails/rails/pull/26976#discussion_r87855694

そのほかにも`accepts_nested_attributes_for`を避ける動きが見られます。

https://moneyforward-dev.jp/entry/2018/12/15/formobject/

https://zenn.dev/murakamiiii/articles/5ecefb7a58d1ef#%E3%81%AA%E3%81%9C%E4%BD%BF%E7%94%A8%E3%81%97%E3%81%AA%E3%81%84%E6%96%B9%E3%81%8C%E3%82%88%E3%81%84%E3%81%AE%E3%81%8B

今回は挙動確認のためこの実装を試してみましたが、引き続き他の方法も模索していきます。

## おわりに

1つの検証としてこの記事を書いてみましたが、そもそも論で言えば最適な実装ではないと思います。

ただし手元で動かしてみたことで、Railsの関連付けについて色々と調べる機会になりました。

## 参考資料

https://api.rubyonrails.org/v7.2.0/classes/ActiveRecord/NestedAttributes/ClassMethods.html

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