ActiveRecordでScoped preloading

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

# tl;dr

- many to many 関連でwhere句で絞りこんでpreloadしたい
- `ActiveRecord::Associations::Preloader.new.preload`を使えばできる
- Decorator使うときちょっと注意

# モデル

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

- User: エンドユーザ
- Article: タイトルと本文があるようなやつ
- UserReadArticle: ユーザの既読済みの記事

ブログサイトであるようなようなモデルを簡略化したものを想定してます。
ユーザが記事一覧を表示するときに、記事が未読であればなんらかマークを表示する、
みたいなケースを考えてみます。

## migration

以下のような感じ。

```ruby: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
```

```ruby: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
```

```ruby: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になってくれればよいわけです。

```sql
SELECT id, title, created_by_id, created_at
FROM articles ORDER BY created_at DESC
LIMIT 10 OFFSET 0
```

上記で取得したarticle.idのリストで以下を実行。

```sql
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`が[あります](https://qiita.com/k0kubun/items/80c5a5494f53bb88dc58)が今回の場合、`user_read_articles`で`user_id`を指定したいので考えないといけません。

[こちらの記事](https://qiita.com/k0kubun/items/80c5a5494f53bb88dc58)で紹介されている通り、`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](https://apidock.com/rails/ActiveRecord/QueryMethods/preload)をみると、scopeを引数に渡せるようにはなっていません。

そこで`ActiveRecord::Associations::Preloader.new.preload`の登場です。
[API Doc](https://apidock.com/rails/ActiveRecord/Associations/Preloader/preload)をみると、第3引数に`preload_scope`を渡せるようになっています。
これを使うとよいようです。

```ruby: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](https://github.com/drapergem/draper)を作ってみます。

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

```ruby: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](https://github.com/drapergem/draper/issues/538)にありますが、`.decorate`時にはcacheを使わずにreloadするとのこと。
これを回避するには`decorate_collection`を使います。

よって以下のように修正。

```diff
-- @articles = articles.decorate
++ @articles = ArticleDecorator.decorate_collection(articles)
```

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