ActiveRecord::QueryMethodsのselectメソッドについて深掘りしてみた

こんにちは。BPSに入社してちょうど1年になりましたshin1rokです。

入社時に目標にしていた「TechRachoに技術系の記事を投稿する」を果たすべく、ActiveRecord::QueryMethodsselectメソッドを深掘りしてみます。

環境

  • 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

rails/query_methods.rb#L220-L231 · rails/railsより

selectメソッドはArrayに対しても使うことができるのですが、その部分がif block_given?です。

ActiveRecordのselectspawn._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より

spawnselectのレシーバ(User.joins(:posts))のようです。

_select!メソッド

def _select!(*fields) # :nodoc:
  fields.flatten!
  self.select_values += fields
  self
end

rails/query_methods.rb#L233-L237 · rails/railsより

引数をレシーバのselect_valuesに設定して、selfを返しています。

recordsメソッド

def records # :nodoc:
  load
  @records
end

rails/relation.rb#L199-L202 · rails/railsより

_select!のあとなんやかんやがあってrecordsメソッドのloadが呼ばれます。

注) なんやかんや: ActiveRecord::Relationは遅延評価されるため。なんやかんやの部分は追いきれませんでした。

loadメソッド

def load(&block)
  exec_queries(&block) unless loaded?

  self
end

rails/relation.rb#L421-L425 · rails/railsより

まだ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

rails/relation.rb#L546-L576 · rails/railsより

急に長くなったので面食らいますが、注目するところはklass.find_by_sql(arel, &block).freezeです。
eager_loadとpreloadは今回は関係ないので無視すると、@recordsklass.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

rails/querying.rb#L40-L54 · rails/railsより

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

rails/persistence.rb#L68-L72 · rails/railsより

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_idpost_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
デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

shin1rok

レールに乗るエンジニア

shin1rokの書いた記事

夏のTechRachoフェア2019

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