Opened 4 weeks ago

Closed 15 hours ago

Last modified 15 hours ago

#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):  
    4848    text = models.TextField(default="", blank=True)
    4949    integer = models.IntegerField(default=0)
    5050
     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
    5161
    5262class Post(models.Model):
    5363    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  
    11from django.contrib.contenttypes.models import ContentType
    22from django.core.exceptions import ValidationError
     3from django.db import connection
    34from django.test import TestCase
     5from django.test.utils import CaptureQueriesContext
    46
    57from .models import Comment, Tenant, Token, User
    68
    class CompositePKModelsTests(TestCase):  
    119121                self.assertSequenceEqual(ctx.exception.messages, messages)
    120122
    121123    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):
    123141            self.comment_1.full_clean()
    124142
    125143    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):  
    187187            self.assertEqual(user.email, self.user.email)
    188188
    189189    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)
    191196        with self.assertNumQueries(1):
    192197            comments = list(Comment.objects.select_related("user").order_by("pk"))
    193198            self.assertEqual(len(comments), 2)
    194199            self.assertEqual(comments[0].user, self.user)
    195             self.assertIsNone(comments[1].user)
     200            self.assertEqual(comments[1].user, user2)
    196201
    197202    def test_model_forms(self):
    198203        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 Sarah Boyce, 4 weeks ago

Severity: NormalRelease blocker
Triage Stage: UnreviewedAccepted

Thank you Jacob, replicated applying to 5.2

Reproduced on 5.2, but thus, not a release blocker.

I think following the reasoning in this comment, we should mark as a release blocker for 5.2

comment:2 by Samriddha Kumar Tripathi, 4 weeks ago

Cc: Samriddha Kumar Tripathi added

comment:3 by Sarah Boyce, 4 weeks ago

Cc: JaeHyuckSa Simon Charette added

comment:4 by Devendra Tumu, 4 weeks ago

Cc: Devendra Tumu added

comment:5 by Sarah Boyce, 4 weeks ago

Note that I have written a test

  • tests/foreign_object/models/__init__.py

    a b  
    11from .article import Article, ArticleIdea, ArticleTag, ArticleTranslation, NewsArticle
    2 from .customers import Address, Contact, Customer, CustomerTab
     2from .customers import Address, Contact, ContactCheck, Customer, CustomerTab
    33from .empty_join import SlugPage
    44from .person import Country, Friendship, Group, Membership, Person
    55
    __all__ = [  
    1010    "ArticleTag",
    1111    "ArticleTranslation",
    1212    "Contact",
     13    "ContactCheck",
    1314    "Country",
    1415    "Customer",
    1516    "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):  
    4141    )
    4242
    4343
     44class 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
    4465class CustomerTab(models.Model):
    4566    customer_id = models.IntegerField()
    4667    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 (  
    1515    ArticleTag,
    1616    ArticleTranslation,
    1717    Country,
     18    ContactCheck,
    1819    CustomerTab,
    1920    Friendship,
    2021    Group,
    class ForeignObjectModelValidationTests(TestCase):  
    798799    def test_validate_constraints_excluding_foreign_object_member(self):
    799800        customer_tab = CustomerTab(customer_id=150)
    800801        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 Simon Charette, 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  
    3939    lazy_related_operation,
    4040    resolve_relation,
    4141)
     42from django.db.models.fields.tuple_lookups import Tuple
    4243from django.db.models.functions import Coalesce
    4344from django.db.models.manager import Manager
    4445from django.db.models.options import Options
    def _get_field_expression_map(self, meta, exclude=None):  
    13801381                isinstance(field.remote_field, ForeignObjectRel)
    13811382                and field not in meta.local_concrete_fields
    13821383            ):
    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                    )
    13881405            elif field.concrete:
    13891406                value = getattr(self, field.attname)
    13901407            else:

comment:7 by Sarah Boyce, 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 Simon Charette, 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 Jacob Walls, 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 Sarah Boyce, 4 weeks ago

Owner: set to Sarah Boyce
Status: newassigned

I will try and pull something together

comment:11 by Sarah Boyce, 4 weeks ago

Has patch: set

comment:12 by Sarah Boyce, 3 weeks ago

Severity: Release blockerNormal

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 Jacob Walls, 3 weeks ago

Triage Stage: AcceptedReady for checkin

comment:14 by Natalia Bidart, 6 days ago

Triage Stage: Ready for checkinAccepted

Setting back to Accepted until the final tweaks are pushed.

comment:15 by Jacob Walls, 18 hours ago

Owner: changed from Sarah Boyce to Jacob Walls

Ready for a final review.

comment:16 by Natalia Bidart, 16 hours ago

Triage Stage: AcceptedReady for checkin

comment:17 by nessita <124304+nessita@…>, 15 hours ago

Resolution: fixed
Status: assignedclosed

In 5b51e6f:

Fixed #36611, Refs #36580 -- Added system check for multicolumn ForeignObject in Meta.indexes/constraints/unique_together.

ForeignObjects with multiple from_fields are not supported in these
options.

Co-authored-by: Jacob Walls <jacobtylerwalls@…>
Co-authored-by: Natalia <124304+nessita@…>

comment:18 by Natalia <124304+nessita@…>, 15 hours ago

In 0fa339c:

[6.0.x] Fixed #36611, Refs #36580 -- Added system check for multicolumn ForeignObject in Meta.indexes/constraints/unique_together.

ForeignObjects with multiple from_fields are not supported in these
options.

Co-authored-by: Jacob Walls <jacobtylerwalls@…>
Co-authored-by: Natalia <124304+nessita@…>

Backport of 5b51e6f759f2ba993219347435149173c756c478 from main.

Note: See TracTickets for help on using tickets.
Back to Top