Skip to content

Commit

Permalink
feat: filter_field optional value resolution (#510)
Browse files Browse the repository at this point in the history
* feat: filter_field optional GlobalID, Enum value resolution

* do not resolve for custom methods by default
  • Loading branch information
Kitefiko authored Apr 1, 2024
1 parent e163d39 commit 9c13e79
Show file tree
Hide file tree
Showing 5 changed files with 85 additions and 16 deletions.
15 changes: 14 additions & 1 deletion docs/guide/filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,12 @@ class FruitFilter:

!!! tip

#### process_filters

As seen above `strawberry_django.process_filters` function is exposed and can be
reused in custom methods. Above it's used to resolve fields lookups

!!! tip
#### null values

By default `null` value is ignored for all filters & lookups. This applies to custom
filter methods as well. Those won't even be called (you don't have to check for `None`).
Expand All @@ -222,6 +224,17 @@ class FruitFilter:
This also means that build in `exact` & `iExact` lookups cannot be used to filter for `None`
and `isNull` have to be used explicitly.

#### value resolution
- `value` parameter of type `relay.GlobalID` is resolved to its `node_id` attribute
- `value` parameter of type `Enum` is resolved to is's value
- above types are converted in `lists` as well

resolution can modified via `strawberry_django.filter_field(resolve_value=...)`

- True - always resolve
- False - never resolve
- UNSET (default) - resolves for filters without custom method only

The code above generates the following schema:

