Composite Primary Keys Supported by `excluding`
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 互換性も維持されています。