`inverse_of` とコンポジット外部キーの組み合わせで発生する `FrozenError` を修正

rails/rails

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_toinverse_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! + freezemap { }.freeze に置き換え、元の配列を変更せず新しい凍結済み配列を生成するようにしました。次に、has_query_constraints? による derive_fk_query_constraints の呼び出しを、derived_fk がスカラー値の場合のみに制限するガード条件(!derived_fk.is_a?(Array))を追加しています。これは、コンポジット外部キーがすでに配列として導出されている場合に derive_fk_query_constraints を通す必要がないためです。

テスト側では、Sharded::BlogPosthas_many :comments_with_inverseinverse_of: :blog_post_with_inverse 付き)を、Sharded::Comment に対応する belongs_to :blog_post_with_inverseforeign_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 を組み合わせるユースケースの安全性を保証し、既存の動作を維持しつつ問題を根本から解決しています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
778cdce9

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

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

カスタムMarkdown構文 ✓ PASS

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

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

対象読者への適合性 ✓ PASS

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

内容はRailsの内部実装における特定のエラー修正に関するものであり、専門知識を持つエンジニアという対象読者に適合しています。

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

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

各セクション、各パラグラフがトピックセンテンスで始まり、1段落1トピックの原則が守られているため、非常に高い可読性を実現しています。

Diff内容との照合 ✓ PASS

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

記事内のコード引用は、提供されたDiff情報と完全に一致しています。変更前後のコードが正確に示されており、説明と整合しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「コンポジット外部キー」「inverse_of」「FrozenError」「メモ化」などの技術用語が、文脈に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

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

「メモ化された凍結配列に対して破壊的メソッド `map!` を呼び出すことで `FrozenError` が発生する」という根本原因の説明が技術的に正確かつ明快です。

事実の突合 ✓ PASS

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

記事内のすべての主張は、PRのDescriptionおよびDiffの内容に基づいており、根拠のない推測や創作(ハルシネーション)は見られません。

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

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

PR番号(#56877)やファイルパスなどの固有名詞はすべて正確です。

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

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

記事のタイトルは、PRのタイトル「Fix FrozenError when deriving foreign key from inverse with composite foreign key」の内容を忠実に反映しています。

外部知識の正確性 ✓ PASS

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

PR情報に含まれないバージョンサポート状況やリリース日程などの外部知識は記載されておらず、事実に忠実です。

時間表現の正確性 ✓ PASS

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

「修正しました」という過去形の表現が使われており、時間表現は正確です。