diff --git a/docs/guide/filters.md b/docs/guide/filters.md index bee2273c..d17c15e4 100644 --- a/docs/guide/filters.md +++ b/docs/guide/filters.md @@ -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`). @@ -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} diff --git a/strawberry_django/fields/filter_order.py b/strawberry_django/fields/filter_order.py index 15ffcc49..33ec2ebb 100644 --- a/strawberry_django/fields/filter_order.py +++ b/strawberry_django/fields/filter_order.py @@ -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): @@ -173,6 +174,7 @@ def filter_field( directives: Sequence[object] = (), extensions: list[FieldExtension] | None = None, filter_none: bool = False, + resolve_value: bool = UNSET, ) -> T: ... @@ -190,6 +192,7 @@ def filter_field( directives: Sequence[object] = (), extensions: list[FieldExtension] | None = None, filter_none: bool = False, + resolve_value: bool = UNSET, ) -> Any: ... @@ -207,6 +210,7 @@ def filter_field( directives: Sequence[object] = (), extensions: list[FieldExtension] | None = None, filter_none: bool = False, + resolve_value: bool = UNSET, ) -> StrawberryField: ... @@ -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. @@ -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, @@ -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, diff --git a/strawberry_django/fields/filter_types.py b/strawberry_django/fields/filter_types.py index f7d41ca4..9468afd9 100644 --- a/strawberry_django/fields/filter_types.py +++ b/strawberry_django/fields/filter_types.py @@ -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") @@ -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 diff --git a/strawberry_django/filters.py b/strawberry_django/filters.py index 0df75139..880d63f3 100644 --- a/strawberry_django/filters.py +++ b/strawberry_django/filters.py @@ -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, @@ -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 @@ -160,7 +161,7 @@ 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 @@ -168,6 +169,8 @@ def process_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: @@ -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 @@ -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 diff --git a/tests/filters/test_filters_v2.py b/tests/filters/test_filters_v2.py index c67c5caf..de170c92 100644 --- a/tests/filters/test_filters_v2.py +++ b/tests/filters/test_filters_v2.py @@ -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 @@ -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(): @@ -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" @@ -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: