Skip to content

Commit 0e46aa7

Browse files
authored
Merge branch 'main' into instance-field
2 parents fbdc10f + 5171752 commit 0e46aa7

26 files changed

+1255
-133
lines changed

docs/filtering.rst

+43
Original file line numberDiff line numberDiff line change
@@ -258,3 +258,46 @@ with this set up, you can now order the users under group:
258258
}
259259
}
260260
}
261+
262+
263+
PostgreSQL `ArrayField`
264+
-----------------------
265+
266+
Graphene provides an easy to implement filters on `ArrayField` as they are not natively supported by django_filters:
267+
268+
.. code:: python
269+
270+
from django.db import models
271+
from django_filters import FilterSet, OrderingFilter
272+
from graphene_django.filter import ArrayFilter
273+
274+
class Event(models.Model):
275+
name = models.CharField(max_length=50)
276+
tags = ArrayField(models.CharField(max_length=50))
277+
278+
class EventFilterSet(FilterSet):
279+
class Meta:
280+
model = Event
281+
fields = {
282+
"name": ["exact", "contains"],
283+
}
284+
285+
tags__contains = ArrayFilter(field_name="tags", lookup_expr="contains")
286+
tags__overlap = ArrayFilter(field_name="tags", lookup_expr="overlap")
287+
tags = ArrayFilter(field_name="tags", lookup_expr="exact")
288+
289+
class EventType(DjangoObjectType):
290+
class Meta:
291+
model = Event
292+
interfaces = (Node,)
293+
filterset_class = EventFilterSet
294+
295+
with this set up, you can now filter events by tags:
296+
297+
.. code::
298+
299+
query {
300+
events(tags_Overlap: ["concert", "festival"]) {
301+
name
302+
}
303+
}

docs/testing.rst

+35
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,41 @@ Usage:
8282
# Add some more asserts if you like
8383
...
8484
85+
86+
For testing mutations that are executed within a transaction you should subclass `GraphQLTransactionTestCase`
87+
88+
Usage:
89+
90+
.. code:: python
91+
92+
import json
93+
94+
from graphene_django.utils.testing import GraphQLTransactionTestCase
95+
96+
class MyFancyTransactionTestCase(GraphQLTransactionTestCase):
97+
98+
def test_some_mutation_that_executes_within_a_transaction(self):
99+
response = self.query(
100+
'''
101+
mutation myMutation($input: MyMutationInput!) {
102+
myMutation(input: $input) {
103+
my-model {
104+
id
105+
name
106+
}
107+
}
108+
}
109+
''',
110+
op_name='myMutation',
111+
input_data={'my_field': 'foo', 'other_field': 'bar'}
112+
)
113+
114+
# This validates the status code and if you get errors
115+
self.assertResponseNoErrors(response)
116+
117+
# Add some more asserts if you like
118+
...
119+
85120
Using pytest
86121
------------
87122

graphene_django/converter.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from collections import OrderedDict
2-
from functools import singledispatch, partial, wraps
2+
from functools import singledispatch, wraps
33

44
from django.db import models
55
from django.utils.encoding import force_str
@@ -23,7 +23,6 @@
2323
Time,
2424
Decimal,
2525
)
26-
from graphene.types.resolver import get_default_resolver
2726
from graphene.types.json import JSONString
2827
from graphene.utils.str_converters import to_camel_case
2928
from graphql import GraphQLError, assert_valid_name
@@ -36,7 +35,7 @@
3635

3736

3837
class BlankValueField(Field):
39-
def get_resolver(self, parent_resolver):
38+
def wrap_resolve(self, parent_resolver):
4039
resolver = self.resolver or parent_resolver
4140

4241
# create custom resolver

graphene_django/fields.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,13 @@ def list_resolver(
6161
return queryset
6262

6363
def wrap_resolve(self, parent_resolver):
64+
resolver = super(DjangoListField, self).wrap_resolve(parent_resolver)
6465
_type = self.type
6566
if isinstance(_type, NonNull):
6667
_type = _type.of_type
6768
django_object_type = _type.of_type.of_type
6869
return partial(
69-
self.list_resolver, django_object_type, parent_resolver, self.get_manager(),
70+
self.list_resolver, django_object_type, resolver, self.get_manager(),
7071
)
7172

7273

graphene_django/filter/__init__.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,19 @@
99
)
1010
else:
1111
from .fields import DjangoFilterConnectionField
12-
from .filters import GlobalIDFilter, GlobalIDMultipleChoiceFilter
12+
from .filters import (
13+
ArrayFilter,
14+
GlobalIDFilter,
15+
GlobalIDMultipleChoiceFilter,
16+
ListFilter,
17+
RangeFilter,
18+
)
1319

