#36611 closed Bug (fixed)
Model validation of constraint involving ForeignObject considers only first column
Reported by: | Jacob Walls | Owned by: | Jacob Walls |
---|---|---|---|
Component: | Database layer (models, ORM) | Version: | 5.2 |
Severity: | Normal | Keywords: | |
Cc: | Samriddha Kumar Tripathi, JaeHyuckSa, Simon Charette, Devendra Tumu | Triage Stage: | Ready for checkin |
Has patch: | yes | Needs documentation: | no |
Needs tests: | no | Patch needs improvement: | no |
Easy pickings: | no | UI/UX: | no |
Description
Discovered during #36580 in review.
Similar to #36431, where only the first column of a ForeignObject
was considered in values()
, only the first column is considered during model validation of constraints.
Composite PK's are not affected, because they raise system checks if you try to use them in a constraint. But ForeignObject
has been broken since its introduction in this regard. Reproduced on 5.2, but thus, not a release blocker.
Rough test (needs adjusting to avoid hijacking this model and unnecessarily skipping tests on backends not supporting constraints):
-
tests/composite_pk/models/tenant.py
diff --git a/tests/composite_pk/models/tenant.py b/tests/composite_pk/models/tenant.py index 65eb0feae8..c818ec4de7 100644
a b class Comment(models.Model): 48 48 text = models.TextField(default="", blank=True) 49 49 integer = models.IntegerField(default=0) 50 50 51 class Meta: 52 # TODO: use new model instead 53 required_db_features = {"supports_table_check_constraints"} 54 constraints = [ 55 models.CheckConstraint( 56 condition=models.Q(user__lt=(1000, 1000)), 57 name="user_limit", 58 ), 59 ] 60 51 61 52 62 class Post(models.Model): 53 63 pk = models.CompositePrimaryKey("tenant_id", "id") -
tests/composite_pk/test_models.py
diff --git a/tests/composite_pk/test_models.py b/tests/composite_pk/test_models.py index 27157a52ad..05aafd5306 100644
a b 1 1 from django.contrib.contenttypes.models import ContentType 2 2 from django.core.exceptions import ValidationError 3 from django.db import connection 3 4 from django.test import TestCase 5 from django.test.utils import CaptureQueriesContext 4 6 5 7 from .models import Comment, Tenant, Token, User 6 8 … … class CompositePKModelsTests(TestCase): 119 121 self.assertSequenceEqual(ctx.exception.messages, messages) 120 122 121 123 def test_full_clean_update(self): 122 with self.assertNumQueries(1): 124 with CaptureQueriesContext(connection) as ctx: 125 self.comment_1.full_clean() 126 select_queries = [ 127 query["sql"] 128 for query in ctx.captured_queries 129 if "select" in query["sql"].lower() 130 ] 131 self.assertEqual(len(select_queries), 2, select_queries) # 1 on 5.2.x 132 133 def test_full_clean_update_invalid(self): 134 self.comment_1.tenant_id = 1001 135 with self.assertRaises(ValidationError): 136 self.comment_1.full_clean() 137 138 self.comment_1.tenant_id = 1 139 self.comment_1.user_id = 1001 140 with self.assertRaises(ValidationError): 123 141 self.comment_1.full_clean() 124 142 125 143 def test_field_conflicts(self): -
tests/composite_pk/tests.py
diff --git a/tests/composite_pk/tests.py b/tests/composite_pk/tests.py index 2245a472e4..5b7e34a0bc 100644
a b class CompositePKTests(TestCase): 187 187 self.assertEqual(user.email, self.user.email) 188 188 189 189 def test_select_related(self): 190 Comment.objects.create(tenant=self.tenant, id=2) 190 user2 = User.objects.create( 191 tenant=self.tenant, 192 id=2, 193 email="user0002@example.com", 194 ) 195 Comment.objects.create(tenant=self.tenant, id=2, user=user2) 191 196 with self.assertNumQueries(1): 192 197 comments = list(Comment.objects.select_related("user").order_by("pk")) 193 198 self.assertEqual(len(comments), 2) 194 199 self.assertEqual(comments[0].user, self.user) 195 self.assert IsNone(comments[1].user)200 self.assertEqual(comments[1].user, user2) 196 201 197 202 def test_model_forms(self): 198 203 fields = ["tenant", "id", "user_id", "text", "integer"]
Notice 1001
only appears in the first query of the test_full_clean_update_invalid
.
FAIL: test_full_clean_update_invalid (composite_pk.test_models.CompositePKModelsTests.test_full_clean_update_invalid) ---------------------------------------------------------------------- Traceback (most recent call last): File "/Users/jwalls/django/tests/composite_pk/test_models.py", line 140, in test_full_clean_update_invalid with self.assertRaises(ValidationError): ~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^ AssertionError: ValidationError not raised ---------------------------------------------------------------------- (0.000) SELECT 1 AS "a" FROM "composite_pk_tenant" WHERE "composite_pk_tenant"."id" = 1001 LIMIT 1; args=(1, 1001); ALIAS=DEFAULT (0.000) SELECT 1 AS "a" FROM "composite_pk_tenant" WHERE "composite_pk_tenant"."id" = 1 LIMIT 1; args=(1, 1); ALIAS=DEFAULT ---------------------------------------------------------------------- Ran 2 tests in 0.003s FAILED (failures=1)
Change History (18)
comment:1 by , 4 weeks ago
Severity: | Normal → Release blocker |
---|---|
Triage Stage: | Unreviewed → Accepted |
comment:2 by , 4 weeks ago
Cc: | added |
---|
comment:3 by , 4 weeks ago
Cc: | added |
---|
comment:4 by , 4 weeks ago
Cc: | added |
---|
comment:5 by , 4 weeks ago
Note that I have written a test
-
tests/foreign_object/models/__init__.py
a b 1 1 from .article import Article, ArticleIdea, ArticleTag, ArticleTranslation, NewsArticle 2 from .customers import Address, Contact, C ustomer, CustomerTab2 from .customers import Address, Contact, ContactCheck, Customer, CustomerTab 3 3 from .empty_join import SlugPage 4 4 from .person import Country, Friendship, Group, Membership, Person 5 5 … … __all__ = [ 10 10 "ArticleTag", 11 11 "ArticleTranslation", 12 12 "Contact", 13 "ContactCheck", 13 14 "Country", 14 15 "Customer", 15 16 "CustomerTab", -
tests/foreign_object/models/customers.py
diff --git a/tests/foreign_object/models/customers.py b/tests/foreign_object/models/customers.py index 085b7272e9..f9a2e932c5 100644
a b class Contact(models.Model): 41 41 ) 42 42 43 43 44 class ContactCheck(models.Model): 45 company_code = models.CharField(max_length=1) 46 customer_code = models.IntegerField() 47 customer = models.ForeignObject( 48 Customer, 49 on_delete=models.CASCADE, 50 related_name="contact_checks", 51 to_fields=["customer_id", "company"], 52 from_fields=["customer_code", "company_code"], 53 ) 54 55 class Meta: 56 required_db_features = {"supports_table_check_constraints"} 57 constraints = [ 58 models.CheckConstraint( 59 condition=models.Q(customer__lt=(1000, "c")), 60 name="customer_company_limit", 61 ), 62 ] 63 64 44 65 class CustomerTab(models.Model): 45 66 customer_id = models.IntegerField() 46 67 customer = models.ForeignObject( -
tests/foreign_object/tests.py
diff --git a/tests/foreign_object/tests.py b/tests/foreign_object/tests.py index 09fb47e771..539c1a4fec 100644
a b from .models import ( 15 15 ArticleTag, 16 16 ArticleTranslation, 17 17 Country, 18 ContactCheck, 18 19 CustomerTab, 19 20 Friendship, 20 21 Group, … … class ForeignObjectModelValidationTests(TestCase): 798 799 def test_validate_constraints_excluding_foreign_object_member(self): 799 800 customer_tab = CustomerTab(customer_id=150) 800 801 customer_tab.validate_constraints(exclude={"customer_id"}) 802 803 @skipUnlessDBFeature("supports_table_check_constraints") 804 def test_validate_constraints_with_foreign_object_multiple_fields(self): 805 contact = ContactCheck(company_code="d", customer_code=1500) 806 with self.assertRaisesMessage(ValidationError, "customer_company_limit"): 807 contact.validate_constraints()
When applied to Django 5.2, this fails with AssertionError: ValidationError not raised
When applied to Django main, this fails with:
====================================================================== ERROR: test_validate_constraints_with_foreign_object_multiple_fields (foreign_object.tests.ForeignObjectModelValidationTests.test_validate_constraints_with_foreign_object_multiple_fields) ---------------------------------------------------------------------- Traceback (most recent call last): File "/path_to_django/tests/foreign_object/tests.py", line 807, in test_validate_constraints_with_foreign_object_multiple_fields contact.validate_constraints() File "/path_to_django/db/models/base.py", line 1653, in validate_constraints constraint.validate(model_class, self, exclude=exclude, using=using) File "/path_to_django/django/db/models/constraints.py", line 212, in validate if not Q(self.condition).check(against, using=using): ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/path_to_django/django/db/models/query_utils.py", line 187, in check return compiler.execute_sql(SINGLE) is not None ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/path_to_django/django/db/models/sql/compiler.py", line 1611, in execute_sql sql, params = self.as_sql() ^^^^^^^^^^^^^ File "/path_to_django/django/db/models/sql/compiler.py", line 795, in as_sql self.compile(self.where) if self.where is not None else ("", []) ^^^^^^^^^^^^^^^^^^^^^^^^ File "/path_to_django/django/db/models/sql/compiler.py", line 578, in compile sql, params = node.as_sql(self, self.connection) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/path_to_django/django/db/models/sql/where.py", line 151, in as_sql sql, params = compiler.compile(child) ^^^^^^^^^^^^^^^^^^^^^^^ File "/path_to_django/django/db/models/sql/compiler.py", line 578, in compile sql, params = node.as_sql(self, self.connection) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/path_to_django/django/db/models/lookups.py", line 404, in as_sql lhs_sql, params = self.process_lhs(compiler, connection) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/path_to_django/django/db/models/lookups.py", line 230, in process_lhs lhs_sql, params = super().process_lhs(compiler, connection, lhs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/path_to_django/django/db/models/lookups.py", line 110, in process_lhs sql, params = compiler.compile(lhs) ^^^^^^^^^^^^^^^^^^^^^ File "/path_to_django/django/db/models/sql/compiler.py", line 576, in compile sql, params = vendor_impl(self, self.connection) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/path_to_django/django/db/models/expressions.py", line 29, in as_sqlite sql, params = self.as_sql(compiler, connection, **extra_context) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/path_to_django/django/db/models/expressions.py", line 1107, in as_sql arg_sql, arg_params = compiler.compile(arg) ^^^^^^^^^^^^^^^^^^^^^ File "/path_to_django/db/models/sql/compiler.py", line 578, in compile sql, params = node.as_sql(self, self.connection) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/path_to_django/django/db/models/sql/where.py", line 151, in as_sql sql, params = compiler.compile(child) ^^^^^^^^^^^^^^^^^^^^^^^ File "/path_to_django/django/db/models/sql/compiler.py", line 578, in compile sql, params = node.as_sql(self, self.connection) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/path_to_django/django/db/models/fields/related_lookups.py", line 128, in as_sql return super().as_sql(compiler, connection) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/path_to_django/django/db/models/lookups.py", line 239, in as_sql rhs_sql, rhs_params = self.process_rhs(compiler, connection) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/path_to_django/django/db/models/lookups.py", line 138, in process_rhs return self.get_db_prep_lookup(value, connection) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/path_to_django/db/models/lookups.py", line 259, in get_db_prep_lookup field = getattr(self.lhs.output_field, "target_field", None) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/path_to_django/db/models/fields/related.py", line 506, in target_field raise exceptions.FieldError( django.core.exceptions.FieldError: The relation has multiple target fields, but only single target field was asked for
So note to self that any fix, we should also make sure we have applied to 5.2 and tested (in case it is relying on a commit currently applied to main, not to 5.2)
comment:6 by , 4 weeks ago
I suspect this will be very hard to to solve without solving #35956 of the one ticket I can't find to add generic composite field support as it's preventing us from performing lookups of the form
ContactCheck.objects.filter(customer__lt=(1000, "c"))
in the first place.
Here's an attempt at using Tuple
which results in the same problem as Tuple.output_field
is currently faked as models.Field
due to the lack of CompositeField
existence
-
django/db/models/base.py
diff --git a/django/db/models/base.py b/django/db/models/base.py index 36b16c1132..d7af0ebd1a 100644
a b 39 39 lazy_related_operation, 40 40 resolve_relation, 41 41 ) 42 from django.db.models.fields.tuple_lookups import Tuple 42 43 from django.db.models.functions import Coalesce 43 44 from django.db.models.manager import Manager 44 45 from django.db.models.options import Options … … def _get_field_expression_map(self, meta, exclude=None): 1380 1381 isinstance(field.remote_field, ForeignObjectRel) 1381 1382 and field not in meta.local_concrete_fields 1382 1383 ): 1383 value = tuple( 1384 getattr(self, from_field) for from_field in field.from_fields 1385 ) 1386 if len(value) == 1: 1387 value = value[0] 1384 composed_fields = field.local_related_fields 1385 if len(composed_fields) == 1: 1386 value = getattr(self, composed_fields[0].attname) 1387 else: 1388 composed_values = ( 1389 getattr(self, composed_field.attname) 1390 for composed_field in composed_fields 1391 ) 1392 value = Tuple( 1393 *( 1394 ( 1395 composed_value 1396 if hasattr(composed_value, "resolve_expression") 1397 else Value(composed_value, composed_field) 1398 ) 1399 for composed_value, composed_field in zip( 1400 composed_values, composed_fields 1401 ) 1402 ), 1403 output_field=field, 1404 ) 1388 1405 elif field.concrete: 1389 1406 value = getattr(self, field.attname) 1390 1407 else:
comment:7 by , 4 weeks ago
In #35992, we added a system check to say that CompositePrimaryKey
fields cannot be used in indexes/constraints/unique_together
We could perhaps have approached #36580 with a similar system check, and maybe that option is still open (so we disallow ForeignObject fields in indexes/constraints/unique_together)
comment:8 by , 4 weeks ago
Sarah, I know it represents reverting back work but I do like this approach. We could revisit once we we add support for generic composite fields but until then baking hacks to support what is essentially ORM sugar (that can be expressed by pointing at the concrete fields directly) makes me feel like we're opening the door to unbounded work.
comment:9 by , 4 weeks ago
We could add a check without reverting #36580, since anyone who might be using this sugar already in 5.2 might be getting by just fine, for instance if they don't use ModelForm
or full_clean
. They could just silence the system check and carry on, and we can leave work in place we think we're likely to want later anyway.
comment:10 by , 4 weeks ago
Owner: | set to |
---|---|
Status: | new → assigned |
I will try and pull something together
comment:11 by , 4 weeks ago
Has patch: | set |
---|
comment:12 by , 3 weeks ago
Severity: | Release blocker → Normal |
---|
Following further discussion, downgraded from a release blocker.
This bug technically exists prior to 5.2 and the preferred fix is not 100% backwards compatible.
comment:13 by , 3 weeks ago
Triage Stage: | Accepted → Ready for checkin |
---|
comment:14 by , 6 days ago
Triage Stage: | Ready for checkin → Accepted |
---|
Setting back to Accepted until the final tweaks are pushed.
comment:16 by , 16 hours ago
Triage Stage: | Accepted → Ready for checkin |
---|
Thank you Jacob, replicated applying to 5.2
I think following the reasoning in this comment, we should mark as a release blocker for 5.2