Browse Source

Fixed #33996 -- Fixed CheckConstraint validation on NULL values.

Bug in 667105877e6723c6985399803a364848891513cc.

Thanks James Beith for the report.
David Sanders 2 years ago
parent
commit
e14d08cd89

+ 3 - 0
django/db/backends/base/features.py

@@ -302,6 +302,9 @@ class BaseDatabaseFeatures:
     # Does the backend support boolean expressions in SELECT and GROUP BY
     # clauses?
     supports_boolean_expr_in_select_clause = True
+    # Does the backend support comparing boolean expressions in WHERE clauses?
+    # Eg: WHERE (price > 0) IS NOT NULL
+    supports_comparing_boolean_expr = True
 
     # Does the backend support JSONField?
     supports_json_field = True

+ 1 - 0
django/db/backends/oracle/features.py

@@ -71,6 +71,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
     supports_slicing_ordering_in_compound = True
     allows_multiple_constraints_on_same_fields = False
     supports_boolean_expr_in_select_clause = False
+    supports_comparing_boolean_expr = False
     supports_primitives_in_json_field = False
     supports_json_field_contains = False
     supports_collation_on_textfield = False

+ 7 - 3
django/db/models/query_utils.py

@@ -11,7 +11,7 @@ import logging
 from collections import namedtuple
 
 from django.core.exceptions import FieldError
-from django.db import DEFAULT_DB_ALIAS, DatabaseError
+from django.db import DEFAULT_DB_ALIAS, DatabaseError, connections
 from django.db.models.constants import LOOKUP_SEP
 from django.utils import tree
 
@@ -115,7 +115,8 @@ class Q(tree.Node):
         matches against the expressions.
         """
         # Avoid circular imports.
-        from django.db.models import Value
+        from django.db.models import BooleanField, Value
+        from django.db.models.functions import Coalesce
         from django.db.models.sql import Query
         from django.db.models.sql.constants import SINGLE
 
@@ -126,7 +127,10 @@ class Q(tree.Node):
             query.add_annotation(value, name, select=False)
         query.add_annotation(Value(1), "_check")
         # This will raise a FieldError if a field is missing in "against".
-        query.add_q(self)
+        if connections[using].features.supports_comparing_boolean_expr:
+            query.add_q(Q(Coalesce(self, True, output_field=BooleanField())))
+        else:
+            query.add_q(self)
         compiler = query.get_compiler(using=using)
         try:
             return compiler.execute_sql(SINGLE) is not None

+ 9 - 0
docs/ref/models/constraints.txt

@@ -102,6 +102,15 @@ specifies the check you want the constraint to enforce.
 For example, ``CheckConstraint(check=Q(age__gte=18), name='age_gte_18')``
 ensures the age field is never less than 18.
 
+.. admonition:: Oracle
+
+    Checks with nullable fields on Oracle must include a condition allowing for
+    ``NULL`` values in order for :meth:`validate() <BaseConstraint.validate>`
+    to behave the same as check constraints validation. For example, if ``age``
+    is a nullable field::
+
+        CheckConstraint(check=Q(age__gte=18) | Q(age__isnull=True), name='age_gte_18')
+
 .. versionchanged:: 4.1
 
     The ``violation_error_message`` argument was added.

+ 3 - 0
docs/releases/4.1.2.txt

@@ -15,3 +15,6 @@ Bugfixes
 * Fixed a regression in Django 4.1 that caused aggregation over a queryset that
   contained an ``Exists`` annotation to crash due to too many selected columns
   (:ticket:`33992`).
+
+* Fixed a bug in Django 4.1 that caused an incorrect validation of
+  ``CheckConstraint`` on ``NULL`` values (:ticket:`33996`).

+ 18 - 1
tests/constraints/tests.py

@@ -6,7 +6,7 @@ from django.db.models import F
 from django.db.models.constraints import BaseConstraint
 from django.db.models.functions import Lower
 from django.db.transaction import atomic
-from django.test import SimpleTestCase, TestCase, skipUnlessDBFeature
+from django.test import SimpleTestCase, TestCase, skipIfDBFeature, skipUnlessDBFeature
 
 from .models import (
     ChildModel,
@@ -234,6 +234,23 @@ class CheckConstraintTests(TestCase):
         constraint.validate(Product, Product(price=501, discounted_price=5))
         constraint.validate(Product, Product(price=499, discounted_price=5))
 
+    @skipUnlessDBFeature("supports_comparing_boolean_expr")
+    def test_validate_nullable_field_with_none(self):
+        # Nullable fields should be considered valid on None values.
+        constraint = models.CheckConstraint(
+            check=models.Q(price__gte=0),
+            name="positive_price",
+        )
+        constraint.validate(Product, Product())
+
+    @skipIfDBFeature("supports_comparing_boolean_expr")
+    def test_validate_nullable_field_with_isnull(self):
+        constraint = models.CheckConstraint(
+            check=models.Q(price__gte=0) | models.Q(price__isnull=True),
+            name="positive_price",
+        )
+        constraint.validate(Product, Product())
+
 
 class UniqueConstraintTests(TestCase):
     @classmethod

+ 1 - 3
tests/postgres_tests/test_constraints.py

@@ -156,9 +156,7 @@ class SchemaTests(PostgreSQLTestCase):
             check=Q(ints__startswith__gte=0),
             name="ints_positive_range",
         )
-        msg = f"Constraint “{constraint.name}” is violated."
-        with self.assertRaisesMessage(ValidationError, msg):
-            constraint.validate(RangesModel, RangesModel())
+        constraint.validate(RangesModel, RangesModel())
 
     def test_opclass(self):
         constraint = UniqueConstraint(