RailsのActiveRecordって、DBの問い合わせ回数を意識し始めると意外と悩むことって多いですよね。
少し意識している人なら、N+1問題を防ぐためにincludes
, preload
, eagerload
メソッドなどを使っているはずです。
このエントリは、あらかじめLoadされているかで挙動が変わるメソッドを、使ったり、作ったりしたら、eagerloadもより効果的に使えて、ちょっと幸せになれるよという話です。
問題提起:Loadされているかいないか?何が変わるか?
まず、以下のようにpost
has_many comments
な関係があるとします。
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::CollectionProxyのcount
メソッドが呼ばれています。
実際コードに書くときはどちらを使うべきか?
さて、ということはコメントの数を数えるときはpost.comments.to_a.count
の方がいいのか!と言うとそういうわけではありません。
SQL的にはSELECT *
よりSELECT COUNT(*)
の方が速いので、post.comments
が既にロードされていない時はこのto_a
を使った方が遅くなってしまいます。
じゃぁどうするのかといえば、既にロードされているか調べて条件分岐すれば良いわけですね。
つまり、loaded?
メソッドを使って、
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.count
のcount
自体がそういう判断をしてくれればいいんじゃないの?と思う人がいるかも知れません。
まったくもってその通りで、実はそれが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の概念は以前からあったので同じことはできるはずです。