Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add enum support to filters and fix filter typing (v2) #1114

Merged
merged 2 commits into from
Feb 23, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/filtering.rst
Original file line number Diff line number Diff line change
Expand Up @@ -228,3 +228,46 @@ with this set up, you can now order the users under group:
}
}
}


PostgreSQL `ArrayField`
-----------------------

Graphene provides an easy to implement filters on `ArrayField` as they are not natively supported by django_filters:

.. code:: python

from django.db import models
from django_filters import FilterSet, OrderingFilter
from graphene_django.filter import ArrayFilter

class Event(models.Model):
name = models.CharField(max_length=50)
tags = ArrayField(models.CharField(max_length=50))

class EventFilterSet(FilterSet):
class Meta:
model = Event
fields = {
"name": ["exact", "contains"],
}

tags__contains = ArrayFilter(field_name="tags", lookup_expr="contains")
tags__overlap = ArrayFilter(field_name="tags", lookup_expr="overlap")
tags = ArrayFilter(field_name="tags", lookup_expr="exact")

class EventType(DjangoObjectType):
class Meta:
model = Event
interfaces = (Node,)
filterset_class = EventFilterSet

with this set up, you can now filter events by tags:

.. code::

query {
events(tags_Overlap: ["concert", "festival"]) {
name
}
}
11 changes: 10 additions & 1 deletion graphene_django/filter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,19 @@
)
else:
from .fields import DjangoFilterConnectionField
from .filters import GlobalIDFilter, GlobalIDMultipleChoiceFilter
from .filters import (
ArrayFilter,
GlobalIDFilter,
GlobalIDMultipleChoiceFilter,
ListFilter,
RangeFilter,
)

__all__ = [
"DjangoFilterConnectionField",
"GlobalIDFilter",
"GlobalIDMultipleChoiceFilter",
"ArrayFilter",
"ListFilter",
"RangeFilter",
]
4 changes: 2 additions & 2 deletions graphene_django/filter/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ def filterset_class(self):
if self._extra_filter_meta:
meta.update(self._extra_filter_meta)

filterset_class = self._provided_filterset_class or (
self.node_type._meta.filterset_class
filterset_class = (
self._provided_filterset_class or self.node_type._meta.filterset_class
)
self._filterset_class = get_filterset_class(filterset_class, **meta)

Expand Down
34 changes: 30 additions & 4 deletions graphene_django/filter/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from django.forms import Field

from django_filters import Filter, MultipleChoiceFilter
from django_filters.constants import EMPTY_VALUES

from graphql_relay.node.node import from_global_id

Expand Down Expand Up @@ -31,14 +32,15 @@ def filter(self, qs, value):
return super(GlobalIDMultipleChoiceFilter, self).filter(qs, gids)


class InFilter(Filter):
class ListFilter(Filter):
"""
Filter for a list of value using the `__in` Django filter.
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 weather the list is
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
Expand All @@ -52,7 +54,7 @@ def filter(self, qs, value):
else:
return qs.none()
else:
return super(InFilter, self).filter(qs, value)
return super(ListFilter, self).filter(qs, value)


def validate_range(value):
Expand All @@ -73,3 +75,27 @@ class RangeField(Field):

class RangeFilter(Filter):
field_class = RangeField


class ArrayFilter(Filter):
"""
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
42 changes: 34 additions & 8 deletions graphene_django/filter/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from graphene.relay import Node
from graphene_django import DjangoObjectType
from graphene_django.utils import DJANGO_FILTER_INSTALLED
from graphene_django.filter import ArrayFilter, ListFilter

from ...compat import ArrayField

Expand All @@ -32,27 +33,37 @@ def Event():
class Event(models.Model):
name = models.CharField(max_length=50)
tags = ArrayField(models.CharField(max_length=50))
tag_ids = ArrayField(models.IntegerField())
random_field = ArrayField(models.BooleanField())

return Event


@pytest.fixture
def EventFilterSet(Event):

from django.contrib.postgres.forms import SimpleArrayField

class ArrayFilter(filters.Filter):
base_field_class = SimpleArrayField

class EventFilterSet(FilterSet):
class Meta:
model = Event
fields = {
"name": ["exact"],
"name": ["exact", "contains"],
}

# Those are actually usable with our Query fixture bellow
tags__contains = ArrayFilter(field_name="tags", lookup_expr="contains")
tags__overlap = ArrayFilter(field_name="tags", lookup_expr="overlap")
tags = ArrayFilter(field_name="tags", lookup_expr="exact")

# Those are actually not usable and only to check type declarations
tags_ids__contains = ArrayFilter(field_name="tag_ids", lookup_expr="contains")
tags_ids__overlap = ArrayFilter(field_name="tag_ids", lookup_expr="overlap")
tags_ids = ArrayFilter(field_name="tag_ids", lookup_expr="exact")
random_field__contains = ArrayFilter(
field_name="random_field", lookup_expr="contains"
)
random_field__overlap = ArrayFilter(
field_name="random_field", lookup_expr="overlap"
)
random_field = ArrayFilter(field_name="random_field", lookup_expr="exact")

return EventFilterSet

Expand All @@ -70,6 +81,11 @@ class Meta:

@pytest.fixture
def Query(Event, EventType):
"""
Note that we have to use a custom resolver to replicate the arrayfield filter behavior as
we are running unit tests in sqlite which does not have ArrayFields.
"""

class Query(graphene.ObjectType):
events = DjangoFilterConnectionField(EventType)

Expand All @@ -79,6 +95,7 @@ def resolve_events(self, info, **kwargs):
Event(name="Live Show", tags=["concert", "music", "rock"],),
Event(name="Musical", tags=["movie", "music"],),
Event(name="Ballet", tags=["concert", "dance"],),
Event(name="Speech", tags=[],),
]

