Polymorphic belongs_to のプライマリキー解決をシャーディング対象で修正
Rails 7.1 以降、ポリモーフィックな belongs_to がシャーディングされたターゲットの複合主キーを誤って扱い、ActiveModel::MissingAttributeError を引き起こす問題を、ガードロジックを復元して解決しました。
背景
BelongsToReflection#association_primary_key が複合主キーを解決できる 一方で、belongs_to 側は単一の外部キーしか保持できないため、AutosaveAssociation が主キーと外部キーを zip する際に不足分が nil となり例外が発生していました。この不整合は AutosaveAssociation から compute_primary_key が削除され、二つの保護ガードが失われたことが根本原因です。
この regress は、:inverse_of が指定されたときに inverse.options[:primary_key] をそのまま採用するロジックが、シャーディングされた逆側で提供される 複合 :primary_key に対して不適切に動作した結果でもあります。結果として、belongs_to が明示的な単一 :foreign_key を持っていても、フォールスルーで複合リストに展開され、MissingAttributeError がスローされました。
結論:ポリモーフィックな belongs_to がシャーディング対象と連携する際の主キー解決ロジックに欠陥があり、テストで再現された例外が問題の核心でした。
技術的な変更
association_primary_key メソッドに二つのガードを復元 し、inverse_of が提供するカスタム :primary_key を使用する条件を :query_constraints が未設定の場合に限定しました。また、klass.has_query_constraints? が真でかつ単一 :foreign_key が指定されているときは、対象モデルの単一主キーを直接返すロジックを追加しました。
@@ -936,17 +936,23 @@ def association_class
# klass option is necessary to support loading polymorphic associations
def association_primary_key(klass = nil)
if options[:primary_key]
- @association_primary_key ||= ActiveRecord::Key.for(options[:primary_key]).name
- elsif polymorphic? && options[:inverse_of] && klass
+ return @association_primary_key ||= ActiveRecord::Key.for(options[:primary_key]).name
end
+
+ if polymorphic? && options[:inverse_of] && klass
inverse = klass.reflect_on_association(options[:inverse_of])
- if inverse && inverse.options[:primary_key]
- ActiveRecord::Key.for(inverse.options[:primary_key]).name
- else
- derive_primary_key(klass) { |model| model.composite_query_constraints_list }
- end
- else
- derive_primary_key(klass || self.klass) { |model| model.composite_query_constraints_list }
+ if inverse && inverse.options[:primary_key] && !inverse.options[:query_constraints]
+ return ActiveRecord::Key.for(inverse.options[:primary_key]).name
+ end
+ end
+
+ klass ||= self.klass
+
+ if klass.has_query_constraints? && options[:foreign_key] && !options[:query_constraints]
+ return klass.primary_key_definition.inferred_id || primary_key(klass).freeze
+ end
+
+ derive_primary_key(klass) { |model| model.composite_query_constraints_list }
end
def join_primary_key(klass = nil)
テスト用モデル Adjustment と Shipment を新規作成し、belongs_to と has_many の間で複合外部キー・主キーを設定しました。また、スキーマに shipments と adjustments テーブルを追加し、region_id カラムを含めた複合キー構成を再現しています。
class Adjustment < ActiveRecord::Base
belongs_to :adjustable, polymorphic: true, inverse_of: :adjustments, autosave: true
end
class Shipment < ActiveRecord::Base
has_many :adjustments,
as: :adjustable,
foreign_key: [:region_id, :adjustable_id],
primary_key: [:region_id, :id],
inverse_of: :adjustable
end
スキーマ変更は次の通りです。
@@ -376,6 +376,15 @@
t.integer :tag_id
end
+ create_table :shipments, force: true do |t|
+ t.integer :region_id
+ end
+
+ create_table :adjustments, force: true do |t|
+ t.integer :region_id
+ t.references :adjustable, polymorphic: true
+ end
+
create_table :clubs, force: true do |t|
t.string :name
t.integer :category_id
これらの追加に伴い、belongs_to のプライマリキー解決が期待通りに動作することを確認するテストが新たに実装されました。
結論:ガードロジックの復元と条件分岐の明確化により、ポリモーフィックな belongs_to がシャーディングされたターゲットの単一主キーを正しく取得でき、AutosaveAssociation の zip 操作での nil 発生を防止しました。
設計判断
後方互換性を最優先にした修正 が選択されました。association_primary_key のロジックは既存の API をそのまま利用しつつ、失われた二つのガード (inverse.options[:query_constraints] の有無、options[:query_constraints] の有無) を再導入する形で実装されています。新たにメソッド全体を大幅に書き換えるのではなく、最小限の分岐追加で問題を解決した点が特徴です。
また、AutosaveAssociation が独自に主キー解決ロジックを保持していた部分を削除し、リフレクション層の単一真理源 に委任する設計へと統一しました。これにより、将来的に主キー解決ロジックが変更された場合でも、リフレクションだけを更新すれば全体に反映される一貫性が保たれます。
結論:ガード復元による安全性向上と、リフレクションへのロジック集約という二本柱の設計判断により、既存コードへの影響を最小化しつつバグ修正を実現しました。
まとめ
この PR は、ポリモーフィック belongs_to がシャーディングされたモデルの複合主キーを誤って扱うバグを、失われたガード条件を復元することで根本的に解消しています。リフレクション層にロジックを集中させ、テストケースを追加したことで、今後の複合キー対応も安定して行える基盤が整いました。