Skip to content

Commit 84d0676

Browse files
author
Ryan P Kilby
committed
Merge pull request #70 from rpkilby/refactor-get_filters
Refactor get_filters
2 parents 2a0d0c6 + 3578d51 commit 84d0676

File tree

15 files changed

+521
-113
lines changed

15 files changed

+521
-113
lines changed

.coveragerc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[run]
2+
branch = True
3+
source =
4+
rest_framework_filters
5+
6+
[report]
7+
show_missing = True

.travis.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ env:
1212
- DJANGO="Django>=1.8,<1.9"
1313
- DJANGO="Django>=1.9,<1.10"
1414
install:
15-
- travis_retry pip install -q $DJANGO
15+
- travis_retry pip install -q $DJANGO "coverage==3.7.1"
1616
- pip install .
17-
script: python manage.py test
17+
script:
18+
- coverage run manage.py test
19+
- coverage report
1820

1921
matrix:
2022
exclude:

CHANGELOG.rst

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
1+
Unreleased
2+
----------
3+
4+
* #64 Fix browsable API filter form
5+
* #69 Fix compatibility with base django-filter `FilterSet`s
6+
* #70 Refactor related filter handling, fixing some edge cases
7+
* Deprecated 'cache' argument to FilterSet
8+
19
v0.7.0:
210
-------
311

4-
* #61 Change django-filter requirement to 0.12.0
5-
* Adds support for Django 1.9
6-
* #47 Changes implementation of MethodFilterss
7-
* Drops support for Django 1.7
8-
* #49 Fix ALL_LOOKUPS shortcut to obey filter overrides (in, isnull)
9-
* #46 Fix boolean filter behavior (#25) and isnull override (#6)
10-
* #60 Fix filtering on nonexistent related field
12+
* #61 Change django-filter requirement to 0.12.0
13+
* Adds support for Django 1.9
14+
* #47 Changes implementation of MethodFilterss
15+
* Drops support for Django 1.7
16+
* #49 Fix ALL_LOOKUPS shortcut to obey filter overrides (in, isnull)
17+
* #46 Fix boolean filter behavior (#25) and isnull override (#6)
18+
* #60 Fix filtering on nonexistent related field
1119

1220
v0.6.0:
1321
-------

rest_framework_filters/backends.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,35 @@
11

2+
from django.template import loader
3+
from rest_framework.compat import template_render
24
import rest_framework.filters
5+
36
from .filterset import FilterSet
47

58

69
class DjangoFilterBackend(rest_framework.filters.DjangoFilterBackend):
710
default_filter_set = FilterSet
8-
_related_filterset_cache = {} # set to None to disable cache propagation
911

1012
def filter_queryset(self, request, queryset, view):
1113
filter_class = self.get_filter_class(view, queryset)
1214

1315
if filter_class:
14-
cache = self._related_filterset_cache
15-
return filter_class(request.query_params, queryset=queryset, cache=cache).qs
16+
if hasattr(filter_class, 'get_subset'):
17+
filter_class = filter_class.get_subset(request.query_params)
18+
return filter_class(request.query_params, queryset=queryset).qs
1619

1720
return queryset
21+
22+
def to_html(self, request, queryset, view):
23+
filter_class = self.get_filter_class(view, queryset)
24+
if not filter_class:
25+
return None
26+
filter_instance = filter_class(request.query_params, queryset=queryset)
27+
28+
# forces `form` evaluation before `qs` is called. This prevents an empty form from being cached.
29+
filter_instance.form
30+
31+
context = {
32+
'filter': filter_instance
33+
}
34+
template = loader.get_template(self.template)
35+
return template_render(template, context)

rest_framework_filters/filterset.py

Lines changed: 157 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from collections import OrderedDict
55
import copy
6+
import warnings
67

78
from django.db import models
89
from django.db.models.constants import LOOKUP_SEP
@@ -48,19 +49,40 @@ def __new__(cls, name, bases, attrs):
4849

