UpdateAll/DeleteAll が group/having だけのクエリで全行更新するバグを修正
Rails 6 以降で update_all と delete_all が group / having を含むが joins を持たないRelationに対して実行された場合、HAVING 条件を無視してテーブル全行を更新・削除していた問題を解消しました。これにより、意図したレコードだけが対象になることが保証されます。
背景
update_all / delete_all は内部で Arel の UpdateManager / DeleteManager を生成し、prepare_update_statement が主キーサブクエリへのラップを判定します。従来の判定ロジックは has_limit_or_offset_or_orders? と has_join_sources? だけをチェックし、has_group_by_and_having? を除外していたため、group と having のみを持つクエリはサブクエリ化されずに単純な UPDATE / DELETE が生成されました。その結果、HAVING に合致しないレコードも含めて全行が更新・削除されるという致命的なバグが発生していました。
このバグは joins + group + having のケースでは has_join_sources? が真になるため回避されていましたが、純粋に group / having だけを利用したスコープはテストが無く、全アダプタ(SQLite、PostgreSQL、MySQL)で同様に問題が潜んでいました。過去の PR #43465 が group / having に対するサブクエリサポートを導入したものの、判定条件に組み込まれていなかった点が原因です。
技術的な変更
判定ロジックに has_group_by_and_having? を追加 しました。activerecord/lib/arel/visitors/to_sql.rb の prepare_update_statement の条件式を次のように変更しています。
- if o.key && (has_limit_or_offset_or_orders?(o) || has_join_sources?(o))
+ if o.key && (has_limit_or_offset_or_orders?(o) || has_join_sources?(o) || has_group_by_and_having?(o))
この変更により、group と having が存在するRelationでもサブクエリが生成され、WHERE pk IN (SELECT pk FROM … GROUP BY … HAVING …) という安全な形に変換されます。結果として生成される SQL は次の通りです。
UPDATE "posts" SET "title" = ?
WHERE ("posts"."id") IN (
SELECT "posts"."id" FROM "posts" WHERE "id" IN (...) GROUP BY "posts"."id"
HAVING (MAX(legacy_comments_count) >= 3)
)
さらに、テストスイートに 純粋な group/having ケース を検証するテストを追加しました。activerecord/test/cases/relation/update_all_test.rb と delete_all_test.rb にそれぞれ test_update_all_with_group_by_and_having_without_joins と test_delete_all_with_group_by_and_having_without_joins を実装し、SQLite、PostgreSQL、MySQL の全3アダプタで期待通りにレコードが絞り込まれることを確認しています。
設計判断
この修正は 既存の判定フローに最小限の拡張 を加えるアプローチを採っています。has_group_by_and_having? はすでに Arel に実装されているヘルパーであり、条件式への追加だけで全アダプタで一貫した動作を得られました。新たな設定キーや大規模なリファクタリングを行わず、後方互換性を維持しつつバグを根本的に除去した点が設計上のポイントです。
また、テストカバレッジを拡張したことで、将来的に同様のロジック変更が再度抜け落ちるリスクを低減しています。今回の変更は 「group/having のみのケースでもサブクエリへルーティング」 という既存設計の意図を明示的に全ケースに適用する形となり、Rails の更新系メソッドの振る舞いがドキュメント通りに保証されるようになりました。
まとめ
update_all と delete_all が group / having だけのクエリでも正しくサブクエリを使用するよう判定ロジックを拡張し、全行更新・削除という致命的バグを修正しました。最小限のコード追加で全アダプタ対応を実現し、テストで挙動を検証したことで、Rails のデータ操作系 API の信頼性がさらに向上しています。