【OSSリーディング】ActiveRecord::RelationがDBにアクセスするタイミングを覗いてみた

記事タイトルとURLをコピーする

こんにちは、サーバーワークス OSS探求部のくればやしです。(「部」は部活の部)

以下の記事ではActive Recordの findメソッドを例に、生成されるモデルのオブジェクトにメソッドがどのように動的に定義されるか等を探っていきました。

【OSSリーディング】Active Recordの内部実装を覗いてみた - サーバーワークスエンジニアブログ

find メソッドの場合は、メソッドが実行されると同時にDBにクエリを実行するため、DBへの処理タイミングといった意味では比較的シンプルに捉えることができました。

他方、Active Record で提供される where メソッド等は、それ単体で実行されたタイミングではDBにアクセスせず、レコードが必要になったタイミングでクエリが実行されます。

When an Active Record method is called, the query is not immediately generated and sent to the database. The query is sent only when the data is actually needed. guides.rubyonrails.org

例えば、

  • ①のように .where を実行しただけではDBにアクセスはせずActiveRecord::Relationインスタンスが生成されるだけです
  • ②のように .where を実行した結果を出力しようとするとDBにアクセスします
  • ③のように別のメソッドを実行してレコードを取得しようとするとDBにアクセスします
User.where(name: "akira") # => ①DBへのアクセスはしない(ActiveRecord::Relationインスタンスが生成される)
puts User.where(name: "akira") # => ②DBへアクセスする
User.where(name: "akira").first #=> ③DBへアクセスする

今回、①②③それぞれについて、以下に焦点を当ててソースコードを探っていきたいと思います。

① ActiveRecord::Relation インスタンスがどのように生成されるか
②③ DBへアクセスする部分はどのように実装されているか

環境は前述の記事と同様とします。

ソースコードリーディング

① ActiveRecord::Relation インスタンスがどのように生成されるか

User.where の結果を展開すると User::ActiveRecord_Relation のインスタンスオブジェクトとなっています。

User は今回のサンプルコード内で ActiveRecord::Base を継承したクラス名です。つまり、ユーザーが定義したクラス名の名前空間で、 ActiveRecord_Relation クラスが動的に定義されていると分かります。

