Skip to content

Commit 0473f1a

Browse files
tcleonardThomas Leonard
and
Thomas Leonard
authored
fix: empty list is not an empty value for list filters even when a custom filtering method is provided (#1450)
Co-authored-by: Thomas Leonard <thomas@loftorbital.com>
1 parent 720db1f commit 0473f1a

9 files changed

+450
-94
lines changed

graphene_django/compat.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import sys
2+
from pathlib import PurePath
3+
14
# For backwards compatibility, we import JSONField to have it available for import via
25
# this compat module (https://github.com/graphql-python/graphene-django/issues/1428).
36
# Django's JSONField is available in Django 3.2+ (the minimum version we support)
@@ -19,4 +22,23 @@ def __init__(self, *args, **kwargs):
1922
RangeField,
2023
)
2124
except ImportError:
22-
IntegerRangeField, ArrayField, HStoreField, RangeField = (MissingType,) * 4
25+
IntegerRangeField, HStoreField, RangeField = (MissingType,) * 3
26+
27+
# For unit tests we fake ArrayField using JSONFields
28+
if any(
29+
PurePath(sys.argv[0]).match(p)
30+
for p in [
31+
"**/pytest",
32+
"**/py.test",
33+
"**/pytest/__main__.py",
34+
]
35+
):
36+
37+
class ArrayField(JSONField):
38+
def __init__(self, *args, **kwargs):
39+
if len(args) > 0:
40+
self.base_field = args[0]
41+
super().__init__(**kwargs)
42+
43+
else:
44+
ArrayField = MissingType

graphene_django/filter/filters/array_filter.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,36 @@
11
from django_filters.constants import EMPTY_VALUES
2+
from django_filters.filters import FilterMethod
23

34
from .typed_filter import TypedFilter
45

56

7+
class ArrayFilterMethod(FilterMethod):
8+
def __call__(self, qs, value):
9+
if value is None:
10+
return qs
11+
return self.method(qs, self.f.field_name, value)
12+
13+
614
class ArrayFilter(TypedFilter):
715
"""
816
Filter made for PostgreSQL ArrayField.
917
"""
1018

19+
@TypedFilter.method.setter
20+
def method(self, value):
21+
"""
22+
Override method setter so that in case a custom `method` is provided
23+
(see documentation https://django-filter.readthedocs.io/en/stable/ref/filters.html#method),
24+
it doesn't fall back to checking if the value is in `EMPTY_VALUES` (from the `__call__` method
25+
of the `FilterMethod` class) and instead use our ArrayFilterMethod that consider empty lists as values.
26+
27+
Indeed when providing a `method` the `filter` method below is overridden and replaced by `FilterMethod(self)`
28+
which means that the validation of the empty value is made by the `FilterMethod.__call__` method instead.
29+
"""
30+
TypedFilter.method.fset(self, value)
31+
if value is not None:
32+
self.filter = ArrayFilterMethod(self)
33+
1134
def filter(self, qs, value):
1235
"""
1336
Override the default filter class to check first whether the list is

graphene_django/filter/filters/list_filter.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,36 @@
1+
from django_filters.filters import FilterMethod
2+
13
from .typed_filter import TypedFilter
24

35

6+
class ListFilterMethod(FilterMethod):
7+
def __call__(self, qs, value):
8+
if value is None:
9+
return qs
10+
return self.method(qs, self.f.field_name, value)
11+
12+
413
class ListFilter(TypedFilter):
514
"""
615
Filter that takes a list of value as input.
716
It is for example used for `__in` filters.
817
"""
918

19+
@TypedFilter.method.setter
20+
def method(self, value):
21+
"""
22+
Override method setter so that in case a custom `method` is provided
23+
(see documentation https://django-filter.readthedocs.io/en/stable/ref/filters.html#method),
24+
it doesn't fall back to checking if the value is in `EMPTY_VALUES` (from the `__call__` method
25+
of the `FilterMethod` class) and instead use our ListFilterMethod that consider empty lists as values.
26+
27+
Indeed when providing a `method` the `filter` method below is overridden and replaced by `FilterMethod(self)`
28+
which means that the validation of the empty value is made by the `FilterMethod.__call__` method instead.
29+
"""
30+
TypedFilter.method.fset(self, value)
31+
if value is not None:
32+
self.filter = ListFilterMethod(self)
33+
1034
def filter(self, qs, value):
1135
"""
1236
Override the default filter class to check first whether the list is
Lines changed: 97 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from unittest.mock import MagicMock
1+
from functools import reduce
22

