はじめに
Rails mainにActiveRecord::Relation#uniq!を非推奨化するコミットが入った(コミット e1bcb9970a / PR #58525)。変更自体は10行程度の小さなdiffで、コミットメッセージも「6.1で追加され、7.0のデフォルト重複排除で不要になったので9.0で消す」という一言で終わっている。
しかしこの一言の裏には、2012年のgroup!メソッド誕生から2024年の恒久修正まで、14年・1回のリバートと複数の関連バグを含む長い経緯があった。本記事はこの非推奨化コミットを起点に、Railsのコミット履歴を遡って「なぜこの実装になったか」を追う。
uniq!は何をしていたか
対象はactiverecord/lib/active_record/relation/query_methods.rbのuniq!メソッド。
def uniq!(name) # :nodoc:
ActiveRecord.deprecator.warn(<<~MSG.squish)
`ActiveRecord::Relation#uniq!` is deprecated and will be removed in
Rails 9.0. ...
MSG
if values = @values[name]
values.uniq! if values.is_a?(Array) && !values.empty?
end
引数のnameはカラム名ではなく:groupや:annotateのようなRelation内部の多値クエリメソッド名を指す。@values[:group]は.group(:a, :b)のように呼び出した引数を溜め込む配列で、uniq!(:group)はこの配列の重複を除去するメソッドだった。SQLのDISTINCTとは無関係。
紛らわしいことに、Railsには過去に**bang無しの#uniq**という別のメソッドも存在した。こちらはクエリ結果を重複排除する#distinctの旧名で、系譜が異なる。
| メソッド | 役割 | 経緯 |
|---|---|---|
Relation#uniq(bang無し) |
クエリ結果の重複排除 | 4.0で#distinctにリネーム → 5.0で非推奨 → 5.1で削除。現存しない |
Relation#uniq!(bang付き) |
@values内の多値配列の重複除去 |
6.1で新規追加 → 今回非推奨 → 9.0で削除予定 |
#uniq(bang無し)の削除履歴は次の通り確認できた。
4.0 (a1bb6c8b06, 2013-03-12): rename Relation#uniq to Relation#distinct. #uniq still works.
5.0 (adfab2dcf4, 2015-05-26): deprecate Relation#uniq use Relation#distinct instead.
5.1 (c6dcc37bee): Remove deprecated #uniq, #uniq!, #uniq_value
動作確認
現行コードでuniq!が実際にどう振る舞うか、ローカルのActiveRecordソースに向けて確認した。
relation = Post.group(:title).group(:title)
puts relation.to_sql
p relation.group_values
"SELECT \"posts\".* FROM \"posts\" GROUP BY \"posts\".\"title\""
[:title]
.group(:title)を2回呼んでも、group_valuesにもSQLにも重複は現れない。次にuniq!自体を明示的に呼ぶ。
p Post.group(:title).uniq!(:group).group_values
DEPRECATION WARNING: `ActiveRecord::Relation#uniq!` is deprecated and will be removed in Rails 9.0. ...
[:title]
警告は出るが、結果([:title])はuniq!を呼ぶ前と変わらない。呼んでも呼ばなくても同じだった。
重複がなぜ「実害あり」だったのか
GROUP BY title, titleという重複したSQL自体は、グルーピング結果を変えない(titleとtitle, titleは同じ集合に分割される)という意味では無害に見える。しかし実際には、集計系メソッドの戻り値のHashのキーの形が変わるという実害があった。
7.0での修正前後のテストコードの差分(コミット 98f608bcf2)を見ると分かりやすい。
# 修正前:グルーピングカラムが重複していると、Hashのキーまで配列になる
expected = { [1, 1] => 50, [2, 2] => 60, ... }
# 修正後:スカラーキーに統一
expected = { 1 => 50, 2 => 60, ... }
accounts.merge!(accounts).maximum(:credit_limit)のように、同じ.groupを持つRelation同士をmergeするとgroup_valuesが[:id, :id]のように重複し、maximumの戻り値のHashキーが1ではなく[1, 1]という配列になっていた。呼び出し側がhash[1]でアクセスするコードを書いていれば、mergeのタイミング次第で静かにnilが返るバグになり得る。
「警告は出すが、挙動は変えない」を実装するとこうなる
この実害を伏せたまま非推奨サイクルを回すため、calculations.rbには次のようなコードが書かれていた(2580b83f42で新規追加、98f608bcf2で削除)。
def execute_grouped_calculation(operation, column_name, distinct) #:nodoc:
group_fields = group_values
group_fields = group_fields.uniq if group_fields.size > 1
unless group_fields == group_values
ActiveSupport::Deprecation.warn(<<-MSG.squish)
`#{operation}` with group by duplicated fields does no longer affect to result in Rails 6.2.
To migrate to Rails 6.2's behavior, use `uniq!(:group)` to deduplicate group fields ...
MSG
group_fields = group_values
end
...
group_valuesが[:id, :id](重複あり)の場合を具体的に追う。
-
group_fields = group_values→group_fields = [:id, :id] -
group_fields = group_fields.uniq if group_fields.size > 1→ サイズが2なので実行され、group_fields = [:id](重複除去された)。ただしgroup_values自体は[:id, :id]のまま変わらない -
unless group_fields == group_values→[:id] == [:id, :id]はfalseなので、unlessの中身が実行される - 警告を出す
-
group_fields = group_values→group_fieldsを[:id, :id]に戻す
最終的に使われるのは重複ありの[:id, :id]。.uniqの計算結果は、警告を出すべきかどうかの判定(3行目の比較)にしか使われず、実際の挙動には反映されなかった。
一方group_valuesが[:id](元々重複が無い)場合は、2行目の.uniqが何もせず、3行目の比較はtrueになってunlessブロックごとスキップされる。警告も出ないし、書き戻しも起きない。重複が無い人には何も起きない設計になっている。
98f608bcf2(7.0)がやったのはこのunless ... endブロックを丸ごと削除しただけ。1〜2行目で最初から計算されていたgroup_fields.uniqが、警告も後退も無しにそのまま使われるようになった。バグを直したのではなく、最初から時限式で書かれていた「警告専用の一時凌ぎコード」が、約束の期限(7.0)通りに撤去された。
uniq!は誰のためのメソッドだったか
uniq!(:group)は、この「重複によるHashキー崩れ」の非推奨警告に気づいた開発者が、Railsの次のメジャーバージョンを待たずに今すぐ正しい挙動(スカラーキー)へ切り替えるためのオプトインスイッチとして用意された。デフォルトで有効にはせず、明示的に呼んだ人だけ新挙動を先取りできる、という設計。
時系列で見る14年間
group!メソッド(@values[:group]に値を追加する内部メソッド)の実装をgit log -Lで追うと、この重複排除は一直線に進んだわけではなく、一度実装されて撤回され、正規の非推奨サイクルを経て何年も後にようやく定着したことが分かった。
git log -L 578,581:activerecord/lib/active_record/relation/query_methods.rb
| 時期 | バージョン目安 | 変更 | 内容 |
|---|---|---|---|
| 2012-04 | - |
group!誕生(8c2c60511) |
self.group_values += args。重複は無条件に許容 |
| 2020-01-03 | - |
group_valuesが+= → |=(aca85f62, sinsoku, PR #38145) |
Post.joins(:author).joins(:author).or(...)がArgumentErrorになるバグの修正(#38052)。groupの集計とは無関係な、#orの構造互換性チェックの文脈から重複排除が入った |
| 2020-05-06〜07 | - |
group_valuesのみ|= → +=に差し戻し(82b66fe48, kamipo) |
#39171(ankaneが報告)で、集計結果のHashキーが[1,1]から1に変わり既存アプリを壊すregressionが発覚。「勝手に公開挙動を変えるべきではない」とgroupだけ撤回 |
| 2020-05-15〜23 | 6.1 | 非推奨サイクル開始(2580b83f42〜#39358マージ、kamipo) |
groupのリバートを受け、正規の非推奨警告つきで同じ変更を再提案。ここでuniq!が生まれる。移行先として明記されていたのは「Rails 6.2」
|
| 2020-05-18 | - | 多値の正規化統一に着手(#39333, kamipo) | 「flattenされる値とされない値が混在」等、正規化がバラバラな状態を統一しようとした別のPR。同日中に作成・マージ |
| 2020-08-24 | - |
select_values/order_valuesは+= → |=(29874cc, kamipo) |
groupとは別に、selectとorderはレビューの跡もなく静かに|=化され、そのまま定着した。group_valuesはここでも対象外のまま
|
| 2021-02-04 | - | 「Rails 6.2」が「Rails 7.0」に改名(1b455e2e9d, Rafael Mendonça França) |
開発版の名称変更。同じコミットで警告文言の「Rails 6.2」表記も一括で「Rails 7.0」に書き換わった |
| 2021-11-18 | 7.0 |
calculations.rb側で強制dedup(98f608bcf2, Rafael Mendonça França) |
集計計算の読み出し時にのみ.uniqを適用して確定。group_values自体はまだ+=のまま残った |
| 2024-10-28 | - |
#mergeがwith_valuesをマージしない、と報告(#53468, tim-semba) |
with_values(CTE用)は6.1で追加された多値で、2020年の正規化統一の対象に入っておらず、dedup/正規化が漏れたまま4年間放置されていた |
| 2024-10-29 | - |
with_valuesを個別に重複除去(#53469「Deduplicate with CTE expressions」) |
#53468への直接の修正PR |
| 2024-10-29 | 8.1 |
group_valuesも含め全多値が+= → |=(a3884476, #53480「Unify query values deduplication for multi values」、kamipo) |
#53468を機に、2020年の#39333で未完に終わっていた統一作業を完遂 |
| 今回 | main(9.0系開発中) |
uniq!非推奨化(e1bcb9970a, #58525) |
オプトインスイッチとしての役目を終えたため |
つまり非推奨化コミットにあった「Deduplication has been applied automatically since Rails 7.0」という一文は、正確には簡略化された説明だった。7.0で直ったのは集計結果のHashキーという目に見える症状だけで、group_valuesという配列そのものが書き込み時点で重複を持てなくなった(=根治)のは2024年、Rails 8.1でのこと。「症状を消したパッチ」と「原因を塞いだ修正」の間に3年の空白がある。
さらに言うと、この3年間「全ての多値メソッドが等しく足踏みしていた」わけでもない。selectとorderは2020年8月(29874cc)の時点で静かに\|=化されて定着していて、頓挫していたのはgroup(と、後から追加されて統一作業自体の対象に入り損ねたwith)だけだった。
もう1つ、非推奨化のCHANGELOG(2580b83f42)自体のコミットメッセージには、当時の移行先バージョンが「Rails 6.2」と明記されている。
So I'd like to deprecate the duplicated behavior in Rails 6.1, and will be deduplicated all multiple values in Rails 6.2.
Rails 6.1の次のメジャーバージョンは、開発中は「6.2」と呼ばれていた。改名の瞬間も特定できる。コミット 1b455e2e9d(2021-02-04、Rafael Mendonça França)「Rails 6.2 is now Rails 7.0」で、リポジトリ全体のバージョン表記が一括で書き換えられている。理由は本人の言葉でこう説明されている。
We have big plans for the next version of Rails and that require big versions.
このコミットの差分にはcalculations.rbも含まれていて、まさに今追っている警告文言の「Rails 6.2」が「Rails 7.0」に書き換わった瞬間そのものだった。
- `#{operation}` with group by duplicated fields does no longer affect to result in Rails 6.2.
- To migrate to Rails 6.2's behavior, use `uniq!(:group)` to deduplicate group fields
+ `#{operation}` with group by duplicated fields does no longer affect to result in Rails 7.0.
+ To migrate to Rails 7.0's behavior, use `uniq!(:group)` to deduplicate group fields
つまり非推奨化した2020年5月時点では「7.0」という名前自体がまだ存在しておらず、今回の非推奨化コミット(e1bcb9970a)が「Rails 7.0's default deduplication」と書いているのは、あくまで現在から見た呼び方に過ぎない。
同じコミットメッセージには、このバグの根がさらに遡ることを示唆する一文もある。
I've investigated the deduplication for the values, at least that had existed since Rails 3.0.
Aggregations with group by multiple fields was introduced at Rails 3.1, but we missed the deduplication for the aggregation result, unlike the generated SQL.
kamipo自身の調査によれば、「生成されるSQL自体は重複除去されていたのに、集計結果(Hash)を組み立てる処理だけがそれに倣っていなかった」というズレは、複数カラムでの集計が導入されたRails 3.1(2011年頃)まで遡るという。本記事が起点としている2012年のgroup!誕生より前に、バグの種自体はすでに存在していたことになる。この2010〜2011年の該当コミット自体は今回未検証。
なぜ2026年だったのか——nodoc化のつもりが非推奨化になった経緯
#58525自体は1コミット・レビューコメント0件・kamipo自身が即日マージという、議論の跡がほぼ残らないPRだった。実際の経緯は、まったく別目的で提出された#56338というPRの中にある。
-
2025-12-10(p8):「
QueryMethodsの他のbangメソッドは全部:nodoc:なのにuniq!だけ違う。揃えよう」という趣旨でPRを提出。ドキュメント整理のつもりだった -
2025-12-11(yahonda):「この方法だけ
:nodoc:にするのは待った方がいい。#39358で入って6.1から公開APIとして存在してるし、CHANGELOGにも載ってる」と指摘 -
2025-12-11(p8):「コミット履歴を見るべきだった。
distinctの旧名の方のuniqと勘違いしてた」と誤りを認める -
2025-12-13(p8):出典を読み直し、「
uniq!は7.0のデフォルト重複排除に向けた移行のために6.1で入ったっぽい。7.0で既定になったなら、もうこのメソッドは要らないのでは」と再診断。原作者のkamipoに「これまだ必要か」と直接質問 -
2025-12-14(kamipo):
#39358を書いた本人が「そう、非推奨サイクルのために導入したもので、今は全ての多値がdedupされてるからもう不要。移行が終わったら非推奨化するつもりだったから、良い考え」と回答
#56338自体の当初の提案(:nodoc:化)は採用されず、代わりにuniq!そのものを非推奨化する#58525という形でクローズされた(2026-08-20提出、同日マージ)。
kamipo自身「移行が終わったら非推奨化するつもり」という意図は#39358の時点からあったが、5年間そのままになっていた。それを思い出すきっかけになったのは、uniq!をdistinctの旧名の方のuniqと勘違いしたところから始まった、別のコントリビューターの調査だった。
.groupはなぜ蓄積され、.regroupはなぜ置き換えるのか
|=が実際にどう積み上がるのかは、.groupの実装をspawnまで遡ると分かる。
def group(*args)
check_if_method_has_arguments!(__callee__, args)
spawn.group!(*args)
end
def group!(*args) # :nodoc:
self.group_values |= args
self
end
spawnの中身はほぼcloneで、呼び出し元のRelationの状態(group_values含む)を丸ごと複製する。つまり.groupを呼ぶたびに、前のRelationの状態を複製してから、その場の引数だけを|=で足していく。Post.group(:a).group(:b)が[:a, :b]になるのはこの積み重ねのため。
これと対になるのが.regroup。
def regroup!(*args) # :nodoc:
args.uniq!
self.group_values = args
self
end
|=ではなく=を使っているため、既存のgroup_valuesを無条件に上書きする。ドキュメントは「unscope(:group).group(fields)のショートハンド」と説明しているが、実装はunscopeを経由しない直接代入。.mergeの挙動に関しては両者に差は無かった(merger.rbは:groupのマージにunscope_valuesを参照していない)。
まとめ
-
uniq!(bang付き)と#uniq(bang無し)は名前が似ているだけの無関係な別メソッド。前者は@values内の多値配列の重複除去、後者は#distinctの旧名 -
GROUP BYの重複は「SQL的には無害」に見えて、実際には集計結果のHashキーの形を変える実害があった - 2020年、
groupだけが集計結果のHashキー崩れ(#39171)というregressionのせいで特別扱いされ、非推奨サイクルに回された。同じ年にselect・orderは静かに\|=化されて定着しており、「全部の多値メソッドが足踏みしていた」わけではない -
group_values自体が根本的にdedupされる(=根治)のは2024年。2020年の統一作業(#39333)で果たせなかった宿題が、6.1で後から追加されて対象漏れしていたwith_valuesのバグ報告(#53468)をきっかけに完遂された -
#58525(非推奨化PR)自体には議論の跡がなく、実際の「なぜ今か」の答えは、まったく別目的(:nodoc:化)で提出された#56338の中に、レビュアーの訂正を経て偶然掘り起こされる形で存在した——「別目的の作業や別のバグ報告が、忘れられていた技術的負債を掘り起こす」というパターンが14年史の中に2回現れている - 現在のコードだけを読んでも「なぜこの実装か」は分からない。
git log -Lで行単位の履歴を辿り、関連PR・issueのコメント欄・コミット本文まで見ることで、素朴な修正がregressionを生み、より慎重な形に置き換わっていく過程と、それが実際に動く「きっかけ」が見えた
今回一番の収穫は、「非推奨化」というものの実体を、ルールの説明としてではなくコードの変遷として追えたこと。「警告は出すが挙動は変えない」という抽象的な原則が、実装ではunless group_fields == group_valuesという具体的な分岐になり、しかもその分岐自体が期限付きの一時コードだった、という構造は他の非推奨化にも同じ形で潜んでいるはずだ。次にDEPRECATION WARNINGを見かけたときは、その裏にある「移行期間中だけの橋渡しコード」を探しに行けそうな気がする。
参考:検証に使ったコマンド
# 非推奨化コミット・関連PRの特定
git log --oneline --all --grep="uniq!" -i
git show e1bcb9970aa9b959a170e2d468880c853ac701c0
gh pr view 58525 --repo rails/rails --json number,title,url,mergeCommit,state
# 旧#uniq(bang無し)の廃止バージョンの特定
git tag --contains adfab2dcf4 | grep -E "^v[0-9]" | sort -V | head -5
git tag --contains c6dcc37bee | grep -E "^v[0-9]" | sort -V | head -5
# Railsの公式非推奨ポリシーの確認
cat guides/source/maintenance_policy.md
# 動作確認(bundler/inlineでローカルのactiverecordソースに接続)
RBENV_VERSION=3.3.8 ruby test.rb
# 7.0でのデフォルト化コミットの特定
git log --oneline --all --since="2020-01-01" --until="2021-12-31" --grep="duplicat" -i
git show 98f608bcf2cf5407459fb611d4680999a76df2b7
# group!メソッドの行単位の変更履歴(本記事のコアとなる調査)
git log -L 578,581:activerecord/lib/active_record/relation/query_methods.rb
# 2024年の恒久対応がどのバージョンに入ったか
git tag --contains a3884476699272976f3f839a66bddd8ffad35db | grep -E "^v[0-9]" | sort -V | head -5
# spawn / regroup! / unscope! の実装確認
grep -rn "def spawn" activerecord/lib/active_record/relation/
grep -n "def regroup!" -A 5 activerecord/lib/active_record/relation/query_methods.rb
grep -n "def unscope" -A 20 activerecord/lib/active_record/relation/query_methods.rb
# #56338(非推奨化のきっかけとなったPR)の会話を裏取り
gh api repos/rails/rails/issues/56338/comments --jq '.[] | {user: .user.login, created_at, body}'
gh api repos/rails/rails/issues/56338 --jq '{user: .user.login, created_at, closed_at, state, title}'
# aca85f62の所属PR番号の特定
gh api repos/rails/rails/commits/aca85f62595b01c1c32310dd5e3c0650d6a2979a/pulls --jq '.[] | {number, title, url}'
# #39333が2020年のPRであることの確認(2024年のissueという誤推測を訂正)
gh api repos/rails/rails/issues/39333 --jq '{user: .user.login, created_at, closed_at, state, title, pull_request: (.pull_request != null)}'
# 29874cc(select/orderのdedup化。groupは対象外だったことの発見)
git show 29874cc4e220edadfdf50ba93db57d2df396e395
# 「Rails 6.2」→「Rails 7.0」への改名コミットの特定(Zeitwerkという未検証の憶測を訂正)
git log --oneline --all --since="2021-01-01" --until="2021-12-31" --grep="7.0\|6.2" -i -- version.rb RAILS_VERSION
git show 1b455e2e9d -- activerecord/lib/active_record/relation/calculations.rb
# #39171 / #53468 の一次ソース確認
gh api repos/rails/rails/issues/39171 --jq '{user: .user.login, created_at, closed_at, state, title}'
gh api repos/rails/rails/issues/53468 --jq '{user: .user.login, created_at, closed_at, state, title}'
# #53468に紐付く修正PR(#53469, #53480)の特定と再現コード確認
gh api repos/rails/rails/issues/53468/timeline --jq '.[] | select(.event == "cross-referenced" or .event == "closed") | {event, source: .source.issue.number, source_title: .source.issue.title}'
gh api repos/rails/rails/issues/53468 --jq '.body'
# regroup!とunscope(:group).groupの.merge挙動差を確認(差は無いと判明)
cat activerecord/lib/active_record/relation/merger.rb
grep -rn "unscope_values" activerecord/lib/