From ca456e47a4c1cd02abb1045841ca018ea848e5b3 Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:44:01 -0700 Subject: [PATCH] fix(queryset): make distinct().count() ignore join-duplicated rows QuerySet.count() always emitted COUNT(*), which counts every row the query produces. When the queryset is distinct() and the filter spans a m2m (or any other row-multiplying join), the returned objects are de-duplicated by the SELECT DISTINCT but the count was not, so count() reported more rows than the queryset actually yields. CountQuery now receives the _distinct flag and counts distinct primary keys instead. Grouped queries keep the existing windowed COUNT(*). Closes #2178 --- CHANGELOG.rst | 1 + tests/test_queryset.py | 13 +++++++++++++ tortoise/queryset.py | 22 ++++++++++++++++++---- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 36b51e657..6c475595a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -26,6 +26,7 @@ Fixed - ``QuerySet.count()`` now matches the limited query result for the LIMIT/OFFSET edge cases: it returns ``0`` (instead of a negative number) when ``offset()`` exceeds the total row count, and ``0`` (instead of the total) for ``limit(0)``. (#2208) - Field declarations on models now resolve to their concrete type (e.g. ``CharField[str]``) in Pyright/Pylance instead of ``Field[Unknown]``; the ``Field.__new__`` type-check stub now returns ``Self``. (#2216) - ``TransactionContext`` now returns a ``TransactionalDBClient`` instead of a raw database connection. This change gives the correct inferred type for the transaction context. (#2232) +- ``QuerySet.distinct().count()`` no longer counts rows duplicated by a join (e.g. filtering on a m2m relation); it now counts distinct primary keys and matches the number of rows the query returns. (#2178) 1.1.7 diff --git a/tests/test_queryset.py b/tests/test_queryset.py index 8eaefb795..35a6033ed 100644 --- a/tests/test_queryset.py +++ b/tests/test_queryset.py @@ -9,6 +9,7 @@ MinRelation, Node, Reporter, + Team, Tournament, Tree, ) @@ -227,6 +228,18 @@ async def test_distinct(db, intfields_data): ] +@pytest.mark.asyncio +async def test_distinct_count_with_m2m_join(db): + tournament = await Tournament.create(name="Tournament") + event = await Event.create(name="Event", tournament=tournament) + for name in ("Team A", "Team B", "Team C"): + await event.participants.add(await Team.create(name=name)) + + queryset = Event.filter(participants__name__in=["Team A", "Team B", "Team C"]).distinct() + assert len(await queryset) == 1 + assert await queryset.count() == 1 + + @pytest.mark.asyncio async def test_limit_offset_values_list(db, intfields_data): # Test limit/offset/ordering values_list diff --git a/tortoise/queryset.py b/tortoise/queryset.py index fe7d4a3f7..b5f5af198 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -9,8 +9,9 @@ from pypika_tortoise import JoinType, Order, Table from pypika_tortoise.analytics import Count from pypika_tortoise.functions import Cast +from pypika_tortoise.functions import Count as DistinctCount from pypika_tortoise.queries import QueryBuilder, _SetOperation -from pypika_tortoise.terms import Case, Field, Star, Term, ValueWrapper +from pypika_tortoise.terms import Case, Field, Function, Star, Term, ValueWrapper from tortoise.backends.base.client import BaseDBAsyncClient, Capabilities from tortoise.exceptions import ( @@ -806,6 +807,7 @@ def count(self) -> CountQuery: offset=self._offset, force_indexes=self._force_indexes, use_indexes=self._use_indexes, + distinct=self._distinct, ) def exists(self) -> ExistsQuery: @@ -1520,6 +1522,7 @@ class CountQuery(AwaitableQuery): "_offset", "_force_indexes", "_use_indexes", + "_distinct", ) def __init__( @@ -1533,6 +1536,7 @@ def __init__( offset: int | None, force_indexes: set[str], use_indexes: set[str], + distinct: bool = False, ) -> None: super().__init__(model) self._q_objects = q_objects @@ -1543,13 +1547,23 @@ def __init__( self._db = db self._force_indexes = force_indexes self._use_indexes = use_indexes + self._distinct = distinct def _make_query(self) -> None: self.query = copy(self.model._meta.basequery) self.resolve_filters() - count_term = Count(Star()) - if self.query._groupbys: - count_term = count_term.over() + count_term: Function + if self._distinct and not self.query._groupbys: + # ``DISTINCT`` on the outer SELECT does not apply to ``COUNT(*)``, + # so rows duplicated by a join would still be counted. Count the + # distinct primary keys instead. + count_term = DistinctCount( + self.model._meta.basetable[self.model._meta.db_pk_column] + ).distinct() + else: + count_term = Count(Star()) + if self.query._groupbys: + count_term = count_term.over() # remove annotations self.query._selects = []