【Django】annotateを使って高度なorder_byを書く方法

Djangoの`.all()`や`.filter()`で値を取得した結果得られるオブジェクトは、DjangoのQueryset型になっています。

`.order_by()`一発で並べ替えることが出来る単純なソートなら良いですが、値に応じて順番を変えるなど少し凝ったことをする場合はfor文で回す必要があり、コードもやや煩雑になるので、できればレコード取得と同時に`.order_by()`で並べ替えを完了させておきたい所です。そんな時に、`.annotate()`を用いることでより高度な並べ替えを行う方法を紹介したいと思います。

※ querysetの基本的な扱いを理解していることを前提とした記事になっているので、`.all()`や`.filter()`、`.order_by()`などが分からない方は先に下記の記事などを参照していただければ幸いです。

参考:
[Django逆引きチートシート（QuerySet編）](https://qiita.com/uenosy/items/54136aff0f6373957d22)

# annotateとは

「annotate」とは、「注釈をつける」という意味を持った英単語です。使用方法としては、他のクエリメソッド同様、レコード取得の後に`.`で連結する形になります。

```python
Book.objects.all().annotate(new_field="value")
```

これを使用すれば、**「モデルが持っているフィールドに加えて、こちらで指定したフィールドを追加で出力する」**ということができるようになります。

ちなみに、docstringには下記のような説明が書かれています。

```text
Return a query set in which the returned objects have been annotated with extra data or aggregations.
(返されたオブジェクトが、追加データや集約でアノテーションされているクエリセットを返す)
```
参照: https://github.com/django/django/blob/master/django/db/models/query.py

# テーブル例
説明のため、下記のようなテーブルがあるとします。

|  テーブル  |  説明  |
| ---- | ---- |
|  Book  |  本の情報を保持する。  |
|  AwardBook  |  受賞歴のある本のIDを保持する。  |

これは、Djangoのモデルでは下記のように書けます。

```python
from django.db import models


class Book(models.Model):
    id = models.AutoField(primary_key=True)
    title = models.CharField(max_length=50)  # 本の題名
    price = models.IntegerField()  # 本の値段


class AwardBook(models.Model):
    id = models.AutoField(primary_key=True)
    book = models.ForeignKey(Book, on_delete=models.CASCADE)  # 受賞歴のある本のID
```

※「Bookテーブルに受賞歴フラグを持たせれば良い」という話ではありますが、今回はあくまで簡略化した例なので、諸事情でテーブルを分ける必要があることとします。


# 使い方の例
例えば、本の一覧を取得した後、**値段が安い順(=昇順)**に並べ替えたい場合、下記のように書けます。

```python
queryset = Book.objects.all().order_by("price")
```

ちなみに、**値段が高い順(=降順)**にしたい場合、フィールド名の最初に`-`を付けます。

```python
queryset = Book.objects.all().order_by("-price")
```


ただし、複数の要素で並べ替えをしなくてはならず、その条件が下記のような場合はどうすれば良いでしょうか。

**優先①: 受賞歴がある
優先②: 値段が安い**

`filter`を使えば、「受賞歴のある本のみを取得する」ことは出来ますが、それ以外の本を同時に取得することが出来ません。そんな時、`annotate`を使えば次のように書くことが出来ます。

```python
from django.db.models import Case, When, Value, IntegerField

from .models import Book, AwardBook


# 受賞歴のあるの本のIDを取得する
award_book_ids = set(  # 計算量を減らすため、setに変換
    AwardBook.objects.all().values_list(
        "book_id", flat=True
    )
)

# 並び替えを行いつつ、本一覧を取得
queryset = (
    Book.objects.all()
    .annotate(  # annotateで新しいフィールドを付与
        # 「受賞歴があるかどうか」を表すフラグ
        award_flg=Case(
            # 本のIDが「award_book_ids」に含まれている場合、値を1に設定
            When(id__in=award_book_ids, then=Value(1)),
            # それ以外の場合、値を0に設定
            default=Value(0),
            # それらの値のフィールドは「IntegerField」にする
            output_field=IntegerField(),
        )
    )
    .order_by(
        "award_flg",  # 1. 受賞歴のある本一覧の中に存在するか
        "price",  # 2. 値段が安い順
    )
)
```
`annotate`を用いて、`award_flg`という「受賞歴があるかどうか」を表すフラグを新たに設け、そちらのフラグが1になっているものを優先するロジックになっています。

ここで、新たに登場したCase、Whenについて説明したいと思います。

## Case, When
`Case`および`When`は、SQL文のCASE式を表現するために使用することのできるクラスです。

CASE式を使用すれば、「あるカラムの値に応じ、別の値を割り当てる」という処理を行うことができます。例えば、「テストの点数に応じて、成績をS,A,B,C,Fで割り振りたい」という処理を行う場合、下記のように書くことができます。

```SQL
CASE
    WHEN score >= 90 THEN 'S'
    WHEN score >= 80 THEN 'A'
    WHEN score >= 70 THEN 'B'
    WHEN score >= 60 THEN 'C'
    ELSE 'F'
END
```
なお、式は上から順に処理されるので、2番目の式は自動的に`80 <= score < 90`という意味になります。

上記の成績評価をDjangoの`Case`, `When`で表現すると、下記のように書くことができます。

```python
from django.db.models import Case, When, Value, CharField


grade = Case(
    When(score__gte=90, then=Value("S")),
    When(score__gte=80, then=Value("A")),
    When(score__gte=70, then=Value("B")),
    When(score__gte=60, then=Value("C")),
    default=Value("F"),
    output_field=CharField(max_length=1)
)
```
CASE式のELSEに該当するパラメータが`default`になっていることに注意してください。また、出力用のフィールドを指定することが必須であり、今回は文字列なので`CharField`を指定しています。

`{field}__gte`の他にも、`{field}__in`や`{field}__contains`など、querysetで用いることのできる演算子は使用することができます。もちろん、そのままイコールの意味として使いたい場合、下記のように`=`だけでつなげば大丈夫です。

```python
When(score=100, then=Value("SS"))
```

参考: [CASE式で条件分岐をSQL文に任せる](https://qiita.com/sfp_waterwalker/items/acc7f95f6ab5aa5412f3)

# まとめ
- `annotate`による、新しいフィールドの追加
- `Case`・`When`による、条件に応じた値の割り当て
- `order_by`で新しく追加されたフィールドを指定し、並べ替え

これらを組み合わせることで、高度な並べ替えを実現することが出来ました。工夫次第でさらに多様なケースに対応することが出来るので、ぜひ活用していただければ幸いです。