Repository: django-json-api/django-rest-framework-json-api

Issue:
spec compliance: sparse fieldsets, sorting & filtering query parameters
Split #416 and refactor as appropriate to implement sparse [fieldsets](http://jsonapi.org/format/#fetching-sparse-fieldsets), [sorting](http://jsonapi.org/format/#fetching-sorting) and [filtering](http://jsonapi.org/format/#fetching-filtering).


Discussion:
See #455

Resolving patch:
diff --git a/example/settings/dev.py b/example/settings/dev.py
--- a/example/settings/dev.py
+++ b/example/settings/dev.py
@@ -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',
     ),
diff --git a/example/views.py b/example/views.py
--- 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):


Test patch:
diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py
--- a/example/tests/test_filters.py
+++ b/example/tests/test_filters.py
@@ -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)