Fixed #35462 -- Added support for JSON_ArrayAgg aggregate function.

Supported the JSON_ARRAYAGG aggregate function
and JSON_GROUP_ARRAY as a variant to add support
for sqlite database. Added tests for the JSON_ARRAYAGG
and JSON_GROUP_ARRAY aggregate functions.
Documented JSONArrayAgg and added a release note.
This commit is contained in:
lufafajoshua 2025-04-21 15:00:36 +03:00
parent 1831f7733d
commit 51db6a80ba
4 changed files with 175 additions and 0 deletions

View file

@ -14,6 +14,7 @@ from django.db.models.expressions import (
When,
)
from django.db.models.fields import IntegerField, TextField
from django.db.models.fields.json import JSONField
from django.db.models.functions import Coalesce
from django.db.models.functions.mixins import (
FixDurationInputMixin,
@ -30,6 +31,7 @@ __all__ = [
"StringAgg",
"Sum",
"Variance",
"JSONArrayAgg",
]
@ -392,3 +394,61 @@ class Variance(NumericOutputFieldMixin, Aggregate):
def _get_repr_options(self):
return {**super()._get_repr_options(), "sample": self.function == "VAR_SAMP"}
class JSONArrayAgg(Aggregate):
function = "JSON_ARRAYAGG"
output_field = JSONField()
arity = 1
def as_sql(self, compiler, connection, **extra_context):
if self.filter and not connection.features.supports_aggregate_filter_clause:
raise NotSupportedError(
"JSONArrayAgg(filter) is not supported on this database backend."
)
return super().as_sql(compiler, connection, **extra_context)
def as_sqlite(self, compiler, connection, **extra_context):
sql, params = self.as_sql(
compiler, connection, function="JSON_GROUP_ARRAY", **extra_context
)
# JSON_GROUP_ARRAY defaults to returning an empty array on an empty set.
# Modifies the SQL to support a custom default value to be returned,
# if a default argument is not passed, null is returned instead of [].
if (default := self.default) == []:
return sql, params
# Ensure Count() is against the exact same parameters (filter, distinct)
count = self.copy()
count.__class__ = Count
count_sql, count_params = compiler.compile(count)
default_sql = ""
default_params = ()
if default is not None:
default_sql, default_params = compiler.compile(default)
default_sql = f" ELSE {default_sql}"
sql = f"(CASE WHEN {count_sql} > 0 THEN {sql}{default_sql} END)"
return sql, count_params + params + default_params
def as_postgresql(self, compiler, connection, **extra_context):
if not connection.features.is_postgresql_16:
sql, params = super().as_sql(
compiler,
connection,
function="ARRAY_AGG",
**extra_context,
)
return f"TO_JSONB({sql})", params
extra_context.setdefault(
"template", "%(function)s(%(distinct)s%(expressions)s RETURNING JSONB)"
)
return self.as_sql(compiler, connection, **extra_context)
def as_oracle(self, compiler, connection, **extra_context):
# Return same date field format as on other supported backends.
expression = self.get_source_expressions()[0]
internal_type = expression.output_field.get_internal_type()
if internal_type == "DateField":
extra_context.setdefault(
"template", "%(function)s(TO_CHAR(%(expressions)s, 'YYYY-MM-DD'))"
)
return self.as_sql(compiler, connection, **extra_context)

View file

@ -4078,6 +4078,19 @@ by the aggregate.
A ``Value`` or expression representing the string that should separate
each of the values. For example, ``Value(",")``.
``JSONArrayAgg``
~~~~~~~~~~~~~~~~
.. versionadded:: 6.0
.. class:: JSONArrayAgg(expression, output_field=None, sample=False, filter=None, default=None, **extra)
Converts each expression to a JSON value and returns a single JSON array
containing those JSON values.
* Return type: ``list`` containing an element for each value
in a set of JSON or SQL values.
Query-related tools
===================

View file

@ -196,6 +196,10 @@ Models
values concatenated into a string, separated by the ``delimiter`` string.
This aggregate was previously supported only for PostgreSQL.
* The new :class:`~django.db.models.JSONArrayAgg` aggregate function accepts
a list of field names or expressions, converts each expression to a JSON
value, and returns a single JSON array containing those JSON values.
* The :meth:`~django.db.models.Model.save` method now raises a specialized
:exc:`Model.NotUpdated <django.db.models.Model.NotUpdated>` exception, when
:ref:`a forced update <ref-models-force-insert>` results in no affected rows,

View file

@ -18,6 +18,7 @@ from django.db.models import (
F,
FloatField,
IntegerField,
JSONArrayAgg,
Max,
Min,
OuterRef,
@ -2640,3 +2641,100 @@ class AggregateAnnotationPruningTests(TestCase):
)
)
self.assertEqual(qs.count(), 3)
@skipUnlessDBFeature("supports_json_field")
class JSONArrayAggTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.a1 = Author.objects.create(name="Adrian Holovaty", age=34)
cls.a2 = Author.objects.create(name="Jacob Kaplan-Moss", age=45)
cls.p1 = Publisher.objects.create(num_awards=3)
cls.p2 = Publisher.objects.create(num_awards=1)
cls.b1 = Book.objects.create(
isbn="159059725",
name="b1",
pages=447,
rating=4.5,
price=Decimal("30.00"),
contact=cls.a1,
publisher=cls.p1,
pubdate=datetime.date(2007, 12, 6),
)
cls.b1.authors.add(cls.a1)
cls.b2 = Book.objects.create(
isbn="067232959",
name="b2",
pages=528,
rating=3.0,
price=Decimal("23.09"),
contact=cls.a2,
publisher=cls.p2,
pubdate=datetime.date(2008, 3, 3),
)
cls.b2.authors.add(cls.a2)
def test(self):
vals = Book.objects.aggregate(jsonarrayagg=JSONArrayAgg("contact__name"))
self.assertEqual(
vals,
{"jsonarrayagg": ["Adrian Holovaty", "Jacob Kaplan-Moss"]},
)
def test_datefield(self):
vals = Author.objects.aggregate(jsonarrayagg=JSONArrayAgg("book__pubdate"))
self.assertEqual(
vals,
{
"jsonarrayagg": [
"2007-12-06",
"2008-03-03",
]
},
)
def test_decimalfield(self):
vals = Author.objects.aggregate(jsonarrayagg=JSONArrayAgg("book__price"))
self.assertEqual(vals, {"jsonarrayagg": [30.0, 23.09]})
def test_integerfield(self):
vals = Author.objects.aggregate(jsonarrayagg=JSONArrayAgg("book__pages"))
self.assertEqual(vals, {"jsonarrayagg": [447, 528]})
@skipUnlessDBFeature("supports_aggregate_filter_clause")
def test_filter(self):
vals = Book.objects.aggregate(
jsonarrayagg=JSONArrayAgg("contact__age", filter=Q(contact__age__gt=35))
)
self.assertEqual(vals, {"jsonarrayagg": [45]})
def test_empty_result_set(self):
Author.objects.all().delete()
val = Author.objects.aggregate(jsonarrayagg=JSONArrayAgg("age"))
self.assertEqual(val, {"jsonarrayagg": None})
def test_default_set(self):
Author.objects.all().delete()
val = Author.objects.aggregate(
jsonarrayagg=JSONArrayAgg("name", default=["<empty>"])
)
self.assertEqual(val, {"jsonarrayagg": ["<empty>"]})
def test_distinct_true(self):
msg = "JSONArrayAgg does not allow distinct."
with self.assertRaisesMessage(TypeError, msg):
JSONArrayAgg("age", distinct=True)
@skipIfDBFeature("supports_aggregate_filter_clause")
def test_not_supported(self):
msg = "JSONArrayAgg(filter) is not supported on this database backend."
with self.assertRaisesMessage(NotSupportedError, msg):
Author.objects.aggregate(arrayagg=JSONArrayAgg("age", filter=Q(age__gt=35)))
@skipIfDBFeature("supports_json_field")
class JSONArrayAggNotSupportedTests(TestCase):
def test_not_supported(self):
msg = "JSONFields are not supported on this database backend."
with self.assertRaisesMessage(NotSupportedError, msg):
Book.objects.aggregate(jsonarrayagg=JSONArrayAgg("contact__name"))