Browse Source

Fixed #32961 -- Added BitXor() aggregate to django.contrib.postgres.

Nick Pope 3 years ago
parent
commit
bd47b9bc81

+ 6 - 1
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()

+ 5 - 0
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'))

+ 10 - 0
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``
 -----------
 

+ 3 - 1
docs/releases/4.1.txt

@@ -64,7 +64,9 @@ Minor features
 :mod:`django.contrib.postgres`
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-* ...
+* The new :class:`BitXor() <django.contrib.postgres.aggregates.BitXor>`
+  aggregate function returns an ``int`` of the bitwise ``XOR`` of all non-null
+  input values.
 
 :mod:`django.contrib.redirects`
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

+ 31 - 3
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('["<empty>"]')), ['<empty>']),
             (StringAgg('char_field', delimiter=';', default=Value('<empty>')), '<empty>'),
         ]
+        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})