summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCharles Roelli <charles@aurox.ch>2026-05-05 09:24:59 +0000
committerCharles Roelli <charles@aurox.ch>2026-05-05 09:24:59 +0000
commitb3c1fd51b6537c122b8ead6aa9234685560866d8 (patch)
tree544c306118205eb54bbd1ab97d3ea5e863dfc913
parent9f790ef1a0f356cf6342b5d57bbaeac35aed0d9f (diff)
Fixed #31295 -- Avoided Select widget triggering additional query when using ModelChoiceIterator.fix-31295
-rw-r--r--django/forms/models.py26
-rw-r--r--django/forms/widgets.py6
-rw-r--r--docs/ref/forms/fields.txt6
-rw-r--r--docs/releases/6.1.txt6
-rw-r--r--tests/model_forms/test_modelchoicefield.py16
5 files changed, 51 insertions, 9 deletions
diff --git a/django/forms/models.py b/django/forms/models.py
index 9686baa6f2..c04a806caa 100644
--- a/django/forms/models.py
+++ b/django/forms/models.py
@@ -3,6 +3,7 @@ Helper functions for creating Form classes from Django models
and database field objects.
"""
+from collections import deque
from itertools import chain
from django.core.exceptions import (
@@ -1438,17 +1439,20 @@ class ModelChoiceIterator(BaseChoiceIterator):
def __init__(self, field):
self.field = field
self.queryset = field.queryset
+ self._deque = deque()
+ self._generator = self.generator()
- def __iter__(self):
+ def generator(self):
if self.field.empty_label is not None:
yield ("", self.field.empty_label)
- queryset = self.queryset
- # Can't use iterator() when queryset uses prefetch_related()
- if not queryset._prefetch_related_lookups:
- queryset = queryset.iterator()
- for obj in queryset:
+ for obj in self.queryset.iterator(chunk_size=100):
yield self.choice(obj)
+ def __iter__(self):
+ yield from chain(self._deque, self._generator)
+ self._deque = deque()
+ self._generator = self.generator()
+
def __len__(self):
# count() adds a query but uses less memory since the QuerySet results
# won't be cached. In most cases, the choices will only be iterated on,
@@ -1456,7 +1460,9 @@ class ModelChoiceIterator(BaseChoiceIterator):
return self.queryset.count() + (1 if self.field.empty_label is not None else 0)
def __bool__(self):
- return self.field.empty_label is not None or self.queryset.exists()
+ return (
+ self.field.empty_label is not None or self._deque or self.queryset.exists()
+ )
def choice(self, obj):
return (
@@ -1464,6 +1470,12 @@ class ModelChoiceIterator(BaseChoiceIterator):
self.field.label_from_instance(obj),
)
+ def peek(self):
+ value = next(self._generator, None)
+ if value is not None:
+ self._deque.append(value)
+ return value
+
class ModelChoiceField(ChoiceField):
"""A ChoiceField whose choices are a model QuerySet."""
diff --git a/django/forms/widgets.py b/django/forms/widgets.py
index 1bcfeba288..82685a3fea 100644
--- a/django/forms/widgets.py
+++ b/django/forms/widgets.py
@@ -851,7 +851,11 @@ class Select(ChoiceWidget):
if self.allow_multiple_selected:
return use_required_attribute
- first_choice = next(iter(self.choices), None)
+ first_choice = (
+ self.choices.peek()
+ if hasattr(self.choices, "peek")
+ else next(iter(self.choices), None)
+ )
return (
use_required_attribute
and first_choice is not None
diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt
index 8ddc5b9d79..6186f7d4e8 100644
--- a/docs/ref/forms/fields.txt
+++ b/docs/ref/forms/fields.txt
@@ -1619,12 +1619,16 @@ customize the yielded 2-tuple choices.
``ModelChoiceIterator`` has the following method:
- .. method:: __iter__()
+ .. method:: generator()
Yields 2-tuple choices, in the ``(value, label)`` format used by
:attr:`ChoiceField.choices`. The first ``value`` element is a
:class:`ModelChoiceIteratorValue` instance.
+ .. versionchanged:: 6.1
+
+ ``generator`` yields choices instead of ``__iter__``.
+
``ModelChoiceIteratorValue``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt
index 987d46874a..cc236b5ac2 100644
--- a/docs/releases/6.1.txt
+++ b/docs/releases/6.1.txt
@@ -264,6 +264,12 @@ Forms
field's choices. This allows per-request refreshing when called in a form's
``__init__()``.
+* :class:`~django.forms.ModelChoiceIterator` now yields choices via
+ its :meth:`~django.forms.ModelChoiceIterator.generator` method.
+ Users who have previously overridden the ``__iter__()`` method of
+ :class:`~django.forms.ModelChoiceIterator` should override
+ :meth:`~django.forms.ModelChoiceIterator.generator` instead.
+
Generic Views
~~~~~~~~~~~~~
diff --git a/tests/model_forms/test_modelchoicefield.py b/tests/model_forms/test_modelchoicefield.py
index 40c625da7c..387caf0778 100644
--- a/tests/model_forms/test_modelchoicefield.py
+++ b/tests/model_forms/test_modelchoicefield.py
@@ -169,6 +169,22 @@ class ModelChoiceFieldTests(TestCase):
Category.objects.all().delete()
self.assertIs(bool(f.choices), False)
+ def test_empty_label_one_query(self):
+ class MyForm(forms.Form):
+ f = forms.ModelChoiceField(Category.objects.all(), empty_label=None)
+
+ form = MyForm()
+ with self.assertNumQueries(1):
+ form.as_ul()
+ # Ensure the query re-runs when re-rendering the form.
+ with self.assertNumQueries(1):
+ form.as_ul()
+
+ # Ensure the iterator stops after yielding its choices.
+ choice_iterator = iter(form.fields["f"].choices)
+ self.assertEqual(len(list(choice_iterator)), 3)
+ self.assertEqual(len(list(choice_iterator)), 0)
+
def test_choices_bool_empty_label(self):
f = forms.ModelChoiceField(Category.objects.all(), empty_label="--------")
Category.objects.all().delete()