上記の動作を知らずに実装していたらハマったのでメモします。
(ジャストなドキュメントを見つけられなかったためActive Recordのソースやその他記事等を確認しましたが、間違っている部分がありましたらご指摘ください)
前提
- Rails 5.2.3
Task
というクラスがあり、 tests
という属性を持っているとします。
tests
はRails上では ActiveSupport::TimeWithZone
として扱われます。
アプリケーションのデフォルトのタイムゾーンは Tokyo
です。
[2] pry(main)> Task.columns_hash["tests"].type
=> :datetime
[5] pry(main)> Time.zone.name
=> "Tokyo"
疑問
以下のアクション( TasksController#index
)が実行された場合に # ??
の部分はどのような値(タイムゾーン)となるか?
class TasksController < ApplicationController
def index
task = Task.new(tests: "2019/01/01")
Time.use_zone("UTC"){ task.tests }
puts task.tests # ??
end
end
正解
正解は Tue, 01 Jan 2019 00:00:00 UTC +00:00
Tokyo
のタイムゾーンになると思っていたのでハマった。
Active Recordの型変換
事前知識
Active Recordでは属性として入力された値を型変換することがあります。例えば属性にInteger型の Task.id
があった場合で、idにInteger型以外の値が入力された場合でも、以下のように型変換して属性値として扱います。
[1] pry(main)> task = Task.new
=> #<Task:0x00007f3a201cad70 id: nil, created_at: nil, updated_at: nil, tests: nil>
[2] pry(main)> task.id = "1"
=> "1"
[3] pry(main)> task.id
=> 1
[4] pry(main)> task.id = "2019/01/01"
=> "2019/01/01"
[5] pry(main)> task.id
=> 2019
型変換が実行されるタイミングは?
さて、上記の型変換が実行されるタイミングですが、これまで私は何となく、前述のケースでいえば以下(1)にようにモデルがインスタンス化されたときだと思っていました。しかし、今回調べたところ、正しくは、初めてその属性のアクセサメソッドが実行されたとき、のようです。
すなわち、以下(2)のタイミングのときにtask.tests
は TimeWithZone 型に変換されるようです。
(1) task = Task.new(tests: "2019/01/01")
(2) Time.use_zone("UTC"){ task.tests }
では、 (1) ではどこまで処理が実行されるのかというと、アクセスメソッドを定義するまで、実行されるようです(他にも色々しているのでしょうが)。
以下、(1)(2)それそれが実行される流れを簡単にコードで追ってみようと思います。
ソースコードを読んでみる
Model.new すると起きること
ActiveRecord
を継承したクラスが new
される場合に実行されるコンストラクタは ActiveRecord::Core
モジュール(includeされるクラスは ActiveRecord::Base
クラス)のコンストラクタのようです。コードを抜粋して確認してみます。
# New objects can be instantiated as either empty (pass no construction parameter) or pre-set with
# attributes but not yet saved (pass a hash with key names matching the associated table column names).
# In both instances, valid attribute keys are determined by the column names of the associated table --
# hence you can't have attributes that aren't part of the table columns.
#
# ==== Example:
# # Instantiates a single new object
# User.new(first_name: 'Jamie')
def initialize(attributes = nil)
self.class.define_attribute_methods
@attributes = self.class._default_attributes.deep_dup
init_internals
initialize_internals_callback
assign_attributes(attributes) if attributes
yield self if block_given?
_run_initialize_callbacks
end
上記の
self.class.define_attribute_methods
部分で属性のアクセサメソッドを定義していると想像できます。
さらにこの奥に進んでみると、ActiveRecord::AttributeMethods
モジュールの中で define_attribute_methods
が定義されていました。
# Generates all the attribute related methods for columns in the database
# accessors, mutators and query methods.
def define_attribute_methods # :nodoc:
return false if @attribute_methods_generated
# Use a mutex; we don't want two threads simultaneously trying to define
# attribute methods.
generated_attribute_methods.synchronize do
return false if @attribute_methods_generated
superclass.define_attribute_methods unless self == base_class
super(attribute_names)
@attribute_methods_generated = true
end
end
この中の
superclass.define_attribute_methods
によって今度はActiveModelモジュールの方の同名のメソッドが呼ばれます。
def define_attribute_methods(*attr_names)
attr_names.flatten.each { |attr_name| define_attribute_method(attr_name) }
end
この中での define_attribute_method
メソッドがアクセサメソッドを定義していそうです。ここを追ってみると同じファイル内で以下のメソッドが定義されています。
def define_attribute_method(attr_name)
attribute_method_matchers.each do |matcher|
method_name = matcher.method_name(attr_name)
unless instance_method_already_implemented?(method_name)
generate_method = "define_method_#{matcher.method_missing_target}"
if respond_to?(generate_method, true)
send(generate_method, attr_name.to_s)
else
define_proxy_call true, generated_attribute_methods, method_name, matcher.method_missing_target, attr_name.to_s
end
end
end
attribute_method_matchers_cache.clear
end
この中の attribute_method_matchers
は ActiveModel::AttributeMethods::ClassMethods::AttributeMethodMatcher
の配列となっており、これらは <Atrribute名>_in_database
等すべての属性に共通して使えるようにするメソッドのプレフィックス等が格納されていました。
今回は、それらの深追いはせず、属性名自体のアクセサメソッドの定義の動作を追います。
それについては、上記コードの
send(generate_method, attr_name.to_s)
が呼ばれるようです。このとき引数は
generate_method # => define_method_attribute
attr_name.to_s # => tests
の通りでした。
define_method_attribute
メソッドが実行されるので、追ってみると以下で定義されていました。
def define_method_attribute(name)
safe_name = name.unpack("h*".freeze).first
temp_method = "__temp__#{safe_name}"
ActiveRecord::AttributeMethods::AttrNames.set_name_cache safe_name, name
sync_with_transaction_state = "sync_with_transaction_state" if name == primary_key
generated_attribute_methods.module_eval <<-STR, __FILE__, __LINE__ + 1
def #{temp_method}
#{sync_with_transaction_state}
name = ::ActiveRecord::AttributeMethods::AttrNames::ATTR_#{safe_name}
_read_attribute(name) { |n| missing_attribute(n, caller) }
end
STR
generated_attribute_methods.module_eval do
alias_method name, temp_method
undef_method temp_method
end
end
上記により、 module_evalによりメソッドが動的に定義されている様子がわかります。
型変換が実行されるタイミング
さて、上記によりモデルが new されてアクセサメソッドが定義されるまでの流れは分かりました。話を戻して型変換が実際に実行される処理を確認してみたいと思います。すなわち、今回の例では(2)を実行すると起きること、です。
(1) task = Task.new(tests: "2019/01/01")
(2) Time.use_zone("UTC"){ task.tests }
(2)の task.tests
(=Model.newしたときに動的に定義されたアクセサメソッド)を実行すると、上記で確認したように以下メソッドの内容が実行されることになります。
def #{temp_method}
#{sync_with_transaction_state}
name = ::ActiveRecord::AttributeMethods::AttrNames::ATTR_#{safe_name}
_read_attribute(name) { |n| missing_attribute(n, caller) }
end
_read_attribute(name)
が属性値を読み込んで値を返答している箇所と考えられるのでこの部分を追ってみると、同ファイルに定義されています。
def _read_attribute(attr_name) # :nodoc:
@attributes.fetch_value(attr_name.to_s) { |n| yield n if block_given? }
end
@attributes
は ActiveModel::AttributeSet
クラスです。
fetch_value
メソッドを追ってみると、以下で定義されていました。
def fetch_value(name)
self[name].value { |n| yield n if block_given? }
end
self[name].value
は今回はWebブラウザからリクエストを送信しており ActiveModel::Attribute::FromUser
でした。
ここで実行される value
メソッドを追うと以下の定義されていました。
def value
# `defined?` is cheaper than `||=` when we get back falsy values
@value = type_cast(value_before_type_cast) unless defined?(@value)
@value
end
さて、上記まで来るとかなり具体的なな処理が見えてきたように思います。
すなわち、
-
type_cast
メソッドで元々の入力値(value_before_type_cast
、今回では"2019/01/01"
)をキャストしている - 一度キャストして返答した値は再びキャストしない(
unless defined?(@value)
)
と考えてよいでしょう。せっかくなので type_cast
ももう少し追ってみようと思います。同ファイル内に以下の定義があります。
class FromUser < Attribute # :nodoc:
def type_cast(value)
type.cast(value)
end
type.class
は ActiveRecord::AttributeMethods::TimeZoneConversion::TimeZoneConverter
です。そのクラスの cast
メソッドを確認してみると以下の定義が見つかります。
def cast(value)
return if value.nil?
if value.is_a?(Hash)
set_time_zone_without_conversion(super)
elsif value.respond_to?(:in_time_zone)
begin
super(user_input_in_time_zone(value)) || super
rescue ArgumentError
nil
end
else
map_avoiding_infinite_recursion(super) { |v| cast(v) }
end
end
今回 value
は "2019/01/01"
であり、Railsでは文字列が日時への変換対象のフォーマットを満たしていれば in_time_zoneメソッドで変換できるので、
begin
super(user_input_in_time_zone(value)) || super
rescue ArgumentError
nil
end
の処理が実行されます。user_input_in_time_zone
メソッドを追うと、
def user_input_in_time_zone(value)
value.in_time_zone
end
が、実行されていました。なるほど型変換の実体は in_time_zoneメソッドが実行されていわけですね。すなわちその時の Time.zone
のタイムゾーンにしたがって値が変換されると理解できます。
まとめ
さて、最初に戻って、以下のアクション( TasksController#index
)が実行された場合に # ??
の部分が Tue, 01 Jan 2019 00:00:00 UTC +00:00
となる理由は、
- (1) のタイミングでは各属性のアクセサメソッド等が定義されたたけで、実際にアクセサメソッド経由で取得される値の生成(型変換)はされていない
- (2) のタイミングではじめてアクセサメソッドが実行され、元々入力された値が(必要な場合に)型変換されて返答される
- 型変換は String#in_time_zone で実行されており、このときのタイムゾーンはUTCのためUTCのタイムゾーンで値が生成される
- 一度型変換された値は二度目以降にアクセサメソッドが実行された後は再度型変換されないため、(3)のタイミングではタイムゾーンがTokyoであっても(2)で生成されたUTCの値が読み出される
class TasksController < ApplicationController
def index
task = Task.new(tests: "2019/01/01") # (1)
Time.use_zone("UTC"){ task.tests } # (2)
puts task.tests # ?? (3)
end
end
感想
長かった…。
参考
『メタプログラミングRuby』にも少しActive Recordの記載があった(ただし少しVersionが古いので今と違う部分があるかも)。
オライリージャパン
売り上げランキング: 85,250