diff options
| author | mlissner <mlissner@michaeljaylissner.com> | 2026-01-15 12:30:21 -0800 |
|---|---|---|
| committer | Jacob Walls <jacobtylerwalls@gmail.com> | 2026-01-30 11:45:39 -0500 |
| commit | 4cecf3039586ea738afafb9a28c946bff42c37c1 (patch) | |
| tree | 5d822147b21b499494ceff4b533d32bd34fd2f76 /django/contrib/admin/options.py | |
| parent | b25bc2441827a31b5629e9f79ad7296b992648a2 (diff) | |
Fixed #36865 -- Removed casting from exact lookups in admin searches.
Instead of casting non-text fields to CharField (which prevents index
usage), skip exact lookups when the search term fails
formfield.to_python().
This preserves index usage for valid searches while gracefully handling
invalid search terms by simply not including them in the query for that
field.
For multi-term searches like 'foo 123' on search_fields=['name', 'age__exact']:
- 'foo': invalid for age, so only name lookup is used
- '123': valid for both, so both lookups are used
This entails a slight increase in permissiveness for search terms that
can be normalized by formfield.to_python().
Diffstat (limited to 'django/contrib/admin/options.py')
| -rw-r--r-- | django/contrib/admin/options.py | 63 |
1 files changed, 37 insertions, 26 deletions
diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 69b7031e82..9c787d2329 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -41,7 +41,6 @@ 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, @@ -1137,6 +1136,12 @@ class ModelAdmin(BaseModelAdmin): # 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 @@ class ModelAdmin(BaseModelAdmin): 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 @@ class ModelAdmin(BaseModelAdmin): 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 @@ class ModelAdmin(BaseModelAdmin): 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 |
