Skip to content

Commit 64ec0ca

Browse files
committed
Adding support for filtering on to-many fields
1 parent ed8eac9 commit 64ec0ca

File tree

6 files changed

+116
-24
lines changed

6 files changed

+116
-24
lines changed
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from .fields import DjangoFilterConnectionField
2-
from .filterset import GrapheneFilterSet, GlobalIDFilter
2+
from .filterset import GrapheneFilterSet, GlobalIDFilter, GlobalIDMultipleChoiceFilter
33
from .resolvers import FilterConnectionResolver
44

55
__all__ = ['DjangoFilterConnectionField', 'GrapheneFilterSet',
6-
'GlobalIDFilter', 'FilterConnectionResolver']
6+
'GlobalIDFilter', 'GlobalIDMultipleChoiceFilter',
7+
'FilterConnectionResolver']

graphene/contrib/django/filter/filterset.py

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import six
22
from django.conf import settings
33
from django.db import models
4-
from django_filters import Filter
4+
from django.utils.text import capfirst
5+
from django_filters import Filter, MultipleChoiceFilter
56
from django_filters.filterset import FilterSetMetaclass, FilterSet
67
from graphql_relay.node.node import from_global_id
78

8-
from graphene.contrib.django.forms import GlobalIDFormField
9+
from graphene.contrib.django.forms import GlobalIDFormField, GlobalIDMultipleChoiceField
910

1011

1112
class GlobalIDFilter(Filter):
@@ -16,6 +17,14 @@ def filter(self, qs, value):
1617
return super(GlobalIDFilter, self).filter(qs, gid.id)
1718

1819

20+
class GlobalIDMultipleChoiceFilter(MultipleChoiceFilter):
21+
field_class = GlobalIDMultipleChoiceField
22+
23+
def filter(self, qs, value):
24+
gids = [from_global_id(v).id for v in value]
25+
return super(GlobalIDMultipleChoiceFilter, self).filter(qs, gids)
26+
27+
1928
ORDER_BY_FIELD = getattr(settings, 'GRAPHENE_ORDER_BY_FIELD', 'order')
2029

2130

@@ -28,8 +37,10 @@ def filter(self, qs, value):
2837
},
2938
models.ForeignKey: {
3039
'filter_class': GlobalIDFilter,
40+
},
41+
models.ManyToManyField: {
42+
'filter_class': GlobalIDMultipleChoiceFilter,
3143
}
32-
# TODO: Support ManyToManyFields. GlobalIDFilterList?
3344
}
3445

3546

@@ -42,25 +53,39 @@ def __new__(cls, name, bases, attrs):
4253
return new_class
4354

4455

45-
class GrapheneFilterSet(six.with_metaclass(GrapheneFilterSetMetaclass, FilterSet)):
56+
class GrapheneFilterSetMixin(object):
57+
order_by_field = ORDER_BY_FIELD
58+
59+
@classmethod
60+
def filter_for_reverse_field(cls, f, name):
61+
rel = f.field.rel
62+
default = {
63+
'name': name,
64+
'label': capfirst(rel.related_name)
65+
}
66+
if rel.multiple:
67+
return GlobalIDMultipleChoiceFilter(**default)
68+
else:
69+
return GlobalIDFilter(**default)
70+
71+
72+
class GrapheneFilterSet(six.with_metaclass(GrapheneFilterSetMetaclass, GrapheneFilterSetMixin, FilterSet)):
4673
""" Base class for FilterSets used by Graphene
4774
4875
You shouldn't usually need to use this class. The
4976
DjangoFilterConnectionField will wrap FilterSets with this class as
5077
necessary
5178
"""
52-
order_by_field = ORDER_BY_FIELD
79+
pass
5380

5481

5582
def setup_filterset(filterset_class):
5683
""" Wrap a provided filterset in Graphene-specific functionality
5784
"""
5885
return type(
5986
'Graphene{}'.format(filterset_class.__name__),
60-
(six.with_metaclass(GrapheneFilterSetMetaclass, filterset_class),),
61-
{
62-
'order_by_field': ORDER_BY_FIELD
63-
},
87+
(six.with_metaclass(GrapheneFilterSetMetaclass, GrapheneFilterSetMixin, filterset_class),),
88+
{},
6489
)
6590

6691

graphene/contrib/django/form_converter.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from singledispatch import singledispatch
44

55
from graphene import String, Int, Boolean, Float, ID
6+
from graphene.contrib.django.forms import GlobalIDFormField, GlobalIDMultipleChoiceField
7+
from graphene.core.types.definitions import List
68

79
try:
810
UUIDField = forms.UUIDField
@@ -57,13 +59,12 @@ def convert_form_field_to_float(field):
5759

5860

5961
@convert_form_field.register(forms.ModelMultipleChoiceField)
62+
@convert_form_field.register(GlobalIDMultipleChoiceField)
6063
def convert_form_field_to_list_or_connection(field):
61-
# TODO: Consider how filtering on a many-to-many should work
62-
from .fields import DjangoModelField, ConnectionOrListField
63-
model_field = DjangoModelField(field.queryset.model)
64-
return ConnectionOrListField(model_field)
64+
return List(ID())
6565

6666

6767
@convert_form_field.register(forms.ModelChoiceField)
68+
@convert_form_field.register(GlobalIDFormField)
6869
def convert_form_field_to_djangomodel(field):
6970
return ID()

graphene/contrib/django/forms.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import binascii
22

33
from django.core.exceptions import ValidationError
4-
from django.forms import Field, IntegerField, CharField
4+
from django.forms import Field, IntegerField, CharField, MultipleChoiceField
55
from django.utils.translation import ugettext_lazy as _
66

