-
-
Notifications
You must be signed in to change notification settings - Fork 32.8k
Fixed #36426 -- Prefetch_related_objects to support all iterabe types. #19685
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
6f2c878
to
ae8bdef
Compare
3628ed6
to
63a36ba
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you! I added initial comments
# set | ||
prefetch_related_objects({book}, "authors") | ||
self.assertCountEqual( | ||
book.authors.all(), [self.author1, self.author2, self.author3] | ||
) | ||
# tuple | ||
prefetch_related_objects((book,), "authors") | ||
self.assertCountEqual( | ||
book.authors.all(), [self.author1, self.author2, self.author3] | ||
) | ||
# dict_values | ||
d = {"a": book} | ||
prefetch_related_objects(d.values(), "authors") | ||
self.assertCountEqual( | ||
book.authors.all(), [self.author1, self.author2, self.author3] | ||
) | ||
# frozenset | ||
prefetch_related_objects(frozenset([book]), "authors") | ||
self.assertCountEqual( | ||
book.authors.all(), [self.author1, self.author2, self.author3] | ||
) | ||
# generator | ||
prefetch_related_objects((x for x in [book]), "authors") | ||
self.assertCountEqual( | ||
book.authors.all(), [self.author1, self.author2, self.author3] | ||
) | ||
# map | ||
prefetch_related_objects(map(lambda x: x, [book]), "authors") | ||
self.assertCountEqual( | ||
book.authors.all(), [self.author1, self.author2, self.author3] | ||
) | ||
# filter | ||
prefetch_related_objects(filter(lambda x: True, [book]), "authors") | ||
self.assertCountEqual( | ||
book.authors.all(), [self.author1, self.author2, self.author3] | ||
) | ||
# collections.deque | ||
prefetch_related_objects(deque([book]), "authors") | ||
self.assertCountEqual( | ||
book.authors.all(), [self.author1, self.author2, self.author3] | ||
) | ||
|
||
# custom iterable | ||
class MyIterable: | ||
def __iter__(self_inner): | ||
yield book | ||
|
||
prefetch_related_objects(MyIterable(), "authors") | ||
self.assertCountEqual( | ||
book.authors.all(), [self.author1, self.author2, self.author3] | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it would be good to use subTest
here and to assert that prefetch_related_objects
is working as expected (we would need to assert the query count)
This could look like:
# set | |
prefetch_related_objects({book}, "authors") | |
self.assertCountEqual( | |
book.authors.all(), [self.author1, self.author2, self.author3] | |
) | |
# tuple | |
prefetch_related_objects((book,), "authors") | |
self.assertCountEqual( | |
book.authors.all(), [self.author1, self.author2, self.author3] | |
) | |
# dict_values | |
d = {"a": book} | |
prefetch_related_objects(d.values(), "authors") | |
self.assertCountEqual( | |
book.authors.all(), [self.author1, self.author2, self.author3] | |
) | |
# frozenset | |
prefetch_related_objects(frozenset([book]), "authors") | |
self.assertCountEqual( | |
book.authors.all(), [self.author1, self.author2, self.author3] | |
) | |
# generator | |
prefetch_related_objects((x for x in [book]), "authors") | |
self.assertCountEqual( | |
book.authors.all(), [self.author1, self.author2, self.author3] | |
) | |
# map | |
prefetch_related_objects(map(lambda x: x, [book]), "authors") | |
self.assertCountEqual( | |
book.authors.all(), [self.author1, self.author2, self.author3] | |
) | |
# filter | |
prefetch_related_objects(filter(lambda x: True, [book]), "authors") | |
self.assertCountEqual( | |
book.authors.all(), [self.author1, self.author2, self.author3] | |
) | |
# collections.deque | |
prefetch_related_objects(deque([book]), "authors") | |
self.assertCountEqual( | |
book.authors.all(), [self.author1, self.author2, self.author3] | |
) | |
# custom iterable | |
class MyIterable: | |
def __iter__(self_inner): | |
yield book | |
prefetch_related_objects(MyIterable(), "authors") | |
self.assertCountEqual( | |
book.authors.all(), [self.author1, self.author2, self.author3] | |
) | |
book = self.book1 | |
class MyIterable: | |
def __iter__(self): | |
yield book | |
cases = { | |
"set": {book}, | |
"tuple": (book,), | |
"dict_values": {"a": book}.values(), | |
"frozenset": frozenset([book]), | |
"generator": (x for x in [book]), | |
"map": map(lambda x: x, [book]), | |
"filter": filter(lambda x: True, [book]), | |
"deque": deque([book]), | |
"custom iterator": MyIterable(), | |
} | |
for case_type, case in cases.items(): | |
with self.subTest(case=case_type): | |
# Clear the prefetch cache. | |
book._prefetched_objects_cache = {} | |
with self.assertNumQueries(1): | |
prefetch_related_objects(case, "authors") | |
with self.assertNumQueries(0): | |
self.assertCountEqual( | |
book.authors.all(), [self.author1, self.author2, self.author3] | |
) |
However, the filter, map and generator cases are failing
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Your approach definitely seems better. That said, as I mentioned before, I think it’s best to update the documentation instead, so this test will probably be removed. Thanks for the suggestion!
# empty iterable | ||
prefetch_related_objects([], "authors") | ||
prefetch_related_objects(set(), "authors") | ||
prefetch_related_objects((), "authors") | ||
prefetch_related_objects(frozenset(), "authors") | ||
prefetch_related_objects({}.values(), "authors") | ||
prefetch_related_objects((x for x in []), "authors") | ||
prefetch_related_objects(map(lambda x: x, []), "authors") | ||
prefetch_related_objects(filter(lambda x: True, []), "authors") | ||
prefetch_related_objects(deque([]), "authors") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would separate out the empty cases to a new test
# empty iterable | |
prefetch_related_objects([], "authors") | |
prefetch_related_objects(set(), "authors") | |
prefetch_related_objects((), "authors") | |
prefetch_related_objects(frozenset(), "authors") | |
prefetch_related_objects({}.values(), "authors") | |
prefetch_related_objects((x for x in []), "authors") | |
prefetch_related_objects(map(lambda x: x, []), "authors") | |
prefetch_related_objects(filter(lambda x: True, []), "authors") | |
prefetch_related_objects(deque([]), "authors") | |
def test_prefetch_related_objects_with_empty_iterables(self): | |
empty_cases = { | |
"list": [], | |
"set": set(), | |
"tuple": (), | |
"dict_values": {}.values(), | |
"frozenset": frozenset(), | |
"generator": (x for x in []), | |
"map": map(lambda x: x, []), | |
"filter": filter(lambda x: True, []), | |
"deque": deque([]), | |
} | |
for case_type, case in empty_cases.items(): | |
with self.subTest(case=case_type): | |
with self.assertNumQueries(0): | |
prefetch_related_objects(case, "authors") |
prefetch_related_objects(map(lambda x: x, []), "authors") | ||
prefetch_related_objects(filter(lambda x: True, []), "authors") | ||
prefetch_related_objects(deque([]), "authors") | ||
prefetch_related_objects([book, book, book], "authors") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note that this is under the " # empty iterable" comment but is not an empty iterable. There's no asserts, other than you showing that this doesn't error, not sure what is being tested here
if isinstance(obj_list, Iterator): | ||
obj_list, obj_list_for_next = tee(obj_list, 2) | ||
else: | ||
obj_list_for_next = obj_list | ||
|
||
first_obj = next(iter(obj_list_for_next), None) | ||
|
||
if first_obj is None: | ||
continue |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note that the solution here is relatively complex and I would be more in favor of a docs change as Natalia suggested rather than try to support all cases
Note that Jacob suggested we update to first_obj = next(iter(obj_list))
, which works in most cases (except filter, map and generator but not sure those cases are working currently in this solution)
Either of those would be preferable
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just chiming in to add that the cases similar to generator are not very realistic for prefetch_related_objects()
because the premise is that you already have materialized instances in hand.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the detailed review!
At first, I tried using first_obj = next(iter(obj_list)) as you suggested, but since the iterator gets consumed, it didn’t work as intended later on and the tests failed.
So I tried handling multiple cases separately, but like you said, I think it’s better to just update the docs rather than trying to support every case.
I’ll proceed that way!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree the generator/map/filter cases are not worth supporting, but we don't have to treat that as the reason to pass on the next(iter(...
solution. What about just fleshing out the current caveat like this?
diff --git a/django/db/models/query.py b/django/db/models/query.py
index cc4d9c1f22..d0788867f1 100644
--- a/django/db/models/query.py
+++ b/django/db/models/query.py
@@ -2390,7 +2390,7 @@ def prefetch_related_objects(model_instances, *related_lookups):
# We assume that objects retrieved are homogeneous (which is the
# premise of prefetch_related), so what applies to first object
# applies to all.
- first_obj = obj_list[0]
+ first_obj = next(iter(obj_list))
to_attr = lookup.get_current_to_attr(level)[0]
prefetcher, descriptor, attr_found, is_fetched = get_prefetcher(
first_obj, through_attr, to_attr
diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt
index ef6ceb36ad..90dcaac9f2 100644
--- a/docs/ref/models/querysets.txt
+++ b/docs/ref/models/querysets.txt
@@ -4217,8 +4217,9 @@ Prefetches the given lookups on an iterable of model instances. This is useful
in code that receives a list of model instances as opposed to a ``QuerySet``;
for example, when fetching models from a cache or instantiating them manually.
-Pass an iterable of model instances (must all be of the same class) and the
-lookups or :class:`Prefetch` objects you want to prefetch for. For example:
+Pass an iterable of model instances (must all be of the same class and able to be
+iterated multiple times) and the lookups or :class:`Prefetch` objects you want to
+prefetch for. For example:
Trac ticket number
ticket-36426
Branch description
Support all iterable types (e.g., list, set, tuple) as input for prefetch_related_objects, making it more flexible and resolving #36426.
Checklist
main
branch.