JSONカラムのシリアライズで二重エンコードを防止する Guard 変更
この PR は serialize に対する JSON コーダの互換性ガードを拡張し、coder: ActiveRecord::Coders::JSON が JSON カラムで二重エンコードされるバグを修正します。
背景
serialize で JSON カラムに coder: JSON を指定すると ColumnNotSerializableError が発生し、適切に保護されますが、同等とみなされる coder: ActiveRecord::Coders::JSON がガードをすり抜けて二重エンコードが起きていました。
二重エンコードによりデータベースには "{\"a\":1}" のような JSON 文字列が保存され、シリアライズされていないモデルは文字列を取得してデータ破損が顕在化します。
この不整合は type_incompatible_with_serialize? が ::JSON 定数しか比較していなかったことが根本原因です。
技術的な変更
ガードチェックに渡す引数を正規化された column_serializer に置き換え、比較対象を統一しました。
変更前:
if type_incompatible_with_serialize?(cast_type, coder, type)
raise ColumnNotSerializableError.new(attr_name, cast_type)
end
変更後:
if type_incompatible_with_serialize?(cast_type, column_serializer, type)
raise ColumnNotSerializableError.new(attr_name, cast_type)
end
build_column_serializer では JSON コーダを Coders::JSON に正規化しているため、column_serializer は常に同一形式になります。正規化に合わせてガードが同じオブジェクトを受け取るようになりました。
type_incompatible_with_serialize? の条件式を拡張し、Coders::JSON === coder を含めることで ActiveRecord::Coders::JSON も検出できるようになりました。
def type_incompatible_with_serialize?(cast_type, coder, type)
cast_type.is_a?(ActiveRecord::Type::Json) && Coders::JSON === coder ||
cast_type.respond_to?(:type_cast_array, true) && type == ::Array
end
追加されたテスト test_not_compatible_with_serialize_active_record_coders_json が ColumnNotSerializableError を期待通りに発生させることを確認しています。
def test_not_compatible_with_serialize_active_record_coders_json
new_klass = Class.new(klass) do
serialize :payload, coder: ActiveRecord::Coders::JSON
end
assert_raises(ActiveRecord::AttributeMethods::Serialization::ColumnNotSerializableError) do
new_klass.new
end
end
設計判断
既存キー coder の正規化ロジックに合わせてガードを拡張する方針が採られ、API の破壊的変更は回避されました。
条件式に === を使用したケース比較により、将来的に Coders::JSON を継承したクラスが追加されても同様に判定できる拡張性が確保されています。
変更箇所は数行のロジック追加とメソッド呼び出しの差し替えに留まり、既存コードベースへの侵入リスクは最小限です。
まとめ
本 PR は serialize の互換性ガードを正規化 coder に合わせて拡張し、coder: ActiveRecord::Coders::JSON が JSON カラムで二重エンコードするバグを根本的に解消しました。データ破損リスクが除去され、Rails のシリアライズ機構の整合性が向上します。