Skip to content

Document how to use rest_framework.filters.SearchFilter #476

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Sep 17, 2018
Merged
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
2 changes: 2 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,9 @@ override ``settings.REST_FRAMEWORK``
'DEFAULT_FILTER_BACKENDS': (
'rest_framework_json_api.filters.OrderingFilter',
'rest_framework_json_api.django_filters.DjangoFilterBackend',
'rest_framework.filters.SearchFilter',
),
'SEARCH_PARAM': 'filter[search]',
'TEST_REQUEST_RENDERER_CLASSES': (
'rest_framework_json_api.renderers.JSONRenderer',
),
Expand Down
28 changes: 24 additions & 4 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ REST_FRAMEWORK = {
'DEFAULT_FILTER_BACKENDS': (
'rest_framework_json_api.filters.OrderingFilter',
'rest_framework_json_api.django_filters.DjangoFilterBackend',
'rest_framework.filters.SearchFilter',
),
'SEARCH_PARAM': 'filter[search]',
'TEST_REQUEST_RENDERER_CLASSES': (
'rest_framework_json_api.renderers.JSONRenderer',
),
Expand Down Expand Up @@ -102,7 +104,8 @@ class MyLimitPagination(JsonApiLimitOffsetPagination):

### Filter Backends

_There are several anticipated JSON:API-specific filter backends in development. The first two are described below._
Following are descriptions for two JSON:API-specific filter backends and documentation on suggested usage
for a standard DRF keyword-search filter backend that makes it consistent with JSON:API.

#### `OrderingFilter`
`OrderingFilter` implements the [JSON:API `sort`](http://jsonapi.org/format/#fetching-sorting) and uses
Expand Down Expand Up @@ -151,12 +154,12 @@ Filters can be:
- A related resource path can be used:
`?filter[inventory.item.partNum]=123456` (where `inventory.item` is the relationship path)

If you are also using [`rest_framework.filters.SearchFilter`](https://django-rest-framework.readthedocs.io/en/latest/api-guide/filtering/#searchfilter)
(which performs single parameter searchs across multiple fields) you'll want to customize the name of the query
If you are also using [`SearchFilter`](#searchfilter)
(which performs single parameter searches across multiple fields) you'll want to customize the name of the query
parameter for searching to make sure it doesn't conflict with a field name defined in the filterset.
The recommended value is: `search_param="filter[search]"` but just make sure it's
`filter[_something_]` to comply with the JSON:API spec requirement to use the filter
keyword. The default is "search" unless overriden.
keyword. The default is `REST_FRAMEWORK['SEARCH_PARAM']` unless overriden.

The filter returns a `400 Bad Request` error for invalid filter query parameters as in this example
for `GET http://127.0.0.1:8000/nopage-entries?filter[bad]=1`:
Expand All @@ -173,6 +176,15 @@ for `GET http://127.0.0.1:8000/nopage-entries?filter[bad]=1`:
]
}
```
#### `SearchFilter`

To comply with JSON:API query parameter naming standards, DRF's
[SearchFilter](https://django-rest-framework.readthedocs.io/en/latest/api-guide/filtering/#searchfilter) should
be configured to use a `filter[_something_]` query parameter. This can be done by default by adding the
SearchFilter to `REST_FRAMEWORK['DEFAULT_FILTER_BACKENDS']` and setting `REST_FRAMEWORK['SEARCH_PARAM']` or
adding the `.search_param` attribute to a custom class derived from `SearchFilter`. If you do this and also
use [`DjangoFilterBackend`](#djangofilterbackend), make sure you set the same values for both classes.


#### Configuring Filter Backends

Expand All @@ -182,11 +194,19 @@ in the [example settings](#configuration) or individually add them as `.filter_b
```python
from rest_framework_json_api import filters
from rest_framework_json_api import django_filters
from rest_framework import SearchFilter
from models import MyModel

class MyViewset(ModelViewSet):
queryset = MyModel.objects.all()
serializer_class = MyModelSerializer
filter_backends = (filters.OrderingFilter, django_filters.DjangoFilterBackend,)
filterset_fields = {
'id': ('exact', 'lt', 'gt', 'gte', 'lte', 'in'),
'descriptuon': ('icontains', 'iexact', 'contains'),
'tagline': ('icontains', 'iexact', 'contains'),
}
search_fields = ('id', 'description', 'tagline',)
```


Expand Down
2 changes: 2 additions & 0 deletions example/settings/dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@
'DEFAULT_FILTER_BACKENDS': (
'rest_framework_json_api.filters.OrderingFilter',
'rest_framework_json_api.django_filters.DjangoFilterBackend',
'rest_framework.filters.SearchFilter',
),
'SEARCH_PARAM': 'filter[search]',
'TEST_REQUEST_RENDERER_CLASSES': (
'rest_framework_json_api.renderers.JSONRenderer',
),
Expand Down
140 changes: 140 additions & 0 deletions example/tests/test_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,3 +338,143 @@ def test_filter_missing_rvalue_equal(self):
dja_response = response.json()
self.assertEqual(dja_response['errors'][0]['detail'],
"missing filter[headline] test value")

def test_search_keywords(self):
"""
test for `filter[search]="keywords"` where some of the keywords are in the entry and
others are in the related blog.
"""
response = self.client.get(self.url, data={'filter[search]': 'barnard field research'})
expected_result = {
'data': [
{
'type': 'posts',
'id': '7',
'attributes': {
'headline': 'ANTH3868X',
'bodyText': 'ETHNOGRAPHIC FIELD RESEARCH IN NYC',
'pubDate': None,
'modDate': None},
'relationships': {
'blog': {
'data': {
'type': 'blogs',
'id': '1'
}
},
'blogHyperlinked': {
'links': {
'self': 'http://testserver/entries/7/relationships/blog_hyperlinked', # noqa: E501
'related': 'http://testserver/entries/7/blog'}
},
'authors': {
'meta': {
'count': 0
},
'data': []
},
'comments': {
'meta': {
'count': 0
},
'data': []
},
'commentsHyperlinked': {
'links': {
'self': 'http://testserver/entries/7/relationships/comments_hyperlinked', # noqa: E501
'related': 'http://testserver/entries/7/comments'
}
},
'suggested': {
'links': {
'self': 'http://testserver/entries/7/relationships/suggested',
'related': 'http://testserver/entries/7/suggested/'
},
'data': [
{'type': 'entries', 'id': '1'},
{'type': 'entries', 'id': '2'},
{'type': 'entries', 'id': '3'},
{'type': 'entries', 'id': '4'},
{'type': 'entries', 'id': '5'},
{'type': 'entries', 'id': '6'},
{'type': 'entries', 'id': '8'},
{'type': 'entries', 'id': '9'},
{'type': 'entries', 'id': '10'},
{'type': 'entries', 'id': '11'},
{'type': 'entries', 'id': '12'}
]
},
'suggestedHyperlinked': {
'links': {
'self': 'http://testserver/entries/7/relationships/suggested_hyperlinked', # noqa: E501
'related': 'http://testserver/entries/7/suggested/'}
},
'tags': {
'data': []
},
'featuredHyperlinked': {
'links': {
'self': 'http://testserver/entries/7/relationships/featured_hyperlinked', # noqa: E501
'related': 'http://testserver/entries/7/featured'
}
}
},
'meta': {
'bodyFormat': 'text'
}
}
]
}
assert response.json() == expected_result

def test_search_multiple_keywords(self):
"""
test for `filter[search]=keyword1...` (keyword1 [AND keyword2...])

See the four search_fields defined in views.py which demonstrate both searching
direct fields (entry) and following ORM links to related fields (blog):
`search_fields = ('headline', 'body_text', 'blog__name', 'blog__tagline')`

SearchFilter searches for items that match all whitespace separated keywords across
the many fields.

This code tests that functionality by comparing the result of the GET request
with the equivalent results used by filtering the test data via the model manager.
To do so, iterate over the list of given searches:
1. For each keyword, search the 4 search_fields for a match and then get the result
set which is the union of all results for the given keyword.
2. Intersect those results sets such that *all* keywords are represented.
See `example/fixtures/blogentry.json` for the test content that the searches are based on.
The searches test for both direct entries and related blogs across multiple fields.
"""
for searches in ("research", "chemistry", "nonesuch",
"research seminar", "research nonesuch",
"barnard classic", "barnard ethnographic field research"):
response = self.client.get(self.url, data={'filter[search]': searches})
self.assertEqual(response.status_code, 200, msg=response.content.decode("utf-8"))
dja_response = response.json()
keys = searches.split()
# dicts keyed by the search keys for the 4 search_fields:
headline = {} # list of entry ids where key is in entry__headline
body_text = {} # list of entry ids where key is in entry__body_text
blog_name = {} # list of entry ids where key is in entry__blog__name
blog_tagline = {} # list of entry ids where key is in entry__blog__tagline
for key in keys:
headline[key] = [str(k.id) for k in
self.entries.filter(headline__icontains=key)]
body_text[key] = [str(k.id) for k in
self.entries.filter(body_text__icontains=key)]
blog_name[key] = [str(k.id) for k in
self.entries.filter(blog__name__icontains=key)]
blog_tagline[key] = [str(k.id) for k in
self.entries.filter(blog__tagline__icontains=key)]
union = [] # each list item is a set of entry ids matching the given key
for key in keys:
union.append(set(headline[key] + body_text[key] +
blog_name[key] + blog_tagline[key]))
# all keywords must be present: intersect the keyword sets
expected_ids = set.intersection(*union)
expected_len = len(expected_ids)
self.assertEqual(len(dja_response['data']), expected_len)
returned_ids = set([k['id'] for k in dja_response['data']])
self.assertEqual(returned_ids, expected_ids)
1 change: 1 addition & 0 deletions example/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ class NonPaginatedEntryViewSet(EntryViewSet):
'blog__tagline': rels,
}
filter_fields = filterset_fields # django-filter<=1.1 (required for py27)
search_fields = ('headline', 'body_text', 'blog__name', 'blog__tagline')


class EntryFilter(filters.FilterSet):
Expand Down