Opened 14 months ago

Closed 14 months ago

Last modified 14 months ago

#36530 closed Bug (fixed)

System check for ManyToManyField & Composite Primary Key checks only one direction

Reported by: Jacob Walls Owned by: Jason Hall
Component: Database layer (models, ORM) Version: 5.2
Severity: Release blocker Keywords: compositeprimarykey
Cc: Simon Charette 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

#36034 added the system check E347 for ManyToManyField when the "to_model" has a composite pk, but the situation is just as unsupported when the composite primary key model is the "from_model", i.e. the one with *both* the ManyToManyField and the CompositePrimaryKey.

We should be able to just adjust ​this system check condition to check the "from" table also.


e.g. this (purely for demo, not suggesting we edit this test model):

  • tests/composite_pk/models/tenant.py

    diff --git a/tests/composite_pk/models/tenant.py b/tests/composite_pk/models/tenant.py
    index 65eb0feae8..ef777edff9 100644
    a b class TimeStamped(models.Model):  
    6060    id = models.SmallIntegerField(unique=True)
    6161    created = models.DateTimeField(auto_now_add=True)
    6262    text = models.TextField(default="", blank=True)
     63    tenants = models.ManyToManyField(Tenant)

gives:

ERROR: test_serialize_datetime (composite_pk.tests.CompositePKFixturesTests.test_serialize_datetime)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/jwalls/django/tests/composite_pk/tests.py", line 368, in test_serialize_datetime
    result = serializers.serialize("json", TimeStamped.objects.all())
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/jwalls/django/django/core/serializers/__init__.py", line 134, in serialize
    s.serialize(queryset, **options)
  File "/Users/jwalls/django/django/core/serializers/base.py", line 144, in serialize
    self.handle_m2m_field(obj, field)
  File "/Users/jwalls/django/django/core/serializers/python.py", line 95, in handle_m2m_field
    queryset_iterator(obj, field),
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/jwalls/django/django/core/serializers/python.py", line 89, in queryset_iterator
    query_set = getattr(obj, field.name).select_related(None).only("pk")
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/jwalls/django/django/db/models/manager.py", line 87, in manager_method
    return getattr(self.get_queryset(), name)(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/jwalls/django/django/db/models/query.py", line 1632, in select_related
    self._not_support_combined_queries("select_related")
  File "/Users/jwalls/django/django/db/models/query.py", line 2052, in _not_support_combined_queries
    if self.query.combinator:
       ^^^^^^^^^^
  File "/Users/jwalls/django/django/db/models/query.py", line 302, in query
    self._filter_or_exclude_inplace(negate, args, kwargs)
  File "/Users/jwalls/django/django/db/models/query.py", line 1549, in _filter_or_exclude_inplace
    self._query.add_q(Q(*args, **kwargs))
  File "/Users/jwalls/django/django/db/models/sql/query.py", line 1667, in add_q
    clause, _ = self._add_q(q_object, can_reuse)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/jwalls/django/django/db/models/sql/query.py", line 1699, in _add_q
    child_clause, needed_inner = self.build_filter(
                                 ^^^^^^^^^^^^^^^^^^
  File "/Users/jwalls/django/django/db/models/sql/query.py", line 1609, in build_filter
    condition = self.build_lookup(lookups, col, value)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/jwalls/django/django/db/models/sql/query.py", line 1436, in build_lookup
    lookup = lookup_class(lhs, rhs)
             ^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/jwalls/django/django/db/models/lookups.py", line 35, in __init__
    self.rhs = self.get_prep_lookup()
               ^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/jwalls/django/django/db/models/fields/tuple_lookups.py", line 55, in get_prep_lookup
    self.check_rhs_length_equals_lhs_length()
  File "/Users/jwalls/django/django/db/models/fields/tuple_lookups.py", line 69, in check_rhs_length_equals_lhs_length
    len_lhs = len(self.lhs)
              ^^^^^^^^^^^^^
TypeError: object of type 'Col' has no len()

----------------------------------------------------------------------
Ran 170 tests in 0.183s

FAILED (errors=1)

Change History (14)

comment:1 by Natalia Bidart, 14 months ago

Cc: Simon Charette added
Easy pickings: unset
Triage Stage: Unreviewed → Accepted

Thank you Jacob! How about this for a regression test?

    def test_many_to_many_from_model_with_composite_primary_key(self):
        class Parent(models.Model):
            name = models.CharField(max_length=20)

        class Child(models.Model):
            pk = models.CompositePrimaryKey("version", "name")
            version = models.IntegerField()
            name = models.CharField(max_length=20)
            parents = models.ManyToManyField(Parent)

        field = Child._meta.get_field("parents")
        self.assertEqual(
            field.check(from_model=Child),
            [
                Error(
                    "Field has a CompositePrimaryKey and defines a relation with model "
                    "'Parent' which is not supported.",
                    obj=field,
                    id="fields.E348",
                ),
            ],
        )

comment:2 by Jason Hall, 14 months ago

Owner: set to Jason Hall
Status: new → assigned

comment:3 by Jason Hall, 14 months ago

I was just trying to reproduce this issue with the following test:

@isolate_apps("model_fields")
class M2MCompositePKCheckTests(SimpleTestCase):
    def test_m2m_from_model_composite_pk_should_raise_e347(self):
        class Target(models.Model):
            name = models.CharField(max_length=100)

            class Meta:
                app_label = "model_fields"
        
        class TimeStamped(models.Model):
            id = models.IntegerField()
            created = models.DateTimeField()
            tenants = models.ManyToManyField(Target)

            class Meta:
                app_label = "model_fields"
                constraints = [
                    models.UniqueConstraint(fields=["id", "created"], name="composite_pk")
                ]
        
        errors = TimeStamped.check()
        self.assertTrue(
            any(e.id == "models.E347" for e in errors),
            msg="Expected E347 for ManyToManyField declared on a model with composite primary key",
        )

I'm not sure if this approach (using UniqueConstraint to simulate a composite PK) is appropriate for the final test, or if we should prefer the version that uses CompositePrimaryKey and field.check(from_model=...).

Just wanted to share what I came up with to help confirm the issue before working on the fix. Happy to adjust based on feedback!

comment:4 by Jacob Walls, 14 months ago

We should use CompositePrimaryKey, as that ensures the check guards what is unsupported in fact.

Natalia's version riffs on the tests from #36034, which is what I had in mind. And incidentally why I thought this was an easy picking, but I suppose I should go to the forum before taking up a 1-person campaign to use that flag more ;)

comment:5 by Jason Hall, 14 months ago

Has patch: set

comment:6 by Sarah Boyce, 14 months ago

Patch needs improvement: set

comment:7 by Jason Hall, 14 months ago

Triage Stage: Accepted → Ready for checkin

comment:8 by Simon Charette, 14 months ago

Triage Stage: Ready for checkin → Accepted

Please refer to the contributing documentation, ​you can't mark your own patch RFC.

comment:9 by Jason Hall, 14 months ago

Patch needs improvement: unset

comment:10 by Natalia Bidart, 14 months ago

Patch needs improvement: set

comment:11 by Jason Hall, 14 months ago

Patch needs improvement: unset

comment:12 by Natalia Bidart, 14 months ago

Triage Stage: Accepted → Ready for checkin

comment:13 by nessita <124304+nessita@…>, 14 months ago

Resolution: → fixed
Status: assigned → closed

In 2013092:

Fixed #36530 -- Extended fields.E347 to check for ManyToManyField involving CompositePrimaryKey on either side.

Thanks to Jacob Walls for the report.

comment:14 by Natalia <124304+nessita@…>, 14 months ago

In bdc3f9e:

[5.2.x] Fixed #36530 -- Extended fields.E347 to check for ManyToManyField involving CompositePrimaryKey on either side.

Thanks to Jacob Walls for the report.

Backport of 2013092b693be0ebdf36f41dc61615a2de1bbe31 from main.

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