ActiveRecord::Associations::Preloader.new.preload
のメモ
tl;dr
- many to many 関連でwhere句で絞りこんでpreloadしたい
-
ActiveRecord::Associations::Preloader.new.preload
を使えばできる - Decorator使うときちょっと注意
モデル
以下の3つのモデルを考える
- User: エンドユーザ
- Article: タイトルと本文があるようなやつ
- UserReadArticle: ユーザの既読済みの記事
ブログサイトであるようなようなモデルを簡略化したものを想定してます。
ユーザが記事一覧を表示するときに、記事が未読であればなんらかマークを表示する、
みたいなケースを考えてみます。
migration
以下のような感じ。
class CreateUsers < ActiveRecord::Migration[5.2]
def change
create_table :users, id: :uuid do |t|
t.string :name, null: false
t.string :email, null: false, index: :unique
t.string :password_digest, null: false
t.timestamps
end
end
end
class CreateArticles < ActiveRecord::Migration[5.2]
def change
create_table :articles, id: :uuid do |t|
t.string :title, null: false
t.text :body, null: false
t.references :created_by, type:uuid, null: false, foreign_key: {to_table: :users}
t.timestamps
end
add_index :created_at, order: {created_at: :desc}
end
end
class CreateUserReadArticles < ActiveRecord::Migration[5.2]
def change
create_table :user_read_articles, id: :uuid do |t|
t.references :user, type: :uuid, null: false, foreign_key: true, index: false
t.references :article, type: :uuid, null: false, foreign_key: true, index: false
t.datetime :created_at, null: false
t.index %i[user_id article_id], unique: true, name: :
\index_user_read_artilces
end
end
end
目的のSQL
SQLとしてはN+1を回避するために以下のような2つのSELECTを実行して、いい感じにActiveRecordになってくれればよいわけです。
SELECT id, title, created_by_id, created_at
FROM articles ORDER BY created_at DESC
LIMIT 10 OFFSET 0
上記で取得したarticle.idのリストで以下を実行。
SELECT article_id FROM user_read_articles
WHERE user_id=:user_id AND article_id IN (:article_ids)
Eager loading
Eager loadingのやり方には、includes
, eager_load
, preload
, joins
がありますが今回の場合、user_read_articles
でuser_id
を指定したいので考えないといけません。
こちらの記事で紹介されている通り、includes
, eager_load
, joins
はOUTER JOINかINNER JOINになって、user_read_article.user_id
をwhereにいれてしまうと、絞り込みで既読記事しかでてきません。
記事の絞り込みは行わず(最新10件のみ)、user_read_articles
のSELECTだけ絞りこむ必要があります。
やりたいのは、preload
でarticle_id
だけでなく、user_id
も指定したい。
Preloadのオプション
preloadのAPI Docをみると、scopeを引数に渡せるようにはなっていません。
そこでActiveRecord::Associations::Preloader.new.preload
の登場です。
API Docをみると、第3引数にpreload_scope
を渡せるようになっています。
これを使うとよいようです。
def index(page: 1)
@articles = Artile.sort_by_recently_created
.page(page).per(10)
# scoped preload
ActiveRecord::Associations::Preloader.new.preload(
@articles,
:user,
ReadArtile.where(user: current_user)
)
end
こんな感じでやるとarticles.read_users
にいい感じでcacheしてくれます。
おまけ: Decorator
viewで使い易くするためにDecoratorを作ってみます。
class ArticleDecorator < Draper::Decorator
delegate :id, :title, :body
def unread?
object.read_users.empty?
end
def created_at
object.created_at.strftime('%Y/%m/%d')
end
end
これでarticle.unread?
未読かどうかの分岐ができますね。
さっきのcontrollerをdecoratorを使うように書きかえます。
def index(page: 1)
articles = Artile.sort_by_recently_created
.page(page).per(10)
# scoped preload
ActiveRecord::Associations::Preloader.new.preload(
articles,
:read_users,
ReadArtile.where(user: current_user)
)
@articles = articles.decorate
end
ActiveRecord::Associations::Preloader.new.preload
の引数にdecorateしたarticlesは渡せないので、一時変数を利用します。
これでview側でarticle.unread?
を呼びだすと、なぜかpreloadしたcacheを使ってくれず、N+1になってしまいます。。
これはDecoratorのissueにありますが、.decorate
時にはcacheを使わずにreloadするとのこと。
これを回避するにはdecorate_collection
を使います。
よって以下のように修正。
-- @articles = articles.decorate
++ @articles = ArticleDecorator.decorate_collection(articles)
これで負荷に優しいクエリを書けました。