Ruby
Rails

countじゃなくてsizeを使った方がいい!! アソシエーションがLoadされてるか調べて常に高速なメソッドを作ろう!

More than 3 years have passed since last update.

RailsのActiveRecordって、DBの問い合わせ回数を意識し始めると意外と悩むことって多いですよね。

少し意識している人なら、N+1問題を防ぐためにincludes, preload, eagerloadメソッドなどを使っているはずです。

このエントリは、あらかじめLoadされているかで挙動が変わるメソッドを、使ったり、作ったりしたら、eagerloadもより効果的に使えて、ちょっと幸せになれるよという話です。


問題提起:Loadされているかいないか?何が変わるか?

まず、以下のようにpost has_many comments な関係があるとします。


post.rb

class Post < ActiveRecord::Base

has_many :comments
end

ここで、あるPostのインスタンスpostのコメント一覧をとることを考えてみましょう。

もちろん、post.commentsで取れますが、今回は既にloadされているかどうかの挙動も含めて、見てみるためにrails cで以下のようにコマンドを叩いてみます。

[2] pry(main)> post.comments.loaded?

=> false
[3] pry(main)> post.comments
Comment Load (42.4ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = $1 [["post_id", 9759707]]
=> [#<Comment id: 6290480, post_id: 9759707, created_at: "2014-06-26 02:10:37", updated_at: "2014-06-26 02:10:37">]
[4] pry(main)> post.comments.loaded?
=> true
[5] pry(main)> post.comments
=> [#<Comment id: 6290480, post_id: 9759707, created_at: "2014-06-26 02:10:37", updated_at: "2014-06-26 02:10:37">]

ここで、注目して欲しいのが、[3] pry(main)の部分ではDB問い合わせが生じているけれども、2回目のクエリである[5] pry(main)の部分ではDB問い合わせが発生していないという部分です。

つまり、loaded?かどうかによって挙動をrails側が切り替えてくれています。

さて、では、コメントの数を取得したいときはどうでしょう?

もちろん、post.comments.countでできます。

[6] pry(main)> post.comments.count

(0.8ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = $1 [["post_id", 9759707]]
=> 1

注目点は、post.comments.countだと新しいCOUNTを使ったSQLクエリが発生している部分です。

これに対し、to_aしてから数えてあげると、以下のようにDB問い合わせが発生しません。

[7] pry(main)> post.comments.to_a.count

=> 1

これは、post.commentsが既にロードされているので、Array#count

を呼んで配列のサイズを返すことで、to_aすることが結果として高速な問い合わせになっているわけです。

ちなみに[6] pry(main)の時は、ActiveRecord::Associations::CollectionProxycountメソッドが呼ばれています。


実際コードに書くときはどちらを使うべきか?

さて、ということはコメントの数を数えるときはpost.comments.to_a.countの方がいいのか!と言うとそういうわけではありません。

SQL的にはSELECT *よりSELECT COUNT(*)の方が速いので、post.commentsが既にロードされていない時はこのto_aを使った方が遅くなってしまいます。

じゃぁどうするのかといえば、既にロードされているか調べて条件分岐すれば良いわけですね。

つまり、loaded?メソッドを使って、


post.rb

class Post < ActiveRecord::Base

has_many :comments

def comment_count
if comments.loaded?
comments.to_a.count
else
comments.count
end
end
end


というようなのを作って、

post.comment_count

とやれば、良いとこどりをできます。

これが、真価を発揮するのは更にネストした時で、例えば、

user.posts.map(&:comment_count)

とやった時に、User.incldues(post: :comments)と事前にやっているか否かでちゃんと挙動を変えてくれます。

素晴らしい。


もっと良い方法はないのか?

ここまで読んで、まてまて、カウントを取るだけでいちいちメソッドを作るんじゃなくて、post.comments.countcount自体がそういう判断をしてくれればいいんじゃないの?と思う人がいるかも知れません。

まったくもってその通りで、実はそれがActiveRecordのsizeメソッドです。

実はこういった細かい部分でcountメソッドと違うわけです。


ちなみに:loaded?の使い方

loaded?をどういうふうに使うかは多少難しく、具体的にはhas_one, belongs_toの場合は、そのまま使うことができません。

例えば、commentが所属しているpostがLoadされているか調べるとき、

comment.post.loaded?

は、comment.postの時点で、クラスがPostクラスになってしまうため、エラーが出ます。

この場合、

comment.association(:post).loaded?

と、associationメソッドを使って明示的にAssociationにするとうまくいきます。

このassociationメソッドはhas_manyに対しても問題なく動くので、associationメソッドを常に使うようにするという方針もありかもしれないです。


まとめ


  • ActiveRecordでは特に理由がなければcountではなくsizeを使いましょう!


  • loaded?で条件分岐することで、自分で作った少し複雑なDB問い合わせを含むメソッドも、常に最適なクエリを発行することが可能です。積極的に取り入れましょう!

(注) これはRails4の内容で、Rails3の頃は少し違った実装でした。ただ、loadedの概念は以前からあったので同じことはできるはずです。