77
from graphql_relay import from_global_id
@@ -28,3 +28,15 @@ def clean(self, value):
2828
raise ValidationError(self.error_messages['invalid'])
2929

3030
return value
31+
32+
33+
class GlobalIDMultipleChoiceField(MultipleChoiceField):
34+
default_error_messages = {
35+
'invalid_choice': _('One of the specified IDs was invalid (%(value)s).'),
36+
'invalid_list': _('Enter a list of values.'),
37+
}
38+
39+
def valid_value(self, value):
40+
# Clean will raise a validation error if there is a problem
41+
GlobalIDFormField().clean(value)
42+
return True

graphene/contrib/django/tests/filter/test_fields.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,25 @@
55
except ImportError:
66
pytestmark = pytest.mark.skipif(True, reason='django_filters not installed')
77
else:
8-
from graphene.contrib.django.filter import GlobalIDFilter, DjangoFilterConnectionField
8+
from graphene.contrib.django.filter import (GlobalIDFilter, DjangoFilterConnectionField,
9+
GlobalIDMultipleChoiceFilter)
910
from graphene.contrib.django.tests.filter.filters import ArticleFilter, PetFilter
1011

1112
from graphene.contrib.django import DjangoNode
12-
from graphene.contrib.django.forms import GlobalIDFormField
13-
from graphene.contrib.django.tests.models import Article, Pet
13+
from graphene.contrib.django.forms import GlobalIDFormField, GlobalIDMultipleChoiceField
14+
from graphene.contrib.django.tests.models import Article, Pet, Reporter
1415

1516

1617
class ArticleNode(DjangoNode):
1718
class Meta:
1819
model = Article
1920

2021

22+
class ReporterNode(DjangoNode):
23+
class Meta:
24+
model = Reporter
25+
26+
2127
class PetNode(DjangoNode):
2228
class Meta:
2329
model = Pet
@@ -129,3 +135,51 @@ def test_global_id_field_relation():
129135
id_filter = filterset_class.base_filters['reporter']
130136
assert isinstance(id_filter, GlobalIDFilter)
131137
assert id_filter.field_class == GlobalIDFormField
138+
139+
140+
def test_global_id_multiple_field_implicit():
141+
field = DjangoFilterConnectionField(ReporterNode, fields=['pets'])
142+
filterset_class = field.resolver_fn.get_filterset_class()
143+
multiple_filter = filterset_class.base_filters['pets']
144+
assert isinstance(multiple_filter, GlobalIDMultipleChoiceFilter)
145+
assert multiple_filter.field_class == GlobalIDMultipleChoiceField
146+
147+
148+
def test_global_id_multiple_field_explicit():
149+
class ReporterPetsFilter(django_filters.FilterSet):
150+
class Meta:
151+
model = Reporter
152+
fields = ['pets']
153+
154+
field = DjangoFilterConnectionField(ReporterNode, filterset_class=ReporterPetsFilter)
155+
filterset_class = field.resolver_fn.get_filterset_class()
156+
multiple_filter = filterset_class.base_filters['pets']
157+
assert isinstance(multiple_filter, GlobalIDMultipleChoiceFilter)
158+
assert multiple_filter.field_class == GlobalIDMultipleChoiceField
159+
160+
161+
@pytest.mark.skipif(True, reason="Trying to test GrapheneFilterSetMixin.filter_for_reverse_field"
162+
"but django has not loaded the models, so the test fails as "
163+
"reverse relations are not ready yet")
164+
def test_global_id_multiple_field_implicit_reverse():
165+
field = DjangoFilterConnectionField(ReporterNode, fields=['articles'])
166+
filterset_class = field.resolver_fn.get_filterset_class()
167+
multiple_filter = filterset_class.base_filters['articles']
168+
assert isinstance(multiple_filter, GlobalIDMultipleChoiceFilter)
169+
assert multiple_filter.field_class == GlobalIDMultipleChoiceField
170+
171+
172+
@pytest.mark.skipif(True, reason="Trying to test GrapheneFilterSetMixin.filter_for_reverse_field"
173+
"but django has not loaded the models, so the test fails as "
174+
"reverse relations are not ready yet")
175+
def test_global_id_multiple_field_explicit_reverse():
176+
class ReporterPetsFilter(django_filters.FilterSet):
177+
class Meta:
178+
model = Reporter
179+
fields = ['articles']
180+
181+
field = DjangoFilterConnectionField(ReporterNode, filterset_class=ReporterPetsFilter)
182+
filterset_class = field.resolver_fn.get_filterset_class()
183+
multiple_filter = filterset_class.base_filters['articles']
184+
assert isinstance(multiple_filter, GlobalIDMultipleChoiceFilter)
185+
assert multiple_filter.field_class == GlobalIDMultipleChoiceField

graphene/contrib/django/tests/test_form_converter.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from django import forms
2+
from graphene.core.types import List, ID
23
from py.test import raises
34

45
import graphene
56
from graphene.contrib.django.form_converter import convert_form_field
6-
from graphene.contrib.django.fields import (ConnectionOrListField,
7-
DjangoModelField)
7+
88

99
from .models import Reporter
1010

@@ -94,9 +94,8 @@ def test_should_decimal_convert_float():
9494
def test_should_multiple_choice_convert_connectionorlist():
9595
field = forms.ModelMultipleChoiceField(Reporter.objects.all())
9696
graphene_type = convert_form_field(field)
97-
assert isinstance(graphene_type, ConnectionOrListField)
98-
assert isinstance(graphene_type.type, DjangoModelField)
99-
assert graphene_type.type.model == Reporter
97+
assert isinstance(graphene_type, List)
98+
assert isinstance(graphene_type.of_type, ID)
10099

101100

102101
def test_should_manytoone_convert_connectionorlist():

0 commit comments

Comments
 (0)