Fixed #26184 -- Allowed using any lookups in ModelAdmin.search_fields.
Thanks Krzysztof Nazarewski for the initial patch.
This commit is contained in:
parent
3af305e8b8
commit
244cc40155
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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`
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue