Help us understand the problem. What is going on with this article?

ActiveRecord::QueryMethods#eager_loadがeager loadingを行う仕組み

More than 5 years have passed since last update.

ActiveRecord::QueryMethods#eager_loadでeager loadingをする際に、
ActiveRecord::Associations::JoinDependencyがどのような働きをしてレコードの読み込みを行っているかコードリーディングをしたので、そのメモ。

そもそもeager_loadって何

JOINで関連先オブジェクトをeager loadingするためのscope。

ActiveRecordのjoinsとpreloadとincludesとeager_loadの違い

eager_loadを呼んでからクエリが走るまで

Tweet.eager_load(:favorites).to_arbtraceを使って実際にトレースして少し加工したものを以下に示す。

ActiveRecord::QueryMethods#eager_load
  ActiveRecord::QueryMethods#eager_load!

ActiveRecord::Relation#exec_queries
  ActiveRecord::FinderMethods#find_with_associations
    ActiveRecord::FinderMethods#construct_join_dependency
    ActiveRecord::Associations::JoinDependency#aliases
    ActiveRecord::Associations::JoinDependency::Aliases#columns
    ActiveRecord::QueryMethods#select
    ActiveRecord::FinderMethods#apply_join_dependency
    ActiveRecord::ConnectionAdapters::DatabaseStatements#select_all
    ActiveRecord::Associations::JoinDependency#instanciate

まずeager_loadを呼ぶと引数がrelationにeager_load_valuesとして追加される。この時点ではrelationなのでクエリは走らない。
findとかfirstみたいな、クエリが実行されるメソッド(ActiveRecord::FinderMethods)が呼ばれるとexec_queriesが走り、ここでJOINなどが行われる。

ActiveRecord::Relation#exec_queries

exec_queriesは、eager loadingを行ったassociationのキャッシュを持つActiveRecord::BaseのArrayを返すことになる。

def exec_queries
  @records = eager_loading? ? find_with_associations : @klass.find_by_sql(arel, bind_values)

  preload = preload_values
  preload +=  includes_values unless eager_loading?
  preloader = ActiveRecord::Associations::Preloader.new
  preload.each do |associations|
    preloader.preload @records, associations
  end

  @records.each { |record| record.readonly! } if readonly_value

  @loaded = true
  @records
end

preloadを呼んだ場合の処理とeager_loadを読んだ場合の処理の分岐がここに入っている。
eager_loadを呼んだ場合はeager_loading?がtrueになるため、ここではfind_with_associationsがメインになる。

ActiveRecord::FinderMethods#find_with_associations

eager_loadを呼んだときのメインの処理がここに入っていて、このメソッドを理解することが重要だと思う。
このメソッドの途中にbinding.pryを挟むとわかりやすい。

def find_with_associations
  join_dependency = construct_join_dependency

  aliases  = join_dependency.aliases
  relation = select aliases.columns
  relation = apply_join_dependency(relation, join_dependency)

  if block_given?
    yield relation
  else
    if ActiveRecord::NullRelation === relation
      []
    else
      rows = connection.select_all(relation.arel, 'SQL', relation.bind_values.dup)
      join_dependency.instantiate(rows, aliases)
    end
  end
end

join_dependencyとは

ActiveRecordがeager loadingを行うために使うクラスは2つある。

  • Preloader
    • preloadや単独のincludesを呼んだ時に使われる
    • JOINしない
  • JoinDependency
    • eager_loadを呼んだ時に使われる
    • JOINする

そのうちのJoinDependencyのことである。

construct_join_dependency

Tweet.eager_load(:favorites).to_aの場合、[:favorites]eager_load_valuesとなり、

ActiveRecord::Associations::JoinDependency.new(Tweet, [:favorites], [])

を返す。(おわり)

join_dependency.aliases

ActiveRecordがJOINをすると、id AS t1_r0, tweet_id AS t1_r1, ...みたいな汚いカラム名でSELECTされるのを見たことがあると思う。
この実際のカラム名と汚いカラム名の対応関係を持っているのがaliasesで、これがないとActiveRecord::Baseのカラムに対応させることができない。

