`ActiveStorage::Attached` プロキシの `as_json` 定義による無限再帰の修正

rails/rails

Attached::OneAttached::Manyas_json を明示定義することで、has_one_attached / has_many_attached の名前がモデル属性と衝突する際に発生していた SystemStackError を解消します。

背景

ignored_columns に指定したカラム名と同名の has_one_attached / has_many_attached が存在し、かつ select('*') でそのカラムが返される場合、record.to_json がスタックオーバーフローを引き起こす問題がありました(#57287)。

class Product < ApplicationRecord
  self.ignored_columns = [:photo]
  has_one_attached :photo
end

Product.select('*').first.to_json
# => SystemStackError: stack level too deep

原因は Attached::One / Attached::Manyas_json を持たなかったことにあります。これらのプロキシは @record にオーナーレコードへの後方参照を持っており、as_json が未定義のまま Object#as_json にフォールスルーすると instance_values を丸ごとシリアライズしようとします。結果として record → attached proxy → record → ... の循環参照が発生し、次のサイクルが繰り返されていました。

1209 cycles of:
  ActiveModel::Serializers::JSON#as_json
  block in Hash#as_json
  Hash#each
  Hash#as_json
  Object#as_json          # <-- Attached::One がここにフォールスルー
  block in Hash#as_json
  Hash#each
  Hash#as_json

なお、select('*') なしの通常クエリでは ignored_columns の対象カラムがSELECT句から除外されるため問題は発現せず、select('*') で明示的に全カラムを取得した場合にのみ再現する挙動でした。

技術的な変更

Attached::OneAttached::Many それぞれに as_json メソッドを追加し、Object#as_json へのフォールスルーを遮断します。

Attached::One#as_json は添付がある場合に attachmentActiveStorage::Attachment レコード)の JSON 表現を、未添付の場合は nil を返します。

# Returns the attachment record's JSON representation, or +nil+ when no
# attachment is present.
def as_json(options = nil)
  attached? ? attachment.as_json(options) : nil
end

Attached::Many#as_jsonattachments コレクションの as_json をそのまま委譲し、添付がない場合は空配列を返します。

def as_json(options = nil)
  attachments.as_json(options)
end

ActiveStorage::Attachment レコードが持つカラムは idblob_idnamerecord_idrecord_typecreated_at のスカラー値のみであるため、これらを起点に新たな循環参照が発生することはありません。

テストは as_json の戻り値の正確性と、属性名が衝突する状況での to_jsonSystemStackError を発生させないことの両面を検証しています。例えば User.select("name AS avatar").find(@user.id).to_json のように意図的に属性名を衝突させたケースでも assert_nothing_raised で通過することが確認されます。

設計判断

プロキシクラスに最小限の as_json を定義する方式 が採用されました。

PR内では代替案として、ActiveRecord::Persistence#instantiate_instance_ofignored_columns の値を行ハッシュから除外する方法が検討されました。しかしこのアプローチはユーザーが明示的に select('*') で要求したカラムをサイレントに除外するという広範な挙動変更であり、Attached 以外の @record 後方参照を持つオブジェクトには適用できない点から採用されませんでした。

プロキシへの as_json 定義は変更が局所的で影響範囲が明確です。既存の to_json の出力に関しても、これまで as_json の未定義により不定な動作だった部分が、添付レコードのスカラーカラムという一貫した形式に確定するという意味で、破壊的変更ではなく修正として機能します。

まとめ

プロキシオブジェクトへの as_json 定義という一点の変更が、@record 後方参照に起因する無限再帰という根本原因を正確に断ち切っています。変更はプロキシクラスの2ファイルに閉じており、既存の添付操作や取得の動作には一切影響を与えません。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
edbed832

この記事は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リンク記法の正確性

ファイル名付きシンタックスハイライトやGitHubのPR/Issueへのリンク記法が正しく使用されています。

対象読者への適合性 ✓ PASS

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

専門用語が適切に使用されており、Railsの内部実装に関心のあるエンジニアという対象読者に合致した技術レベルと表現です。

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

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

各セクション・各パラグラフが「総論→各論」の構造で書かれており、トピックセンテンスが明確です。1段落1トピックの原則も守られており、非常に読みやすいです。

Diff内容との照合 ✓ PASS

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

記事内で引用されている`activestorage/lib/active_storage/attached/one.rb`と`many.rb`のコードは、提供されたDiffと完全に一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「プロキシ」「後方参照」「フォールスルー」「循環参照」といった技術用語が、PRの文脈に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

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

無限再帰が発生する原因(`as_json`の未定義によるフォールスルー)と、その解決策がPRの内容に基づき技術的に正確に説明されています。

事実の突合 ✓ PASS

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

記事内のすべての主張(問題の背景、技術的詳細、設計判断)は、PRのDescriptionやDiffの内容によって裏付けられており、ハルシネーションは見られません。

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

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

PR番号(#57288)やIssue番号(#57287)などの固有名詞はすべて正確に記載されています。

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

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

記事タイトルはPRの主題を的確に要約しており、記事の内容とも完全に一致しています。

外部知識の正確性 ✓ PASS

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

記事の内容は提供されたPR情報のみに基づいており、バージョンサポート情報などの外部知識の持ち込みはありません。

時間表現の正確性 ✓ PASS

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

「問題がありました」など、修正前後の状況を示す時間表現は正確に使用されています。