⏱️

Rails で日時比較したときに思った結果が返ってこなかったときの話

2022/12/04に公開

サマリ

user.deactivated_on < 3.days.ago

が true になる user が、

User.where('deactivated_on < ?', 3.days.ago)

では返ってこないことに気づき、調べてみたことをまとめた記事です。

ポイントは下記です。

  • 異なるクラスの比較に注意する
  • 特に Rails 上での変換とデータベース上での変換は挙動が異なることがあるので気をつける

背景

Web サービスを開発・運用していると、不要になったデータやファイルを定期的に削除するバッチ、所謂お掃除バッチを作ることはよくあると思います。

弊社 IVRy でも、例えば解約されたクライアントについて、解約から一定期間経ってから関連するデータ等を削除するような処理があり、定期的にバッチ処理を実行しています。
これらの処理は主に Ruby on Rails を利用して書いており、Sidekiqsidekiq-scheduler を用いて定期実行を行っています。

参考: 弊社の Sidekiq 利活用に関連する記事です。
https://zenn.dev/kose_atsuya/articles/637f6f0f0c7db7

ある日 解約後お掃除処理の修正ついでに RSpec を書いていたところ、以下のような謎が発生しました。

※ なお、今回登場する日付時刻はすべて UTC であり、タイムゾーンに関する話題はありません。
また、Ruby 3.1.0, Rails 7.0.4 時点での内容になっています。

ApplicationRecord を継承した Active Record のモデルである User があったとします。

user.deactivated_on < 3.days.ago

が true になる user が、

User.where('deactivated_on < ?', 3.days.ago)

では返ってこないという事象に出会いました。

パッと見では、前者の条件式を満たした user であれば、後者のクエリで引っ張ってくることができそうにも見えます。

この事象を調査するため、それぞれの式について見ていきます。


user.deactivated_on < 3.days.ago

まずは user.deactivated_on < 3.days.ago について見てみます。

左辺、右辺のクラス

まず、この式の比較演算子 < はどのクラスに定義されているかを考えるため、左辺と右辺がそれぞれどのようなクラスなのか考えます。

Rails では、慣習的に Date クラス等には _on という suffix を、 Time クラス等には _at という suffix をつけることが多いです。
左辺の User#deactivated_on は、IVRy でもこの慣習に従っているため Date クラスになっています。

一方、右辺の 3.days.ago は一見なんのクラスかわかりづらいです。
Numeric に対する days の呼び出しは ActiveSupport::Duration を返します。[1]
Duration#ago の内部実装を追っていくと少し複雑ですが、メソッドのコメントからは TimeDate を返しそうだということがわかります。[2]
今回は ActiveSupport::TimeWithZone クラスでした。

user.deactivated_on.class
=> Date

1.days.ago.class
=> ActiveSupport::TimeWithZone

ここまでで、 user.deactivated_on < 3.days.agoDate クラスと ActiveSupport::TimeWithZone クラスの比較を行おうとしていることがわかりました。

なお、 Date クラスは Ruby の Date クラスではなく Rails(ActiveSupport) でそれを拡張したクラスであることに注意します。

比較演算子の挙動

Ruby において < を始めとする比較演算を行うことができるクラスでは module Comparable を Mix-in している必要があり、その際は <=> 演算子を定義することが必要とされています。
DateActiveSupport::TimeWithZone も共に Comparable です。

Date<=> 演算子の実装はこのようになっています。[3]

rails/activesupport/lib/active_support/core_ext/date/calculations.rb
# Allow Date to be compared with Time by converting to DateTime and relying on the <=> from there.
def compare_with_coercion(other)
  if other.is_a?(Time)
    to_datetime <=> other
  else
    compare_without_coercion(other)
  end
end

Date クラスと比較する otherTime クラスかそのサブクラスのインスタンスであれば、to_datetime して比較していることがわかります。

次に、ActiveSupport::TimeWithZone<=> 演算子の実装を見てみます。[4]

rails/activesupport/lib/active_support/time_with_zone.rb
# Use the time in UTC for comparisons.
def <=>(other)
  utc <=> other
end

utc を用いて other との比較を行っていることがわかります。utcTime クラスになります。

続いて ActiveSupport::DateTime<=> 演算子の実装はこのようになっています。[5]

rails/activesupport/lib/active_support/core_ext/date_time/calculations.rb
# Layers additional behavior on DateTime#<=> so that Time and
# ActiveSupport::TimeWithZone instances can be compared with a DateTime.
def <=>(other)
  if other.respond_to? :to_datetime
    super other.to_datetime rescue nil
  else
    super
  end
end

つまり、 user.deactivated_on < 3.days.ago は最終的に DateTime クラス同士の比較を行う式であることがわかりました。

左辺の user.deactivated_onDate クラスであり時刻を持ちません。 to_datetime される際は 00:00:00 の時刻が補完される形でコンバートされます。

(そういえば Ruby における DateTime は Ruby 3.0 以降 deprecated[6] ですが、ActiveSupport::DateTime はどうなっていくんでしょうか)

つまり何の比較をしているのか

