複合主キーを持つモデルで `inverse_of` が正しく動作しない問題を修正
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_values と primary_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_values や foreign_key_values は入力の型を意識せず統一的に扱えます。これにより、複合キー対応のための特殊分岐を追加せずに済んでいます。
まとめ
matches_foreign_key? の一枚岩的な実装を、キー値を配列として正規化する補助メソッド群に分割したことで、複合キーという特殊ケースへの対応と可読性の向上を同時に実現しています。Array() による正規化パターンは、Railsの複合キー対応における汎用的な設計原則を示しており、同種の問題に対する指針となる変更です。