[Django]ManyToManyの有無でフィルタリングしたい


動機

Django(3.1)で、ManyToManyField(多対多のrelation)を持つmodelを考えます。
このとき、対象のFieldにレコードがセットされているか否かでフィルタリングをしたい場合があります。
例えば以下のようなmodelを考えます。一つの記事に複数のタグがついている想定です。

models.py
from django.db import models

# タグ
class Tag(models.Model):
    name=models.CharField(verbose_name="名前",max_length=32)


# 記事
class Article(models.Model):
    title=models.CharField(verbose_name="タイトル",max_length=32)
    text=models.TextField(verbose_name="本文")
    tags=models.ManyToManyField(Tag,verbose_name="タグ",related_name="articles")

ここで、タグが一つでもついている記事の一覧を引っ張りたいとします。まず思いつくのは以下の書き方です。

views.py
from django.views.generic import ListView
from .models import *

class ArticleWithTagsListView(ListView):
    model=Article
    template_name="article_with_tags.html"

    def get_queryset(self):
        # tags__isnull=Falseで、タグが少なくとも一個ついているレコードだけ引っ張る
        queryset=Article.objects.filter(tags__isnull=False)
        return queryset

しかしこの方法はうまくいきません。紐づいているTag数に応じてレコードが重複してしまいます。

かといって

views.py
queryset=Article.objects.all()
filtered_queryset=[q for q in queryset if q.tags.count()>0]

なんてしようものならループの度にクエリが走って、実行時間がえらいことになります。

distinct()を使おう

最後にdistinct()を絡めれば、isnullフィルターを有効活用できます。

views.py
queryset=Article.objects.filter(tags__isnull=False).distinct()

annotate()を使おう

annotate()を使って各レコードのtag数を算出→countが0より大きいものをフィルタリングという方法をとってもOKです。

views.py
from django.db.models import Count

queryset=Article.objects.annotate(tag_count=Count('tag')).filter(tag_count__gt=0)

分解すると、まず下記の操作で"tag_count"という値に各レコードのtagの数を格納しています。

views.py
queryset=Article.objects.annotate(tag_count=Count('tag'))

#例 : 最初のレコードのtag数が3のとき
q=queryset.first()
print(q.tag_count) # 3

次に、"tag_count"が0より大きいレコードだけを抽出しています。

views.py
queryset=Article.objects.annotate(tag_count=Count('tag'))
queryset=queryset.filter(tag_count__gt=0) #追加