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,7 +943,26 @@ class ModelAdmin(BaseModelAdmin):
|
||||||
return "%s__iexact" % field_name[1:]
|
return "%s__iexact" % field_name[1:]
|
||||||
elif field_name.startswith('@'):
|
elif field_name.startswith('@'):
|
||||||
return "%s__search" % field_name[1:]
|
return "%s__search" % field_name[1:]
|
||||||
|
# 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:
|
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
|
return "%s__icontains" % field_name
|
||||||
|
|
||||||
use_distinct = False
|
use_distinct = False
|
||||||
|
|
|
@ -7,7 +7,6 @@ from django.core.exceptions import FieldDoesNotExist
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models.constants import LOOKUP_SEP
|
from django.db.models.constants import LOOKUP_SEP
|
||||||
from django.db.models.deletion import Collector
|
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.forms.utils import pretty_name
|
||||||
from django.urls import NoReverseMatch, reverse
|
from django.urls import NoReverseMatch, reverse
|
||||||
from django.utils import formats, timezone
|
from django.utils import formats, timezone
|
||||||
|
@ -26,20 +25,22 @@ def lookup_needs_distinct(opts, lookup_path):
|
||||||
Return True if 'distinct()' should be used to query the given lookup path.
|
Return True if 'distinct()' should be used to query the given lookup path.
|
||||||
"""
|
"""
|
||||||
lookup_fields = lookup_path.split(LOOKUP_SEP)
|
lookup_fields = lookup_path.split(LOOKUP_SEP)
|
||||||
# Remove the last item of the lookup path if it is a query term
|
# Go through the fields (following all relations) and look for an m2m.
|
||||||
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
|
|
||||||
for field_name in lookup_fields:
|
for field_name in lookup_fields:
|
||||||
if field_name == 'pk':
|
if field_name == 'pk':
|
||||||
field_name = opts.pk.name
|
field_name = opts.pk.name
|
||||||
|
try:
|
||||||
field = opts.get_field(field_name)
|
field = opts.get_field(field_name)
|
||||||
|
except FieldDoesNotExist:
|
||||||
|
# Ignore query lookups.
|
||||||
|
continue
|
||||||
|
else:
|
||||||
if hasattr(field, 'get_path_info'):
|
if hasattr(field, 'get_path_info'):
|
||||||
# This field is a relation, update opts to follow the relation
|
# This field is a relation; update opts to follow the relation.
|
||||||
path_info = field.get_path_info()
|
path_info = field.get_path_info()
|
||||||
opts = path_info[-1].to_opts
|
opts = path_info[-1].to_opts
|
||||||
if any(path.m2m for path in path_info):
|
if any(path.m2m for path in path_info):
|
||||||
# This field is a m2m relation so we know we need to call distinct
|
# This field is a m2m relation so distinct must be called.
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
|
@ -4,16 +4,6 @@ Constants specific to the SQL storage portion of the ORM.
|
||||||
|
|
||||||
import re
|
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.
|
# Size of each "chunk" for get_iterator calls.
|
||||||
# Larger values are slightly faster at the expense of more storage space.
|
# Larger values are slightly faster at the expense of more storage space.
|
||||||
GET_ITERATOR_CHUNK_SIZE = 100
|
GET_ITERATOR_CHUNK_SIZE = 100
|
||||||
|
|
|
@ -1238,51 +1238,39 @@ subclass::
|
||||||
|
|
||||||
When somebody does a search in the admin search box, Django splits the
|
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
|
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
|
words, case-insensitive (using the :lookup:`icontains` lookup), where each
|
||||||
``search_fields``. For example, if ``search_fields`` is set to
|
word must be in at least one of ``search_fields``. For example, if
|
||||||
``['first_name', 'last_name']`` and a user searches for ``john lennon``,
|
``search_fields`` is set to ``['first_name', 'last_name']`` and a user
|
||||||
Django will do the equivalent of this SQL ``WHERE`` clause::
|
searches for ``john lennon``, Django will do the equivalent of this SQL
|
||||||
|
``WHERE`` clause::
|
||||||
|
|
||||||
WHERE (first_name ILIKE '%john%' OR last_name ILIKE '%john%')
|
WHERE (first_name ILIKE '%john%' OR last_name ILIKE '%john%')
|
||||||
AND (first_name ILIKE '%lennon%' OR last_name ILIKE '%lennon%')
|
AND (first_name ILIKE '%lennon%' OR last_name ILIKE '%lennon%')
|
||||||
|
|
||||||
For faster and/or more restrictive searches, prefix the field name
|
If you don't want to use ``icontains`` as the lookup, you can use any
|
||||||
with an operator:
|
lookup by appending it the field. For example, you could use :lookup:`exact`
|
||||||
|
by setting ``search_fields`` to ``['first_name__exact']``.
|
||||||
|
|
||||||
``^``
|
Beware that because query terms are split and ANDed as described earlier,
|
||||||
Use the '^' operator to match starting at the beginning of the
|
searching with :lookup:`exact` only works with a single search word since
|
||||||
field. For example, if ``search_fields`` is set to
|
two or more words can't all be an exact match unless all words are the same.
|
||||||
``['^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%')
|
.. versionadded:: 2.1
|
||||||
AND (first_name ILIKE 'lennon%' OR last_name ILIKE 'lennon%')
|
|
||||||
|
|
||||||
This query is more efficient than the normal ``'%john%'`` query,
|
The ability to specify a field lookup was added.
|
||||||
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.
|
|
||||||
|
|
||||||
``=``
|
Some (older) shortcuts for specifying a field lookup are also available.
|
||||||
Use the '=' operator for case-insensitive exact matching. For
|
You can prefix a field in ``search_fields`` with the following characters
|
||||||
example, if ``search_fields`` is set to
|
and it's equivalent to adding ``__<lookup>`` to the field:
|
||||||
``['=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')
|
Prefix Lookup
|
||||||
|
====== ====================
|
||||||
Note that the query input is split by spaces, so, following this
|
^ :lookup:`startswith`
|
||||||
example, it's currently not possible to search for all records in which
|
= :lookup:`iexact`
|
||||||
``first_name`` is exactly ``'john winston'`` (containing a space).
|
@ :lookup:`search`
|
||||||
|
None :lookup:`icontains`
|
||||||
``@``
|
====== ====================
|
||||||
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.
|
|
||||||
|
|
||||||
If you need to customize search you can use
|
If you need to customize search you can use
|
||||||
:meth:`ModelAdmin.get_search_results` to provide additional or alternate
|
:meth:`ModelAdmin.get_search_results` to provide additional or alternate
|
||||||
|
|
|
@ -32,7 +32,8 @@ Minor features
|
||||||
:mod:`django.contrib.admin`
|
:mod:`django.contrib.admin`
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
* ...
|
* :attr:`.ModelAdmin.search_fields` now accepts any lookup such as
|
||||||
|
``field__exact``.
|
||||||
|
|
||||||
:mod:`django.contrib.admindocs`
|
:mod:`django.contrib.admindocs`
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
|
@ -28,6 +28,7 @@ class Band(models.Model):
|
||||||
|
|
||||||
class Musician(models.Model):
|
class Musician(models.Model):
|
||||||
name = models.CharField(max_length=30)
|
name = models.CharField(max_length=30)
|
||||||
|
age = models.IntegerField(null=True, blank=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
@ -111,3 +112,7 @@ class OrderedObject(models.Model):
|
||||||
|
|
||||||
class CustomIdUser(models.Model):
|
class CustomIdUser(models.Model):
|
||||||
uuid = models.AutoField(primary_key=True)
|
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.admin.views.main import ALL_VAR, SEARCH_VAR
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.contenttypes.models import ContentType
|
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.template import Context, Template
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
from django.test.client import RequestFactory
|
from django.test.client import RequestFactory
|
||||||
|
@ -24,9 +26,9 @@ from .admin import (
|
||||||
site as custom_site,
|
site as custom_site,
|
||||||
)
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
Band, Child, ChordsBand, ChordsMusician, Concert, CustomIdUser, Event,
|
Band, CharPK, Child, ChordsBand, ChordsMusician, Concert, CustomIdUser,
|
||||||
Genre, Group, Invitation, Membership, Musician, OrderedObject, Parent,
|
Event, Genre, Group, Invitation, Membership, Musician, OrderedObject,
|
||||||
Quartet, Swallow, SwallowOneToOne, UnorderedObject,
|
Parent, Quartet, Swallow, SwallowOneToOne, UnorderedObject,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -403,6 +405,78 @@ class ChangeListTests(TestCase):
|
||||||
cl = m.get_changelist_instance(request)
|
cl = m.get_changelist_instance(request)
|
||||||
self.assertEqual(cl.queryset.count(), 0)
|
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):
|
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,
|
If a ManyToManyField is in list_filter but isn't in any lookup params,
|
||||||
|
|
Loading…
Reference in New Issue