【Django】prefetch_related の挙動を理解する

# Djangoのprefetch_relatedについて
prefetch_relatedはDjangoのN+1問題を回避するための機能です。
select_relatedと並び重要なメソッドですが、理解が曖昧な部分があったので整理したいと思います。

## DjangoのSQLが実行されるタイミングについて
DjangoがDBを叩きにいくタイミングについて確認しておきます。

### 1.メソッドが呼ばれたタイミング
いくつかのメソッドはクエリセットは返さずに、呼び出されたタイミングでDBを叩きに行きます。
代表的なものをあげておきます。
| メソッド名 | 機能 |
| ---- | ---- |
| first() | はじめの一つを取得する。返す値がない場合は None を返す |
| last() | 最後の一つを取得する。対象がない場合は None を返す |
| get() | 一つのオブジェクトを返す。対象が1つ以外の場合は models.DoesNotExists, models.MultipleObjectReturned エラーが起きる|
| count() | SQLのCOUNT(*)。対象の列数を返す |
| exists() | SQLのEXISTS()。対象が存在するかどうかをBooleanで返す|

### 2. クエリが評価されたタイミング
一方で、多くのメソッドはクエリセットを返すだけで、呼び出されたタイミングではDBを叩きに行きません。発行されたクエリがDBを叩くきに行くのは「評価された」タイミングです。
以下のメソッドはその一例です。
|メソッド名|機能|
| ---- | ---- |
| all() | 全てを取得する |
| filter(**kwargs) | kwargsの条件に一致する列を返す |
| exclude(**kwargs) | kwargsの条件に一致しない列を返す |
| values(*args) | 受け取ったフィールドのみを取得するクエリを発行します |

上の説明で使った「評価される」というのは、例えば
1. ループなど、イテレーターとして実行される
2. python のスライスをステップ[::]で実行する
3. len() や bool() に渡す
4. ifなどの条件式として評価する

などの状況をさします。
そのほかにもいくつかの条件が[公式ドキュメント](https://docs.djangoproject.com/en/4.0/ref/models/querysets/)に載っていますが、ここでは省略します。

## prefetch_related の挙動
`prefetch_related`も評価されたタイミングでDBを検索しにいくメソッドです。
以下のようなモデル例に説明していきます。
```python:db_test/models.py
class Author(models.Model):
    name = models.CharField(max_length=50)


class Book(models.Model):
    title = models.CharField(max_length=100)
    author = models.ForeignKey(
        Author, related_name='books', on_delete=models.CASCADE)

```

### prefetch_relatedはDBを2回叩く
prefetch_relatedが付いたクエリセットを評価すると、DBが2回叩かれます。
```python
authors = Author.objects.prefetch_related('books').filter(id__lt=3) # まだ評価されていないのでDBはアクセスされない
for author in authors: # ループに使われたのでDBが叩かれる
    titles = [book.title for book in author.books.all()]
```
この時、Djangoは以下のようなSQLを2回実行しにいきます。
```sql
-- クエリ1
SELECT `db_test_author`.`id`, `db_test_author`.`name` FROM `db_test_author` WHERE  `db_test_author`.`id` < 3;

-- クエリ2
SELECT `db_test_book`.`id`, `db_test_book`.`title`, `db_test_book`.`author_id` FROM `db_test_book` WHERE `db_test_book`.`author_id` IN (1, 2);
```
prefetch_related()では、事前にAuthorに紐づいたBookを取得してキャッシュしておきます。そうすることで、N+1を回避しています。
↓もしprefetch_related()を使わなかった場合
```sql
-- クエリ1
SELECT `db_test_author`.`id`, `db_test_author`.`name` FROM `db_test_author` WHERE  `db_test_author`.`id` < 3;

-- クエリ2
SELECT `db_test_book`.`id`, `db_test_book`.`title`, `db_test_book`.`author_id` FROM `db_test_book` WHERE `db_test_book`.`author_id` IN (1);

-- クエリ3
SELECT `db_test_book`.`id`, `db_test_book`.`title`, `db_test_book`.`author_id` FROM `db_test_book` WHERE `db_test_book`.`author_id` IN (2);
```

### prefetch_related　がうまく機能しない場合
しかし、このprefetch_related()ではうまく機能しない場合があります。
それは、all()意外のメソッドを呼び出す時です。
```python
authors = Author.objects.all().prefetch_related('books')
for author in authors:
    titles = [book.title for book in author.books.filter(title__startswith='hoge')]
```
これは残念ながらN+1回SQLが発行されてしまいます。
filter()などのメソッドはあくまでもDBを叩くためのものです。そのため、filter()での絞り込みは事前に取得してキャッシュしたものは使われず、あらたにDBから取得してしまいます。
これに対応するためには、Prefetchオブジェクトを使用する必要があります。
```python
from django.db.models import Prefetch
authors = Author.objects.all().preftch_related(
    Prefetch('books',
             queryset=Book.objects.filter(title__startswith='hoge'),
             to_attr='hoge_books'
    )
)
for author in authors:
    titles = [book.title for book in author.hoge_books]
```

Prefech オブジェクトを使用することで、all() 意外の結果のも効率よく取得することができます。