summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSimon Charette <charette.s@gmail.com>2025-02-18 12:43:38 -0500
committerSarah Boyce <42296566+sarahboyce@users.noreply.github.com>2025-08-04 09:41:29 +0200
commite5ccb69bc3da407ab2b0477c0cc5db27c7207225 (patch)
treea5bbe4747edc65c6c3dc056942b34c473513ee39
parent5aefd005fc3dd35be6e9e4a24f9c2bc92b69df3b (diff)
[5.2.x] Fixed #36198 -- Implemented unresolved transform expression replacement.
This allows the proper resolving of F("field__transform") when performing constraint validation. Thanks Tom Hall for the report and Sarah for the test. Prerequisite for #36518. Backport of fc303551077c3e023fe4f9d01fc1b3026c816fa4 from main.
-rw-r--r--django/db/models/expressions.py19
-rw-r--r--tests/constraints/models.py1
-rw-r--r--tests/constraints/tests.py18
-rw-r--r--tests/expressions/tests.py34
4 files changed, 71 insertions, 1 deletions
diff --git a/django/db/models/expressions.py b/django/db/models/expressions.py
index b214be7fa5..3e1bcdbd2b 100644
--- a/django/db/models/expressions.py
+++ b/django/db/models/expressions.py
@@ -902,7 +902,24 @@ class F(Combinable):
return query.resolve_ref(self.name, allow_joins, reuse, summarize)
def replace_expressions(self, replacements):
- return replacements.get(self, self)
+ if (replacement := replacements.get(self)) is not None:
+ return replacement
+ field_name, *transforms = self.name.split(LOOKUP_SEP)
+ # Avoid unnecessarily looking up replacements with field_name again as
+ # in the vast majority of cases F instances won't be composed of any
+ # lookups.
+ if not transforms:
+ return self
+ if (
+ replacement := replacements.get(F(field_name))
+ ) is None or replacement._output_field_or_none is None:
+ return self
+ for transform in transforms:
+ transform_class = replacement.get_transform(transform)
+ if transform_class is None:
+ return self
+ replacement = transform_class(replacement)
+ return replacement
def asc(self, **kwargs):
return OrderBy(self, **kwargs)
diff --git a/tests/constraints/models.py b/tests/constraints/models.py
index 95a29ffa4d..41b827640e 100644
--- a/tests/constraints/models.py
+++ b/tests/constraints/models.py
@@ -73,6 +73,7 @@ class UniqueConstraintProduct(models.Model):
name = models.CharField(max_length=255)
color = models.CharField(max_length=32, null=True)
age = models.IntegerField(null=True)
+ updated = models.DateTimeField(null=True)
class Meta:
constraints = [
diff --git a/tests/constraints/tests.py b/tests/constraints/tests.py
index 9047710098..e67a32b806 100644
--- a/tests/constraints/tests.py
+++ b/tests/constraints/tests.py
@@ -1,3 +1,4 @@
+from datetime import datetime, timedelta
from unittest import mock
from django.core.exceptions import ValidationError
@@ -1059,6 +1060,23 @@ class UniqueConstraintTests(TestCase):
exclude={"name"},
)
+ def test_validate_field_transform(self):
+ updated_date = datetime(2005, 7, 26)
+ UniqueConstraintProduct.objects.create(name="p1", updated=updated_date)
+ constraint = models.UniqueConstraint(
+ models.F("updated__date"), name="date_created_unique"
+ )
+ msg = "Constraint “date_created_unique” is violated."
+ with self.assertRaisesMessage(ValidationError, msg):
+ constraint.validate(
+ UniqueConstraintProduct,
+ UniqueConstraintProduct(updated=updated_date),
+ )
+ constraint.validate(
+ UniqueConstraintProduct,
+ UniqueConstraintProduct(updated=updated_date + timedelta(days=1)),
+ )
+
def test_validate_ordered_expression(self):
constraint = models.UniqueConstraint(
Lower("name").desc(), name="name_lower_uniq_desc"
diff --git a/tests/expressions/tests.py b/tests/expressions/tests.py
index a06fbc6b95..5f61f65ac0 100644
--- a/tests/expressions/tests.py
+++ b/tests/expressions/tests.py
@@ -59,10 +59,12 @@ from django.db.models.expressions import (
from django.db.models.functions import (
Coalesce,
Concat,
+ ExtractDay,
Left,
Length,
Lower,
Substr,
+ TruncDate,
Upper,
)
from django.db.models.sql import constants
@@ -1347,6 +1349,38 @@ class FTests(SimpleTestCase):
with self.assertRaisesMessage(TypeError, msg):
"" in F("name")
+ def test_replace_expressions_transform(self):
+ replacements = {F("timestamp"): Value(None)}
+ transform_ref = F("timestamp__date")
+ self.assertIs(transform_ref.replace_expressions(replacements), transform_ref)
+ invalid_transform_ref = F("timestamp__invalid")
+ self.assertIs(
+ invalid_transform_ref.replace_expressions(replacements),
+ invalid_transform_ref,
+ )
+ replacements = {F("timestamp"): Value(datetime.datetime(2025, 3, 1, 14, 10))}
+ self.assertEqual(
+ F("timestamp__date").replace_expressions(replacements),
+ TruncDate(Value(datetime.datetime(2025, 3, 1, 14, 10))),
+ )
+ self.assertEqual(
+ F("timestamp__date__day").replace_expressions(replacements),
+ ExtractDay(TruncDate(Value(datetime.datetime(2025, 3, 1, 14, 10)))),
+ )
+ invalid_nested_transform_ref = F("timestamp__date__invalid")
+ self.assertIs(
+ invalid_nested_transform_ref.replace_expressions(replacements),
+ invalid_nested_transform_ref,
+ )
+ # `replacements` is not unnecessarily looked up a second time for
+ # transform-less field references as it's the case the vast majority of
+ # the time.
+ mock_replacements = mock.Mock()
+ mock_replacements.get.return_value = None
+ field_ref = F("name")
+ self.assertIs(field_ref.replace_expressions(mock_replacements), field_ref)
+ mock_replacements.get.assert_called_once_with(field_ref)
+
class ExpressionsTests(TestCase):
def test_F_reuse(self):