Skip to content

Commit 64ff476

Browse files
author
Nikolay Dolzhenkov
committed
Added FilterLookup for use in field annotations with FilterSchema
1 parent 1b46ff1 commit 64ff476

File tree

2 files changed

+138
-38
lines changed

2 files changed

+138
-38
lines changed

ninja/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pydantic import Field
77

88
from ninja.files import UploadedFile
9-
from ninja.filter_schema import FilterSchema
9+
from ninja.filter_schema import FilterLookup, FilterSchema
1010
from ninja.main import NinjaAPI
1111
from ninja.openapi.docs import Redoc, Swagger
1212
from ninja.orm import ModelSchema
@@ -54,6 +54,7 @@
5454
"Schema",
5555
"ModelSchema",
5656
"FilterSchema",
57+
"FilterLookup",
5758
"Swagger",
5859
"Redoc",
5960
"PatchDict",

ninja/filter_schema.py

Lines changed: 136 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, TypeVar, cast
1+
from typing import Any, List, Optional, TypeVar, cast
22

33
from django.core.exceptions import ImproperlyConfigured
44
from django.db.models import Q, QuerySet
@@ -7,19 +7,43 @@
77

88
from .schema import Schema
99

10-
DEFAULT_IGNORE_NONE = True
11-
DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR = "AND"
12-
DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR = "OR"
13-
1410
# XOR is available only in Django 4.1+: https://docs.djangoproject.com/en/4.1/ref/models/querysets/#xor
1511
ExpressionConnector = Literal["AND", "OR", "XOR"]
1612

17-
18-
# class FilterConfig(BaseConfig):
19-
# ignore_none: bool = DEFAULT_IGNORE_NONE
20-
# expression_connector: ExpressionConnector = cast(
21-
# ExpressionConnector, DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR
22-
# )
13+
DEFAULT_IGNORE_NONE = True
14+
DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR: ExpressionConnector = "AND"
15+
DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR: ExpressionConnector = "OR"
16+
17+
18+
class FilterLookup:
19+
"""
20+
Annotation class for specifying database query lookups in FilterSchema fields.
21+
22+
Example usage:
23+
class MyFilterSchema(FilterSchema):
24+
name: Annotated[str | None, FilterLookup("name__icontains")] = None
25+
search: Annotated[str | None, FilterLookup(["name__icontains", "email__icontains"])] = None
26+
"""
27+
28+
def __init__(
29+
self,
30+
q: str | List[str],
31+
*,
32+
expression_connector: str = DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR,
33+
ignore_none: Optional[bool] = DEFAULT_IGNORE_NONE,
34+
):
35+
"""
36+
Args:
37+
q: Database lookup expression(s). Can be:
38+
- A string like "name__icontains"
39+
- A list of strings like ["name__icontains", "email__icontains"]
40+
- Use "__" prefix for implicit field name: "__icontains" becomes "fieldname__icontains"
41+
expression_connector: How to combine multiple field-level expressions ("OR", "AND", "XOR"). Default is "OR".
42+
ignore_none: Whether to ignore None values for this field specifically. Default is True.
43+
"""
44+
self.q = q
45+
self.expression_connector = cast(ExpressionConnector, expression_connector)
46+
self.ignore_none = ignore_none
2347

2448

2549
T = TypeVar("T", bound=QuerySet)
@@ -33,8 +57,8 @@ class FilterSchema(Schema):
3357

