Opened 7 hours ago
Last modified 4 hours ago
#36116 assigned Cleanup/optimization
prefetch_related() selects too many objects from a related table with a composite primary key and hidden related name
Reported by: | Jacob Walls | Owned by: | Simon Charette |
---|---|---|---|
Component: | Database layer (models, ORM) | Version: | 5.2 |
Severity: | Release blocker | Keywords: | |
Cc: | Simon Charette | Triage Stage: | Accepted |
Has patch: | yes | Needs documentation: | no |
Needs tests: | no | Patch needs improvement: | no |
Easy pickings: | no | UI/UX: | no |
Description
Noticed a "fixme" comment in the code that pointed me here. With a hidden related name, e.g. comments+
, we hit a branch that expects primary keys to consist of one column only. When a composite primary key violates that assumption, more objects than necessary will be fetched.
With this test:
-
tests/composite_pk/models/__init__.py
diff --git a/tests/composite_pk/models/__init__.py b/tests/composite_pk/models/__init__.py index 5996ae33b0..40750d91ae 100644
a b 1 from .tenant import Comment, Post, Tenant, TimeStamped, Token, User 1 from .tenant import ( 2 Comment, 3 CommentWithHiddenUserField, 4 Post, 5 Tenant, 6 TimeStamped, 7 Token, 8 User, 9 ) 2 10 3 11 __all__ = [ 4 12 "Comment", 13 "CommentWithHiddenUserField", 5 14 "Post", 6 15 "Tenant", 7 16 "TimeStamped", -
tests/composite_pk/models/tenant.py
diff --git a/tests/composite_pk/models/tenant.py b/tests/composite_pk/models/tenant.py index 6286ed2354..66fb371d3a 100644
a b class Comment(models.Model): 46 46 text = models.TextField(default="", blank=True) 47 47 48 48 49 class CommentWithHiddenUserField(models.Model): 50 pk = models.CompositePrimaryKey("tenant", "id") 51 tenant = models.ForeignKey( 52 Tenant, 53 on_delete=models.CASCADE, 54 related_name="comments+", 55 ) 56 id = models.SmallIntegerField(unique=True, db_column="comment_id") 57 user_id = models.SmallIntegerField() 58 user = models.ForeignObject( 59 User, 60 on_delete=models.CASCADE, 61 from_fields=("tenant_id", "user_id"), 62 to_fields=("tenant_id", "id"), 63 related_name="comments+", 64 ) 65 text = models.TextField(default="", blank=True) 66 67 49 68 class Post(models.Model): 50 69 pk = models.CompositePrimaryKey("tenant_id", "id") 51 70 tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, default=1) -
tests/composite_pk/tests.py
diff --git a/tests/composite_pk/tests.py b/tests/composite_pk/tests.py index 6b09480fb0..29da1be47b 100644
a b from django.db import IntegrityError, connection 16 16 from django.db.models import CompositePrimaryKey 17 17 from django.forms import modelform_factory 18 18 from django.test import TestCase 19 from django.test.utils import CaptureQueriesContext 19 20 20 from .models import Comment, Post, Tenant, TimeStamped, User21 from .models import Comment, CommentWithHiddenUserField, Post, Tenant, TimeStamped, User 21 22 22 23 23 24 class CommentForm(forms.ModelForm): … … class CompositePKTests(TestCase): 38 39 email="user0001@example.com", 39 40 ) 40 41 cls.comment = Comment.objects.create(tenant=cls.tenant, id=1, user=cls.user) 42 cls.comment_by_hidden_user = CommentWithHiddenUserField.objects.create( 43 tenant=cls.tenant, id=1, user=cls.user 44 ) 41 45 42 46 @staticmethod 43 47 def get_primary_key_columns(table): … … class CompositePKTests(TestCase): 157 161 result = list(Comment.objects.iterator()) 158 162 self.assertEqual(result, [self.comment]) 159 163 164 def test_prefetch_related_hidden_related_name(self): 165 with CaptureQueriesContext(connection) as queries: 166 CommentWithHiddenUserField.objects.prefetch_related("user").get( 167 pk=self.comment_by_hidden_user.pk 168 ) 169 self.assertIn('"comment_id" = 1', queries[1]["sql"]) 170 160 171 def test_query(self): 161 172 users = User.objects.values_list("pk").order_by("pk") 162 173 self.assertNotIn('AS "pk"', str(users.query))
The WHERE
is too permissive -- would expect both primary key columns to be checked, i.e. also "comment_id".
====================================================================== FAIL: test_prefetch_related_hidden_related_name (composite_pk.tests.CompositePKTests.test_prefetch_related_hidden_related_name) ---------------------------------------------------------------------- Traceback (most recent call last): File "/Users/source/django/tests/composite_pk/tests.py", line 169, in test_prefetch_related_hidden_related_name self.assertIn('"comment_id" = 1', queries[1]["sql"]) AssertionError: '"comment_id" = 1' not found in 'SELECT "composite_pk_user"."tenant_id", "composite_pk_user"."id", "composite_pk_user"."email" FROM "composite_pk_user" WHERE "composite_pk_user"."tenant_id" IN (1)' ---------------------------------------------------------------------- Ran 1 test in 0.006s FAILED (failures=1)
Change History (2)
comment:1 by , 5 hours ago
Cc: | added |
---|---|
Severity: | Normal → Release blocker |
Triage Stage: | Unreviewed → Accepted |
comment:2 by , 4 hours ago
Has patch: | set |
---|---|
Owner: | set to |
Status: | new → assigned |
Going to assign since I got a patch ready that doesn't require introducing new models and we'll surely see many of these required adjustments until 5.0 gets fully released. Let me know if you want me to hold off for longer when you report these.
Thanks for surfacing Jacob, glad we foresaw this one in cb83448891f2920c926f61826d8eae4051a3d8f2.
Tentatively elevating to release blocker as we've opted to start documenting
ForeignObject
's usage withCompositePrimaryKey
and I would expect users to run into this problem as well.