Skip to content

Commit 55e701f

Browse files
author
Ryan P Kilby
committed
Add lazy related filter evaluation, fixes #8
Based on @JockeTF's suggestion to limit the filters based on the request data. This goes a step further by lazily evaluating related filters based on the request data.
1 parent fe72834 commit 55e701f

File tree

2 files changed

+79
-48
lines changed

2 files changed

+79
-48
lines changed

rest_framework_filters/filters.py

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,18 @@
44
from django.utils import six
55

66
from rest_framework.settings import api_settings
7-
import rest_framework.filters
87
import django_filters
98
from django_filters.filters import *
109

1110
from . import fields
1211

12+
13+
def _import_class(path):
14+
module_path, class_name = path.rsplit('.', 1)
15+
module = __import__(module_path, fromlist=[class_name], level=0)
16+
return getattr(module, class_name)
17+
18+
1319
def subsitute_iso8601(date_type):
1420
from rest_framework import ISO_8601
1521

@@ -42,18 +48,22 @@ def subsitute_iso8601(date_type):
4248
class RelatedFilter(ModelChoiceFilter):
4349
def __init__(self, filterset, *args, **kwargs):
4450
self.filterset = filterset
45-
self.parent_relation = kwargs.get('parent_relation', None)
51+
# self.parent_relation = kwargs.get('parent_relation', None)
4652
return super(RelatedFilter, self).__init__(*args, **kwargs)
4753

48-
def setup_filterset(self):
49-
if isinstance(self.filterset, six.string_types):
50-
# This is a recursive relation, defined via a string, so we need
51-
# to create and import the class here.
52-
items = self.filterset.split('.')
53-
cls = str(items[-1]) # Ensure not unicode on py2.x
54-
mod = __import__('.'.join(items[:-1]), fromlist=[cls])
55-
self.filterset = getattr(mod, cls)
54+
def filterset():
55+
def fget(self):
56+
if isinstance(self._filterset, six.string_types):
57+
self._filterset = _import_class(self._filterset)
58+
return self._filterset
5659

60+
def fset(self, value):
61+
self._filterset = value
62+
63+
return locals()
64+
filterset = property(**filterset())
65+
66+
def setup_filterset(self):
5767
self.extra['queryset'] = self.filterset._meta.model.objects.all()
5868

5969

rest_framework_filters/filterset.py

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

44
from copy import copy
5+
from collections import OrderedDict
56

67
try:
78
from django.db.models.constants import LOOKUP_SEP
@@ -45,13 +46,12 @@ def __init__(self, *args, **kwargs):
4546

4647
for name, filter_ in six.iteritems(self.filters):
4748
if isinstance(filter_, filters.RelatedFilter):
48-
# Populate our FilterSet fields with the fields we've stored
49-
# in RelatedFilter.
5049
filter_.setup_filterset()
51-
self.populate_from_filterset(filter_.filterset, filter_, name)
50+
5251
# Add an 'isnull' filter to allow checking if the relation is empty.
5352
isnull_filter = filters.BooleanFilter(name=("%s%sisnull" % (filter_.name, LOOKUP_SEP)))
5453
self.filters['%s%s%s' % (filter_.name, LOOKUP_SEP, 'isnull')] = isnull_filter
54+
5555
elif isinstance(filter_, filters.AllLookupsFilter):
5656
# Populate our FilterSet fields with all the possible
5757
# filters for the AllLookupsFilter field.
@@ -66,6 +66,62 @@ def __init__(self, *args, **kwargs):
6666
f = self.fix_filter_field(f)
6767
self.filters["%s%s%s" % (name, LOOKUP_SEP, lookup_type)] = f
6868

69+
def get_filters(self):
70+
"""
71+
Build a set of filters based on the requested data. The resulting set
72+
will walk `RelatedFilter`s to recursively build the set of filters.
73+
"""
74+
requested_filters = OrderedDict()
75+
76+
# filter out any filters not included in the request data
77+
for filter_key, filter_value in six.iteritems(self.filters):
78+
if filter_key in self.data:
79+
requested_filters[filter_key] = filter_value
80+
81+
# build a map of potential {rel: [filter]} pairs
82+
related_data = OrderedDict()
83+
for filter_key in self.data:
84+
if filter_key not in self.filters:
85+
86+
# skip non lookup/related keys
87+
if LOOKUP_SEP not in filter_key:
88+
continue
89+
90+
rel_name, filter_key = filter_key.split(LOOKUP_SEP, 1)
91+
92+
related_data.setdefault(rel_name, [])
93+
related_data[rel_name].append(filter_key)
94+
95+
# walk the related lookup data. If the rel is a RelatedFilter,
96+
# then instantiate its filterset and append its filters
97+
for rel_name, rel_data in related_data.items():
98+
related_filter = self.filters.get(rel_name, None)
99+
100+
# skip non-`RelatedFilter`s
101+
if not isinstance(related_filter, filters.RelatedFilter):
102+
continue
103+
104+
filterset = related_filter.filterset(data=rel_data)
105+
rel_filters = filterset.get_filters()
106+
107+
for filter_key, filter_value in six.iteritems(rel_filters):
108+
rel_filter_key = LOOKUP_SEP.join([rel_name, filter_key])
109+
filter_value.name = LOOKUP_SEP.join([related_filter.name, filter_value.name])
110+
requested_filters[rel_filter_key] = filter_value
111+
112+
return requested_filters
113+
114+
@property
115+
def qs(self):
116+
available_filters = self.filters
117+
requested_filters = self.get_filters()
118+
119+
self.filters = requested_filters
120+
qs = super(FilterSet, self).qs
121+
self.filters = available_filters
122+
123+
return qs
124+
69125
def fix_filter_field(self, f):
70126
"""
71127
Fix the filter field based on the lookup type.
@@ -76,38 +132,3 @@ def fix_filter_field(self, f):
76132
if lookup_type == 'in' and type(f) in [filters.NumberFilter]:
77133
return filters.InSetNumberFilter(name=("%s%sin" % (f.name, LOOKUP_SEP)))
78134
return f
79-
80-
def populate_from_filterset(self, filterset, filter_, name):
81-
"""
82-
Populate `filters` with filters provided on `filterset`.
83-
"""
84-
def _should_skip():
85-
for name, filter_ in six.iteritems(self.filters):
86-
if filter_value == filter_:
87-
return True
88-
# Avoid infinite recursion on recursive relations. If the queryset and
89-
# class are the same, then we assume that we've already added this
90-
# filter previously along the lookup chain, e.g.
91-
# a__b__a <-- the last 'a' there.
92-
if (isinstance(filter_, filters.RelatedFilter) and
93-
isinstance(filter_value, filters.RelatedFilter)):
94-
if filter_value.extra.get('queryset', None) == filter_.extra.get('queryset'):
95-
return True
96-
return False
97-
98-
for (filter_key, filter_value) in filterset.base_filters.items():
99-
if _should_skip():
100-
continue
101-
102-
filter_value = copy(filter_value)
103-
104-
# Guess on the field to join on, if applicable
105-
if not getattr(filter_value, 'parent_relation', None):
106-
filter_value.parent_relation = filterset._meta.model.__name__.lower()
107-
108-
# We use filter_.name -- which is the internal name, to do the actual query
109-
filter_name = filter_value.name
110-
filter_value.name = '%s%s%s' % (filter_.name, LOOKUP_SEP, filter_name)
111-
# and then we use the /given/ name keyword as the actual querystring lookup, and
112-
# the filter's name in the related class (filter_key).
113-
self.filters['%s%s%s' % (name, LOOKUP_SEP, filter_key)] = filter_value

0 commit comments

Comments
 (0)