一部を覗き見ると、

[#<struct ActiveRecord::Associations::JoinDependency::Aliases::Column name="id", alias="t1_r0">,
 #<struct ActiveRecord::Associations::JoinDependency::Aliases::Column name="tweet_id", alias="t1_r1">,
 #<struct ActiveRecord::Associations::JoinDependency::Aliases::Column name="user_id", alias="t1_r2">,
 #<struct ActiveRecord::Associations::JoinDependency::Aliases::Column name="created_at", alias="t1_r3">,
 #<struct ActiveRecord::Associations::JoinDependency::Aliases::Column name="updated_at", alias="t1_r4">]>]>

みたいになっている。

relationの構築

relation = select aliases.columns
relation = apply_join_dependency(relation, join_dependency)

1行目で必要な汚いカラムのSELECTをrelationに追加し、2行目で必要なテーブルのJOINをrelationに追加している。

クエリ実行

rows = connection.select_all(relation.arel, 'SQL', relation.bind_values.dup)
# => #<ActiveRecord::Result:0x007f97d2d286d0
#  @column_types={},
#  @columns=["t0_r0", "t0_r1", "t0_r2", "t0_r3", "t0_r4", "t1_r0", "t1_r1", "t1_r2", "t1_r3", "t1_r4"],
#  @hash_rows=nil,
#  @rows=
#   [[1, nil, 1, 2014-11-22 19:40:29 UTC, 2014-11-22 19:40:29 UTC, 1, 1, 2, 2014-11-22 19:40:29 UTC, 2014-11-22 19:40:29 UTC],
#    [3, nil, 3, 2014-11-22 19:40:29 UTC, 2014-11-22 19:40:29 UTC, 2, 3, 4, 2014-11-22 19:40:29 UTC, 2014-11-22 19:40:29 UTC],
#    [3, nil, 3, 2014-11-22 19:40:29 UTC, 2014-11-22 19:40:29 UTC, 3, 3, 5, 2014-11-22 19:40:29 UTC, 2014-11-22 19:40:29 UTC],
#    [6, nil, 6, 2014-11-22 19:40:29 UTC, 2014-11-22 19:40:29 UTC, 4, 6, 7, 2014-11-22 19:40:29 UTC, 2014-11-22 19:40:29 UTC],
#    ...

join_dependency.instantiate(rows, aliases)
# => [#<Tweet id: 1, tweet_id: nil, user_id: 1, created_at: "2014-11-22 19:40:29", updated_at: "2014-11-22 19:40:29">,
#  #<Tweet id: 3, tweet_id: nil, user_id: 3, created_at: "2014-11-22 19:40:29", updated_at: "2014-11-22 19:40:29">,
#  #<Tweet id: 6, tweet_id: nil, user_id: 6, created_at: "2014-11-22 19:40:29", updated_at: "2014-11-22 19:40:29">,
#  #<Tweet id: 10, tweet_id: nil, user_id: 10, created_at: "2014-11-22 19:40:29", updated_at: "2014-11-22 19:40:29">,
#  #<Tweet id: 15, tweet_id: nil, user_id: 15, created_at: "2014-11-22 19:40:29", updated_at: "2014-11-22 19:40:29">]

JOINクエリの結果なので、rowsには1行にテーブル2つ分が入っている。
rowsには汚いカラム名が割り振られているので、先ほどのaliasesを使ってjoin_dependency.instanciateが適切なカラムに値を入れてActiveRecord::Baseのインスタンスにする。

まとめ

eager_loadは以下の流れでeager loadingを行う。

  • eager_loadでrelationにeager_load_valuesが追加される
  • eager_load_valuesからjoin_dependencyが作られる
  • join_dependencyがカラム名のaliasを作る
  • selectとjoinのscopeを追加してクエリを実行し、aliasでActiveRecord::Baseインスタンスにする
Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away