Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/api-guide/filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,18 @@ For example:

By default, the search parameter is named `'search'`, but this may be overridden with the `SEARCH_PARAM` setting in the `REST_FRAMEWORK` configuration.

#### Accent-insensitive search

The `UnaccentedSearchFilter` subclass performs accent-insensitive matching, so that a search for `Jeremy` also matches `Jérémy`. It behaves like `SearchFilter`, except the lookups are wrapped with the `unaccent` transform: the default lookup becomes `unaccent__icontains`, `^` becomes `unaccent__istartswith`, `=` becomes `unaccent__iexact`, and `$` becomes `unaccent__iregex`. The `@` (full-text search) prefix is left unchanged, as the `unaccent` transform cannot be combined with a full-text search lookup.

from rest_framework import filters

class MyView(generics.ListAPIView):
filter_backends = [filters.UnaccentedSearchFilter]
search_fields = ['name']

This is currently only supported on Django's [PostgreSQL backend][postgres-lookups], and requires the `unaccent` extension to be installed and `django.contrib.postgres` to be present in `INSTALLED_APPS`.

To dynamically change search fields based on request content, it's possible to subclass the `SearchFilter` and override the `get_search_fields()` function. For example, the following subclass will only search on `title` if the query parameter `title_only` is in the request:

from rest_framework import filters
Expand Down Expand Up @@ -370,3 +382,4 @@ The [djangorestframework-word-filter][django-rest-framework-word-search-filter]
[HStoreField]: https://docs.djangoproject.com/en/stable/ref/contrib/postgres/fields/#hstorefield
[JSONField]: https://docs.djangoproject.com/en/stable/ref/models/fields/#django.db.models.JSONField
[postgres-search]: https://docs.djangoproject.com/en/stable/ref/contrib/postgres/search/
[postgres-lookups]: https://docs.djangoproject.com/en/stable/ref/contrib/postgres/lookups/#unaccent
24 changes: 22 additions & 2 deletions rest_framework/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class SearchFilter(BaseFilterBackend):
'@': 'search',
'$': 'iregex',
}
default_lookup = 'icontains'
search_title = _('Search')
search_description = _('A search term.')

Expand Down Expand Up @@ -104,8 +105,8 @@ def construct_search(self, field_name, queryset):
if hasattr(field, "path_infos"):
# Update opts to follow the relation.
opts = field.path_infos[-1].to_opts
# Otherwise, use the field with icontains.
lookup = 'icontains'
# Otherwise, use the field with the default lookup.
lookup = self.default_lookup
return LOOKUP_SEP.join([field_name, lookup])

def must_call_distinct(self, queryset, search_fields):
Expand Down Expand Up @@ -190,6 +191,25 @@ def get_schema_operation_parameters(self, view):
]


class UnaccentedSearchFilter(SearchFilter):
"""
A SearchFilter that performs accent-insensitive matching.

Requires PostgreSQL with the ``unaccent`` extension installed and
``django.contrib.postgres`` in ``INSTALLED_APPS``. See
https://docs.djangoproject.com/en/stable/ref/contrib/postgres/lookups/#unaccent
"""
lookup_prefixes = {
'^': 'unaccent__istartswith',
'=': 'unaccent__iexact',
# '@' stays accent-sensitive: unaccent can't be applied to full-text
# search.
'@': 'search',
'$': 'unaccent__iregex',
}
default_lookup = 'unaccent__icontains'


class OrderingFilter(BaseFilterBackend):
# The URL query parameter used for the ordering.
ordering_param = api_settings.ORDERING_PARAM
Expand Down
14 changes: 14 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@
from django.db import connection


@pytest.fixture
def unaccent_extension(db):
"""
Enable the PostgreSQL ``unaccent`` extension for a single test.

Opt in with ``@pytest.mark.usefixtures("unaccent_extension")`` so only
tests that need it have it. No-op on non-PostgreSQL backends.
"""
if connection.vendor != 'postgresql':
return
with connection.cursor() as cursor:
cursor.execute('CREATE EXTENSION IF NOT EXISTS unaccent')


@pytest.fixture
def reset_sequences():
"""
Expand Down
27 changes: 27 additions & 0 deletions tests/test_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,33 @@ def as_sql(self, compiler, connection):
{'id': 2, 'title': 'zz', 'text': 'bcd'},
]

@pytest.mark.requires_postgres
@pytest.mark.usefixtures("unaccent_extension")
def test_unaccented_search_lookups(self):
for title in ('Jérémy', 'Jeremy', 'Jérémie', 'Amélie'):
SearchFilterModel.objects.create(title=title, text=title.lower())

# (search field, term, expected titles) for each prefix. All but '@'
# are accent-insensitive, so an unaccented term matches accented titles.
cases = [
('title', 'rem', {'Jérémy', 'Jeremy', 'Jérémie'}), # unaccent__icontains
('^title', 'jer', {'Jérémy', 'Jeremy', 'Jérémie'}), # unaccent__istartswith
('=title', 'jeremy', {'Jérémy', 'Jeremy'}), # unaccent__iexact
('$title', 'jerem.+', {'Jérémy', 'Jeremy', 'Jérémie'}), # unaccent__iregex
('@title', 'Jeremy', {'Jeremy'}), # search (accent-sensitive)
]

for search_field, term, expected in cases:
with self.subTest(search_field=search_field):
class SearchListView(generics.ListAPIView):
queryset = SearchFilterModel.objects.all()
serializer_class = SearchFilterSerializer
filter_backends = (filters.UnaccentedSearchFilter,)
search_fields = (search_field,)

response = SearchListView.as_view()(factory.get('/', {'search': term}))
assert {item['title'] for item in response.data} == expected

def test_search_field_with_multiple_words(self):
class SearchListView(generics.ListAPIView):
queryset = SearchFilterModel.objects.all()
Expand Down
Loading