diff --git a/django/db/backends/base/features.py b/django/db/backends/base/features.py index c54d30cf73..4fd21beee3 100644 --- a/django/db/backends/base/features.py +++ b/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 diff --git a/django/db/backends/oracle/features.py b/django/db/backends/oracle/features.py index 289f786f5e..49e58ff59d 100644 --- a/django/db/backends/oracle/features.py +++ b/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 diff --git a/django/db/models/query_utils.py b/django/db/models/query_utils.py index 5562303e00..4a83fc380d 100644 --- a/django/db/models/query_utils.py +++ b/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 diff --git a/docs/ref/models/constraints.txt b/docs/ref/models/constraints.txt index d0a9a017dc..8134a657d8 100644 --- a/docs/ref/models/constraints.txt +++ b/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() ` + 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. diff --git a/docs/releases/4.1.2.txt b/docs/releases/4.1.2.txt index 96c2fefcdc..546ab7a635 100644 --- a/docs/releases/4.1.2.txt +++ b/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`). diff --git a/tests/constraints/tests.py b/tests/constraints/tests.py index d4054dfd77..5a498f0d73 100644 --- a/tests/constraints/tests.py +++ b/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 diff --git a/tests/postgres_tests/test_constraints.py b/tests/postgres_tests/test_constraints.py index 2b6df7d5f5..844c04cd6d 100644 --- a/tests/postgres_tests/test_constraints.py +++ b/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(