Rails の `between?` が false になる原因と型を揃える解決策
キャンペーンバナーの表示期間を between? で制御しようとして、日時がはっきり範囲内なのに false が返ってきて詰まりました。原因は Time.zone.now と Date の型が違うことでした。
詰まったコード
期間限定のバナーを出したくて、こんな条件分岐を書きました。
Time.zone.now.between?(Date.new(2022, 12, 01), Date.new(2022, 12, 31))
2022年12月1日になってもこれが false を返し続けました。
[1] pry(main)> Time.zone.now.between?(Date.new(2022, 12, 01), Date.new(2022, 12, 31))
=> false
原因: 型が違う
Time.zone.now と Date.new のクラスを確認すると、まったく別の型でした。
[1] pry(main)> Time.zone.now.class
=> ActiveSupport::TimeWithZone
[2] pry(main)> Date.new(2022, 12, 01).class
=> Date
RailsとRubyには複数の日時型があり、それぞれ比較の互換性が異なります。
| 型 | 主な生成方法 | タイムゾーン |
|---|---|---|
Time | Time.now | システム設定に依存 |
DateTime | DateTime.now | UTC(Railsではほぼ非推奨) |
ActiveSupport::TimeWithZone | Time.zone.now, Time.zone.local(...) | config.time_zone に従う |
Date | Date.new(...), Time.zone.today | 日付のみ(時刻なし) |
between? は内部で <=> 演算子を使って比較しています。ActiveSupport::TimeWithZone の <=> は相手が Date 型のときに nil を返すため、比較が成立せず false になります。
rails/activesupport/lib/active_support/time_with_zone.rb at 8015c2c2cf5c8718449677570f372ceb01318a32 · rails/rails
Ruby on Rails. Contribute to rails/rails development by creating an account on GitHub.
<=> が nil を返すとどうなるかというと、Comparable モジュールの between? は min <= self && self <= max として評価されるため、<= が false ではなく nil(falsy)を返し、結果として false になります。エラーにならず false が返ってくるのでデバッグしにくいのがこの問題の難しいところです。
解決策: 型を揃える
比較する両者を同じ型に揃えると期待どおりに動きます。
Time.zone.local で揃える
Time.zone.now に合わせて、範囲の境界も ActiveSupport::TimeWithZone にします。
[1] pry(main)> Time.zone.now.between?(Time.zone.local(2022, 12, 01), Time.zone.local(2022, 12, 31))
=> true
[2] pry(main)> Time.zone.local(2022, 12, 01).class
=> ActiveSupport::TimeWithZone
時刻まで厳密に制御したい場合(例: 「12月1日 10:00 から」)はこちらが適しています。Time.zone.local(2022, 12, 01, 10, 00, 00) のように時・分・秒も指定できます。
Time.zone.today で揃える
日付レベルの比較で十分な場合は、Date 型に揃える方法もあります。
[1] pry(main)> Time.zone.today.between?(Date.new(2022, 12, 01), Date.new(2022, 12, 31))
=> true
[2] pry(main)> Time.zone.today.class
=> Date
Time.zone.today は config.time_zone のタイムゾーンで今日の日付を返します。Date.today はシステムのタイムゾーンを参照するため、アプリの設定と異なると日付がずれることがあります。Railsアプリ内では Time.zone.today の方が安全です。
よくある落とし穴: Date.today と Time.zone.today の違い
日本のサービスを JST(Asia/Tokyo)で運用している場合、深夜0時〜午前9時の間は UTC の日付と1日ずれます。Date.today は UTC をベースにしたシステム時刻を返すため、この時間帯にキャンペーン判定が誤動作することがあります。
# config/application.rb に設定がある場合
config.time_zone = 'Tokyo'
# この2つは深夜時間帯に別の値を返すことがある
Date.today # システム(UTC)ベースの今日
Time.zone.today # config.time_zone(Asia/Tokyo)ベースの今日
Railsアプリ内で日付を扱う場合は Time.zone.today を使うのが原則です。同様に Time.now ではなく Time.zone.now、DateTime.now ではなく Time.zone.now を使います。
モデルに期間判定メソッドを持たせる
実際のキャンペーン機能では、判定ロジックをモデルのメソッドやスコープに切り出すのが保守しやすいパターンです。
class Campaign < ApplicationRecord
# starts_at, ends_at カラムは datetime 型(DBに保存する際はタイムゾーン対応に注意)
def active?
Time.zone.now.between?(starts_at, ends_at)
end
scope :active, -> { where('starts_at <= ? AND ends_at >= ?', Time.zone.now, Time.zone.now) }
end
DB に保存する日時は通常 UTC として保存され、ActiveRecord が取り出す際に config.time_zone のタイムゾーンに変換します。そのためカラムから取得した starts_at・ends_at はすでに ActiveSupport::TimeWithZone 型になっており、Time.zone.now と型が一致します。
between? の代替: Range#cover?
Ruby には Range#cover? という別の方法もあります。
(Time.zone.local(2022, 12, 01)..Time.zone.local(2022, 12, 31)).cover?(Time.zone.now)
between? と cover? は同じ <=> ベースですが、cover? は Range に対して呼ぶため、Rangeの端点と比較対象の型が揃っていれば同様に動きます。読み方の好みで使い分けてよいです。
バージョン・公式リファレンス
ActiveSupport::TimeWithZone と between? は ActiveSupport の組み込み型として Rails で長く提供されているAPIです。<=> の挙動(Date と比較すると nil を返す仕様)を含めた詳細は、お使いの Rails バージョンに合わせて公式 API ドキュメントで確認してください。
ActiveSupport::TimeWithZone
Active Support Time With Zone¶ ↑ A Time-like class that can represent a time in any time zone.
参考にしたRailsのタイムゾーン解説はこちらです。
qiita.com/jnchito/diary/cae89ee43c30f5d6fa2c
qiita.com/YumaInaura/item/f0db20bc125cada554a1
記事の更新をメールで受け取る
質問・リクエストを送る
記事についての質問や、取り上げてほしいテーマがあればお気軽にどうぞ。いただいた質問はブログ記事として回答し、Q&Aページで公開することがあります。