Skip to content

Commit 5d98c0b

Browse files
n2ygksliverc
authored andcommitted
Document how to use rest_framework.filters.SearchFilter (#476)
1 parent 59c439d commit 5d98c0b

File tree

5 files changed

+169
-4
lines changed

5 files changed

+169
-4
lines changed

README.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,9 @@ override ``settings.REST_FRAMEWORK``
175175
'DEFAULT_FILTER_BACKENDS': (
176176
'rest_framework_json_api.filters.OrderingFilter',
177177
'rest_framework_json_api.django_filters.DjangoFilterBackend',
178+
'rest_framework.filters.SearchFilter',
178179
),
180+
'SEARCH_PARAM': 'filter[search]',
179181
'TEST_REQUEST_RENDERER_CLASSES': (
180182
'rest_framework_json_api.renderers.JSONRenderer',
181183
),

docs/usage.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ REST_FRAMEWORK = {
3535
'DEFAULT_FILTER_BACKENDS': (
3636
'rest_framework_json_api.filters.OrderingFilter',
3737
'rest_framework_json_api.django_filters.DjangoFilterBackend',
38+
'rest_framework.filters.SearchFilter',
3839
),
40+
'SEARCH_PARAM': 'filter[search]',
3941
'TEST_REQUEST_RENDERER_CLASSES': (
4042
'rest_framework_json_api.renderers.JSONRenderer',
4143
),
@@ -102,7 +104,8 @@ class MyLimitPagination(JsonApiLimitOffsetPagination):
102104

103105
### Filter Backends
104106

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

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

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

161164
The filter returns a `400 Bad Request` error for invalid filter query parameters as in this example
162165
for `GET http://127.0.0.1:8000/nopage-entries?filter[bad]=1`:
@@ -173,6 +176,15 @@ for `GET http://127.0.0.1:8000/nopage-entries?filter[bad]=1`:
173176
]
174177
}
175178
```
179+
#### `SearchFilter`
180+
181+
To comply with JSON:API query parameter naming standards, DRF's
182+
[SearchFilter](https://django-rest-framework.readthedocs.io/en/latest/api-guide/filtering/#searchfilter) should
183+
be configured to use a `filter[_something_]` query parameter. This can be done by default by adding the
184+
SearchFilter to `REST_FRAMEWORK['DEFAULT_FILTER_BACKENDS']` and setting `REST_FRAMEWORK['SEARCH_PARAM']` or
185+
adding the `.search_param` attribute to a custom class derived from `SearchFilter`. If you do this and also
186+
use [`DjangoFilterBackend`](#djangofilterbackend), make sure you set the same values for both classes.
187+
176188

177189
#### Configuring Filter Backends
178190

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

186200
class MyViewset(ModelViewSet):
187201
queryset = MyModel.objects.all()
188202
serializer_class = MyModelSerializer
189203
filter_backends = (filters.OrderingFilter, django_filters.DjangoFilterBackend,)
204+
filterset_fields = {
205+
'id': ('exact', 'lt', 'gt', 'gte', 'lte', 'in'),
206+
'descriptuon': ('icontains', 'iexact', 'contains'),
207+
'tagline': ('icontains', 'iexact', 'contains'),
208+
}
209+
search_fields = ('id', 'description', 'tagline',)
190210
```
191211

192212

example/settings/dev.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,9 @@
9292
'DEFAULT_FILTER_BACKENDS': (
9393
'rest_framework_json_api.filters.OrderingFilter',
9494
'rest_framework_json_api.django_filters.DjangoFilterBackend',
95+
'rest_framework.filters.SearchFilter',
9596
),
97+
'SEARCH_PARAM': 'filter[search]',
9698
'TEST_REQUEST_RENDERER_CLASSES': (
9799
'rest_framework_json_api.renderers.JSONRenderer',
98100
),

