元ネタはこのQiitaの投稿。
この投稿のように、UserモデルとArticleモデルが1対多になっていて、ユーザーに紐づいた最新の記事を取得したいこととかはあると思います。私がやってるプロジェクトでも似たようなことがありました。ユーザーに紐づいたデータがバージョン管理されていて、最新のが欲しいとき、とか…。
N+1が起きるコード
以下のようなModelがあったとします。
class User < ApplicationRecord has_many :articles, dependent: :destroy end
class Article < ApplicationRecord belongs_to :user end
これで最新の記事を取るためにメソッドを定義したら、こうなります。
class User < ApplicationRecord has_many :articles, dependent: :destroy def latest_article articles.order(id: :desc).first end end
これをViewでループすると、残念なことにN+1問題が発生します。
<ul> <% @users.each do |user| %> <li><%= link_to user.latest_article.title, user.latest_article %></li> <% end %> </ul>
Userが3名いて、記事が登録済みの場合、このようなログが出ました。
Started GET "/users/" for 127.0.0.1 at 2020-10-31 02:58:25 +0900 Processing by UsersController#index as HTML Rendering users/index.html.erb within layouts/application User Load (1.2ms) SELECT "users".* FROM "users" â³ app/views/users/index.html.erb:6 Article Load (0.9ms) SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ? ORDER BY "articles"."id" DESC LIMIT ? [["user_id", 1], ["LIMIT", 1]] â³ app/models/user.rb:7:in `latest_article' CACHE Article Load (0.0ms) SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ? ORDER BY "articles"."id" DESC LIMIT ? [["user_id", 1], ["LIMIT", 1]] â³ app/models/user.rb:7:in `latest_article' Article Load (0.1ms) SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ? ORDER BY "articles"."id" DESC LIMIT ? [["user_id", 2], ["LIMIT", 1]] â³ app/models/user.rb:7:in `latest_article' CACHE Article Load (0.0ms) SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ? ORDER BY "articles"."id" DESC LIMIT ? [["user_id", 2], ["LIMIT", 1]] â³ app/models/user.rb:7:in `latest_article' Article Load (0.2ms) SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ? ORDER BY "articles"."id" DESC LIMIT ? [["user_id", 3], ["LIMIT", 1]] â³ app/models/user.rb:7:in `latest_article' CACHE Article Load (0.0ms) SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ? ORDER BY "articles"."id" DESC LIMIT ? [["user_id", 3], ["LIMIT", 1]] â³ app/models/user.rb:7:in `latest_article' Rendered users/index.html.erb within layouts/application (Duration: 87.8ms | Allocations: 17016)
ここではNが3のため、3+1=4回のクエリが発行されていることが確認できました。
latest_articleをhas_oneで定義
では、見出しの通りにhas_oneにしていきます。
class User < ApplicationRecord has_many :articles, dependent: :destroy has_one :latest_article, -> { where(id: Article.group(:user_id).select('MAX(id)')) }, class_name: 'Article' end
肝は、whereのサブクエリで外部キーであるuser_idでGROUP BYを行い、その最新の記事IDを取るためにMAX(id)
をしているところです。これで、取得されるのはユーザー毎の最新の記事のみになります。
こうなると、メソッドではなくリレーションになったため、preloadなどが使えるようになります。
使ってみる
では、Controllerでpreloadを使ってみます。
class UsersController < ApplicationController def index @users = User.all.preload(:latest_article) end end
Viewは変わりません。
<ul> <% @users.each do |user| %> <li><%= link_to user.latest_article.title, user.latest_article %></li> <% end %> </ul>
しかし、発行されるSQLは2回になっています!
Started GET "/users/" for 127.0.0.1 at 2020-10-31 03:06:53 +0900 Processing by UsersController#index as HTML Rendering users/index.html.erb within layouts/application User Load (0.6ms) SELECT "users".* FROM "users" â³ app/views/users/index.html.erb:6 Article Load (2.2ms) SELECT "articles".* FROM "articles" WHERE "articles"."id" IN (SELECT max(id) FROM "articles" GROUP BY "articles"."user_id") AND "articles"."user_id" IN (?, ?, ?) [["user_id", 1], ["user_id", 2], ["user_id", 3]] â³ app/views/users/index.html.erb:6 Rendered users/index.html.erb within layouts/application (Duration: 56.3ms | Allocations: 11242)
eager_loadを使えば1回でも済みますが、どういうクエリが発行されているかを確認しやすいのでpreloadにしてみました。
has_oneがこういうふうに使えるのは便利です!