Activerecordにおけるincludesとjoinsの振る舞いについて
Activerecordを使ってるとき、関連(Association)のあるmodel同士をまとめて取得したい時がけっこうある。そんな時、includes
やjoins
を使えば効率良くデータを取得出来るんだけど、実はこの二つは振る舞いや特徴が全然違ってたりする。ややこしい気がしたので、ここでちょっとまとめておく。
先に結論を書いておくと、基本的には
includes
は先読みしてキャッシュしておく。joins
はただINNER JOINしてくれる。
と思っておけばOK。
ちなみに、railsのversionは4.1.0。Web上に落ちてる情報は古いせいか若干現状の挙動とは違ってたりしたので、気をつけた方が良さそう。
includes
includes
はデータの先読みをしてくれる。その為、関連modelに対する参照を持ちたい場合に使う。そう言われてもよくわからないと思うので、実際に使用例を見てみる。
[1] pry(main)> Blog.includes(:tags).each{|blog| p blog.tags.name} Blog Load (90.4ms) SELECT "blogs".* FROM "blogs" Tagging Load (992.6ms) SELECT "taggings".* FROM "taggings" WHERE "taggings"."blog_id" IN (`取得したblogsのID`) Tag Load (71.3ms) SELECT "tags".* FROM "tags" WHERE "tags"."id" IN (`取得したtaggingのid`)
上記の例では、blogs
をincludes(:tags)
した状態で取得している。tableの構造としては、中間tableのtagging
がblog_id
とtag_id
を持っていて、blog
とtag
の間に多対多の関係が出来ている。blog
はtag
の集合としてtags
への参照をもっており、tags
を効率良く取得する為にincludes
が使われている。
ActiveRecord::QueryMethods
の実行結果は、実際に発行されたSQLの形で確認出来る。その結果を見てやると、blogs, taggings, tags
へのsqlの発行がそれぞれ1回ずつ行われている事が分かる。blogs
を取得し、取得したblogs
に相当するtagging
を取得し、さらに取得したtagging
に相当するtag
を取得するといった具合である。
当然の挙動とおもうかも知れないが、includes(:tags)
しなかった場合にはこうはいかない。blog
からtags
への参照が発生するたびに新規のsqlを発行してtagsを取得しようとしてしまうため、いわゆる N+1問題 が発生する。これは大変効率が悪い....!!!
[2] pry(main)> Blog.each{|blog| p blog.tags.name} Blog Load (90.4ms) SELECT "blogs".* FROM "blogs" Tag Load (10.5ms) SELECT "tags".* FROM "tags" INNER JOIN "taggings" ON "tags"."id" = "taggings"."tag_id" WHERE "taggings"."blog_id" = `blogのidのうち一つ` # このtagsへのクエリが取得したblogの数N回だけ発行される!!!
そんな訳で、includes
は別のmodelへの参照が必要となる場合にN+1問題を避ける為に使われる。
joins
joins
の仕組みはめちゃくちゃシンプルで、ただINNER JOINしてくれる。
[109] pry(main)> Blog.joins(:tags) Blog Load (1211.6ms) SELECT "blogs".* FROM "blogs" INNER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id" INNER JOIN "tags" ON "tags"."id" = "taggings"."tag_id"
joins
を使えば、joins先のテーブルのカラムの値を使って絞り込んだり出来る。
[159] pry(main)> Blog.joins(:tags).where(tags: {id: [*1..3]}) Blog Load (4.6ms) SELECT "blogs".* FROM "blogs" INNER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id" AND INNER JOIN "tags" ON "tags"."id" = "taggings"."tag_id" WHERE "tags"."id" IN (1, 2, 3)
注意しなければいけないのは、INNER JOINした結果のレコード1つ1つが1つのオブジェクトにマッピングされて返ってくる事。当然、返ってくるレコードの数は違ってくる。
[150] pry(main)> Blog.all.size (2.1ms) SELECT COUNT(*) FROM "blogs" => 2923 [151] pry(main)> Blog.includes(:tags).all.size (1.9ms) SELECT COUNT(*) FROM "blogs" => 2923 [152] pry(main)> Blog.joins(:tags).all.size (73.0ms) SELECT COUNT(*) FROM "blogs" INNER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id" INNER JOIN "tags" ON "tags"."id" = "taggings"."tag_id" => 38943
blog
のレコード数自体が2923
なのに対して、joins(:tags)
してINNER JOINするとレコード数が38943
まで増えている事が分かる。
それから、joins
したテーブルのカラムにアクセスするには、明示的にselect
をしてやらなければならない。
[154] pry(main)> Blog.joins(:tags).select('blogs.*, tags.name').first.attributes Blog Load (1.8ms) SELECT blogs.*, tags.name FROM "blogs" INNER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id" INNER JOIN "tags" ON "tags"."id" = "taggings"."tag_id" ORDER BY "blogs"."id" ASC LIMIT 1 => {"id"=>1, "title"=>"眠りに恐怖を感じる「睡眠恐怖症」。難病患者の苦しみに見る社会の闇。", "name"=>"VICE.COM ORIGINAL"} [156] pry(main)> Blog.first.attributes Blog Load (0.7ms) SELECT "blogs".* FROM "blogs" ORDER BY "blogs"."id" ASC LIMIT 1 => {"id"=>1, "title"=>"眠りに恐怖を感じる「睡眠恐怖症」。難病患者の苦しみに見る社会の闇。"}
1レコードが1つのオブジェクトにマッピングされるため、取得したオブジェクトのname
プロパティにtag
のname
が格納されているのが特徴的。includes
と違って先読みはしていない為、'tags'への参照はSQLの発行を意味する事にも気をつけなければならない。
ちょっと細かい話。includes + referencesとかincludes + joinsとか
includes
は先読みと書いたが、references
も付ける事でLEFT OUTER JOINにもなる。OUTER JOINは片方にしか存在しないレコードも結果に含めるJOINで、INNER JOINよりも広い範囲でレコードを取得する。
[159] pry(main)> Blog.includes(:tags).references(:tags).where(tags: {id: [*1..3]}).size (2.3ms) SELECT COUNT(DISTINCT "blogs"."id") FROM "blogs" LEFT OUTER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "taggings"."tag_id" WHERE "tags"."id" IN (1, 2, 3) => 97 [160] pry(main)> Blog.includes(:tags).references(:tags).where(tags: {id: [*1..3]}) SQL (4.8ms) SELECT "blogs"."id" AS t0_r0, "blogs"."title" AS t0_r1, "blogs"."published_on" AS t0_r2, "blogs"."priority" AS t0_r3, "blogs"."category_cd" AS t0_r4, "blogs"."description" AS t0_r5, "blogs"."media_id" AS t0_r6, "blogs"."created_at" AS t0_r7, "blogs"."updated_at" AS t0_r8, "blogs"."body" AS t0_r9, "blogs"."cover_image_id" AS t0_r10, "blogs"."url_id" AS t0_r11, "blogs"."hatebu_likes" AS t0_r12, "blogs"."fb_shares" AS t0_r13, "tags"."id" AS t1_r0, "tags"."name" AS t1_r1, "tags"."taggings_count" AS t1_r2 FROM "blogs" LEFT OUTER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "taggings"."tag_id" WHERE "tags"."id" IN (1, 2, 3)
size
でレコード数を見ると、元のincludes
の処理に合わせてblogsの重複を無くした数を返してくれる(その為にDISTINCT
が使われている。)。
joins
の様にJOIN先のテーブルのカラムを使って絞り込む事も出来る。上記の例では、どちらもtags
のid
が[1, 2, 3]に含まれる様なblogs
だけを返している。
先読みの機能も残してくれていて、blog
からtags
への参照も余分なクエリを発行する事無く行える。
[166] pry(main)> Blog.includes(:tags).references(:tags).where(tags: {id: [*1..3]}).each{|blog| p "#{blog.title}, #{blog.tags.first}"} SQL (6.7ms) SELECT "blogs"."id" AS t0_r0, "blogs"."title" AS t0_r1, "blogs"."published_on" AS t0_r2, "blogs"."priority" AS t0_r3, "blogs"."category_cd" AS t0_r4, "blogs"."description" AS t0_r5, "blogs"."media_id" AS t0_r6, "blogs"."created_at" AS t0_r7, "blogs"."updated_at" AS t0_r8, "blogs"."body" AS t0_r9, "blogs"."cover_image_id" AS t0_r10, "blogs"."url_id" AS t0_r11, "blogs"."hatebu_likes" AS t0_r12, "blogs"."fb_shares" AS t0_r13, "tags"."id" AS t1_r0, "tags"."name" AS t1_r1, "tags"."taggings_count" AS t1_r2 FROM "blogs" LEFT OUTER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "taggings"."tag_id" WHERE "tags"."id" IN (1, 2, 3)
元のincludesとjoinsの良いとこどり見たいな感じで、すごい。
ただ、気をつけなければいけないのはlimit
つけた場合で、DISTINCTを使って決められたレコード数のblog
を取得してからOUTER JOINのクエリを発行しようとする。このDISTINCTの処理がなかなか重かったりするので、乱用は良く無いと思った。
[195] pry(main)> Blog.includes(:tags).references(:tags).where(tags: {id: [*100..200]}).limit(100) SQL (25.7ms) SELECT DISTINCT "blogs"."id" FROM "blogs" LEFT OUTER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "taggings"."tag_id" WHERE "tags"."id" IN (`100から200`) LIMIT 100 SQL (31.9ms) SELECT "blogs"."id" AS t0_r0, "blogs"."title" AS t0_r1, "blogs"."published_on" AS t0_r2, "blogs"."priority" AS t0_r3, "blogs"."category_cd" AS t0_r4, "blogs"."description" AS t0_r5, "blogs"."media_id" AS t0_r6, "blogs"."created_at" AS t0_r7, "blogs"."updated_at" AS t0_r8, "blogs"."body" AS t0_r9, "blogs"."cover_image_id" AS t0_r10, "blogs"."url_id" AS t0_r11, "blogs"."hatebu_likes" AS t0_r12, "blogs"."fb_shares" AS t0_r13, "tags"."id" AS t1_r0, "tags"."name" AS t1_r1, "tags"."taggings_count" AS t1_r2 FROM "blogs" LEFT OUTER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "taggings"."tag_id" WHERE "tags"."id" IN (`100から200`) AND "blogs"."id" IN (`取得したblogのid`)
ちなみに、INNER JOINで同じ様な事したかったらincludes+joins
で出来るっぽい。references
使った時との違いは、size
でDISTINCTがかからない事。
[194] pry(main)> Blog.includes(:tags).joins(:tags).where(tags: {id: [*100..200]}).limit(100) SQL (25.9ms) SELECT DISTINCT "blogs"."id" FROM "blogs" INNER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id" INNER JOIN "tags" ON "tags"."id" = "taggings"."tag_id" WHERE "tags"."id" IN (`100から200`) LIMIT 100 SQL (31.6ms) SELECT "blogs"."id" AS t0_r0, "blogs"."title" AS t0_r1, "blogs"."published_on" AS t0_r2, "blogs"."priority" AS t0_r3, "blogs"."category_cd" AS t0_r4, "blogs"."description" AS t0_r5, "blogs"."media_id" AS t0_r6, "blogs"."created_at" AS t0_r7, "blogs"."updated_at" AS t0_r8, "blogs"."body" AS t0_r9, "blogs"."cover_image_id" AS t0_r10, "blogs"."url_id" AS t0_r11, "blogs"."hatebu_likes" AS t0_r12, "blogs"."fb_shares" AS t0_r13, "tags"."id" AS t1_r0, "tags"."name" AS t1_r1, "tags"."taggings_count" AS t1_r2 FROM "blogs" INNER JOIN "taggings" ON "taggings"."blog_id" = "blogs"."id" INNER JOIN "tags" ON "tags"."id" = "taggings"."tag_id" WHERE "tags"."id" IN (`取得されたtagsのid`) [199] pry(main)> Blog.includes(:tags).joins(:tags).where(tags: {id: [*100..200]}).size (23.8ms) SELECT COUNT(*) FROM "blogs" INNER JOIN "taggings" ON "taggings"."blogs_id" = "blogs"."id" INNER JOIN "tags" ON "tags"."id" = "taggings"."tag_id" WHERE "tags"."id" IN (`100から200`) => 74
まとめ
Activerecordでは、関連するモデルを効率良く取得する為の手段としてincludes
やjoins
メソッドが提供されている。ただ参照先を利用したいならincludes
, INNER JOINして絞り込みたいならjoins
, 絞り込みつつ参照先も利用したいならincludes + references
やincludes + joins
を使えば良い。
と言いつつ、一番大事なのは実際に発行されたSQLと処理にかかった時間を見て判断する事だったりするので、ちょっと複雑なSQL書きたい時はきちんと確認する癖をつけると良いのかなと思う。