[ActiveRecord] has_oneなモデルをbuildする時の意外な挙動

Ruby on Rails

Ruby on Railsでは、あるActiveRecordのModelの関連先のインスタンスのメソッドを作るためのメソッドとして build メソッド(build_association メソッド)があります。

例えば以下のようなUserモデルにおいて

class User < ActiveRecord::Base
  has_one :project, dependent: :destroy
end

関連先であるproject のインスタンスを以下のように作ることができます。

user = User.find(1)
project = user.build_project # similar to user.project = Project.new

build なので、DBにはまだ保存されていない状態のインスタンスであり、save 等行えばDBに保存されます。
しかし、「DBに保存されていない状態」インスタンスではあるのですが、DBの操作が行われるケースがあったので、備忘的に残しておきます。

buildでDB操作が行われるケースとは?

それは既に user に紐づくprojectが存在している場合です。
この状態で user.build_project すると、実行時に既に紐づいている project を削除してしまいます。

$ user = User.find(1)
  User Load (6.7ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1 /*application='Myapp'*/
=> #<User:0x0000ffff5dc581b0 id: 3, name: "Bob", created_at: "2025-02-04 12:09:01.621168000 +0000", updated_at: "2025-02-04 12:09:01.621168000 +0000">
$ user.project
  Project Load (2.5ms)  SELECT `projects`.* FROM `projects` WHERE `projects`.`user_id` = 1 LIMIT 1 /*application='Myapp'*/
=> 
#<Project:0x0000ffff66560368
 id: 2,
 user_id: 1,
 created_at: "2025-02-04 12:17:57.823714000 +0000",
 updated_at: "2025-02-04 12:17:57.823714000 +0000">
$ user.build_project # Destroyが走ってる!
  Project Destroy (3.2ms)  DELETE FROM `projects` WHERE `projects`.`id` = 2 /*application='Myapp'*/
=> #<Project:0x0000ffff5dc30b60 id: nil, content: nil, user_id: 3, created_at: nil, updated_at: nil>

build という言葉の意味から、DBの操作はてっきりしないと思い込んでしまうため、注意が必要です。
私が遭遇したケースでは、build をトランザクション外で行っていたため、異常時ロールバックができないという事態が発生してしまっていました。

project = Project.find(params[:id])
# project に紐づく user が存在していたが、build により destroyされてしまった
user = project.build_user
user.name = 'John Doe'
ActiveRecord::Base.transaction do
  # バリデーションエラー等が発生しても destroy はトランザクション外で
  # 既に実行済みなのでロールバックされない!
  user.save!
end

どこで削除が走っている?

なぜこんな挙動なのかは分からないので、ActiveRecordのソースを読んでみました。
原因は has_one_association.rb 内の remove_target! にありました。

        def remove_target!(method)
          case method
          when :delete
            target.delete # ここで delete もしてる!
          when :destroy
            target.destroyed_by_association = reflection
            if target.persisted?
              target.destroy # ここで destroy してる!
            end
          else
            ...

dependent: :destroy だけでなく dependent: :delete も同様に削除されてしまうようです。
なお例えばdependent: :nullifyといった他のオプションの場合は else 内を通りますが、build の場合はいずれもエラーが発生するようになっていそうでした。

build から順に追うと以下の流れ。

  1. https://github.com/rails/rails/blob/593dfca100eb1bf5f83adebde94b4e01770d67c4/activerecord/lib/active_record/associations/builder/singular_association.rb#L32-L34
  2. https://github.com/rails/rails/blob/593dfca100eb1bf5f83adebde94b4e01770d67c4/activerecord/lib/active_record/associations/singular_association.rb#L29-L33
  3. https://github.com/rails/rails/blob/593dfca100eb1bf5f83adebde94b4e01770d67c4/activerecord/lib/active_record/associations/has_one_association.rb#L91-L93
  4. https://github.com/rails/rails/blob/593dfca100eb1bf5f83adebde94b4e01770d67c4/activerecord/lib/active_record/associations/has_one_association.rb#L59-L69

なお、project.build_user といった逆向きは削除されません。
あくまで has_one の関連付けだけのようです。

なぜこんな挙動になっているかというところは結局分からなかったのですが、どういう意図からこうなったのかすごく気になります…
とりあえず has_one での build には気をつけるようにしましょう。

タイトルとURLをコピーしました