33
import pytest
44
from django.db import models
@@ -25,15 +25,15 @@
2525
)
2626

2727

28-
STORE = {"events": []}
29-
30-
3128
class Event(models.Model):
3229
name = models.CharField(max_length=50)
3330
tags = ArrayField(models.CharField(max_length=50))
3431
tag_ids = ArrayField(models.IntegerField())
3532
random_field = ArrayField(models.BooleanField())
3633

34+
def __repr__(self):
35+
return f"Event [{self.name}]"
36+
3737

3838
@pytest.fixture
3939
def EventFilterSet():
@@ -48,6 +48,14 @@ class Meta:
4848
tags__contains = ArrayFilter(field_name="tags", lookup_expr="contains")
4949
tags__overlap = ArrayFilter(field_name="tags", lookup_expr="overlap")
5050
tags = ArrayFilter(field_name="tags", lookup_expr="exact")
51+
tags__len = ArrayFilter(
52+
field_name="tags", lookup_expr="len", input_type=graphene.Int
53+
)
54+
tags__len__in = ArrayFilter(
55+
field_name="tags",
56+
method="tags__len__in_filter",
57+
input_type=graphene.List(graphene.Int),
58+
)
5159

5260
# Those are actually not usable and only to check type declarations
5361
tags_ids__contains = ArrayFilter(field_name="tag_ids", lookup_expr="contains")
@@ -61,6 +69,14 @@ class Meta:
6169
)
6270
random_field = ArrayFilter(field_name="random_field", lookup_expr="exact")
6371

72+
def tags__len__in_filter(self, queryset, _name, value):
73+
if not value:
74+
return queryset.none()
75+
return reduce(
76+
lambda q1, q2: q1.union(q2),
77+
[queryset.filter(tags__len=v) for v in value],
78+
).distinct()
79+
6480
return EventFilterSet
6581

6682

