1
0
mirror of https://github.com/django/django.git synced 2025-03-12 18:30:48 +00:00

Fixed #36116 -- Optimized multi-column ForwardManyToOne prefetching.

Rely on ColPairs and TupleIn which support a single column to be specified
to avoid special casing ForwardManyToOne.get_prefetch_querysets().

Thanks Jacob Walls for the report.
This commit is contained in:
Simon Charette 2025-01-19 21:35:50 -05:00 committed by Sarah Boyce
parent 337c641abb
commit 626d77e52a
3 changed files with 51 additions and 20 deletions

View File

@ -74,6 +74,8 @@ from django.db import (
transaction, transaction,
) )
from django.db.models import Manager, Q, Window, signals from django.db.models import Manager, Q, Window, signals
from django.db.models.expressions import ColPairs
from django.db.models.fields.tuple_lookups import TupleIn
from django.db.models.functions import RowNumber from django.db.models.functions import RowNumber
from django.db.models.lookups import GreaterThan, LessThanOrEqual from django.db.models.lookups import GreaterThan, LessThanOrEqual
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
@ -164,23 +166,19 @@ class ForwardManyToOneDescriptor:
rel_obj_attr = self.field.get_foreign_related_value rel_obj_attr = self.field.get_foreign_related_value
instance_attr = self.field.get_local_related_value instance_attr = self.field.get_local_related_value
instances_dict = {instance_attr(inst): inst for inst in instances} instances_dict = {instance_attr(inst): inst for inst in instances}
related_field = self.field.foreign_related_fields[0] related_fields = self.field.foreign_related_fields
remote_field = self.field.remote_field remote_field = self.field.remote_field
queryset = queryset.filter(
# FIXME: This will need to be revisited when we introduce support for TupleIn(
# composite fields. In the meantime we take this practical approach to ColPairs(
# solve a regression on 1.6 when the reverse manager is hidden queryset.model._meta.db_table,
# (related_name ends with a '+'). Refs #21410. related_fields,
# The check for len(...) == 1 is a special case that allows the query related_fields,
# to be join-less and smaller. Refs #21760. self.field,
if remote_field.hidden or len(self.field.foreign_related_fields) == 1: ),
query = { list(instances_dict),
"%s__in" )
% related_field.name: {instance_attr(inst)[0] for inst in instances} )
}
else:
query = {"%s__in" % self.field.related_query_name(): instances}
queryset = queryset.filter(**query)
# There can be only one object prefetched for each instance so clear # There can be only one object prefetched for each instance so clear
# ordering if the query allows it without side effects. # ordering if the query allows it without side effects.
queryset.query.clear_ordering() queryset.query.clear_ordering()

View File

@ -107,6 +107,6 @@ class Friendship(models.Model):
Person, Person,
from_fields=["to_friend_country_id", "to_friend_id"], from_fields=["to_friend_country_id", "to_friend_id"],
to_fields=["person_country_id", "id"], to_fields=["person_country_id", "id"],
related_name="to_friend", related_name="to_friend+",
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )

View File

@ -4,7 +4,7 @@ import pickle
from operator import attrgetter from operator import attrgetter
from django.core.exceptions import FieldError from django.core.exceptions import FieldError
from django.db import models from django.db import connection, models
from django.test import SimpleTestCase, TestCase, skipUnlessDBFeature from django.test import SimpleTestCase, TestCase, skipUnlessDBFeature
from django.test.utils import isolate_apps from django.test.utils import isolate_apps
from django.utils import translation from django.utils import translation
@ -246,7 +246,7 @@ class MultiColumnFKTests(TestCase):
normal_people = [m.person for m in Membership.objects.order_by("pk")] normal_people = [m.person for m in Membership.objects.order_by("pk")]
self.assertEqual(people, normal_people) self.assertEqual(people, normal_people)
def test_prefetch_foreignkey_forward_works(self): def test_prefetch_foreignobject_forward(self):
Membership.objects.create( Membership.objects.create(
membership_country=self.usa, person=self.bob, group=self.cia membership_country=self.usa, person=self.bob, group=self.cia
) )
@ -263,7 +263,40 @@ class MultiColumnFKTests(TestCase):
normal_people = [m.person for m in Membership.objects.order_by("pk")] normal_people = [m.person for m in Membership.objects.order_by("pk")]
self.assertEqual(people, normal_people) self.assertEqual(people, normal_people)
def test_prefetch_foreignkey_reverse_works(self): def test_prefetch_foreignobject_hidden_forward(self):
Friendship.objects.create(
from_friend_country=self.usa,
from_friend_id=self.bob.id,
to_friend_country_id=self.usa.id,
to_friend_id=self.george.id,
)
Friendship.objects.create(
from_friend_country=self.usa,
from_friend_id=self.bob.id,
to_friend_country_id=self.soviet_union.id,
to_friend_id=self.sam.id,
)
with self.assertNumQueries(2) as ctx:
friendships = list(
Friendship.objects.prefetch_related("to_friend").order_by("pk")
)
prefetch_sql = ctx[-1]["sql"]
# Prefetch queryset should be filtered by all foreign related fields
# to prevent extra rows from being eagerly fetched.
prefetch_where_sql = prefetch_sql.split("WHERE")[-1]
for to_field_name in Friendship.to_friend.field.to_fields:
to_field = Person._meta.get_field(to_field_name)
with self.subTest(to_field=to_field):
self.assertIn(
connection.ops.quote_name(to_field.column),
prefetch_where_sql,
)
self.assertNotIn(" JOIN ", prefetch_sql)
with self.assertNumQueries(0):
self.assertEqual(friendships[0].to_friend, self.george)
self.assertEqual(friendships[1].to_friend, self.sam)
def test_prefetch_foreignobject_reverse(self):
Membership.objects.create( Membership.objects.create(
membership_country=self.usa, person=self.bob, group=self.cia membership_country=self.usa, person=self.bob, group=self.cia
) )