Skip to content

Commit 70cedc0

Browse files
committed
Adding support for filtering by global ID
This is supported for AutoFields, OneToOneFields, and ForeignKey. I have also added the GrapheneFilterSet base class. This provides customsiations needed for Graphene. However, making developers tie their FilterSets to Graphene would not be ideal as it would prevent their use elsewhere. I therefore wrap any FilterSets provided to Graphene with this additional functionality. See `setup_filterset()` for how this is done. Such FilterSets are also created by `custom_filterset_factory()` (in times when a filterset is implicitly required via the `fields` or `order_by` params passed to `DjangoFilterConnectionField`.
1 parent 463c1f9 commit 70cedc0

File tree

7 files changed

+181
-17
lines changed

7 files changed

+181
-17
lines changed

graphene/contrib/django/converter.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from django import forms
21
from django.db import models
32
from singledispatch import singledispatch
43

graphene/contrib/django/fields.py

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

33
import six
4-
from django_filters import FilterSet
54

5+
from graphene.contrib.django.filterset import setup_filterset
66
from ...core.exceptions import SkipField
77
from ...core.fields import Field
88
from ...core.types import Argument, String
@@ -13,6 +13,7 @@
1313
from .form_converter import convert_form_field
1414
from .resolvers import FilterConnectionResolver
1515
from .utils import get_type_for_model
16+
from .filterset import custom_filterset_factory
1617

1718

1819
class DjangoConnectionField(ConnectionField):
@@ -66,23 +67,11 @@ def get_object_type(self, schema):
6667
return get_type_for_model(schema, self.model)
6768

6869

69-
def custom_filterset_factory(model, filter_base_class=FilterSet, **meta):
70-
meta.update({
71-
'model': model,
72-
})
73-
meta_class = type(str('Meta'), (object,), meta)
74-
filterset = type(str('%sFilterSet' % model._meta.object_name),
75-
(filter_base_class,), {'Meta': meta_class})
76-
return filterset
77-
78-
7970
class DjangoFilterConnectionField(DjangoConnectionField):
8071

8172
def __init__(self, type, filterset_class=None, resolver=None, on=None,
8273
fields=None, order_by=None, extra_filter_meta=None,
8374
*args, **kwargs):
84-
if not resolver:
85-
resolver = FilterConnectionResolver(type, on, filterset_class)
8675

8776
if not filterset_class:
8877
# If no filter class is specified then create one given the
@@ -95,6 +84,11 @@ def __init__(self, type, filterset_class=None, resolver=None, on=None,
9584
if extra_filter_meta:
9685
meta.update(extra_filter_meta)
9786
filterset_class = custom_filterset_factory(**meta)
87+
else:
88+
filterset_class = setup_filterset(filterset_class)
89+
90+
if not resolver:
91+
resolver = FilterConnectionResolver(type, on, filterset_class)
9892

9993
kwargs.setdefault('args', {})
10094
kwargs['args'].update(**self.get_filtering_args(type, filterset_class))

graphene/contrib/django/filterset.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import six
2+
from django.db import models
3+
from django_filters import Filter
4+
from django_filters.filterset import FilterSetMetaclass, FilterSet
5+
from graphql_relay.node.node import from_global_id
6+
7+
from graphene.contrib.django.forms import GlobalIDFormField
8+
9+
10+
class GlobalIDFilter(Filter):
11+
field_class = GlobalIDFormField
12+
13+
def filter(self, qs, value):
14+
gid = from_global_id(value)
15+
return super(GlobalIDFilter, self).filter(qs, gid.id)
16+
17+
18+
GRAPHENE_FILTER_SET_OVERRIDES = {
19+
models.AutoField: {
20+
'filter_class': GlobalIDFilter,
21+
},
22+
models.OneToOneField: {
23+
'filter_class': GlobalIDFilter,
24+
},
25+
models.ForeignKey: {
26+
'filter_class': GlobalIDFilter,
27+
}
28+
# TODO: Support ManyToManyFields. GlobalIDFilterList?
29+
}
30+
31+
32+
class GrapheneFilterSetMetaclass(FilterSetMetaclass):
33+
def __new__(cls, name, bases, attrs):
34+
new_class = super(GrapheneFilterSetMetaclass, cls).__new__(cls, name, bases, attrs)
35+
# Customise the filter_overrides for Graphene
36+
for k, v in GRAPHENE_FILTER_SET_OVERRIDES.items():
37+
new_class.filter_overrides.setdefault(k, v)
38+
return new_class
39+
40+
41+
class GrapheneFilterSet(six.with_metaclass(GrapheneFilterSetMetaclass, FilterSet)):
42+
""" Base class for FilterSets used by Graphene
43+
44+
You shouldn't usually need to use this class. The
45+
DjangoFilterConnectionField will wrap FilterSets with this class as
46+
necessary
47+
"""
48+
pass
49+
50+
51+
def setup_filterset(filterset_class):
52+
""" Wrap a provided filterset in Graphene-specific functionality
53+
"""
54+
return type(
55+
'Graphene{}'.format(filterset_class.__name__),
56+
(six.with_metaclass(GrapheneFilterSetMetaclass, filterset_class),),
57+
{},
58+
)
59+
60+
61+
def custom_filterset_factory(model, filterset_base_class=GrapheneFilterSet,
62+
**meta):
63+
""" Create a filterset for the given model using the provided meta data
64+
"""
65+
meta.update({
66+
'model': model,
67+
})
68+
meta_class = type(str('Meta'), (object,), meta)
69+
filterset = type(str('%sFilterSet' % model._meta.object_name),
70+
(filterset_base_class,), {'Meta': meta_class})
71+
return filterset

graphene/contrib/django/form_converter.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
class UUIDField(object):
1111
pass
1212

13+
1314
@singledispatch
1415
def convert_form_field(field):
1516
raise Exception(

graphene/contrib/django/forms.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import binascii
2+
3+
from django.core.exceptions import ValidationError
4+
from django.forms import Field, IntegerField, CharField
5+
from django.utils.translation import ugettext_lazy as _
6+
7+
from graphql_relay import from_global_id
8+
9+
10+
class GlobalIDFormField(Field):
11+
default_error_messages = {
12+
'invalid': _('Invalid ID specified.'),
13+
}
14+
15+
def clean(self, value):
16+
if not value and not self.required:
17+
return None
18+
19+
try:
20+
gid = from_global_id(value)
21+
except (UnicodeDecodeError, TypeError, binascii.Error):
22+
raise ValidationError(self.error_messages['invalid'])
23+
24+
try:
25+
IntegerField().clean(gid.id)
26+
CharField().clean(gid.type)
27+
except ValidationError:
28+
raise ValidationError(self.error_messages['invalid'])
29+
30+
return value

graphene/contrib/django/tests/test_fields.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1+
import django_filters
2+
13
from graphene.contrib.django import DjangoFilterConnectionField, DjangoNode
4+
from graphene.contrib.django.filterset import GlobalIDFilter
5+
from graphene.contrib.django.forms import GlobalIDFormField
26
from graphene.contrib.django.tests.filters import ArticleFilter, PetFilter
37
from graphene.contrib.django.tests.models import Article, Pet
48

@@ -56,9 +60,9 @@ def test_filter_shortcut_filterset_arguments_list():
5660

5761
def test_filter_shortcut_filterset_arguments_dict():
5862
field = DjangoFilterConnectionField(ArticleNode, fields={
59-
'headline': ['exact', 'icontains'],
60-
'reporter': ['exact'],
61-
})
63+
'headline': ['exact', 'icontains'],
64+
'reporter': ['exact'],
65+
})
6266
assert_arguments(field,
6367
'headline', 'headlineIcontains',
6468
'reporter',
@@ -90,3 +94,32 @@ def test_filter_shortcut_filterset_extra_meta():
9094
'ordering': True
9195
})
9296
assert_orderable(field)
97+
98+
99+
def test_global_id_field_implicit():
100+
field = DjangoFilterConnectionField(ArticleNode, fields=['id'])
101+
filterset_class = field.resolver_fn.filterset_class
102+
id_filter = filterset_class.base_filters['id']
103+
assert isinstance(id_filter, GlobalIDFilter)
104+
assert id_filter.field_class == GlobalIDFormField
105+
106+
107+
def test_global_id_field_explicit():
108+
class ArticleIdFilter(django_filters.FilterSet):
109+
class Meta:
110+
model = Article
111+
fields = ['id']
112+
113+
field = DjangoFilterConnectionField(ArticleNode, filterset_class=ArticleIdFilter)
114+
filterset_class = field.resolver_fn.filterset_class
115+
id_filter = filterset_class.base_filters['id']
116+
assert isinstance(id_filter, GlobalIDFilter)
117+
assert id_filter.field_class == GlobalIDFormField
118+
119+
120+
def test_global_id_field_relation():
121+
field = DjangoFilterConnectionField(ArticleNode, fields=['reporter'])
122+
filterset_class = field.resolver_fn.filterset_class
123+
id_filter = filterset_class.base_filters['reporter']
124+
assert isinstance(id_filter, GlobalIDFilter)
125+
assert id_filter.field_class == GlobalIDFormField
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from django.core.exceptions import ValidationError
2+
from py.test import raises
3+
4+
from graphene.contrib.django.forms import GlobalIDFormField
5+
6+
7+
# 'TXlUeXBlOjEwMA==' -> 'MyType', 100
8+
# 'TXlUeXBlOmFiYw==' -> 'MyType', 'abc'
9+
10+
11+
def test_global_id_valid():
12+
field = GlobalIDFormField()
13+
field.clean('TXlUeXBlOjEwMA==')
14+
15+
16+
def test_global_id_invalid():
17+
field = GlobalIDFormField()
18+
with raises(ValidationError):
19+
field.clean('badvalue')
20+
21+
22+
def test_global_id_none():
23+
field = GlobalIDFormField()
24+
with raises(ValidationError):
25+
field.clean(None)
26+
27+
28+
def test_global_id_none_optional():
29+
field = GlobalIDFormField(required=False)
30+
field.clean(None)
31+
32+
33+
def test_global_id_bad_int():
34+
field = GlobalIDFormField()
35+
with raises(ValidationError):
36+
field.clean('TXlUeXBlOmFiYw==')

0 commit comments

Comments
 (0)