Railsソースコードリーディング2:ActiveRecordのcountとlengthの違い(あとsizeも)

2022/06/29に公開

3行まとめ

  • ActiveRecordのcountとlength、そしてsizeの振る舞いについて学ぶ
  • 実際にRailsのソースコードを追って調べる
  • countをするか、SQLの実行結果を格納した配列の長さを取っているか、cacheをどのように活用するかに違いがある

はじめに

最近Railsを書いていて、lengthとcountの振る舞いが違うことがあり、この二つの振る舞いの違い(というか、countはSQLでcountしているものだと思えば、特にlengthがどのような振る舞いをしているか)が気になったので、Railsのソースコードを読んでいこうと思います。

早速、最初はcountメソッドとlengthメソッドのsource_locationを表示し、どのファイルで定義しているかから調べます。

[8] pry(main)> Task.where.not(id: nil).method(:count).source_location
=> ["/Users/****/projects/sample_app/vendor/bundle/ruby/2.6.0/gems/activerecord-6.0.5/lib/active_record/relation/calculations.rb", 41]
[9] pry(main)> Task.where.not(id: nil).method(:length).source_location
=> ["/Users/****/projects/sample_app/vendor/bundle/ruby/2.6.0/gems/activerecord-6.0.5/lib/active_record/relation/delegation.rb", 88]

countを追う

countは以下のメソッドで定義されていることがわかりました。
https://github.com/rails/rails/blob/main/activerecord/lib/active_record/relation/calculations.rb#L43

    def count(column_name = nil)
      if block_given?
        unless column_name.nil?
          raise ArgumentError, "Column name argument is not supported when a block is passed."
        end

        super()
      else
        calculate(:count, column_name)
      end
    end

