Skip to content

Commit ddb8529

Browse files
ontowheesarahboyce
authored andcommitted
Fixed #34262 -- Added support for AnyValue for SQLite, MySQL, Oracle, and Postgresql 16+.
Thanks Simon Charette for the guidance and review. Thanks Tim Schilling for the documentation review. Thanks David Wobrock for investigation and solution proposals.
1 parent f603ece commit ddb8529

File tree

11 files changed

+212
-11
lines changed

11 files changed

+212
-11
lines changed

django/db/backends/base/features.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,9 @@ class BaseDatabaseFeatures:
266266
# delimiter along with DISTINCT.
267267
supports_aggregate_distinct_multiple_argument = True
268268

269+
# Does the database support SQL 2023 ANY_VALUE in GROUP BY?
270+
supports_any_value = False
271+
269272
# Does the backend support indexing a TextField?
270273
supports_index_on_text_field = True
271274

django/db/backends/mysql/features.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -111,16 +111,6 @@ def django_test_skips(self):
111111
},
112112
}
113113
)
114-
if "ONLY_FULL_GROUP_BY" in self.connection.sql_mode:
115-
skips.update(
116-
{
117-
"GROUP BY cannot contain nonaggregated column when "
118-
"ONLY_FULL_GROUP_BY mode is enabled on MySQL, see #34262.": {
119-
"aggregation.tests.AggregateTestCase."
120-
"test_group_by_nested_expression_with_params",
121-
},
122-
}
123-
)
124114
if self.connection.mysql_version < (8, 0, 31):
125115
skips.update(
126116
{
@@ -297,3 +287,7 @@ def allows_group_by_selected_pks(self):
297287
if self.connection.mysql_is_mariadb:
298288
return "ONLY_FULL_GROUP_BY" not in self.connection.sql_mode
299289
return True
290+
291+
@cached_property
292+
def supports_any_value(self):
293+
return not self.connection.mysql_is_mariadb

django/db/backends/oracle/features.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
6161
END;
6262
"""
6363
supports_callproc_kwargs = True
64+
supports_any_value = True
6465
supports_over_clause = True
6566
supports_frame_range_fixed_distance = True
6667
supports_ignore_conflicts = False

django/db/backends/postgresql/features.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,5 @@ def is_postgresql_17(self):
162162
supports_nulls_distinct_unique_constraints = property(
163163
operator.attrgetter("is_postgresql_15")
164164
)
165+
166+
supports_any_value = property(operator.attrgetter("is_postgresql_16"))

django/db/backends/sqlite3/_functions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def register(connection):
8080
connection.create_aggregate("STDDEV_SAMP", 1, StdDevSamp)
8181
connection.create_aggregate("VAR_POP", 1, VarPop)
8282
connection.create_aggregate("VAR_SAMP", 1, VarSamp)
83+
connection.create_aggregate("ANY_VALUE", 1, AnyValue)
8384
# Some math functions are enabled by default in SQLite 3.35+.
8485
sql = "select sqlite_compileoption_used('ENABLE_MATH_FUNCTIONS')"
8586
if not connection.execute(sql).fetchone()[0]:
@@ -513,3 +514,8 @@ class VarPop(ListAggregate):
513514

514515
class VarSamp(ListAggregate):
515516
finalize = statistics.variance
517+
518+
519+
class AnyValue(ListAggregate):
520+
def finalize(self):
521+
return self[0]

django/db/backends/sqlite3/features.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
3636
supports_aggregate_filter_clause = True
3737
supports_aggregate_order_by_clause = Database.sqlite_version_info >= (3, 44, 0)
3838
supports_aggregate_distinct_multiple_argument = False
39+
supports_any_value = True
3940
order_by_nulls_first = True
4041
supports_json_field_contains = False
4142
supports_update_conflicts = True

django/db/models/aggregates.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
__all__ = [
2424
"Aggregate",
25+
"AnyValue",
2526
"Avg",
2627
"Count",
2728
"Max",
@@ -229,6 +230,20 @@ def _get_repr_options(self):
229230
return options
230231

231232

233+
class AnyValue(Aggregate):
234+
function = "ANY_VALUE"
235+
name = "AnyValue"
236+
arity = 1
237+
window_compatible = False
238+
239+
def as_sql(self, compiler, connection, **extra_context):
240+
if not connection.features.supports_any_value:
241+
raise NotSupportedError(
242+
"ANY_VALUE is not supported on this database backend."
243+
)
244+
return super().as_sql(compiler, connection, **extra_context)
245+
246+
232247
class Avg(FixDurationInputMixin, NumericOutputFieldMixin, Aggregate):
233248
function = "AVG"
234249
name = "Avg"

docs/ref/models/querysets.txt

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3943,6 +3943,60 @@ when the queryset (or grouping) contains no entries.
39433943
Keyword arguments that can provide extra context for the SQL generated
39443944
by the aggregate.
39453945

3946+
``AnyValue``
3947+
~~~~~~~~~~~~
3948+
3949+
.. versionadded:: 6.0
3950+
3951+
.. class:: AnyValue(expression, output_field=None, filter=None, default=None, **extra)
3952+
3953+
Returns an arbitrary value from the non-null input values.
3954+
3955+
* Default alias: ``<field>__anyvalue``
3956+
* Return type: same as input field, or ``output_field`` if supplied. If the
3957+
queryset or grouping is empty, ``default`` is returned.
3958+
3959+
Usage example:
3960+
3961+
.. code-block:: pycon
3962+
3963+
>>> # Get average rating for each year along with a sample headline
3964+
>>> # from that year.
3965+
>>> from django.db.models import AnyValue, Avg, F, Q
3966+
>>> sample_headline = AnyValue("headline")
3967+
>>> Entry.objects.values(
3968+
... pub_year=F("pub_date__year"),
3969+
... ).annotate(
3970+
... avg_rating=Avg("rating"),
3971+
... sample_headline=sample_headline,
3972+
... )
3973+
3974+
>>> # Get a sample headline from each year with rating greater than 4.5.
3975+
>>> sample_headline = AnyValue(
3976+
... "headline",
3977+
... filter=Q(rating__gt=4.5),
3978+
... )
3979+
>>> Entry.objects.values(
3980+
... pub_year=F("pub_date__year"),
3981+
... ).annotate(
3982+
... avg_rating=Avg("rating"),
3983+
... sample_headline=sample_headline,
3984+
... )
3985+
3986+
Supported on SQLite, MySQL, Oracle, and PostgreSQL 16+.
3987+
3988+
.. admonition:: MySQL with ``ONLY_FULL_GROUP_BY`` enabled
3989+
3990+
When the ``ONLY_FULL_GROUP_BY`` SQL mode is enabled on MySQL it may be
3991+
necessary to use ``AnyValue`` if an aggregation includes a mix of
3992+
aggregate and non-aggregate functions. Using ``AnyValue`` allows the
3993+
non-aggregate function to be referenced in the select list when
3994+
database cannot determine that it is functionally dependent on the
3995+
columns in the `group by`_ clause. See the :ref:`aggregation
3996+
documentation <aggregation-mysql-only-full-group-by>` for more details.
3997+
3998+
.. _group by: https://dev.mysql.com/doc/refman/8.4/en/group-by-handling.html
3999+
39464000
``Avg``
39474001
~~~~~~~
39484002

docs/releases/6.0.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,10 @@ Models
212212
* :class:`~django.db.models.JSONField` now supports
213213
:ref:`negative array indexing <key-index-and-path-transforms>` on SQLite.
214214

215+
* The new :class:`~django.db.models.AnyValue` aggregate returns an arbitrary
216+
value from the non-null input values. This is supported on SQLite, MySQL,
217+
Oracle, and PostgreSQL 16+.
218+
215219
Pagination
216220
~~~~~~~~~~
217221

docs/topics/db/aggregation.txt

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,3 +679,65 @@ no books can be found:
679679
Under the hood, the :ref:`default <aggregate-default>` argument is implemented
680680
by wrapping the aggregate function with
681681
:class:`~django.db.models.functions.Coalesce`.
682+
683+
.. _aggregation-mysql-only-full-group-by:
684+
685+
Aggregating with MySQL ``ONLY_FULL_GROUP_BY`` enabled
686+
-----------------------------------------------------
687+
688+
When using the ``values()`` clause to group query results for annotations in
689+
MySQL with the ``ONLY_FULL_GROUP_BY`` SQL mode enabled, you may need to apply
690+
:class:`~django.db.models.AnyValue` if the annotation includes a mix of
691+
aggregate and non-aggregate expressions.
692+
693+
Take the following example:
694+
695+
.. code-block:: pycon
696+
697+
>>> from django.db.models import F, Count, Greatest
698+
>>> Book.objects.values(greatest_pages=Greatest("pages", 600)).annotate(
699+
... num_authors=Count("authors"),
700+
... pages_per_author=F("greatest_pages") / F("num_authors"),
701+
... ).aggregate(Avg("pages_per_author"))
702+
703+
This creates groups of books based on the SQL column ``GREATEST(pages, 600)``.
704+
One unique group consists of books with 600 pages or less, and other unique
705+
groups will consist of books with the same pages. The ``pages_per_author``
706+
annotation is composed of aggregate and non-aggregate expressions,
707+
``num_authors`` is an aggregate expression while ``greatest_page`` isn't.
708+
709+
Since the grouping is based on the ``greatest_pages`` expression, MySQL may be
710+
unable to determine that ``greatest_pages`` (used in the ``pages_per_author``
711+
expression) is functionally dependent on the grouped column. As a result, it
712+
may raise an error like:
713+
714+
.. code-block:: pytb
715+
716+
OperationalError: (1055, "Expression #2 of SELECT list is not in GROUP BY
717+
clause and contains nonaggregated column 'book_book.pages' which is not
718+
functionally dependent on columns in GROUP BY clause; this is incompatible
719+
with sql_mode=only_full_group_by")
720+
721+
To avoid this, you can wrap the non-aggregate expression with
722+
:class:`~django.db.models.AnyValue`.
723+
724+
.. code-block:: pycon
725+
726+
>>> from django.db.models import F, Count, Greatest
727+
>>> Book.objects.values(
728+
... greatest_pages=Greatest("pages", 600),
729+
... ).annotate(
730+
... num_authors=Count("authors"),
731+
... pages_per_author=AnyValue(F("greatest_pages")) / F("num_authors"),
732+
... ).aggregate(Avg("pages_per_author"))
733+
{'pages_per_author__avg': 532.57143333}
734+
735+
Other supported databases do not encounter the ``OperationalError`` in the
736+
example above because they can detect the functional dependency. In general,
737+
``AnyValue`` is useful when dealing with select list columns that involve
738+
non-aggregate functions or complex expressions not recognized by the database
739+
as functionally dependent on the columns in the grouping clause.
740+
741+
.. versionchanged:: 6.0
742+
743+
The :class:`~django.db.models.AnyValue` aggregate was added.

tests/aggregation/tests.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from django.core.exceptions import FieldError
77
from django.db import NotSupportedError, connection
88
from django.db.models import (
9+
AnyValue,
910
Avg,
1011
Case,
1112
CharField,
@@ -1662,19 +1663,77 @@ def test_group_by_exists_annotation(self):
16621663
self.assertEqual(dict(has_long_books_breakdown), {True: 2, False: 3})
16631664

16641665
def test_group_by_nested_expression_with_params(self):
1666+
greatest_pages_param = "greatest_pages"
1667+
if connection.vendor == "mysql" and connection.features.supports_any_value:
1668+
greatest_pages_param = AnyValue("greatest_pages")
1669+
16651670
books_qs = (
16661671
Book.objects.annotate(greatest_pages=Greatest("pages", Value(600)))
16671672
.values(
16681673
"greatest_pages",
16691674
)
16701675
.annotate(
16711676
min_pages=Min("pages"),
1672-
least=Least("min_pages", "greatest_pages"),
1677+
least=Least("min_pages", greatest_pages_param),
16731678
)
16741679
.values_list("least", flat=True)
16751680
)
16761681
self.assertCountEqual(books_qs, [300, 946, 1132])
16771682

1683+
@skipUnlessDBFeature("supports_any_value")
1684+
def test_any_value(self):
1685+
books_qs = (
1686+
Book.objects.values(greatest_pages=Greatest("pages", 600))
1687+
.annotate(
1688+
pubdate_year=AnyValue("pubdate__year"),
1689+
)
1690+
.values_list("pubdate_year", flat=True)
1691+
.order_by("pubdate_year")
1692+
)
1693+
self.assertCountEqual(books_qs[0:2], [1991, 1995])
1694+
self.assertIn(books_qs[2], [2007, 2008])
1695+
1696+
@skipUnlessDBFeature("supports_any_value")
1697+
def test_any_value_filter(self):
1698+
books_qs = (
1699+
Book.objects.values(greatest_pages=Greatest("pages", 600))
1700+
.annotate(
1701+
pubdate_year=AnyValue("pubdate__year", filter=Q(rating__lte=4.5)),
1702+
)
1703+
.values_list("pubdate_year", flat=True)
1704+
)
1705+
self.assertCountEqual(books_qs, [2007, 1995, None])
1706+
1707+
@skipUnlessDBFeature("supports_any_value")
1708+
def test_any_value_aggregate_clause(self):
1709+
books_qs = (
1710+
Book.objects.values(greatest_pages=Greatest("pages", 600))
1711+
.annotate(
1712+
num_authors=Count("authors"),
1713+
pages_per_author=(
1714+
AnyValue("greatest_pages") / (Cast("num_authors", FloatField()))
1715+
),
1716+
)
1717+
.values_list("pages_per_author", flat=True)
1718+
.order_by("pages_per_author")
1719+
)
1720+
self.assertAlmostEqual(books_qs[0], 600 / 7, places=4)
1721+
self.assertAlmostEqual(books_qs[1], 1132 / 2, places=4)
1722+
self.assertAlmostEqual(books_qs[2], 946 / 1, places=4)
1723+
1724+
aggregate_qs = books_qs.aggregate(Avg("pages_per_author"))
1725+
self.assertAlmostEqual(
1726+
aggregate_qs["pages_per_author__avg"],
1727+
((600 / 7) + (1132 / 2) + (946 / 1)) / 3,
1728+
places=4,
1729+
)
1730+
1731+
@skipIfDBFeature("supports_any_value")
1732+
def test_any_value_not_supported(self):
1733+
message = "ANY_VALUE is not supported on this database backend."
1734+
with self.assertRaisesMessage(NotSupportedError, message):
1735+
Book.objects.aggregate(AnyValue("rating"))
1736+
16781737
@skipUnlessDBFeature("supports_subqueries_in_group_by")
16791738
def test_aggregation_subquery_annotation_related_field(self):
16801739
publisher = Publisher.objects.create(name=self.a9.name, num_awards=2)

0 commit comments

Comments
 (0)