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`.
This commit is contained in:
Adam Charnock 2015-12-03 17:55:41 +00:00
parent 463c1f98df
commit 70cedc046f
7 changed files with 181 additions and 17 deletions

View File

@ -1,4 +1,3 @@
from django import forms
from django.db import models
from singledispatch import singledispatch

View File

@ -1,8 +1,8 @@
import warnings
import six
from django_filters import FilterSet
from graphene.contrib.django.filterset import setup_filterset
from ...core.exceptions import SkipField
from ...core.fields import Field
from ...core.types import Argument, String
@ -13,6 +13,7 @@ from ...relay.utils import is_node
from .form_converter import convert_form_field
from .resolvers import FilterConnectionResolver
from .utils import get_type_for_model
from .filterset import custom_filterset_factory
class DjangoConnectionField(ConnectionField):
@ -66,23 +67,11 @@ class DjangoModelField(FieldType):
return get_type_for_model(schema, self.model)
def custom_filterset_factory(model, filter_base_class=FilterSet, **meta):
meta.update({
'model': model,
})
meta_class = type(str('Meta'), (object,), meta)
filterset = type(str('%sFilterSet' % model._meta.object_name),
(filter_base_class,), {'Meta': meta_class})
return filterset
class DjangoFilterConnectionField(DjangoConnectionField):
def __init__(self, type, filterset_class=None, resolver=None, on=None,
fields=None, order_by=None, extra_filter_meta=None,
*args, **kwargs):
if not resolver:
resolver = FilterConnectionResolver(type, on, filterset_class)
if not filterset_class:
# If no filter class is specified then create one given the
@ -95,6 +84,11 @@ class DjangoFilterConnectionField(DjangoConnectionField):
if extra_filter_meta:
meta.update(extra_filter_meta)
filterset_class = custom_filterset_factory(**meta)
else:
filterset_class = setup_filterset(filterset_class)
if not resolver:
resolver = FilterConnectionResolver(type, on, filterset_class)
kwargs.setdefault('args', {})
kwargs['args'].update(**self.get_filtering_args(type, filterset_class))

View File

@ -0,0 +1,71 @@
import six
from django.db import models
from django_filters import Filter
from django_filters.filterset import FilterSetMetaclass, FilterSet
from graphql_relay.node.node import from_global_id
from graphene.contrib.django.forms import GlobalIDFormField
class GlobalIDFilter(Filter):
field_class = GlobalIDFormField
def filter(self, qs, value):
gid = from_global_id(value)
return super(GlobalIDFilter, self).filter(qs, gid.id)
GRAPHENE_FILTER_SET_OVERRIDES = {
models.AutoField: {
'filter_class': GlobalIDFilter,
},
models.OneToOneField: {
'filter_class': GlobalIDFilter,
},
models.ForeignKey: {
'filter_class': GlobalIDFilter,
}
# TODO: Support ManyToManyFields. GlobalIDFilterList?
}
class GrapheneFilterSetMetaclass(FilterSetMetaclass):
def __new__(cls, name, bases, attrs):
new_class = super(GrapheneFilterSetMetaclass, cls).__new__(cls, name, bases, attrs)
# Customise the filter_overrides for Graphene
for k, v in GRAPHENE_FILTER_SET_OVERRIDES.items():
new_class.filter_overrides.setdefault(k, v)
return new_class
class GrapheneFilterSet(six.with_metaclass(GrapheneFilterSetMetaclass, FilterSet)):
""" Base class for FilterSets used by Graphene
You shouldn't usually need to use this class. The
DjangoFilterConnectionField will wrap FilterSets with this class as
necessary
"""
pass
def setup_filterset(filterset_class):
""" Wrap a provided filterset in Graphene-specific functionality
"""
return type(
'Graphene{}'.format(filterset_class.__name__),
(six.with_metaclass(GrapheneFilterSetMetaclass, filterset_class),),
{},
)
def custom_filterset_factory(model, filterset_base_class=GrapheneFilterSet,
**meta):
""" Create a filterset for the given model using the provided meta data
"""
meta.update({
'model': model,
})
meta_class = type(str('Meta'), (object,), meta)
filterset = type(str('%sFilterSet' % model._meta.object_name),
(filterset_base_class,), {'Meta': meta_class})
return filterset

View File

@ -10,6 +10,7 @@ except AttributeError:
class UUIDField(object):
pass
@singledispatch
def convert_form_field(field):
raise Exception(

View File

@ -0,0 +1,30 @@
import binascii
from django.core.exceptions import ValidationError
from django.forms import Field, IntegerField, CharField
from django.utils.translation import ugettext_lazy as _
from graphql_relay import from_global_id
class GlobalIDFormField(Field):
default_error_messages = {
'invalid': _('Invalid ID specified.'),
}
def clean(self, value):
if not value and not self.required:
return None
try:
gid = from_global_id(value)
except (UnicodeDecodeError, TypeError, binascii.Error):
raise ValidationError(self.error_messages['invalid'])
try:
IntegerField().clean(gid.id)
CharField().clean(gid.type)
except ValidationError:
raise ValidationError(self.error_messages['invalid'])
return value

View File

@ -1,4 +1,8 @@
import django_filters
from graphene.contrib.django import DjangoFilterConnectionField, DjangoNode
from graphene.contrib.django.filterset import GlobalIDFilter
from graphene.contrib.django.forms import GlobalIDFormField
from graphene.contrib.django.tests.filters import ArticleFilter, PetFilter
from graphene.contrib.django.tests.models import Article, Pet
@ -56,9 +60,9 @@ def test_filter_shortcut_filterset_arguments_list():
def test_filter_shortcut_filterset_arguments_dict():
field = DjangoFilterConnectionField(ArticleNode, fields={
'headline': ['exact', 'icontains'],
'reporter': ['exact'],
})
'headline': ['exact', 'icontains'],
'reporter': ['exact'],
})
assert_arguments(field,
'headline', 'headlineIcontains',
'reporter',
@ -90,3 +94,32 @@ def test_filter_shortcut_filterset_extra_meta():
'ordering': True
})
assert_orderable(field)
def test_global_id_field_implicit():
field = DjangoFilterConnectionField(ArticleNode, fields=['id'])
filterset_class = field.resolver_fn.filterset_class
id_filter = filterset_class.base_filters['id']
assert isinstance(id_filter, GlobalIDFilter)
assert id_filter.field_class == GlobalIDFormField
def test_global_id_field_explicit():
class ArticleIdFilter(django_filters.FilterSet):
class Meta:
model = Article
fields = ['id']
field = DjangoFilterConnectionField(ArticleNode, filterset_class=ArticleIdFilter)
filterset_class = field.resolver_fn.filterset_class
id_filter = filterset_class.base_filters['id']
assert isinstance(id_filter, GlobalIDFilter)
assert id_filter.field_class == GlobalIDFormField
def test_global_id_field_relation():
field = DjangoFilterConnectionField(ArticleNode, fields=['reporter'])
filterset_class = field.resolver_fn.filterset_class
id_filter = filterset_class.base_filters['reporter']
assert isinstance(id_filter, GlobalIDFilter)
assert id_filter.field_class == GlobalIDFormField

View File

@ -0,0 +1,36 @@
from django.core.exceptions import ValidationError
from py.test import raises
from graphene.contrib.django.forms import GlobalIDFormField
# 'TXlUeXBlOjEwMA==' -> 'MyType', 100
# 'TXlUeXBlOmFiYw==' -> 'MyType', 'abc'
def test_global_id_valid():
field = GlobalIDFormField()
field.clean('TXlUeXBlOjEwMA==')
def test_global_id_invalid():
field = GlobalIDFormField()
with raises(ValidationError):
field.clean('badvalue')
def test_global_id_none():
field = GlobalIDFormField()
with raises(ValidationError):
field.clean(None)
def test_global_id_none_optional():
field = GlobalIDFormField(required=False)
field.clean(None)
def test_global_id_bad_int():
field = GlobalIDFormField()
with raises(ValidationError):
field.clean('TXlUeXBlOmFiYw==')