From 244cc401559e924355cf943b6b8e66ccf2f6da3a Mon Sep 17 00:00:00 2001
From: Krzysztof Nazarewski <nazarewk@gmail.com>
Date: Wed, 5 Jul 2017 13:00:10 +0200
Subject: [PATCH] Fixed #26184 -- Allowed using any lookups in
 ModelAdmin.search_fields.

Thanks Krzysztof Nazarewski for the initial patch.
---
 django/contrib/admin/options.py   | 23 ++++++++-
 django/contrib/admin/utils.py     | 27 ++++++-----
 django/db/models/sql/constants.py | 10 ----
 docs/ref/contrib/admin/index.txt  | 60 ++++++++++-------------
 docs/releases/2.1.txt             |  3 +-
 tests/admin_changelist/models.py  |  5 ++
 tests/admin_changelist/tests.py   | 80 +++++++++++++++++++++++++++++--
 7 files changed, 143 insertions(+), 65 deletions(-)

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 ``__<lookup>`` 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,