ActiveRecord::Store の *_change 系メソッドが未変更キーを誤って報告するバグを修正
ActiveRecord::Store が生成する <key>_change と saved_change_to_<key> が、同一ストア列の他キーが変更された際に [value, value] ペアを返す問題を解消した。これにより、変更が無いキーに対しても正しく nil が返り、Dirty Tracking API の一貫性が保たれるようになった。
背景
ActiveRecord::Store は store_accessor を通じて個別キー用のアクセサと Dirty メソッドを動的に定義する。従来、<key>_change 系メソッドは attribute_changed?(store_attribute) でストア全体の変更を判定した後、prev_store と new_store から対象キーの値を取得し、無条件に [prev, new] を返していた。その結果、同一ストア列内で別キーが変更されただけでも、変更されていないキーが [value, value] というペアを返すというバグが発生した。
この挙動は *_changed? 系プリディケートが正しく false を返すにも関わらず、*_change 系が nil ではなく同一値の配列を返すため、prev, cur = record.size_change のように直接ペアを利用するコードで誤った結果が混入するリスクがあった。テストスイートでもこのケースは網羅されておらず、7 年以上放置された状態であった。
技術的な変更
activerecord/lib/active_record/store.rb の定義部を書き換え、変更前後の値を取得した後に キー単位で等価比較 を行うロジックを追加した。具体的には prev_value, new_value = accessor.get(prev_store, key), accessor.get(new_store, key) を取得し、prev_value == new_value でなければ [prev_value, new_value] を返し、等しい場合は nil を返すようにした。コード変更は以下の通りである。
@@ -160,7 +160,8 @@ def store_accessor(store_attribute, *keys, prefix: nil, suffix: nil)
return unless attribute_changed?(store_attribute)
prev_store, new_store = changes[store_attribute]
accessor = store_accessor_for(store_attribute)
- [accessor.get(prev_store, key), accessor.get(new_store, key)]
+ prev_value, new_value = accessor.get(prev_store, key), accessor.get(new_store, key)
+ [prev_value, new_value] unless prev_value == new_value
end
saved_change_to_<key> についても同様の修正を加え、
@@ -181,7 +182,8 @@ def store_accessor(store_attribute, *keys, prefix: nil, suffix: nil)
return unless saved_change_to_attribute?(store_attribute)
prev_store, new_store = saved_changes[store_attribute]
accessor = store_accessor_for(store_attribute)
- [accessor.get(prev_store, key), accessor.get(new_store, key)]
+ prev_value, new_value = accessor.get(prev_store, key), accessor.get(new_store, key)
+ [prev_value, new_value] unless prev_value == new_value
end
この変更に合わせてテスト activerecord/test/cases/store_test.rb に、兄弟アクセサが変更されても *_change 系が nil を返すことを確認する回帰テストを追加した。テストは color を変更した際に homepage_change が nil になること、保存後の saved_change_to_homepage も nil になることを検証している。
設計判断
修正は *_change 系メソッドだけ の挙動を attr_change 系の契約に合わせて厳密にすることを目的とした。*_changed? 系プリディケートは既にキー単位で正しく動作しているため、これらを変更せずに *_change 系だけを調整する方針が採られた。これにより、既存コードが *_change を nil でチェックするロジックを壊すことなく、誤ったペアの出力だけを排除できる。
*_was および *_before_last_save は、標準的な attr_was / attribute_before_last_save と同様に「現在の値」を返す仕様であるため変更していない。つまり、変更が無い場合は nil、変更がある場合は前後の値 という一貫した API になるよう意図的に設計されている。
この判断は、後方互換性と API の予測可能性を最優先し、最小限のコード変更でバグを除去するという実務的なトレードオフを示している。開発者は *_change 系が nil を返すことを前提に利用でき、不要なガードロジックを追加する必要がなくなる。
まとめ
store_accessor が生成する <key>_change と saved_change_to_<key> は、キーが実際に変化したときだけ変更ペアを返すよう修正された。これにより、未変更のキーに対して誤った [value, value] が返るバグが解消され、Dirty Tracking API の一貫性が向上した。プリディケート系はそのまま残し、後方互換性を保った設計判断が行われた。