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


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インスタンスにする