Skip to content

Commit 604327f

Browse files
authored
Fix AutoFilter accidentally munging declared filter names (philipn#304)
1 parent bed0cb9 commit 604327f

File tree

5 files changed

+276
-67
lines changed

5 files changed

+276
-67
lines changed

rest_framework_filters/filters.py

Lines changed: 88 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,62 @@
22

33
from django.utils.module_loading import import_string
44
from django_filters.rest_framework.filters import * # noqa
5-
from django_filters.rest_framework.filters import Filter, ModelChoiceFilter
5+
from django_filters.rest_framework.filters import (
6+
ModelChoiceFilter, ModelMultipleChoiceFilter,
7+
)
68

79
ALL_LOOKUPS = '__all__'
810

911

10-
class AutoFilter(Filter):
11-
"""
12-
Declarative alternative to using the `Meta.fields` dictionary syntax. These
13-
fields are processed by the metaclass and resolved into per-lookup filters.
12+
class AutoFilter:
13+
"""A placeholder class that enables generating multiple per-lookup filters.
14+
15+
This is a declarative alternative to the ``Meta.fields`` dict syntax, and
16+
the below are functionally equivalent:
17+
18+
class PersonFilter(filters.FilterSet):
19+
name = AutoFilter(lookups=['exact', 'contains'])
20+
21+
class Meta:
22+
model = Person
23+
fields = []
24+
25+
class PersonFilter(filters.FilterSet):
26+
class Meta:
27+
model = Person
28+
fields = {'name': ['exact', 'contains']}
29+
30+
Due to its declarative nature, an ``AutoFilter`` allows for paramater name
31+
aliasing for its generated filters. e.g.,
32+
33+
class BlogFilter(filters.FilterSet):
34+
title = AutoFilter(field_name='name', lookups=['contains'])
1435
15-
`AutoFilter`s benefit from their declarative nature in that it is possible
16-
to change the parameter name of the generated filters. This is not possible
17-
with the `Meta.fields` syntax.
36+
The above generates a ``title__contains`` filter for the ``name`` field
37+
intstead of ``name__contains``. This is not possible with ``Meta.fields``,
38+
since the key must match the model field name.
39+
40+
Note that ``AutoFilter`` is a filter-like placeholder and is not present in
41+
the ``FilterSet.filters``. In the above example, ``title`` would not be
42+
filterable. However, an ``AutoFilter`` is typically replaced by a generated
43+
``exact`` filter of the same name, which enables filtering by that param.
1844
"""
45+
creation_counter = 0
1946

20-
def __init__(self, *args, lookups=None, **kwargs):
21-
super().__init__(*args, **kwargs)
47+
def __init__(self, field_name=None, *, lookups=None):
48+
self.field_name = field_name
2249
self.lookups = lookups or []
2350

51+
self.creation_counter = AutoFilter.creation_counter
52+
AutoFilter.creation_counter += 1
2453

25-
class RelatedFilter(AutoFilter, ModelChoiceFilter):
26-
"""
27-
A `ModelChoiceFilter` that defines a relationship to another `FilterSet`.
28-
This related filterset is processed by the filter's `parent` instance, and
29-
"""
3054

31-
def __init__(self, filterset, *args, **kwargs):
55+
class BaseRelatedFilter:
56+
57+
def __init__(self, filterset, *args, lookups=None, **kwargs):
3258
super().__init__(*args, **kwargs)
3359
self.filterset = filterset
60+
self.lookups = lookups or []
3461

3562
def bind(self, bind_cls):
3663
"""
@@ -63,13 +90,56 @@ def fset(self, value):
6390
filterset = property(**filterset())
6491

6592
def get_queryset(self, request):
66-
queryset = super(RelatedFilter, self).get_queryset(request)
93+
queryset = super(BaseRelatedFilter, self).get_queryset(request)
6794
assert queryset is not None, \
68-
"Expected `.get_queryset()` for related filter '%s.%s' to return a `QuerySet`, but got `None`." \
95+
"Expected `.get_queryset()` for related filter '%s.%s' to " \
96+
"return a `QuerySet`, but got `None`." \
6997
% (self.parent.__class__.__name__, self.field_name)
7098
return queryset
7199

72100

101+
class RelatedFilter(BaseRelatedFilter, ModelChoiceFilter):
102+
"""A ``ModelChoiceFilter`` that enables filtering across relationships.
103+
104+
Take the following example:
105+
106+
class ManagerFilter(filters.FilterSet):
107+
class Meta:
108+
model = Manager
109+
fields = {'name': ['exact', 'in', 'startswith']}
110+
111+
class DepartmentFilter(filters.FilterSet):
112+
manager = RelatedFilter(ManagerFilter, queryset=managers)
113+
114+
class Meta:
115+
model = Department
116+
fields = {'name': ['exact', 'in', 'startswith']}
117+
118+
In the above, the ``DepartmentFilter`` can traverse the ``manager``
119+
relationship with the ``__`` lookup seperator, accessing the filters of the
120+
``ManagerFilter`` class. For example, the above would enable calls like:
121+
122+
/api/managers?name=john%20doe
123+
/api/departments?manager__name=john%20doe
124+
125+
Related filters function similarly to auto filters in that they can generate
126+
per-lookup filters. However, unlike auto filters, related filters are
127+
functional and not just placeholders. They will not be replaced by a
128+
generated ``exact`` filter.
129+
130+
Attributes:
131+
filterset: The ``FilterSet`` that is traversed by this relationship.
132+
May be a class, an absolute import path, or the name of a class
133+
located in the same module as the origin filterset.
134+
lookups: A list of lookups to generate per-lookup filters for. This
135+
functions similarly to the ``AutoFilter.lookups`` argument.
136+
"""
137+
138+
139+
class RelatedMultipleFilter(BaseRelatedFilter, ModelMultipleChoiceFilter):
140+
"""A ``ModelMultipleChoiceFilter`` variant of ``RelatedFilter``."""
141+
142+
73143
class AllLookupsFilter(AutoFilter):
74144
def __init__(self, *args, **kwargs):
75145
super().__init__(*args, lookups=ALL_LOOKUPS, **kwargs)

rest_framework_filters/filterset.py

Lines changed: 77 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -20,58 +20,99 @@ def related(filterset, filter_name):
2020

2121
class FilterSetMetaclass(filterset.FilterSetMetaclass):
2222
def __new__(cls, name, bases, attrs):
23-
new_class = super(FilterSetMetaclass, cls).__new__(cls, name, bases, attrs)
23+
attrs['auto_filters'] = cls.get_auto_filters(bases, attrs)
2424

25-
new_class.auto_filters = [
26-
name for name, f in new_class.declared_filters.items()
27-
if isinstance(f, filters.AutoFilter)]
28-
new_class.related_filters = [
29-
name for name, f in new_class.declared_filters.items()
30-
if isinstance(f, filters.RelatedFilter)]
25+
new_class = super().__new__(cls, name, bases, attrs)
3126

32-
# see: :meth:`rest_framework_filters.filters.RelatedFilter.bind`
33-
for name in new_class.related_filters:
34-
new_class.declared_filters[name].bind(new_class)
27+
new_class.related_filters = OrderedDict([
28+
(name, f) for name, f in new_class.declared_filters.items()
29+
if isinstance(f, filters.BaseRelatedFilter)])
3530

36-
# If model is defined, process auto filters
31+
# See: :meth:`rest_framework_filters.filters.RelatedFilter.bind`
32+
for name, f in new_class.related_filters.items():
33+
f.bind(new_class)
34+
35+
# Only expand when model is defined. Model may be undefined for mixins.
3736
if new_class._meta.model is not None:
38-
cls.expand_auto_filters(new_class)
37+
for name, f in new_class.auto_filters.items():
38+
expanded = cls.expand_auto_filter(new_class, name, f)
39+
new_class.base_filters.update(expanded)
40+
41+
for name, f in new_class.related_filters.items():
42+
expanded = cls.expand_auto_filter(new_class, name, f)
43+
new_class.base_filters.update(expanded)
3944

4045
return new_class
4146

4247
@classmethod
43-
def expand_auto_filters(cls, new_class):
44-
"""
45-
Resolve `AutoFilter`s into their per-lookup filters. `AutoFilter`s are
46-
a declarative alternative to the `Meta.fields` dictionary syntax, and
47-
use the same machinery internally.
48+
def get_auto_filters(cls, bases, attrs):
49+
# Auto filters are specially handled since they aren't an actual filter
50+
# subclass and aren't handled by the declared filter machinery. Note
51+
# that this is a nearly identical copy of `get_declared_filters`.
52+
auto_filters = [
53+
(filter_name, attrs.pop(filter_name))
54+
for filter_name, obj in list(attrs.items())
55+
if isinstance(obj, filters.AutoFilter)
56+
]
57+
58+
# Default the `filter.field_name` to the attribute name on the filterset
59+
for filter_name, f in auto_filters:
60+
if getattr(f, 'field_name', None) is None:
61+
f.field_name = filter_name
62+
63+
auto_filters.sort(key=lambda x: x[1].creation_counter)
64+
65+
# merge auto filters from base classes
66+
for base in reversed(bases):
67+
if hasattr(base, 'auto_filters'):
68+
auto_filters = [
69+
(name, f) for name, f
70+
in base.auto_filters.items()
71+
if name not in attrs
72+
] + auto_filters
73+
74+
return OrderedDict(auto_filters)
75+
76+
@classmethod
77+
def expand_auto_filter(cls, new_class, filter_name, f):
78+
"""Resolve an ``AutoFilter`` into its per-lookup filters.
79+
80+
This method name is slightly inaccurate since it handles both
81+
:class:`rest_framework_filters.filters.AutoFilter` and
82+
:class:`rest_framework_filters.filters.BaseRelatedFilter`, as well as
83+
their subclasses, which all support per-lookup filter generation.
84+
85+
Args:
86+
new_class: The ``FilterSet`` class to generate filters for.
87+
filter_name: The attribute name of the filter on the ``FilterSet``.
88+
f: The filter instance.
89+
90+
Returns:
91+
A named map of generated filter objects.
4892
"""
49-
# get reference to opts/declared filters
50-
orig_meta, orig_declared = new_class._meta, new_class.declared_filters
93+
expanded = OrderedDict()
5194

52-
# override opts/declared filters w/ copies
95+
# get reference to opts/declared filters so originals aren't modified
96+
orig_meta, orig_declared = new_class._meta, new_class.declared_filters
5397
new_class._meta = copy.deepcopy(new_class._meta)
54-
new_class.declared_filters = new_class.declared_filters.copy()
55-
56-
for name in new_class.auto_filters:
57-
f = new_class.declared_filters[name]
98+
new_class.declared_filters = {}
5899

59-
# Remove auto filters from declared_filters so that they *are* overwritten
60-
# RelatedFilter is an exception, and should *not* be overwritten
61-
if not isinstance(f, filters.RelatedFilter):
62-
del new_class.declared_filters[name]
100+
# Use meta.fields to generate auto filters
101+
new_class._meta.fields = {f.field_name: f.lookups or []}
102+
for gen_name, gen_f in new_class.get_filters().items():
103+
# get_filters() generates param names from the model field name, so
104+
# replace the field name with the param name from the filerset
105+
gen_name = gen_name.replace(f.field_name, filter_name, 1)
63106

64-
# Use meta.fields to generate auto filters
65-
new_class._meta.fields = {f.field_name: f.lookups or []}
66-
for gen_name, gen_f in new_class.get_filters().items():
67-
# get_filters() generates param names from the model field name
68-
# Replace the field name with the parameter name from the filerset
69-
gen_name = gen_name.replace(f.field_name, name, 1)
70-
new_class.base_filters[gen_name] = gen_f
107+
# do not overwrite declared filters
108+
if gen_name not in orig_declared:
109+
expanded[gen_name] = gen_f
71110

72111
# restore reference to opts/declared filters
73112
new_class._meta, new_class.declared_filters = orig_meta, orig_declared
74113

114+
return expanded
115+
75116

76117
class SubsetDisabledMixin:
77118
"""
@@ -95,6 +136,7 @@ def __init__(self, data=None, queryset=None, *, relationship=None, **kwargs):
95136

96137
@classmethod
97138
def get_fields(cls):
139+
# Extend the 'Meta.fields' dict syntax to allow '__all__' field lookups.
98140
fields = super(FilterSet, cls).get_fields()
99141

100142
for name, lookups in fields.items():

tests/perf/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ relationships via `RelatedFilter`. The intent is to provide some assurance that:
1313
The performance tests have been isolated from the main test suite so that they can be
1414
ran independently. Simply run:
1515

16+
$ tox -e performance
17+
18+
Or more directly:
19+
1620
$ python manage.py test tests.perf
1721

1822

tests/test_filtering.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,19 @@ class Meta:
6363
model = Note
6464
fields = []
6565

66+
class Subclass(Actual):
67+
class Meta:
68+
model = Note
69+
fields = []
70+
6671
GET = {'title__contains': 'Hello'}
6772
f = Actual(GET, queryset=Note.objects.all())
6873
self.assertEqual(len(list(f.qs)), 2)
6974

75+
GET = {'title__contains': 'Hello'}
76+
f = Subclass(GET, queryset=Note.objects.all())
77+
self.assertEqual(len(list(f.qs)), 2)
78+
7079

7180
class RelatedFilterTests(TestCase):
7281

@@ -305,7 +314,7 @@ def test_nonexistent_related_field(self):
305314

306315
def test_related_filters_inheritance(self):
307316
class ChildFilter(PostFilter):
308-
foo = filters.RelatedFilter(PostFilter)
317+
foo = filters.RelatedFilter(NoteFilter, field_name='note')
309318

310319
self.assertEqual(['author', 'note', 'tags'], list(PostFilter.related_filters))
311320
self.assertEqual(['author', 'note', 'tags', 'foo'], list(ChildFilter.related_filters))

0 commit comments

Comments
 (0)