diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 545c8a53cd..627c76be6a 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -943,8 +943,27 @@ class ModelAdmin(BaseModelAdmin): return "%s__iexact" % field_name[1:] elif field_name.startswith('@'): return "%s__search" % field_name[1:] - else: - return "%s__icontains" % field_name + # Use field_name if it includes a lookup. + opts = queryset.model._meta + lookup_fields = field_name.split(LOOKUP_SEP) + # Go through the fields, following all relations. + prev_field = None + for path_part in lookup_fields: + if path_part == 'pk': + path_part = opts.pk.name + try: + field = opts.get_field(path_part) + except FieldDoesNotExist: + # Use valid query lookups. + if prev_field and prev_field.get_lookup(path_part): + return field_name + else: + prev_field = field + if hasattr(field, 'get_path_info'): + # Update opts to follow the relation. + opts = field.get_path_info()[-1].to_opts + # Otherwise, use the field with icontains. + return "%s__icontains" % field_name use_distinct = False search_fields = self.get_search_fields(request) diff --git a/django/contrib/admin/utils.py b/django/contrib/admin/utils.py index c55336ad00..f1e2d4ed87 100644 --- a/django/contrib/admin/utils.py +++ b/django/contrib/admin/utils.py @@ -7,7 +7,6 @@ from django.core.exceptions import FieldDoesNotExist from django.db import models from django.db.models.constants import LOOKUP_SEP from django.db.models.deletion import Collector -from django.db.models.sql.constants import QUERY_TERMS from django.forms.utils import pretty_name from django.urls import NoReverseMatch, reverse from django.utils import formats, timezone @@ -26,21 +25,23 @@ def lookup_needs_distinct(opts, lookup_path): Return True if 'distinct()' should be used to query the given lookup path. """ lookup_fields = lookup_path.split(LOOKUP_SEP) - # Remove the last item of the lookup path if it is a query term - if lookup_fields[-1] in QUERY_TERMS: - lookup_fields = lookup_fields[:-1] - # Now go through the fields (following all relations) and look for an m2m + # Go through the fields (following all relations) and look for an m2m. for field_name in lookup_fields: if field_name == 'pk': field_name = opts.pk.name - field = opts.get_field(field_name) - if hasattr(field, 'get_path_info'): - # This field is a relation, update opts to follow the relation - path_info = field.get_path_info() - opts = path_info[-1].to_opts - if any(path.m2m for path in path_info): - # This field is a m2m relation so we know we need to call distinct - return True + try: + field = opts.get_field(field_name) + except FieldDoesNotExist: + # Ignore query lookups. + continue + else: + if hasattr(field, 'get_path_info'): + # This field is a relation; update opts to follow the relation. + path_info = field.get_path_info() + opts = path_info[-1].to_opts + if any(path.m2m for path in path_info): + # This field is a m2m relation so distinct must be called. + return True return False diff --git a/django/db/models/sql/constants.py b/django/db/models/sql/constants.py index 57857796b8..28f4242a7a 100644 --- a/django/db/models/sql/constants.py +++ b/django/db/models/sql/constants.py @@ -4,16 +4,6 @@ Constants specific to the SQL storage portion of the ORM. import re -# Valid query types (a set is used for speedy lookups). These are (currently) -# considered SQL-specific; other storage systems may choose to use different -# lookup types. -QUERY_TERMS = { - 'exact', 'iexact', 'contains', 'icontains', 'gt', 'gte', 'lt', 'lte', 'in', - 'startswith', 'istartswith', 'endswith', 'iendswith', 'range', 'year', - 'month', 'day', 'week_day', 'hour', 'minute', 'second', 'isnull', 'search', - 'regex', 'iregex', -} - # Size of each "chunk" for get_iterator calls. # Larger values are slightly faster at the expense of more storage space. GET_ITERATOR_CHUNK_SIZE = 100 diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt index 76b427c473..d5741c6569 100644 --- a/docs/ref/contrib/admin/index.txt +++ b/docs/ref/contrib/admin/index.txt @@ -1238,51 +1238,39 @@ subclass:: When somebody does a search in the admin search box, Django splits the search query into words and returns all objects that contain each of the - words, case insensitive, where each word must be in at least one of - ``search_fields``. For example, if ``search_fields`` is set to - ``['first_name', 'last_name']`` and a user searches for ``john lennon``, - Django will do the equivalent of this SQL ``WHERE`` clause:: + words, case-insensitive (using the :lookup:`icontains` lookup), where each + word must be in at least one of ``search_fields``. For example, if + ``search_fields`` is set to ``['first_name', 'last_name']`` and a user + searches for ``john lennon``, Django will do the equivalent of this SQL + ``WHERE`` clause:: WHERE (first_name ILIKE '%john%' OR last_name ILIKE '%john%') AND (first_name ILIKE '%lennon%' OR last_name ILIKE '%lennon%') - For faster and/or more restrictive searches, prefix the field name - with an operator: + If you don't want to use ``icontains`` as the lookup, you can use any + lookup by appending it the field. For example, you could use :lookup:`exact` + by setting ``search_fields`` to ``['first_name__exact']``. - ``^`` - Use the '^' operator to match starting at the beginning of the - field. For example, if ``search_fields`` is set to - ``['^first_name', '^last_name']`` and a user searches for - ``john lennon``, Django will do the equivalent of this SQL ``WHERE`` - clause:: + Beware that because query terms are split and ANDed as described earlier, + searching with :lookup:`exact` only works with a single search word since + two or more words can't all be an exact match unless all words are the same. - WHERE (first_name ILIKE 'john%' OR last_name ILIKE 'john%') - AND (first_name ILIKE 'lennon%' OR last_name ILIKE 'lennon%') + .. versionadded:: 2.1 - This query is more efficient than the normal ``'%john%'`` query, - because the database only needs to check the beginning of a column's - data, rather than seeking through the entire column's data. Plus, if - the column has an index on it, some databases may be able to use the - index for this query, even though it's a ``LIKE`` query. + The ability to specify a field lookup was added. - ``=`` - Use the '=' operator for case-insensitive exact matching. For - example, if ``search_fields`` is set to - ``['=first_name', '=last_name']`` and a user searches for - ``john lennon``, Django will do the equivalent of this SQL - ``WHERE`` clause:: + Some (older) shortcuts for specifying a field lookup are also available. + You can prefix a field in ``search_fields`` with the following characters + and it's equivalent to adding ``__`` to the field: - WHERE (first_name ILIKE 'john' OR last_name ILIKE 'john') - AND (first_name ILIKE 'lennon' OR last_name ILIKE 'lennon') - - Note that the query input is split by spaces, so, following this - example, it's currently not possible to search for all records in which - ``first_name`` is exactly ``'john winston'`` (containing a space). - - ``@`` - Using the '@' operator to perform a full text match. This is like the - default search method but uses an index. Currently this is only - available for MySQL. + ====== ==================== + Prefix Lookup + ====== ==================== + ^ :lookup:`startswith` + = :lookup:`iexact` + @ :lookup:`search` + None :lookup:`icontains` + ====== ==================== If you need to customize search you can use :meth:`ModelAdmin.get_search_results` to provide additional or alternate diff --git a/docs/releases/2.1.txt b/docs/releases/2.1.txt index bb8090e34b..ed1ee8cb88 100644 --- a/docs/releases/2.1.txt +++ b/docs/releases/2.1.txt @@ -32,7 +32,8 @@ Minor features :mod:`django.contrib.admin` ~~~~~~~~~~~~~~~~~~~~~~~~~~~ -* ... +* :attr:`.ModelAdmin.search_fields` now accepts any lookup such as + ``field__exact``. :mod:`django.contrib.admindocs` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/admin_changelist/models.py b/tests/admin_changelist/models.py index 1cec7b8a82..62268a2b79 100644 --- a/tests/admin_changelist/models.py +++ b/tests/admin_changelist/models.py @@ -28,6 +28,7 @@ class Band(models.Model): class Musician(models.Model): name = models.CharField(max_length=30) + age = models.IntegerField(null=True, blank=True) def __str__(self): return self.name @@ -111,3 +112,7 @@ class OrderedObject(models.Model): class CustomIdUser(models.Model): uuid = models.AutoField(primary_key=True) + + +class CharPK(models.Model): + char_pk = models.CharField(max_length=100, primary_key=True) diff --git a/tests/admin_changelist/tests.py b/tests/admin_changelist/tests.py index b4c08b3e84..537949021f 100644 --- a/tests/admin_changelist/tests.py +++ b/tests/admin_changelist/tests.py @@ -8,6 +8,8 @@ from django.contrib.admin.tests import AdminSeleniumTestCase from django.contrib.admin.views.main import ALL_VAR, SEARCH_VAR from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType +from django.db.models.fields import Field, IntegerField +from django.db.models.lookups import Contains, Exact from django.template import Context, Template from django.test import TestCase, override_settings from django.test.client import RequestFactory @@ -24,9 +26,9 @@ from .admin import ( site as custom_site, ) from .models import ( - Band, Child, ChordsBand, ChordsMusician, Concert, CustomIdUser, Event, - Genre, Group, Invitation, Membership, Musician, OrderedObject, Parent, - Quartet, Swallow, SwallowOneToOne, UnorderedObject, + Band, CharPK, Child, ChordsBand, ChordsMusician, Concert, CustomIdUser, + Event, Genre, Group, Invitation, Membership, Musician, OrderedObject, + Parent, Quartet, Swallow, SwallowOneToOne, UnorderedObject, ) @@ -403,6 +405,78 @@ class ChangeListTests(TestCase): cl = m.get_changelist_instance(request) self.assertEqual(cl.queryset.count(), 0) + def test_builtin_lookup_in_search_fields(self): + band = Group.objects.create(name='The Hype') + concert = Concert.objects.create(name='Woodstock', group=band) + + m = ConcertAdmin(Concert, custom_site) + m.search_fields = ['name__iexact'] + + request = self.factory.get('/', data={SEARCH_VAR: 'woodstock'}) + cl = m.get_changelist_instance(request) + self.assertCountEqual(cl.queryset, [concert]) + + request = self.factory.get('/', data={SEARCH_VAR: 'wood'}) + cl = m.get_changelist_instance(request) + self.assertCountEqual(cl.queryset, []) + + def test_custom_lookup_in_search_fields(self): + band = Group.objects.create(name='The Hype') + concert = Concert.objects.create(name='Woodstock', group=band) + + m = ConcertAdmin(Concert, custom_site) + m.search_fields = ['group__name__cc'] + Field.register_lookup(Contains, 'cc') + try: + request = self.factory.get('/', data={SEARCH_VAR: 'Hype'}) + cl = m.get_changelist_instance(request) + self.assertCountEqual(cl.queryset, [concert]) + + request = self.factory.get('/', data={SEARCH_VAR: 'Woodstock'}) + cl = m.get_changelist_instance(request) + self.assertCountEqual(cl.queryset, []) + finally: + Field._unregister_lookup(Contains, 'cc') + + def test_spanning_relations_with_custom_lookup_in_search_fields(self): + hype = Group.objects.create(name='The Hype') + concert = Concert.objects.create(name='Woodstock', group=hype) + vox = Musician.objects.create(name='Vox', age=20) + Membership.objects.create(music=vox, group=hype) + # Register a custom lookup on IntegerField to ensure that field + # traversing logic in ModelAdmin.get_search_results() works. + IntegerField.register_lookup(Exact, 'exactly') + try: + m = ConcertAdmin(Concert, custom_site) + m.search_fields = ['group__members__age__exactly'] + + request = self.factory.get('/', data={SEARCH_VAR: '20'}) + cl = m.get_changelist_instance(request) + self.assertCountEqual(cl.queryset, [concert]) + + request = self.factory.get('/', data={SEARCH_VAR: '21'}) + cl = m.get_changelist_instance(request) + self.assertCountEqual(cl.queryset, []) + finally: + IntegerField._unregister_lookup(Exact, 'exactly') + + def test_custom_lookup_with_pk_shortcut(self): + self.assertEqual(CharPK._meta.pk.name, 'char_pk') # Not equal to 'pk'. + m = admin.ModelAdmin(CustomIdUser, custom_site) + + abc = CharPK.objects.create(char_pk='abc') + abcd = CharPK.objects.create(char_pk='abcd') + m = admin.ModelAdmin(CharPK, custom_site) + m.search_fields = ['pk__exact'] + + request = self.factory.get('/', data={SEARCH_VAR: 'abc'}) + cl = m.get_changelist_instance(request) + self.assertCountEqual(cl.queryset, [abc]) + + request = self.factory.get('/', data={SEARCH_VAR: 'abcd'}) + cl = m.get_changelist_instance(request) + self.assertCountEqual(cl.queryset, [abcd]) + 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,