@@ -83,68 +99,94 @@ def Query(EventType):
8399
we are running unit tests in sqlite which does not have ArrayFields.
84100
"""
85101

102+
events = [
103+
Event(name="Live Show", tags=["concert", "music", "rock"]),
104+
Event(name="Musical", tags=["movie", "music"]),
105+
Event(name="Ballet", tags=["concert", "dance"]),
106+
Event(name="Speech", tags=[]),
107+
]
108+
86109
class Query(graphene.ObjectType):
87110
events = DjangoFilterConnectionField(EventType)
88111

89112
def resolve_events(self, info, **kwargs):
90-
events = [
91-
Event(name="Live Show", tags=["concert", "music", "rock"]),
92-
Event(name="Musical", tags=["movie", "music"]),
93-
Event(name="Ballet", tags=["concert", "dance"]),
94-
Event(name="Speech", tags=[]),
95-
]
96-
97-
STORE["events"] = events
98-
99-
m_queryset = MagicMock(spec=QuerySet)
100-
m_queryset.model = Event
101-
102-
def filter_events(**kwargs):
103-
if "tags__contains" in kwargs:
104-
STORE["events"] = list(
105-
filter(
106-
lambda e: set(kwargs["tags__contains"]).issubset(
107-
set(e.tags)
108-
),
109-
STORE["events"],
113+
class FakeQuerySet(QuerySet):
114+
def __init__(self, model=None):
115+
self.model = Event
116+
self.__store = list(events)
117+
118+
def all(self):
119+
return self
120+
121+
def filter(self, **kwargs):
122+
queryset = FakeQuerySet()
123+
queryset.__store = list(self.__store)
124+
if "tags__contains" in kwargs:
125+
queryset.__store = list(
126+
filter(
127+
lambda e: set(kwargs["tags__contains"]).issubset(
128+
set(e.tags)
129+
),
130+
queryset.__store,
131+
)
132+
)
133+
if "tags__overlap" in kwargs:
134+
queryset.__store = list(
135+
filter(
136+
lambda e: not set(kwargs["tags__overlap"]).isdisjoint(
137+
set(e.tags)
138+
),
139+
queryset.__store,
140+
)
110141
)
111-
)
112-
if "tags__overlap" in kwargs:
113-
STORE["events"] = list(
114-
filter(
115-
lambda e: not set(kwargs["tags__overlap"]).isdisjoint(
116-
set(e.tags)
117-
),
118-
STORE["events"],
142+
if "tags__exact" in kwargs:
143+
queryset.__store = list(
144+
filter(
145+
lambda e: set(kwargs["tags__exact"]) == set(e.tags),
146+
queryset.__store,
147+
)
119148
)
120-
)
121-
if "tags__exact" in kwargs:
122-
STORE["events"] = list(
123-
filter(
124-
lambda e: set(kwargs["tags__exact"]) == set(e.tags),
125-
STORE["events"],
149+
if "tags__len" in kwargs:
150+
queryset.__store = list(
151+
filter(
152+
lambda e: len(e.tags) == kwargs["tags__len"],
153+
queryset.__store,
154+
)
126155
)
127-
)
156+
return queryset
157+
158+
def union(self, *args):
159+
queryset = FakeQuerySet()
160+
queryset.__store = self.__store
161+
for arg in args:
162+
queryset.__store += arg.__store
163+
return queryset
128164

129-
def mock_queryset_filter(*args, **kwargs):
130-
filter_events(**kwargs)
131-
return m_queryset
165+
def none(self):
166+
queryset = FakeQuerySet()
167+
queryset.__store = []
168+
return queryset
132169

133-
def mock_queryset_none(*args, **kwargs):
134-
STORE["events"] = []
135-
return m_queryset
170+
def count(self):
171+
return len(self.__store)
136172

137-
def mock_queryset_count(*args, **kwargs):
138-
return len(STORE["events"])
173+
def distinct(self):
174+
queryset = FakeQuerySet()
175+
queryset.__store = []
176+
for event in self.__store:
177+
if event not in queryset.__store:
178+
queryset.__store.append(event)
179+
queryset.__store = sorted(queryset.__store, key=lambda e: e.name)
180+
return queryset
139181

140-
m_queryset.all.return_value = m_queryset
141-
m_queryset.filter.side_effect = mock_queryset_filter
142-
m_queryset.none.side_effect = mock_queryset_none
143-
m_queryset.count.side_effect = mock_queryset_count
144-
m_queryset.__getitem__.side_effect = lambda index: STORE[
145-
"events"
146-
].__getitem__(index)
182+
def __getitem__(self, index):
183+
return self.__store[index]
147184

148-
return m_queryset
185+
return FakeQuerySet()
149186

150187
return Query
188+
189+
190+
@pytest.fixture
191+
def schema(Query):
192+
return graphene.Schema(query=Query)

graphene_django/filter/tests/test_array_field_contains_filter.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,14 @@
11
import pytest
22

3-
from graphene import Schema
4-
53
from ...compat import ArrayField, MissingType
64

75

86
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
9-
def test_array_field_contains_multiple(Query):
7+
def test_array_field_contains_multiple(schema):
108
"""
119
Test contains filter on a array field of string.
1210
"""
1311

14-
schema = Schema(query=Query)
15-
1612
query = """
1713
query {
1814
events (tags_Contains: ["concert", "music"]) {
@@ -32,13 +28,11 @@ def test_array_field_contains_multiple(Query):
3228

3329

3430
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
35-
def test_array_field_contains_one(Query):
31+
def test_array_field_contains_one(schema):
3632
"""
3733
Test contains filter on a array field of string.
3834
"""
3935

40-
schema = Schema(query=Query)
41-
4236
query = """
4337
query {
4438
events (tags_Contains: ["music"]) {
@@ -59,13 +53,11 @@ def test_array_field_contains_one(Query):
5953

6054

6155
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
62-
def test_array_field_contains_empty_list(Query):
56+
def test_array_field_contains_empty_list(schema):
6357
"""
6458
Test contains filter on a array field of string.
6559
"""
6660

67-
schema = Schema(query=Query)
68-
6961
query = """
7062
query {
7163
events (tags_Contains: []) {

0 commit comments

Comments
 (0)