JSONGemCoderEncoderがHash型を返すas_jsonを持つオブジェクトをキーとして正しく処理

rails/rails

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.rbtest_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キーとして活用する設計パターンをサポートし続けることに成功した修正といえます。

記事メタデータ

Generated by:
Claude Sonnet 4.5 for DiffDaily

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

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文(総論)→背景・技術詳細・設計判断(各論)→まとめ(結論)という「総論→各論→結論」の構成が明確に適用されており、非常に分かりやすいです。

カスタムMarkdown構文 ✓ PASS

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

ファイル名付きシンタックスハイライト(```ruby:filepath)およびGitHubのPR番号・コミットIDのリンク記法がすべて正しく使用されています。

対象読者への適合性 ✓ PASS

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

内容はJSONエンコーディングやas_jsonの挙動に関する専門的なもので、対象読者であるエンジニアに適した技術レベルと表現で記述されています。

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

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

各セクションが総論・各論で構成され、各段落はトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が守られており、高い可読性を実現しています。

Diff内容との照合 ✓ PASS

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

記事内で引用されている変更前後のコード、および追加されたテストコードは、提供されたDiff情報と完全に一致しており、正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「JSONGemCoderEncoder」「as_json」「シリアライズ」などの技術用語が、PR情報と一致し、文脈に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

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

不具合の原因(to_sの呼び出し対象の誤り)や修正内容、過去バージョンとの挙動の違いに関する説明は、PR情報に基づいており、技術的に正確です。

事実の突合 ✓ PASS

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

記事内のすべての主張(影響範囲、実環境での問題、修正による動作保証など)は、PRのDescriptionで裏付けられており、ハルシネーションは検出されませんでした。

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

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

PR番号(#56785)、コミットID(2761020)、および関連するRailsのバージョン番号(8.1.0, 7.2.xなど)はすべて正確に記載されています。

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

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

記事のタイトルは、PRのタイトルよりも具体的な事象を説明しており、PRが解決する問題を的確に表現しています。

外部知識の正確性 ✓ PASS

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

記事に含まれる情報はすべてPR内で提供された情報に基づいており、PRに記載のない外部知識(バージョンのサポート状況など)の追記はありません。

時間表現の正確性 ✓ PASS

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

「Rails 8.1.0で導入された」といった時間表現は、PR情報と一致しており、事実を正確に伝えています。