Refs #10929 -- Deprecated forced empty result value for PostgreSQL aggregates.
This deprecates forcing a return value for ArrayAgg, JSONBAgg, and StringAgg when there are no rows in the query. Now that we have a ``default`` argument for aggregates, we want to revert to returning the default of ``None`` which most aggregate functions return and leave it up to the user to decide what they want to be returned by default.
This commit is contained in:
parent
501a8db465
commit
fee8734596
|
@ -1,5 +1,8 @@
|
||||||
|
import warnings
|
||||||
|
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.db.models import Aggregate, BooleanField, JSONField, Value
|
from django.db.models import Aggregate, BooleanField, JSONField, Value
|
||||||
|
from django.utils.deprecation import RemovedInDjango50Warning
|
||||||
|
|
||||||
from .mixins import OrderableAggMixin
|
from .mixins import OrderableAggMixin
|
||||||
|
|
||||||
|
@ -8,20 +11,44 @@ __all__ = [
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class ArrayAgg(OrderableAggMixin, Aggregate):
|
# RemovedInDjango50Warning
|
||||||
|
NOT_PROVIDED = object()
|
||||||
|
|
||||||
|
|
||||||
|
class DeprecatedConvertValueMixin:
|
||||||
|
def __init__(self, *expressions, default=NOT_PROVIDED, **extra):
|
||||||
|
if default is NOT_PROVIDED:
|
||||||
|
default = None
|
||||||
|
self._default_provided = False
|
||||||
|
else:
|
||||||
|
self._default_provided = True
|
||||||
|
super().__init__(*expressions, default=default, **extra)
|
||||||
|
|
||||||
|
def convert_value(self, value, expression, connection):
|
||||||
|
if value is None and not self._default_provided:
|
||||||
|
warnings.warn(self.deprecation_msg, category=RemovedInDjango50Warning)
|
||||||
|
return self.deprecation_value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class ArrayAgg(DeprecatedConvertValueMixin, OrderableAggMixin, Aggregate):
|
||||||
function = 'ARRAY_AGG'
|
function = 'ARRAY_AGG'
|
||||||
template = '%(function)s(%(distinct)s%(expressions)s %(ordering)s)'
|
template = '%(function)s(%(distinct)s%(expressions)s %(ordering)s)'
|
||||||
allow_distinct = True
|
allow_distinct = True
|
||||||
|
|
||||||
|
# RemovedInDjango50Warning
|
||||||
|
deprecation_value = property(lambda self: [])
|
||||||
|
deprecation_msg = (
|
||||||
|
'In Django 5.0, ArrayAgg() will return None instead of an empty list '
|
||||||
|
'if there are no rows. Pass default=None to opt into the new behavior '
|
||||||
|
'and silence this warning or default=Value([]) to keep the previous '
|
||||||
|
'behavior.'
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def output_field(self):
|
def output_field(self):
|
||||||
return ArrayField(self.source_expressions[0].output_field)
|
return ArrayField(self.source_expressions[0].output_field)
|
||||||
|
|
||||||
def convert_value(self, value, expression, connection):
|
|
||||||
if value is None and self.default is None:
|
|
||||||
return []
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class BitAnd(Aggregate):
|
class BitAnd(Aggregate):
|
||||||
function = 'BIT_AND'
|
function = 'BIT_AND'
|
||||||
|
@ -41,28 +68,36 @@ class BoolOr(Aggregate):
|
||||||
output_field = BooleanField()
|
output_field = BooleanField()
|
||||||
|
|
||||||
|
|
||||||
class JSONBAgg(OrderableAggMixin, Aggregate):
|
class JSONBAgg(DeprecatedConvertValueMixin, OrderableAggMixin, Aggregate):
|
||||||
function = 'JSONB_AGG'
|
function = 'JSONB_AGG'
|
||||||
template = '%(function)s(%(distinct)s%(expressions)s %(ordering)s)'
|
template = '%(function)s(%(distinct)s%(expressions)s %(ordering)s)'
|
||||||
allow_distinct = True
|
allow_distinct = True
|
||||||
output_field = JSONField()
|
output_field = JSONField()
|
||||||
|
|
||||||
def convert_value(self, value, expression, connection):
|
# RemovedInDjango50Warning
|
||||||
if value is None and self.default is None:
|
deprecation_value = '[]'
|
||||||
return '[]'
|
deprecation_msg = (
|
||||||
return value
|
"In Django 5.0, JSONBAgg() will return None instead of an empty list "
|
||||||
|
"if there are no rows. Pass default=None to opt into the new behavior "
|
||||||
|
"and silence this warning or default=Value('[]') to keep the previous "
|
||||||
|
"behavior."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class StringAgg(OrderableAggMixin, Aggregate):
|
class StringAgg(DeprecatedConvertValueMixin, OrderableAggMixin, Aggregate):
|
||||||
function = 'STRING_AGG'
|
function = 'STRING_AGG'
|
||||||
template = '%(function)s(%(distinct)s%(expressions)s %(ordering)s)'
|
template = '%(function)s(%(distinct)s%(expressions)s %(ordering)s)'
|
||||||
allow_distinct = True
|
allow_distinct = True
|
||||||
|
|
||||||
|
# RemovedInDjango50Warning
|
||||||
|
deprecation_value = ''
|
||||||
|
deprecation_msg = (
|
||||||
|
"In Django 5.0, StringAgg() will return None instead of an empty "
|
||||||
|
"string if there are no rows. Pass default=None to opt into the new "
|
||||||
|
"behavior and silence this warning or default=Value('') to keep the "
|
||||||
|
"previous behavior."
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, expression, delimiter, **extra):
|
def __init__(self, expression, delimiter, **extra):
|
||||||
delimiter_expr = Value(str(delimiter))
|
delimiter_expr = Value(str(delimiter))
|
||||||
super().__init__(expression, delimiter_expr, **extra)
|
super().__init__(expression, delimiter_expr, **extra)
|
||||||
|
|
||||||
def convert_value(self, value, expression, connection):
|
|
||||||
if value is None and self.default is None:
|
|
||||||
return ''
|
|
||||||
return value
|
|
||||||
|
|
|
@ -30,6 +30,10 @@ details on these changes.
|
||||||
* The ``extra_tests`` argument for ``DiscoverRunner.build_suite()`` and
|
* The ``extra_tests`` argument for ``DiscoverRunner.build_suite()`` and
|
||||||
``DiscoverRunner.run_tests()`` will be removed.
|
``DiscoverRunner.run_tests()`` will be removed.
|
||||||
|
|
||||||
|
* The ``django.contrib.postgres.aggregates.ArrayAgg``, ``JSONBAgg``, and
|
||||||
|
``StringAgg`` aggregates will return ``None`` when there are no rows instead
|
||||||
|
of ``[]``, ``[]``, and ``''`` respectively.
|
||||||
|
|
||||||
.. _deprecation-removed-in-4.1:
|
.. _deprecation-removed-in-4.1:
|
||||||
|
|
||||||
4.1
|
4.1
|
||||||
|
|
|
@ -52,6 +52,13 @@ General-purpose aggregation functions
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
F('some_field').desc()
|
F('some_field').desc()
|
||||||
|
|
||||||
|
.. deprecated:: 4.0
|
||||||
|
|
||||||
|
If there are no rows and ``default`` is not provided, ``ArrayAgg``
|
||||||
|
returns an empty list instead of ``None``. This behavior is deprecated
|
||||||
|
and will be removed in Django 5.0. If you need it, explicitly set
|
||||||
|
``default`` to ``Value([])``.
|
||||||
|
|
||||||
``BitAnd``
|
``BitAnd``
|
||||||
----------
|
----------
|
||||||
|
|
||||||
|
@ -138,6 +145,13 @@ General-purpose aggregation functions
|
||||||
|
|
||||||
Examples are the same as for :attr:`ArrayAgg.ordering`.
|
Examples are the same as for :attr:`ArrayAgg.ordering`.
|
||||||
|
|
||||||
|
.. deprecated:: 4.0
|
||||||
|
|
||||||
|
If there are no rows and ``default`` is not provided, ``JSONBAgg``
|
||||||
|
returns an empty list instead of ``None``. This behavior is deprecated
|
||||||
|
and will be removed in Django 5.0. If you need it, explicitly set
|
||||||
|
``default`` to ``Value('[]')``.
|
||||||
|
|
||||||
``StringAgg``
|
``StringAgg``
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
|
@ -164,6 +178,13 @@ General-purpose aggregation functions
|
||||||
|
|
||||||
Examples are the same as for :attr:`ArrayAgg.ordering`.
|
Examples are the same as for :attr:`ArrayAgg.ordering`.
|
||||||
|
|
||||||
|
.. deprecated:: 4.0
|
||||||
|
|
||||||
|
If there are no rows and ``default`` is not provided, ``StringAgg``
|
||||||
|
returns an empty string instead of ``None``. This behavior is
|
||||||
|
deprecated and will be removed in Django 5.0. If you need it,
|
||||||
|
explicitly set ``default`` to ``Value('')``.
|
||||||
|
|
||||||
Aggregate functions for statistics
|
Aggregate functions for statistics
|
||||||
==================================
|
==================================
|
||||||
|
|
||||||
|
|
|
@ -540,6 +540,13 @@ Miscellaneous
|
||||||
* The ``extra_tests`` argument for :meth:`.DiscoverRunner.build_suite` and
|
* The ``extra_tests`` argument for :meth:`.DiscoverRunner.build_suite` and
|
||||||
:meth:`.DiscoverRunner.run_tests` is deprecated.
|
:meth:`.DiscoverRunner.run_tests` is deprecated.
|
||||||
|
|
||||||
|
* The :class:`~django.contrib.postgres.aggregates.ArrayAgg`,
|
||||||
|
:class:`~django.contrib.postgres.aggregates.JSONBAgg`, and
|
||||||
|
:class:`~django.contrib.postgres.aggregates.StringAgg` aggregates will return
|
||||||
|
``None`` when there are no rows instead of ``[]``, ``[]``, and ``''``
|
||||||
|
respectively in Django 5.0. If you need the previous behavior, explicitly set
|
||||||
|
``default`` to ``Value([])``, ``Value('[]')``, or ``Value('')``.
|
||||||
|
|
||||||
Features removed in 4.0
|
Features removed in 4.0
|
||||||
=======================
|
=======================
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,8 @@ from django.db.models import (
|
||||||
)
|
)
|
||||||
from django.db.models.fields.json import KeyTextTransform, KeyTransform
|
from django.db.models.fields.json import KeyTextTransform, KeyTransform
|
||||||
from django.db.models.functions import Cast, Concat, Substr
|
from django.db.models.functions import Cast, Concat, Substr
|
||||||
from django.test.utils import Approximate
|
from django.test.utils import Approximate, ignore_warnings
|
||||||
|
from django.utils.deprecation import RemovedInDjango50Warning
|
||||||
|
|
||||||
from . import PostgreSQLTestCase
|
from . import PostgreSQLTestCase
|
||||||
from .models import AggregateTestModel, StatTestModel
|
from .models import AggregateTestModel, StatTestModel
|
||||||
|
@ -44,6 +45,7 @@ class TestGeneralAggregate(PostgreSQLTestCase):
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
@ignore_warnings(category=RemovedInDjango50Warning)
|
||||||
def test_empty_result_set(self):
|
def test_empty_result_set(self):
|
||||||
AggregateTestModel.objects.all().delete()
|
AggregateTestModel.objects.all().delete()
|
||||||
tests = [
|
tests = [
|
||||||
|
@ -100,6 +102,49 @@ class TestGeneralAggregate(PostgreSQLTestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(values, {'aggregation': expected_result})
|
self.assertEqual(values, {'aggregation': expected_result})
|
||||||
|
|
||||||
|
def test_convert_value_deprecation(self):
|
||||||
|
AggregateTestModel.objects.all().delete()
|
||||||
|
queryset = AggregateTestModel.objects.all()
|
||||||
|
|
||||||
|
with self.assertWarnsMessage(RemovedInDjango50Warning, ArrayAgg.deprecation_msg):
|
||||||
|
queryset.aggregate(aggregation=ArrayAgg('boolean_field'))
|
||||||
|
|
||||||
|
with self.assertWarnsMessage(RemovedInDjango50Warning, JSONBAgg.deprecation_msg):
|
||||||
|
queryset.aggregate(aggregation=JSONBAgg('integer_field'))
|
||||||
|
|
||||||
|
with self.assertWarnsMessage(RemovedInDjango50Warning, StringAgg.deprecation_msg):
|
||||||
|
queryset.aggregate(aggregation=StringAgg('char_field', delimiter=';'))
|
||||||
|
|
||||||
|
# No warnings raised if default argument provided.
|
||||||
|
self.assertEqual(
|
||||||
|
queryset.aggregate(aggregation=ArrayAgg('boolean_field', default=None)),
|
||||||
|
{'aggregation': None},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
queryset.aggregate(aggregation=JSONBAgg('integer_field', default=None)),
|
||||||
|
{'aggregation': None},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
queryset.aggregate(
|
||||||
|
aggregation=StringAgg('char_field', delimiter=';', default=None),
|
||||||
|
),
|
||||||
|
{'aggregation': None},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
queryset.aggregate(aggregation=ArrayAgg('boolean_field', default=Value([]))),
|
||||||
|
{'aggregation': []},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
queryset.aggregate(aggregation=JSONBAgg('integer_field', default=Value('[]'))),
|
||||||
|
{'aggregation': []},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
queryset.aggregate(
|
||||||
|
aggregation=StringAgg('char_field', delimiter=';', default=Value('')),
|
||||||
|
),
|
||||||
|
{'aggregation': ''},
|
||||||
|
)
|
||||||
|
|
||||||
def test_array_agg_charfield(self):
|
def test_array_agg_charfield(self):
|
||||||
values = AggregateTestModel.objects.aggregate(arrayagg=ArrayAgg('char_field'))
|
values = AggregateTestModel.objects.aggregate(arrayagg=ArrayAgg('char_field'))
|
||||||
self.assertEqual(values, {'arrayagg': ['Foo1', 'Foo2', 'Foo4', 'Foo3']})
|
self.assertEqual(values, {'arrayagg': ['Foo1', 'Foo2', 'Foo4', 'Foo3']})
|
||||||
|
|
Loading…
Reference in New Issue