4950
return new_class
5051

52+
@property
53+
def related_filters(self):
54+
# check __dict__ instead of use hasattr. we *don't* want to check
55+
# parents for existence of existing cache. eg, we do not want
56+
# FilterSet.get_subset([...]) to return the same cache.
57+
if '_related_filters' not in self.__dict__:
58+
self._related_filters = OrderedDict([
59+
(name, f) for name, f in six.iteritems(self.base_filters)
60+
if isinstance(f, filters.RelatedFilter)
61+
])
62+
return self._related_filters
63+
5164

5265
class FilterSet(six.with_metaclass(FilterSetMetaclass, filterset.FilterSet)):
5366
filter_overrides = {
67+
models.BooleanField: {
68+
'filter_class': filters.BooleanFilter,
69+
},
5470

5571
# In order to support ISO-8601 -- which is the default output for
5672
# DRF -- we need to use django-filter's IsoDateTimeFilter
5773
models.DateTimeField: {
5874
'filter_class': filters.IsoDateTimeFilter,
5975
},
6076
}
77+
_subset_cache = {}
6178

6279
def __init__(self, *args, **kwargs):
63-
self._related_filterset_cache = kwargs.pop('cache', {})
80+
if 'cache' in kwargs:
81+
warnings.warn(
82+
"'cache' argument is deprecated. Override '_subset_cache' instead.",
83+
DeprecationWarning, stacklevel=2
84+
)
85+
self.__class__._subset_cache = kwargs.pop('cache', None)
6486

6587
super(FilterSet, self).__init__(*args, **kwargs)
6688

@@ -79,74 +101,68 @@ def get_filters(self):
79101
Build a set of filters based on the requested data. The resulting set
80102
will walk `RelatedFilter`s to recursively build the set of filters.
81103
"""
82-
requested_filters = OrderedDict()
83-
84-
# Add plain lookup filters if match. ie, `username__icontains`
85-
for filter_key, filter_value in six.iteritems(self.filters):
86-
exclude_key = '%s!' % filter_key
87-
88-
if filter_key in self.data:
89-
requested_filters[filter_key] = filter_value
90-
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-
96-
# build a map of potential {rel: {filter: value}} data
97-
related_data = OrderedDict()
98-
for filter_key, value in six.iteritems(self.data):
99-
if filter_key not in self.filters:
100-
101-
# skip non lookup/related keys
102-
if LOOKUP_SEP not in filter_key:
103-
continue
104-
105-
rel_name, filter_key = filter_key.split(LOOKUP_SEP, 1)
106-
107-
related_data.setdefault(rel_name, OrderedDict())
108-
related_data[rel_name][filter_key] = value
109-
110-
# walk the related lookup data. If the filter is a RelatedFilter,
111-
# then instantiate its filterset and append its filters.
112-
for rel_name, rel_data in related_data.items():
113-
related_filter = self.filters.get(rel_name, None)
114-
115-
# skip non-`RelatedFilter`s
116-
if not isinstance(related_filter, filters.RelatedFilter):
104+
# build param data for related filters: {rel: {param: value}}
105+
related_data = OrderedDict(
106+
[(name, OrderedDict()) for name in self.__class__.related_filters]
107+
)
108+
for param, value in six.iteritems(self.data):
109+
filter_name, related_param = self.get_related_filter_param(param)
110+
111+
# skip non lookup/related keys
112+
if filter_name is None:
117113
continue
118114

119-
# get known filter names
120-
filterset_class = related_filter.filterset
121-
filter_names = [filterset_class.get_filter_name(param) for param in rel_data.keys()]
122-
123-
# filter out empty values - indicates an unknown field (author__foobar__isnull)
124-
filter_names = [f for f in filter_names if f is not None]
125-
126-
# attempt to retrieve related filterset subset from the cache
127-
key = self.cache_key(filterset_class, filter_names)
128-
subset_class = self.cache_get(key)
115+
if filter_name in related_data:
116+
related_data[filter_name][related_param] = value
129117

130-
# otherwise build and insert it into the cache
131-
if subset_class is None:
132-
subset_class = related_filter.filterset.get_subset(filter_names)
133-
self.cache_set(key, subset_class)
134-
135-
# initialize and copy filters
136-
filterset = subset_class(data=rel_data)
137-
rel_filters = filterset.get_filters()
138-
for filter_key, filter_value in six.iteritems(rel_filters):
139-
# modify filter name to account for relationship
140-
rel_filter_key = LOOKUP_SEP.join([rel_name, filter_key])
141-
filter_value.name = LOOKUP_SEP.join([related_filter.name, filter_value.name])
142-
requested_filters[rel_filter_key] = filter_value
118+
# build the compiled set of all filters
119+
requested_filters = OrderedDict()
120+
for filter_name, f in six.iteritems(self.filters):
121+
exclude_name = '%s!' % filter_name
122+
123+
# Add plain lookup filters if match. ie, `username__icontains`
124+
if filter_name in self.data:
125+
requested_filters[filter_name] = f
126+
127+
# include exclusion keys
128+
if exclude_name in self.data:
129+
f = copy.deepcopy(f)
130+
f.exclude = not f.exclude
131+
requested_filters[exclude_name] = f
132+
133+
# include filters from related subsets
134+
if isinstance(f, filters.RelatedFilter) and filter_name in related_data:
135+
subset_data = related_data[filter_name]
136+
subset_class = f.filterset.get_subset(subset_data)
137+
filterset = subset_class(data=subset_data)
138+
139+
# modify filter names to account for relationship
140+
for related_name, related_f in six.iteritems(filterset.get_filters()):
141+
related_name = LOOKUP_SEP.join([filter_name, related_name])
142+
related_f.name = LOOKUP_SEP.join([f.name, related_f.name])
143+
requested_filters[related_name] = related_f
143144

144145
return requested_filters
145146

146147
@classmethod
147148
def get_filter_name(cls, param):
148149
"""
149150
Get the filter name for the request data parameter.
151+
152+
ex::
153+
154+
# regular attribute filters
155+
name = FilterSet.get_filter_name('email')
156+
assert name == 'email'
157+
158+
# exclusion filters
159+
name = FilterSet.get_filter_name('email!')
160+
assert name == 'email'
161+
162+
# related filters
163+
name = FilterSet.get_filter_name('author__email')
164+
assert name == 'author'
165+
150166
"""
151167
# Attempt to match against filters with lookups first. (username__endswith)
152168
if param in cls.base_filters:
@@ -156,40 +172,97 @@ def get_filter_name(cls, param):
156172
if param[-1] == '!' and param[:-1] in cls.base_filters:
157173
return param[:-1]
158174

159-
# Fallback to matching against relationships. (author__username__endswith)
160-
related_param = param.split(LOOKUP_SEP, 1)[0]
161-
f = cls.base_filters.get(related_param, None)
162-
if isinstance(f, filters.RelatedFilter):
163-
return related_param
175+
# Fallback to matching against relationships. (author__username__endswith).
176+
related_filters = cls.related_filters.keys()
177+
178+
# preference more specific filters. eg, `note__author` over `note`.
179+
for name in sorted(related_filters)[::-1]:
180+
# we need to match against '__' to prevent eager matching against
181+
# like names. eg, note vs note2. Exact matches are handled above.
182+
if param.startswith("%s%s" % (name, LOOKUP_SEP)):
183+
return name
164184

165185
@classmethod
166-
def get_subset(cls, filter_names):
186+
def get_related_filter_param(cls, param):
167187
"""
168-
Returns a FilterSet subclass that contains the subset of filters
169-
specified in `filter_names`. This is useful for creating FilterSets
170-
used across relationships, as it minimizes the deepcopy overhead
171-
incurred when instantiating the FilterSet.
188+
Get a tuple of (filter name, related param).
189+
190+
ex::
191+
192+
name, param = FilterSet.get_filter_name('author__email__foobar')
193+
assert name == 'author'
194+
assert param = 'email__foobar'
195+
196+
name, param = FilterSet.get_filter_name('author')
197+
assert name is None
198+
assert param is None
199+
172200
"""
173-
class FilterSetSubset(cls):
174-
pass
201+
related_filters = cls.related_filters.keys()
175202

176-
FilterSetSubset.__name__ = str('%sSubset' % (cls.__name__))
177-
FilterSetSubset.base_filters = OrderedDict([
178-
(name, f)
179-
for name, f in six.iteritems(cls.base_filters)
180-
if name in filter_names
181-
])
203+
# preference more specific filters. eg, `note__author` over `note`.
204+
for name in sorted(related_filters)[::-1]:
205+
# we need to match against '__' to prevent eager matching against
206+
# like names. eg, note vs note2. Exact matches are handled above.
207+
if param.startswith("%s%s" % (name, LOOKUP_SEP)):
208+
# strip param + LOOKUP_SET from param
209+
related_param = param[len(name) + len(LOOKUP_SEP):]
210+
return name, related_param
182211

183-
return FilterSetSubset
212+
# not a related param
213+
return None, None
184214

185-
def cache_key(self, filterset, filter_names):
186-
return '%sSubset-%s' % (filterset.__name__, '-'.join(sorted(filter_names)), )
215+
@classmethod
216+
def get_subset(cls, params):
217+
"""
218+
Returns a FilterSubset class that contains the subset of filters
219+
specified in the requested `params`. This is useful for creating
220+
FilterSets that traverse relationships, as it helps to minimize
221+
the deepcopy overhead incurred when instantiating the FilterSet.
222+
"""
223+
# Determine names of filters from query params and remove empty values.
224+
# param names that traverse relations are translated to just the local
225+
# filter names. eg, `author__username` => `author`. Empty values are
226+
# removed, as they indicate an unknown field eg, author__foobar__isnull
227+
filter_names = [cls.get_filter_name(param) for param in params]
228+
filter_names = [f for f in filter_names if f is not None]
229+
230+
# attempt to retrieve related filterset subset from the cache
231+
key = cls.cache_key(filter_names)
232+
subset_class = cls.cache_get(key)
233+
234+
# if no cached subset, then derive base_filters and create new subset
235+
if subset_class is not None:
236+
return subset_class
237+
238+
class FilterSubsetMetaclass(FilterSetMetaclass):
239+
def __new__(cls, name, bases, attrs):
240+
new_class = super(FilterSubsetMetaclass, cls).__new__(cls, name, bases, attrs)
241+
new_class.base_filters = OrderedDict([
242+
(name, f)
243+
for name, f in six.iteritems(new_class.base_filters)
244+
if name in filter_names
245+
])
246+
return new_class
247+
248+
class FilterSubset(six.with_metaclass(FilterSubsetMetaclass, cls)):
249+
pass
187250

188-
def cache_get(self, key):
189-
return self._related_filterset_cache.get(key)
251+
FilterSubset.__name__ = str('%sSubset' % (cls.__name__, ))
252+
cls.cache_set(key, FilterSubset)
253+
return FilterSubset
190254

191-
def cache_set(self, key, value):
192-
self._related_filterset_cache[key] = value
255+
@classmethod
256+
def cache_key(cls, filter_names):
257+
return '%sSubset-%s' % (cls.__name__, '-'.join(sorted(filter_names)), )
258+
259+
@classmethod
260+
def cache_get(cls, key):
261+
return cls._subset_cache.get(key)
262+
263+
@classmethod
264+
def cache_set(cls, key, value):
265+
cls._subset_cache[key] = value
193266

194267
@property
195268
def qs(self):

0 commit comments

Comments
 (0)