diff --git a/docs/docs/guides/input/filtering.md b/docs/docs/guides/input/filtering.md index f360d4ad6..1a2b98f73 100644 --- a/docs/docs/guides/input/filtering.md +++ b/docs/docs/guides/input/filtering.md @@ -73,30 +73,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="14" -from ninja import FilterSchema +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] = FilterField(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] = FilterField(None, q=['name__icontains', - 'author__name__icontains', - 'publisher__name__icontains']) + search: Annotated[Optional[str], FilterLookup( + ["name__icontains", + "author__name__icontains", + "publisher__name__icontains"] + )] ``` -And to make generic fields, you can make the field name implicit by skipping it: -```python hl_lines="2" -from typing import Annotated -IContainsField = Annotated[Optional[str], FilterField(None, q='__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="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 @@ -108,7 +130,9 @@ By default, So, with the following `FilterSchema`... ```python class BookFilterSchema(FilterSchema): - search: Optional[str] = FilterField(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 @@ -119,14 +143,19 @@ 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="12" +from ninja import FilterConfigDict, FilterLookup, FilterSchema + class BookFilterSchema(FilterSchema): - active: Optional[bool] = FilterField(None, q=['is_active', 'publisher__is_active'], - expression_connector='AND') - name: Optional[str] = FilterField(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 Meta: - expression_connector = 'OR' + model_config = FilterConfigDict(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. @@ -144,20 +173,19 @@ 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] = FilterField(None, q='name__icontains') - tag: Optional[str] = FilterField(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: -```python hl_lines="6" +You can also specify this setting for all fields at the same time in `model_config`: +```python hl_lines="5" class BookFilterSchema(FilterSchema): - name: Optional[str] = FilterField(None, q='name__icontains') - tag: Optional[str] = FilterField(None, q='tag', ignore_none=False) + name: Annotated[Optional[str], FilterLookup("name__icontains")] = None + tag: Optional[str] = None - class Meta: - ignore_none = False + model_config = FilterConfigDict(ignore_none=False) ``` @@ -173,7 +201,7 @@ class BookFilterSchema(FilterSchema): def filter_popular(self, value: bool) -> Q: return Q(view_count__gt=1000) | Q(download_count__gt=100) if value else Q() ``` -Such field methods take precedence over what is specified in the `FilterField()` definition of the corresponding fields. +Such field methods take precedence over what is specified in the `Field()` definition of the corresponding fields. If that is not enough, you can implement your own custom filtering logic for the entire `FilterSet` class in a `custom_expression` method: diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 567784479..9ce8a91e6 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -100,6 +100,7 @@ markdown_extensions: - abbr - codehilite - admonition + - pymdownx.details - pymdownx.superfences plugins: - search diff --git a/ninja/__init__.py b/ninja/__init__.py index f6ce797fb..dff539a7c 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 FilterField, FilterSchema +from ninja.filter_schema import FilterConfigDict, FilterLookup, FilterSchema from ninja.main import NinjaAPI from ninja.openapi.docs import Redoc, Swagger from ninja.orm import ModelSchema @@ -54,7 +54,8 @@ "Schema", "ModelSchema", "FilterSchema", - "FilterField", + "FilterLookup", + "FilterConfigDict", "Swagger", "Redoc", "PatchDict", diff --git a/ninja/filter_schema.py b/ninja/filter_schema.py index daaa52c86..93367cc21 100644 --- a/ninja/filter_schema.py +++ b/ninja/filter_schema.py @@ -1,55 +1,67 @@ import warnings -from typing import Any, Dict, Optional, TypeVar, Union, cast +from typing import Any, List, Optional, TypeVar, Union, cast from django.core.exceptions import ImproperlyConfigured from django.db.models import Q, QuerySet -from pydantic import ConfigDict, Field +from pydantic import ConfigDict from pydantic.fields import FieldInfo from typing_extensions import Literal +from .constants import NOT_SET 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"] +DEFAULT_IGNORE_NONE: bool = 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[Union[str, None], FilterLookup("name__icontains")] = None + search: Annotated[Union[str, None], FilterLookup(["name__icontains", "email__icontains"])] = None + """ + + def __init__( + self, + q: Union[str, List[str], None], + *, + expression_connector: ExpressionConnector = DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR, + ignore_none: 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 = expression_connector + self.ignore_none = ignore_none + + T = TypeVar("T", bound=QuerySet) -def FilterField( - default: Any = ..., - *, - q: Optional[Union[str, list]] = None, - ignore_none: Optional[bool] = None, - expression_connector: Optional[ExpressionConnector] = None, - **kwargs: Any, -) -> Any: - """Custom Field function for FilterSchema that properly handles filter-specific parameters.""" - json_schema_extra = kwargs.get("json_schema_extra", {}) - if isinstance(json_schema_extra, dict): - if q is not None: - json_schema_extra["q"] = q - if ignore_none is not None: - json_schema_extra["ignore_none"] = ignore_none - if expression_connector is not None: - json_schema_extra["expression_connector"] = expression_connector - kwargs["json_schema_extra"] = json_schema_extra - - return Field(default, **kwargs) +class FilterConfigDict(ConfigDict, total=False): + ignore_none: bool + expression_connector: ExpressionConnector class FilterSchema(Schema): - model_config = ConfigDict(from_attributes=True) - - class Meta: - # TODO: filters_ignore_none, filters_expression_connector - ignore_none: bool = DEFAULT_IGNORE_NONE - expression_connector: ExpressionConnector = cast( - ExpressionConnector, DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR - ) + model_config = FilterConfigDict( + ignore_none=DEFAULT_IGNORE_NONE, + expression_connector=DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR, + ) def custom_expression(self) -> Q: """ @@ -69,82 +81,167 @@ 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}.\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', ...])]" + ) + + def _get_field_q_expression( + self, + field_name: str, + field_info: FieldInfo, + ) -> Union[str, List[str], None]: + filter_lookup = self._get_filter_lookup(field_name, field_info) + if filter_lookup: + return filter_lookup.q + + # Legacy approach, consider removing in future versions + 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, + ) -> Union[ExpressionConnector, None]: + filter_lookup = self._get_filter_lookup(field_name, field_info) + if filter_lookup: + return filter_lookup.expression_connector + + # Legacy approach, consider removing in future versions + return cast( + 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 + ) -> 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 + 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: 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] - - field_extra = field.json_schema_extra or {} + return cast(Q, func(field_value)) - # Check if user is using pydantic Field with deprecated extra kwargs - # This check is for backwards compatibility during transition period - if hasattr(field, "extra") and field.extra: - warnings.warn( - f"Using pydantic Field with extra keyword arguments (q, ignore_none, expression_connector) " - f"in field '{field_name}' is deprecated. Please use ninja.FilterField instead:\n" - f" from ninja import FilterField\n" - f" {field_name}: Optional[...] = FilterField(..., q='...', ignore_none=...)", - DeprecationWarning, - stacklevel=4, - ) + q_expression = self._get_field_q_expression(field_name, field_info) + expression_connector = ( + self._get_field_expression_connector(field_name, field_info) + or 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() - model_config = self._model_config() - for field_name, field in self.__class__.model_fields.items(): + 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) - field_extra = field.json_schema_extra or {} - ignore_none = field_extra.get( # type: ignore - "ignore_none", - model_config["ignore_none"], - ) - # Resolve q for a field even if we skip it due to None value + # class-level ignore_none set to False (non-default) takes precedence over field-level 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 - 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, 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 - @classmethod - def _model_config(cls) -> Dict[str, Any]: - return { - "ignore_none": cls.Meta.ignore_none, - "expression_connector": cls.Meta.expression_connector, - } + 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 b4cb57444..38eb643f7 100644 --- a/tests/test_filter_schema.py +++ b/tests/test_filter_schema.py @@ -3,8 +3,10 @@ import pytest 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 FilterField, FilterSchema +from ninja import FilterConfigDict, FilterLookup, FilterSchema class FakeQS(QuerySet): @@ -18,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 @@ -26,19 +30,57 @@ 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)) + + filter_instance = DummyFilterSchema() + with pytest.raises(ImproperlyConfigured): + filter_instance.get_filter_expression() + + +def test_improperly_configured_annotated(): + """Test ImproperlyConfigured error when q is not a string or list of strings (FilterLookup annotation).""" + class DummyFilterSchema(FilterSchema): - popular: Optional[str] = FilterField(None, q=Q(view_count__gt=1000)) + 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(): +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] = FilterField(None, q="name__icontains") - tag: Optional[str] = FilterField(None, q="tag") + name: Optional[str] = Field(None, q="name__icontains") + tag: Optional[str] = Field(None, q="tag") + + filter_instance = DummyFilterSchema() + q = filter_instance.get_filter_expression() + 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() @@ -46,25 +88,57 @@ class DummyFilterSchema(FilterSchema): @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: q = "name__icontains" class DummyFilterSchema(FilterSchema): - name: Optional[str] = FilterField(None, q=q) - tag: Optional[str] = FilterField(None, q="tag") + name: Optional[str] = Field(None, q=q) + tag: Optional[str] = Field(None, q="tag") filter_instance = DummyFilterSchema(name="John", tag=None) q = filter_instance.get_filter_expression() 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: Optional[str] = FilterField(None, q="name__icontains") - tag: Optional[str] = FilterField(None, q="tag") + 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") + + filter_instance = DummyFilterSchema(name="John", tag="active") + q = filter_instance.get_filter_expression() + 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() @@ -72,17 +146,16 @@ class DummyFilterSchema(FilterSchema): @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: q__name = "name__icontains" class DummyFilterSchema(FilterSchema): - name: Optional[str] = FilterField( - None, q=[q__name, "user__username__icontains"] - ) - tag: Optional[str] = FilterField(None, q="tag") + name: Optional[str] = Field(None, q=[q__name, "user__username__icontains"]) + tag: Optional[str] = Field(None, q="tag") filter_instance = DummyFilterSchema(name="foo", tag="bar") q = filter_instance.get_filter_expression() @@ -91,14 +164,56 @@ 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] = FilterField( - None, + name: Optional[str] = Field( q=["name__icontains", "user__username__icontains"], expression_connector="AND", ) - tag: Optional[str] = FilterField(None, q="tag") + tag: Optional[str] = Field(None, q="tag") + + 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_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() @@ -107,30 +222,45 @@ class DummyFilterSchema(FilterSchema): ) -def test_class_level_expression_connector(): +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") + + model_config = FilterConfigDict(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_expression_connector_annotated(): + """Test class-level expression connector (FilterLookup annotation).""" + class DummyFilterSchema(FilterSchema): - tag1: Optional[str] = FilterField(None, q="tag1") - tag2: Optional[str] = FilterField(None, q="tag2") + tag1: Annotated[Optional[str], FilterLookup("tag1")] = None + tag2: Annotated[Optional[str], FilterLookup("tag2")] = None - class Meta(FilterSchema.Meta): - expression_connector = "OR" + model_config = FilterConfigDict(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(): +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] = FilterField( - None, + name: Optional[str] = Field( q=["name__icontains", "user__username__icontains"], expression_connector="AND", ) - tag: Optional[str] = FilterField(None, q="tag") + tag: Optional[str] = Field(None, q="tag") - class Meta(FilterSchema.Meta): - expression_connector = "OR" + model_config = FilterConfigDict(expression_connector="OR") filter_instance = DummyFilterSchema(name="foo", tag="bar") q = filter_instance.get_filter_expression() @@ -139,22 +269,72 @@ class Meta(FilterSchema.Meta): ) -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): - tag: Optional[str] = FilterField(None, q="tag", ignore_none=False) + name: Annotated[ + Optional[str], + FilterLookup( + ["name__icontains", "user__username__icontains"], + expression_connector="AND", + ), + ] = None + tag: Annotated[Optional[str], FilterLookup("tag")] = None + + model_config = FilterConfigDict(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) filter_instance = DummyFilterSchema() q = filter_instance.get_filter_expression() 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): - tag1: Optional[str] = FilterField(None, q="tag1") - tag2: Optional[str] = FilterField(None, q="tag2") + tag: Annotated[Optional[str], FilterLookup("tag", ignore_none=False)] = None + + filter_instance = DummyFilterSchema() + q = filter_instance.get_filter_expression() + assert q == Q(tag=None) - class Meta(FilterSchema.Meta): - ignore_none = False + +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") + + model_config = FilterConfigDict(ignore_none=False) + + filter_instance = DummyFilterSchema() + q = filter_instance.get_filter_expression() + 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 + + model_config = FilterConfigDict(ignore_none=False) filter_instance = DummyFilterSchema() q = filter_instance.get_filter_expression() @@ -162,6 +342,8 @@ class Meta(FilterSchema.Meta): def test_field_level_custom_expression(): + """Test custom filter_* methods override field configuration.""" + class DummyFilterSchema(FilterSchema): name: Optional[str] = None popular: Optional[bool] = None @@ -183,8 +365,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] = FilterField(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() @@ -195,8 +379,10 @@ def custom_expression(self) -> Q: def test_filter_called(): + """Test filter() method applies expression to queryset (FilterLookup annotation).""" + class DummyFilterSchema(FilterSchema): - name: Optional[str] = FilterField(None, q="name") + name: Annotated[Optional[str], FilterLookup("name")] = None filter_instance = DummyFilterSchema(name="foobar") queryset = FakeQS() @@ -204,70 +390,34 @@ class DummyFilterSchema(FilterSchema): assert queryset.filtered -def test_filter_field_with_non_dict_json_schema_extra(): - """Test FilterField when json_schema_extra is not a dict""" - # This tests the branch where json_schema_extra is not a dict - field = FilterField( - None, - q="name__icontains", - ignore_none=False, - expression_connector="AND", - json_schema_extra="not_a_dict", # This is not a dict - ) - # The field should still be created, but filter params won't be added to json_schema_extra - assert field.json_schema_extra == "not_a_dict" - - -def test_filter_field_with_callable_json_schema_extra(): - """Test FilterField when json_schema_extra is a callable""" - - def custom_schema(): - return {"custom": "value"} - - field = FilterField(None, q="name__icontains", json_schema_extra=custom_schema) - # The callable should be preserved - assert callable(field.json_schema_extra) - +def test_multiple_filter_lookup_instances_error(): + """Test that multiple FilterLookup instances in a single annotation raises ImproperlyConfigured.""" -def test_filter_field_partial_params(): - """Test FilterField with only some parameters set""" - # Test with only ignore_none set (q is None) - field1 = FilterField(None, ignore_none=False) - assert field1.json_schema_extra == {"ignore_none": False} - - # Test with only expression_connector set - field2 = FilterField(None, expression_connector="AND") - assert field2.json_schema_extra == {"expression_connector": "AND"} + class DummyFilterSchema(FilterSchema): + name: Annotated[ + Optional[str], FilterLookup("name__icontains"), FilterLookup("name__exact") + ] = None - # Test with no filter params at all - field3 = FilterField(None) - assert field3.json_schema_extra == {} + 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 - from unittest.mock import Mock - - from pydantic.fields import FieldInfo class DummyFilterSchema(FilterSchema): - name: Optional[str] = None - - # Create a mock field with the 'extra' attribute to simulate old pydantic behavior - mock_field = Mock(spec=FieldInfo) - mock_field.json_schema_extra = None - mock_field.extra = {"q": "name__icontains"} # Simulate old-style extra kwargs + name: Optional[str] = Field(None, q="name__icontains") filter_instance = DummyFilterSchema() with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - # Call the method that checks for deprecated usage - filter_instance._resolve_field_expression("name", "test", mock_field) + 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 "FilterField" in str(w[0].message) + assert "FilterLookup" in str(w[0].message)