はじめに
has_manyなどで関連付けされているモデルで特定の条件の関連レコードを取得する際に安易にメソッドを定義したりしていませんか?
たとえば
# ユーザー
class User < ApplicationRecord
has_many :articles
end
# 記事
class Article < ApplicationRecord
belongs_to :user
end
という関係があった場合、最新の記事を取得するのに
def latest_article
articles.order(created_at: :desc).first
end
などというメソッドを安易に定義しがちではないかと思います。
メソッドの場合
これが一つのviewで一度しか呼び出されないのならば特に問題はないかと思います。
でもリスト表示などの場合、
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
@users = User.all
end
end
# index.html.erb
<% @users.each do |user| %>
<%= user.name %>
<%= user.latest_article.title %>
<% end %>
のようなviewを表示するのに
User Load (0.5ms) SELECT `users`.* FROM `users`
Article Load (0.4ms) SELECT `articles`.* FROM `articles` WHERE `articles`.`user_id` = 1 ORDER BY `articles`.`created_at` DESC LIMIT 1
Article Load (0.4ms) SELECT `articles`.* FROM `articles` WHERE `articles`.`user_id` = 2 ORDER BY `articles`.`created_at` DESC LIMIT 1
Article Load (0.3ms) SELECT `articles`.* FROM `articles` WHERE `articles`.`user_id` = 3 ORDER BY `articles`.`created_at` DESC LIMIT 1
とユーザーの数だけArticleをロードするようになります。
さらに
# index.html.erb
<% @users.each do |user| %>
<%= user.name %>
<%= user.latest_article.title %>
<%= user.latest_article.body %>
<% end %>
などのようにしようものなら、
User Load (0.3ms) SELECT `users`.* FROM `users`
Article Load (0.4ms) SELECT `articles`.* FROM `articles` WHERE `articles`.`user_id` = 1 ORDER BY `articles`.`created_at` DESC LIMIT 1
CACHE Article Load (0.0ms) SELECT `articles`.* FROM `articles` WHERE `articles`.`user_id` = 1 ORDER BY `articles`.`created_at` DESC LIMIT 1 [["user_id", 1], ["LIMIT", 1]]
Article Load (0.3ms) SELECT `articles`.* FROM `articles` WHERE `articles`.`user_id` = 2 ORDER BY `articles`.`created_at` DESC LIMIT 1
CACHE Article Load (0.0ms) SELECT `articles`.* FROM `articles` WHERE `articles`.`user_id` = 2 ORDER BY `articles`.`created_at` DESC LIMIT 1 [["user_id", 2], ["LIMIT", 1]]
Article Load (0.5ms) SELECT `articles`.* FROM `articles` WHERE `articles`.`user_id` = 3 ORDER BY `articles`.`created_at` DESC LIMIT 1
CACHE Article Load (0.0ms) SELECT `articles`.* FROM `articles` WHERE `articles`.`user_id` = 3 ORDER BY `articles`.`created_at` DESC LIMIT 1
キャッシュを使っていますがuser.latest_article
がメモ化されるわけでもなく、その都度Articleを探しに行ってしまいます。
has_oneを利用した場合
そこでメソッドでなく以下のように関連付けで定義してみると
has_one :latest_article, lambda {
order(created_at: :desc)
}, class_name: :Article
# index.html.erb
<% @users.each do |user| %>
<%= user.name %>
<%= user.latest_article.title %>
<%= user.latest_article.body %>
<% end %>
User Load (0.4ms) SELECT `users`.* FROM `users`
Article Load (0.3ms) SELECT `articles`.* FROM `articles` WHERE `articles`.`user_id` = 1 ORDER BY `articles`.`created_at` DESC LIMIT 1
Article Load (0.4ms) SELECT `articles`.* FROM `articles` WHERE `articles`.`user_id` = 2 ORDER BY `articles`.`created_at` DESC LIMIT 1
Article Load (0.3ms) SELECT `articles`.* FROM `articles` WHERE `articles`.`user_id` = 3 ORDER BY `articles`.`created_at` DESC LIMIT 1
view内では同じく二度latest_articleが呼ばれているのですが、ArticleをLoadするのは一度づつに減っています。
さらにコントローラーでpreloadしてやると
class HomeController < ApplicationController
def index
@users = User.preload(:latest_article).all
end
end
User Load (2.1ms) SELECT `users`.* FROM `users`
Article Load (0.3ms) SELECT `articles`.* FROM `articles` WHERE `articles`.`user_id` IN (1, 2, 3) ORDER BY `articles`.`created_at` DESC
とArticleをLoadするのは一度だけになりました。
結論
has_manyなどで既に関連付けされているモデルから特定の条件でレコードを取得する際に安易にメソッドやviewから関連モデルを呼び出しがちですが、条件を付加したhas_oneやhas_manyで定義することでパフォーマンスにもお財布にも優しいコードになるかと思います。