複合主キーを持つモデルで `inverse_of` が正しく動作しない問題を修正

rails/rails

ActiveRecord::Associations::Association#matches_foreign_key? が複合キーを配列のまま read_attribute に渡していたため、inverse_of の解決に失敗していたバグが修正されました。各キーコンポーネントを個別に読み取るよう実装を整理したことで、複合主キー・複合外部キーの双方でインバースアソシエーションが正しく機能します。

背景

#56662 で報告されたこのバグは、複合キーを持つモデルで inverse_of を使用すると inversable? の検証が常に失敗するという問題でした。query_constraints で複合主キーを定義した Parent モデルと、複合外部キー ([:store_id, :parent_id]) を持つ Child モデルを組み合わせた場合に再現します。

問題の根本原因は matches_foreign_key? の実装にありました。reflection.foreign_key が配列(例: [:store_id, :parent_id])を返す場合でも、それをそのまま record.read_attribute(reflection.foreign_key)record._has_attribute? に渡していました。これらのメソッドはカラム名として文字列を期待しているため、配列が渡されると正しく動作せず、外部キーの一致判定が失敗していました。

Issue では問題を回避するモンキーパッチも提示されており、今回のPRはそのパッチのロジックを ActiveRecord 本体に統合する形で修正しています。

技術的な変更

matches_foreign_key? を分解し、複合キーを扱える補助メソッド群に再構成することで問題を解消しています。

変更前:

def matches_foreign_key?(record)
  if foreign_key_for?(record)
    record.read_attribute(reflection.foreign_key) == owner.id ||
      (foreign_key_for?(owner) && owner.read_attribute(reflection.foreign_key) == record.id)
  else
    owner.read_attribute(reflection.foreign_key) == record.id
  end
end

変更後:

def matches_foreign_key?(record)
  (foreign_key_for?(record) && record_foreign_key_matches_owner?(record)) ||
    (foreign_key_for?(owner) && owner_foreign_key_matches_record?(record))
end

def record_foreign_key_matches_owner?(record)
  foreign_key_values(record) == primary_key_values(owner)
end

def owner_foreign_key_matches_record?(record)
  foreign_key_values(owner) == primary_key_values(record)
end

def foreign_key_values(record)
  Array(reflection.foreign_key).map { |key| record.read_attribute(key) }
end

def primary_key_values(record)
  Array(reflection.association_primary_key(record.class)).map { |key| record.read_attribute(key) }
end

核心となる変更は foreign_key_valuesprimary_key_values の2つのメソッドです。Array(reflection.foreign_key) を使うことで、単一キー(文字列)でも複合キー(配列)でも同じように処理でき、各要素を read_attribute で個別に読み取った値の配列同士を比較します。単一キーの場合は1要素の配列同士の比較になるため、既存の動作との互換性も保たれています。

またテストには、複合主キーを持つ cpk_orders を使った test_belongs_to_a_model_with_composite_primary_key_sets_inverse_of が追加されました。book.order を2回参照したとき同一オブジェクトが返ること(assert_same)を検証しており、インバースアソシエーションのオブジェクト同一性が担保されています。

def test_belongs_to_a_model_with_composite_primary_key_sets_inverse_of
  order = cpk_orders(:cpk_groceries_order_1)
  store_id, _order_id = order.id
  book = order.books.create!(id: [store_id, 4], title: "Book")

  assert_same book.order, book.order.books.first.order
end

設計判断

既存の条件分岐をフラットな2条件に整理するアプローチが採用されました。

変更前の実装では if/else の中に || による短絡評価が混在し、record 側と owner 側どちらに外部キーがあるかによる分岐が読みにくい構造になっていました。変更後は「recordが外部キーを持つ場合の一致判定」と「ownerが外部キーを持つ場合の一致判定」という2つの独立した条件に整理されており、各補助メソッドが単一責任を持つ設計です。

Array() によるラップは、単一キーと複合キーの両方を同一コードパスで処理する慣用的なRubyイディオムです。単一文字列を渡せば1要素の配列になるため、primary_key_valuesforeign_key_values は入力の型を意識せず統一的に扱えます。これにより、複合キー対応のための特殊分岐を追加せずに済んでいます。

まとめ

matches_foreign_key? の一枚岩的な実装を、キー値を配列として正規化する補助メソッド群に分割したことで、複合キーという特殊ケースへの対応と可読性の向上を同時に実現しています。Array() による正規化パターンは、Railsの複合キー対応における汎用的な設計原則を示しており、同種の問題に対する指針となる変更です。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
1ce27f04

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

ファイル名付きシンタックスハイライト(```言語:ファイルパス)や、PR・Issue番号のGitHubリンク記法が正しく使用されています。

対象読者への適合性 ✓ PASS

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

ActiveRecordの内部実装に関するトピックであり、Railsエンジニアという専門知識を持つ読者層に適切に適合しています。過度な説明はありません。

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

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

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

Diff内容との照合 ✓ PASS

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

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

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`inverse_of`、複合キー、`read_attribute`などの技術用語が、PR情報と一致しており、文脈上も正確に使用されています。

説明の技術的正確性 ✓ PASS

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

バグの原因(配列をそのままメソッドに渡していた点)と、その解決策(各キーを個別に処理するリファクタリング)に関する説明は、技術的に正確かつ論理的です。

事実の突合 ✓ PASS

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

記事内のすべての主張(関連Issue、モンキーパッチの存在、リファクタリング方針など)は、PRのDescriptionやDiffの内容によって裏付けられており、ハルシネーションは見られません。

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

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

PR番号(#56664)やIssue番号(#56662)などの数値・固有名詞はすべて正確です。

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

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

記事のタイトルはPRの主題(`inverse_of`と複合キーの問題修正)を的確に要約しており、内容との整合性も取れています。

外部知識の正確性 ✓ PASS

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

PRで言及されていないバージョン情報やリリース予定といった外部知識は記事に含まれておらず、提供された情報源に忠実です。

時間表現の正確性 ✓ PASS

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

記事内には時間表現の歪曲はなく、事実を正確に記述しています。