1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Railsのenum `not_` スコープがNULLを除外するバグを追った

1
Posted at

はじめに

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
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?