diff --git a/django/db/models/expressions.py b/django/db/models/expressions.py index 444e2fab7b..ced8779637 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 51f09f2937..20a5357cc5 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 @@ -1030,6 +1031,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 89601de85b..1fb4e2f34d 100644 --- a/tests/expressions/tests.py +++ b/tests/expressions/tests.py @@ -58,10 +58,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 @@ -1330,6 +1332,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):