おもしろwebサービス開発日記

Ruby や Rails を中心に、web技術について書いています

Rails 7.0.5以降におけるcreate_associationメソッドの挙動変更についてまとめ

この件、関連するPRやIssueが複数あってコメントも分散しており、人に説明するのがややこしいのでブログとしてまとめたものになります。間違いや意見などあったらコメントください!

追記(2023/08/02)

7-0-stableブランチに今回の変更をrevertするコミットが入りました。挙動が変わって困った人が出たので一度元に戻して仕切り直しにする方針のようです。

[7-0-stable] Revert singular association breaking changes by zzak · Pull Request #48809 · rails/rails

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!をした際にバリデーションエラーになっても既存のレコードが削除される、という不具合がありました。

関連Issue: has_one association getting deleted on using create_association & validation fails · Issue #46737 · rails/rails

他にも、次のような流れで既存レコードの削除が効かない、という問題もありました。

  1. バリデーションが通った際は新規にレコードを作成する
  2. 次に既存のレコードを削除する
  3. 削除するための既存のレコードの取得はhas_one関連経由になる
  4. has_one関連のorder byが設定されていない場合(has_oneでorder by設定することはほぼないと思われるので、ほとんどのケースが該当する)で稀に作ったばかりのレコードを取得してしまう
  5. この作ったばかりのレコードが取得された場合は削除するレコードがないという扱いになる
  6. 結果として、削除が効かずレコードが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するとバリデーションエラーとしたいアプリケーションにとってはコードの修正が必要になります。

関連Issue: has_one relation start deleting existing record even when new record failed passing validation · Issue #48330 · rails/rails

所感

7.0.5における変更は「前提」の節で説明した挙動の整合性を取るために必要な修正であったと個人的に感じていますが、一方で「association=build_associationcreate_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しても一緒なのでそういう仕様と言えなくもない