example/tests/test_filters.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,3 +338,143 @@ def test_filter_missing_rvalue_equal(self):
338338
dja_response = response.json()
339339
self.assertEqual(dja_response['errors'][0]['detail'],
340340
"missing filter[headline] test value")
341+
342+
def test_search_keywords(self):
343+
"""
344+
test for `filter[search]="keywords"` where some of the keywords are in the entry and
345+
others are in the related blog.
346+
"""
347+
response = self.client.get(self.url, data={'filter[search]': 'barnard field research'})
348+
expected_result = {
349+
'data': [
350+
{
351+
'type': 'posts',
352+
'id': '7',
353+
'attributes': {
354+
'headline': 'ANTH3868X',
355+
'bodyText': 'ETHNOGRAPHIC FIELD RESEARCH IN NYC',
356+
'pubDate': None,
357+
'modDate': None},
358+
'relationships': {
359+
'blog': {
360+
'data': {
361+
'type': 'blogs',
362+
'id': '1'
363+
}
364+
},
365+
'blogHyperlinked': {
366+
'links': {
367+
'self': 'http://testserver/entries/7/relationships/blog_hyperlinked', # noqa: E501
368+
'related': 'http://testserver/entries/7/blog'}
369+
},
370+
'authors': {
371+
'meta': {
372+
'count': 0
373+
},
374+
'data': []
375+
},
376+
'comments': {
377+
'meta': {
378+
'count': 0
379+
},
380+
'data': []
381+
},
382+
'commentsHyperlinked': {
383+
'links': {
384+
'self': 'http://testserver/entries/7/relationships/comments_hyperlinked', # noqa: E501
385+
'related': 'http://testserver/entries/7/comments'
386+
}
387+
},
388+
'suggested': {
389+
'links': {
390+
'self': 'http://testserver/entries/7/relationships/suggested',
391+
'related': 'http://testserver/entries/7/suggested/'
392+
},
393+
'data': [
394+
{'type': 'entries', 'id': '1'},
395+
{'type': 'entries', 'id': '2'},
396+
{'type': 'entries', 'id': '3'},
397+
{'type': 'entries', 'id': '4'},
398+
{'type': 'entries', 'id': '5'},
399+
{'type': 'entries', 'id': '6'},
400+
{'type': 'entries', 'id': '8'},
401+
{'type': 'entries', 'id': '9'},
402+
{'type': 'entries', 'id': '10'},
403+
{'type': 'entries', 'id': '11'},
404+
{'type': 'entries', 'id': '12'}
405+
]
406+
},
407+
'suggestedHyperlinked': {
408+
'links': {
409+
'self': 'http://testserver/entries/7/relationships/suggested_hyperlinked', # noqa: E501
410+
'related': 'http://testserver/entries/7/suggested/'}
411+
},
412+
'tags': {
413+
'data': []
414+
},
415+
'featuredHyperlinked': {
416+
'links': {
417+
'self': 'http://testserver/entries/7/relationships/featured_hyperlinked', # noqa: E501
418+
'related': 'http://testserver/entries/7/featured'
419+
}
420+
}
421+
},
422+
'meta': {
423+
'bodyFormat': 'text'
424+
}
425+
}
426+
]
427+
}
428+
assert response.json() == expected_result
429+
430+
def test_search_multiple_keywords(self):
431+
"""
432+
test for `filter[search]=keyword1...` (keyword1 [AND keyword2...])
433+
434+
See the four search_fields defined in views.py which demonstrate both searching
435+
direct fields (entry) and following ORM links to related fields (blog):
436+
`search_fields = ('headline', 'body_text', 'blog__name', 'blog__tagline')`
437+
438+
SearchFilter searches for items that match all whitespace separated keywords across
439+
the many fields.
440+
441+
This code tests that functionality by comparing the result of the GET request
442+
with the equivalent results used by filtering the test data via the model manager.
443+
To do so, iterate over the list of given searches:
444+
1. For each keyword, search the 4 search_fields for a match and then get the result
445+
set which is the union of all results for the given keyword.
446+
2. Intersect those results sets such that *all* keywords are represented.
447+
See `example/fixtures/blogentry.json` for the test content that the searches are based on.
448+
The searches test for both direct entries and related blogs across multiple fields.
449+
"""
450+
for searches in ("research", "chemistry", "nonesuch",
451+
"research seminar", "research nonesuch",
452+
"barnard classic", "barnard ethnographic field research"):
453+
response = self.client.get(self.url, data={'filter[search]': searches})
454+
self.assertEqual(response.status_code, 200, msg=response.content.decode("utf-8"))
455+
dja_response = response.json()
456+
keys = searches.split()
457+
# dicts keyed by the search keys for the 4 search_fields:
458+
headline = {} # list of entry ids where key is in entry__headline
459+
body_text = {} # list of entry ids where key is in entry__body_text
460+
blog_name = {} # list of entry ids where key is in entry__blog__name
461+
blog_tagline = {} # list of entry ids where key is in entry__blog__tagline
462+
for key in keys:
463+
headline[key] = [str(k.id) for k in
464+
self.entries.filter(headline__icontains=key)]
465+
body_text[key] = [str(k.id) for k in
466+
self.entries.filter(body_text__icontains=key)]
467+
blog_name[key] = [str(k.id) for k in
468+
self.entries.filter(blog__name__icontains=key)]
469+
blog_tagline[key] = [str(k.id) for k in
470+
self.entries.filter(blog__tagline__icontains=key)]
471+
union = [] # each list item is a set of entry ids matching the given key
472+
for key in keys:
473+
union.append(set(headline[key] + body_text[key] +
474+
blog_name[key] + blog_tagline[key]))
475+
# all keywords must be present: intersect the keyword sets
476+
expected_ids = set.intersection(*union)
477+
expected_len = len(expected_ids)
478+
self.assertEqual(len(dja_response['data']), expected_len)
479+
returned_ids = set([k['id'] for k in dja_response['data']])
480+
self.assertEqual(returned_ids, expected_ids)

example/views.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ class NonPaginatedEntryViewSet(EntryViewSet):
104104
'blog__tagline': rels,
105105
}
106106
filter_fields = filterset_fields # django-filter<=1.1 (required for py27)
107+
search_fields = ('headline', 'body_text', 'blog__name', 'blog__tagline')
107108

108109

109110
class EntryFilter(filters.FilterSet):

0 commit comments

Comments
 (0)