こんにちは。BPSに入社してちょうど1年になりましたshin1rokです。
入社時に目標にしていた「TechRachoに技術系の記事を投稿する」を果たすべく、ActiveRecord::QueryMethods
のselect
メソッドを深掘りしてみます。
環境
- Ruby: 2.6.3
- Rails: 5.2.3
ローカルにRailsを読むためだけの小さいアプリを作り、RubyMineのコードジャンプとブレークポイントを駆使して探索しました。
そもそも(および深掘りの視点)
select
メソッドはこのようにModelを拡張する形でAttribute(?)を追加することができます。
※アソシエーションはUser has_many posts
irb(main):014:0> user = User.joins(:posts).select('users.id, posts.id as post_id').first
User Load (0.6ms) SELECT users.id, posts.id as post_id FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" ORDER BY "users"."id" ASC LIMIT $1 [["LIMIT", 1]]
=> #<User id: 1, post_id: 30>
irb(main):016:0> user.post_id
=> 30
このときuserインスタンスにid
メソッドは定義されていますが、post_id
メソッドは定義されていません。
irb(main):023:0> user.methods.find { |m| m == :id }
=> :id
irb(main):024:0> user.methods.find { |m| m == :post_id }
=> nil
なぜだろうか?と思いつつも、仕事でコードを書くときには意識しなくていい部分なので、無視していました。
夏のTechRachフェアが開催され良い機会なので、Railsのコードを読んで post_id
メソッドがどこで定義されているのか 探してみようと思います。
Railsダンジョンへ
以下を実行した時のselect
メソッド以降を呼び出し順に探索していきます。
class UsersController < ApplicationController
def index
puts User.joins(:posts).select('users.id, posts.id as post_id').limit(2)
end
end
※件数を減らすためにlimit(2)
しています。
select
メソッド
def select(*fields)
if block_given?
if fields.any?
raise ArgumentError, "`select' with block doesn't take arguments."
end
return super()
end
raise ArgumentError, "Call `select' with at least one field" if fields.empty?
spawn._select!(*fields)
end
select
メソッドはArrayに対しても使うことができるのですが、その部分がif block_given?
です。
ActiveRecordのselect
はspawn._select!(*fields)
の部分です。
spawnは生み出すという意味があるので、spawn._select!(*fields)
でSQLのSELECT文を生成しようとしているように見えます。
spawn
メソッド
def spawn #:nodoc:
@delegate_to_klass ? klass.all : clone
end
rails/spawn_methods.rb#L10-L12 at 5-2-stable · rails/railsより
spawn
はselect
のレシーバ(User.joins(:posts)
)のようです。
_select!
メソッド
def _select!(*fields) # :nodoc:
fields.flatten!
self.select_values += fields
self
end
引数をレシーバのselect_values
に設定して、selfを返しています。
records
メソッド
def records # :nodoc:
load
@records
end
_select!
のあとなんやかんやがあってrecords
メソッドのload
が呼ばれます。
注) なんやかんや: ActiveRecord::Relationは遅延評価されるため。なんやかんやの部分は追いきれませんでした。
load
メソッド
def load(&block)
exec_queries(&block) unless loaded?
self
end
まだload
していないのでexec_queries(&block)
が実行されます。
exec_queries
メソッド
def exec_queries(&block)
skip_query_cache_if_necessary do
@records =
if eager_loading?
apply_join_dependency do |relation, join_dependency|
if ActiveRecord::NullRelation === relation
[]
else
relation = join_dependency.apply_column_aliases(relation)
rows = connection.select_all(relation.arel, "SQL")
join_dependency.instantiate(rows, &block)
end.freeze
end
else
klass.find_by_sql(arel, &block).freeze
end
preload = preload_values
preload += includes_values unless eager_loading?
preloader = nil
preload.each do |associations|
preloader ||= build_preloader
preloader.preload @records, associations
end
@records.each(&:readonly!) if readonly_value
@loaded = true
@records
end
end
急に長くなったので面食らいますが、注目するところはklass.find_by_sql(arel, &block).freeze
です。
eager_loadとpreloadは今回は関係ないので無視すると、@records
にklass.find_by_sql(arel, &block).freeze
を入れて、@records
をreturnしています。
このときklass
にはUserクラスが入っています。
0> klass
=> User(id: integer, email: string, encrypted_password: string, reset_password_token: string, reset_password_sent_at: datetime, remember_created_at: datetime, confirmation_token: string, confirmed_at: datetime, confirmation_sent_at: datetime, unconfirmed_email: string, created_at: datetime, updated_at: datetime, url_name: string)
find_by_sql
メソッド
def find_by_sql(sql, binds = [], preparable: nil, &block)
result_set = connection.select_all(sanitize_sql(sql), "#{name} Load", binds, preparable: preparable)
column_types = result_set.column_types.dup
attribute_types.each_key { |k| column_types.delete k }
message_bus = ActiveSupport::Notifications.instrumenter
payload = {
record_count: result_set.length,
class_name: name
}
message_bus.instrument("instantiation.active_record", payload) do
result_set.map { |record| instantiate(record, column_types, &block) }
end
end
result_set
はActiveRecord::Resultクラスのオブジェクトで、map
のブロック引数の値はこうなっています。
0> result_set
=> #<ActiveRecord::Result:0x00007fc08d695230 @columns=["id", "post_id"], @rows=[[1, 30], [1, 29]], @hash_rows=[{"id"=>1, "post_id"=>30}, {"id"=>1, "post_id"=>29}], @column_types={"id"=>#<ActiveModel::Type::Integer:0x00007fc08f0c59c0 @precision=nil, @scale=nil, @limit=8, @range=-9223372036854775808...9223372036854775808>, "post_id"=>#<ActiveModel::Type::Integer:0x00007fc08f0c59c0 @precision=nil, @scale=nil, @limit=8, @range=-9223372036854775808...9223372036854775808>}>
0> result_set[0]
=> {"id"=>1, "post_id"=>30}
column_types
にはpost_id
がどのような型なのかを定義する情報がはいっています。
0> column_types
=> {"post_id"=>#<ActiveModel::Type::Integer:0x00007fcd66236ff0 @precision=nil, @scale=nil, @limit=8, @range=-9223372036854775808...9223372036854775808>}
ActiveSupport::Notifications.instrumenter.instrument
はブロックで渡された内容の実行時間を計測します。
result_set.map do |record| instantiate(record, column_types, &block)
をActiveRecord::RelationオブジェクトでラップしたものがUser.joins(:posts).select('users.id, posts.id as post_id').limit(2)
とイコールになります。
0> result_set.map {|record| instantiate(record, column_types, &block)}
=> [#<User id: 1, post_id: 30>, #<User id: 1, post_id: 29>]
0> User.joins(:posts).select('users.id, posts.id as post_id').limit(2)
CACHE User Load (0.0ms) SELECT users.id, posts.id as post_id FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" LIMIT $1 [["LIMIT", 2]]
↳ /Users/shin1rok/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/ruby-debug-ide-0.7.0/lib/ruby-debug-ide/command.rb:138
=> #<ActiveRecord::Relation [#<User id: 1, post_id: 30>, #<User id: 1, post_id: 29>]>
instantiate
メソッド
def instantiate(attributes, column_types = {}, &block)
klass = discriminate_class_for_record(attributes)
attributes = klass.attributes_builder.build_from_database(attributes, column_types)
klass.allocate.init_with("attributes" => attributes, "new_record" => false, &block)
end
klass.allocate.init_with("attributes" => attributes, "new_record" => false, &block)
がActiveRecord::Relationオブジェクトの各要素です。
0> klass.allocate.init_with("attributes" => attributes, "new_record" => false, &block)
=> #<User id: 1, post_id: 30>
0> klass.allocate.init_with("attributes" => attributes, "new_record" => false, &block).post_id
=> 30
ここまででpost_id
をUserモデルのインスタンスに割り当てている処理が特定できたので今回の探索はここまでにします。
続きは君の目で確かめてくれ!
余談
user_id
とpost_id
のペアが欲しかったのでselectを使ったのですが、
id_pairs = User.joins(:posts).select('users.id, posts.id as post_id')
id_pairs.group_by(&:id).each do |user_id, pairs|
p user_id
p pairs
end
ActiveRecord::Relationオブジェクトを作らない分速くなるので、こちらの方が良かったですね。
pluck
に関連先のカラムを書くという発想がありませんでした😇
id_pairs = User.joins(:posts).pluck('users.id, posts.id')
id_pairs.group_by(&:first).each do |user_id, pairs|
p user_id
p pairs
end