Rails の `between?` が false になる原因と型を揃える解決策

9分で読めるテック
最終更新:

キャンペーンバナーの表示期間を between? で制御しようとして、日時がはっきり範囲内なのに false が返ってきて詰まりました。原因は Time.zone.nowDate の型が違うことでした。

詰まったコード

期間限定のバナーを出したくて、こんな条件分岐を書きました。

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.nowDate.new のクラスを確認すると、まったく別の型でした。

[1] pry(main)> Time.zone.now.class
=> ActiveSupport::TimeWithZone
[2] pry(main)> Date.new(2022, 12, 01).class
=> Date

RailsとRubyには複数の日時型があり、それぞれ比較の互換性が異なります。

主な生成方法タイムゾーン
TimeTime.nowシステム設定に依存
DateTimeDateTime.nowUTC(Railsではほぼ非推奨)
ActiveSupport::TimeWithZoneTime.zone.now, Time.zone.local(...)config.time_zone に従う
DateDate.new(...), Time.zone.today日付のみ(時刻なし)

between? は内部で <=> 演算子を使って比較しています。ActiveSupport::TimeWithZone<=> は相手が Date 型のときに nil を返すため、比較が成立せず false になります。

<=>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.todayconfig.time_zone のタイムゾーンで今日の日付を返します。Date.today はシステムのタイムゾーンを参照するため、アプリの設定と異なると日付がずれることがあります。Railsアプリ内では Time.zone.today の方が安全です。

よくある落とし穴: Date.todayTime.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.nowDateTime.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_atends_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::TimeWithZonebetween? は ActiveSupport の組み込み型として Rails で長く提供されているAPIです。<=> の挙動(Date と比較すると nil を返す仕様)を含めた詳細は、お使いの Rails バージョンに合わせて公式 API ドキュメントで確認してください。

参考にしたRailsのタイムゾーン解説はこちらです。

質問・リクエストを送る

記事についての質問や、取り上げてほしいテーマがあればお気軽にどうぞ。いただいた質問はブログ記事として回答し、Q&Aページで公開することがあります。

このサイトについて

井上 周(Amane Inoue)の個人ブログです。技術・読書・ドラマ・旅・大学生活のことを書いています。