patorashのブログ

方向性はまだない

has_manyの最新のデータをhas_oneで関連付けする方法

元ネタはこのQiitaの投稿。

qiita.com

この投稿のように、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がこういうふうに使えるのは便利です!