diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 6d5c0708a3..78063a134d 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -41,6 +41,7 @@ from django.core.exceptions import ( from django.core.paginator import Paginator from django.db import models, router, transaction from django.db.models.constants import LOOKUP_SEP +from django.db.models.functions import Cast from django.forms.formsets import DELETION_FIELD_NAME, all_valid from django.forms.models import ( BaseInlineFormSet, @@ -1207,9 +1208,33 @@ class ModelAdmin(BaseModelAdmin): may_have_duplicates = False search_fields = self.get_search_fields(request) if search_fields and search_term: - orm_lookups = [ - construct_search(str(search_field)) for search_field in search_fields - ] + str_annotations = {} + orm_lookups = [] + for field in search_fields: + if field.endswith("__exact"): + field_name = field.rsplit("__exact", 1)[0] + try: + field_obj = queryset.model._meta.get_field(field_name) + except FieldDoesNotExist: + lookup = construct_search(field) + orm_lookups.append(lookup) + continue + # Add string cast annotations for non-string exact lookups. + if not isinstance(field_obj, (models.CharField, models.TextField)): + str_annotations[f"{field_name}_str"] = Cast( + field_name, output_field=models.CharField() + ) + orm_lookups.append(f"{field_name}_str__exact") + else: + lookup = construct_search(field) + orm_lookups.append(lookup) + else: + lookup = construct_search(str(field)) + orm_lookups.append(lookup) + + if str_annotations: + queryset = queryset.annotate(**str_annotations) + term_queries = [] for bit in smart_split(search_term): if bit.startswith(('"', "'")) and bit[0] == bit[-1]: diff --git a/tests/admin_changelist/admin.py b/tests/admin_changelist/admin.py index 349ef7d465..d9dc498e84 100644 --- a/tests/admin_changelist/admin.py +++ b/tests/admin_changelist/admin.py @@ -48,6 +48,7 @@ class ChildAdmin(admin.ModelAdmin): list_display = ["name", "parent"] list_per_page = 10 list_filter = ["parent", "age"] + search_fields = ["age__exact", "name__exact"] def get_queryset(self, request): return super().get_queryset(request).select_related("parent") diff --git a/tests/admin_changelist/tests.py b/tests/admin_changelist/tests.py index d8055a809b..a823a72f7d 100644 --- a/tests/admin_changelist/tests.py +++ b/tests/admin_changelist/tests.py @@ -860,6 +860,25 @@ class ChangeListTests(TestCase): cl = m.get_changelist_instance(request) self.assertCountEqual(cl.queryset, [abcd]) + def test_search_with_exact_lookup_for_non_string_field(self): + child = Child.objects.create(name="Asher", age=11) + model_admin = ChildAdmin(Child, custom_site) + + for search_term, expected_result in [ + ("11", [child]), + ("Asher", [child]), + ("1", []), + ("A", []), + ("random", []), + ]: + request = self.factory.get("/", data={SEARCH_VAR: search_term}) + request.user = self.superuser + with self.subTest(search_term=search_term): + # 1 query for filtered result, 1 for filtered count, 1 for total count. + with self.assertNumQueries(3): + cl = model_admin.get_changelist_instance(request) + self.assertCountEqual(cl.queryset, expected_result) + def test_no_distinct_for_m2m_in_list_filter_without_params(self): """ If a ManyToManyField is in list_filter but isn't in any lookup params,