1420
__all__ = [
1521
"DjangoFilterConnectionField",
1622
"GlobalIDFilter",
1723
"GlobalIDMultipleChoiceFilter",
24+
"ArrayFilter",
25+
"ListFilter",
26+
"RangeFilter",
1827
]

graphene_django/filter/fields.py

+23-4
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,31 @@
22
from functools import partial
33

44
from django.core.exceptions import ValidationError
5+
6+
from graphene.types.enum import EnumType
57
from graphene.types.argument import to_arguments
68
from graphene.utils.str_converters import to_snake_case
9+
710
from ..fields import DjangoConnectionField
811
from .utils import get_filtering_args_from_filterset, get_filterset_class
912

1013

14+
def convert_enum(data):
15+
"""
16+
Check if the data is a enum option (or potentially nested list of enum option)
17+
and convert it to its value.
18+
19+
This method is used to pre-process the data for the filters as they can take an
20+
graphene.Enum as argument, but filters (from django_filters) expect a simple value.
21+
"""
22+
if isinstance(data, list):
23+
return [convert_enum(item) for item in data]
24+
if isinstance(type(data), EnumType):
25+
return data.value
26+
else:
27+
return data
28+
29+
1130
class DjangoFilterConnectionField(DjangoConnectionField):
1231
def __init__(
1332
self,
@@ -43,8 +62,8 @@ def filterset_class(self):
4362
if self._extra_filter_meta:
4463
meta.update(self._extra_filter_meta)
4564

46-
filterset_class = self._provided_filterset_class or (
47-
self.node_type._meta.filterset_class
65+
filterset_class = (
66+
self._provided_filterset_class or self.node_type._meta.filterset_class
4867
)
4968
self._filterset_class = get_filterset_class(filterset_class, **meta)
5069

@@ -68,7 +87,7 @@ def filter_kwargs():
6887
if k in filtering_args:
6988
if k == "order_by" and v is not None:
7089
v = to_snake_case(v)
71-
kwargs[k] = v
90+
kwargs[k] = convert_enum(v)
7291
return kwargs
7392

7493
qs = super(DjangoFilterConnectionField, cls).resolve_queryset(
@@ -78,7 +97,7 @@ def filter_kwargs():
7897
filterset = filterset_class(
7998
data=filter_kwargs(), queryset=qs, request=info.context
8099
)
81-
if filterset.form.is_valid():
100+
if filterset.is_valid():
82101
return filterset.qs
83102
raise ValidationError(filterset.form.errors.as_json())
84103

graphene_django/filter/filters.py

+29-3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from django.forms import Field
33

44
from django_filters import Filter, MultipleChoiceFilter
5+
from django_filters.constants import EMPTY_VALUES
56

67
from graphql_relay.node.node import from_global_id
78

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

3334

34-
class InFilter(Filter):
35+
class ListFilter(Filter):
3536
"""
36-
Filter for a list of value using the `__in` Django filter.
37+
Filter that takes a list of value as input.
38+
It is for example used for `__in` filters.
3739
"""
3840

3941
def filter(self, qs, value):
4042
"""
41-
Override the default filter class to check first weather the list is
43+
Override the default filter class to check first whether the list is
4244
empty or not.
4345
This needs to be done as in this case we expect to get an empty output
4446
(if not an exclude filter) but django_filter consider an empty list
@@ -73,3 +75,27 @@ class RangeField(Field):
7375

7476
class RangeFilter(Filter):
7577
field_class = RangeField
78+
79+
80+
class ArrayFilter(Filter):
81+
"""
82+
Filter made for PostgreSQL ArrayField.
83+
"""
84+
85+
def filter(self, qs, value):
86+
"""
87+
Override the default filter class to check first whether the list is
88+
empty or not.
89+
This needs to be done as in this case we expect to get the filter applied with
90+
an empty list since it's a valid value but django_filter consider an empty list
91+
to be an empty input value (see `EMPTY_VALUES`) meaning that
92+
the filter does not need to be applied (hence returning the original
93+
queryset).
94+
"""
95+
if value in EMPTY_VALUES and value != []:
96+
return qs
97+
if self.distinct:
98+
qs = qs.distinct()
99+
lookup = "%s__%s" % (self.field_name, self.lookup_expr)
100+
qs = self.get_method(qs)(**{lookup: value})
101+
return qs

graphene_django/filter/tests/conftest.py

+41-18
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from graphene.relay import Node
1010
from graphene_django import DjangoObjectType
1111
from graphene_django.utils import DJANGO_FILTER_INSTALLED
12+
from graphene_django.filter import ArrayFilter, ListFilter
1213

1314
from ...compat import ArrayField
1415

@@ -27,49 +28,61 @@
2728
STORE = {"events": []}
2829

2930

30-
@pytest.fixture
31-
def Event():
32-
class Event(models.Model):
33-
name = models.CharField(max_length=50)
34-
tags = ArrayField(models.CharField(max_length=50))
35-
36-
return Event
31+
class Event(models.Model):
32+
name = models.CharField(max_length=50)
33+
tags = ArrayField(models.CharField(max_length=50))
34+
tag_ids = ArrayField(models.IntegerField())
35+
random_field = ArrayField(models.BooleanField())
3736

3837

3938
@pytest.fixture
40-
def EventFilterSet(Event):
41-
42-
from django.contrib.postgres.forms import SimpleArrayField
43-
44-
class ArrayFilter(filters.Filter):
45-
base_field_class = SimpleArrayField
46-
39+
def EventFilterSet():
4740
class EventFilterSet(FilterSet):
4841
class Meta:
4942
model = Event
5043
fields = {
51-
"name": ["exact"],
44+
"name": ["exact", "contains"],
5245
}
5346

47+
# Those are actually usable with our Query fixture bellow
5448
tags__contains = ArrayFilter(field_name="tags", lookup_expr="contains")
5549
tags__overlap = ArrayFilter(field_name="tags", lookup_expr="overlap")
50+
tags = ArrayFilter(field_name="tags", lookup_expr="exact")
51+
52+
# Those are actually not usable and only to check type declarations
53+
tags_ids__contains = ArrayFilter(field_name="tag_ids", lookup_expr="contains")
54+
tags_ids__overlap = ArrayFilter(field_name="tag_ids", lookup_expr="overlap")
55+
tags_ids = ArrayFilter(field_name="tag_ids", lookup_expr="exact")
56+
random_field__contains = ArrayFilter(
57+
field_name="random_field", lookup_expr="contains"
58+
)
59+
random_field__overlap = ArrayFilter(
60+
field_name="random_field", lookup_expr="overlap"
61+
)
62+
random_field = ArrayFilter(field_name="random_field", lookup_expr="exact")
5663

5764
return EventFilterSet
5865

5966

6067
@pytest.fixture
61-
def EventType(Event, EventFilterSet):
68+
def EventType(EventFilterSet):
6269
class EventType(DjangoObjectType):
6370
class Meta:
6471
model = Event
6572
interfaces = (Node,)
73+
fields = "__all__"
6674
filterset_class = EventFilterSet
6775

6876
return EventType
6977

7078

7179
@pytest.fixture
72-
def Query(Event, EventType):
80+
def Query(EventType):
81+
"""
82+
Note that we have to use a custom resolver to replicate the arrayfield filter behavior as
83+
we are running unit tests in sqlite which does not have ArrayFields.
84+
"""
85+
7386
class Query(graphene.ObjectType):
7487
events = DjangoFilterConnectionField(EventType)
7588

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

8498
STORE["events"] = events
@@ -105,6 +119,13 @@ def filter_events(**kwargs):
105119
STORE["events"],
106120
)
107121
)
122+
if "tags__exact" in kwargs:
123+
STORE["events"] = list(
124+
filter(
125+
lambda e: set(kwargs["tags__exact"]) == set(e.tags),
126+
STORE["events"],
127+
)
128+
)
108129

109130
def mock_queryset_filter(*args, **kwargs):
110131
filter_events(**kwargs)
@@ -121,7 +142,9 @@ def mock_queryset_count(*args, **kwargs):
121142
m_queryset.filter.side_effect = mock_queryset_filter
122143
m_queryset.none.side_effect = mock_queryset_none
123144
m_queryset.count.side_effect = mock_queryset_count
124-
m_queryset.__getitem__.side_effect = STORE["events"].__getitem__
145+
m_queryset.__getitem__.side_effect = lambda index: STORE[
146+
"events"
147+
].__getitem__(index)
125148

126149
return m_queryset
127150

graphene_django/filter/tests/filters.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ class Meta:
1010
fields = {
1111
"headline": ["exact", "icontains"],
1212
"pub_date": ["gt", "lt", "exact"],
13-
"reporter": ["exact"],
13+
"reporter": ["exact", "in"],
1414
}
1515

1616
order_by = OrderingFilter(fields=("pub_date",))

0 commit comments

Comments
 (0)