Polymorphic belongs_to のプライマリキー解決をシャーディング対象で修正

rails/rails

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)

テスト用モデル AdjustmentShipment を新規作成し、belongs_tohas_many の間で複合外部キー・主キーを設定しました。また、スキーマに shipmentsadjustments テーブルを追加し、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 がシャーディングされたモデルの複合主キーを誤って扱うバグを、失われたガード条件を復元することで根本的に解消しています。リフレクション層にロジックを集中させ、テストケースを追加したことで、今後の複合キー対応も安定して行える基盤が整いました。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
e206c679

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

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文、背景、技術的変更、設計判断(任意)、まとめの全要素が揃っており、3部構成が明確です。

カスタムMarkdown構文 ⚠ WARNING

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

コードブロックのファイル名付きハイライトは正しく使用されていますが、PRリンクの記法が `[PR #57959](URL)` となっており、仕様の `[ #57959 ](URL)` 形式と異なります。

対象読者への適合性 ✓ PASS

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

専門的なRailsの内部実装に焦点を当てており、エンジニア向けの内容となっています。過度な基礎解説はありません。

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

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

各セクションは総論→各論→結論の流れで構成され、段落はトピックセンテンスで始まり、1段落1トピック・6文以内で書かれています。空行で区切りも適切です。

Diff内容との照合 ⚠ WARNING

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

reflection.rb のコードはDiffと一致していますが、adjustment.rb と shipment.rb の冒頭にある `# frozen_string_literal: true` のコメント行が記事から省略されています。機能上の問題はありませんが、Diff全体の引用としては抜けがあります。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「composite primary key」「query_constraints」「AutosaveAssociation」などの用語はPRと一致し、誤用は見られません。

説明の技術的正確性 ✓ PASS

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

技術的な説明(ガード復元のロジック、条件分岐)とPRの記述が整合しており、誤った主張はありません。

事実の突合 ⚠ WARNING

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

「後方互換性を最優先にした修正」や「リフレクション層にロジックを集中させ」等の設計判断はPR本文に明示されておらず、推測的記述と捉えられます。

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

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

PR番号、ファイル名、コード中のシンボルは正確です。数値や固有名詞の誤りはありません。

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

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

記事タイトルはPRタイトル「Fix polymorphic belongs_to primary key for sharded targets」の意味を正しく日本語化しています。

外部知識の正確性 ✓ PASS

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

LTSやリリース日程等、PRに記載されていない外部知識は含まれていません。

時間表現の正確性 ✓ PASS

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

時間表現の歪曲はなく、PRの記述と一致しています。