```{.graphql title=schema.graphql}
Expand Down
12 changes: 8 additions & 4 deletions strawberry_django/fields/filter_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
OBJECT_FILTER_NAME: Final[str] = "filter"
OBJECT_ORDER_NAME: Final[str] = "order"
WITH_NONE_META: Final[str] = "WITH_NONE_META"
RESOLVE_VALUE_META: Final[str] = "RESOLVE_VALUE_META"


class FilterOrderFieldResolver(StrawberryResolver):
Expand Down Expand Up @@ -173,6 +174,7 @@ def filter_field(
directives: Sequence[object] = (),
extensions: list[FieldExtension] | None = None,
filter_none: bool = False,
resolve_value: bool = UNSET,
) -> T: ...


Expand All @@ -190,6 +192,7 @@ def filter_field(
directives: Sequence[object] = (),
extensions: list[FieldExtension] | None = None,
filter_none: bool = False,
resolve_value: bool = UNSET,
) -> Any: ...


Expand All @@ -207,6 +210,7 @@ def filter_field(
directives: Sequence[object] = (),
extensions: list[FieldExtension] | None = None,
filter_none: bool = False,
resolve_value: bool = UNSET,
) -> StrawberryField: ...


Expand All @@ -223,6 +227,7 @@ def filter_field(
directives: Sequence[object] = (),
extensions: list[FieldExtension] | None = None,
filter_none: bool = False,
resolve_value: bool = UNSET,
# This init parameter is used by pyright to determine whether this field
# is added in the constructor or not. It is not used to change
# any behavior at the moment.
Expand All @@ -247,8 +252,8 @@ def filter_field(
"""
metadata = metadata or {}
metadata["_FIELD_TYPE"] = OBJECT_FILTER_NAME
if filter_none:
metadata[WITH_NONE_META] = True
metadata[RESOLVE_VALUE_META] = resolve_value
metadata[WITH_NONE_META] = filter_none

field_ = FilterOrderField(
python_name=None,
Expand Down Expand Up @@ -358,8 +363,7 @@ def order_field(
"""
metadata = metadata or {}
metadata["_FIELD_TYPE"] = OBJECT_ORDER_NAME
if order_none:
metadata[WITH_NONE_META] = True
metadata[WITH_NONE_META] = order_none

field_ = FilterOrderField(
python_name=None,
Expand Down
6 changes: 5 additions & 1 deletion strawberry_django/fields/filter_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from django.db.models import Q
from strawberry import UNSET

from strawberry_django.filters import resolve_value

from .filter_order import filter_field

T = TypeVar("T")
Expand All @@ -35,7 +37,9 @@ class RangeLookup(Generic[T]):

@filter_field
def filter(self, queryset, prefix: str):
return queryset, Q(**{f"{prefix}range": [self.start, self.end]})
return queryset, Q(**{
prefix[:-2]: [resolve_value(self.start), resolve_value(self.end)]
})


@strawberry.input
Expand Down
23 changes: 18 additions & 5 deletions strawberry_django/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from typing_extensions import Self, assert_never, dataclass_transform

from strawberry_django.fields.filter_order import (
RESOLVE_VALUE_META,
WITH_NONE_META,
FilterOrderField,
FilterOrderFieldResolver,
Expand Down Expand Up @@ -95,9 +96,9 @@ class FilterLookup(Generic[T]):
}


def _resolve_value(value: Any) -> Any:
def resolve_value(value: Any) -> Any:
if isinstance(value, list):
return [_resolve_value(v) for v in value]
return [resolve_value(v) for v in value]

if isinstance(value, relay.GlobalID):
return value.node_id
Expand Down Expand Up @@ -160,14 +161,16 @@ def process_filters(
filters.__strawberry_definition__.fields,
key=lambda x: len(x.name) if x.name in {"OR", "DISTINCT"} else 0,
):
field_value = _resolve_value(getattr(filters, f.name))
field_value = getattr(filters, f.name)
# None is still acceptable for v1 (backwards compatibility) and filters that support it via metadata
if field_value is UNSET or (
field_value is None
and not f.metadata.get(WITH_NONE_META, using_old_filters)
):
continue

should_resolve = f.metadata.get(RESOLVE_VALUE_META, UNSET)

field_name = lookup_name_conversion_map.get(f.name, f.name)
if field_name == "DISTINCT":
if field_value:
Expand All @@ -191,7 +194,11 @@ def process_filters(
assert_never(field_name)
elif isinstance(f, FilterOrderField) and f.base_resolver:
res = f.base_resolver(
filters, info, value=field_value, queryset=queryset, prefix=prefix
filters,
info,
value=(resolve_value(field_value) if should_resolve else field_value),
queryset=queryset,
prefix=prefix,
)
if isinstance(res, tuple):
queryset, sub_q = res
Expand All @@ -212,7 +219,13 @@ def process_filters(
)
q &= sub_q
else:
q &= Q(**{f"{prefix}{field_name}": field_value})
q &= Q(**{
f"{prefix}{field_name}": (
resolve_value(field_value)
if should_resolve or should_resolve is UNSET
else field_value
)
})

return queryset, q

Expand Down
45 changes: 40 additions & 5 deletions tests/filters/test_filters_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
FilterOrderField,
FilterOrderFieldResolver,
)
from strawberry_django.filters import _resolve_value, process_filters
from strawberry_django.filters import process_filters, resolve_value
from tests import models, utils
from tests.types import Fruit, FruitType

Expand Down Expand Up @@ -125,7 +125,7 @@ def query():
],
)
def test_resolve_value(value, resolved):
assert _resolve_value(value) == resolved
assert resolve_value(value) == resolved


def test_filter_field_missing_prefix():
Expand Down Expand Up @@ -256,13 +256,13 @@ def custom_filter(self, root, info, prefix, value: auto, queryset):


def test_filter_object_method():
@strawberry_django.ordering.order(models.Fruit)
@strawberry_django.filters.filter(models.Fruit)
class Filter:
@strawberry_django.order_field
@strawberry_django.filter_field
def field_filter(self, value: str, prefix):
raise AssertionError("Never called due to object filter override")

@strawberry_django.order_field
@strawberry_django.filter_field
def filter(self, root, info, prefix, queryset):
assert self == _filter, "Unexpected self passed"
assert root == _filter, "Unexpected root passed"
Expand All @@ -279,6 +279,41 @@ def filter(self, root, info, prefix, queryset):
assert q_object, "Filter was not called"


def test_filter_value_resolution():
@strawberry_django.filters.filter(models.Fruit)
class Filter:
id: Optional[strawberry_django.ComparisonFilterLookup[GlobalID]]

gid = GlobalID("FruitNode", "125")
_filter: Any = Filter(
id=strawberry_django.ComparisonFilterLookup(
exact=gid, range=strawberry_django.RangeLookup(start=gid, end=gid)
)
)
_object: Any = object()
q = process_filters(_filter, _object, _object)[1]
assert q == Q(id__exact="125", id__range=["125", "125"])


def test_filter_method_value_resolution():
@strawberry_django.filters.filter(models.Fruit)
class Filter:
@strawberry_django.filter_field(resolve_value=True)
def field_filter_resolved(self, value: GlobalID, prefix):
assert isinstance(value, str)
return Q()

@strawberry_django.filter_field
def field_filter_skip_resolved(self, value: GlobalID, prefix):
assert isinstance(value, GlobalID)
return Q()

gid = GlobalID("FruitNode", "125")
_filter: Any = Filter(field_filter_resolved=gid, field_filter_skip_resolved=gid) # type: ignore
_object: Any = object()
process_filters(_filter, _object, _object)


def test_filter_type():
@strawberry_django.filter(models.Fruit, lookups=True)
class FruitOrder:
Expand Down

0 comments on commit 9c13e79

Please sign in to comment.