`ActiveStorage::Attached` プロキシの `as_json` 定義による無限再帰の修正
Attached::One と Attached::Many に as_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::Many が as_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::One と Attached::Many それぞれに as_json メソッドを追加し、Object#as_json へのフォールスルーを遮断します。
Attached::One#as_json は添付がある場合に attachment(ActiveStorage::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_json は attachments コレクションの as_json をそのまま委譲し、添付がない場合は空配列を返します。
def as_json(options = nil)
attachments.as_json(options)
end
ActiveStorage::Attachment レコードが持つカラムは id、blob_id、name、record_id、record_type、created_at のスカラー値のみであるため、これらを起点に新たな循環参照が発生することはありません。
テストは as_json の戻り値の正確性と、属性名が衝突する状況での to_json が SystemStackError を発生させないことの両面を検証しています。例えば User.select("name AS avatar").find(@user.id).to_json のように意図的に属性名を衝突させたケースでも assert_nothing_raised で通過することが確認されます。
設計判断
プロキシクラスに最小限の as_json を定義する方式 が採用されました。
PR内では代替案として、ActiveRecord::Persistence#instantiate_instance_of で ignored_columns の値を行ハッシュから除外する方法が検討されました。しかしこのアプローチはユーザーが明示的に select('*') で要求したカラムをサイレントに除外するという広範な挙動変更であり、Attached 以外の @record 後方参照を持つオブジェクトには適用できない点から採用されませんでした。
プロキシへの as_json 定義は変更が局所的で影響範囲が明確です。既存の to_json の出力に関しても、これまで as_json の未定義により不定な動作だった部分が、添付レコードのスカラーカラムという一貫した形式に確定するという意味で、破壊的変更ではなく修正として機能します。
まとめ
プロキシオブジェクトへの as_json 定義という一点の変更が、@record 後方参照に起因する無限再帰という根本原因を正確に断ち切っています。変更はプロキシクラスの2ファイルに閉じており、既存の添付操作や取得の動作には一切影響を与えません。