もた日記

くだらないことを真面目にやる

Djangoメモ(28) : QuerySet API(count, annotate)で個数のカウント

Python 3.6.4 Django 2.0.2

A Complete Beginner's Guide to Djangoのチュートリアルを参考にQuerySet APIを使ってみる。


対話型シェルで確認

チュートリアルに沿って掲示板アプリを作成しているが、今回はQuerySet APIを使用してPost、Topicの個数、最後のPostに関する情報を表示してみる。

f:id:wonder-wall:20180326220954p:plain

最初に、オブジェクトをわかりやすく表示するために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の使い方については下記記事を参照。

wonderwall.hatenablog.com

f:id:wonder-wall:20180326222709p:plain

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

f:id:wonder-wall:20180326222904p:plain

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

f:id:wonder-wall:20180326223034p:plain

最後のPostの情報を取得するにはorder_by('-created_at')で降順(-が降順の意味)にソートしてfirst()で先頭のオブジェクトを取得する。


モデル、テンプレートの実装

データの取得方法がわかったので実装する。
boards/models.pyget_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_postpostで参照できるようになる)。
これで図のようにPost、Topicの個数、最後のPostに関する情報が表示される。

f:id:wonder-wall:20180326223719p:plain

ただし、上記の実装だと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を追加して問題ないことを確認する。

f:id:wonder-wall:20180326224724p:plain


annotate()

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

f:id:wonder-wall:20180326230105p:plain

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 %}

これで返信数が表示されるようになる。

f:id:wonder-wall:20180326230520p:plain


まとめ

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