A Complete Beginner's Guide to Djangoのチュートリアルを参考にQuerySet APIを使ってみる。
対話型シェルで確認
チュートリアルに沿って掲示板アプリを作成しているが、今回はQuerySet APIを使用してPost、Topicの個数、最後のPostに関する情報を表示してみる。

最初に、オブジェクトをわかりやすく表示するためにboards/models.pyの各モデルに__str__を定義しておく。
Truncatorは文字列を切り詰めることができるクラス。
from django.db import models from django.utils.text import Truncator class Board(models.Model): # ... def __str__(self): return self.name class Topic(models.Model): # ... def __str__(self): return self.subject class Post(models.Model): # ... def __str__(self): truncated_message = Truncator(self.message) return truncated_message.chars(30)
以降、対話型シェルで確認を進めていくが、データが多い方がわかりやすいのでTopic、Postに適当にデータを追加しておく。
対話型シェルはpython manage.py shellで起動できるが、import文などが不要なshell_plusを使用して確認する。
shell_plusの使い方については下記記事を参照。

board.topics.count()で"Django" Boardに属するTopicの個数を取得している。
Post.objects.count()でPostの個数を取得しているが、これは全体の個数で"Django" Boardに属しているPostの個数ではない。

"Django" Boardに属しているPostの個数を取得するにはPost.objects.filter(topic__board=board).count()のようにする。
このようにアンダースコアを2つ繋げる(__)ことでリレーションを辿ることができる。

最後のPostの情報を取得するにはorder_by('-created_at')で降順(-が降順の意味)にソートしてfirst()で先頭のオブジェクトを取得する。
モデル、テンプレートの実装
データの取得方法がわかったので実装する。
boards/models.pyにget_posts_count(self)とget_last_post(self)を追加。
from django.db import models class Board(models.Model): name = models.CharField(max_length=30, unique=True) description = models.CharField(max_length=100) def __str__(self): return self.name def get_posts_count(self): return Post.objects.filter(topic__board=self).count() def get_last_post(self): return Post.objects.filter(topic__board=self).order_by('-created_at').first()
関数の処理は対話型シェルで確認したもので、selfにはBoardインスタンスが入る。
次にtemplates/home.htmlテンプレートを編集する。
{% extends 'base.html' %}
{% block breadcrumb %}
<li class="breadcrumb-item active">Boards</li>
{% endblock %}
{% block content %}
<table class="table">
<thead class="thead-dark">
<tr>
<th>Board</th>
<th>Posts</th>
<th>Topics</th>
<th>Last Post</th>
</tr>
</thead>
<tbody>
{% for board in boards %}
<tr>
<td>
<a href="{% url 'board_topics' board.pk %}">{{ board.name }}</a>
<small class="text-muted d-block">{{ board.description }}</small>
</td>
<td class="align-middle">
{{ board.get_posts_count }}
</td>
<td class="align-middle">
{{ board.topics.count }}
</td>
<td class="align-middle">
{% with post=board.get_last_post %}
<small>
<a href="{% url 'topic_posts' board.pk post.topic.pk %}">
By {{ post.created_by.username }} at {{ post.created_at }}
</a>
</small>
{% endwith %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{{ board.get_posts_count }}のようにモデルで定義した関数を呼び出している。
withタグは複雑な表現の変数の値をキャッシュし、簡単な名前で参照できるようにするタグ(board.get_last_postがpostで参照できるようになる)。
これで図のようにPost、Topicの個数、最後のPostに関する情報が表示される。

ただし、上記の実装だとPostが何もない場合にテストがエラーになる。
$ python manage.py test Creating test database for alias 'default'... System check identified no issues (0 silenced). .......................................................EEE...................... ====================================================================== ... FAILED (errors=3) Destroying test database for alias 'default'...
エラーが出ないようにtemplates/home.htmlを編集する。
{% with post=board.get_last_post %}
{% if post %}
<small>
<a href="{% url 'topic_posts' board.pk post.topic.pk %}">
By {{ post.created_by.username }} at {{ post.created_at }}
</a>
</small>
{% else %}
<small class="text-muted">
<em>No posts yet.</em>
</small>
{% endif %}
{% endwith %}
動作確認用にPostのないBoardを追加して問題ないことを確認する。

annotate()
次にTopic一覧ページでも同様に個数をカウントするがannotate()を使用してみる。

annotate()は即席のカラムが追加されるイメージで、annotate(replies=Count('posts')のようにするとtopic.repliesで個数を取得できる(-1としているのはTopic作成時のPostを除外するため)。
Count()部分にはAvg()、Max()といった集計関数を指定できる。
annotate()の使い方がわかったのでTopic一覧ページのboards/views.pyを編集する。
from django.db.models import Count from django.shortcuts import get_object_or_404, render from .models import Board def board_topics(request, pk): board = get_object_or_404(Board, pk=pk) topics = board.topics.order_by('-last_updated').annotate(replies=Count('posts') - 1) return render(request, 'topics.html', {'board': board, 'topics': topics})
次にtemplates/topics.htmlテンプレートを編集する。
{% for topic in topics %}
<tr>
<td><a href="{% url 'topic_posts' board.pk topic.pk %}">{{ topic.subject }}</a></td>
<td>{{ topic.starter.username }}</td>
<td>{{ topic.replies }}</td>
<td>0</td>
<td>{{ topic.last_updated }}</td>
</tr>
{% endfor %}
これで返信数が表示されるようになる。

まとめ
Truncatorは文字列を切り詰めることができるクラスtopic__boardのように__でリレーションを辿れる- 降順ソートは
order_by('-created_at')のように-を付ける annotate(replies=Count('posts'))でtopic.repliesで個数を取得できるannotate()では集計関数を指定できる