本記事の環境
結論
9sako6 です。突然ですが、
AS 句で作ったカラムに DB の型情報はありません。
次の例をご覧ください。スキーマに日時型で定義されている created_at
カラムは TimeWithZone
オブジェクトが返るのに対し、AS 句で作った latest_created_at
カラムは文字列が返されます。
> Foo.select('created_at').first.created_at
Foo Load (0.2ms) SELECT "foos"."created_at" FROM "foos" ORDER BY "foos"."id" ASC LIMIT ? [["LIMIT", 1]]
=> Mon, 08 Feb 2021 16:56:10.531831000 JST +09:00
> Foo.select('MAX(created_at) AS latest_created_at').first.latest_created_at
Foo Load (0.3ms) SELECT MAX(created_at) AS latest_created_at FROM "foos" ORDER BY "foos"."id" ASC LIMIT ? [["LIMIT", 1]]
=> "2021-02-08 07:56:10.591486"
create_table "foos", force: :cascade do |t|
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
end
さらに、AS 句で作ったカラムは、limit
, precision
, scale
のようなカラム修飾子の情報も持ちません。
この挙動を把握していないと、次のような問題が起こってしまうかもしれません。
- Rails アプリに設定したタイムゾーンではなく、DB のタイムゾーンで日付が表示される
なぜこの挙動になるのかに興味のある方は以降のおまけをご覧ください。
おまけ: コードリーディング
次のコードを実行したとき、なんのメソッドが呼ばれるか追っていきます。
foo = Foo.select('created_at, created_at AS created_at_clone').first
foo.created_at_clone
その過程で、AS 句で作ったカラムに型情報がつかない理由もわかります。
スキーマにないカラムはデフォルトで ActiveModel::Type::Value
型になるから、です。
※ 記事中で書いた foos
テーブルが定義されているものとします。
環境
DB からデータを取ってくるフェーズ
Foo.select('created_at, created_at AS created_at_clone').first
で何が起こるかを追っていきます。
select
メソッドから始まり、おおよそ次の順に呼ばれていきます。
ActiveRecord::ConnectionAdapters::DatabaseStatements#select
ActiveRecord::ConnectionAdapters::SQLite3::DatabaseStatements#exec_query
ActiveRecord::ConnectionAdapters::AbstractAdapter#build_result
ActiveRecord::Result#initialize
次に、select
の戻り値 Foo::ActiveRecord_Relation
のインスタンスに対して first
メソッドから次の順に呼ばれていきます。
ActiveRecord::FinderMethods#first
ActiveRecord::FinderMethods#find_nth
ActiveRecord::FinderMethods#find_nth_with_limit
ActiveRecord::Relation#to_ary
ActiveRecord::Relation#records
ActiveRecord::Relation#load
ActiveRecord::Relation#exec_queries
これが Foo
のインスタンスを返します。上記の ActiveRecord::Relation#exec_queries
の中で呼ばれる
ActiveRecord::Querying#find_by_sql
の中の ActiveRecord::Persistence::ClassMethods#instantiate_instance_of
で foo
(Foo
のインスタンス)のインスタンス変数 @attributes
に DB からとってきたデータや型情報を格納しています。
ActiveRecord::Querying#find_by_sql
ActiveRecord::Persistence::ClassMethods#instantiate_instance_of
ここまでが DB からデータを取ってくるフェーズです。
まとめると、DB からとってきたデータやカラムの型情報などをfoo
( Foo
インスタンス)のインスタンス変数 @attributes
に格納しました。
@attributes
は以下のようになっています。
> pp Foo.select('created_at, created_at AS created_at_clone').first.instance_variable_get(:@attributes)
Foo Load (0.4ms) SELECT created_at, created_at AS created_at_clone FROM "foos" ORDER BY "foos"."id" ASC LIMIT ? [["LIMIT", 1]]
@additional_types={},
@attributes={},
@casted_values={},
@default_attributes=
{"id"=>
@name="id",
@original_attribute=nil,
@type=
@limit=nil,
@precision=nil,
@range=-9223372036854775808...9223372036854775808,
@scale=nil>,
@value_before_type_cast=nil>},
@materialized=false,
@types=
{"id"=>
@limit=nil,
@precision=nil,
@range=-9223372036854775808...9223372036854775808,
@scale=nil>,
"created_at"=>
@limit=nil,
@precision=6,
@scale=nil>,
"updated_at"=>
@limit=nil,
@precision=6,
@scale=nil>},
@values=
{"created_at"=>"2021-02-08 07:56:10.531831",
"created_at_clone"=>"2021-02-08 07:56:10.531831"}>
値を取得するフェーズ
foo.created_at_clone
で何が起こるかを追っていきます。
まず、ActiveModel::AttributeMethods#method_missing
が呼ばれます。
ActiveModel::AttributeMethods#method_missing
def method_missing(method, *args, &block)
if respond_to_without_attributes?(method, true)
super
else
match = matched_attribute_method(method.to_s)
match ? attribute_missing(match, *args, &block) : super
end
end
次に、以下のメソッドが呼ばれます。
ActiveModel::AttributeMethods#attribute_missing
def attribute_missing(match, *args, &block)
__send__(match.target, match.attr_name, *args, &block)
end
このとき、match.target
は 'attribute'
となっており、attribute
メソッドが呼ばれます。
attribute
メソッドの定義は ActiveRecord::AttributeMethods::Read#_read_attribute
にあります。
ActiveRecord::AttributeMethods::Read#_read_attribute
def _read_attribute(attr_name, &block)
@attributes.fetch_value(attr_name, &block)
end
alias :attribute :_read_attribute
private :attribute
次に呼ばれるのが fetch_value
です。この fetch_value
の戻り値が foo.created_at_clone
の戻り値になります。
ActiveModel::LazyAttributeSet#fetch_value
class LazyAttributeSet < AttributeSet
...
def fetch_value(name, &block)
if attr = @attributes[name]
return attr.value(&block)
end
...
name
は 'created_at_clone'
です。attr = @attributes[name]
に、created_at_clone
カラムのデータをインスタンス化したものが格納されています。
そして、attr.value(&block)
が、foo.created_at_clone
として返ります。
ん...?ちょっと待ってください!
前節、「DB からデータを取ってくるフェーズ」で最後に記載した @attributes
をもう一度思い出してみます。
前節で登場した @attributes
は foo
のインスタンス変数であり、 LazyAttributeSet
のインスタンスです。
よく見ると、@attributes
自身もインスタンス変数 @attributes
を持っています。そしてその中身は空の Hash
です。
@additional_types={},
@attributes={},
@casted_values={},
@default_attributes=
{"id"=>
@name="id",
@original_attribute=nil,
@type=
@limit=nil,
@precision=nil,
@range=-9223372036854775808...9223372036854775808,
@scale=nil>,
@value_before_type_cast=nil>},
...
話を戻します。
class LazyAttributeSet < AttributeSet
...
def fetch_value(name, &block)
if attr = @attributes[name]
return attr.value(&block)
end
...
ここで登場した @attributes
は、前節で登場した @attributes
のインスタンス変数です。つまり、foo.instance_variable_get(:@attributes).instance_variable_get(:@attributes)
です。
そして、中身は空でした。
fetch_value
で @attributes[name]
すると値は取れるので、どこかで値を格納しているはずです。
値の格納は、一番最初に呼ばれた method_missing
の matched_attribute_method(method.to_s)
で行われます。次の流れです。
ActiveModel::AttributeMethods#matched_attribute_method
ActiveRecord::AttributeMethods::PrimaryKey#attribute_method?
ActiveRecord::AttributeMethods#attribute_method?
ActiveModel::LazyAttributeSet#key?
ActiveModel::AttributeSet#[]
ActiveModel::LazyAttributeSet#default_attribute
def default_attribute(
name,
value_present = true,
value = values.fetch(name) { value_present = false }
)
type = additional_types.fetch(name, types[name])
if value_present
@attributes[name] = Attribute.from_database(name, value, type, @casted_values[name])
...
@attributes[name] = Attribute.from_database(name, value, type, @casted_values[name])
で値が格納されます。
type
が created_at_clone
カラムの型です。types['created_at_clone']
が格納されます。
types
は前節の @attributes
のもつインスタンス変数 @types
です。つまり、foo.instance_v
ariable_get(:@attributes).instance_variable_get(:@types)
です。
前節で @attributes
が設定された際に @types
も初期化されました。DB のもつカラムである id
, created_at
, updated_at
の型情報が格納されています。
@types
はただの Hash
ですが、デフォルト値が ActiveModel::Type::Value
のインスタンスになっています。スキーマにない 'created_at_clone'
カラムの型は ActiveModel::Type::Value
になるというわけです。
ちなみに、id
の型は ActiveRecord::ConnectionAdapters::SQLite3Adapter::SQLite3Integer
、created_at
の型は ActiveRecord::Type::DateTime
です。
以降、次のように呼ばれます。
ActiveModel::Attribute.from_database
ActiveModel::Attribute#value
ActiveModel::Attribute::FromDatabase#type_cast
ActiveModel::Attribute::FromDatabase#type_cast
の戻り値が foo.created_at_clone
です。
def type_cast(value)
type.deserialize(value)
end
type
は ActiveModel::Type::Value
が入るのでしたね。この deserialize
メソッドは型ごとに定義されており、データベースから得た値を Ruby 型に変換します。
ActiveModel::Type::Value
の deserialize
は、引数 value
をそのまま返すメソッドです。
というわけで、foo.created_at_clone
は文字列として返ります。
ActiveModel::Type::Value#deserialize
def deserialize(value)
cast(value)
end
ActiveModel::Type::Value#cast
def cast(value)
cast_value(value) unless value.nil?
end
ActiveModel::Type::Value#cast_value
def cast_value(value)
value
end
以上です。お疲れ様でした。
おまけのおまけ: コードを読んだ際の手の動き
EDITOR=code bundle open activerecord
で、ランタイムで実際に動いている gem のコードを開きます。
明らかに呼ばれそうなメソッドに byebug
を仕込み、up
, down
コマンドでスタックフレームを移動しながら呼ばれるメソッドを追います。これを繰り返します。
他にヒントが欲しければ Method#source_location
で調べます。
最後に gem pristine activerecord
して自分の編集箇所を残さないように掃除します。
永和システムマネジメント アジャイル事業部は Ruby とアジャイルの精鋭集団です。
一緒に働くことに興味をもってくださった方、ぜひ一度お話してみませんか!
agile.esm.co.jp