はじめに
Railsのenumには、publishedに対してnot_publishedのような否定スコープが自動生成される。この否定スコープが、対象カラムがNULLのレコードを結果から除外してしまう不具合がissue #55908として報告されている。
原因はSQLの否定条件とNULLの相性の悪さにあり、修正も一度で完了せず「素朴な修正→別の壊れ方が発覚→原理的な解決」という2段階を踏んでいる。この過程をRailsのコミット履歴を辿りながら追う。
現象
class Post < ActiveRecord::Base
enum :state, %w[pending published]
end
Post.create!(state: "pending")
Post.create!(state: "published")
Post.create!(state: nil)
Post.not_published # 期待: pendingとnilの2件 / 実際: pendingの1件だけ
stateがNULLのレコードは、not_published("publishedじゃない")の条件に本来当てはまるはずだが、結果から漏れる。stateは必須カラムではないため、この状況は普通に起こり得る。
再現
Railsのissueはbundler/inline形式で書かれており、コピペしたスクリプトをそのままrubyで実行できる。
ruby repro_55908.rb
バージョン未指定のgem "rails"は常に最新版を取得するため、手元のRubyバージョンと噛み合わずSyntaxErrorになることがある。issueの報告バージョンに合わせてgem "rails", "8.0.3"と固定すると解決する。
実測した生成SQL:
SELECT "posts".* FROM "posts" WHERE "posts"."state" != '1'
原因 — SQLの三値論理
SQLの比較演算子は、NULLが絡むとTRUE/FALSEではなく**UNKNOWN**という第3の値を返す。
'1' = '1' → TRUE
'1' = '2' → FALSE
NULL != '1' → UNKNOWN
WHERE句はTRUEの行だけを残す。FALSEはもちろんUNKNOWNも弾かれる。そのためstate != '1'でstateがNULLの行は、直感的には「'1'と等しくない」のに結果から消える。
原因のコードを追う
activerecord/lib/active_record/enum.rbの該当箇所(現在のmain):
klass.scope "not_#{value_method_name}", -> { where(predicate_builder[name, value, :is_distinct_from]) }
!=ではなくIS DISTINCT FROMが使われている。IS DISTINCT FROMはSQL標準のNULL安全な比較演算子で、NULLを含む比較でも必ずTRUE/FALSEを返す。
NULL IS DISTINCT FROM '1' → TRUE
修正の経緯
該当行の変更履歴をgit log -Lで追うと、1回の修正では終わっていないことが分かる。
git log -p -L 322,322:activerecord/lib/active_record/enum.rb
| 時期 | 変更 | 内容 |
|---|---|---|
| 2019 | where.not(attr => value) |
否定scope機能の新規追加。NULLは未考慮 |
| 2021×2 | 名前衝突・object key対応 | 別件の修正 |
| 2025-10-15 | not_eq(value).or(eq(nil)) |
最初の修正。NULLをORで明示的に救う |
| 2025-10-21 | IS DISTINCT FROM |
最初の修正への再修正(別の壊れ方が発覚) |
最初の修正が生んだ別のバグ
最初の修正コミットは、次の再修正で「enum定義自体にnil値を含む場合に否定scopeを壊した」と説明されている。これは「カラムの値がたまたまnil」ではなく、nilそのものが正式なenumの値として設計されているケースを指す。
enum :status, { unspecified: nil, active: 0, archived: 1 }
not_unspecifiedを呼ぶと、valueにはnilが入る。最初の修正コードに代入すると:
arel_column.not_eq(nil).or(arel_column.eq(nil))
Arelは.eq(nil)/.not_eq(nil)をIS NULL/IS NOT NULLに変換する:
eq(nil) -> "posts"."state" IS NULL
not_eq(nil) -> "posts"."state" IS NOT NULL
生成される条件は次の通りで、あらゆる行に対して常にtrueになるタウトロジーになってしまう。
WHERE (state IS NOT NULL OR state IS NULL)
not_unspecifiedは「unspecifiedじゃないものだけ」が欲しいのに、絞り込みが完全に無効化されて全行返ってしまう。「NULLの行を救うためにORでNULL条件を足す」という発想は、valueが具体的な値の場合はうまく機能するが、valueそのものがnilになるケースを想定していなかった。IS DISTINCT FROMはこの特殊分岐が不要で、両方のケースを同じロジックで正しく処理できる。
まとめ
- SQLの否定条件(
!=、NOT IN)はNULLを暗黙に取りこぼす。三値論理(TRUE/FALSE/UNKNOWN)が原因 -
IS DISTINCT FROMはNULL安全な比較演算子で、この種の問題を特殊分岐なしで解決できる - 現在のコードだけを読んでも「なぜこの実装になったか」は分からないことがある。
git log -Lで行単位の変更履歴を追うと、素朴な修正が別の不具合を生み、より原理的な解決に置き換わった経緯が見えた
参考:検証に使ったコマンド
# 再現スクリプトの実行
ruby repro_55908.rb
# 該当コードの場所を特定
grep -n "not_" ~/Documents/oss/rails/activerecord/lib/active_record/enum.rb
# 該当行の変更履歴を追う
cd ~/Documents/oss/rails
git log -p -L 322,322:activerecord/lib/active_record/enum.rb | head -80
# 最初の修正コミットの全文確認
git show d81fbf33bc9c478fca57252255cbd0013fe137b6
# ORベースの修正がなぜ壊れるかをArel単体で検証
ruby arel_nil_check.rb