summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKrzysztof Nazarewski <nazarewk@gmail.com>2017-07-05 13:00:10 +0200
committerTim Graham <timograham@gmail.com>2017-11-18 19:33:52 -0500
commit244cc401559e924355cf943b6b8e66ccf2f6da3a (patch)
tree124c16b3fd1e844cb2925fc4fd0c76b7fde0584f
parent3af305e8b8a89f4b0e5874cd601568ab8dcd7334 (diff)
Fixed #26184 -- Allowed using any lookups in ModelAdmin.search_fields.
Thanks Krzysztof Nazarewski for the initial patch.
-rw-r--r--django/contrib/admin/options.py23
-rw-r--r--django/contrib/admin/utils.py27
-rw-r--r--django/db/models/sql/constants.py10
-rw-r--r--docs/ref/contrib/admin/index.txt60
-rw-r--r--docs/releases/2.1.txt3
-rw-r--r--tests/admin_changelist/models.py5
-rw-r--r--tests/admin_changelist/tests.py80
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,