From 70b0be281caa08a0b639acb73e3b9fe5f6e7d278 Mon Sep 17 00:00:00 2001 From: Charles Roelli Date: Fri, 1 May 2026 13:45:01 +0000 Subject: Fixed #31295 -- Avoid Select widget triggering additional query when using ModelChoiceIterator. --- django/forms/models.py | 41 +++++++++++++++++++++++------- django/forms/widgets.py | 6 ++++- tests/model_forms/test_modelchoicefield.py | 11 ++++++++ 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/django/forms/models.py b/django/forms/models.py index 9686baa6f2..ac56a4df78 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,16 +1439,30 @@ class ModelChoiceIterator(BaseChoiceIterator): def __init__(self, field): self.field = field self.queryset = field.queryset + self._deque = deque() + + def generator(): + 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 iter(queryset): + yield self.choice(obj) + # Reset the generator after it's exhausted so that it can + # be used again. + self.generator = generator() + + self.generator = generator() def __iter__(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: - yield self.choice(obj) + return self + + def __next__(self): + if self._deque: + return self._deque.popleft() + return next(self.generator) def __len__(self): # count() adds a query but uses less memory since the QuerySet results @@ -1456,7 +1471,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 +1481,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/tests/model_forms/test_modelchoicefield.py b/tests/model_forms/test_modelchoicefield.py index 40c625da7c..f2f75829d1 100644 --- a/tests/model_forms/test_modelchoicefield.py +++ b/tests/model_forms/test_modelchoicefield.py @@ -169,6 +169,17 @@ 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() + # Make sure the query re-runs when re-rendering the form. + with self.assertNumQueries(1): + form.as_ul() + def test_choices_bool_empty_label(self): f = forms.ModelChoiceField(Category.objects.all(), empty_label="--------") Category.objects.all().delete() -- cgit v1.3