Skip to content

Commit 4b64b64

Browse files
sobysourcery-ai[bot]pre-commit-ci[bot]bellini666
authored
Add support for AND/OR filters to be lists (#762)
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Thiago Bellini Ribeiro <[email protected]>
1 parent 5100289 commit 4b64b64

File tree

5 files changed

+173
-14
lines changed

5 files changed

+173
-14
lines changed

docs/guide/filters.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,44 @@ To every filter `AND`, `OR`, `NOT` & `DISTINCT` fields are added to allow more c
6363
}
6464
```
6565

66+
## List-based AND/OR/NOT Filters
67+
68+
The `AND`, `OR`, and `NOT` operators can also be declared as lists, allowing for more complex combinations of conditions. This is particularly useful when you need to combine multiple conditions in a single operation.
69+
70+
```python title="types.py"
71+
@strawberry_django.filter_type(models.Vegetable, lookups=True)
72+
class VegetableFilter:
73+
id: auto
74+
name: auto
75+
AND: Optional[list[Self]] = strawberry.UNSET
76+
OR: Optional[list[Self]] = strawberry.UNSET
77+
NOT: Optional[list[Self]] = strawberry.UNSET
78+
```
79+
80+
This enables queries like:
81+
82+
```graphql
83+
{
84+
vegetables(
85+
filters: {
86+
AND: [{ name: { contains: "blue" } }, { name: { contains: "squash" } }]
87+
}
88+
) {
89+
id
90+
}
91+
}
92+
```
93+
94+
The list-based filtering system differs from the single object filter in a few ways:
95+
96+
1. It allows combining multiple conditions in a single `AND`, `OR`, or `NOT` operation
97+
2. The conditions in a list are evaluated together as a group
98+
3. When using `AND`, all conditions in the list must be satisfied
99+
4. When using `OR`, any condition in the list can be satisfied
100+
5. When using `NOT`, none of the conditions in the list should be satisfied
101+
102+
This is particularly useful for complex queries where you need to have multiple conditions against the same field.
103+
66104
## Lookups
67105

68106
Lookups can be added to all fields with `lookups=True`, which will

strawberry_django/filters.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import functools
44
import inspect
5+
import operator
56
import warnings
67
from enum import Enum
78
from types import FunctionType
@@ -194,19 +195,33 @@ def process_filters(
194195
if field_value:
195196
queryset = queryset.distinct()
196197
elif field_name in ("AND", "OR", "NOT"): # noqa: PLR6201
197-
assert has_object_definition(field_value)
198-
199-
queryset, sub_q = process_filters(
200-
cast("WithStrawberryObjectDefinition", field_value),
201-
queryset,
202-
info,
203-
prefix,
204-
)
198+
values = field_value if isinstance(field_value, list) else [field_value]
199+
all_q = [Q()]
200+
for value in values:
201+
assert has_object_definition(value)
202+
203+
queryset, sub_q_for_value = process_filters(
204+
cast("WithStrawberryObjectDefinition", value),
205+
queryset,
206+
info,
207+
prefix,
208+
)
209+
all_q.append(sub_q_for_value)
205210
if field_name == "AND":
211+
sub_q = functools.reduce(operator.and_, all_q)
206212
q &= sub_q
207213
elif field_name == "OR":
208-
q |= sub_q
214+
sub_q = functools.reduce(operator.or_, all_q)
215+
if isinstance(field_value, list):
216+
# The behavior of AND/OR/NOT with a list of values means AND/OR/NOT
217+
# with respect to the list members but AND with respect to other
218+
# filters
219+
q &= sub_q
220+
else:
221+
q |= sub_q
209222
elif field_name == "NOT":
223+
# Whether this is an AND or OR operation is undefined in the spec and implementation specific
224+
sub_q = functools.reduce(operator.or_, all_q)
210225
q &= ~sub_q
211226
else:
212227
assert_never(field_name)

strawberry_django/type.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,18 @@ def _process_type(
119119
if is_filter:
120120
cls_annotations.update(
121121
{
122-
"AND": Optional[Self], # type: ignore
123-
"OR": Optional[Self], # type: ignore
124-
"NOT": Optional[Self], # type: ignore
125-
"DISTINCT": Optional[bool],
122+
"AND": existing_annotations.get("AND").annotation # type: ignore
123+
if existing_annotations.get("AND")
124+
else Optional[Self], # type: ignore
125+
"OR": existing_annotations.get("OR").annotation # type: ignore
126+
if existing_annotations.get("OR")
127+
else Optional[Self], # type: ignore
128+
"NOT": existing_annotations.get("NOT").annotation # type: ignore
129+
if existing_annotations.get("NOT")
130+
else Optional[Self], # type: ignore
131+
"DISTINCT": existing_annotations.get("DISTINCT").annotation # type: ignore
132+
if existing_annotations.get("DISTINCT")
133+
else Optional[bool],
126134
},
127135
)
128136

tests/filters/test_filters_v2.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from strawberry.relay import GlobalID
1111
from strawberry.types import ExecutionResult, get_object_definition
1212
from strawberry.types.base import WithStrawberryObjectDefinition, get_object_definition
13+
from typing_extensions import Self
1314

1415
import strawberry_django
1516
from strawberry_django.exceptions import (
@@ -25,7 +26,7 @@
2526
)
2627
from strawberry_django.filters import process_filters, resolve_value
2728
from tests import models, utils
28-
from tests.types import Fruit, FruitType
29+
from tests.types import Fruit, FruitType, Vegetable
2930

3031

3132
@strawberry.enum
@@ -35,6 +36,15 @@ class Version(Enum):
3536
THREE = "third"
3637

3738

39+
@strawberry_django.filter_type(models.Vegetable, lookups=True)
40+
class VegetableFilter:
41+
id: auto
42+
name: auto
43+
AND: Optional[list[Self]] = strawberry.UNSET
44+
OR: Optional[list[Self]] = strawberry.UNSET
45+
NOT: Optional[list[Self]] = strawberry.UNSET
46+
47+
3848
@strawberry_django.filter_type(models.Color, lookups=True)
3949
class ColorFilter:
4050
id: auto
@@ -106,6 +116,7 @@ def filter(self, info, queryset: QuerySet, prefix):
106116
class Query:
107117
types: list[FruitType] = strawberry_django.field(filters=FruitTypeFilter)
108118
fruits: list[Fruit] = strawberry_django.field(filters=FruitFilter)
119+
vegetables: list[Vegetable] = strawberry_django.field(filters=VegetableFilter)
109120

110121

111122
@pytest.fixture
@@ -423,6 +434,87 @@ def test_filter_distinct(query, db, fruits):
423434
assert len(result.data["fruits"]) == 1
424435

425436

437+
def test_filter_and_or_not(query, db):
438+
v1 = models.Vegetable.objects.create(
439+
name="v1", description="d1", world_production=100
440+
)
441+
v2 = models.Vegetable.objects.create(
442+
name="v2", description="d2", world_production=200
443+
)
444+
v3 = models.Vegetable.objects.create(
445+
name="v3", description="d3", world_production=300
446+
)
447+
448+
# Test impossible AND
449+
result = query("""
450+
{
451+
vegetables(filters: { AND: [{ name: { exact: "v1" } }, { name: { exact: "v2" } }] }) { id }
452+
}
453+
""")
454+
assert not result.errors
455+
assert len(result.data["vegetables"]) == 0
456+
457+
# Test AND with contains
458+
result = query("""
459+
{
460+
vegetables(filters: { AND: [{ name: { contains: "v" } }, { name: { contains: "2" } }] }) { id }
461+
}
462+
""")
463+
assert not result.errors
464+
assert len(result.data["vegetables"]) == 1
465+
assert result.data["vegetables"][0]["id"] == str(v2.pk)
466+
467+
# Test OR
468+
result = query("""
469+
{
470+
vegetables(filters: { OR: [{ name: { exact: "v1" } }, { name: { exact: "v3" } }] }) { id }
471+
}
472+
""")
473+
assert not result.errors
474+
assert len(result.data["vegetables"]) == 2
475+
assert {
476+
result.data["vegetables"][0]["id"],
477+
result.data["vegetables"][1]["id"],
478+
} == {str(v1.pk), str(v3.pk)}
479+
480+
# Test NOT
481+
result = query("""
482+
{
483+
vegetables(filters: { NOT: [{ name: { exact: "v1" } }, { name: { exact: "v2" } }] }) { id }
484+
}
485+
""")
486+
assert not result.errors
487+
assert len(result.data["vegetables"]) == 1
488+
assert result.data["vegetables"][0]["id"] == str(v3.pk)
489+
490+
# Test interaction with simple filters. No matches due to AND logic relative to simple filters.
491+
result = query(
492+
"""
493+
{
494+
vegetables(filters: { id: { exact: """
495+
+ str(v1.pk)
496+
+ """ }, AND: [{ name: { exact: "v2" } }] }) { id }
497+
}
498+
"""
499+
)
500+
assert not result.errors
501+
assert len(result.data["vegetables"]) == 0
502+
503+
# Test interaction with simple filters. Match on same record
504+
result = query(
505+
"""
506+
{
507+
vegetables(filters: { id: { exact: """
508+
+ str(v1.pk)
509+
+ """ }, AND: [{ name: { exact: "v1" } }] }) { id }
510+
}
511+
"""
512+
)
513+
assert not result.errors
514+
assert len(result.data["vegetables"]) == 1
515+
assert result.data["vegetables"][0]["id"] == str(v1.pk)
516+
517+
426518
def test_filter_none(query, db):
427519
yellow = models.Color.objects.create(name="yellow")
428520
models.Fruit.objects.create(name="banana", color=yellow)

tests/types.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ class FruitType:
3232
fruits: list[Fruit]
3333

3434

35+
@strawberry_django.type(models.Vegetable)
36+
class Vegetable:
37+
id: auto
38+
name: auto
39+
40+
3541
@strawberry_django.type(models.TomatoWithRequiredPicture, fields="__all__")
3642
class TomatoWithRequiredPictureType:
3743
pass

0 commit comments

Comments
 (0)