Skip to content

Commit 8258761

Browse files
author
Ryan P Kilby
committed
Add FilterSet subsetting, re-add caching
- FilterSet subsetting reduces the cost of initialization by limiting the base_fields of the subclass. This reduces `deepcopy` overhead. - Added back caching. It now occurs at the class level instead of the instance level, which should help prevent race conditions.
1 parent 992ac80 commit 8258761

File tree

4 files changed

+92
-9
lines changed

4 files changed

+92
-9
lines changed

rest_framework_filters/backends.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,13 @@
55

66
class DjangoFilterBackend(rest_framework.filters.DjangoFilterBackend):
77
default_filter_set = FilterSet
8+
_related_filterset_cache = {} # set to None to disable cache propagation
9+
10+
def filter_queryset(self, request, queryset, view):
11+
filter_class = self.get_filter_class(view, queryset)
12+
13+
if filter_class:
14+
cache = self._related_filterset_cache
15+
return filter_class(request.query_params, queryset=queryset, cache=cache).qs
16+
17+
return queryset

rest_framework_filters/filters.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import absolute_import
22
from __future__ import unicode_literals
33

4+
from collections import OrderedDict
45
from django.utils import six
56

67
from rest_framework.settings import api_settings
@@ -63,6 +64,27 @@ def fset(self, value):
6364
return locals()
6465
filterset = property(**filterset())
6566

67+
def get_filterset_subset(self, filter_names):
68+
"""
69+
Returns a FilterSet subclass that contains the subset of filters
70+
specified in `filter_names`. This is useful for creating FilterSets
71+
used across relationships, as it minimizes the deepcopy overhead
72+
incurred when instantiating the FilterSet.
73+
"""
74+
BaseFilterSet = self.filterset
75+
76+
class FilterSetSubset(BaseFilterSet):
77+
pass
78+
79+
FilterSetSubset.__name__ = str('%sSubset' % (BaseFilterSet.__name__))
80+
FilterSetSubset.base_filters = OrderedDict([
81+
(name, f)
82+
for name, f in six.iteritems(BaseFilterSet.base_filters)
83+
if name in filter_names
84+
])
85+
86+
return FilterSetSubset
87+
6688
def setup_filterset(self):
6789
self.extra['queryset'] = self.filterset._meta.model.objects.all()
6890

rest_framework_filters/filterset.py

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ class FilterSet(six.with_metaclass(FilterSetMetaclass, filterset.FilterSet)):
6969
}
7070

7171
def __init__(self, *args, **kwargs):
72+
self._related_filterset_cache = kwargs.pop('cache', {})
73+
7274
super(FilterSet, self).__init__(*args, **kwargs)
7375

7476
for name, filter_ in six.iteritems(self.filters):
@@ -86,14 +88,14 @@ def get_filters(self):
8688
"""
8789
requested_filters = OrderedDict()
8890

89-
# filter out any filters not included in the request data
91+
# Add plain lookup filters if match. ie, `username__icontains`
9092
for filter_key, filter_value in six.iteritems(self.filters):
9193
if filter_key in self.data:
9294
requested_filters[filter_key] = filter_value
9395

94-
# build a map of potential {rel: [filter]} pairs
96+
# build a map of potential {rel: {filter: value}} data
9597
related_data = OrderedDict()
96-
for filter_key in self.data:
98+
for filter_key, value in six.iteritems(self.data):
9799
if filter_key not in self.filters:
98100

99101
# skip non lookup/related keys
@@ -102,28 +104,66 @@ def get_filters(self):
102104

103105
rel_name, filter_key = filter_key.split(LOOKUP_SEP, 1)
104106

105-
related_data.setdefault(rel_name, [])
106-
related_data[rel_name].append(filter_key)
107+
related_data.setdefault(rel_name, OrderedDict())
108+
related_data[rel_name][filter_key] = value
107109

108-
# walk the related lookup data. If the rel is a RelatedFilter,
109-
# then instantiate its filterset and append its filters
110+
# walk the related lookup data. If the filter is a RelatedFilter,
111+
# then instantiate its filterset and append its filters.
110112
for rel_name, rel_data in related_data.items():
111113
related_filter = self.filters.get(rel_name, None)
112114

113115
# skip non-`RelatedFilter`s
114116
if not isinstance(related_filter, filters.RelatedFilter):
115117
continue
116118

117-
filterset = related_filter.filterset(data=rel_data)
118-
rel_filters = filterset.get_filters()
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+
# attempt to retrieve related filterset subset from the cache
124+
key = self.cache_key(filterset_class, filter_names)
125+
subset_class = self.cache_get(key)
126+
127+
# otherwise build and insert it into the cache
128+
if subset_class is None:
129+
subset_class = related_filter.get_filterset_subset(filter_names)
130+
self.cache_set(key, subset_class)
119131

132+
# initialize and copy filters
133+
filterset = subset_class(data=rel_data)
134+
rel_filters = filterset.get_filters()
120135
for filter_key, filter_value in six.iteritems(rel_filters):
136+
# modify filter name to account for relationship
121137
rel_filter_key = LOOKUP_SEP.join([rel_name, filter_key])
122138
filter_value.name = LOOKUP_SEP.join([related_filter.name, filter_value.name])
123139
requested_filters[rel_filter_key] = filter_value
124140

125141
return requested_filters
126142

143+
@classmethod
144+
def get_filter_name(cls, param):
145+
"""
146+
Get the filter name for the request data parameter.
147+
"""
148+
# Attempt to match against filters with lookups first. (username__endswith)
149+
if param in cls.base_filters:
150+
return param
151+
152+
# Fallback to matching against relationships. (author__username__endswith)
153+
param = param.split(LOOKUP_SEP, 1)[0]
154+
f = cls.base_filters.get(param, None)
155+
if isinstance(f, filters.RelatedFilter):
156+
return param
157+
158+
def cache_key(self, filterset, filter_names):
159+
return '%sSubset-%s' % (filterset.__name__, '-'.join(sorted(filter_names)), )
160+
161+
def cache_get(self, key):
162+
return self._related_filterset_cache.get(key)
163+
164+
def cache_set(self, key, value):
165+
self._related_filterset_cache[key] = value
166+
127167
@property
128168
def qs(self):
129169
available_filters = self.filters

rest_framework_filters/tests.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,3 +669,14 @@ def test_inset_filter(self):
669669
self.assertEqual(len(f), 2)
670670
self.assertIn(p1, f)
671671
self.assertIn(p2, f)
672+
673+
def test_get_filterset_subset(self):
674+
related_filter = NoteFilterWithRelated.base_filters['author']
675+
filterset_class = related_filter.get_filterset_subset(['email'])
676+
677+
# ensure that the class name is useful when debugging
678+
self.assertEqual(filterset_class.__name__, 'UserFilterSubset')
679+
680+
# ensure that the FilterSet subset only contains the requested fields
681+
self.assertIn('email', filterset_class.base_filters)
682+
self.assertEqual(len(filterset_class.base_filters), 1)

0 commit comments

Comments
 (0)