user.deactivated_on < 3.days.ago は Rails 上で「ユーザの解約日の0時0分0秒と、現在時刻のちょうど3日前の時刻」を < で比較しています。


User.where('deactivated_on < ?', 3.days.ago)

次に、 User.where('deactivated_on < ?', 3.days.ago) について見ていきます。

発行されるクエリ

User#whereActiveRecord::QueryMethods#where を呼び出しています。
つまり、SELECT クエリを生成しデータベースに投げる処理を行います。
今回 IVRy の環境では PostgreSQL を利用していたため、PostgreSQL 上の users テーブルに対して SELECT するクエリが発行されます。

users テーブルの定義を確認しておきます。deactivated_ondate 型です。

rails db
\d users

                                                   Table "public.users"
           Column            |              Type              | Collation | Nullable |
       Default
-----------------------------+--------------------------------+-----------+-----------
 id                          | bigint                         |           | not null |
(略)
 deactivated_on              | date                           |           |          |

User.where('deactivated_on < ?', 3.days.ago) で上記テーブルに対してどのようなクエリが発行されるかは explain するとわかります。

User.where('deactivated_on < ?', 1.days.ago).explain
  User Load (XX.Xms)  SELECT "users".* FROM "users" WHERE (deactivated_on < '2022-12-03 01:23:45.678901')
=>
EXPLAIN for: SELECT "users".* FROM "users" WHERE (deactivated_on < '2022-12-03 01:23:45.678901')
                       QUERY PLAN
---------------------------------------------------------
 Seq Scan on users  (cost=0.00..XX.XX rows=XX width=XXX)
   Filter: (deactivated_on < '2022-12-03'::date)
(X rows)

前述のとおり 3.days.agoActiveSupport::TimeWithZone クラスですが、PostgreSQL の中では date 型に変換して処理されていることがわかります。
text 型の date 型への変換処理は、日付/時刻型のテンプレートパターン[7] における date 相当の部分について評価する挙動になっていると思われます。
(明確なドキュメントを見つけることができませんでした…)

select '2022-12-03 01:23:45.678901'::date;
    date
------------
 2022-12-03
(1 row)

つまり何の比較をしているのか

User.where('deactivated_on < ?', 3.days.ago) は PostgreSQL 上で「ユーザの解約日と今日から3日前の日」を < で比較しています。


謎解き

これで謎を解くための準備ができました。

  • user.deactivated_on < 3.days.ago は Rails 上で「ユーザの解約日の0時0分0秒と、現在時刻のちょうど3日前の時刻」を < で比較しています。
  • User.where('deactivated_on < ?', 3.days.ago) は PostgreSQL 上で「ユーザの解約日と今日から3日前の日」を < で比較しています。

ユーザ A の解約日が 2022/12/01 で現在時刻が 2022/12/04 10:00:00 であるとき、前者と後者の式がそれぞれどのようになるかみていきます。

前者の式は

  • ユーザ A の解約日 : 2022/12/01
  • ユーザ A の解約日の0時0分0秒 : 2022/12/01 00:00:00 (左辺)
  • 現在時刻: 2022/12/04 10:00:00
  • 現在時刻ちょうど3日前の時刻: 2022/12/01 10:00:00 (右辺)

となるため user.deactivated_on < 3.days.ago は true になります。

後者の式は

  • ユーザ A の解約日 : 2022/12/01 (SQL 上の左辺)
  • 今日: 2022/12/04
  • 今日から3日前の日: 2022/12/01 (SQL 上の右辺)

となるため User.where('deactivated_on < ?', 3.days.ago) でユーザ A が SELECT されることはありません。

Rails においては DateTime で比較される一方、PostgreSQL においては date で比較されるため、境界となる日付においては比較結果がずれる、ということがわかりました。

余談として IVRy での対応は、このケースは全体的に時刻を含まない日付での比較にして条件式には等号を含む形で整理しました。

まとめ

  • 異なるクラスの比較に注意する
  • 特に Rails 上での変換とデータベース上での変換は挙動が異なることがあるので気をつける

おまけ

IVRy ではちゃんと Rails を書きたい方を募集しています。

https://ivry-jp.notion.site/IVRy-e1d47e4a79ba4f9d8a891fc938e02271

https://ivry.jp/

脚注
  1. https://github.com/rails/rails/blob/v7.0.4/activesupport/lib/active_support/core_ext/numeric/time.rb#L37 ↩︎

  2. https://github.com/rails/rails/blob/v7.0.4/activesupport/lib/active_support/duration.rb#L438 ↩︎

  3. https://github.com/rails/rails/blob/v7.0.4/activesupport/lib/active_support/core_ext/date/calculations.rb#L137 ↩︎

  4. https://github.com/rails/rails/blob/v7.0.4/activesupport/lib/active_support/time_with_zone.rb#L261 ↩︎

  5. https://github.com/rails/rails/blob/v7.0.4/activesupport/lib/active_support/core_ext/date_time/calculations.rb#L204 ↩︎

  6. https://docs.ruby-lang.org/ja/latest/class/DateTime.html ↩︎

  7. https://www.postgresql.org/docs/current/functions-formatting.html ↩︎

IVRyテックブログ

Discussion