selectで指定した日付カラムを別名に設定したとき、タイムゾーンがUTCで返却される問題
はじめに
Railsでは、config/application.rb
にconfig.time_zone
を設定することで、DBから取得した日時データをアプリケーションのタイムゾーンに自動変換する機能があります。
しかしselectメソッドで別名を使った場合、期待通りのタイムゾーン変換が行われないケースがあります。
Post.select("created_at").first.created_at # => 2025-02-22 14:00:00 JST +09:00
Post.select("created_at as date").first.date # => 2025-02-22 05:00:00 UTC +00:00
通常のcreated_atはJSTで返却されますが、created_at as date
ではUTCのままとなり、日本時刻よりも-9時間の差が生まれてしまいます。
この事象が発生すると、例えば「登録日」などの日付データを画面に表示させる場合に、前日の日付が表示されているといった日付の差異が生じてしまいます。
今回はこの問題の原因と、解決策となり得るアプローチをいくつか紹介します。
前提
-
config/application.rb
でconfig.time_zone = "Tokyo"
を設定 -
Post
モデル(created_atはDATETIME型) - RDBMSのタイムゾーンがUTCであること
Railsがタイムゾーン変換を行う処理のフロー
原因を特定するために、RailsがDBから取得した値をモデルのインスタンスに変換する過程を確認しました。
-
selectによるクエリの構築
Post.select
によりActiveRecord::Relationが生成され、SQLが構築されます。- created_at:
SELECT created_at FROM posts
- created_at as date:
SELECT created_at AS date FROM posts
- created_at:
-
DBからの値取得
select後のメソッドチェイン(.first
や.last
など)によってActiveRecord::Relation#exec_queriesが実行され、ActiveRecord::Resultが返却されます。DBは通常UTCで値を保存するため、この時点では"2025-02-22 05:00:00 UTC"が取得されます。 -
値のキャスト
ActiveRecord::Resultから値が取得され、AttributeSet::Builder#build_from_databaseがLazyAttributeSetを生成します。属性値へのアクセス(例: post.created_at)で、LazyAttributeSet#fetch_valueが呼び出されます。 -
型情報の決定と変換
fetch_value内で型が決定され、値がキャストされます。type = additional_types.fetch(name, types[name]) @casted_values[name] = type.deserialize(value)
-
created_atの場合
- types["created_at"]が呼び出され、ActiveRecord::ConnectionAdapters::ColumnがDATETIME型を提供し、ActiveRecord::Type::DateTime#castで文字列からTimeオブジェクトに変換します。(※Type::DateTimeはActiveModel::Type::Valueを継承)
- その後TimeZoneConverter#deserialize内でTime.zoneを適用します。
- 結果的に、日付データの出力値は"2025-02-22 14:00:00 JST +09:00"となりJSTタイムゾーンに変換された状態で返却されます。この値はActiveSupport::TimeWithZoneオブジェクトとして返却されます。
-
dateの場合
- additional_types["date"]が呼び出され、ActiveRecord::Result#column_typesがDATETIME型を推測し、ActiveRecord::Type::DateTimeを適用します。
- type.deserializeでは、ActiveRecord::Type::DateTime#castによるRubyオブジェクトへの変換のみ行われ、TimeZoneConverterが適用されないためここで終了します。
- 結果的に、日付データの出力値は"2025-02-22 05:00:00 UTC +00:00"となり、UTCタイムゾーンのままで返却されます。タイムゾーン変換は行われていないので、出力値はTimeオブジェクトとして返却されます。
-
事象が発生した原因
この挙動の原因は、TimeZoneConverterの適用条件にあります。
Rubyオブジェクトに変換される過程で、ActiveRecord::ConnectionAdapters::Column
クラスによるDBスキーマ情報の参照が行われるのですが、渡された属性が、DBのスキーマ情報にあれば、そこから型情報を決定しますが、別名が付いているとスキーマ情報と特定することができずColumn
クラスが関与しない、という流れになります。
TimeZoneConverter
の適用はモデル属性に限定されるため、スキーマ情報が紐づかない属性については適用されません。
つまり別名カラムがモデル属性として認識されないため、タイムゾーン変換がスキップされ、UTCのTimeオブジェクトが返却されます。これが本事象の原因です。
解決になりえそうなアプローチ
-
DB側での変換(※今回はMySQLで紹介します)
SQLでタイムゾーンを指定するPost.select("CONVERT_TZ(created_at, '+00:00', '+09:00') as date).first # => [#<Post:0x000000012bcb8c08 date: "2025-02-22 14:00:00.000000000 +0000", id: nil>]
DBレベルで一貫性を保つことができますが、その一方でDBに依存してしまうという点に注意が必要です。
-
アプリケーション側で後処理を行う。
ActiveSupport::TimeWithZoneで提供されているin_time_zoneメソッドを使います。このメソッドはRailsのタイムゾーンに準拠して変換を行います。Post.select("created_at as date").first.date.in_time_zone("Asia/Tokyo") # => 2025-02-22 14:00:00.000000000 JST +09:00
このアプローチだと1と比べてDBに依存せずに柔軟に対応できます。デメリットを上げるとすれば、別名カラムをつけたクエリを使用しているモデルを、複数のControllerで呼び出している場合、対応する各viewテンプレートごとに対応する必要があるので、そこが手間になるのかなと思います。
-
ActiveModel::Attributes#attributeを利用する
ActiveModel::Attributesモジュールは、モデルに対して仮想的なカラムを用意して、データ型の定義やデフォルト値の設定などを行うことができる機能を持っています。class Post < ApplicationRecord attribute :date, :datetime end Post.select("created_at as date").first # => #<Post:0x000000011e802c20 date: "2025-02-22 14:00:00.000000000 +0900", id: nil>
上記のように別名をつけていたとしても、dateカラムがJSTに変換された状態で出力できました。
しかしこのアプローチにも注意が必要で、attributeメソッドが使えるのは、ActiveModel::Attributesをincludeしているクラス、またはActiveRecordのモデル(ApplicationRecordを継承しているクラス)に限ります。
弊社での対応
弊社では結果的に、DB側での変換(CONVERT_TZ)を選択しました。
理由は下記のとおりです。
- 該当のモデルがActiveRecordを継承しておらず、複雑なクエリを必要とするモデルであったため、ActiveModel::Attributesの利用が難しかった。
- 該当のモデルのクエリを利用するviewテンプレートがが増えた場合に、毎回
in_time_zone
で対応するのは手間がかかり、対応漏れのリスクもあるため煩雑だと判断した。
まとめ
Railsのタイムゾーン変換はモデル属性に依存しているため、別名カラムでは日付データがUTCのまま返却されます。
クエリ設計時にこの特性を考慮して、必要に応じて上記のアプローチを取ることが重要かなと思います。
Discussion