Skip to content

Commit

Permalink
Add typed filters (v3) (#1148)
Browse files Browse the repository at this point in the history
* feat: add TypedFilter which allow to explicitly give a filter input GraphQL type

* Fix doc typo

Co-authored-by: Thomas Leonard <thomas@loftorbital.com>
  • Loading branch information
tcleonard and Thomas Leonard authored Mar 31, 2021
1 parent 3cf940d commit 80ea51f
Show file tree
Hide file tree
Showing 11 changed files with 413 additions and 154 deletions.
42 changes: 41 additions & 1 deletion docs/filtering.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ You will need to install it manually, which can be done as follows:
# You'll need to install django-filter
pip install django-filter>=2
After installing ``django-filter`` you'll need to add the application in the ``settings.py`` file:

.. code:: python
Expand Down Expand Up @@ -290,6 +290,7 @@ Graphene provides an easy to implement filters on `ArrayField` as they are not n
class Meta:
model = Event
interfaces = (Node,)
fields = "__all__"
filterset_class = EventFilterSet
with this set up, you can now filter events by tags:
Expand All @@ -301,3 +302,42 @@ with this set up, you can now filter events by tags:
name
}
}
`TypedFilter`
-------------

Sometimes the automatic detection of the filter input type is not satisfactory for what you are trying to achieve.
You can then explicitly specify the input type you want for your filter by using a `TypedFilter`:

.. code:: python
from django.db import models
from django_filters import FilterSet, OrderingFilter
import graphene
from graphene_django.filter import TypedFilter
class Event(models.Model):
name = models.CharField(max_length=50)
class EventFilterSet(FilterSet):
class Meta:
model = Event
fields = {
"name": ["exact", "contains"],
}
only_first = TypedFilter(input_type=graphene.Boolean, method="only_first_filter")
def only_first_filter(self, queryset, _name, value):
if value:
return queryset[:1]
else:
return queryset
class EventType(DjangoObjectType):
class Meta:
model = Event
interfaces = (Node,)
fields = "__all__"
filterset_class = EventFilterSet
2 changes: 2 additions & 0 deletions graphene_django/filter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
GlobalIDMultipleChoiceFilter,
ListFilter,
RangeFilter,
TypedFilter,
)

__all__ = [
Expand All @@ -24,4 +25,5 @@
"ArrayFilter",
"ListFilter",
"RangeFilter",
"TypedFilter",
]
101 changes: 0 additions & 101 deletions graphene_django/filter/filters.py

This file was deleted.

25 changes: 25 additions & 0 deletions graphene_django/filter/filters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import warnings
from ...utils import DJANGO_FILTER_INSTALLED

if not DJANGO_FILTER_INSTALLED:
warnings.warn(
"Use of django filtering requires the django-filter package "
"be installed. You can do so using `pip install django-filter`",
ImportWarning,
)
else:
from .array_filter import ArrayFilter
from .global_id_filter import GlobalIDFilter, GlobalIDMultipleChoiceFilter
from .list_filter import ListFilter
from .range_filter import RangeFilter
from .typed_filter import TypedFilter

__all__ = [
"DjangoFilterConnectionField",
"GlobalIDFilter",
"GlobalIDMultipleChoiceFilter",
"ArrayFilter",
"ListFilter",
"RangeFilter",
"TypedFilter",
]
27 changes: 27 additions & 0 deletions graphene_django/filter/filters/array_filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from django_filters.constants import EMPTY_VALUES

from .typed_filter import TypedFilter


class ArrayFilter(TypedFilter):
"""
Filter made for PostgreSQL ArrayField.
"""

def filter(self, qs, value):
"""
Override the default filter class to check first whether the list is
empty or not.
This needs to be done as in this case we expect to get the filter applied with
an empty list since it's a valid value but django_filter consider an empty list
to be an empty input value (see `EMPTY_VALUES`) meaning that
the filter does not need to be applied (hence returning the original
queryset).
"""
if value in EMPTY_VALUES and value != []:
return qs
if self.distinct:
qs = qs.distinct()
lookup = "%s__%s" % (self.field_name, self.lookup_expr)
qs = self.get_method(qs)(**{lookup: value})
return qs
28 changes: 28 additions & 0 deletions graphene_django/filter/filters/global_id_filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from django_filters import Filter, MultipleChoiceFilter

from graphql_relay.node.node import from_global_id

from ...forms import GlobalIDFormField, GlobalIDMultipleChoiceField


class GlobalIDFilter(Filter):
"""
Filter for Relay global ID.
"""

field_class = GlobalIDFormField

def filter(self, qs, value):
""" Convert the filter value to a primary key before filtering """
_id = None
if value is not None:
_, _id = from_global_id(value)
return super(GlobalIDFilter, self).filter(qs, _id)


class GlobalIDMultipleChoiceFilter(MultipleChoiceFilter):
field_class = GlobalIDMultipleChoiceField

def filter(self, qs, value):
gids = [from_global_id(v)[1] for v in value]
return super(GlobalIDMultipleChoiceFilter, self).filter(qs, gids)
26 changes: 26 additions & 0 deletions graphene_django/filter/filters/list_filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from .typed_filter import TypedFilter


class ListFilter(TypedFilter):
"""
Filter that takes a list of value as input.
It is for example used for `__in` filters.
"""

def filter(self, qs, value):
"""
Override the default filter class to check first whether the list is
empty or not.
This needs to be done as in this case we expect to get an empty output
(if not an exclude filter) but django_filter consider an empty list
to be an empty input value (see `EMPTY_VALUES`) meaning that
the filter does not need to be applied (hence returning the original
queryset).
"""
if value is not None and len(value) == 0:
if self.exclude:
return qs
else:
return qs.none()
else:
return super(ListFilter, self).filter(qs, value)
24 changes: 24 additions & 0 deletions graphene_django/filter/filters/range_filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from django.core.exceptions import ValidationError
from django.forms import Field

from .typed_filter import TypedFilter


def validate_range(value):
"""
Validator for range filter input: the list of value must be of length 2.
Note that validators are only run if the value is not empty.
"""
if len(value) != 2:
raise ValidationError(
"Invalid range specified: it needs to contain 2 values.", code="invalid"
)


class RangeField(Field):
default_validators = [validate_range]
empty_values = [None]


class RangeFilter(TypedFilter):
field_class = RangeField
27 changes: 27 additions & 0 deletions graphene_django/filter/filters/typed_filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from django_filters import Filter

from graphene.types.utils import get_type


class TypedFilter(Filter):
"""
Filter class for which the input GraphQL type can explicitly be provided.
If it is not provided, when building the schema, it will try to guess
it from the field.
"""

def __init__(self, input_type=None, *args, **kwargs):
self._input_type = input_type
super(TypedFilter, self).__init__(*args, **kwargs)

@property
def input_type(self):
input_type = get_type(self._input_type)
if input_type is not None:
if not callable(getattr(input_type, "get_type", None)):
raise ValueError(
"Wrong `input_type` for {}: it only accepts graphene types, got {}".format(
self.__class__.__name__, input_type
)
)
return input_type
Loading

0 comments on commit 80ea51f

Please sign in to comment.