Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 37 additions & 26 deletions django/contrib/admin/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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("="):
Expand All @@ -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:
Expand All @@ -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"):
Expand All @@ -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

Expand Down
7 changes: 7 additions & 0 deletions tests/admin_changelist/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
84 changes: 84 additions & 0 deletions tests/admin_changelist/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
Group,
Invitation,
Membership,
MixedFieldsModel,
Musician,
OrderedObject,
Parent,
Expand Down Expand Up @@ -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)
Expand Down