Skip to content

Commit 403a4dc

Browse files
fix: clamp count() at 0 when offset exceeds total (#2208)
* fix: clamp count() to limit/offset semantics at the edges COUNT(*) ignores LIMIT/OFFSET, so CountQuery applies them in Python. Two edge cases didn't match the limited query result: - offset greater than the total returned a negative number (e.g. .offset(100).count() == -95 for 5 rows) instead of 0. - limit(0) returned the full total instead of 0, because the limit clamp used a truthiness check (`if self._limit`) that skips 0. Clamp the offset subtraction at 0 and use `self._limit is not None` so an explicit limit(0) is honored. * docs: reference count fix PR in changelog * test: format count edge assertions
1 parent 179ea6f commit 403a4dc

3 files changed

Lines changed: 24 additions & 2 deletions

File tree

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Added
2121
Fixed
2222
^^^^^
2323
- ``MigrationRecorder`` now uses parameterized queries; fixes MariaDB/MySQL rejecting ISO-8601 ``applied_at`` values. (#2132)
24+
- ``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)
2425

2526
1.1.7
2627
-----

tests/test_queryset.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,14 @@ async def test_limit_count(db, intfields_data):
8888
assert await IntFields.all().limit(10).count() == 10
8989

9090

91+
@pytest.mark.asyncio
92+
async def test_limit_zero_count(db, intfields_data):
93+
# limit(0) means zero rows, so count() must be 0 (not the total), matching
94+
# the actual limited query.
95+
assert await IntFields.all().limit(0).count() == 0
96+
assert await IntFields.all().limit(0).count() == len(await IntFields.all().limit(0))
97+
98+
9199
@pytest.mark.asyncio
92100
async def test_limit_negative(db, intfields_data):
93101
with pytest.raises(ParamsError, match="Limit should be non-negative number"):
@@ -106,6 +114,14 @@ async def test_offset_count(db, intfields_data):
106114
assert await IntFields.all().offset(10).count() == 20
107115

108116

117+
@pytest.mark.asyncio
118+
async def test_offset_count_beyond_total(db, intfields_data):
119+
# An offset past the total must report 0, not a negative count (the SQL
120+
# LIMIT/OFFSET would return zero rows).
121+
assert await IntFields.all().offset(100).count() == 0
122+
assert await IntFields.all().offset(100).count() == len(await IntFields.all().offset(100))
123+
124+
109125
@pytest.mark.asyncio
110126
async def test_offset_negative(db, intfields_data):
111127
with pytest.raises(ParamsError, match="Offset should be non-negative number"):

tortoise/queryset.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1571,8 +1571,13 @@ async def _execute(self) -> int:
15711571
_, result = await self._db.execute_query(*self.query.get_parameterized_sql())
15721572
if not result:
15731573
return 0
1574-
count = list(dict(result[0]).values())[0] - self._offset
1575-
if self._limit and count > self._limit:
1574+
# COUNT(*) ignores LIMIT/OFFSET, so the offset is applied here. Clamp at
1575+
# 0: when the offset is past the total, SQL would return 0 rows, not a
1576+
# negative count.
1577+
count = max(0, list(dict(result[0]).values())[0] - self._offset)
1578+
# Use ``is not None`` so an explicit ``limit(0)`` clamps to 0 instead of
1579+
# being treated as "no limit" by a truthiness check.
1580+
if self._limit is not None and count > self._limit:
15761581
return self._limit
15771582
return count
15781583

0 commit comments

Comments
 (0)