Composite Primary Keys Supported by `excluding`

rails/rails

Relation#excluding (alias without) now works with models that define composite primary keys, eliminating the ActiveRecord::StatementInvalid that was raised previously.

背景

excluding! raised an exception when invoked on a model with a composite primary key. The method built its predicate using predicate_builder[primary_key, records], where primary_key becomes an array such as ["author_id", "id"]. That array was coerced into a single column identifier, resulting in SQL that referenced a non‑existent column like "cpk_books"."[\"author_id\", \"id\"]".

この問題は、シングルキーのモデル(例: Post.excluding(post)) では正常に動作する一方、複合キーを持つモデル(例: Cpk::Book.excluding(book)) で ActiveRecord::StatementInvalid が発生しました。テストスイートでも同様の失敗が報告され、実運用でクエリビルドが不安定になるリスクがありました。結果として、複合キーを使用するアプリケーションで excluding が利用できない状態が発生していました。

このエラーは、primary_key が配列として渡されたことに起因し、SQL生成ロジックが期待通りに機能しなかったことが直接の原因です。

技術的な変更

excluding! の内部実装を、predicate_builder の呼び出しから build_where_clause を用いた形に置き換えました。まず、レコードから ID を抽出し、primary_key => ids のハッシュを build_where_clause に渡して WHERE 句を生成し、最後に .invert で除外条件に変換します。

@@ -1656,8 +1656,8 @@ def excluding(*records)
     alias :without :excluding

     def excluding!(records) # :nodoc:
-      predicates = [ predicate_builder[primary_key, records].invert ]
-      self.where_clause += Relation::WhereClause.new(predicates)
+      ids = records.map { |record| record.is_a?(model) ? record.id : record }
+      self.where_clause += build_where_clause(primary_key => ids).invert
       self
     end

この変更により、primary_key が配列であっても build_where_clause が内部で正しい複合条件(例: "author_id" = ? AND "id" = ?)を生成し、.invert が除外ロジックを適用します。シングルキーの場合は、ids が単一の配列となり、元のロジックと同等の SQL が生成されるため、既存の挙動は変わりません。

テストファイル activerecord/test/cases/excluding_test.rb には、Cpk::Book に対する除外クエリと、クエリオブジェクト自体を除外対象にしたケースが追加され、期待どおりにレコードが除外されることが検証されています。

設計判断

この修正は公開 API を変更せずに内部ロジックだけを差し替える形を取っています。primary_key => ids というハッシュベースの条件生成は、ActiveRecord が where(primary_key => ids) で既に複合キーを正しく扱っている実装と整合性が取れており、追加のヘルパーメソッドやオプションキーを導入する必要がありませんでした。

代替案として新しいメソッドや設定キーを追加する案も検討されましたが、コードベースの肥大化と互換性リスクを避けるため、最小限の差分で既存パスを拡張する方針が選択されました。このアプローチにより、単一キーと複合キーの両方で同一の excluding 使用感が提供され、既存アプリケーションへの影響はゼロです。

まとめ

excluding メソッドは、複合主キーを持つモデルでも正しい除外条件を生成できるようになり、ActiveRecord::StatementInvalid が解消されました。実装は build_where_clause の活用に置き換えるだけで、シングルキーの挙動はそのまま保持され、API 互換性も維持されています。

記事メタデータ

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

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

品質レビュー結果

Review Status:
リトライ後承認
Review Count:
2回 (改善を経て承認)
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文がタイトル直下にあり、背景・技術的な変更・設計判断(任意)・まとめの順に構成されている。すべての必須要素が揃っている。

カスタムMarkdown構文 ✓ PASS

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

コードブロックは正しい diff 記法で記述され、ファイル名付きハイライトは使用されていないが必要なし。GitHub リンクも正しい形式で記載されている。

対象読者への適合性 ✓ PASS

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

内容は Rails の内部実装に詳しいエンジニア向けで、初心者向けの過度な説明はない。

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

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

各セクションは総論→各論→結論の流れになり、段落はトピックセンテンスで始まり、1段落1トピック、長さも適切で空行で区切られている。

Diff内容との照合 ✓ PASS

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

記事中の diff ブロックは提供された PR の diff と完全に一致し、ファイル名や変更内容も正しく反映されている。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

使用されている技術用語は PR と一致し、誤用や不適切な表現は見られない。

説明の技術的正確性 ✓ PASS

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

変更理由・実装方法・影響の説明が PR の記述と合致し、技術的に正確で矛盾がない。

事実の突合 ✓ PASS

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

全ての主張が PR のタイトル、説明、Diff、テストコードに裏付けられており、捏造や推測はない。

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

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

PR 番号 #57656 など数値情報は正確に記載されている。

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

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

記事タイトルは PR タイトルと意味的に一致しており、内容を正確に表現している。

外部知識の正確性 ✓ PASS

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

記事は PR に記載された情報のみを使用し、外部のバージョン情報やベストプラクティス等は含まれていない。

時間表現の正確性 ✓ PASS

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

時間表現の歪曲はなく、PR の表現と一致している。