Skip to content

Commit 08b4dfc

Browse files
VIZZARD-Xjacobtylerwalls
authored andcommitted
Fixed #36857 -- Added QuerySet.totally_ordered property.
Thanks Simon Charette for the idea.
1 parent 3dea5fe commit 08b4dfc

8 files changed

Lines changed: 182 additions & 243 deletions

File tree

django/contrib/admin/views/main.py

Lines changed: 3 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -417,72 +417,9 @@ def get_ordering(self, request, queryset):
417417
# Add the given query's ordering fields, if any.
418418
ordering.extend(queryset.query.order_by)
419419

420-
return self._get_deterministic_ordering(ordering)
421-
422-
def _get_deterministic_ordering(self, ordering):
423-
"""
424-
Ensure a deterministic order across all database backends. Search for a
425-
single field or unique together set of fields providing a total
426-
ordering. If these are missing, augment the ordering with a descendant
427-
primary key.
428-
"""
429-
ordering = list(ordering)
430-
ordering_fields = set()
431-
total_ordering_fields = {"pk"} | {
432-
field.attname
433-
for field in self.lookup_opts.fields
434-
if field.unique and not field.null
435-
}
436-
for part in ordering:
437-
# Search for single field providing a total ordering.
438-
field_name = None
439-
if isinstance(part, str):
440-
field_name = part.lstrip("-")
441-
elif isinstance(part, F):
442-
field_name = part.name
443-
elif isinstance(part, OrderBy) and isinstance(part.expression, F):
444-
field_name = part.expression.name
445-
if field_name:
446-
# Normalize attname references by using get_field().
447-
try:
448-
field = self.lookup_opts.get_field(field_name)
449-
except FieldDoesNotExist:
450-
# Could be "?" for random ordering or a related field
451-
# lookup. Skip this part of introspection for now.
452-
continue
453-
# Ordering by a related field name orders by the referenced
454-
# model's ordering. Skip this part of introspection for now.
455-
if field.remote_field and field_name == field.name:
456-
continue
457-
if field.attname in total_ordering_fields:
458-
break
459-
ordering_fields.add(field.attname)
460-
else:
461-
# No single total ordering field, try unique_together and total
462-
# unique constraints.
463-
constraint_field_names = (
464-
*self.lookup_opts.unique_together,
465-
*(
466-
constraint.fields
467-
for constraint in self.lookup_opts.total_unique_constraints
468-
),
469-
)
470-
for field_names in constraint_field_names:
471-
# Normalize attname references by using get_field().
472-
fields = [
473-
self.lookup_opts.get_field(field_name) for field_name in field_names
474-
]
475-
# Composite unique constraints containing a nullable column
476-
# cannot ensure total ordering.
477-
if any(field.null for field in fields):
478-
continue
479-
if ordering_fields.issuperset(field.attname for field in fields):
480-
break
481-
else:
482-
# If no set of unique fields is present in the ordering, rely
483-
# on the primary key to provide total ordering.
484-
ordering.append("-pk")
485-
return ordering
420+
if queryset.order_by(*ordering).totally_ordered:
421+
return ordering
422+
return ordering + ["-pk"]
486423

