ActiveRecord::Store の *_change 系メソッドが未変更キーを誤って報告するバグを修正

rails/rails

ActiveRecord::Store が生成する <key>_changesaved_change_to_<key> が、同一ストア列の他キーが変更された際に [value, value] ペアを返す問題を解消した。これにより、変更が無いキーに対しても正しく nil が返り、Dirty Tracking API の一貫性が保たれるようになった。

背景

ActiveRecord::Storestore_accessor を通じて個別キー用のアクセサと Dirty メソッドを動的に定義する。従来、<key>_change 系メソッドは attribute_changed?(store_attribute) でストア全体の変更を判定した後、prev_storenew_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_changenil になること、保存後の saved_change_to_homepagenil になることを検証している。

設計判断

修正は *_change 系メソッドだけ の挙動を attr_change 系の契約に合わせて厳密にすることを目的とした。*_changed? 系プリディケートは既にキー単位で正しく動作しているため、これらを変更せずに *_change 系だけを調整する方針が採られた。これにより、既存コードが *_changenil でチェックするロジックを壊すことなく、誤ったペアの出力だけを排除できる。

*_was および *_before_last_save は、標準的な attr_was / attribute_before_last_save と同様に「現在の値」を返す仕様であるため変更していない。つまり、変更が無い場合は nil、変更がある場合は前後の値 という一貫した API になるよう意図的に設計されている。

この判断は、後方互換性と API の予測可能性を最優先し、最小限のコード変更でバグを除去するという実務的なトレードオフを示している。開発者は *_change 系が nil を返すことを前提に利用でき、不要なガードロジックを追加する必要がなくなる。

まとめ

store_accessor が生成する <key>_changesaved_change_to_<key> は、キーが実際に変化したときだけ変更ペアを返すよう修正された。これにより、未変更のキーに対して誤った [value, value] が返るバグが解消され、Dirty Tracking API の一貫性が向上した。プリディケート系はそのまま残し、後方互換性を保った設計判断が行われた。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
d2211c8c

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文がタイトル直下にあり、背景、技術的な変更、設計判断、まとめの各セクションが揃っている。まとめは単なる繰り返しではなく、変更の意義を抽象的に述べている。

カスタムMarkdown構文 ⚠ WARNING

シンタックスハイライト・GitHubリンク記法の正確性

コードブロックは正しいdiff形式だが、PRへのリンクが指定形式 [#123](URL) ではなく "[PR #57519](URL)" となっている。GitHubリンク記法の要件を満たしていない。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

内容はActiveRecordの内部実装に関する技術的説明で、エンジニア向けの深さが保たれている。初心者向けの余計な解説はない。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションは総論→各論→結論の構成になり、段落はトピックセンテンスで始まり1トピックに絞られている。段落は6文以内で空行で区切られている。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事中のdiffコードブロックは提供されたDiffと内容・行番号ともに一致している。変更点の説明も正確。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

ActiveRecord::Store、store_accessor、*_change などの用語はPRと一致し、誤用はない。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

バグの原因と修正ロジックの説明がPRの記述と合致しており、技術的に正確。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事の主張はすべてPRのタイトル、説明、Diffで裏付けられている。外部情報や憶測は含まれていない。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号 #57519、7 年という期間など数値は正確に記述されている。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事タイトルはPRの内容を正確に反映している。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

記事内にPRに記載されていないバージョンサポート情報やリリース日程は含まれていない。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

「7 年以上放置された状態」とPRの「~7 years」の記述が一致し、時間表現に歪曲はない。