2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

複数の値を集計の際にdistinct=Trueが必要な理由

Posted at

はじめに

みなさんこんにちは!Yuyaです。

今現在、DjangoでXのクローンアプリを開発しています。
その際に、投稿のコメント総数に加えて、いいね総数を取得する必要がありました。
コメント総数の部分で使った annotate + Countを脳死で使って集計をしました。

しかし、値がおかしかったのです。1回いいねをするとカウントが「2」増えたり、いいね解除するとカウントが「3」減ったりしました。原因を考えていきましょう。

前提条件

以下に今回の問題に関連する各ファイルを記載します。

  • Postモデル
  • Commentモデル
  • Likeモデル
  • PostService

不要なコードは省きます。

posts/models.py
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"
    )
    ...
    # 残りは省略
comments/models.py
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}"
likes/models.py
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}"
posts/services.py
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 を追加します。

posts/services.py
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とは関係ない部分になるので省略しています。

いいね前
スクリーンショット 2025-05-08 23.58.17.png

いいね後
スクリーンショット 2025-05-08 23.58.33.png

とんでもない問題が発生しています。

  • 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

重複がなくなりました!

最終的なコード

最終的に元のコードに当てはめると

posts/services.py
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を出力
2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?