Railsソースコードリーディング2:ActiveRecordのcountとlengthの違い(あとsizeも)
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は以下のメソッドで定義されていることがわかりました。
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]
該当行では下記のような処理が行われていました。
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