Rails で日時比較したときに思った結果が返ってこなかったときの話
サマリ
user.deactivated_on < 3.days.ago
が true になる user が、
User.where('deactivated_on < ?', 3.days.ago)
では返ってこないことに気づき、調べてみたことをまとめた記事です。
ポイントは下記です。
- 異なるクラスの比較に注意する
- 特に Rails 上での変換とデータベース上での変換は挙動が異なることがあるので気をつける
背景
Web サービスを開発・運用していると、不要になったデータやファイルを定期的に削除するバッチ、所謂お掃除バッチを作ることはよくあると思います。
弊社 IVRy でも、例えば解約されたクライアントについて、解約から一定期間経ってから関連するデータ等を削除するような処理があり、定期的にバッチ処理を実行しています。
これらの処理は主に Ruby on Rails を利用して書いており、Sidekiq や sidekiq-scheduler を用いて定期実行を行っています。
参考: 弊社の Sidekiq 利活用に関連する記事です。
ある日 解約後お掃除処理の修正ついでに 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
の内部実装を追っていくと少し複雑ですが、メソッドのコメントからは Time
や Date
を返しそうだということがわかります。[2]
今回は ActiveSupport::TimeWithZone
クラスでした。
user.deactivated_on.class
=> Date
1.days.ago.class
=> ActiveSupport::TimeWithZone
ここまでで、 user.deactivated_on < 3.days.ago
は Date
クラスと ActiveSupport::TimeWithZone
クラスの比較を行おうとしていることがわかりました。
なお、 Date
クラスは Ruby の Date
クラスではなく Rails(ActiveSupport) でそれを拡張したクラスであることに注意します。
比較演算子の挙動
Ruby において <
を始めとする比較演算を行うことができるクラスでは module Comparable
を Mix-in している必要があり、その際は <=>
演算子を定義することが必要とされています。
Date
も ActiveSupport::TimeWithZone
も共に Comparable
です。
Date
の <=>
演算子の実装はこのようになっています。[3]
# 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
クラスと比較する other
が Time
クラスかそのサブクラスのインスタンスであれば、to_datetime
して比較していることがわかります。
次に、ActiveSupport::TimeWithZone
の <=>
演算子の実装を見てみます。[4]
# Use the time in UTC for comparisons.
def <=>(other)
utc <=> other
end
utc
を用いて other
との比較を行っていることがわかります。utc
は Time
クラスになります。
続いて ActiveSupport::DateTime
の <=>
演算子の実装はこのようになっています。[5]
# 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_on
は Date
クラスであり時刻を持ちません。 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#where
は ActiveRecord::QueryMethods#where
を呼び出しています。
つまり、SELECT クエリを生成しデータベースに投げる処理を行います。
今回 IVRy の環境では PostgreSQL を利用していたため、PostgreSQL 上の users
テーブルに対して SELECT するクエリが発行されます。
users テーブルの定義を確認しておきます。deactivated_on
は date
型です。
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.ago
は ActiveSupport::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 を書きたい方を募集しています。
Discussion