`inverse_of` とコンポジット外部キーの組み合わせで発生する `FrozenError` を修正
has_many/has_one アソシエーションで inverse_of を使って外部キーを派生させる際、belongs_to 側がコンポジット外部キーを持つ場合に FrozenError が発生するバグを修正しました。map! による破壊的操作を map { }.freeze に置き換えることで、メモ化された凍結済み配列への変更を回避しています。
背景
inverse_of を使った外部キーの自動導出において、派生元がコンポジット外部キーの場合に凍結済みオブジェクトへの変更が発生していました。derive_foreign_key は内部で inverse_reflection の外部キーをそのまま返す場合があります。belongs_to :blog_post_with_inverse のように foreign_key: [:blog_id, :blog_post_id] が指定されていると、この値は Active Record によってメモ化・凍結された配列として保持されています。
has_many :comments_with_inverse 側で foreign_key を呼び出すと、derive_foreign_key がこのメモ化済み凍結配列を返し、直後の map! がその配列をインプレース変換しようとして FrozenError が発生していました。スカラー値(文字列)の場合はそもそも map! の対象にならないため、この問題はコンポジット外部キーを持つ belongs_to と inverse_of の組み合わせでのみ顕在化します。
技術的な変更
reflection.rb における foreign_key メソッドの配列処理が、破壊的操作から非破壊的操作へ変更されました。
変更前:
if active_record.has_query_constraints?
derived_fk = derive_fk_query_constraints(derived_fk)
end
if derived_fk.is_a?(Array)
derived_fk.map! { |fk| -fk.freeze }
derived_fk.freeze
else
-derived_fk.freeze
end
変更後:
if !derived_fk.is_a?(Array) && active_record.has_query_constraints?
derived_fk = derive_fk_query_constraints(derived_fk)
end
if derived_fk.is_a?(Array)
derived_fk.map { |fk| -fk.freeze }.freeze
else
-derived_fk.freeze
end
変更点は2つあります。まず、map! + freeze を map { }.freeze に置き換え、元の配列を変更せず新しい凍結済み配列を生成するようにしました。次に、has_query_constraints? による derive_fk_query_constraints の呼び出しを、derived_fk がスカラー値の場合のみに制限するガード条件(!derived_fk.is_a?(Array))を追加しています。これは、コンポジット外部キーがすでに配列として導出されている場合に derive_fk_query_constraints を通す必要がないためです。
テスト側では、Sharded::BlogPost に has_many :comments_with_inverse(inverse_of: :blog_post_with_inverse 付き)を、Sharded::Comment に対応する belongs_to :blog_post_with_inverse(foreign_key: [:blog_id, :blog_post_id])を追加し、reflection.foreign_key が ["blog_id", "blog_post_id"] を正しく返すことを確認するテストが追加されました。
設計判断
非破壊的な配列操作への統一 が選ばれた点が、この修正の核心です。
map! はオブジェクトをインプレース変換する Ruby の慣用的なパターンですが、メモ化によって凍結済みオブジェクトが返ってくる可能性がある文脈では安全ではありません。map { }.freeze に変更することで、元のオブジェクトの状態に依存せず常に新しい凍結済み配列を返すようになり、呼び出し側がメモ化の詳細を意識する必要がなくなります。
has_query_constraints? の条件に !derived_fk.is_a?(Array) を追加したことも重要です。コンポジット外部キーがすでに配列として決定している場合、クエリ制約から外部キーを再導出するパスは意味をなさないため、このガード追加は論理的な整合性の修正でもあります。
まとめ
この修正は、Active Record のメモ化・凍結機構と破壊的配列操作が衝突するという微妙なバグを最小限のコード変更で解消しています。map! から map { }.freeze への置き換えというシンプルな変更が、コンポジット外部キーと inverse_of を組み合わせるユースケースの安全性を保証し、既存の動作を維持しつつ問題を根本から解決しています。