0
0

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

Posted at

はじめに

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

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

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

問題

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

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

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を実行したタイミングで、子モデル・孫モデルのレコードも一緒に作成されることを期待していました。

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

  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のレコードが存在していないのかと思いましたが、確認してみるとたしかに存在していました。

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">

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

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

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
  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'

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

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

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

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
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を付与することで孫モデルも一緒に保存されました。

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に否定的でした。

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

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

おわりに

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

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

参考資料

0
0
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
0
0