class User < ActiveRecord::Base`
end

where メソッドを実行したところからソースコードを探っていってみます。 source_location メソッドで定義場所を確認すると、以下 ActiveRecord::Querying モジュールに定義されていると分かります。 delegate されているため、実際には all.where が実行されていると分かるため、 .all を見てみます。

module ActiveRecord
  module Querying
    QUERYING_METHODS = [
      :find, :find_by, :find_by!, :take, :take!, :sole, :find_sole_by, :first, :first!, :last, :last!,
      :second, :second!, :third, :third!, :fourth, :fourth!, :fifth, :fifth!,
      :forty_two, :forty_two!, :third_to_last, :third_to_last!, :second_to_last, :second_to_last!,
      :exists?, :any?, :many?, :none?, :one?,
      :first_or_create, :first_or_create!, :first_or_initialize,
      :find_or_create_by, :find_or_create_by!, :find_or_initialize_by,
      :create_or_find_by, :create_or_find_by!,
      :destroy_all, :delete_all, :update_all, :touch_all, :destroy_by, :delete_by,
      :find_each, :find_in_batches, :in_batches,
      :select, :reselect, :order, :in_order_of, :reorder, :group, :limit, :offset, :joins, :left_joins, :left_outer_joins,
      :where, :rewhere, :invert_where, :preload, :extract_associated, :eager_load, :includes, :from, :lock, :readonly,
      :and, :or, :annotate, :optimizer_hints, :extending,
      :having, :create_with, :distinct, :references, :none, :unscope, :merge, :except, :only,
      :count, :average, :minimum, :maximum, :sum, :calculate,
      :pluck, :pick, :ids, :strict_loading, :excluding, :without
    ].freeze # :nodoc:
    delegate(*QUERYING_METHODS, to: :all)

rails/activerecord/lib/active_record/querying.rb at v7.0.5 · rails/rails · GitHub

allActiveRecord::Scoping::Named::ClassMethods モジュールで定義されています。 rails/activerecord/lib/active_record/scoping/named.rb at v7.0.5 · rails/rails · GitHub

さらに進むと、同モジュールの default_scoped メソッドが呼ばれ、この引数の relation メソッドの結果が最終的に返っているようです。この relation メソッドをさらに読んでいきます。

def default_scoped(scope = relation, all_queries: nil)
  build_default_scope(scope, all_queries: all_queries) || scope
end

rails/activerecord/lib/active_record/scoping/named.rb at v7.0.5 · rails/rails · GitHub

relation メソッドは ActiveRecord::Core モジュールに定義されています。返り値は Relation.create メソッドの結果となっていました。これをさらに探っていきます。 Relation は ActiveRecord::Relation で定義されたクラスです。

def relation
  relation = Relation.create(self)

rails/activerecord/lib/active_record/core.rb at v7.0.5 · rails/rails · GitHub

すると、 ActiveRecord::Delegation::DelegateCache モジュールで定義されている create メソッドが呼ばれ、

def create(klass, *args, **kwargs)
  relation_class_for(klass).new(klass, *args, **kwargs)
end

rails/activerecord/lib/active_record/relation/delegation.rb at v7.0.5 · rails/rails · GitHub

initialize_relation_delegate_cache メソッドの結果が返ります。ここで、 ActiveRecord::Relation のインスタンスを生成しているようなので読んでいきます。

  • 9行目、 delegate = Class.new(klass)ActiveRecord::Relation を親クラスに持つクラスが delegate に動的に定義されています。
    • mangled_name の中身は ActiveRecord_Relation です。
  • 14行目、 const_set mangled_name, delegate で、self= User を名前区間に持っているため delegate のクラス名が User::ActiveRecord_Relation として定義されます。
def initialize_relation_delegate_cache
  @relation_delegate_cache = cache = {}
  [
    ActiveRecord::Relation,
    ActiveRecord::Associations::CollectionProxy,
    ActiveRecord::AssociationRelation,
    ActiveRecord::DisableJoinsAssociationRelation
  ].each do |klass|
    delegate = Class.new(klass) {
      include ClassSpecificRelation
    }
    include_relation_methods(delegate)
    mangled_name = klass.name.gsub("::", "_")
    const_set mangled_name, delegate
    private_constant mangled_name

    cache[klass] = delegate
  end
end

rails/activerecord/lib/active_record/relation/delegation.rb at v7.0.5 · rails/rails · GitHub

これにより、 ActiveRecord::Relation を親クラスに持つ User::ActiveRecord_Relation インスタンスが生成されることを確認できました。

② DBへアクセスする部分はどのように実装されているか

さて、では putsした時にどのようにDBにアクセスしているかを見ていきたいと思います。

puts User.where(name: "akira") #=> DBにアクセスする

まず puts メソッドですが、

配列や文字列以外のオブジェクトが引数として与えられた場合には、当該オブジェクトを最初に to_ary により配列へ、次に to_s メソッドにより文字列へ変換を試みます。 Kernel.#puts (Ruby 3.2 リファレンスマニュアル)

の通り、今回の場合は対象が User::ActiveRecord_Relation のインスタンスのため、 to_ary が呼ばれます。 ActiveRecord::Relation クラスに実装されています。

def to_ary
  records.dup
end

rails/activerecord/lib/active_record/relation.rb at v7.0.5 · rails/rails · GitHub

同クラスの #records メソッドが呼ばれています。

def records # :nodoc:
  load
  @records
end

rails/activerecord/lib/active_record/relation.rb at v7.0.5 · rails/rails · GitHub

この load メソッドをたどっていくと、同クラスの exec_main_query メソッドにいきます。 今回の場合は下記16行目の処理にいきます。

def exec_main_query(async: false)
  skip_query_cache_if_necessary do
    if where_clause.contradiction?
      [].freeze
    elsif eager_loading?
      apply_join_dependency do |relation, join_dependency|
        if relation.null_relation?
          [].freeze
        else
          relation = join_dependency.apply_column_aliases(relation)
          @_join_dependency = join_dependency
          connection.select_all(relation.arel, "SQL", async: async)
        end
      end
    else
      klass._query_by_sql(arel, async: async)
    end
  end
end

rails/activerecord/lib/active_record/relation.rb at v7.0.5 · rails/rails · GitHub

この16行目の _query_by_sql メソッドですが、このメソッドは前回の記事で見覚えがあります。 前回記事を参照すると分かる通り、このメソッドを通してSQLクエリが実行されます。

③ DBへアクセスする部分はどのように実装されているか

では、次に first メソッドでどのようにDBにアクセスしているか見ていきます。

User.where(name: "akira").first 

first の定義場所を見ていくと、 ActiveRecord::FinderMethods モジュールに定義されています。このモジュールは ActiveRecord::Relation に include されています。

def first(limit = nil)
  if limit
    find_nth_with_limit(0, limit)
  else
    find_nth 0
  end
end

rails/activerecord/lib/active_record/relation/finder_methods.rb at v7.0.5 · rails/rails · GitHub

ここを読み進めていくと、同モジュールの find_nth_with_limit メソッドにいきます。今回の例では、11行目の処理にいきます。

ここで to_a メソッドが呼ばれますが、実はこれは先ほど見た to_ary メソッドのエイリアスとなっています。したがって先ほどの処理が進み、SQLクエリが実行されることとなります。

def find_nth_with_limit(index, limit)
  if loaded?
    records[index, limit] || []
  else
    relation = ordered_relation

    if limit_value
      limit = [limit_value - index, limit].min
    end

    if limit > 0
      relation = relation.offset((offset_value || 0) + index) unless index.zero?
      relation.limit(limit).to_a
    else
      []
    end
  end
end

おわりに

Active Recordのメソッド(の一部)はすぐにDBにアクセスするわけではなく必要なタイミングでアクセスする、というのはRailsを開発に使う場合でも意識することはあると思いますが、実際に中の実装を覗いたことは無かったので、今回具体的にイメージすることが出来ました。 一度でも雑に仕組みを読んでおくと、何かあった時に再度細かいところを確認できると思えるので、ライブラリを使うという意味でも安心して使えるようになると思います。

またまたコードリーディングの殴り書きのような形になりましたが、Active Recordの内部実装が気になった時の参考になれば幸いです。

参考

ActiveRecord各メソッドのクエリ実行タイミングについて - Qiita

くればやし (記事一覧)