diff --git a/django/contrib/postgres/aggregates/general.py b/django/contrib/postgres/aggregates/general.py index 6ff6727bd4..5d420505eb 100644 --- a/django/contrib/postgres/aggregates/general.py +++ b/django/contrib/postgres/aggregates/general.py @@ -1,7 +1,8 @@ +from django.contrib.postgres.fields import JSONField from django.db.models.aggregates import Aggregate __all__ = [ - 'ArrayAgg', 'BitAnd', 'BitOr', 'BoolAnd', 'BoolOr', 'StringAgg', + 'ArrayAgg', 'BitAnd', 'BitOr', 'BoolAnd', 'BoolOr', 'JsonAgg', 'StringAgg', ] @@ -30,6 +31,16 @@ class BoolOr(Aggregate): function = 'BOOL_OR' +class JsonAgg(Aggregate): + function = 'JSONB_AGG' + _output_field = JSONField() + + def convert_value(self, value, expression, connection, context): + if not value: + return [] + return value + + class StringAgg(Aggregate): function = 'STRING_AGG' template = "%(function)s(%(distinct)s%(expressions)s, '%(delimiter)s')" diff --git a/docs/ref/contrib/postgres/aggregates.txt b/docs/ref/contrib/postgres/aggregates.txt index f9b7be0fd3..38b876c664 100644 --- a/docs/ref/contrib/postgres/aggregates.txt +++ b/docs/ref/contrib/postgres/aggregates.txt @@ -58,6 +58,15 @@ General-purpose aggregation functions Returns ``True`` if at least one input value is true, ``None`` if all values are null or if there are no values, otherwise ``False``. +``JsonAgg`` +----------- + +.. class:: JsonAgg(expressions, **extra) + + .. versionadded:: 1.11 + + Returns the input values as a ``JSON`` array. + ``StringAgg`` ------------- diff --git a/docs/releases/1.11.txt b/docs/releases/1.11.txt index 142eb4e9ba..f5faedd0a9 100644 --- a/docs/releases/1.11.txt +++ b/docs/releases/1.11.txt @@ -172,6 +172,9 @@ Minor features operation allow using PostgreSQL's ``citext`` extension for case-insensitive lookups. +* The new :class:`~django.contrib.postgres.aggregates.JsonAgg` allows + aggregating values as a JSON array. + :mod:`django.contrib.redirects` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/postgres_tests/test_aggregates.py b/tests/postgres_tests/test_aggregates.py index f6f48fdd61..aaab3b1bc6 100644 --- a/tests/postgres_tests/test_aggregates.py +++ b/tests/postgres_tests/test_aggregates.py @@ -1,14 +1,21 @@ -from django.contrib.postgres.aggregates import ( - ArrayAgg, BitAnd, BitOr, BoolAnd, BoolOr, Corr, CovarPop, RegrAvgX, - RegrAvgY, RegrCount, RegrIntercept, RegrR2, RegrSlope, RegrSXX, RegrSXY, - RegrSYY, StatAggregate, StringAgg, -) +import json + from django.db.models.expressions import F, Value +from django.test.testcases import skipUnlessDBFeature from django.test.utils import Approximate from . import PostgreSQLTestCase from .models import AggregateTestModel, StatTestModel +try: + from django.contrib.postgres.aggregates import ( + ArrayAgg, BitAnd, BitOr, BoolAnd, BoolOr, Corr, CovarPop, JsonAgg, + RegrAvgX, RegrAvgY, RegrCount, RegrIntercept, RegrR2, RegrSlope, + RegrSXX, RegrSXY, RegrSYY, StatAggregate, StringAgg, + ) +except ImportError: + pass # psycopg2 is not installed + class TestGeneralAggregate(PostgreSQLTestCase): @classmethod @@ -110,6 +117,16 @@ class TestGeneralAggregate(PostgreSQLTestCase): values = AggregateTestModel.objects.aggregate(stringagg=StringAgg('char_field', delimiter=';')) self.assertEqual(values, {'stringagg': ''}) + @skipUnlessDBFeature('has_jsonb_datatype') + def test_json_agg(self): + values = AggregateTestModel.objects.aggregate(jsonagg=JsonAgg('char_field')) + self.assertEqual(values, {'jsonagg': ['Foo1', 'Foo2', 'Foo3', 'Foo4']}) + + @skipUnlessDBFeature('has_jsonb_datatype') + def test_json_agg_empty(self): + values = AggregateTestModel.objects.none().aggregate(jsonagg=JsonAgg('integer_field')) + self.assertEqual(values, json.loads('{"jsonagg": []}')) + class TestStringAggregateDistinct(PostgreSQLTestCase): @classmethod