487424
def get_ordering_field_columns(self):
488425
"""

django/db/models/query.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from django.db.models import AutoField, DateField, DateTimeField, Field, Max, sql
2727
from django.db.models.constants import LOOKUP_SEP, OnConflict
2828
from django.db.models.deletion import Collector
29-
from django.db.models.expressions import Case, DatabaseDefault, F, Value, When
29+
from django.db.models.expressions import Case, DatabaseDefault, F, OrderBy, Value, When
3030
from django.db.models.fetch_modes import FETCH_ONE
3131
from django.db.models.functions import Cast, Trunc
3232
from django.db.models.query_utils import FilteredRelation, Q
@@ -1974,6 +1974,80 @@ def ordered(self):
19741974
else:
19751975
return False
19761976

1977+
@property
1978+
def totally_ordered(self):
1979+
"""
1980+
Returns True if the QuerySet is ordered and the ordering is
1981+
deterministic. This requires that the ordering includes a field
1982+
(or set of fields) that is unique and non-nullable.
1983+
1984+
For queries involving a GROUP BY clause, the model's default
1985+
ordering is ignored. Ordering specified via .extra(order_by=...)
1986+
is also ignored.
1987+
"""
1988+
if not self.ordered:
1989+
return False
1990+
ordering = self.query.order_by
1991+
if not ordering and self.query.default_ordering:
1992+
ordering = self.query.get_meta().ordering
1993+
if not ordering:
1994+
return False
1995+
opts = self.model._meta
1996+
pk_fields = {f.attname for f in opts.pk_fields}
1997+
ordering_fields = set()
1998+
for part in ordering:
1999+
# Search for single field providing a total ordering.
2000+
field_name = None
2001+
if isinstance(part, str):
2002+
field_name = part.lstrip("-")
2003+
elif isinstance(part, F):
2004+
field_name = part.name
2005+
elif isinstance(part, OrderBy) and isinstance(part.expression, F):
2006+
field_name = part.expression.name
2007+
if field_name:
2008+
if field_name == "pk":
2009+
return True
2010+
# Normalize attname references by using get_field().
2011+
try:
2012+
field = opts.get_field(field_name)
2013+
except exceptions.FieldDoesNotExist:
2014+
# Could be "?" for random ordering or a related field
2015+
# lookup. Skip this part of introspection for now.
2016+
continue
2017+
# Ordering by a related field name orders by the referenced
2018+
# model's ordering. Skip this part of introspection for now.
2019+
if field.remote_field and field_name == field.name:
2020+
continue
2021+
if field.attname in pk_fields and len(pk_fields) == 1:
2022+
return True
2023+
if field.unique and not field.null:
2024+
return True
2025+
ordering_fields.add(field.attname)
2026+
2027+
# Account for members of a CompositePrimaryKey.
2028+
if ordering_fields.issuperset(pk_fields):
2029+
return True
2030+
# No single total ordering field, try unique_together and total
2031+
# unique constraints.
2032+
constraint_field_names = (
2033+
*opts.unique_together,
2034+
*(constraint.fields for constraint in opts.total_unique_constraints),
2035+
)
2036+
for field_names in constraint_field_names:
2037+
# Normalize attname references by using get_field().
2038+
try:
2039+
fields = [opts.get_field(field_name) for field_name in field_names]
2040+
except exceptions.FieldDoesNotExist:
2041+
continue
2042+
# Composite unique constraints containing a nullable column
2043+
# cannot ensure total ordering.
2044+
if any(field.null for field in fields):
2045+
continue
2046+
if ordering_fields.issuperset(field.attname for field in fields):
2047+
return True
2048+
2049+
return False
2050+
19772051
@property
19782052
def db(self):
19792053
"""Return the database used if this query is executed now."""

docs/ref/models/querysets.txt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,18 @@ Here's the formal declaration of a ``QuerySet``:
170170
:meth:`order_by` clause or a default ordering on the model.
171171
``False`` otherwise.
172172

173+
.. attribute:: totally_ordered
174+
175+
.. versionadded:: 6.1
176+
177+
Returns ``True`` if the ``QuerySet`` is ordered and the ordering is
178+
deterministic. This requires that the ordering includes a field
179+
(or set of fields) that is unique and non-nullable.
180+
181+
For queries involving a ``GROUP BY`` clause, the model's default
182+
ordering is ignored. Ordering specified via ``.extra(order_by=...)``
183+
is also ignored.
184+
173185
.. attribute:: db
174186

175187
The database that will be used if this query is executed now.

docs/releases/6.1.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,10 @@ Models
287287
* :class:`~django.db.models.StringAgg` now supports ``distinct=True`` on SQLite
288288
when using the default delimiter ``Value(",")`` only.
289289

290+
* The new :attr:`.QuerySet.totally_ordered` property returns ``True`` if the
291+
:class:`~django.db.models.query.QuerySet` is ordered and the ordering is
292+
deterministic.
293+
290294
Pagination
291295
~~~~~~~~~~
292296

tests/admin_changelist/tests.py

Lines changed: 2 additions & 172 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@
1717
)
1818
from django.contrib.auth.models import User
1919
from django.contrib.messages.storage.cookie import CookieStorage
20-
from django.db import DatabaseError, connection, models
20+
from django.db import DatabaseError, connection
2121
from django.db.models import F, Field, IntegerField
2222
from django.db.models.functions import Upper
2323
from django.db.models.lookups import Contains, Exact
2424
from django.template import Context, Template, TemplateSyntaxError
2525
from django.test import TestCase, override_settings, skipUnlessDBFeature
2626
from django.test.client import RequestFactory
27-
from django.test.utils import CaptureQueriesContext, isolate_apps, register_lookup
27+
from django.test.utils import CaptureQueriesContext, register_lookup
2828
from django.urls import reverse
2929
from django.utils import formats
3030

@@ -1582,176 +1582,6 @@ def check_results_order(ascending=False):
15821582
OrderedObjectAdmin.ordering = ["id", "bool"]
15831583
check_results_order(ascending=True)
15841584

1585-
@isolate_apps("admin_changelist")
1586-
def test_total_ordering_optimization(self):
1587-
class Related(models.Model):
1588-
unique_field = models.BooleanField(unique=True)
1589-
1590-
class Meta:
1591-
ordering = ("unique_field",)
1592-
1593-
class Model(models.Model):
1594-
unique_field = models.BooleanField(unique=True)
1595-
unique_nullable_field = models.BooleanField(unique=True, null=True)
1596-
related = models.ForeignKey(Related, models.CASCADE)
1597-
other_related = models.ForeignKey(Related, models.CASCADE)
1598-
related_unique = models.OneToOneField(Related, models.CASCADE)
1599-
field = models.BooleanField()
1600-
other_field = models.BooleanField()
1601-
null_field = models.BooleanField(null=True)
1602-
1603-
class Meta:
1604-
unique_together = {
1605-
("field", "other_field"),
1606-
("field", "null_field"),
1607-
("related", "other_related_id"),
1608-
}
1609-
1610-
class ModelAdmin(admin.ModelAdmin):
1611-
def get_queryset(self, request):
1612-
return Model.objects.none()
1613-
1614-
request = self._mocked_authenticated_request("/", self.superuser)
1615-
site = admin.AdminSite(name="admin")
1616-
model_admin = ModelAdmin(Model, site)
1617-
change_list = model_admin.get_changelist_instance(request)
1618-
tests = (
1619-
([], ["-pk"]),
1620-
# Unique non-nullable field.
1621-
(["unique_field"], ["unique_field"]),
1622-
(["-unique_field"], ["-unique_field"]),
1623-
# Unique nullable field.
1624-
(["unique_nullable_field"], ["unique_nullable_field", "-pk"]),
1625-
# Field.
1626-
(["field"], ["field", "-pk"]),
1627-
# Related field introspection is not implemented.
1628-
(["related__unique_field"], ["related__unique_field", "-pk"]),
1629-
# Related attname unique.
1630-
(["related_unique_id"], ["related_unique_id"]),
1631-
# Related ordering introspection is not implemented.
1632-
(["related_unique"], ["related_unique", "-pk"]),
1633-
# Composite unique.
1634-
(["field", "-other_field"], ["field", "-other_field"]),
1635-
# Composite unique nullable.
1636-
(["-field", "null_field"], ["-field", "null_field", "-pk"]),
1637-
# Composite unique and nullable.
1638-
(
1639-
["-field", "null_field", "other_field"],
1640-
["-field", "null_field", "other_field"],
1641-
),
1642-
# Composite unique attnames.
1643-
(["related_id", "-other_related_id"], ["related_id", "-other_related_id"]),
1644-
# Composite unique names.
1645-
(["related", "-other_related_id"], ["related", "-other_related_id", "-pk"]),
1646-
)
1647-
# F() objects composite unique.
1648-
total_ordering = [F("field"), F("other_field").desc(nulls_last=True)]
1649-
# F() objects composite unique nullable.
1650-
non_total_ordering = [F("field"), F("null_field").desc(nulls_last=True)]
1651-
tests += (
1652-
(total_ordering, total_ordering),
1653-
(non_total_ordering, non_total_ordering + ["-pk"]),
1654-
)
1655-
for ordering, expected in tests:
1656-
with self.subTest(ordering=ordering):
1657-
self.assertEqual(
1658-
change_list._get_deterministic_ordering(ordering), expected
1659-
)
1660-
1661-
@isolate_apps("admin_changelist")
1662-
def test_total_ordering_optimization_meta_constraints(self):
1663-
class Related(models.Model):
1664-
unique_field = models.BooleanField(unique=True)
1665-
1666-
class Meta:
1667-
ordering = ("unique_field",)
1668-
1669-
class Model(models.Model):
1670-
field_1 = models.BooleanField()
1671-
field_2 = models.BooleanField()
1672-
field_3 = models.BooleanField()
1673-
field_4 = models.BooleanField()
1674-
field_5 = models.BooleanField()
1675-
field_6 = models.BooleanField()
1676-
nullable_1 = models.BooleanField(null=True)
1677-
nullable_2 = models.BooleanField(null=True)
1678-
related_1 = models.ForeignKey(Related, models.CASCADE)
1679-
related_2 = models.ForeignKey(Related, models.CASCADE)
1680-
related_3 = models.ForeignKey(Related, models.CASCADE)
1681-
related_4 = models.ForeignKey(Related, models.CASCADE)
1682-
1683-
class Meta:
1684-
constraints = [
1685-
*[
1686-
models.UniqueConstraint(fields=fields, name="".join(fields))
1687-
for fields in (
1688-
["field_1"],
1689-
["nullable_1"],
1690-
["related_1"],
1691-
["related_2_id"],
1692-
["field_2", "field_3"],
1693-
["field_2", "nullable_2"],
1694-
["field_2", "related_3"],
1695-
["field_3", "related_4_id"],
1696-
)
1697-
],
1698-
models.CheckConstraint(condition=models.Q(id__gt=0), name="foo"),
1699-
models.UniqueConstraint(
1700-
fields=["field_5"],
1701-
condition=models.Q(id__gt=10),
1702-
name="total_ordering_1",
1703-
),
1704-
models.UniqueConstraint(
1705-
fields=["field_6"],
1706-
condition=models.Q(),
1707-
name="total_ordering",
1708-
),
1709-
]
1710-
1711-
class ModelAdmin(admin.ModelAdmin):
1712-
def get_queryset(self, request):
1713-
return Model.objects.none()
1714-
1715-
request = self._mocked_authenticated_request("/", self.superuser)
1716-
site = admin.AdminSite(name="admin")
1717-
model_admin = ModelAdmin(Model, site)
1718-
change_list = model_admin.get_changelist_instance(request)
1719-
tests = (
1720-
# Unique non-nullable field.
1721-
(["field_1"], ["field_1"]),
1722-
# Unique nullable field.
1723-
(["nullable_1"], ["nullable_1", "-pk"]),
1724-
# Related attname unique.
1725-
(["related_1_id"], ["related_1_id"]),
1726-
(["related_2_id"], ["related_2_id"]),
1727-
# Related ordering introspection is not implemented.
1728-
(["related_1"], ["related_1", "-pk"]),
1729-
# Composite unique.
1730-
(["-field_2", "field_3"], ["-field_2", "field_3"]),
1731-
# Composite unique nullable.
1732-
(["field_2", "-nullable_2"], ["field_2", "-nullable_2", "-pk"]),
1733-
# Composite unique and nullable.
1734-
(
1735-
["field_2", "-nullable_2", "field_3"],
1736-
["field_2", "-nullable_2", "field_3"],
1737-
),
1738-
# Composite field and related field name.
1739-
(["field_2", "-related_3"], ["field_2", "-related_3", "-pk"]),
1740-
(["field_3", "related_4"], ["field_3", "related_4", "-pk"]),
1741-
# Composite field and related field attname.
1742-
(["field_2", "related_3_id"], ["field_2", "related_3_id"]),
1743-
(["field_3", "-related_4_id"], ["field_3", "-related_4_id"]),
1744-
# Partial unique constraint is ignored.
1745-
(["field_5"], ["field_5", "-pk"]),
1746-
# Unique constraint with an empty condition.
1747-
(["field_6"], ["field_6"]),
1748-
)
1749-
for ordering, expected in tests:
1750-
with self.subTest(ordering=ordering):
1751-
self.assertEqual(
1752-
change_list._get_deterministic_ordering(ordering), expected
1753-
)
1754-
17551585
def test_dynamic_list_filter(self):
17561586
"""
17571587
Regression tests for ticket #17646: dynamic list_filter support.

0 commit comments

Comments
 (0)