From bd47b9bc816bf213b6d0027ed9a9a44955bb7694 Mon Sep 17 00:00:00 2001 From: Nick Pope Date: Fri, 23 Jul 2021 16:57:26 +0100 Subject: [PATCH] Fixed #32961 -- Added BitXor() aggregate to django.contrib.postgres. --- django/contrib/postgres/aggregates/general.py | 7 +++- django/db/backends/postgresql/features.py | 5 +++ docs/ref/contrib/postgres/aggregates.txt | 10 ++++++ docs/releases/4.1.txt | 4 ++- tests/postgres_tests/test_aggregates.py | 34 +++++++++++++++++-- 5 files changed, 55 insertions(+), 5 deletions(-) diff --git a/django/contrib/postgres/aggregates/general.py b/django/contrib/postgres/aggregates/general.py index 99fe0d87392..d90ca50e2b4 100644 --- a/django/contrib/postgres/aggregates/general.py +++ b/django/contrib/postgres/aggregates/general.py @@ -9,7 +9,8 @@ from django.utils.deprecation import RemovedInDjango50Warning from .mixins import OrderableAggMixin __all__ = [ - 'ArrayAgg', 'BitAnd', 'BitOr', 'BoolAnd', 'BoolOr', 'JSONBAgg', 'StringAgg', + 'ArrayAgg', 'BitAnd', 'BitOr', 'BitXor', 'BoolAnd', 'BoolOr', 'JSONBAgg', + 'StringAgg', ] @@ -60,6 +61,10 @@ class BitOr(Aggregate): function = 'BIT_OR' +class BitXor(Aggregate): + function = 'BIT_XOR' + + class BoolAnd(Aggregate): function = 'BOOL_AND' output_field = BooleanField() diff --git a/django/db/backends/postgresql/features.py b/django/db/backends/postgresql/features.py index 09c157505e7..caa37335e08 100644 --- a/django/db/backends/postgresql/features.py +++ b/django/db/backends/postgresql/features.py @@ -91,6 +91,11 @@ class DatabaseFeatures(BaseDatabaseFeatures): def is_postgresql_13(self): return self.connection.pg_version >= 130000 + @cached_property + def is_postgresql_14(self): + return self.connection.pg_version >= 140000 + + has_bit_xor = property(operator.attrgetter('is_postgresql_14')) has_websearch_to_tsquery = property(operator.attrgetter('is_postgresql_11')) supports_covering_indexes = property(operator.attrgetter('is_postgresql_11')) supports_covering_gist_indexes = property(operator.attrgetter('is_postgresql_12')) diff --git a/docs/ref/contrib/postgres/aggregates.txt b/docs/ref/contrib/postgres/aggregates.txt index cc177aca87f..7c901a0bad8 100644 --- a/docs/ref/contrib/postgres/aggregates.txt +++ b/docs/ref/contrib/postgres/aggregates.txt @@ -75,6 +75,16 @@ General-purpose aggregation functions Returns an ``int`` of the bitwise ``OR`` of all non-null input values, or ``default`` if all values are null. +``BitXor`` +---------- + +.. versionadded:: 4.1 + +.. class:: BitXor(expression, filter=None, default=None, **extra) + + Returns an ``int`` of the bitwise ``XOR`` of all non-null input values, or + ``default`` if all values are null. It requires PostgreSQL 14+. + ``BoolAnd`` ----------- diff --git a/docs/releases/4.1.txt b/docs/releases/4.1.txt index 8401103767a..e089ea6cf92 100644 --- a/docs/releases/4.1.txt +++ b/docs/releases/4.1.txt @@ -64,7 +64,9 @@ Minor features :mod:`django.contrib.postgres` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -* ... +* The new :class:`BitXor() ` + aggregate function returns an ``int`` of the bitwise ``XOR`` of all non-null + input values. :mod:`django.contrib.redirects` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/postgres_tests/test_aggregates.py b/tests/postgres_tests/test_aggregates.py index 07200f9f974..399c4fa8a98 100644 --- a/tests/postgres_tests/test_aggregates.py +++ b/tests/postgres_tests/test_aggregates.py @@ -1,8 +1,10 @@ +from django.db import connection from django.db.models import ( CharField, F, Func, IntegerField, OuterRef, Q, Subquery, Value, ) from django.db.models.fields.json import KeyTextTransform, KeyTransform from django.db.models.functions import Cast, Concat, Substr +from django.test import skipUnlessDBFeature from django.test.utils import Approximate, ignore_warnings from django.utils import timezone from django.utils.deprecation import RemovedInDjango50Warning @@ -12,9 +14,9 @@ from .models import AggregateTestModel, HotelReservation, Room, StatTestModel try: from django.contrib.postgres.aggregates import ( - ArrayAgg, BitAnd, BitOr, BoolAnd, BoolOr, Corr, CovarPop, JSONBAgg, - RegrAvgX, RegrAvgY, RegrCount, RegrIntercept, RegrR2, RegrSlope, - RegrSXX, RegrSXY, RegrSYY, StatAggregate, StringAgg, + ArrayAgg, BitAnd, BitOr, BitXor, BoolAnd, BoolOr, Corr, CovarPop, + JSONBAgg, RegrAvgX, RegrAvgY, RegrCount, RegrIntercept, RegrR2, + RegrSlope, RegrSXX, RegrSXY, RegrSYY, StatAggregate, StringAgg, ) from django.contrib.postgres.fields import ArrayField except ImportError: @@ -68,6 +70,8 @@ class TestGeneralAggregate(PostgreSQLTestCase): (JSONBAgg('integer_field'), []), (StringAgg('char_field', delimiter=';'), ''), ] + if connection.features.has_bit_xor: + tests.append((BitXor('integer_field'), None)) for aggregation, expected_result in tests: with self.subTest(aggregation=aggregation): # Empty result with non-execution optimization. @@ -96,6 +100,8 @@ class TestGeneralAggregate(PostgreSQLTestCase): (JSONBAgg('integer_field', default=Value('[""]')), ['']), (StringAgg('char_field', delimiter=';', default=Value('')), ''), ] + if connection.features.has_bit_xor: + tests.append((BitXor('integer_field', default=0), 0)) for aggregation, expected_result in tests: with self.subTest(aggregation=aggregation): # Empty result with non-execution optimization. @@ -275,6 +281,28 @@ class TestGeneralAggregate(PostgreSQLTestCase): integer_field=0).aggregate(bitor=BitOr('integer_field')) self.assertEqual(values, {'bitor': 0}) + @skipUnlessDBFeature('has_bit_xor') + def test_bit_xor_general(self): + AggregateTestModel.objects.create(integer_field=3) + values = AggregateTestModel.objects.filter( + integer_field__in=[1, 3], + ).aggregate(bitxor=BitXor('integer_field')) + self.assertEqual(values, {'bitxor': 2}) + + @skipUnlessDBFeature('has_bit_xor') + def test_bit_xor_on_only_true_values(self): + values = AggregateTestModel.objects.filter( + integer_field=1, + ).aggregate(bitxor=BitXor('integer_field')) + self.assertEqual(values, {'bitxor': 1}) + + @skipUnlessDBFeature('has_bit_xor') + def test_bit_xor_on_only_false_values(self): + values = AggregateTestModel.objects.filter( + integer_field=0, + ).aggregate(bitxor=BitXor('integer_field')) + self.assertEqual(values, {'bitxor': 0}) + def test_bool_and_general(self): values = AggregateTestModel.objects.aggregate(booland=BoolAnd('boolean_field')) self.assertEqual(values, {'booland': False})