Composite Primary Key の inverse_of マッチング修正
この PR は、所有側が単一の id を持ち、関連側が複合主キーを宣言しているケースで inverse_of が正しく機能しない問題を修正します。 ActiveRecord::Associations::Association#matches_foreign_key? の比較ロジックを拡張し、テストとモデル定義も更新しています。
背景
inverse_of が機能しないと、belongs_to / has_many 間のオブジェクト同一性が保てず、期待したレコードが取得できません。 具体的には、所有クラスがスカラー id、関連クラスが複合主キー(例: [shop_id, order_id])を持つと、従来の比較は所有側のプライマリキー配列と外部キー配列を直接比較していたため不一致が生じていました。
この不一致は、PR #56664 で配列比較のロジックが修正されたものの、所有側がスカラー id のケースを網羅できていなかったことが根本原因です。 本 PR はその抜け漏れを埋め、inverse_of が正しく逆参照マッチングできるようにします。
技術的な変更
Association#matches_foreign_key? の内部比較対象を active_record_primary_key_values と association_primary_key_values に置き換えました。 これにより、所有レコードのプライマリキーは reflection.active_record_primary_key から取得し、関連レコードは reflection.association_primary_key(record.class) から取得して個別に比較します。変更は以下の抜粋で確認できます(抜粋):
@@
- foreign_key_values(record) == primary_key_values(owner)
+ foreign_key_values(record) == active_record_primary_key_values(owner)
@@
- foreign_key_values(owner) == primary_key_values(record)
+ foreign_key_values(owner) == association_primary_key_values(record)
@@
- def primary_key_values(record)
- Array(reflection.active_record_primary_key).map { |key| record.read_attribute(key) }
+ def active_record_primary_key_values(record)
+ Array(reflection.active_record_primary_key).map { |key| record.read_attribute(key) }
end
+
+ def association_primary_key_values(record)
+ Array(reflection.association_primary_key(record.class)).map { |key| record.read_attribute(key) }
+ end
テストも追加され、[shop_id, order_id] 形式の外部キーが所有側の [shop_id, id] と正しくマッチすることを検証しています。 代表的なテストコードは次の抜粋です(抜粋):
@@
def test_belongs_to_a_model_with_composite_association_primary_key_sets_inverse_of
cpk_order = cpk_orders(:cpk_groceries_order_1)
store_id, order_id = cpk_order.id
order = Cpk::NonCpkOrder.find(order_id)
book = order.books_with_composite_primary_key.create!(id: [store_id, 4], title: "Book")
@@
assert_same associated_order, associated_book.non_cpk_order
end
モデル定義も更新され、BookWithNonCpkOrder と NonCpkOrder に複合キーと inverse_of を明示的に設定しました。 変更点は以下の通りです(抜粋):
class BookWithNonCpkOrder < ActiveRecord::Base
self.table_name = :cpk_books
belongs_to :non_cpk_order, class_name: "Cpk::NonCpkOrder",
foreign_key: [:shop_id, :order_id], primary_key: [:shop_id, :id], inverse_of: :books_with_composite_primary_key
end
class NonCpkOrder < Order
self.primary_key = :id
has_many :books_with_composite_primary_key, class_name: "Cpk::BookWithNonCpkOrder",
foreign_key: [:shop_id, :order_id], primary_key: [:shop_id, :id], inverse_of: :non_cpk_order
end
設計判断
reflection が提供する active_record_primary_key と association_primary_key をそのまま利用する設計は、キー構成が将来的に拡張された場合でも同一ロジックで対応可能という拡張性をもたらします。 既存のメソッドシグネチャは変更せず、内部ロジックだけを差し替えることで後方互換性を確保しています。
配列を直接 read_attribute に渡す危険性を排除し、キーを個別に取得して比較することで、バグの根本原因である型不一致を防止しています。 この変更は inverse_of の正確な動作を保証しつつ、既存コードへの影響を最小限に抑えるというトレードオフを適切に取っています。
まとめ
所有側がスカラー id、関連側が複合主キーを持つケースでも inverse_of が正しく機能するようになり、アソシエーションの同一性が保証されます。 比較ロジックの細分化とテスト追加により、ActiveRecord のアソシエーション機構が複合キーに対しても一貫した振る舞いを提供できるようになりました。