valid,invalid

関心を持てる事柄について

ActiveRecordのgroupクエリの結果の件数取得、count vs size vs length添え

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のコードで確認した結果をメモする。

RailsActiveRecordではrelationに対してlength, size, countのいずれでもクエリの結果の件数を取得できるが、挙動に違いがある。

使い分けの結論

  • キャッシュを使うならsize
  • キャッシュを使わないならcount
  • lengthはクエリの結果をloadするので件数取得のためだけには使わない

length

def records # :nodoc:
  load
  @records
end
 def load(&block)
   unless loaded?
     @records = exec_queries(&block)
     @loaded = true
   end

   self
 end

つまり、クエリを実行し、その結果として返ってきたArrayArray#lengthを呼ぶ

count

  • ActiveRecord::RelationincludeしているActiveRecord::Relation::Calculations#count
  • relationがloaded?かどうかはチェックせずにSQLCOUNTをDBに発行する

size

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

loadされていたらキャッシュしている@recordsArray#lengthを呼ぶ

loadされていなければActiveRecord::Relationincludeしている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を呼んだあとに(sizecount経由で)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#sizeHash#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)
  )

環境

関連