非整数サブレンジでクラッシュしていた Range#include? と Range#=== を安全に修正
ActiveSupport が提供する Range#include? と Range#=== の拡張が、排他末端かつ整数以外の要素を持つサブレンジに対して TypeError を発生させていた問題を、Ruby 標準の Range#cover? へ委譲することで根本的に解消しました。この変更により、時間窓や浮動小数点範囲の比較が例外なく正しい真偽値を返すようになります。
背景
ActiveSupport は元々、引数が Range である場合に独自ロジックで包含関係を判定していました。そのロジックは value.max を参照してサブレンジの最大要素を取得し、排他末端かつ整数でないケースでは Range#max が列挙を試みて TypeError を投げるため、(1.0..10.0).include?(2.0...5.0) などの呼び出しがクラッシュしていました。対象となったのは Float, Time, DateTime, TimeWithZone, BigDecimal といった非整数型で、時間ウィンドウや許容帯の表現に頻出するパターンでした。Rails は Ruby 2.6 以降を必須としているため、標準メソッド Range#cover? が引数に Range を受け取る機能を備えている点が修正の鍵となります。
技術的な変更
拡張ロジックは activesupport/lib/active_support/core_ext/range/compare_range.rb に実装されており、=== と include? の両メソッドで Range 引数時に value.max を計算していました。以下は変更前の抜粋です。
if value.is_a?(::Range)
# 省略...
value_max = !exclude_end? && value.exclude_end? ? value.max : value.last
super(value.first) && (self.end.nil? || value_max.public_send(operator, last))
else
super
end
修正後は value_max に依存せず、Ruby 標準の cover? に委譲します。
if value.is_a?(::Range)
cover?(value)
else
super
end
このシンプルな置き換えにより、排他末端・非整数サブレンジでも例外が発生せず、既存のスカラー比較ロジックはそのまま維持されます。テストも activesupport/test/core_ext/range_ext_test.rb に新規ケースが追加され、Float、Time 系列での正常動作が検証されています。
設計判断
Rails が前提としている Ruby バージョンが Range#cover? の拡張をサポートしている点を利用し、独自の範囲比較ロジックを排除した点が最大の設計判断です。元の実装は整数サブレンジだけを正しく扱うために多数の条件分岐と value.max 呼び出しを組み合わせていましたが、可読性と保守性の観点から標準メソッドへの委譲へと統一しました。スカラー引数に対する else super 部分はそのまま残し、既存の Range#include? / Range#=== の振る舞いは変更していません。結果として、後方互換性を保ちつつ、バグの根源である Range#max の列挙失敗を回避できました。
まとめ
この PR は、ActiveSupport が提供する Range#include? と Range#=== のサブレンジ比較を Ruby 標準の Range#cover? に置き換えることで、非整数型サブレンジで発生していた TypeError を根本的に解消しました。コード量は大幅に削減され、可読性と保守性が向上すると同時に、Rails が対象とする Ruby バージョン上での正確な真偽値判定が保証されます。