概要
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_DEFAULT
はnull
となってしまうので、返信が通常のコメントになってしまいこれもおかしくなります。結局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_replies
はNone
となります。このときは返信数を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_to
にblank=True
となっているのは管理者画面から普通のコメントを作成できるようにするためです。もしもblank=True
となっていない場合、reply_to
が空のままだとエラーが生じます。
僕から以上