ここで気づいたのですが、countメソッドって引数取れるんですね。(確かにドキュメントにも書いてありました https://railsdoc.com/page/count )あと、blockも渡せるようです。これについては別途調べてみたいと思います。

ここで実際に計算しているのは calculate メソッドのようなので、それをさらに追ってみます。
calculateメソッドのsource_locationは、以下のようになっています。

[5] pry(#<Task::ActiveRecord_Relation>)> method(:calculate).source_location
=> ["/Users/****/projects/sample_app/vendor/bundle/ruby/2.6.0/gems/activerecord-6.0.5/lib/active_record/relation/calculations.rb", 128]

中身はこのような形です。

    def calculate(operation, column_name)
      if has_include?(column_name)
        relation = apply_join_dependency

        if operation.to_s.downcase == "count"
          unless distinct_value || distinct_select?(column_name || select_for_count)
            relation.distinct!
            relation.select_values = [ klass.primary_key || table[Arel.star] ]
          end
          # PostgreSQL: ORDER BY expressions must appear in SELECT list when using DISTINCT
          relation.order_values = [] if group_values.empty?
        end

        relation.calculate(operation, column_name)
      else
        perform_calculation(operation, column_name)
      end
    end

2行目のhas_include?による分岐を考えます。今回は

[1] pry(#<Task::ActiveRecord_Relation>)> has_include?(column_name)
=> false

だったので、else句の方の分岐に入り、perform_calculationに直接渡っていそうです。has_include?の方の処理はざっとみた感じ、relationとか言っているので、関連付けされたテーブルのカラムを持ってくる時に追加される処理だと予想できます。

それでは、perform_calculationを見ていきます。perform_calculationのsource_locationを調べます。

[4] pry(#<Task::ActiveRecord_Relation>)> method(:perform_calculation).source_location
=> ["/Users/****/projects/sample_app/vendor/bundle/ruby/2.6.0/gems/activerecord-6.0.5/lib/active_record/relation/calculations.rb", 230]
      def perform_calculation(operation, column_name)
        operation = operation.to_s.downcase

        # If #count is used with #distinct (i.e. `relation.distinct.count`) it is
        # considered distinct.
        distinct = distinct_value

        if operation == "count"
          column_name ||= select_for_count
          if column_name == :all
            if !distinct
              distinct = distinct_select?(select_for_count) if group_values.empty?
            elsif group_values.any? || select_values.empty? && order_values.empty?
              column_name = primary_key
            end
          elsif distinct_select?(column_name)
            distinct = nil
          end
        end

        if group_values.any?
          execute_grouped_calculation(operation, column_name, distinct)
        else
          execute_simple_calculation(operation, column_name, distinct)
        end
      end

ここでも特にcountの場合、distinctされているケースなど色々分岐があるようですが、今回は単にcountするだけなので引っかからないようで、そのままexecute_simple_calculationに進みます。

execute_simple_calculationのsource_locationを調べましょう。

[8] pry(#<Task::ActiveRecord_Relation>)> method(:execute_simple_calculation).source_location
=> ["/Users/****/projects/sample_app/vendor/bundle/ruby/2.6.0/gems/activerecord-6.0.5/lib/active_record/relation/calculations.rb", 273]
      def execute_simple_calculation(operation, column_name, distinct) #:nodoc:
        column_alias = column_name

        if operation == "count" && (column_name == :all && distinct || has_limit_or_offset?)
          # Shortcut when limit is zero.
          return 0 if limit_value == 0

          query_builder = build_count_subquery(spawn, column_name, distinct)
        else
          # PostgreSQL doesn't like ORDER BY when there are no GROUP BY
          relation = unscope(:order).distinct!(false)

          column = aggregate_column(column_name)

          select_value = operation_over_aggregate_column(column, operation, distinct)
          if operation == "sum" && distinct
            select_value.distinct = true
          end

          column_alias = select_value.alias
          column_alias ||= @klass.connection.column_name_for_operation(operation, select_value)
          relation.select_values = [select_value]

          query_builder = relation.arel
        end

        result = skip_query_cache_if_necessary { @klass.connection.select_all(query_builder, nil) }
        row    = result.first
        value  = row && row.values.first
        type   = result.column_types.fetch(column_alias) do
          type_for(column_name)
        end

        type_cast_calculated_value(value, type, operation)
      end

limitが0の場合は直ちに0を返すようにするとか、そこかしこに高速化?の工夫が見えて面白いですね。
ざっと読んだのですが、else句の最初、
relationという変数を定義していますが、

[5] pry(#<Task::ActiveRecord_Relation>)> relation
  Task Load (1.3ms)  SELECT COUNT(*) FROM "tasks" WHERE "tasks"."id" IS NOT NULL

とクエリの原型を作成しているような形跡があります。その後、
query_builder = relation.arel
で、クエリを構造化して持っているような気配がします。

        result = skip_query_cache_if_necessary { @klass.connection.select_all(query_builder, nil) }

ここで、最終的にクエリを発行していそうです。キャッシュの仕組みもここで組み込まれていそうですね。
resultの返り値で、結果が出ていて、あとはそれを加工しているだけのようです。

振り返り

ここまでで、ざっとcountの仕組みを追ってきました。
大まかな流れとしては、
count -> calculate -> perform_calculation -> execute_simple_calculation
と調べ、その過程でRailsが少しずつ処理の分岐を行っている様子がわかりました。
その過程で、実際にクエリを組み立てている過程を大まかに追うことができました。
また、小さな高速化の工夫や、キャッシュをしているような痕跡も追うことができてよかったです。
次に深掘りするとすれば、最初にrelationを組み立てた、

relation = unscope(:order).distinct!(false)

この行でしょうか。ここでクエリの種となるようなところを生成しているようでした。自分のメモのためにsource_locationも残しておきます。

[16] pry(#<Task::ActiveRecord_Relation>)> method(:unscope).source_location
=> ["/Users/****/projects/sample_app/vendor/bundle/ruby/2.6.0/gems/activerecord-6.0.5/lib/active_record/relation/query_methods.rb", 430]

これでcountについて追うのは終わりにしたいと思います。
次は、lengthを追ってみたいと思います。

lengthを追う

改めて、lengthメソッドのsource_locationをやってメソッドが定義されている場所を調べておきます。

[4] pry(main)> Task.all.method(:length).source_location
=> ["/Users/HiroyukiEndo/projects/sample_app/vendor/bundle/ruby/2.6.0/gems/activerecord-6.0.5/lib/active_record/relation/delegation.rb", 88]

該当行では下記のような処理が行われていました。
https://github.com/rails/rails/blob/main/activerecord/lib/active_record/relation/delegation.rb#L88

    delegate :to_xml, :encode_with, :length, :each, :join,
             :[], :&, :|, :+, :-, :sample, :reverse, :rotate, :compact, :in_groups, :in_groups_of,
             :to_sentence, :to_formatted_s, :as_json,
             :shuffle, :split, :slice, :index, :rindex, to: :records

recordに対するdelegationなので、このようなメソッドが生えていることになります。

def length
  records.length
end

recordsがこの文脈でどのメソッドのことを表しているのか、以下のようにinstance_evalを用いて調べます。

[9] pry(main)> Task.all.instance_eval{p method(:records).source_location}
["/Users/HiroyukiEndo/projects/sample_app/vendor/bundle/ruby/2.6.0/gems/activerecord-6.0.5/lib/active_record/relation.rb", 249]
=> ["/Users/HiroyukiEndo/projects/sample_app/vendor/bundle/ruby/2.6.0/gems/activerecord-6.0.5/lib/active_record/relation.rb", 249]

該当のメソッドのコードを調べてみると、以下のようになっていました。

    def records # :nodoc:
      load
      @records
    end

このrecordsというメソッドでは、loadという処理を行なったのちに、@recordsというインスタンス変数を返していることが分かります。
ここでさらにloadについて調べてみましょう。

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

      self
    end

loaded?について調べてみると、

    attr_reader :table, :klass, :loaded, :predicate_builder
    alias :loaded? :loaded
    
          @loaded = false

loadedというインスタンス変数を読み込んでいそうです。

loadメソッドの処理を素直に読んでみると、
loadはSQLを発行して、relationを返す
loaded(SQL発行済み)なら、何もしない
ということをやっていそうです。

しかし、手元の環境で試しに
Task.where(content: "aaaa").load
を何度やっても

[3] pry(main)> Task.where(content: "aaaa").load
  Task Load (0.4ms)  SELECT "tasks".* FROM "tasks" WHERE "tasks"."content" = $1  [["content", "aaaa"]]
=> [#<Task:0x0000000106ba3888
  id: 1,
  content: "aaaa",
  created_at: Fri, 10 Jun 2022 07:53:23 UTC +00:00,
  updated_at: Fri, 10 Jun 2022 07:53:23 UTC +00:00>]

SQLが発行されているように見える。これは謎ですが一旦置いておきましょう。

ここまでrecordsメソッドのloadという下処理を調べてきたわけですが、戻って、返り値である@recordについて調べてみましょう。@recordのlengthメソッドについて調べてみると、

[9] pry(main)> Task.all.instance_eval{p records.method(:length).source_location}
  Task Load (0.4ms)  SELECT "tasks".* FROM "tasks"
nil
=> nil

ということが分かります。source_locationがnilということは、Ruby本体のメソッドが呼ばれているということです。
@recordsは、Arrayなので、
@records.lengthは、rubyのArrayのlengthを呼んでいそうだということが分かります。

これまで調べたrecordメソッドの振る舞いをまとめると、lengthを呼び出した時の振る舞いは、
過去にSQLが発行されていて、結果がキャッシュされてたらキャッシュされた結果の長さを返す。
そうでなければ、SQLを新たに発行し、その結果の長さを返す。
ということになりそうです。

おまけ: size

よくcount, lengthと一緒に話題に上がるのはsizeですが、sizeも同じファイルに見つけたのでついでに調べてみたいと思います。
sizeメソッドは以下の場所で定義されています。

[15] pry(main)> Task.all.method(:size).source_location
=> ["/Users/****/projects/sample_app/vendor/bundle/ruby/2.6.0/gems/activerecord-6.0.5/lib/active_record/relation.rb", 260]

その箇所を見ると、sizeメソッドは以下のように定義されています。

    def size
      loaded? ? @records.length : count(:all)
    end

count, length, sizeのまとめ

これまで調べてきたことをまとめると、以下のように言うことができるでしょう。

count: dbにSQLで問い合わせる(count(*))
length: modelオブジェクトが取れた数を返している(キャッシュされてたらそのキャッシュ結果の数)
size: 取得済みならlengthメソッドでオブジェクトの長さ、未取得ならcountを発行して結果を得る

おまけ2: count, length, sizeの違いを実際に動かして試してみる。

rails consoleで実行すると先述のようにloadがうまく働いていないようだったので、controllerに書いてloadされるように動かして試してみました。
 
loadを三回実行

Started GET "/tasks" for 127.0.0.1 at 2022-06-15 17:50:36 +0900
   (7.6ms)  SELECT "schema_migrations"."version" FROM "schema_migrations" ORDER BY "schema_migrations"."version" ASC
Processing by TasksController#index as HTML
  Task Load (0.9ms)  SELECT "tasks".* FROM "tasks" WHERE "tasks"."content" = $1  [["content", "aaaa"]]
  ↳ app/controllers/tasks_controller.rb:7:in `index'
  CACHE Task Load (0.0ms)  SELECT "tasks".* FROM "tasks" WHERE "tasks"."content" = $1  [["content", "aaaa"]]
  ↳ app/controllers/tasks_controller.rb:8:in `index'
  CACHE Task Load (0.0ms)  SELECT "tasks".* FROM "tasks" WHERE "tasks"."content" = $1  [["content", "aaaa"]]
  ↳ app/controllers/tasks_controller.rb:9:in `index'
  Rendering tasks/index.html.erb within layouts/application
  Task Load (0.4ms)  SELECT "tasks".* FROM "tasks"

二回目からはcacheが動いていることが分かります。

lengthを三回実行

Started GET "/tasks" for 127.0.0.1 at 2022-06-15 17:52:25 +0900
Processing by TasksController#index as HTML
  Task Load (0.7ms)  SELECT "tasks".* FROM "tasks" WHERE "tasks"."content" = $1  [["content", "aaaa"]]
  ↳ app/controllers/tasks_controller.rb:7:in `index'
  CACHE Task Load (0.0ms)  SELECT "tasks".* FROM "tasks" WHERE "tasks"."content" = $1  [["content", "aaaa"]]
  ↳ app/controllers/tasks_controller.rb:8:in `index'
  CACHE Task Load (0.0ms)  SELECT "tasks".* FROM "tasks" WHERE "tasks"."content" = $1  [["content", "aaaa"]]
  ↳ app/controllers/tasks_controller.rb:9:in `index'
  Rendering tasks/index.html.erb within layouts/application
  Task Load (0.4ms)  SELECT "tasks".* FROM "tasks"
  ↳ app/views/tasks/index.html.erb:14
  Rendered tasks/index.html.erb within layouts/application (Duration: 5.5ms | Allocations: 1200)
[Webpacker] Everything's up-to-date. Nothing to do
Completed 200 OK in 36ms (Views: 18.2ms | ActiveRecord: 1.1ms | Allocations: 7143)

一回目ではSELECT *を実行して、その結果の配列の長さを取得していそうです。二回目以降では、その結果をキャッシュした上で、長さを取得していそうです。

sizeを三回実行

Started GET "/tasks" for 127.0.0.1 at 2022-06-15 17:53:32 +0900
Processing by TasksController#index as HTML
   (0.3ms)  SELECT COUNT(*) FROM "tasks" WHERE "tasks"."content" = $1  [["content", "aaaa"]]
  ↳ app/controllers/tasks_controller.rb:7:in `index'
  CACHE  (0.0ms)  SELECT COUNT(*) FROM "tasks" WHERE "tasks"."content" = $1  [["content", "aaaa"]]
  ↳ app/controllers/tasks_controller.rb:8:in `index'
  CACHE  (0.0ms)  SELECT COUNT(*) FROM "tasks" WHERE "tasks"."content" = $1  [["content", "aaaa"]]
  ↳ app/controllers/tasks_controller.rb:9:in `index'
  Rendering tasks/index.html.erb within layouts/application
  Task Load (0.2ms)  SELECT "tasks".* FROM "tasks"
  ↳ app/views/tasks/index.html.erb:14
  Rendered tasks/index.html.erb within layouts/application (Duration: 5.7ms | Allocations: 3634)
[Webpacker] Everything's up-to-date. Nothing to do
Completed 200 OK in 17ms (Views: 8.3ms | ActiveRecord: 6.2ms | Allocations: 10438)

一回目ではcountを発行し、二回目以降ではcountの結果のキャッシュを取り出していそうです。

次は
size, length, size
の順に三回実行してみます。
これまでの考察を合わせてみると、
一回目のsizeではcountが発行され、二回目のlengthではSELECT *が実行され、三回目のsizeではlengthで得られた結果をcacheして返すのでしょうか、という予想を立ててみます。結果は、

Started GET "/tasks" for 127.0.0.1 at 2022-06-15 17:55:24 +0900
Processing by TasksController#index as HTML
   (0.5ms)  SELECT COUNT(*) FROM "tasks" WHERE "tasks"."content" = $1  [["content", "aaaa"]]
  ↳ app/controllers/tasks_controller.rb:7:in `index'
  Task Load (0.3ms)  SELECT "tasks".* FROM "tasks" WHERE "tasks"."content" = $1  [["content", "aaaa"]]
  ↳ app/controllers/tasks_controller.rb:8:in `index'
  CACHE  (0.0ms)  SELECT COUNT(*) FROM "tasks" WHERE "tasks"."content" = $1  [["content", "aaaa"]]
  ↳ app/controllers/tasks_controller.rb:9:in `index'
  Rendering tasks/index.html.erb within layouts/application
  Task Load (0.3ms)  SELECT "tasks".* FROM "tasks"
  ↳ app/views/tasks/index.html.erb:14
  Rendered tasks/index.html.erb within layouts/application (Duration: 2.6ms | Allocations: 1200)
[Webpacker] Everything's up-to-date. Nothing to do
Completed 200 OK in 23ms (Views: 13.7ms | ActiveRecord: 1.2ms | Allocations: 7235)

のようになります。
一回目のsizeは予想通りcountを返していました。
二回目のlengthも予想通りSELECT *をしていました。
三回目のsizeは、予想とは異なり、countの結果をキャッシュしたものを返していました。先にcountの結果をキャッシュしているので、そちらが優先されるのでしょうか。@records.lengthが返ってくるはずなので、ちょっと挙動が謎です。がこれはいつかの課題に取っておきたいと思います。

まとめ

この記事では、実際のRailsのソースコードリーディングを通して、length, count, そしてsizeメソッドの違いについて調べました。そして、実際に動かしてみて、キャッシュのされ方、クエリの発行のされ方について調べました。
Railsは単純なクエリから複雑なクエリまで、効率的かつ高速に処理できるよう色々と工夫されているんだなということの片鱗を見ることができて大変興味深かったです。ActiveRecordのクエリの組み立て方やキャッシュの作り方など、もう少し掘り下げてみたいテーマもあります(だいぶ深追いしている感のあるテーマではありますが)。
今後もRailsのソースコードリーディングをして学んでいきたいと思います。

Discussion