この件、関連するPRやIssueが複数あってコメントも分散しており、人に説明するのがややこしいのでブログとしてまとめたものになります。間違いや意見などあったらコメントください!
追記(2023/08/02)
7-0-stableブランチに今回の変更をrevertするコミットが入りました。挙動が変わって困った人が出たので一度元に戻して仕切り直しにする方針のようです。
7.0.7がいつリリースされるかはわかりませんが、今の状態でリリースされたら7.0.4の振る舞いに戻ることになります。7.0.5以降の変更で困っている人は一旦7-0-stableを指すようにすると良いかもしれません。
概要
- Railsの7.0.4と7.0.5以降ではhas_one関連を定義したときに生成されるcreate_associationメソッドの挙動が違う
- create_associationの振る舞いを「新しい関連先のレコードを作る」という挙動だけだと思っていると、7.0.5以降を使うためにコードを修正する必要があるかもしれない
前提
- has_one関連で、すでに関連先が永続化されているときに新しく関連先を作ろうとすると、既存の関連先は削除される*1
- 削除のされ方はhas_oneのdependentオプションによって決まる*2
:nullify
、もしくはdependentオプションを設定していないときは外部キーをNULLにしてupdate:destory
のときはdestroyメソッドで削除:delete
のときはdeleteメソッドで削除
例えば次のコードでの(1)~(3)の全てで、既存の関連先に紐づくレコードがあればそれを削除します。
class User < ApplicationRecord has_one :address, dependent: :destroy end class Address < ApplicationRecord belongs_to :user end user = User.find(42) user.address = Address.new # (1) user.create_address # (2) user.build_address # (3)
Rails7.0.4までの挙動
これまで、create_associationの挙動は Improve singular association creation by lazaronixon · Pull Request #46386 · rails/rails で説明があるように別々のトランザクションでinsertしてからdeleteするという挙動でした。
# begin transaction INSERT new_record; # commit transaction # begin transaction DELETE old_record; # commit transaction
さらに、先行のinsert(saveメソッド経由)がバリデーションエラーなどで失敗しても、後続のdeleteは実行されます。この挙動により、既存のレコードがある状態でcreate_association!をした際にバリデーションエラーになっても既存のレコードが削除される、という不具合がありました。
他にも、次のような流れで既存レコードの削除が効かない、という問題もありました。
- バリデーションが通った際は新規にレコードを作成する
- 次に既存のレコードを削除する
- 削除するための既存のレコードの取得はhas_one関連経由になる
- has_one関連のorder byが設定されていない場合(has_oneでorder by設定することはほぼないと思われるので、ほとんどのケースが該当する)で稀に作ったばかりのレコードを取得してしまう
- この作ったばかりのレコードが取得された場合は削除するレコードがないという扱いになる
- 結果として、削除が効かずレコードが2件以上存在する
関連Issue: Sometimes create_association
does not delete existing records · Issue #47554 · rails/rails
Rails7.0.5以降の挙動
7.0.5では次のように同一トランザクションでdeleteしてからinsertする、という挙動に変わりました。上記の不具合はほぼ解消*3されました。
# begin transaction DELETE old_record; INSERT new_record; # commit transaction
しかし、もともとhas_oneの外部キーに対してユニーク制約をかけており、既存のレコードがあるときにcreate_associationするとバリデーションエラーとしたいアプリケーションにとってはコードの修正が必要になります。
所感
7.0.5における変更は「前提」の節で説明した挙動の整合性を取るために必要な修正であったと個人的に感じていますが、一方で「association=
やbuild_association
やcreate_association
をしたら既存のレコードが削除される」というのは知らない人にとってはびっくりする挙動とも思うので、今回の変更でコードの修正を強いられてつらい、という人の気持ちもわかります。
これ、そもそもどのような挙動をするとみんなが幸せになれるんでしょうね…???
*1:association=がその挙動をするのは書かれている http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#method-i-has_one けれど、build_associationやcreate_associationはRailsガイド含めて書かれていなさそう
*2: コード的にはこのあたりが該当します。 https://github.com/rails/rails/blob/89508b95d8d289b430cca7db05109d8171ff7a5f/activerecord/lib/active_record/associations/has_one_association.rb#L94-L116
*3:!なしのcreate_associationでバリデーションエラーになったときは変わらず既存のレコードがDELETEされるのですが、これはbuild_associationしてsaveしても一緒なのでそういう仕様と言えなくもない