normalizes におけるインプレース変更検出のキャストバグ修正
この PR は、normalizes がデータベースから取得した生の値を比較対象にした際に発生していた例外を解消し、インプレース変更検出の正確性を復元します。
背景
インプレース変更検出が正しく機能しない回帰 が #57639 によって導入されました。normalized_attribute_changed_in_place? は現在の値と attribute.type.cast(attribute.value_before_type_cast) を比較していましたが、DB から構築された属性では value_before_type_cast がシリアライズされた生文字列になるため、cast が期待するデシリアライズが行われませんでした。たとえば JSON カラムの場合、生の JSON 文字列が cast に渡され、ユーザー定義の正規化ブロックが文字列に対して呼び出され NoMethodError が発生していました。
結果として正規化が失敗し、レコードの保存やバリデーションができなくなっていました。この問題は、デフォルト値が設定された新規レコードでも同様に起き、JSON 以外のシリアライズ型でも同様のリスクがありました。そこで、比較ロジックを DB 由来の値でも適切にデシリアライズできる形に変更する必要がありました。
技術的な変更
normalized_attribute_changed_in_place? が attribute.type_cast を使用するように変更されました。以下の差分が適用されています。
@@ -145,7 +145,7 @@ def normalize_changed_in_place_attributes
end
def normalized_attribute_changed_in_place?(attribute)
- attribute.changed_in_place? && attribute.value != attribute.type.cast(attribute.value_before_type_cast)
+ attribute.changed_in_place? && attribute.value != attribute.type_cast(attribute.value_before_type_cast)
end
attribute.type_cast は内部で type.deserialize(DB から来た場合)または type.cast(ユーザーが割り当てた場合)を呼び出すため、比較対象が正しくデシリアライズされます。この変更により、既存の true/false 判定ロジックはそのまま維持され、ユーザーが明示的に値を設定したケースでは従来通り cast が使用されます。
テストが追加され、修正の効果が検証されています。NormalizedAdminUser というテストモデルを作成し、JSON カラム json_options に normalizes を適用しました。レコードを DB から取得後にインプレースでキーを追加し validate を呼び出すと、正規化されたハッシュが期待通り取得できることを確認しています。
class NormalizedAdminUser < ActiveRecord::Base
self.table_name = "admin_users"
attribute :json_options, :json
normalizes :json_options, with: -> options { options.transform_keys(&:downcase) }
end
test "normalizes json attribute changed in place after loading from database" do
admin_user = NormalizedAdminUser.find(NormalizedAdminUser.create!(json_options: { "FOO" => "bar" }).id)
admin_user.json_options["BAZ"] = "qux"
admin_user.validate
assert_equal({ "foo" => "bar", "baz" => "qux" }, admin_user.json_options)
end
このテストは、JSON だけでなくデフォルト値が設定されたカラムでも同様に機能することを示しています。
設計判断
既存の型システムを活用して最小限の変更で問題を解決 する方針が採られました。type_cast は型オブジェクトの公開 API であり、デシリアライズとキャストの切り替えロジックを内部に持っています。新たな分岐や特別ケースを追加する代わりに、このメソッドを呼び出すだけで正しい比較が実現でき、既存コードへの影響範囲が極小に抑えられました。
後方互換性が維持されています。ユーザーが手動で属性に値を代入した場合、type.cast がそのまま使われるため、以前の振る舞いに変化はありません。したがって、外部からの API 変更やマイグレーション作業は不要です。
まとめ
この PR は、normalizes がデータベース由来のシリアライズ値を正しくデシリアライズして比較できるようにし、インプレース変更検出の回帰を修正しました。attribute.type_cast への置き換えは既存の型システムを活かす設計選択であり、機能追加や破壊的変更を伴わずにバグを解消しています。テストの追加により、JSON カラムだけでなくデフォルト付きカラムでも安定した正規化が保証されます。