3458
class Config(Schema.Config):
3559
ignore_none: bool = DEFAULT_IGNORE_NONE
36-
expression_connector: ExpressionConnector = cast(
37-
ExpressionConnector, DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR
60+
expression_connector: ExpressionConnector = (
61+
DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR
3862
)
3963

4064
def custom_expression(self) -> Q:
@@ -55,62 +79,137 @@ def get_filter_expression(self) -> Q:
5579
def filter(self, queryset: T) -> T:
5680
return queryset.filter(self.get_filter_expression())
5781

82+
def _get_filter_lookup(
83+
self, field_name: str, field_info: FieldInfo
84+
) -> Optional[FilterLookup]:
85+
if not hasattr(field_info, "metadata") or not field_info.metadata:
86+
return None
87+
88+
filter_lookups = [
89+
metadata_item
90+
for metadata_item in field_info.metadata
91+
if isinstance(metadata_item, FilterLookup)
92+
]
93+
94+
if len(filter_lookups) == 0:
95+
return None
96+
elif len(filter_lookups) == 1:
97+
return filter_lookups[0]
98+
else:
99+
raise ImproperlyConfigured(
100+
f"Multiple FilterLookup instances found in metadata of {self.__class__.__name__}.{field_name}. "
101+
f"Use at most one FilterLookup instance per field. "
102+
f"If you need multiple lookups, specify them as a list in a single FilterLookup: "
103+
f"{field_name}: Annotated[{field_info.annotation}, FilterLookup(['lookup1', 'lookup2', ...])]"
104+
)
105+
106+
def _get_field_q_expression(
107+
self,
108+
field_name: str,
109+
field_info: FieldInfo,
110+
default: str | list[str] | None = None,
111+
) -> str | List[str] | None:
112+
filter_lookup = self._get_filter_lookup(field_name, field_info)
113+
if filter_lookup:
114+
return filter_lookup.q if filter_lookup.q is not None else default
115+
116+
# Legacy approach, consider removing in future versions
117+
field_extra = cast(dict, field_info.json_schema_extra) or {}
118+
return cast(str | list[str] | None, field_extra.get("q", default))
119+
120+
def _get_field_expression_connector(
121+
self,
122+
field_name: str,
123+
field_info: FieldInfo,
124+
default: ExpressionConnector | None = None,
125+
) -> ExpressionConnector | None:
126+
filter_lookup = self._get_filter_lookup(field_name, field_info)
127+
if filter_lookup:
128+
return filter_lookup.expression_connector or default
129+
130+
# Legacy approach, consider removing in future versions
131+
field_extra = cast(dict, field_info.json_schema_extra) or {}
132+
return cast(
133+
ExpressionConnector | None, field_extra.get("expression_connector", default)
134+
)
135+
136+
def _get_field_ignore_none(
137+
self, field_name: str, field_info: FieldInfo, default: bool | None = None
138+
) -> bool | None:
139+
filter_lookup = self._get_filter_lookup(field_name, field_info)
140+
if filter_lookup:
141+
return (
142+
filter_lookup.ignore_none
143+
if filter_lookup.ignore_none is not None
144+
else default
145+
)
146+
147+
# Legacy approach, consider removing in future versions
148+
field_extra = cast(dict, field_info.json_schema_extra) or {}
149+
return cast(bool | None, field_extra.get("ignore_none", default))
150+
58151
def _resolve_field_expression(
59-
self, field_name: str, field_value: Any, field: FieldInfo
152+
self, field_name: str, field_value: Any, field_info: FieldInfo
60153
) -> Q:
61154
func = getattr(self, f"filter_{field_name}", None)
62155
if callable(func):
63-
return func(field_value) # type: ignore[no-any-return]
156+
return cast(Q, func(field_value))
64157

65-
field_extra = field.json_schema_extra or {}
158+
q_expression = self._get_field_q_expression(field_name, field_info)
159+
expression_connector = self._get_field_expression_connector(
160+
field_name, field_info, default=DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR
161+
)
66162

67-
q_expression = field_extra.get("q", None) # type: ignore
68163
if not q_expression:
69164
return Q(**{field_name: field_value})
70165
elif isinstance(q_expression, str):
71166
if q_expression.startswith("__"):
72167
q_expression = f"{field_name}{q_expression}"
73168
return Q(**{q_expression: field_value})
74-
elif isinstance(q_expression, list):
75-
expression_connector = field_extra.get( # type: ignore
76-
"expression_connector", DEFAULT_FIELD_LEVEL_EXPRESSION_CONNECTOR
77-
)
169+
elif isinstance(q_expression, list) and all(
170+
isinstance(item, str) for item in q_expression
171+
):
78172
q = Q()
79173
for q_expression_part in q_expression:
80-
q_expression_part = str(q_expression_part)
81174
if q_expression_part.startswith("__"):
82175
q_expression_part = f"{field_name}{q_expression_part}"
83-
q = q._combine( # type: ignore
176+
q = q._combine( # type: ignore[attr-defined]
84177
Q(**{q_expression_part: field_value}),
85178
expression_connector,
86179
)
87180
return q
88181
else:
89182
raise ImproperlyConfigured(
90-
f"Field {field_name} of {self.__class__.__name__} defines an invalid value under 'q' kwarg.\n"
91-
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"
92-
f" {field_name}: {field.annotation} = Field(..., q='<here>')\n"
93-
f"or\n"
94-
f" {field_name}: {field.annotation} = Field(..., q=['lookup1', 'lookup2', ...])\n"
95-
f"You can omit the field name and make it implicit by starting the lookup directly by '__'."
183+
f"Field {field_name} of {self.__class__.__name__} defines an invalid value for 'q'.\n"
184+
f"Use FilterLookup annotation: {field_name}: Annotated[{field_info.annotation}, FilterLookup('lookup')]\n"
96185
f"Alternatively, you can implement {self.__class__.__name__}.filter_{field_name} that must return a Q expression for that field"
97186
)
98187

99188
def _connect_fields(self) -> Q:
100189
q = Q()
101-
for field_name, field in self.model_fields.items():
190+
for field_name, field_info in self.__class__.model_fields.items():
102191
filter_value = getattr(self, field_name)
103-
field_extra = field.json_schema_extra or {}
104-
ignore_none = field_extra.get( # type: ignore
105-
"ignore_none",
106-
self.model_config["ignore_none"], # type: ignore
192+
ignore_none = self._get_field_ignore_none(
193+
field_name,
194+
field_info,
195+
cast(
196+
bool | None,
197+
self.model_config.get("ignore_none", DEFAULT_IGNORE_NONE),
198+
),
107199
)
108200

109-
# Resolve q for a field even if we skip it due to None value
201+
# Resolve Q expression for a field even if we skip it due to None value
110202
# So that improperly configured fields are easier to detect
111-
field_q = self._resolve_field_expression(field_name, filter_value, field)
203+
field_q = self._resolve_field_expression(
204+
field_name, filter_value, field_info
205+
)
112206
if filter_value is None and ignore_none:
113207
continue
114-
q = q._combine(field_q, self.model_config["expression_connector"]) # type: ignore
208+
q = q._combine( # type: ignore[attr-defined]
209+
field_q,
210+
self.model_config.get(
211+
"expression_connector", DEFAULT_CLASS_LEVEL_EXPRESSION_CONNECTOR
212+
),
213+
)
115214

116215
return q

0 commit comments

Comments
 (0)