From 1bc113d69a83c8bad042ee6416d3a7ced0f5787e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eug=C3=A8ne=20N=C3=A9lou?= Date: Sat, 29 Jun 2024 00:49:42 +0200 Subject: [PATCH] Allow generic filter types --- docs/docs/guides/input/filtering.md | 11 +++++++++-- ninja/filter_schema.py | 8 +++++++- tests/test_filter_schema.py | 22 ++++++++++++++++------ 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/docs/docs/guides/input/filtering.md b/docs/docs/guides/input/filtering.md index f7de6985b..eb46c8bf8 100644 --- a/docs/docs/guides/input/filtering.md +++ b/docs/docs/guides/input/filtering.md @@ -75,14 +75,21 @@ 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" class BookFilterSchema(FilterSchema): - name: Optional[str] = Field(None, q='name__icontains') + name: Optional[str] = Field(None, q='name__icontains') ``` You can even specify multiple lookup keyword argument names as a list: ```python hl_lines="2 3 4" class BookFilterSchema(FilterSchema): search: Optional[str] = Field(None, q=['name__icontains', 'author__name__icontains', - 'publisher__name__icontains']) + 'publisher__name__icontains']) +``` +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')] + +class BookFilterSchema(FilterSchema): + name: IContainsField ``` 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. diff --git a/ninja/filter_schema.py b/ninja/filter_schema.py index 3cc33b9f2..fe8f3b40c 100644 --- a/ninja/filter_schema.py +++ b/ninja/filter_schema.py @@ -68,6 +68,8 @@ def _resolve_field_expression( 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 @@ -75,8 +77,11 @@ def _resolve_field_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_expression_part: field_value}), # type: ignore + Q(**{q_expression_part: field_value}), expression_connector, ) return q @@ -87,6 +92,7 @@ def _resolve_field_expression( 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"Alternatively, you can implement {self.__class__.__name__}.filter_{field_name} that must return a Q expression for that field" ) diff --git a/tests/test_filter_schema.py b/tests/test_filter_schema.py index 1a7716986..6c32d04a1 100644 --- a/tests/test_filter_schema.py +++ b/tests/test_filter_schema.py @@ -46,9 +46,15 @@ class DummyFilterSchema(FilterSchema): assert q == Q() -def test_q_expressions2(): +@pytest.mark.parametrize("implicit_field_name", [False, True]) +def test_q_expressions2(implicit_field_name): + if implicit_field_name: + q = "__icontains" + else: + q = "name__icontains" + class DummyFilterSchema(FilterSchema): - name: Optional[str] = Field(None, q="name__icontains") + name: Optional[str] = Field(None, q=q) tag: Optional[str] = Field(None, q="tag") filter_instance = DummyFilterSchema(name="John", tag=None) @@ -66,11 +72,15 @@ class DummyFilterSchema(FilterSchema): assert q == Q(name__icontains="John") & Q(tag="active") -def test_q_is_a_list(): +@pytest.mark.parametrize("implicit_field_name", [False, True]) +def test_q_is_a_list(implicit_field_name): + if implicit_field_name: + q__name = "__icontains" + else: + q__name = "name__icontains" + class DummyFilterSchema(FilterSchema): - name: Optional[str] = Field( - None, q=["name__icontains", "user__username__icontains"] - ) + 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")