diff options
| author | Charles Roelli <charles@aurox.ch> | 2026-05-05 09:24:59 +0000 |
|---|---|---|
| committer | Charles Roelli <charles@aurox.ch> | 2026-05-05 09:24:59 +0000 |
| commit | b3c1fd51b6537c122b8ead6aa9234685560866d8 (patch) | |
| tree | 544c306118205eb54bbd1ab97d3ea5e863dfc913 | |
| parent | 9f790ef1a0f356cf6342b5d57bbaeac35aed0d9f (diff) | |
Fixed #31295 -- Avoided Select widget triggering additional query when using ModelChoiceIterator.fix-31295
| -rw-r--r-- | django/forms/models.py | 26 | ||||
| -rw-r--r-- | django/forms/widgets.py | 6 | ||||
| -rw-r--r-- | docs/ref/forms/fields.txt | 6 | ||||
| -rw-r--r-- | docs/releases/6.1.txt | 6 | ||||
| -rw-r--r-- | tests/model_forms/test_modelchoicefield.py | 16 |
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() |
