summaryrefslogtreecommitdiff
path: root/django
diff options
context:
space:
mode:
authorRyan Heard <ryanwheard@gmail.com>2021-07-02 15:09:13 -0500
committerMariusz Felisiak <felisiak.mariusz@gmail.com>2022-03-04 12:55:37 +0100
commitc6b4d62fa2c7f73b87f6ae7e8cf1d64ee5312dc5 (patch)
tree238bd8c3045c8d4577e09bd913de9748dcd49968 /django
parent795da6306a048b18c0158496b0d49e8e4f197a32 (diff)
Fixed #29865 -- Added logical XOR support for Q() and querysets.
Diffstat (limited to 'django')
-rw-r--r--django/db/backends/base/features.py3
-rw-r--r--django/db/backends/mysql/features.py1
-rw-r--r--django/db/models/expressions.py20
-rw-r--r--django/db/models/query.py19
-rw-r--r--django/db/models/query_utils.py4
-rw-r--r--django/db/models/sql/__init__.py4
-rw-r--r--django/db/models/sql/where.py30
7 files changed, 71 insertions, 10 deletions
diff --git a/django/db/backends/base/features.py b/django/db/backends/base/features.py
index ccf9104c21..ebf29e80d6 100644
--- a/django/db/backends/base/features.py
+++ b/django/db/backends/base/features.py
@@ -325,6 +325,9 @@ class BaseDatabaseFeatures:
# Does the backend support non-deterministic collations?
supports_non_deterministic_collations = True
+ # Does the backend support the logical XOR operator?
+ supports_logical_xor = False
+
# Collation names for use by the Django test suite.
test_collations = {
"ci": None, # Case-insensitive.
diff --git a/django/db/backends/mysql/features.py b/django/db/backends/mysql/features.py
index 1996208b46..357e431524 100644
--- a/django/db/backends/mysql/features.py
+++ b/django/db/backends/mysql/features.py
@@ -47,6 +47,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
supports_order_by_nulls_modifier = False
order_by_nulls_first = True
+ supports_logical_xor = True
@cached_property
def minimum_database_version(self):
diff --git a/django/db/models/expressions.py b/django/db/models/expressions.py
index a2da1f6e38..25c2803085 100644
--- a/django/db/models/expressions.py
+++ b/django/db/models/expressions.py
@@ -94,7 +94,7 @@ class Combinable:
if getattr(self, "conditional", False) and getattr(other, "conditional", False):
return Q(self) & Q(other)
raise NotImplementedError(
- "Use .bitand() and .bitor() for bitwise logical operations."
+ "Use .bitand(), .bitor(), and .bitxor() for bitwise logical operations."
)
def bitand(self, other):
@@ -106,6 +106,13 @@ class Combinable:
def bitrightshift(self, other):
return self._combine(other, self.BITRIGHTSHIFT, False)
+ def __xor__(self, other):
+ if getattr(self, "conditional", False) and getattr(other, "conditional", False):
+ return Q(self) ^ Q(other)
+ raise NotImplementedError(
+ "Use .bitand(), .bitor(), and .bitxor() for bitwise logical operations."
+ )
+
def bitxor(self, other):
return self._combine(other, self.BITXOR, False)
@@ -113,7 +120,7 @@ class Combinable:
if getattr(self, "conditional", False) and getattr(other, "conditional", False):
return Q(self) | Q(other)
raise NotImplementedError(
- "Use .bitand() and .bitor() for bitwise logical operations."
+ "Use .bitand(), .bitor(), and .bitxor() for bitwise logical operations."
)
def bitor(self, other):
@@ -139,12 +146,17 @@ class Combinable:
def __rand__(self, other):
raise NotImplementedError(
- "Use .bitand() and .bitor() for bitwise logical operations."
+ "Use .bitand(), .bitor(), and .bitxor() for bitwise logical operations."
)
def __ror__(self, other):
raise NotImplementedError(
- "Use .bitand() and .bitor() for bitwise logical operations."
+ "Use .bitand(), .bitor(), and .bitxor() for bitwise logical operations."
+ )
+
+ def __rxor__(self, other):
+ raise NotImplementedError(
+ "Use .bitand(), .bitor(), and .bitxor() for bitwise logical operations."
)
diff --git a/django/db/models/query.py b/django/db/models/query.py
index 0cebcc70d6..5c78c6e315 100644
--- a/django/db/models/query.py
+++ b/django/db/models/query.py
@@ -396,6 +396,25 @@ class QuerySet:
combined.query.combine(other.query, sql.OR)
return combined
+ def __xor__(self, other):
+ self._check_operator_queryset(other, "^")
+ self._merge_sanity_check(other)
+ if isinstance(self, EmptyQuerySet):
+ return other
+ if isinstance(other, EmptyQuerySet):
+ return self
+ query = (
+ self
+ if self.query.can_filter()
+ else self.model._base_manager.filter(pk__in=self.values("pk"))
+ )
+ combined = query._chain()
+ combined._merge_known_related_objects(other)
+ if not other.query.can_filter():
+ other = other.model._base_manager.filter(pk__in=other.values("pk"))
+ combined.query.combine(other.query, sql.XOR)
+ return combined
+
####################################
# METHODS THAT DO DATABASE QUERIES #
####################################
diff --git a/django/db/models/query_utils.py b/django/db/models/query_utils.py
index 6ea82b6520..fde686b2cd 100644
--- a/django/db/models/query_utils.py
+++ b/django/db/models/query_utils.py
@@ -38,6 +38,7 @@ class Q(tree.Node):
# Connection types
AND = "AND"
OR = "OR"
+ XOR = "XOR"
default = AND
conditional = True
@@ -70,6 +71,9 @@ class Q(tree.Node):
def __and__(self, other):
return self._combine(other, self.AND)
+ def __xor__(self, other):
+ return self._combine(other, self.XOR)
+
def __invert__(self):
obj = type(self)()
obj.add(self, self.AND)
diff --git a/django/db/models/sql/__init__.py b/django/db/models/sql/__init__.py
index 2956e047b1..dd31a6ea9e 100644
--- a/django/db/models/sql/__init__.py
+++ b/django/db/models/sql/__init__.py
@@ -1,6 +1,6 @@
from django.db.models.sql.query import * # NOQA
from django.db.models.sql.query import Query
from django.db.models.sql.subqueries import * # NOQA
-from django.db.models.sql.where import AND, OR
+from django.db.models.sql.where import AND, OR, XOR
-__all__ = ["Query", "AND", "OR"]
+__all__ = ["Query", "AND", "OR", "XOR"]
diff --git a/django/db/models/sql/where.py b/django/db/models/sql/where.py
index 532780fd98..8e3ad74d65 100644
--- a/django/db/models/sql/where.py
+++ b/django/db/models/sql/where.py
@@ -1,14 +1,19 @@
"""
Code to manage the creation and SQL rendering of 'where' constraints.
"""
+import operator
+from functools import reduce
from django.core.exceptions import EmptyResultSet
+from django.db.models.expressions import Case, When
+from django.db.models.lookups import Exact
from django.utils import tree
from django.utils.functional import cached_property
# Connection types
AND = "AND"
OR = "OR"
+XOR = "XOR"
class WhereNode(tree.Node):
@@ -39,10 +44,12 @@ class WhereNode(tree.Node):
if not self.contains_aggregate:
return self, None
in_negated = negated ^ self.negated
- # If the effective connector is OR and this node contains an aggregate,
- # then we need to push the whole branch to HAVING clause.
- may_need_split = (in_negated and self.connector == AND) or (
- not in_negated and self.connector == OR
+ # If the effective connector is OR or XOR and this node contains an
+ # aggregate, then we need to push the whole branch to HAVING clause.
+ may_need_split = (
+ (in_negated and self.connector == AND)
+ or (not in_negated and self.connector == OR)
+ or self.connector == XOR
)
if may_need_split and self.contains_aggregate:
return None, self
@@ -85,6 +92,21 @@ class WhereNode(tree.Node):
else:
full_needed, empty_needed = 1, len(self.children)
+ if self.connector == XOR and not connection.features.supports_logical_xor:
+ # Convert if the database doesn't support XOR:
+ # a XOR b XOR c XOR ...
+ # to:
+ # (a OR b OR c OR ...) AND (a + b + c + ...) == 1
+ lhs = self.__class__(self.children, OR)
+ rhs_sum = reduce(
+ operator.add,
+ (Case(When(c, then=1), default=0) for c in self.children),
+ )
+ rhs = Exact(1, rhs_sum)
+ return self.__class__([lhs, rhs], AND, self.negated).as_sql(
+ compiler, connection
+ )
+
for child in self.children:
try:
sql, params = compiler.compile(child)