ActiveRecordでgroupクエリの結果の件数をスマートに取得したいだけ。
SELECT COUNT(*) FROM ( SELECT "users"."id" FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" GROUP BY "users"."id" HAVING (count(1) > 5) )
前提: count vs size vs length
ActiveRecord length vs size vs count でググるとたくさん出てくる。ここではRails v6.1.4のコードで確認した結果をメモする。
RailsのActiveRecordではrelationに対してlength
, size
, count
のいずれでもクエリの結果の件数を取得できるが、挙動に違いがある。
使い分けの結論
- キャッシュを使うなら
size
- キャッシュを使わないなら
count
length
はクエリの結果をloadするので件数取得のためだけには使わない
length
records
にdelegate
されているActiveRecord::Relation#records
はレコードをloadする
def records # :nodoc: load @records end
def load(&block) unless loaded? @records = exec_queries(&block) @loaded = true end self end
つまり、クエリを実行し、その結果として返ってきたArray
のArray#length
を呼ぶ
count
ActiveRecord::Relation
がinclude
しているActiveRecord::Relation::Calculations
の#count
- relationが
loaded?
かどうかはチェックせずにSQLのCOUNT
をDBに発行する
size
- クエリの結果が
loaded?
かどうかで挙動が変わる - https://github.com/rails/rails/blob/v6.1.4/activerecord/lib/active_record/relation.rb#L259-L261
def size loaded? ? @records.length : count(:all) end
loadされていたらキャッシュしている@records
のArray#length
を呼ぶ
loadされていなければActiveRecord::Relation
がinclude
しているActiveRecord::Relation::Calculations
の#count
を呼ぶ
2015年のActive Record の size vs count vs length をコードレベルで見てみた - Qiitaの頃と比べるとだいぶActiveRecord::Relation
のコードがすっきりしていた。
group byの結果の件数を得る場合
group byするとHash
が返る
上記の前提はrelationでgroup
を使っていない(grouped_values
が存在しない)場合の話。
ActiveRecord::QueryMethods#group
を呼んだあとに(size
やcount
経由で)ActiveRecord::Relation::Calculations#count
を呼ぶと、primitiveな数値ではなくHash
が返るようになる。
ここで分岐してexecute_grouped_calculation
が実行され、結果はHash
となるため。
困るケース
たいてい便利なのだが、group byしたあとに欲しいのがHash
ではなく、プリミティブなInteger
なこともある。
たとえば、group byをグループ集計のためでなく絞り込みのためだけに使っているケースなど。
class User < ActiveRecord::Base has_many :posts end class Post < ActiveRecord::Base belongs_to :user end
User has_many Posts
という関係のときに、5件以上のPosts
を持つUser
の件数が知りたい。
> User.joins(:posts).group(:id).having('count(1) > 5').count => {1=>8, 6=>8, 9=>10}
-- 実行されるSQL SELECT COUNT(*) AS count_all, "users"."id" AS users_id FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" GROUP BY "users"."id" HAVING (count(1) > 5)
そういうときは返ってきたHash
に対してHash#size
やHash#length
を呼べばいいだけなのだが…
> User.joins(:posts).group(:id).having('count(1) > 5').count.size => 3
ActiveRecord::Relation#size
の呼び出しが外部モジュールで、結果をコントロールできないケース。
relation = User.joins(:posts).group(:id).having('count(1) > 5') # このモジュール内で`count`が呼ばれる ExternalModule.awesome_method(relation)
できないというと嘘で、モンキーパッチなどの回避策はあるが、当面は自分側のモジュールだけで解決したく、group byのクエリをサブクエリとしてラップした新規のrelationを作って渡すことで回避した。
group_query = User.joins(:posts).group(:id).having('count(1) > 5') relation = User.where(id: group_query) ExternalModule.awesome_method(relation)
-- 実行されるSQL SELECT COUNT(*) FROM "users" WHERE "users"."id" IN ( SELECT "users"."id" FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" GROUP BY "users"."id" HAVING (count(1) > 5) )
まぁ、本当に欲しいSQLは以下なのだけど。
SELECT COUNT(*) FROM ( SELECT "users"."id" FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" GROUP BY "users"."id" HAVING (count(1) > 5) )
環境
- activerecord v6.1.4