UpdateAll/DeleteAll が group/having だけのクエリで全行更新するバグを修正

rails/rails

Rails 6 以降で update_alldelete_allgroup / 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? を除外していたため、grouphaving のみを持つクエリはサブクエリ化されずに単純な 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.rbprepare_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))

この変更により、grouphaving が存在する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.rbdelete_all_test.rb にそれぞれ test_update_all_with_group_by_and_having_without_joinstest_delete_all_with_group_by_and_having_without_joins を実装し、SQLite、PostgreSQL、MySQL の全3アダプタで期待通りにレコードが絞り込まれることを確認しています。

設計判断

この修正は 既存の判定フローに最小限の拡張 を加えるアプローチを採っています。has_group_by_and_having? はすでに Arel に実装されているヘルパーであり、条件式への追加だけで全アダプタで一貫した動作を得られました。新たな設定キーや大規模なリファクタリングを行わず、後方互換性を維持しつつバグを根本的に除去した点が設計上のポイントです。

また、テストカバレッジを拡張したことで、将来的に同様のロジック変更が再度抜け落ちるリスクを低減しています。今回の変更は 「group/having のみのケースでもサブクエリへルーティング」 という既存設計の意図を明示的に全ケースに適用する形となり、Rails の更新系メソッドの振る舞いがドキュメント通りに保証されるようになりました。

まとめ

update_alldelete_allgroup / having だけのクエリでも正しくサブクエリを使用するよう判定ロジックを拡張し、全行更新・削除という致命的バグを修正しました。最小限のコード追加で全アダプタ対応を実現し、テストで挙動を検証したことで、Rails のデータ操作系 API の信頼性がさらに向上しています。

記事メタデータ

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

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

リード文、背景、技術的変更、設計判断(任意)、まとめの構成が揃っており、総論→各論→結論の流れが明確です。

カスタムMarkdown構文 ⚠ WARNING

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

ファイル名付きコードブロックは正しい形式ですが、GitHubリンクは `[PR #57601](URL)` のように "PR " が付いており、要求された `[ #123 ](URL)` 形式から外れています。リンク自体は機能しますが形式違反のため警告とします。

対象読者への適合性 ✓ PASS

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

対象はRailsエンジニアであり、専門用語が適切に使用され、初心者向けの過度な説明はありません。

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

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

各セクションは総論パラグラフ→具体的な各論→結論パラグラフで構成され、段落はトピックセンテンスで始まり、1段落1トピック、適切な長さに保たれています。

Diff内容との照合 ✓ PASS

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

記事中のコードブロックは Diff の変更内容と一致し、削除・追加行ともに正しく反映されています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

使用されている技術用語(has_group_by_and_having?、Arel、UpdateManager など)は PR と一致し、誤用はありません。

説明の技術的正確性 ✓ PASS

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

バグの原因、修正内容、影響範囲の説明が PR の記述と整合しており、技術的に正確です。

事実の突合 ✓ PASS

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

記載された事実はすべて PR の情報(タイトル、説明、Diff)で裏付けられており、未根拠の推測はありません。

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

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

PR 番号やアダプタ数(3)などの数値は正確です。

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

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

記事タイトルは PR の主旨を適切に要約しており、一致しています。

外部知識の正確性 ✓ PASS

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

LTS やリリース日程など、PR に無関係な外部知識は含まれていません。

時間表現の正確性 ✓ PASS

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

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