はじめに
みなさんこんにちは!Yuyaです。
今現在、DjangoでXのクローンアプリを開発しています。
その際に、投稿のコメント総数に加えて、いいね総数を取得する必要がありました。
コメント総数の部分で使った annotate
+ Count
を脳死で使って集計をしました。
しかし、値がおかしかったのです。1回いいねをするとカウントが「2」増えたり、いいね解除するとカウントが「3」減ったりしました。原因を考えていきましょう。
前提条件
以下に今回の問題に関連する各ファイルを記載します。
- Postモデル
- Commentモデル
- Likeモデル
- PostService
不要なコードは省きます。
from django.db import models
from cloudinary.models import CloudinaryField
from django.core.validators import ProhibitNullCharactersValidator
from django.db.models import Q, CheckConstraint
class Post(models.Model):
user = models.ForeignKey(
"accounts.CustomUser",
verbose_name="ユーザーID",
on_delete=models.CASCADE,
related_name="posts"
)
...
# 残りは省略
from django.db import models
from cloudinary.models import CloudinaryField
from django.db.models import Q, CheckConstraint
class Comment(models.Model):
user = models.ForeignKey(
"accounts.CustomUser",
verbose_name="ユーザーID",
on_delete=models.CASCADE,
related_name="comments"
)
post = models.ForeignKey(
"posts.Post",
verbose_name="ポストID",
on_delete=models.CASCADE,
related_name="comments"
)
message = models.CharField(
verbose_name="コメント",
null=True,
blank=True,
max_length=140,
)
image = CloudinaryField(
'image',
null=True,
blank=True,
help_text='画像だけでも投稿できます。'
)
created_at = models.DateTimeField(
verbose_name="作成日時",
auto_now_add=True
)
class Meta:
ordering = ["-id"]
db_table = "comments"
constraints = [
CheckConstraint(
check=Q(message__isnull=False) | Q(image__isnull=False),
name='comment_message_or_image_required'
)
]
def __str__(self):
return f"comment: {self.user} -> {self.post}"
from django.db import models
from django.core.exceptions import ValidationError
class Like(models.Model):
user = models.ForeignKey(
"accounts.CustomUser",
verbose_name="ユーザーID",
on_delete=models.CASCADE,
related_name="likes"
)
post = models.ForeignKey(
"posts.Post",
verbose_name="ポストID",
on_delete=models.CASCADE,
related_name="likes"
)
created_at = models.DateTimeField(
verbose_name="作成日時",
auto_now_add=True
)
class Meta:
ordering = ["-id"]
db_table = "likes"
constraints = [
models.UniqueConstraint(
fields=['user', 'post'],
name='unique_user_post_like'
)
]
def clean(self):
if Like.objects.filter(self.user, self.post).exists():
raise ValidationError("このユーザーは既にいいねをしています")
def __str__(self):
return f"like: {self.user} -> {self.post}"
from apps.posts.models import Post
from apps.relationships.models import Relationship
from django.db.models import Count
class PostService:
@staticmethod
def get_post_list():
""" ポスト一覧を取得 """
return Post.objects.select_related('user') \
.prefetch_related('comments') \
.annotate(total_comments=Count('comments')) \
.order_by('-created_at', '-id')
関係性を整理
各モデルの関係性は以下のようになっています。
- Post → Comment (1対多)
- Post → Like (1対多)
- User → Post (1対多)
- User → Comment (1対多)
- User → Like (1対多)
ポスト一覧でいいね総数を取得(distinct
を考えない)
ポスト一覧取得の際に各ポストのいいね総数を取得する total_likes
を追加します。
class PostService:
@staticmethod
def get_post_list():
""" ポスト一覧を取得 """
return Post.objects.select_related('user') \
.prefetch_related('comments') \
.annotate(
total_comments=Count('comments'),
total_likes=Count('likes')) \ # ここを追加
.order_by('-created_at', '-id')
実際の挙動は...
わかりやすくするために、いいね後は赤いアイコンに変更しています。
今回のdistinctとは関係ない部分になるので省略しています。
とんでもない問題が発生しています。
- 1回のいいねでカウントが「2」増えている
- いいねを実行するとコメントの総数も増えている
要は重複が発生しているのです。
なぜこのような問題が起きているのか
今回のposts/services.py
では以下のSQLが発行されています。
SELECT
"posts"."id",
"posts"."user_id",
"posts"."message",
-- その他必要なpostsフィールド...
"user"."id",
"user"."username",
-- その他userフィールド (省略)...
-- annotate部分(重複あり)
COUNT("comments"."id") AS "total_comments",
COUNT("likes"."id") AS "total_likes"
FROM
"posts"
-- select_related部分
LEFT OUTER JOIN
"users" AS "user" ON ("posts"."user_id" = "user"."id")
-- annotate(Count('comments'))のためのJOIN
LEFT OUTER JOIN
"comments" ON ("posts"."id" = "comments"."post_id")
-- annotate(Count('likes'))のためのJOIN
LEFT OUTER JOIN
"likes" ON ("posts"."id" = "likes"."post_id")
GROUP BY
"posts"."id", "user"."id"
ORDER BY
"posts"."id" DESC;
-- prefetch_related('comments')の部分
SELECT
"comments"."id",
"comments"."user_id",
"comments"."post_id",
"comments"."message",
"comments"."image",
"comments"."created_at"
FROM
"comments"
WHERE
"comments"."post_id" IN (1, 2, 3, ...); -- 取得した投稿IDのリスト
具体的なサンプルデータ
次のようなデータがあるとします。
ポストテーブル(posts)
id | user_id | message |
---|---|---|
1 | 5 | "こんにちは" |
ユーザーテーブル(users)
id | username |
---|---|
5 | "tanaka" |
コメントテーブル(comments)
id | post_id | user_id | message |
---|---|---|---|
101 | 1 | 6 | "いいね!" |
102 | 1 | 7 | "素敵!" |
103 | 1 | 8 | "うらやましい" |
いいねテーブル(likes)
id | post_id | user_id |
---|---|---|
201 | 1 | 9 |
202 | 1 | 10 |
JOIN操作をしてみやすくします。
SELECT
p.id AS post_id,
p.user_id,
c.id AS comment_id,
l.id AS like_id
FROM
posts p
-- ユーザーテーブルとの結合(投稿者情報を取得)
LEFT JOIN
users u ON p.user_id = u.id
-- コメントテーブルとの結合
LEFT JOIN
comments c ON p.id = c.post_id
-- いいねテーブルとの結合
LEFT JOIN
likes l ON p.id = l.post_id
WHERE
p.id = 1;
JOIN操作の結果
post_id | user_id(投稿者) | comment_id | like_id |
---|---|---|---|
1 | 5 | 101 | 201 |
1 | 5 | 101 | 202 |
1 | 5 | 102 | 201 |
1 | 5 | 102 | 202 |
1 | 5 | 103 | 201 |
1 | 5 | 103 | 202 |
これで集計をしてみましょう
SELECT
posts.id,
COUNT(comments.id) AS comment_count,
COUNT(likes.id) AS like_count
FROM
posts
LEFT JOIN
comments ON posts.id = comments.post_id
LEFT JOIN
likes ON posts.id = likes.post_id
WHERE
posts.id = 1
GROUP BY
posts.id
結果
id | comment_count | like_count |
---|---|---|
1 | 6 | 6 |
実際には comment_id
が 101, 102, 103 の 「3コメント」と
like_id
が 201, 202 の「2いいね」なのに、それぞれ「6」とカウントされてしまいます。何回も言いますが、理由は重複を許しているからです。
また、pythonのコードで書くと以下のようになります。
Post.objects.filter(id=1).annotate(
total_comments=Count('comments'),
total_likes=Count('likes')
)
ここで登場 distinct=True
SQL文で表すと重複を削除するには DISTINCT
を使用します。
COUNT(comments.id) AS comment_count,
COUNT(likes.id) AS like_count
-- 以下のようにDISTINCTを追加する
COUNT(DISTINCT c.id) AS total_comments,
COUNT(DISTINCT l.id) AS total_likes
pythonのコードで表すと以下のようになります。
Post.objects.filter(id=1).annotate(
total_comments=Count('comments', distinct=True),
total_likes=Count('likes', distinct=True)
)
全体のSQL文は省略しますが、上記のpythonのコードを実行するとサンプルデータの結果は以下のようになります。
id | total_comments | total_likes |
---|---|---|
1 | 3 | 2 |
重複がなくなりました!
最終的なコード
最終的に元のコードに当てはめると
class PostService:
@staticmethod
def get_post_list():
""" ポスト一覧を取得 """
return Post.objects.select_related('user') \
.prefetch_related('comments') \
.annotate(
total_comments=Count('comments', distinct=True),
total_likes=Count('likes', distinct=True) \
.order_by('-created_at', '-id')
最後に
以下のパターンの場合、distinct=True
をつけた方が良いです。
-
中間テーブルを介した関係では必ず
distinct=True
を検討する- 例: ユーザーがタグ付けできるブログ記事システムで、記事ごとのタグ数を集計する場合
-
複数の一対多関係を同時に集計
- 一つのモデルが複数の別モデルと一対多関係を持つ場合(PostがCommentとLikeを持つ)
- 例: 商品(Product)に対するレビュー(Review)と注文履歴(OrderItem)を同時に集計する場合
- フィルタと組み合わせた集計
Post.objects.filter(
comments__created_at__gt=one_week_ago
).annotate(
comment_count=Count('comments') # distinctが必要
)
また、Django ORMは便利ですが生成されるSQLを理解することが大切です。
django-debug-toolbarで確認したり、以下のように print()
を使うことで確認することも可能です。
query = Post.objects.annotate(
total_comments=Count('comments'),
total_likes=Count('likes')
)
print(query.query) # 生成されるSQLを出力