ActiveModel::Errors#import が渡されたハッシュを破壊しないように修正
ActiveModel::Errors#import が呼び出し元から受け取った override_options ハッシュを直接書き換えていたバグを解消し、ハッシュのミューテーションを防止しました。これにより、オプションハッシュを再利用できるようになり、FrozenHash に対する例外も回避できます。
背景
ActiveModel::Errors#import はエラーオブジェクトを別モデルへコピーするための公開 API ですが、内部で [:attribute, :type] キーをシンボル化する際にハッシュをその場で上書きしていました。呼び出し側が同じハッシュを複数回 import に渡すと、文字列がシンボルに変換されたまま残り、期待した値が失われます。
このミューテーションは二つの具体的な問題を引き起こします。まずハッシュが破壊されるため、再利用時に元の文字列が失われ、デバッグやロギングが困難になります。さらにハッシュが freeze されている場合、書き換えが FrozenError を発生させ、import が例外をスローして処理が中断します。これらは PR 本文でも指摘されている通り、内部テストではカバーされていなかったケースです。
技術的な変更
import メソッドの冒頭で override_options = override_options.dup を追加し、受け取ったハッシュの浅いコピーを作成するようにしました。コピー後のハッシュに対してシンボル化を行うため、呼び出し元のオブジェクトは全く変更されません。
@@
- [:attribute, :type].each do |key|
- if override_options.key?(key)
- override_options[key] = override_options[key].to_sym
- end
- end
+ override_options = override_options.dup
+ [:attribute, :type].each do |key|
+ if override_options.key?(key)
+ override_options[key] = override_options[key].to_sym
+ end
+ end
さらに、テストスイートにハッシュが変化しないことを検証するケースが追加されました。errors.import 後に元のハッシュが文字列のままで残ることと、freeze されたハッシュでも例外が発生しないことを確認しています。
@@
- test "import does not mutate the override options passed in" do
- person = Person.new
- original_error = ActiveModel::Error.new(Person.new, :name, :invalid)
-
- options = { attribute: "age", type: "blank" }
- person.errors.import(original_error, options)
-
- assert_equal({ attribute: "age", type: "blank" }, options)
- end
+ test "import does not mutate the override options passed in" do
+ person = Person.new
+ original_error = ActiveModel::Error.new(Person.new, :name, :invalid)
+
+ options = { attribute: "age", type: "blank" }
+ person.errors.import(original_error, options)
+
+ assert_equal({ attribute: "age", type: "blank" }, options)
+ end
設計判断
ハッシュのコピーには 浅い dup を採用したのは、override_options がトップレベルの :attribute と :type だけを書き換えるシンプルな構造であるためです。深いコピーは不要であり、余計なコストや副作用を招く恐れがあります。結果として API の振る舞いは変わらず、import が受け取るハッシュは「読んで消費する」ものから「読み取り専用」に変わります。
この修正は同様の問題を抱えていた #57782(URL ビルダーが params ハッシュを破壊)や #23103(ルーティングがパラメータを変更)と同系統のバグ修正として位置付けられます。Rails コアチームは「外部から渡されたハッシュを破壊しない」方針を再度確認し、既存の公開 API が予期せぬ副作用を持たないよう統一的に対処しています。
選択された実装は後方互換性を保ちつつ、最小限のコード変更で機能を安全に拡張するというトレードオフを示しています。呼び出し側は既存のシンボル化ロジックをそのまま利用でき、追加のマイグレーションは不要です。
まとめ
ActiveModel::Errors#import が渡されたハッシュをミューテートしないように修正したことで、オプションハッシュの再利用と frozen オブジェクトへの安全な適用が可能になりました。浅い dup に留めた設計は API の一貫性を保ちつつ、過去の同種バグ修正と同様の安全性確保を実現しています。