STORE["events"] = events
Expand All @@ -105,6 +122,13 @@ def filter_events(**kwargs):
STORE["events"],
)
)
if "tags__exact" in kwargs:
STORE["events"] = list(
filter(
lambda e: set(kwargs["tags__exact"]) == set(e.tags),
STORE["events"],
)
)

def mock_queryset_filter(*args, **kwargs):
filter_events(**kwargs)
Expand All @@ -121,7 +145,9 @@ def mock_queryset_count(*args, **kwargs):
m_queryset.filter.side_effect = mock_queryset_filter
m_queryset.none.side_effect = mock_queryset_none
m_queryset.count.side_effect = mock_queryset_count
m_queryset.__getitem__.side_effect = STORE["events"].__getitem__
m_queryset.__getitem__.side_effect = lambda index: STORE[
"events"
].__getitem__(index)

return m_queryset

Expand Down
2 changes: 1 addition & 1 deletion graphene_django/filter/tests/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class Meta:
fields = {
"headline": ["exact", "icontains"],
"pub_date": ["gt", "lt", "exact"],
"reporter": ["exact"],
"reporter": ["exact", "in"],
}

order_by = OrderingFilter(fields=("pub_date",))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@


@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_string_contains_multiple(Query):
def test_array_field_contains_multiple(Query):
"""
Test contains filter on a string field.
Test contains filter on a array field of string.
"""

schema = Schema(query=Query)
Expand All @@ -32,9 +32,9 @@ def test_string_contains_multiple(Query):


@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_string_contains_one(Query):
def test_array_field_contains_one(Query):
"""
Test contains filter on a string field.
Test contains filter on a array field of string.
"""

schema = Schema(query=Query)
Expand All @@ -59,9 +59,9 @@ def test_string_contains_one(Query):


@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_string_contains_none(Query):
def test_array_field_contains_empty_list(Query):
"""
Test contains filter on a string field.
Test contains filter on a array field of string.
"""

schema = Schema(query=Query)
Expand All @@ -79,4 +79,9 @@ def test_string_contains_none(Query):
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == []
assert result.data["events"]["edges"] == [
{"node": {"name": "Live Show"}},
{"node": {"name": "Musical"}},
{"node": {"name": "Ballet"}},
{"node": {"name": "Speech"}},
]
Loading