JSONGemCoderEncoderがHash型を返すas_jsonを持つオブジェクトをキーとして正しく処理
Rails 8.1.0で導入されたJSONGemCoderEncoderに、カスタムオブジェクトをHashのキーとして使用した際の不具合が修正されました。as_jsonがHashを返すオブジェクトをキーとする場合、to_sの呼び出しタイミングが誤っていたため、シリアライズ後のキーが意図しない形式になっていました。#56785はこの問題を1行の変更で解決しています。
背景
Rails 8.1.0では、json gem 2.15.2以降のパフォーマンス改善を活用するため、従来のJSONGemEncoderからJSONGemCoderEncoderへの切り替えが行われました。この変更は2761020で導入されましたが、Hashキーのシリアライズ処理に不具合がありました。
カスタムオブジェクトをHashのキーとして使用し、そのオブジェクトのas_jsonメソッドがHashを返す場合、Rails 8.1.0では以下のような誤った動作が発生していました:
class CustomKey
def initialize(id)
@id = id
end
def to_s
"custom_#{@id}"
end
def as_json(options = nil)
{ id: @id, metadata: { created_at: Time.now.iso8601 } }
end
end
hash = { CustomKey.new(123) => "value" }
# Rails 7.2.x(正常)
hash.to_json # => {"custom_123":"value"}
# Rails 8.1.x(不具合)
hash.to_json # => {"{:id=>123, :metadata=>{...}}":"value"}
この問題は、マルチテナントアプリケーションやドメイン駆動設計でカスタムオブジェクトをHashキーとして使用し、それをJSON/JSONBカラムに保存するケースで影響を及ぼしていました。報告者の実環境では、FieldKeyオブジェクトをJSONBカラムのHashキーとして使用していたため、Rails 7.2から8.1へのアップグレード時にハッシュルックアップが失敗する事態が発生しました。
技術的な変更
activesupport/lib/active_support/json/encoding.rbのJSONGemCoderEncoder定義において、Hashキーの文字列化処理が修正されました。
変更前:
CODER = ::JSON::Coder.new do |value, is_key|
json_value = value.as_json
next json_value.to_s if is_key
# ...
end
変更後:
CODER = ::JSON::Coder.new do |value, is_key|
json_value = value.as_json
# Keep compatibility by calling to_s on non-String keys
next value.to_s if is_key && !(String === json_value)
# Handle objects returning self from as_json
if json_value.equal?(value)
next ::JSON::Fragment.new(::JSON.generate(json_value))
end
# ...
end
変更の核心は、Hashキーを文字列化する際にjson_value.to_sではなくvalue.to_sを呼び出すようにした点です。さらに、as_jsonの結果が既に文字列の場合はto_sを呼ばないよう条件を追加しています。
この修正により、以下の動作が保証されます:
- カスタムオブジェクトがHashキーとして使用される場合、そのオブジェクトの
to_sメソッドが呼ばれる -
as_jsonの戻り値がHashであっても、元のオブジェクトのto_sが優先される -
as_jsonが文字列を返す場合はそれがそのまま使用される
Rails 7.2のJSONGemEncoderでは、Hashのシリアライズ時に明示的にキーを先に文字列化していました:
when Hash
result = {}
value.each do |k, v|
k = k.to_s unless Symbol === k || String === k
result[k] = jsonify(v)
end
result
この処理順序が、Rails 8.1のJSONGemCoderEncoderへの移行時に失われていたことが不具合の原因でした。
テストカバレッジの追加
activesupport/test/json/encoding_test.rbにtest_hash_with_object_keys_that_have_complex_as_jsonが追加され、この問題のリグレッションを防ぐテストが整備されました:
def test_hash_with_object_keys_that_have_complex_as_json
skip "JSONGemCoderEncoder not available" unless defined?(ActiveSupport::JSON::Encoding::JSONGemCoderEncoder)
custom_key_class = Struct.new(:id) do
def to_s
"custom_#{id}"
end
def as_json(options = nil)
{ id: id, metadata: { created_at: Time.now.iso8601 } }
end
end
key = custom_key_class.new(123)
hash = { key => "some_value" }
assert_equal "custom_123", key.to_s
assert_instance_of Hash, key.as_json
json = hash.to_json
parsed = JSON.parse(json)
assert_equal "some_value", parsed["custom_123"]
end
このテストは、as_jsonがHashを返すカスタムオブジェクトをキーとして使用した場合に、to_sの結果でキーがシリアライズされることを検証します。PR本文で提供されている検証スクリプトを使うと、Rails 8.1.2では失敗し、7.2.3や8.0.4では成功することが確認できます。
設計判断
元のオブジェクトのto_sを優先する方式が採用されました。
as_jsonの戻り値に対してto_sを呼ぶ従来の実装は、一見自然に見えますが、as_jsonの設計意図と矛盾していました。as_jsonはオブジェクトのJSON表現を定義するメソッドであり、Hashキーとしての文字列表現を定義するメソッドではありません。オブジェクトをHashキーとして文字列化する責務はto_sにあり、コメントにも「Keep compatibility by calling to_s on non-String keys」と明記されています。
さらに、as_jsonが既に文字列を返している場合はto_sを呼ばない条件分岐が追加されました。これにより、as_jsonで文字列表現をカスタマイズしているオブジェクトにも対応できます。
この修正は後方互換性を保ちながら不具合を解消しています:
- 数値キー(
{1 => "one"})の処理は変更なし - Symbol/Stringキーの処理は変更なし
-
as_jsonが文字列やシンボルを返すオブジェクトも引き続き動作 - 既存の動作する実装には影響を与えず、壊れていた実装のみを修正
まとめ
本PRは、Rails 8.1.0でのJSONエンコーダ切り替え時に混入したHashキーのシリアライズ不具合を修正しました。as_jsonの戻り値ではなく元のオブジェクトに対してto_sを呼ぶことで、Rails 7.2の動作との一貫性を回復し、カスタムオブジェクトをHashキーとして使用するアプリケーションの正常な動作を保証しています。1行の変更で後方互換性を維持しつつ、ドメインオブジェクトをHashキーとして活用する設計パターンをサポートし続けることに成功した修正といえます。