store_accessor の NULL 構造化カラム読み取りでレコードが汚染されるバグを修正
store_accessor を JSON / JSONB / Hstore のような NULL 許容の構造化カラム で使用した際、単なる読み取りがレコードを変更状態にし、次回保存時に NULL が空ハッシュ {} に置き換わってしまう問題を解消しました。
背景
Rails のドキュメントは、PostgreSQL の hstore/json や MySQL の json など 構造化データ型 では store のシリアライズ機能は不要で、代わりに .store_accessor を使うことを推奨しています。しかし、実際に NULL が格納されたカラムに対して accessor を読み取ると、レコードが dirty と判定され、保存時に NULL が {} に上書きされるというバグがありました。
この挙動は、ActiveRecord::Store::HashAccessor.prepare が nil のカラムを空ハッシュに変換し、読み取り時にそのハッシュを書き戻す 実装に起因しています。結果として doc.changed? が true となり、save if changed? といったガードロジックが意図せず走るほか、after_save コールバックや touch が不要に発火するという副作用が発生していました。
技術的な変更
読み取りロジックの非破壊化
HashAccessor.read が prepare 経由でハッシュを生成・書き戻す実装を廃止し、現在のストアオブジェクトをそのまま取得する 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.rb に NULL カラム上でのアクセサ読み取りがレコードを汚染しないこと を検証するテストを追加し、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 を読み取りに使っていたコードは、書き込み専用に限定し、既存の HashAccessor と IndifferentHashAccessor が共有していたミューテーションロジックを分離した点が主な設計変更です。
この判断は、後方互換性の維持 と 予期しない dirty トラッキングの防止 を同時に実現するために行われました。store_accessor の利用パターンは多数存在するため、既存コードが破壊的変更を受けずに安全に動作し続けられることが重要です。変更は read の実装のみであり、store_accessor を既に使用しているアプリケーションへの影響はありません。
まとめ
store_accessor が NULL の構造化カラムを読むだけでレコードを dirty にしていたバグを、読み取りロジックを非破壊化し、IndifferentHashAccessor にも安全な get 実装を追加する形で修正しました。これにより、NULL がそのまま保持され、save if changed? などのガードロジックやコールバックが不要に走ることがなくなります。