Repository: django/django

Issue:
# Fixed #36865 -- Removed casting from exact lookups in admin searches.

Fixes https://code.djangoproject.com/ticket/36865

## Description

This PR fixes a performance regression introduced in PR #17885.

The `Cast` to `CharField` for non-string field exact lookups prevents database index usage on primary key fields, generating SQL like:

```sql
WHERE ("table"."id")::varchar = '123'
```

This cannot use the primary key index and causes full table scans, resulting in timeouts on large tables (reported in [ticket #26001 comment 24](https://code.djangoproject.com/ticket/26001#comment:24)), and discovered in a project I work on at https://github.com/freelawproject/courtlistener/issues/6790.

## Solution

Drop the casting approach introduced in #17885, and replace it with field validation that uses `to_python` on the results of `smart_split` to ensure that we only apply field-term pairs that make sense. For example: 

 - searching a PrimaryKey field for "foo" --> Nope!
 - searching a TextField for "foo" --> Yes!
 - searching a TextField for "123" --> Close enough. Yes!

And so forth. 

This should ensure DB indexes are used and that we don't send nonsensical queries in the first place. 

## Trac tickets

Introducing issue: https://code.djangoproject.com/ticket/26001
Solving it: https://code.djangoproject.com/ticket/36865

Implementation patch:
diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
index 69b7031e8260..9c787d232912 100644
--- a/django/contrib/admin/options.py
+++ b/django/contrib/admin/options.py
@@ -41,7 +41,6 @@
 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,
@@ -1137,6 +1136,12 @@ def get_search_results(self, request, queryset, search_term):
 
         # Apply keyword searches.
         def construct_search(field_name):
+            """
+            Return a tuple of (lookup, field_to_validate).
+
+            field_to_validate is set for non-text exact lookups so that
+            invalid search terms can be skipped (preserving index usage).
+            """
             if field_name.startswith("^"):
                 return "%s__istartswith" % field_name.removeprefix("^"), None
             elif field_name.startswith("="):
@@ -1148,7 +1153,7 @@ def construct_search(field_name):
             lookup_fields = field_name.split(LOOKUP_SEP)
             # Go through the fields, following all relations.
             prev_field = None
-            for i, path_part in enumerate(lookup_fields):
+            for path_part in lookup_fields:
                 if path_part == "pk":
                     path_part = opts.pk.name
                 try:
@@ -1159,15 +1164,9 @@ def construct_search(field_name):
                         if path_part == "exact" and not isinstance(
                             prev_field, (models.CharField, models.TextField)
                         ):
-                            field_name_without_exact = "__".join(lookup_fields[:i])
-                            alias = Cast(
-                                field_name_without_exact,
-                                output_field=models.CharField(),
-                            )
-                            alias_name = "_".join(lookup_fields[:i])
-                            return f"{alias_name}_str", alias
-                        else:
-                            return field_name, None
+                            # Use prev_field to validate the search term.
+                            return field_name, prev_field
+                        return field_name, None
                 else:
                     prev_field = field
                     if hasattr(field, "path_infos"):
@@ -1179,30 +1178,42 @@ def construct_search(field_name):
         may_have_duplicates = False
         search_fields = self.get_search_fields(request)
         if search_fields and search_term:
-            str_aliases = {}
             orm_lookups = []
             for field in search_fields:
-                lookup, str_alias = construct_search(str(field))
-                orm_lookups.append(lookup)
-                if str_alias:
-                    str_aliases[lookup] = str_alias
-
-            if str_aliases:
-                queryset = queryset.alias(**str_aliases)
+                orm_lookups.append(construct_search(str(field)))
 
             term_queries = []
             for bit in smart_split(search_term):
                 if bit.startswith(('"', "'")) and bit[0] == bit[-1]:
                     bit = unescape_string_literal(bit)
-                or_queries = models.Q.create(
-                    [(orm_lookup, bit) for orm_lookup in orm_lookups],
-                    connector=models.Q.OR,
-                )
-                term_queries.append(or_queries)
-            queryset = queryset.filter(models.Q.create(term_queries))
+                # Build term lookups, skipping values invalid for their field.
+                bit_lookups = []
+                for orm_lookup, validate_field in orm_lookups:
+                    if validate_field is not None:
+                        formfield = validate_field.formfield()
+                        try:
+                            if formfield is not None:
+                                value = formfield.to_python(bit)
+                            else:
+                                # Fields like AutoField lack a form field.
+                                value = validate_field.to_python(bit)
+                        except ValidationError:
+                            # Skip this lookup for invalid values.
+                            continue
+                    else:
+                        value = bit
+                    bit_lookups.append((orm_lookup, value))
+                if bit_lookups:
+                    or_queries = models.Q.create(bit_lookups, connector=models.Q.OR)
+                    term_queries.append(or_queries)
+                else:
+                    # No valid lookups: add a filter that returns nothing.
+                    term_queries.append(models.Q(pk__in=[]))
+            if term_queries:
+                queryset = queryset.filter(models.Q.create(term_queries))
             may_have_duplicates |= any(
                 lookup_spawns_duplicates(self.opts, search_spec)
-                for search_spec in orm_lookups
+                for search_spec, _ in orm_lookups
             )
         return queryset, may_have_duplicates
 


Test patch:
diff --git a/tests/admin_changelist/models.py b/tests/admin_changelist/models.py
index a84c27a06662..0b594300d2a3 100644
--- a/tests/admin_changelist/models.py
+++ b/tests/admin_changelist/models.py
@@ -140,3 +140,10 @@ class CharPK(models.Model):
 class ProxyUser(User):
     class Meta:
         proxy = True
+
+
+class MixedFieldsModel(models.Model):
+    """Model with multiple field types for testing search validation."""
+
+    int_field = models.IntegerField(null=True, blank=True)
+    json_field = models.JSONField(null=True, blank=True)
diff --git a/tests/admin_changelist/tests.py b/tests/admin_changelist/tests.py
index 319d6259f6a9..e0772a3e6d4a 100644
--- a/tests/admin_changelist/tests.py
+++ b/tests/admin_changelist/tests.py
@@ -66,6 +66,7 @@
     Group,
     Invitation,
     Membership,
+    MixedFieldsModel,
     Musician,
     OrderedObject,
     Parent,
@@ -856,6 +857,89 @@ def test_custom_lookup_with_pk_shortcut(self):
         cl = m.get_changelist_instance(request)
         self.assertCountEqual(cl.queryset, [abcd])
 
+    def test_exact_lookup_with_invalid_value(self):
+        Child.objects.create(name="Test", age=10)
+        m = admin.ModelAdmin(Child, custom_site)
+        m.search_fields = ["pk__exact"]
+
+        request = self.factory.get("/", data={SEARCH_VAR: "foo"})
+        request.user = self.superuser
+
+        # Invalid values are gracefully ignored.
+        cl = m.get_changelist_instance(request)
+        self.assertCountEqual(cl.queryset, [])
+
+    def test_exact_lookup_mixed_terms(self):
+        """
+        Multi-term search validates each term independently.
+
+        For 'foo 123' with search_fields=['name__icontains', 'age__exact']:
+        - 'foo': age lookup skipped (invalid), name lookup used
+        - '123': both lookups used (valid for age)
+        No Cast should be used; invalid lookups are simply skipped.
+        """
+        child = Child.objects.create(name="foo123", age=123)
+        Child.objects.create(name="other", age=456)
+        m = admin.ModelAdmin(Child, custom_site)
+        m.search_fields = ["name__icontains", "age__exact"]
+
+        request = self.factory.get("/", data={SEARCH_VAR: "foo 123"})
+        request.user = self.superuser
+
+        # One result matching on foo and 123.
+        cl = m.get_changelist_instance(request)
+        self.assertCountEqual(cl.queryset, [child])
+
+        # "xyz" - invalid for age (skipped), no match for name either.
+        request = self.factory.get("/", data={SEARCH_VAR: "xyz"})
+        request.user = self.superuser
+        cl = m.get_changelist_instance(request)
+        self.assertCountEqual(cl.queryset, [])
+
+    def test_exact_lookup_with_more_lenient_formfield(self):
+        """
+        Exact lookups on BooleanField use formfield().to_python() for lenient
+        parsing. Using model field's to_python() would reject 'false' whereas
+        the form field accepts it.
+        """
+        obj = UnorderedObject.objects.create(bool=False)
+        UnorderedObject.objects.create(bool=True)
+        m = admin.ModelAdmin(UnorderedObject, custom_site)
+        m.search_fields = ["bool__exact"]
+
+        # 'false' is accepted by form field but rejected by model field.
+        request = self.factory.get("/", data={SEARCH_VAR: "false"})
+        request.user = self.superuser
+
+        cl = m.get_changelist_instance(request)
+        self.assertCountEqual(cl.queryset, [obj])
+
+    def test_exact_lookup_validates_each_field_independently(self):
+        """
+        Each field validates the search term independently without leaking
+        converted values between fields.
+
+        "3." is valid for IntegerField (converts to 3) but invalid for
+        JSONField. The converted value must not leak to the JSONField check.
+        """
+        # obj_int has int_field=3, should match "3." via IntegerField.
+        obj_int = MixedFieldsModel.objects.create(
+            int_field=3, json_field={"key": "value"}
+        )
+        # obj_json has json_field=3, should NOT match "3." because "3." is
+        # invalid JSON.
+        MixedFieldsModel.objects.create(int_field=99, json_field=3)
+        m = admin.ModelAdmin(MixedFieldsModel, custom_site)
+        m.search_fields = ["int_field__exact", "json_field__exact"]
+
+        # "3." is valid for int (becomes 3) but invalid JSON.
+        # Only obj_int should match via int_field.
+        request = self.factory.get("/", data={SEARCH_VAR: "3."})
+        request.user = self.superuser
+
+        cl = m.get_changelist_instance(request)
+        self.assertCountEqual(cl.queryset, [obj_int])
+
     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)