store_accessor の NULL 構造化カラム読み取りでレコードが汚染されるバグを修正

rails/rails

store_accessor を JSON / JSONB / Hstore のような NULL 許容の構造化カラム で使用した際、単なる読み取りがレコードを変更状態にし、次回保存時に NULL が空ハッシュ {} に置き換わってしまう問題を解消しました。

背景

Rails のドキュメントは、PostgreSQL の hstore/json や MySQL の json など 構造化データ型 では store のシリアライズ機能は不要で、代わりに .store_accessor を使うことを推奨しています。しかし、実際に NULL が格納されたカラムに対して accessor を読み取ると、レコードが dirty と判定され、保存時に NULL{} に上書きされるというバグがありました。

この挙動は、ActiveRecord::Store::HashAccessor.preparenil のカラムを空ハッシュに変換し、読み取り時にそのハッシュを書き戻す 実装に起因しています。結果として doc.changed?true となり、save if changed? といったガードロジックが意図せず走るほか、after_save コールバックや touch が不要に発火するという副作用が発生していました。

技術的な変更

読み取りロジックの非破壊化

HashAccessor.readprepare 経由でハッシュを生成・書き戻す実装を廃止し、現在のストアオブジェクトをそのまま取得する get メソッド を呼び出すだけに変更しました。

@@
- def self.read(object, attribute, key)
-   store_object = prepare(object, attribute)
-   store_object[key]
- end
+ def self.read(object, attribute, key)
+   get(object.public_send(attribute), key)
+ end

prepare は依然として 書き込みパス のみで使用され、NULL カラムに対してキーを書き込む際にハッシュを生成してレコードに代入します。

IndifferentHashAccessor の get 実装追加

シンボル/文字列キーの双方でアクセスできる IndifferentHashAccessor も、read が参照する get をオーバーライドして インデファレントハッシュへ変換した結果を返す ようにしました。

@@
 class IndifferentHashAccessor < ActiveRecord::Store::HashAccessor # :nodoc:
+  def self.get(store_object, key)
+    if store_object
+      IndifferentCoder.as_indifferent_hash(store_object)[key]
+    end
+  end

テスト追加

store_test.rbNULL カラム上でのアクセサ読み取りがレコードを汚染しないこと を検証するテストを追加し、SQLite、PostgreSQL、MySQL の各アダプタでパスが通ることを確認しました。

@@
   test "reading a store accessor on a null structured column does not mutate or persist the store" do
@@
     assert_nil john.enable_friend_requests
@@
     john.name = "John Roe"
     john.save!
     assert_nil john.reload.json_options
   end

設計判断

読み取りを副作用なしにする方向 が採用されました。prepare を読み取りに使っていたコードは、書き込み専用に限定し、既存の HashAccessorIndifferentHashAccessor が共有していたミューテーションロジックを分離した点が主な設計変更です。

この判断は、後方互換性の維持予期しない dirty トラッキングの防止 を同時に実現するために行われました。store_accessor の利用パターンは多数存在するため、既存コードが破壊的変更を受けずに安全に動作し続けられることが重要です。変更は read の実装のみであり、store_accessor を既に使用しているアプリケーションへの影響はありません。

まとめ

store_accessorNULL の構造化カラムを読むだけでレコードを dirty にしていたバグを、読み取りロジックを非破壊化し、IndifferentHashAccessor にも安全な get 実装を追加する形で修正しました。これにより、NULL がそのまま保持され、save if changed? などのガードロジックやコールバックが不要に走ることがなくなります。

記事メタデータ

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

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

リード文、背景、技術的変更、設計判断(任意)、まとめの5要素が明確に配置され、総論→各論→結論の流れが保たれています。

カスタムMarkdown構文 ⚠ WARNING

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

コードブロックは正しい形式(```ruby:ファイルパス)で記載されていますが、PRリンクが "[PR #57600](URL)" となっており、仕様の "[#123](URL)" 形式と完全には一致していません。

対象読者への適合性 ✓ PASS

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

Rails の内部実装に関する詳細が記述されており、エンジニア向けの適切な技術レベルです。初心者向けの余計な説明はありません。

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

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

各セクションは総論→各論→結論の構成になり、段落の先頭がトピックセンテンスになっています。段落は6文未満で、空行で区切られています。

Diff内容との照合 ✓ PASS

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

記事中のコードブロックは提供された Diff と完全に一致しており、変更点(read の非破壊化、IndifferentHashAccessor の get 追加、テスト追加)が正確に反映されています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

store_accessor、NULL、dirty、callback などの用語は PR と合致しており、誤用はありません。

説明の技術的正確性 ✓ PASS

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

技術的な説明は PR の記述と整合し、原因・修正内容・影響が論理的に述べられています。

事実の突合 ✓ PASS

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

記事の全ての主張は PR の Title、Description、Diff に裏付けられており、捏造や推測は見られません。

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

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

PR 番号 #57600 が正しく記載され、他の数値や固有名詞の誤りはありません。

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

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

タイトルは PR の内容(NULL 構造化カラムの read がミューテートするバグ修正)を適切に表現しています。

外部知識の正確性 ✓ PASS

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

記事は PR の情報以外の外部知識(LTS、リリース日程等)を追加していません。

時間表現の正確性 ✓ PASS

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

時間表現の歪曲はなく、PR の記述と一致しています。