LoginSignup
13

More than 5 years have passed since last update.

ActiveRecordでScoped preloading

Last updated at Posted at 2018-03-12

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)

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13