複合外部キーを持つ `belongs_to` バリデーション条件の修正

rails/rails

belongs_to_required_validates_foreign_keyfalse の場合、複合外部キーを持つ belongs_to のバリデーション条件が常に true となり、最適化が無効化されていたバグが修正されました。これにより、複合外部キーのカラムが変更されていない場合には親レコードの存在確認がスキップされる正しい挙動になります。

背景

belongs_to_required_validates_foreign_keyfalseload_defaults(7.1) 以降のデフォルト値)にすると、Railsは必須 belongs_to アソシエーションに条件付きプレゼンスバリデーターを設置します。この条件ラムダは、外部キーが nil であるか変更された場合のみバリデーションを実行することで、外部キーが存在し変更されていない場合に親レコードのロードを回避する最適化として機能します。

しかし、複合外部キーforeign_key: [:shop_id, :order_id] のような配列)を使用する場合、この条件ロジックが正しく動作しませんでした。条件ラムダ内の record.read_attribute(foreign_key) は引数に対して .to_s を呼び出します。foreign_key が配列の場合、.to_s'["shop_id", "order_id"]' のような文字列を生成しますが、これはどの属性名とも一致しないため、read_attribute は常に nil を返していました。結果として条件は常に true となり、バリデーションは常に実行されていました。

この問題が実際に引き起こす障害は深刻です。default_scope でソフトデリートを実装しているモデル(例: where(is_not_deleted: true))では、ソフトデリートされた子レコードがソフトデリートされた親レコードを参照している場合、外部キーカラムが一切変更されていなくても default_scope を通じて親レコードが見つからず、バリデーションエラーが発生するという問題がありました。

技術的な変更

activerecord/lib/active_record/associations/builder/belongs_to.rbdefine_validations メソッド内の条件ラムダが修正され、複合外部キーの各カラムを個別にチェックするようになりました。

変更前:

record.read_attribute(foreign_key).nil? ||
  record.attribute_changed?(foreign_key) ||
  (reflection.polymorphic? && (record.read_attribute(foreign_type).nil? || record.attribute_changed?(foreign_type)))

変更後:

fk_missing_or_changed = if foreign_key.is_a?(Array)
  foreign_key.any? { |fk| record.read_attribute(fk).nil? || record.attribute_changed?(fk) }
else
  record.read_attribute(foreign_key).nil? ||
    record.attribute_changed?(foreign_key)
end

fk_missing_or_changed ||
  (reflection.polymorphic? && (record.read_attribute(foreign_type).nil? || record.attribute_changed?(foreign_type)))

修正後の挙動は次のとおりです:

  • すべてのFKカラムが存在し、いずれも変更されていない場合 → バリデーションをスキップ
  • いずれかのFKカラムが nil の場合 → バリデーションを実行
  • いずれかのFKカラムが変更された場合 → バリデーションを実行

テストは activerecord/test/cases/associations/belongs_to_associations_test.rb に3ケースが追加されました。また、activerecord/test/models/cpk/book.rbCpk::BookWithRequiredOrder モデルが追加され、optional: false の複合外部キー belongs_to を持つテスト用モデルが整備されています。

設計判断

is_a?(Array) による分岐で既存の単一外部キーの処理を維持する方式 が採用されました。

複合外部キーを配列の any? で走査するアプローチは、「いずれかのカラムが欠落・変更されていれば検証を行う」という保守的な判断です。これは単一外部キーの場合の意味論と一致しており、部分的な変更(例: shop_id のみ変更)でも確実にバリデーションが実行される安全側の設計です。また、スカラー外部キーのコードパスは変更されていないため、既存の動作への影響はありません。

ポリモーフィックアソシエーションの foreign_type チェックは fk_missing_or_changed の後段に配置されており、複合外部キーとポリモーフィックアソシエーションが競合するケースへの影響も最小限に抑えられています。

まとめ

本PRは、read_attribute が配列引数を正しく処理できないという根本原因を、最小限のコード変更で修正したバグフィックスです。ソフトデリートパターンと複合外部キーを組み合わせたモデルで不当なバリデーションエラーが発生していたケースが解消され、複合外部キーにおける条件付きバリデーションの最適化が設計通りに機能するようになります。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
fa090cd8

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

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文(総論)、背景・技術的な変更・設計判断(各論)、まとめ(結論)という「総論→各論→結論」の構成が明確であり、ガイドラインに準拠しています。

カスタムMarkdown構文 ✓ PASS

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

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

対象読者への適合性 ✓ PASS

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

複合外部キー、belongs_to、バリデーション最適化といった専門用語を前提としており、対象読者であるエンジニアに適した技術レベルで書かれています。

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

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

各セクションが総論・各論で構成され、各段落はトピックセンテンスで始まり、1段落1トピックの原則が守られています。段落の長さも適切で、高い可読性を確保しています。

Diff内容との照合 ✓ PASS

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

記事内のコードブロックは、提供されたDiffの内容を正確に反映しています。ファイルパスも一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「複合外部キー」「read_attribute」「belongs_to_required_validates_foreign_key」などの技術用語が、PR情報と一致しており、文脈上正しく使用されています。

説明の技術的正確性 ✓ PASS

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

「`read_attribute`が配列を文字列として扱い`nil`を返す」という問題の根本原因や、修正後のロジック(`any?`による個別チェック)の説明は、技術的に正確で論理的です。

事実の突合 ✓ PASS

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

記事内のすべての主張(問題の原因、影響、修正内容)は、PRのDescriptionやDiffの内容によって裏付けられており、ハルシネーションは検出されませんでした。

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

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

PR番号(#57130)や、言及されているファイルパス、メソッド名などがすべて正確です。

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

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

記事のタイトルはPRのタイトル「Fix belongs_to validation condition for composite foreign keys」の内容を的確に要約しており、記事全体の内容とも一致しています。

外部知識の正確性 ✓ PASS

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

「load_defaults(7.1)以降のデフォルト値」という記述はPR Descriptionに記載があり、PR情報に基づかない外部知識の追加はありません。

時間表現の正確性 ✓ PASS

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

記事には時間表現に関する記述はなく、PR情報との齟齬はありません。