疑念は探究の動機であり、探究の唯一の目的は信念の確定である。

数学・論理学・哲学・語学のことを書きたいと思います。どんなことでも何かコメントいただけるとうれしいです。特に、勉学のことで間違いなどあったらご指摘いただけると幸いです。 よろしくお願いします。くりぃむのラジオを聴くこととパワポケ2と日向坂46が人生の唯一の楽しみです。

Djangoのモデルでコメントの返信数を取得する方法

概要

SNSやコメントの返信において、返信数を取得する方法を提示する。


ソースコード

  • モデル
"""Comment Model"""

from django.db import models

class Comment(models.Model):
    """Comment model"""
    text = models.CharField(max_length=2000)
    reply_to = models.ForeignKey(to='self', on_delete=models.DO_NOTHING, null=True, blank=True, default=None)

    def __str__(self) -> str:
        return self.text



class CommentSerializer(serializers.ModelSerializer):
    """Comment serializer"""

    class Meta:
        """Meta"""
        model = Comment
        fields = '__all__'
    
    def to_representation(self, instance):
        """Representation"""
        response = super().to_representation(instance)
        response['reply_count'] = instance.number_of_replies if instance.number_of_replies else 0
        return response



  • ビュー
class CommentList(generics.ListAPIView):
    """Comment view"""
    permission_classes = []
    serializer_class = CommentSerializer
    queryset = Comment.objects.all()

    def get_queryset(self):
        """Get query set"""
        subquery = Comment.objects.filter(reply_to=OuterRef('id')).values('reply_to').annotate(count=Count('reply_to')).values('count')
        return Comment.objects.annotate(number_of_replies=Subquery(subquery))



  • URL
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from backend.books.views import CommentList

router = DefaultRouter()

urlpatterns = [
    path('comments/', CommentList.as_view()),
    path('', include(router.urls)),
]





データ

結果

[
    {
        "id": 1,
        "text": "This is a test.",
        "reply_to": null,
        "reply_count": 1
    },
    {
        "id": 2,
        "text": "This is a reply test.",
        "reply_to": 1,
        "reply_count": 0
    },
    {
        "id": 3,
        "text": "That is a test.",
        "reply_to": null,
        "reply_count": 0
    },
    {
        "id": 4,
        "text": "Who are you?",
        "reply_to": null,
        "reply_count": 3
    },
    {
        "id": 5,
        "text": "I'm a superman.",
        "reply_to": 4,
        "reply_count": 0
    },
    {
        "id": 6,
        "text": "I'm a mathematician.",
        "reply_to": 4,
        "reply_count": 0
    },
    {
        "id": 7,
        "text": "I'm an entertainer.",
        "reply_to": 4,
        "reply_count": 0
    }
]

GitHub - YoheiWatanabe/recording-books at test-comment-reply

解説

SNSの投稿やコメントには返信機能があります。それを実現するモデルの作成および返信数を取得する方法を解説します。

モデル

返信機能のあるコメントモデルCommentを作成します。簡単のために他のフィールドは除いています。

  • backend/backend/books/models/comment.py
from django.db import models

class Comment(models.Model):
    """Comment model"""
    text = models.CharField(max_length=2000)
    reply_to = models.ForeignKey(to='self',
                      on_delete=models.DO_NOTHING,
                      null=True,
                      blank=True,
                      default=None)

フィールドreply_toは返信(リプライ)を実装するためのものです。自分自身のidと紐付けます。そのためにForeign Keyにto='self'を設定します。

このフィールドはコメントが返信なのかどうかを表しています。普通のコメントの場合はnullとなり、返信のコメントの場合は返信しているIDが設定されます。

on_deleteはここでは返信されている元のコメントがなくなった場合どうなるかということです。on_deleteはとりあえずDO_NOTHINGにしています。元のコメントがなくなってもそのままにしています。CASCADEは元のコメントが削除されると、その返信も削除されるので問題があります。またPROTECTは返信がない限り、元のコメントが削除できなくなるので、それも不都合です。SET_DEFAULTnullとなってしまうので、返信が通常のコメントになってしまいこれもおかしくなります。結局DO_NOTHINGにしています。

ビュー

今回の肝はビュー(view)です。返信数を取得するためには愚直にやる方法もありますが、それだと大量のSQLを実行することになります(N + 1問題)。ですので、サブクエリを使って一発で実行します。

  • backend/backend/books/views.py
from django.db.models import OuterRef, Subquery, Count
from rest_framework import generics
from backend.books.serializer import CommentSerializer
from backend.books.models.comment import Comment

class CommentList(generics.ListAPIView):
    """Comment view"""
    permission_classes = []
    serializer_class = CommentSerializer
    queryset = Comment.objects.all()

    def get_queryset(self):
        """Get query set"""
        subquery = Comment.objects
        .filter(reply_to=OuterRef('id'))
        .values('reply_to')
        .annotate(count=Count('reply_to'))
        .values('count')
        
         return Comment.objects
          .annotate(number_of_replies=Subquery(subquery))

今回はgenerics.ListAPIViewを使っていますが、他のViewでも問題ありません。

解説をしたいのですが、まだ「これでうまくいった」ということしか言えず、ちゃんと理解していません...。

annotateがあるので、このクエリセットはnumber_of_repliesというフィールドがあります。

リアライザー

  • backend/backend/books/serializer.py
from rest_framework import serializers
from backend.books.models.comment import Comment

class CommentSerializer(serializers.ModelSerializer):
    """Comment serializer"""

    class Meta:
        """Meta"""
        model = Comment
        fields = '__all__'
    
    def to_representation(self, instance):
        """Representation"""
        response = super().to_representation(instance)
        response['reply_count'] = instance
                                   .number_of_replies
                                   if instance
                                   .number_of_replies
                                   else 0
        return response

to_representationを使って戻り値を修正しています。新たに返信数reply_countを追加しています。返信がない場合(単なるコメントの場合)、instance.number_of_repliesNoneとなります。このときは返信数を0にするために、三値演算子(if...else)を使っています。

URL

  • backend/backend/books/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from backend.books.views import CommentList

router = DefaultRouter()

urlpatterns = [
    path('comments/', CommentList.as_view()),
    path('', include(router.urls)),
]

コメントAPIを実行するためにURLを設定します。

アドミン

  • backend/backend/books/admin.py
from django.contrib import admin
from backend.books.models.comment import Comment

class CommentAdmin(admin.ModelAdmin):
    """Display Comment model"""
    model = Comment
    list_display = ('id', 'text', 'reply_to')

admin.site.register(Comment, CommentAdmin)

管理者画面でコメントのデータベース操作するための設定です。モデルのreply_toblank=Trueとなっているのは管理者画面から普通のコメントを作成できるようにするためです。もしもblank=Trueとなっていない場合、reply_toが空のままだとエラーが生じます。





僕から以上