ActiveRecord
Rails5

ActiveRecordでScoped preloading

More than 1 year has passed since last update.

ActiveRecord::Associations::Preloader.new.preloadのメモ


tl;dr


  • many to many 関連でwhere句で絞りこんでpreloadしたい


  • ActiveRecord::Associations::Preloader.new.preloadを使えばできる

  • Decorator使うときちょっと注意


モデル

以下の3つのモデルを考える


  • User: エンドユーザ

  • Article: タイトルと本文があるようなやつ

  • UserReadArticle: ユーザの既読済みの記事

ブログサイトであるようなようなモデルを簡略化したものを想定してます。

ユーザが記事一覧を表示するときに、記事が未読であればなんらかマークを表示する、

みたいなケースを考えてみます。


migration

以下のような感じ。


create_users.rb

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



create_messages.rb

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



create_user_read_articles.rb

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_articlesuser_idを指定したいので考えないといけません。

こちらの記事で紹介されている通り、includes, eager_load, joinsはOUTER JOINかINNER JOINになって、user_read_article.user_idをwhereにいれてしまうと、絞り込みで既読記事しかでてきません。

記事の絞り込みは行わず(最新10件のみ)、user_read_articlesのSELECTだけ絞りこむ必要があります。

やりたいのは、preloadarticle_idだけでなく、user_idも指定したい。


Preloadのオプション

preloadのAPI Docをみると、scopeを引数に渡せるようにはなっていません。

そこでActiveRecord::Associations::Preloader.new.preloadの登場です。

API Docをみると、第3引数にpreload_scopeを渡せるようになっています。

これを使うとよいようです。


articles_controller.rb

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を作ってみます。


article_decorator.rb

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を使うように書きかえます。


articles_controller.rb

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)

これで負荷に優しいクエリを書けました。