1
0
mirror of https://github.com/django/django.git synced 2025-08-21 01:09:13 +00:00

Fixed #34871, #36518 -- Implemented unresolved lookups expression replacement.

This allows the proper resolving of lookups when performing constraint
validation involving Q and Case objects.

Thanks Andrew Roberts for the report and Sarah for the tests and review.
This commit is contained in:
Simon Charette 2025-02-19 03:04:16 -05:00 committed by Sarah Boyce
parent 500bd42b96
commit 079d31e698
4 changed files with 124 additions and 4 deletions

View File

@ -13,7 +13,7 @@ from collections import namedtuple
from contextlib import nullcontext from contextlib import nullcontext
from django.core.exceptions import FieldError from django.core.exceptions import FieldError
from django.db import DEFAULT_DB_ALIAS, DatabaseError, connections, transaction from django.db import DEFAULT_DB_ALIAS, DatabaseError, connections, models, transaction
from django.db.models.constants import LOOKUP_SEP from django.db.models.constants import LOOKUP_SEP
from django.utils import tree from django.utils import tree
from django.utils.functional import cached_property from django.utils.functional import cached_property
@ -99,6 +99,45 @@ class Q(tree.Node):
query.promote_joins(joins) query.promote_joins(joins)
return clause return clause
def replace_expressions(self, replacements):
if not replacements:
return self
clone = self.create(connector=self.connector, negated=self.negated)
for child in self.children:
child_replacement = child
if isinstance(child, tuple):
lhs, rhs = child
if LOOKUP_SEP in lhs:
path, lookup = lhs.rsplit(LOOKUP_SEP, 1)
else:
path = lhs
lookup = None
field = models.F(path)
if (
field_replacement := field.replace_expressions(replacements)
) is not field:
# Handle the implicit __exact case by falling back to an
# extra transform when get_lookup returns no match for the
# last component of the path.
if lookup is None:
lookup = "exact"
if (lookup_class := field_replacement.get_lookup(lookup)) is None:
if (
transform_class := field_replacement.get_transform(lookup)
) is not None:
field_replacement = transform_class(field_replacement)
lookup = "exact"
lookup_class = field_replacement.get_lookup(lookup)
if rhs is None and lookup == "exact":
lookup_class = field_replacement.get_lookup("isnull")
rhs = True
if lookup_class is not None:
child_replacement = lookup_class(field_replacement, rhs)
else:
child_replacement = child.replace_expressions(replacements)
clone.children.append(child_replacement)
return clone
def flatten(self): def flatten(self):
""" """
Recursively yield this Q object and all subexpressions, in depth-first Recursively yield this Q object and all subexpressions, in depth-first

View File

@ -15,3 +15,7 @@ Bugfixes
* Fixed a crash in Django 5.2 when filtering against a composite primary key * Fixed a crash in Django 5.2 when filtering against a composite primary key
using a tuple containing expressions (:ticket:`36522`). using a tuple containing expressions (:ticket:`36522`).
* Fixed a crash in Django 5.2 when validating a model that uses
``GeneratedField`` or constraints composed of ``Q`` and ``Case`` lookups
(:ticket:`36518`).

View File

@ -3,7 +3,7 @@ from unittest import mock
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import IntegrityError, connection, models from django.db import IntegrityError, connection, models
from django.db.models import F from django.db.models import Case, F, When
from django.db.models.constraints import BaseConstraint, UniqueConstraint from django.db.models.constraints import BaseConstraint, UniqueConstraint
from django.db.models.functions import Abs, Lower, Sqrt, Upper from django.db.models.functions import Abs, Lower, Sqrt, Upper
from django.db.transaction import atomic from django.db.transaction import atomic
@ -1064,6 +1064,23 @@ class UniqueConstraintTests(TestCase):
UniqueConstraintProduct(updated=updated_date + timedelta(days=1)), UniqueConstraintProduct(updated=updated_date + timedelta(days=1)),
) )
def test_validate_case_when(self):
UniqueConstraintProduct.objects.create(name="p1")
constraint = models.UniqueConstraint(
Case(When(color__isnull=True, then=F("name"))),
name="name_without_color_uniq",
)
msg = "Constraint “name_without_color_uniq” is violated."
with self.assertRaisesMessage(ValidationError, msg):
constraint.validate(
UniqueConstraintProduct,
UniqueConstraintProduct(name="p1"),
)
constraint.validate(
UniqueConstraintProduct,
UniqueConstraintProduct(name="p1", color="green"),
)
def test_validate_ordered_expression(self): def test_validate_ordered_expression(self):
constraint = models.UniqueConstraint( constraint = models.UniqueConstraint(
Lower("name").desc(), name="name_lower_uniq_desc" Lower("name").desc(), name="name_lower_uniq_desc"

View File

@ -1,3 +1,5 @@
from datetime import datetime
from django.core.exceptions import FieldError from django.core.exceptions import FieldError
from django.db import connection from django.db import connection
from django.db.models import ( from django.db.models import (
@ -10,8 +12,13 @@ from django.db.models import (
Value, Value,
) )
from django.db.models.expressions import NegatedExpression, RawSQL from django.db.models.expressions import NegatedExpression, RawSQL
from django.db.models.functions import Lower from django.db.models.functions import ExtractDay, Lower, TruncDate
from django.db.models.lookups import Exact, IsNull from django.db.models.lookups import (
Exact,
IntegerFieldExact,
IntegerLessThanOrEqual,
IsNull,
)
from django.db.models.sql.where import NothingNode from django.db.models.sql.where import NothingNode
from django.test import SimpleTestCase, TestCase from django.test import SimpleTestCase, TestCase
@ -292,6 +299,59 @@ class QTests(SimpleTestCase):
expected_base_fields, expected_base_fields,
) )
def test_replace_expressions(self):
replacements = {F("timestamp"): Value(None)}
self.assertEqual(
Q(timestamp__date__day=25).replace_expressions(replacements),
Q(timestamp__date__day=25),
)
replacements = {F("timestamp"): Value(datetime(2025, 10, 23))}
self.assertEqual(
Q(timestamp__date__day=13).replace_expressions(replacements),
Q(
IntegerFieldExact(
ExtractDay(TruncDate(Value(datetime(2025, 10, 23)))),
13,
)
),
)
self.assertEqual(
Q(timestamp__date__day__lte=25).replace_expressions(replacements),
Q(
IntegerLessThanOrEqual(
ExtractDay(TruncDate(Value(datetime(2025, 10, 23)))),
25,
)
),
)
self.assertEqual(
(
Q(Q(timestamp__date__day__lte=25), timestamp__date__day=13)
).replace_expressions(replacements),
(
Q(
Q(
IntegerLessThanOrEqual(
ExtractDay(TruncDate(Value(datetime(2025, 10, 23)))),
25,
)
),
IntegerFieldExact(
ExtractDay(TruncDate(Value(datetime(2025, 10, 23)))),
13,
),
)
),
)
self.assertEqual(
Q(timestamp=None).replace_expressions(replacements),
Q(IsNull(Value(datetime(2025, 10, 23)), True)),
)
self.assertEqual(
Q(timestamp__date__day__invalid=25).replace_expressions(replacements),
Q(timestamp__date__day__invalid=25),
)
class QCheckTests(TestCase): class QCheckTests(TestCase):
def test_basic(self): def test_basic(self):