From 314cc799340dd9f7165ac3b35a4ac5de07b5c732 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Thu, 13 Sep 2018 14:25:25 -0400 Subject: [PATCH 1/3] Document how to use `rest_framework.filters.SearchFilter` Includes updates to the example app and tests. --- README.rst | 2 ++ docs/usage.md | 30 +++++++++++++++++++++++++----- example/settings/dev.py | 2 ++ example/tests/test_filters.py | 30 ++++++++++++++++++++++++++++++ example/views.py | 1 + 5 files changed, 60 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index cb0647fa..340f24a3 100644 --- a/README.rst +++ b/README.rst @@ -175,7 +175,9 @@ override ``settings.REST_FRAMEWORK`` 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework_json_api.filters.JSONAPIOrderingFilter', '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', ), diff --git a/docs/usage.md b/docs/usage.md index 7d329a3c..32ce0bf8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -35,7 +35,9 @@ REST_FRAMEWORK = { 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework_json_api.filters.JSONAPIOrderingFilter', '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', ), @@ -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. #### `JSONAPIOrderingFilter` `JSONAPIOrderingFilter` implements the [JSON:API `sort`](http://jsonapi.org/format/#fetching-sorting) and uses @@ -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`: @@ -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 @@ -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.JSONAPIOrderingFilter, django_filters.DjangoFilterBackend,) + filter_backends = (filters.JSONAPIOrderingFilter, django_filters.DjangoFilterBackend, SearchFilter) + filterset_fields = { + 'id': ('exact', 'lt', 'gt', 'gte', 'lte', 'in'), + 'descriptuon': ('icontains', 'iexact', 'contains'), + 'tagline': ('icontains', 'iexact', 'contains'), + } + search_fields = ('id', 'description', 'tagline',) ``` diff --git a/example/settings/dev.py b/example/settings/dev.py index 5356146f..fdea6f40 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -92,7 +92,9 @@ 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework_json_api.filters.JSONAPIOrderingFilter', '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', ), diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 273fe9ac..4a804c62 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -338,3 +338,33 @@ 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]=keyword1...` (keyword1 [AND keyword2...]) + """ + for keywords in ("research", "chemistry", "nonesuch", + "research seminar", "research nonesuch", + "barnard classic"): + response = self.client.get(self.url, data={'filter[search]': keywords}) + self.assertEqual(response.status_code, 200, msg=response.content.decode("utf-8")) + dja_response = response.json() + # see the search_fields defined in views.py. + a = {} + b = {} + c = {} + d = {} + keys = keywords.split() + for key in keys: + a[key] = [str(k.id) for k in self.entries.filter(headline__icontains=key)] + b[key] = [str(k.id) for k in self.entries.filter(body_text__icontains=key)] + c[key] = [str(k.id) for k in self.entries.filter(blog__name__icontains=key)] + d[key] = [str(k.id) for k in self.entries.filter(blog__tagline__icontains=key)] + union = [] # a list of sets grouped by keyword + for key in keys: + union.append(set(a[key] + b[key] + c[key] + d[key])) + # all keywords must be present: intersect the keyword sets + inter = set.intersection(*union) + expected_len = len(inter) + self.assertEqual(len(dja_response['data']), expected_len) + self.assertEqual(set([k['id'] for k in dja_response['data']]), inter) diff --git a/example/views.py b/example/views.py index 98907902..d0980f01 100644 --- a/example/views.py +++ b/example/views.py @@ -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): From 9f8869ff97ec8efa25d9a795601a73aaf4bcd8ec Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Fri, 14 Sep 2018 10:30:51 -0400 Subject: [PATCH 2/3] try to improve understandability of SearchFilter test case --- example/tests/test_filters.py | 60 ++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 4a804c62..1f6644d7 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -342,29 +342,51 @@ def test_filter_missing_rvalue_equal(self): def test_search_keywords(self): """ test for `filter[search]=keyword1...` (keyword1 [AND keyword2...]) - """ - for keywords in ("research", "chemistry", "nonesuch", + + 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"): - response = self.client.get(self.url, data={'filter[search]': keywords}) + "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() - # see the search_fields defined in views.py. - a = {} - b = {} - c = {} - d = {} - keys = keywords.split() + 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: - a[key] = [str(k.id) for k in self.entries.filter(headline__icontains=key)] - b[key] = [str(k.id) for k in self.entries.filter(body_text__icontains=key)] - c[key] = [str(k.id) for k in self.entries.filter(blog__name__icontains=key)] - d[key] = [str(k.id) for k in self.entries.filter(blog__tagline__icontains=key)] - union = [] # a list of sets grouped by keyword + 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(a[key] + b[key] + c[key] + d[key])) + union.append(set(headline[key] + body_text[key] + + blog_name[key] + blog_tagline[key])) # all keywords must be present: intersect the keyword sets - inter = set.intersection(*union) - expected_len = len(inter) + expected_ids = set.intersection(*union) + expected_len = len(expected_ids) self.assertEqual(len(dja_response['data']), expected_len) - self.assertEqual(set([k['id'] for k in dja_response['data']]), inter) + returned_ids = set([k['id'] for k in dja_response['data']]) + self.assertEqual(returned_ids, expected_ids) From aea6e38e31fa6dc870940c895b1f7637fe276aa9 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Fri, 14 Sep 2018 10:57:54 -0400 Subject: [PATCH 3/3] test case using response.json() comparison. Not super happy with the fragility of this test. Any future changes to unrelated parts will break it. --- example/tests/test_filters.py | 88 +++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 1f6644d7..befba5e2 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -340,6 +340,94 @@ def test_filter_missing_rvalue_equal(self): "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...])