Rails6 のちょい足しな新機能を試す93（ActiveRecord extract_associated編）

# はじめに

Rails 6 に追加された新機能を試す第93段。 今回は、 `ActiveRecord extract_associated` 編です。
Rails 6 では、 `preload(xxx).collect(xxx)` の short hand である `extract_associated` が追加されました。

Ruby 2.6.4, Rails 6.0.0 で確認しました。

```shell
$ rails --version
Rails 6.0.0
```

# プロジェクトを作る

```shell
$ rails new rails_sandbox
$ cd rails_sandbox
```

今回は Author モデル と Book モデルを作って rails console で確認してみます。

# Author モデルを作る

`name` の属性を持つ `Author` モデルを作ります。

```shell
$ bin/rails g model Author name
```

# Book モデルを作る

```shell
$ bin/rails g model Book title author:references
```

# Author モデルを変更する

`has_many` を追加します。

```ruby:app/models/author.rb
class Author < ApplicationRecord
  has_many :books, dependent: :destroy
end
```

# seed データを作る

seed データを作ります。

```ruby:db/seeds.rb
Author.create(
  [
    {
      name: 'Dave Thomas',
      books: Book.create(
        [
          { title: 'Pragmatic Programmer' },
          { title: 'Programming Ruby' },
          { title: 'Agile Web Development with Rails 6' },
          { title: 'Agile Web Development with Rails 5.1' }
        ]
      )
    },
    {
      name: 'Yukihiro Matsumoto',
      books: Book.create(
        [
          { title: 'The Ruby Programming Language' }
        ]
      )
    },
    {
      name: 'Mark Lutz',
      books: Book.create(
        [
          { title: 'Programming Python' }
        ]
      )
    }
  ]
)
```

# Book モデルを変更する

Book モデルに scope を追加します。
`title` に "Ruby" がつく Book を検索する scope `ruby` と `title` に "Rails" がつく Book を検索する scope `rails` を追加します。(共通の処理を `with_title` で別の scope として切り出しました。)

```ruby:app/models/book.rb
class Book < ApplicationRecord
  belongs_to :author

  scope :with_title, ->(title) { where('title like ?', "%#{title}%") }
  scope :ruby, -> { with_title('Ruby') }
  scope :rails, -> { with_title('Rails') }
end
```

# rails console を実行する

rails console を使って確認してみます。

Rubyの本を書いた著者を検索してみます。 `extract_associated` を使ってみます。

```irb
irb(main):001:0> Book.ruby.extract_associated(:author)
  Book Load (0.4ms)  SELECT "books".* FROM "books" WHERE (title like '%Ruby%')
  Author Load (0.5ms)  SELECT "authors".* FROM "authors" WHERE "authors"."id" IN ($1, $2)  [["id", 10], ["id", 11]]
=> [#<Author id: 10, name: "Dave Thomas", created_at: "2019-09-27 05:13:39", updated_at: "2019-09-27 05:13:39">, #<Author id: 11, name: "Yukihiro Matsumoto", created_at: "2019-09-27 05:13:39", updated_at: "2019-09-27 05:13:39">]
```

Dave Thomas と Yukihiro Matsumoto の2人が検索できました。


Rails の本を書いた著者を検索してみます。

```irb
irb(main):002:0> Book.rails.extract_associated(:author)
  Book Load (1.0ms)  SELECT "books".* FROM "books" WHERE (title like '%Rails%')
  Author Load (0.6ms)  SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1  [["id", 10]]
=> [#<Author id: 10, name: "Dave Thomas", created_at: "2019-09-27 05:13:39", updated_at: "2019-09-27 05:13:39">, #<Author id: 10, name: "Dave Thomas", created_at: "2019-09-27 05:13:39", updated_at: "2019-09-27 05:13:39">]
```

Dave Thomas のレコードが2件になりました。

`distinct` が使えると良いのですが、 `extract_associated` は、 Array を返すのでそうはいかないです。

```irb
rb(main):003:0> Book.rails.extract_associated(:author).distinct
  Book Load (0.9ms)  SELECT "books".* FROM "books" WHERE (title like '%Rails%')
  Author Load (0.6ms)  SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1  [["id", 10]]
Traceback (most recent call last):
        1: from (irb):3
NoMethodError (undefined method `distinct' for #<Array:0x000055e16560a1c0>)
```

`uniq` を使えば、1件にはなります。

```irb
irb(main):004:0> Book.rails.extract_associated(:author).uniq
  Book Load (1.0ms)  SELECT "books".* FROM "books" WHERE (title like '%Rails%')
  Author Load (0.6ms)  SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1  [["id", 10]]
=> [#<Author id: 10, name: "Dave Thomas", created_at: "2019-09-27 05:13:39", updated_at: "2019-09-27 05:13:39">]
```

ですが、Array として全部メモリに読み込まれてから、 `uniq` で処理することになるのが少し気になるところです。(データが多くなるとメモリ不足になりそう。)


# 別の方法

`extract_associated` を試すという目的からは、少しずれてしまいますが、別の方法も考えてみます。

## Author に scope を追加する

Author に以下のように scope を追加します。

```ruby:app/models/author.rb
class Author < ApplicationRecord
  has_many :books, dependent: :destroy

  scope :of_books_with, ->(title) { joins(:books).where('books.title like ?', "%#{title}%") }
  scope :of_ruby_books, -> { of_books_with('Ruby') }
  scope :of_rails_books, -> { of_books_with('Rails') }
end
```

rails console で試してみます。今度は、distinct が使えます。

```irb
irb(main):019:0> Author.of_rails_books.distinct
  Author Load (1.2ms)  SELECT DISTINCT "authors".* FROM "authors" INNER JOIN "books" ON "books"."author_id" = "authors"."id" WHERE (books.title like '%Rails%') LIMIT $1  [["LIMIT", 11]]
=> #<ActiveRecord::Relation [#<Author id: 10, name: "Dave Thomas", created_at: "2019-09-27 05:13:39", updated_at: "2019-09-27 05:13:39">]>
```

## Book で絞り込んでから Author を検索する

Book で絞り込んでから、author_id を求め、その条件で、Author を検索します。

```irb
irb(main):020:0> Author.where(id: Book.rails.select(:author_id))
  Author Load (1.2ms)  SELECT "authors".* FROM "authors" WHERE "authors"."id" IN (SELECT "books"."author_id" FROM "books" WHERE (title like '%Rails%')) LIMIT $1  [["LIMIT", 11]]
=> #<ActiveRecord::Relation [#<Author id: 10, name: "Dave Thomas", created_at: "2019-09-27 05:13:39", updated_at: "2019-09-27 05:13:39">]>
```

実行されるSQLに `DISTINCT` は含まれませんが、検索結果は、Dave Thomas が1人です。


# 試したソース

試したソースは以下にあります。
https://github.com/suketa/rails_sandbox/tree/try093_extract_associated

# 参考情報

* [What is new in Rails 6.0](https://bogdanvlviv.com/posts/ruby/rails/what-is-new-in-rails-6_0.html)
* [Add `ActiveRecord::Relation#extract_associated` for extracting associated record](https://github.com/rails/rails/pull/35784)