Help us understand the problem. What is going on with this article?

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)

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

mohikanz
エンジニアのための雑談コミュニティ
https://mohikanz.slack.com
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away