Skip to content

Commit 63cb5e1

Browse files
committed
Merge pull request #43 from rpkilby/filter-exclusion
Add filter exclusion
2 parents 966b414 + b6aa6e1 commit 63cb5e1

File tree

3 files changed

+123
-3
lines changed

3 files changed

+123
-3
lines changed

README.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,20 @@ then we can filter like so::
164164

165165
/api/page/?author__username__icontains=john
166166

167+
Automatic Filter Negation/Exclusion
168+
~~~~~~~~~~~~~~~~~~~~~~~~~
169+
170+
FilterSets also support automatic exclusion using a simple ``k!=v`` syntax. This syntax
171+
internally sets the ``exclude`` property on the filter.
172+
173+
/api/page/?title!=The%20Park
174+
175+
This syntax supports regular filtering combined with exclusion filtering. For example,
176+
the following would search for all articles containing "Hello" in the title, while
177+
excluding those containing "World".
178+
179+
/api/articles/?title__contains=Hello&title__contains!=World
180+
167181
DjangoFilterBackend
168182
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
169183

rest_framework_filters/filterset.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from __future__ import unicode_literals
33

44
from collections import OrderedDict
5+
import copy
56

67
from django.db.models.constants import LOOKUP_SEP
78
from django.db import models
@@ -82,9 +83,16 @@ def get_filters(self):
8283

8384
# Add plain lookup filters if match. ie, `username__icontains`
8485
for filter_key, filter_value in six.iteritems(self.filters):
86+
exclude_key = '%s!' % filter_key
87+
8588
if filter_key in self.data:
8689
requested_filters[filter_key] = filter_value
8790

91+
if exclude_key in self.data:
92+
filter_value = copy.deepcopy(filter_value)
93+
filter_value.exclude = not filter_value.exclude
94+
requested_filters[exclude_key] = filter_value
95+
8896
# build a map of potential {rel: {filter: value}} data
8997
related_data = OrderedDict()
9098
for filter_key, value in six.iteritems(self.data):
@@ -141,11 +149,15 @@ def get_filter_name(cls, param):
141149
if param in cls.base_filters:
142150
return param
143151

152+
# Attempt to match against exclusion filters
153+
if param[-1] == '!' and param[:-1] in cls.base_filters:
154+
return param[:-1]
155+
144156
# Fallback to matching against relationships. (author__username__endswith)
145-
param = param.split(LOOKUP_SEP, 1)[0]
146-
f = cls.base_filters.get(param, None)
157+
related_param = param.split(LOOKUP_SEP, 1)[0]
158+
f = cls.base_filters.get(related_param, None)
147159
if isinstance(f, filters.RelatedFilter):
148-
return param
160+
return related_param
149161

150162
def cache_key(self, filterset, filter_names):
151163
return '%sSubset-%s' % (filterset.__name__, '-'.join(sorted(filter_names)), )

rest_framework_filters/tests.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,3 +766,97 @@ def test_inset_char_filter(self):
766766
self.assertEqual(len(f), 2)
767767
self.assertIn(p1, f)
768768
self.assertIn(p2, f)
769+
770+
771+
class FilterSetExclutionTests(TestCase):
772+
773+
if django.VERSION >= (1, 8):
774+
@classmethod
775+
def setUpTestData(cls):
776+
cls.generateTestData()
777+
778+
else:
779+
def setUp(self):
780+
self.generateTestData()
781+
782+
@classmethod
783+
def generateTestData(cls):
784+
t1 = Tag.objects.create(name='Tag 1')
785+
t2 = Tag.objects.create(name='Tag 2')
786+
t3 = Tag.objects.create(name='Something else entirely')
787+
788+
p1 = BlogPost.objects.create(title='Post 1', content='content 1')
789+
p2 = BlogPost.objects.create(title='Post 2', content='content 2')
790+
791+
p1.tags = [t1, t2]
792+
p2.tags = [t3]
793+
794+
def test_exclude_property(self):
795+
"""
796+
Ensure that the filter is set to exclude
797+
"""
798+
GET = {
799+
'name__contains!': 'Tag',
800+
}
801+
802+
filterset = TagFilter(GET, queryset=Tag.objects.all())
803+
requested_filters = filterset.get_filters()
804+
805+
self.assertTrue(requested_filters['name__contains!'].exclude)
806+
807+
def test_filter_and_exclude(self):
808+
"""
809+
Ensure that both the filter and exclusion filter are available
810+
"""
811+
GET = {
812+
'name__contains': 'Tag',
813+
'name__contains!': 'Tag',
814+
}
815+
816+
filterset = TagFilter(GET, queryset=Tag.objects.all())
817+
requested_filters = filterset.get_filters()
818+
819+
self.assertFalse(requested_filters['name__contains'].exclude)
820+
self.assertTrue(requested_filters['name__contains!'].exclude)
821+
822+
def test_related_exclude(self):
823+
GET = {
824+
'tags__name__contains!': 'Tag',
825+
}
826+
827+
filterset = BlogPostFilter(GET, queryset=BlogPost.objects.all())
828+
requested_filters = filterset.get_filters()
829+
830+
self.assertTrue(requested_filters['tags__name__contains!'].exclude)
831+
832+
def test_exclusion_results(self):
833+
GET = {
834+
'name__contains!': 'Tag',
835+
}
836+
837+
filterset = TagFilter(GET, queryset=Tag.objects.all())
838+
results = [r.name for r in filterset]
839+
self.assertEqual(len(results), 1)
840+
self.assertEqual(results[0], 'Something else entirely')
841+
842+
def test_filter_and_exclusion_results(self):
843+
GET = {
844+
'name__contains': 'Tag',
845+
'name__contains!': '2',
846+
}
847+
848+
filterset = TagFilter(GET, queryset=Tag.objects.all())
849+
results = [r.name for r in filterset]
850+
self.assertEqual(len(results), 1)
851+
self.assertEqual(results[0], 'Tag 1')
852+
853+
def test_related_exclusion_results(self):
854+
GET = {
855+
'tags__name__contains!': 'Tag',
856+
}
857+
858+
filterset = BlogPostFilter(GET, queryset=BlogPost.objects.all())
859+
results = [r.title for r in filterset]
860+
861+
self.assertEqual(len(results), 1)
862+
self.assertEqual(results[0], 'Post 2')

0 commit comments

Comments
 (0)