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

# はじめに
みなさんこんにちは！Yuyaです。

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

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

# 前提条件
以下に今回の問題に関連する各ファイルを記載します。
- Postモデル
- Commentモデル
- Likeモデル
- PostService

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

``` 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')
```

# 実際の挙動は...

:::note
わかりやすくするために、いいね後は赤いアイコンに変更しています。
今回のdistinctとは関係ない部分になるので省略しています。
:::
いいね前
![スクリーンショット 2025-05-08 23.58.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/b093a3de-1640-45e0-acb7-8fc78dae3e2b.png)

いいね後
![スクリーンショット 2025-05-08 23.58.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/95461e04-1b62-43a4-96ae-d4858c58b724.png)

とんでもない問題が発生しています。
- 1回のいいねでカウントが「2」増えている
- いいねを実行するとコメントの総数も増えている

**要は重複が発生しているのです。**

# なぜこのような問題が起きているのか
今回の`posts/services.py`では以下のSQLが発行されています。
``` 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;
```
```sql
-- 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操作をしてみやすくします。

```sql
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

これで集計をしてみましょう

```sql
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のコードで書くと以下のようになります。

```python
Post.objects.filter(id=1).annotate(
    total_comments=Count('comments'),
    total_likes=Count('likes')
)
```

# ここで登場 `distinct=True`
SQL文で表すと重複を削除するには `DISTINCT` を使用します。
```sql
COUNT(comments.id) AS comment_count,
COUNT(likes.id) AS like_count
```
```sql
-- 以下のようにDISTINCTを追加する
COUNT(DISTINCT c.id) AS total_comments,
COUNT(DISTINCT l.id) AS total_likes
```

pythonのコードで表すと以下のようになります。
```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)を同時に集計する場合
- **フィルタと組み合わせた集計**
```python
   Post.objects.filter(
       comments__created_at__gt=one_week_ago
   ).annotate(
       comment_count=Count('comments')  # distinctが必要
   )
```


また、Django ORMは便利ですが生成されるSQLを理解することが大切です。
django-debug-toolbarで確認したり、以下のように `print()`を使うことで確認することも可能です。

```python
query = Post.objects.annotate(
    total_comments=Count('comments'),
    total_likes=Count('likes')
)
print(query.query)  # 生成されるSQLを出力
```