はじめに
Railsで関連モデルを保存する際の挙動に詰まったため、備忘録としてまとめます。
※この実装は不採用としたので、あまり細かくは調査していません。
この記事におけるバージョンは Rails 7.2.0 です。
問題
実装方針の1つの選択肢として、諸々の事情から下記のように親モデルのbefore_save
で子モデルと孫モデルをビルドする方法を検討していました(コールバックは本当は避けたい、、、)
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
そして次のように親モデルを作成します。
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">
子モデルだけを保存する場合は処理が成功する
子モデルだけを一緒に保存する場合はうまくいきます。
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'
↑のログのように、きちんとトランザクションがコミットされています。
そのため、なぜ孫モデルを追加した時だけ処理が失敗するのかが分かりませんでした。
なお子モデル・孫モデルのファイルは次のとおりです。
class Child < ApplicationRecord
belongs_to :parent
has_many :grandchildren, dependent: :destroy
validates :title, presence: true
validates :parent_id, presence: true
end
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
を付与することで孫モデルも一緒に保存されました。
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の関連付けについて色々と調べる機会になりました。
参考資料