[Rails5]ActiveRecord、SQLでの日時の取り扱いについて

Rails

すでに運用中のWebサービスで日時を厳密に取り扱う案件を対応することになり、色々ハマったのでその際に確認したことなどをまとめる。

わかったこと

今回対応したWebアプリは、HerokuはUTC, はRailsはJSTで動いていて、PostgreSQLはUTCで動いていること。

直接、SQLを実行するようなソースを書く場合は、注意しなければならない。

確認方法

Heroku

Heroku CLIでbashを実行し、dateコマンドで確かめる。
実際に調べたところUTCだった。

$ heroku run bash -a [アプリ名]
$ date
Sat Jan 18 06:03:42 UTC 2020

Rails

Time.currentを確認するとJSTであることがわかる。Time.nowだとダメなので注意

$ rails c

$ rails c
pry(main)> Time.current
=> Sat, 18 Jan 2020 15:28:09 JST +09:00

設定はapplication.rbでされていた。

class Application < Rails::Application
  config.time_zone = 'Tokyo'
end

ちなみに、Time.nowだとUTC。これはTime.nowがOSのタイムゾーン(今回だとUTC)で動いているため。

pry(main)> Time.now
=> 2020-01-18 06:28:43 +0000

ActiveRecordのTImeZoneの確認。これはDBに保存されるときのTImeZone。PostgreSQLと一致している必要がある。

pry(main)> ActiveRecord::Base.default_timezone
=> :utc

PostgreSQL

RailsからではなくPostgreSQLに直接クエリを実行する。psqlのCLIだと下記のような感じの出力になり、UTCだとわかる。RedashなどのSQLを実行できるツールでも同様の結果が得られるので、なんでもOK。

# show timezone;
 TimeZone 
----------
 UTC
(1 row)

今回やっかいだったこと

画面から入力させる日時に対して、範囲指定でデータを取得すること。

例えば、
カラムAに 2020/01/04 12:00:00 と 2020/01/05 03:00:00 と画面から入力されたデータが2レコードあり、その中から 2020/01/05 に含まれるデータを取得したい
という場合。

日時をRailsから与えるパターン

まず最初に下記のようなコードを思いついた。これがハマったきっかけ。笑

where( " A >= ? and  A < ? ", Time.new(2020,01,05), Time.new(2020,01,06) )

一見取得できるように見えるが、できない。

理由は、画面から入力された 2020/01/05 03:00:00 というデータは、DBには 2020/01/04 18:00:00 として登録されていて、上記の書き方だとTimeがUTCで作成されて範囲外になるため。
冒頭で書いた通り、RailsはJST、PostgreSQLはUTCなので、画面で入力した時間-9時間でDBには登録される。

具体的なSQLを下記のようになってしまう。

WHERE (A >= '2020-01-05 00:00:00' and A < '2020-01-06 00:00:00')

下記の対応で取得ができた

where( " A >= ? and  A < ? ", Time.zone.local(2020,01,05), Time.zone.local(2020,01,06))

SQLは下記のようになる。

WHERE (A >= '2020-01-04 15:00:00' and A < '2020-01-05 15:00:00')

Time.currentを使った場合どうなるか(日本時間で2020年1月18日 16:00ごろ)

Ruby(Rails)

where("  A >= ? and A < ?", Time.new(2020,01,05), Time.current)

SQL

WHERE (  A >= '2020-01-05 00:00:00' and A < ''2020-01-18 07:00:02.802684')"

RailsでJST扱いされている値は、SQLとして実行されるタイミング(厳密にはバインディングされるタイミング?)で、UTCに変換されるため、実装者が意識しなくても上手く動くようになっている。

日時をSQLで書くパターン

時刻 at time zone ‘タイムゾーン’ で、変換してくれるSQLを利用する

where( "A >= cast('2020/01/05' as timestamp) AT TIME ZONE 'JST' and A < cast('2020/01/06' as timestamp) AT TIME ZONE 'JST'")

SQLは当たり前だがほぼ一緒

WHERE (A >= cast('2020/01/05' as timestamp) AT TIME ZONE 'JST' and A < cast('2020/01/06' as timestamp) AT TIME ZONE 'JST')

他にも下記のように書けばいける。

A  >= cast('2020/01/05' as timestamp) - interval '9 hour'
A + interval '9 hour' >= cast('2020/01/05' as timestamp)

SQLのnow()を使う場合も注意。now()はUTC(PostgreSQLのタイムゾーン)なので、日本時間で考えるのであれば now() + interval ‘9 hour’ としてあげる必要がある。
単純に比較であれば、PostgreSQLはUTCで整合性を保てているので、必ずしも+(-)9時間して考える必要はない。

対応してみて感じたこと

サービスをリリースする時に、サーバーのタイムゾーンの設定などをしてこなかったが、重要だなぁと実感した。色々な箇所に設定があり、それぞれデフォルト値があり変更が可能なので、しっかり把握して設定を行う必要がある。Rails と Herokuだとすぐに公開までできてしまうので、今後、この経験を活かしたい。
運用が始まってからタイムゾーンを変えることはリスクだらけなので、よっぽどのことがない限りやらないのが吉だと思う。なので、アプリケーション側で吸収していくしかない。
今回の内容は、そんな状況でどのように対応していくかというのを身を以て実施できたのでよかった。

参考

Railsタイムゾーンまとめ - Qiita
以下の4個の設定を考慮する必要がある。 rubyプロセスのタイムゾーン config.time_zone config.active_record.default_timezone DBサーバ(postgres等)のタイムゾーン ...

コメント