From 64ff476be724e6716957d54206debe995dfdd667 Mon Sep 17 00:00:00 2001 From: Nikolay Dolzhenkov Date: Fri, 18 Jul 2025 01:17:30 +0300 Subject: [PATCH 1/9] Added FilterLookup for use in field annotations with FilterSchema --- ninja/__init__.py | 3 +- ninja/filter_schema.py | 173 ++++++++++++++++++++++++++++++++--------- 2 files changed, 138 insertions(+), 38 deletions(-) diff --git a/ninja/__init__.py b/ninja/__init__.py index 7d661ab09..384042dd9 100644 --- a/ninja/__init__.py +++ b/ninja/__init__.py @@ -6,7 +6,7 @@ from pydantic import Field from ninja.files import UploadedFile -from ninja.filter_schema import FilterSchema +from ninja.filter_schema import FilterLookup, FilterSchema from ninja.main import NinjaAPI from ninja.openapi.docs import Redoc, Swagger from ninja.orm import ModelSchema @@ -54,6 +54,7 @@ "Schema", "ModelSchema", "FilterSchema", + "FilterLookup", "Swagger", "Redoc", "PatchDict", diff --git a/ninja/filter_schema.py b/ninja/filter_schema.py index fe8f3b40c..1fac3d9be 100644 --- a/ninja/filter_schema.py +++ b/ninja/filter_schema.py @@ -1,4 +1,4 @@ -from typing import Any, TypeVar, cast +from typing import Any, List, Optional, TypeVar, cast from django.core.exceptions import ImproperlyConfigured from django.db.models import Q, QuerySet @@ -7,19 +7,43 @@ from .schema import Schema -DEFAULT_IGNORE_NONE = True -DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR = "AND" -DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR = "OR" - # XOR is available only in Django 4.1+: https://docs.djangoproject.com/en/4.1/ref/models/querysets/#xor ExpressionConnector = Literal["AND", "OR", "XOR"] - -# class FilterConfig(BaseConfig): -# ignore_none: bool = DEFAULT_IGNORE_NONE -# expression_connector: ExpressionConnector = cast( -# ExpressionConnector, DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR -# ) +DEFAULT_IGNORE_NONE = True +DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR: ExpressionConnector = "AND" +DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR: ExpressionConnector = "OR" + + +class FilterLookup: + """ + Annotation class for specifying database query lookups in FilterSchema fields. + + Example usage: + class MyFilterSchema(FilterSchema): + name: Annotated[str | None, FilterLookup("name__icontains")] = None + search: Annotated[str | None, FilterLookup(["name__icontains", "email__icontains"])] = None + """ + + def __init__( + self, + q: str | List[str], + *, + expression_connector: str = DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR, + ignore_none: Optional[bool] = DEFAULT_IGNORE_NONE, + ): + """ + Args: + q: Database lookup expression(s). Can be: + - A string like "name__icontains" + - A list of strings like ["name__icontains", "email__icontains"] + - Use "__" prefix for implicit field name: "__icontains" becomes "fieldname__icontains" + expression_connector: How to combine multiple field-level expressions ("OR", "AND", "XOR"). Default is "OR". + ignore_none: Whether to ignore None values for this field specifically. Default is True. + """ + self.q = q + self.expression_connector = cast(ExpressionConnector, expression_connector) + self.ignore_none = ignore_none T = TypeVar("T", bound=QuerySet) @@ -33,8 +57,8 @@ class FilterSchema(Schema): class Config(Schema.Config): ignore_none: bool = DEFAULT_IGNORE_NONE - expression_connector: ExpressionConnector = cast( - ExpressionConnector, DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR + expression_connector: ExpressionConnector = ( + DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR ) def custom_expression(self) -> Q: @@ -55,62 +79,137 @@ def get_filter_expression(self) -> Q: def filter(self, queryset: T) -> T: return queryset.filter(self.get_filter_expression()) + def _get_filter_lookup( + self, field_name: str, field_info: FieldInfo + ) -> Optional[FilterLookup]: + if not hasattr(field_info, "metadata") or not field_info.metadata: + return None + + filter_lookups = [ + metadata_item + for metadata_item in field_info.metadata + if isinstance(metadata_item, FilterLookup) + ] + + if len(filter_lookups) == 0: + return None + elif len(filter_lookups) == 1: + return filter_lookups[0] + else: + raise ImproperlyConfigured( + f"Multiple FilterLookup instances found in metadata of {self.__class__.__name__}.{field_name}. " + f"Use at most one FilterLookup instance per field. " + f"If you need multiple lookups, specify them as a list in a single FilterLookup: " + f"{field_name}: Annotated[{field_info.annotation}, FilterLookup(['lookup1', 'lookup2', ...])]" + ) + + def _get_field_q_expression( + self, + field_name: str, + field_info: FieldInfo, + default: str | list[str] | None = None, + ) -> str | List[str] | None: + filter_lookup = self._get_filter_lookup(field_name, field_info) + if filter_lookup: + return filter_lookup.q if filter_lookup.q is not None else default + + # Legacy approach, consider removing in future versions + field_extra = cast(dict, field_info.json_schema_extra) or {} + return cast(str | list[str] | None, field_extra.get("q", default)) + + def _get_field_expression_connector( + self, + field_name: str, + field_info: FieldInfo, + default: ExpressionConnector | None = None, + ) -> ExpressionConnector | None: + filter_lookup = self._get_filter_lookup(field_name, field_info) + if filter_lookup: + return filter_lookup.expression_connector or default + + # Legacy approach, consider removing in future versions + field_extra = cast(dict, field_info.json_schema_extra) or {} + return cast( + ExpressionConnector | None, field_extra.get("expression_connector", default) + ) + + def _get_field_ignore_none( + self, field_name: str, field_info: FieldInfo, default: bool | None = None + ) -> bool | None: + filter_lookup = self._get_filter_lookup(field_name, field_info) + if filter_lookup: + return ( + filter_lookup.ignore_none + if filter_lookup.ignore_none is not None + else default + ) + + # Legacy approach, consider removing in future versions + field_extra = cast(dict, field_info.json_schema_extra) or {} + return cast(bool | None, field_extra.get("ignore_none", default)) + def _resolve_field_expression( - self, field_name: str, field_value: Any, field: FieldInfo + self, field_name: str, field_value: Any, field_info: FieldInfo ) -> Q: func = getattr(self, f"filter_{field_name}", None) if callable(func): - return func(field_value) # type: ignore[no-any-return] + return cast(Q, func(field_value)) - field_extra = field.json_schema_extra or {} + q_expression = self._get_field_q_expression(field_name, field_info) + expression_connector = self._get_field_expression_connector( + field_name, field_info, default=DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR + ) - q_expression = field_extra.get("q", None) # type: ignore if not q_expression: return Q(**{field_name: field_value}) elif isinstance(q_expression, str): if q_expression.startswith("__"): q_expression = f"{field_name}{q_expression}" return Q(**{q_expression: field_value}) - elif isinstance(q_expression, list): - expression_connector = field_extra.get( # type: ignore - "expression_connector", DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR - ) + elif isinstance(q_expression, list) and all( + isinstance(item, str) for item in q_expression + ): q = Q() for q_expression_part in q_expression: - q_expression_part = str(q_expression_part) if q_expression_part.startswith("__"): q_expression_part = f"{field_name}{q_expression_part}" - q = q._combine( # type: ignore + q = q._combine( # type: ignore[attr-defined] Q(**{q_expression_part: field_value}), expression_connector, ) return q else: raise ImproperlyConfigured( - f"Field {field_name} of {self.__class__.__name__} defines an invalid value under 'q' kwarg.\n" - f"Define a 'q' kwarg as a string or a list of strings, each string corresponding to a database lookup you wish to filter against:\n" - f" {field_name}: {field.annotation} = Field(..., q='')\n" - f"or\n" - f" {field_name}: {field.annotation} = Field(..., q=['lookup1', 'lookup2', ...])\n" - f"You can omit the field name and make it implicit by starting the lookup directly by '__'." + f"Field {field_name} of {self.__class__.__name__} defines an invalid value for 'q'.\n" + f"Use FilterLookup annotation: {field_name}: Annotated[{field_info.annotation}, FilterLookup('lookup')]\n" f"Alternatively, you can implement {self.__class__.__name__}.filter_{field_name} that must return a Q expression for that field" ) def _connect_fields(self) -> Q: q = Q() - for field_name, field in self.model_fields.items(): + for field_name, field_info in self.__class__.model_fields.items(): filter_value = getattr(self, field_name) - field_extra = field.json_schema_extra or {} - ignore_none = field_extra.get( # type: ignore - "ignore_none", - self.model_config["ignore_none"], # type: ignore + ignore_none = self._get_field_ignore_none( + field_name, + field_info, + cast( + bool | None, + self.model_config.get("ignore_none", DEFAULT_IGNORE_NONE), + ), ) - # Resolve q for a field even if we skip it due to None value + # Resolve Q expression for a field even if we skip it due to None value # So that improperly configured fields are easier to detect - field_q = self._resolve_field_expression(field_name, filter_value, field) + field_q = self._resolve_field_expression( + field_name, filter_value, field_info + ) if filter_value is None and ignore_none: continue - q = q._combine(field_q, self.model_config["expression_connector"]) # type: ignore + q = q._combine( # type: ignore[attr-defined] + field_q, + self.model_config.get( + "expression_connector", DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR + ), + ) return q From c2e387163b22c8819ca285145056950ac7971548 Mon Sep 17 00:00:00 2001 From: Nikolay Dolzhenkov Date: Fri, 18 Jul 2025 02:25:26 +0300 Subject: [PATCH 2/9] Added more tests to test_filter_schema.py --- ninja/filter_schema.py | 42 +++---- tests/test_filter_schema.py | 234 ++++++++++++++++++++++++++++++++++-- 2 files changed, 239 insertions(+), 37 deletions(-) diff --git a/ninja/filter_schema.py b/ninja/filter_schema.py index 1fac3d9be..958033182 100644 --- a/ninja/filter_schema.py +++ b/ninja/filter_schema.py @@ -10,7 +10,7 @@ # XOR is available only in Django 4.1+: https://docs.djangoproject.com/en/4.1/ref/models/querysets/#xor ExpressionConnector = Literal["AND", "OR", "XOR"] -DEFAULT_IGNORE_NONE = True +DEFAULT_IGNORE_NONE: bool = True DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR: ExpressionConnector = "AND" DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR: ExpressionConnector = "OR" @@ -29,8 +29,8 @@ def __init__( self, q: str | List[str], *, - expression_connector: str = DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR, - ignore_none: Optional[bool] = DEFAULT_IGNORE_NONE, + expression_connector: ExpressionConnector = DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR, + ignore_none: bool = DEFAULT_IGNORE_NONE, ): """ Args: @@ -42,7 +42,7 @@ def __init__( ignore_none: Whether to ignore None values for this field specifically. Default is True. """ self.q = q - self.expression_connector = cast(ExpressionConnector, expression_connector) + self.expression_connector = expression_connector self.ignore_none = ignore_none @@ -50,11 +50,6 @@ def __init__( class FilterSchema(Schema): - # if TYPE_CHECKING: - # __config__: ClassVar[Type[FilterConfig]] = FilterConfig # pragma: no cover - - # Config = FilterConfig - class Config(Schema.Config): ignore_none: bool = DEFAULT_IGNORE_NONE expression_connector: ExpressionConnector = ( @@ -97,9 +92,9 @@ def _get_filter_lookup( return filter_lookups[0] else: raise ImproperlyConfigured( - f"Multiple FilterLookup instances found in metadata of {self.__class__.__name__}.{field_name}. " - f"Use at most one FilterLookup instance per field. " - f"If you need multiple lookups, specify them as a list in a single FilterLookup: " + f"Multiple FilterLookup instances found in metadata of {self.__class__.__name__}.{field_name}.\n" + f"Use at most one FilterLookup instance per field.\n" + f"If you need multiple lookups, specify them as a list in a single FilterLookup:\n" f"{field_name}: Annotated[{field_info.annotation}, FilterLookup(['lookup1', 'lookup2', ...])]" ) @@ -138,11 +133,7 @@ def _get_field_ignore_none( ) -> bool | None: filter_lookup = self._get_filter_lookup(field_name, field_info) if filter_lookup: - return ( - filter_lookup.ignore_none - if filter_lookup.ignore_none is not None - else default - ) + return filter_lookup.ignore_none # Legacy approach, consider removing in future versions field_extra = cast(dict, field_info.json_schema_extra) or {} @@ -187,15 +178,18 @@ def _resolve_field_expression( def _connect_fields(self) -> Q: q = Q() + class_ignore_none = self.model_config.get("ignore_none", DEFAULT_IGNORE_NONE) for field_name, field_info in self.__class__.model_fields.items(): filter_value = getattr(self, field_name) - ignore_none = self._get_field_ignore_none( - field_name, - field_info, - cast( - bool | None, - self.model_config.get("ignore_none", DEFAULT_IGNORE_NONE), - ), + # class-level ignore_none set to False (non-default) takes precedence over field-level ignore_none + ignore_none = ( + False + if class_ignore_none is False + else self._get_field_ignore_none( + field_name, + field_info, + DEFAULT_IGNORE_NONE, + ) ) # Resolve Q expression for a field even if we skip it due to None value diff --git a/tests/test_filter_schema.py b/tests/test_filter_schema.py index 6c32d04a1..7622b213b 100644 --- a/tests/test_filter_schema.py +++ b/tests/test_filter_schema.py @@ -4,8 +4,9 @@ from django.core.exceptions import ImproperlyConfigured from django.db.models import Q, QuerySet from pydantic import Field +from typing_extensions import Annotated -from ninja import FilterSchema +from ninja import FilterLookup, FilterSchema class FakeQS(QuerySet): @@ -19,6 +20,8 @@ def filter(self, *args, **kwargs): def test_simple_config(): + """Test basic field filtering without q parameter.""" + class DummyFilterSchema(FilterSchema): name: Optional[str] = None @@ -27,7 +30,20 @@ class DummyFilterSchema(FilterSchema): assert q == Q(name="foobar") -def test_improperly_configured(): +def test_annotated_without_filter_lookup(): + """Test Annotated field without FilterLookup instance falls back to default behavior.""" + + class DummyFilterSchema(FilterSchema): + name: Annotated[Optional[str], "some_annotation"] = None + + filter_instance = DummyFilterSchema(name="foobar") + q = filter_instance.get_filter_expression() + assert q == Q(name="foobar") + + +def test_improperly_configured_deprecated(): + """Test ImproperlyConfigured error when q is not a string or list of strings (deprecated Field approach).""" + class DummyFilterSchema(FilterSchema): popular: Optional[str] = Field(None, q=Q(view_count__gt=1000)) @@ -36,7 +52,20 @@ class DummyFilterSchema(FilterSchema): filter_instance.get_filter_expression() -def test_empty_q_when_none_ignored(): +def test_improperly_configured_annotated(): + """Test ImproperlyConfigured error when q is not a string or list of strings (FilterLookup annotation).""" + + class DummyFilterSchema(FilterSchema): + popular: Annotated[Optional[str], FilterLookup(Q(view_count__gt=1000))] = None + + filter_instance = DummyFilterSchema() + with pytest.raises(ImproperlyConfigured): + filter_instance.get_filter_expression() + + +def test_empty_q_when_none_ignored_deprecated(): + """Test empty Q expression when None values are ignored (deprecated Field approach).""" + class DummyFilterSchema(FilterSchema): name: Optional[str] = Field(None, q="name__icontains") tag: Optional[str] = Field(None, q="tag") @@ -46,8 +75,21 @@ class DummyFilterSchema(FilterSchema): assert q == Q() +def test_empty_q_when_none_ignored_annotated(): + """Test empty Q expression when None values are ignored (FilterLookup annotation).""" + + class DummyFilterSchema(FilterSchema): + name: Annotated[Optional[str], FilterLookup("name__icontains")] = None + tag: Annotated[Optional[str], FilterLookup("tag")] = None + + filter_instance = DummyFilterSchema() + q = filter_instance.get_filter_expression() + assert q == Q() + + @pytest.mark.parametrize("implicit_field_name", [False, True]) -def test_q_expressions2(implicit_field_name): +def test_q_expressions2_deprecated(implicit_field_name): + """Test implicit vs explicit field names in q expressions (deprecated Field approach).""" if implicit_field_name: q = "__icontains" else: @@ -62,7 +104,26 @@ class DummyFilterSchema(FilterSchema): assert q == Q(name__icontains="John") -def test_q_expressions3(): +@pytest.mark.parametrize("implicit_field_name", [False, True]) +def test_q_expressions2_annotated(implicit_field_name): + """Test implicit vs explicit field names in q expressions (FilterLookup annotation).""" + if implicit_field_name: + q = "__icontains" + else: + q = "name__icontains" + + class DummyFilterSchema(FilterSchema): + name: Annotated[Optional[str], FilterLookup(q)] = None + tag: Annotated[Optional[str], FilterLookup("tag")] = None + + filter_instance = DummyFilterSchema(name="John", tag=None) + q = filter_instance.get_filter_expression() + assert q == Q(name__icontains="John") + + +def test_q_expressions3_deprecated(): + """Test multiple fields with different q expressions (deprecated Field approach).""" + class DummyFilterSchema(FilterSchema): name: Optional[str] = Field(None, q="name__icontains") tag: Optional[str] = Field(None, q="tag") @@ -72,8 +133,21 @@ class DummyFilterSchema(FilterSchema): assert q == Q(name__icontains="John") & Q(tag="active") +def test_q_expressions3_annotated(): + """Test multiple fields with different q expressions (FilterLookup annotation).""" + + class DummyFilterSchema(FilterSchema): + name: Annotated[Optional[str], FilterLookup("name__icontains")] = None + tag: Annotated[Optional[str], FilterLookup("tag")] = None + + filter_instance = DummyFilterSchema(name="John", tag="active") + q = filter_instance.get_filter_expression() + assert q == Q(name__icontains="John") & Q(tag="active") + + @pytest.mark.parametrize("implicit_field_name", [False, True]) -def test_q_is_a_list(implicit_field_name): +def test_q_is_a_list_deprecated(implicit_field_name): + """Test q as list of lookups with OR connector (deprecated Field approach).""" if implicit_field_name: q__name = "__icontains" else: @@ -90,7 +164,30 @@ class DummyFilterSchema(FilterSchema): ) -def test_field_level_expression_connector(): +@pytest.mark.parametrize("implicit_field_name", [False, True]) +def test_q_is_a_list_annotated(implicit_field_name): + """Test q as list of lookups with OR connector (FilterLookup annotation).""" + if implicit_field_name: + q__name = "__icontains" + else: + q__name = "name__icontains" + + class DummyFilterSchema(FilterSchema): + name: Annotated[ + Optional[str], FilterLookup([q__name, "user__username__icontains"]) + ] = None + tag: Annotated[Optional[str], FilterLookup("tag")] = None + + filter_instance = DummyFilterSchema(name="foo", tag="bar") + q = filter_instance.get_filter_expression() + assert q == (Q(name__icontains="foo") | Q(user__username__icontains="foo")) & Q( + tag="bar" + ) + + +def test_field_level_expression_connector_deprecated(): + """Test field-level expression connector (deprecated Field approach).""" + class DummyFilterSchema(FilterSchema): name: Optional[str] = Field( q=["name__icontains", "user__username__icontains"], @@ -105,7 +202,29 @@ class DummyFilterSchema(FilterSchema): ) -def test_class_level_expression_connector(): +def test_field_level_expression_connector_annotated(): + """Test field-level expression connector (FilterLookup annotation).""" + + class DummyFilterSchema(FilterSchema): + name: Annotated[ + Optional[str], + FilterLookup( + ["name__icontains", "user__username__icontains"], + expression_connector="AND", + ), + ] = None + tag: Annotated[Optional[str], FilterLookup("tag")] = None + + filter_instance = DummyFilterSchema(name="foo", tag="bar") + q = filter_instance.get_filter_expression() + assert q == Q(name__icontains="foo") & Q(user__username__icontains="foo") & Q( + tag="bar" + ) + + +def test_class_level_expression_connector_deprecated(): + """Test class-level expression connector (deprecated Field approach).""" + class DummyFilterSchema(FilterSchema): tag1: Optional[str] = Field(None, q="tag1") tag2: Optional[str] = Field(None, q="tag2") @@ -118,7 +237,24 @@ class Config: assert q == Q(tag1="foo") | Q(tag2="bar") -def test_class_level_and_field_level_expression_connector(): +def test_class_level_expression_connector_annotated(): + """Test class-level expression connector (FilterLookup annotation).""" + + class DummyFilterSchema(FilterSchema): + tag1: Annotated[Optional[str], FilterLookup("tag1")] = None + tag2: Annotated[Optional[str], FilterLookup("tag2")] = None + + class Config: + expression_connector = "OR" + + filter_instance = DummyFilterSchema(tag1="foo", tag2="bar") + q = filter_instance.get_filter_expression() + assert q == Q(tag1="foo") | Q(tag2="bar") + + +def test_class_level_and_field_level_expression_connector_deprecated(): + """Test both class-level and field-level expression connectors (deprecated Field approach).""" + class DummyFilterSchema(FilterSchema): name: Optional[str] = Field( q=["name__icontains", "user__username__icontains"], @@ -136,7 +272,32 @@ class Config: ) -def test_ignore_none(): +def test_class_level_and_field_level_expression_connector_annotated(): + """Test both class-level and field-level expression connectors (FilterLookup annotation).""" + + class DummyFilterSchema(FilterSchema): + name: Annotated[ + Optional[str], + FilterLookup( + ["name__icontains", "user__username__icontains"], + expression_connector="AND", + ), + ] = None + tag: Annotated[Optional[str], FilterLookup("tag")] = None + + class Config: + expression_connector = "OR" + + filter_instance = DummyFilterSchema(name="foo", tag="bar") + q = filter_instance.get_filter_expression() + assert q == Q(name__icontains="foo") & Q(user__username__icontains="foo") | Q( + tag="bar" + ) + + +def test_ignore_none_deprecated(): + """Test field-level ignore_none setting (deprecated Field approach).""" + class DummyFilterSchema(FilterSchema): tag: Optional[str] = Field(None, q="tag", ignore_none=False) @@ -145,7 +306,20 @@ class DummyFilterSchema(FilterSchema): assert q == Q(tag=None) -def test_ignore_none_class_level(): +def test_ignore_none_annotated(): + """Test field-level ignore_none setting (FilterLookup annotation).""" + + class DummyFilterSchema(FilterSchema): + tag: Annotated[Optional[str], FilterLookup("tag", ignore_none=False)] = None + + filter_instance = DummyFilterSchema() + q = filter_instance.get_filter_expression() + assert q == Q(tag=None) + + +def test_ignore_none_class_level_deprecated(): + """Test class-level ignore_none setting (deprecated Field approach).""" + class DummyFilterSchema(FilterSchema): tag1: Optional[str] = Field(None, q="tag1") tag2: Optional[str] = Field(None, q="tag2") @@ -158,7 +332,24 @@ class Config: assert q == Q(tag1=None) & Q(tag2=None) +def test_ignore_none_class_level_annotated(): + """Test class-level ignore_none setting (FilterLookup annotation).""" + + class DummyFilterSchema(FilterSchema): + tag1: Annotated[Optional[str], FilterLookup("tag1")] = None + tag2: Annotated[Optional[str], FilterLookup("tag2")] = None + + class Config: + ignore_none = False + + filter_instance = DummyFilterSchema() + q = filter_instance.get_filter_expression() + assert q == Q(tag1=None) & Q(tag2=None) + + def test_field_level_custom_expression(): + """Test custom filter_* methods override field configuration.""" + class DummyFilterSchema(FilterSchema): name: Optional[str] = None popular: Optional[bool] = None @@ -180,8 +371,10 @@ def filter_popular(self, value): def test_class_level_custom_expression(): + """Test custom_expression method overrides all field configuration.""" + class DummyFilterSchema(FilterSchema): - adult: Optional[bool] = Field(None, q="this_will_be_ignored") + adult: Annotated[Optional[bool], FilterLookup("this_will_be_ignored")] = None def custom_expression(self) -> Q: return Q(age__gte=18) if self.adult is True else Q() @@ -192,10 +385,25 @@ def custom_expression(self) -> Q: def test_filter_called(): + """Test filter() method applies expression to queryset (FilterLookup annotation).""" + class DummyFilterSchema(FilterSchema): - name: Optional[str] = Field(None, q="name") + name: Annotated[Optional[str], FilterLookup("name")] = None filter_instance = DummyFilterSchema(name="foobar") queryset = FakeQS() queryset = filter_instance.filter(queryset) assert queryset.filtered + + +def test_multiple_filter_lookup_instances_error(): + """Test that multiple FilterLookup instances in a single annotation raises ImproperlyConfigured.""" + + class DummyFilterSchema(FilterSchema): + name: Annotated[ + Optional[str], FilterLookup("name__icontains"), FilterLookup("name__exact") + ] = None + + filter_instance = DummyFilterSchema(name="test") + with pytest.raises(ImproperlyConfigured): + filter_instance.get_filter_expression() From 00a727e108e4fc9b17fe73c3c6d3c40d1f4f496a Mon Sep 17 00:00:00 2001 From: Nikolay Dolzhenkov Date: Fri, 18 Jul 2025 17:27:09 +0300 Subject: [PATCH 3/9] Prefer old-school Union[] for backward compatibility --- ninja/filter_schema.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/ninja/filter_schema.py b/ninja/filter_schema.py index 958033182..04cd1357c 100644 --- a/ninja/filter_schema.py +++ b/ninja/filter_schema.py @@ -1,4 +1,4 @@ -from typing import Any, List, Optional, TypeVar, cast +from typing import Any, List, Optional, TypeVar, Union, cast from django.core.exceptions import ImproperlyConfigured from django.db.models import Q, QuerySet @@ -21,13 +21,13 @@ class FilterLookup: Example usage: class MyFilterSchema(FilterSchema): - name: Annotated[str | None, FilterLookup("name__icontains")] = None - search: Annotated[str | None, FilterLookup(["name__icontains", "email__icontains"])] = None + name: Annotated[Union[str, None], FilterLookup("name__icontains")] = None + search: Annotated[Union[str, None], FilterLookup(["name__icontains", "email__icontains"])] = None """ def __init__( self, - q: str | List[str], + q: Union[str, List[str]], *, expression_connector: ExpressionConnector = DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR, ignore_none: bool = DEFAULT_IGNORE_NONE, @@ -102,22 +102,22 @@ def _get_field_q_expression( self, field_name: str, field_info: FieldInfo, - default: str | list[str] | None = None, - ) -> str | List[str] | None: + default: Union[str, list[str], None] = None, + ) -> Union[str, List[str], None]: filter_lookup = self._get_filter_lookup(field_name, field_info) if filter_lookup: return filter_lookup.q if filter_lookup.q is not None else default # Legacy approach, consider removing in future versions field_extra = cast(dict, field_info.json_schema_extra) or {} - return cast(str | list[str] | None, field_extra.get("q", default)) + return cast(Union[str, list[str], None], field_extra.get("q", default)) def _get_field_expression_connector( self, field_name: str, field_info: FieldInfo, - default: ExpressionConnector | None = None, - ) -> ExpressionConnector | None: + default: Union[ExpressionConnector, None] = None, + ) -> Union[ExpressionConnector, None]: filter_lookup = self._get_filter_lookup(field_name, field_info) if filter_lookup: return filter_lookup.expression_connector or default @@ -125,19 +125,20 @@ def _get_field_expression_connector( # Legacy approach, consider removing in future versions field_extra = cast(dict, field_info.json_schema_extra) or {} return cast( - ExpressionConnector | None, field_extra.get("expression_connector", default) + Union[ExpressionConnector, None], + field_extra.get("expression_connector", default), ) def _get_field_ignore_none( - self, field_name: str, field_info: FieldInfo, default: bool | None = None - ) -> bool | None: + self, field_name: str, field_info: FieldInfo, default: Union[bool, None] = None + ) -> Union[bool, None]: filter_lookup = self._get_filter_lookup(field_name, field_info) if filter_lookup: return filter_lookup.ignore_none # Legacy approach, consider removing in future versions field_extra = cast(dict, field_info.json_schema_extra) or {} - return cast(bool | None, field_extra.get("ignore_none", default)) + return cast(Union[bool, None], field_extra.get("ignore_none", default)) def _resolve_field_expression( self, field_name: str, field_value: Any, field_info: FieldInfo From 20224bc67be17c71eb21df4086358b4271fc9662 Mon Sep 17 00:00:00 2001 From: Nikolay Dolzhenkov Date: Fri, 18 Jul 2025 18:18:34 +0300 Subject: [PATCH 4/9] Updated filtering.md to align with newly supported annotation --- docs/docs/guides/input/filtering.md | 78 ++++++++++++++++++++--------- docs/mkdocs.yml | 1 + 2 files changed, 56 insertions(+), 23 deletions(-) diff --git a/docs/docs/guides/input/filtering.md b/docs/docs/guides/input/filtering.md index eb46c8bf8..fddfe2594 100644 --- a/docs/docs/guides/input/filtering.md +++ b/docs/docs/guides/input/filtering.md @@ -72,26 +72,52 @@ class BookFilterSchema(FilterSchema): ``` The `name` field will be converted into `Q(name=...)` expression. -When your database lookups are more complicated than that, you can explicitly specify them in the field definition using a `"q"` kwarg: -```python hl_lines="2" +When your database lookups are more complicated than that, you can annotate your fields with an instance of `FilterLookup` where you specify how you wish your field to be looked up for filtering: +```python hl_lines="5" +from ninja import FilterSchema, FilterLookup +from typing import Annotated + class BookFilterSchema(FilterSchema): - name: Optional[str] = Field(None, q='name__icontains') + name: Annotated[Optional[str], FilterLookup("name__icontains")] = None ``` -You can even specify multiple lookup keyword argument names as a list: -```python hl_lines="2 3 4" + +You can even specify multiple lookups as a list: +```python hl_lines="3 4 5" class BookFilterSchema(FilterSchema): - search: Optional[str] = Field(None, q=['name__icontains', - 'author__name__icontains', - 'publisher__name__icontains']) + search: Annotated[Optional[str], FilterLookup( + ["name__icontains", + "author__name__icontains", + "publisher__name__icontains"] + )] ``` + +By default, field-level expressions are combined using `"OR"` connector, so with the above setup, a query parameter `?search=foobar` will search for books that have "foobar" in either of their name, author or publisher. + And to make generic fields, you can make the field name implicit by skipping it: -```python hl_lines="2" -IContainsField = Annotated[Optional[str], Field(None, q='__icontains')] +```python hl_lines="1 4" +IContainsField = Annotated[Optional[str], FilterLookup('__icontains')] class BookFilterSchema(FilterSchema): - name: IContainsField + name: IContainsField = None ``` -By default, field-level expressions are combined using `"OR"` connector, so with the above setup, a query parameter `?search=foobar` will search for books that have "foobar" in either of their name, author or publisher. + +??? note "Deprecated syntax" + + In previous versions, database lookups were specified using `Field(q=...)` syntax: + ```python + from ninja import FilterSchema, Field + + class BookFilterSchema(FilterSchema): + name: Optional[str] = Field(None, q="name__icontains") + ``` + + This approach is still supported, but it is considered **deprecated** and **not recommended** for new code because: + + - Poor IDE support (IDEs don't recognize custom `Field` arguments) + - Uses deprecated Pydantic features (`**extra`) + - Less type-safe and harder to maintain + + The new `FilterLookup` annotation provides better developer experience with full IDE support and type safety. Prefer using `FilterLookup` for new projects. ## Combining expressions @@ -103,7 +129,9 @@ By default, So, with the following `FilterSchema`... ```python class BookFilterSchema(FilterSchema): - search: Optional[str] = Field(None, q=['name__icontains', 'author__name__icontains']) + search: Annotated[ + Optional[str], + FilterLookup(["name__icontains", "author__name__icontains"])] = None popular: Optional[bool] = None ``` ...and the following query parameters from the user @@ -114,14 +142,18 @@ the `FilterSchema` instance will look for popular books that have `harry` in the You can customize this behavior using an `expression_connector` argument in field-level and class-level definition: -```python hl_lines="3 7" +```python hl_lines="6 11" class BookFilterSchema(FilterSchema): - active: Optional[bool] = Field(None, q=['is_active', 'publisher__is_active'], - expression_connector='AND') - name: Optional[str] = Field(None, q='name__icontains') + active: Annotated[ + Optional[bool], + FilterLookup( + ["is_active", "publisher__is_active"], + expression_connector="AND" + )] = None + name: Annotated[Optional[str], FilterLookup("name__icontains")] = None class Config: - expression_connector = 'OR' + expression_connector = "OR" ``` An expression connector can take the values of `"OR"`, `"AND"` and `"XOR"`, but the latter is only [supported](https://docs.djangoproject.com/en/4.1/ref/models/querysets/#xor) in Django starting with 4.1. @@ -139,17 +171,17 @@ You can make the `FilterSchema` treat `None` as a valid value that should be fil This can be done on a field level with a `ignore_none` kwarg: ```python hl_lines="3" class BookFilterSchema(FilterSchema): - name: Optional[str] = Field(None, q='name__icontains') - tag: Optional[str] = Field(None, q='tag', ignore_none=False) + name: Annotated[Optional[str], FilterLookup("name__icontains")] = None + tag: Annotated[Optional[str], FilterLookup("tag", ignore_none=False)] = None ``` This way when no other value for `"tag"` is provided by the user, the filtering will always include a condition `tag=None`. -You can also specify this settings for all fields at the same time in the Config: +You can also specify this setting for all fields at the same time in the Config: ```python hl_lines="6" class BookFilterSchema(FilterSchema): - name: Optional[str] = Field(None, q='name__icontains') - tag: Optional[str] = Field(None, q='tag', ignore_none=False) + name: Annotated[Optional[str], FilterLookup("name__icontains")] = None + tag: Optional[str] = None class Config: ignore_none = False diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index fc06094d3..335e96ad9 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -99,6 +99,7 @@ markdown_extensions: - abbr - codehilite - admonition + - pymdownx.details - pymdownx.superfences plugins: - search From b590c84204b0f937bbccaae393b250281485a888 Mon Sep 17 00:00:00 2001 From: Nikolay Dolzhenkov Date: Fri, 18 Jul 2025 18:23:27 +0300 Subject: [PATCH 5/9] Prefer List[] instead of list[] --- ninja/filter_schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ninja/filter_schema.py b/ninja/filter_schema.py index 04cd1357c..3bae1c596 100644 --- a/ninja/filter_schema.py +++ b/ninja/filter_schema.py @@ -102,7 +102,7 @@ def _get_field_q_expression( self, field_name: str, field_info: FieldInfo, - default: Union[str, list[str], None] = None, + default: Union[str, List[str], None] = None, ) -> Union[str, List[str], None]: filter_lookup = self._get_filter_lookup(field_name, field_info) if filter_lookup: @@ -110,7 +110,7 @@ def _get_field_q_expression( # Legacy approach, consider removing in future versions field_extra = cast(dict, field_info.json_schema_extra) or {} - return cast(Union[str, list[str], None], field_extra.get("q", default)) + return cast(Union[str, List[str], None], field_extra.get("q", default)) def _get_field_expression_connector( self, From c226cbd5289c2c4ae6923618c8281dbdcdbcdc68 Mon Sep 17 00:00:00 2001 From: Nikolay Dolzhenkov Date: Tue, 12 Aug 2025 20:46:05 +0200 Subject: [PATCH 6/9] bringing back warning when deprecated filtering configuration is used --- ninja/filter_schema.py | 73 +++++++++++++++++++++++++++---------- tests/test_filter_schema.py | 20 ++++++++++ 2 files changed, 74 insertions(+), 19 deletions(-) diff --git a/ninja/filter_schema.py b/ninja/filter_schema.py index eb2cc3417..e447d09a0 100644 --- a/ninja/filter_schema.py +++ b/ninja/filter_schema.py @@ -1,3 +1,4 @@ +import warnings from typing import Any, List, Optional, TypeVar, Union, cast from django.core.exceptions import ImproperlyConfigured @@ -6,6 +7,7 @@ from pydantic.fields import FieldInfo from typing_extensions import Literal +from .constants import NOT_SET from .schema import Schema # XOR is available only in Django 4.1+: https://docs.djangoproject.com/en/4.1/ref/models/querysets/#xor @@ -28,7 +30,7 @@ class MyFilterSchema(FilterSchema): def __init__( self, - q: Union[str, List[str]], + q: Union[str, List[str], None], *, expression_connector: ExpressionConnector = DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR, ignore_none: bool = DEFAULT_IGNORE_NONE, @@ -107,43 +109,48 @@ def _get_field_q_expression( self, field_name: str, field_info: FieldInfo, - default: Union[str, List[str], None] = None, ) -> Union[str, List[str], None]: filter_lookup = self._get_filter_lookup(field_name, field_info) if filter_lookup: - return filter_lookup.q if filter_lookup.q is not None else default + return filter_lookup.q # Legacy approach, consider removing in future versions - field_extra = cast(dict, field_info.json_schema_extra) or {} - return cast(Union[str, List[str], None], field_extra.get("q", default)) + return cast( + Union[str, List[str], None], + self._get_from_deprecated_field_extra(field_name, field_info, "q"), + ) def _get_field_expression_connector( self, field_name: str, field_info: FieldInfo, - default: Union[ExpressionConnector, None] = None, ) -> Union[ExpressionConnector, None]: filter_lookup = self._get_filter_lookup(field_name, field_info) if filter_lookup: - return filter_lookup.expression_connector or default + return filter_lookup.expression_connector # Legacy approach, consider removing in future versions - field_extra = cast(dict, field_info.json_schema_extra) or {} return cast( - Union[ExpressionConnector, None], - field_extra.get("expression_connector", default), + Union[ExpressionConnector | None], + self._get_from_deprecated_field_extra( + field_name, field_info, "expression_connector" + ), ) def _get_field_ignore_none( - self, field_name: str, field_info: FieldInfo, default: Union[bool, None] = None + self, field_name: str, field_info: FieldInfo ) -> Union[bool, None]: filter_lookup = self._get_filter_lookup(field_name, field_info) if filter_lookup: return filter_lookup.ignore_none # Legacy approach, consider removing in future versions - field_extra = cast(dict, field_info.json_schema_extra) or {} - return cast(Union[bool, None], field_extra.get("ignore_none", default)) + return cast( + Union[bool, None], + self._get_from_deprecated_field_extra( + field_name, field_info, "ignore_none" + ), + ) def _resolve_field_expression( self, field_name: str, field_value: Any, field_info: FieldInfo @@ -153,8 +160,9 @@ def _resolve_field_expression( return cast(Q, func(field_value)) q_expression = self._get_field_q_expression(field_name, field_info) - expression_connector = self._get_field_expression_connector( - field_name, field_info, default=DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR + expression_connector = ( + self._get_field_expression_connector(field_name, field_info) + or DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR ) if not q_expression: @@ -191,11 +199,14 @@ def _connect_fields(self) -> Q: ignore_none = ( False if class_ignore_none is False - else self._get_field_ignore_none( - field_name, - field_info, - DEFAULT_IGNORE_NONE, + else field_ignore_none + if ( + field_ignore_none := self._get_field_ignore_none( + field_name, field_info + ) ) + is not None + else DEFAULT_IGNORE_NONE ) # Resolve Q expression for a field even if we skip it due to None value @@ -213,3 +224,27 @@ def _connect_fields(self) -> Q: ) return q + + def _get_from_deprecated_field_extra( + self, field_name: str, field_info: FieldInfo, attr: str + ) -> Union[Any, None]: + """ + Backward-compatible shim which looks up filtering parameters in the Field's **extra kwargs. + Consider removing this method in favor of FilterLookup annotation class. + """ + field_extra = cast(dict, field_info.json_schema_extra) or {} + value = field_extra.get(attr, NOT_SET) + + if value is not NOT_SET: + warnings.warn( + f"Using Pydantic Field with extra keyword arguments ('{attr}') " + f"in field {self.__class__.__name__}.{field_name} is deprecated. Please use ninja.FilterLookup instead:\n" + f" from typing import Annotated\n" + f" from ninja import FilterLookup, FilterSchema\n\n" + f" class {self.__class__.__name__}(FilterSchema):\n" + f" {field_name}: Annotated[Optional[...], FilterLookup(q='...', ...)] = None", + DeprecationWarning, + stacklevel=4, + ) + return value + return None diff --git a/tests/test_filter_schema.py b/tests/test_filter_schema.py index a91606929..38eb643f7 100644 --- a/tests/test_filter_schema.py +++ b/tests/test_filter_schema.py @@ -401,3 +401,23 @@ class DummyFilterSchema(FilterSchema): filter_instance = DummyFilterSchema(name="test") with pytest.raises(ImproperlyConfigured): filter_instance.get_filter_expression() + + +def test_pydantic_field_with_extra_warns(): + """Test that using pydantic Field with 'extra' attribute shows deprecation warning""" + import warnings + + class DummyFilterSchema(FilterSchema): + name: Optional[str] = Field(None, q="name__icontains") + + filter_instance = DummyFilterSchema() + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + filter_instance.get_filter_expression() + + # Check that a deprecation warning was issued + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "deprecated" in str(w[0].message).lower() + assert "FilterLookup" in str(w[0].message) From 6c0289e05281ac0c5e454fac7a4802bf5d883a44 Mon Sep 17 00:00:00 2001 From: Nikolay Dolzhenkov Date: Tue, 12 Aug 2025 20:49:57 +0200 Subject: [PATCH 7/9] fixing typo in Union typing --- ninja/filter_schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ninja/filter_schema.py b/ninja/filter_schema.py index e447d09a0..8bba2606e 100644 --- a/ninja/filter_schema.py +++ b/ninja/filter_schema.py @@ -131,7 +131,7 @@ def _get_field_expression_connector( # Legacy approach, consider removing in future versions return cast( - Union[ExpressionConnector | None], + Union[ExpressionConnector, None], self._get_from_deprecated_field_extra( field_name, field_info, "expression_connector" ), From 8a196f5e02169adec4f6b1ce7437f3fbaf5065c1 Mon Sep 17 00:00:00 2001 From: Nikolay Dolzhenkov Date: Tue, 12 Aug 2025 21:00:38 +0200 Subject: [PATCH 8/9] Avoid using walrus for the sake of python 3.7 --- ninja/filter_schema.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/ninja/filter_schema.py b/ninja/filter_schema.py index 8bba2606e..93367cc21 100644 --- a/ninja/filter_schema.py +++ b/ninja/filter_schema.py @@ -195,19 +195,16 @@ def _connect_fields(self) -> Q: class_ignore_none = self.model_config.get("ignore_none", DEFAULT_IGNORE_NONE) for field_name, field_info in self.__class__.model_fields.items(): filter_value = getattr(self, field_name) + # class-level ignore_none set to False (non-default) takes precedence over field-level ignore_none - ignore_none = ( - False - if class_ignore_none is False - else field_ignore_none - if ( - field_ignore_none := self._get_field_ignore_none( - field_name, field_info - ) - ) - is not None - else DEFAULT_IGNORE_NONE - ) + if class_ignore_none is False: + ignore_none = False + else: + field_ignore_none = self._get_field_ignore_none(field_name, field_info) + if field_ignore_none is not None: + ignore_none = field_ignore_none + else: + ignore_none = DEFAULT_IGNORE_NONE # Resolve Q expression for a field even if we skip it due to None value # So that improperly configured fields are easier to detect From b48a5ae913a799d8cc969b887de0502f68ad99bd Mon Sep 17 00:00:00 2001 From: Nikolay Dolzhenkov Date: Tue, 12 Aug 2025 21:12:33 +0200 Subject: [PATCH 9/9] removed typo from filtering.md --- docs/docs/guides/input/filtering.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/docs/guides/input/filtering.md b/docs/docs/guides/input/filtering.md index 116263b70..1a2b98f73 100644 --- a/docs/docs/guides/input/filtering.md +++ b/docs/docs/guides/input/filtering.md @@ -1,5 +1,3 @@ -from ninja import FilterConfigDict - # Filtering If you want to allow the user to filter your querysets by a number of different attributes, it makes sense