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