Skip to content

Commit a9b2ee2

Browse files
authored
Merge pull request #61 from QtiQla/master
add list support for filtering
2 parents c792162 + 1f07351 commit a9b2ee2

File tree

7 files changed

+319
-58
lines changed

7 files changed

+319
-58
lines changed

docs/tutorial/ordering.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# **Ordering**
22

3-
**Django Ninja Extra** provides an intuitive ordering model using `ordering` decoration from the Django-Ninja-Extra ordering module. It expects a Queryset from as a route function result.
3+
**Django Ninja Extra** provides an intuitive ordering model using `ordering` decoration from the Django-Ninja-Extra ordering module. It expects a Queryset or a List from as a route function result.
44

55
> This feature was inspired by the [DRF OrderingFilter](https://www.django-rest-framework.org/api-guide/filtering/#orderingfilter)
66
@@ -61,7 +61,7 @@ class UserController:
6161
@route.get('/all-sort', response=List[UserSchema])
6262
@ordering
6363
def get_users_with_all_field_ordering(self):
64-
return user_model.objects.all()
64+
return [u for u in user_model.objects.all()]
6565

6666

6767
api = NinjaExtraAPI(title='Ordering Test')

docs/tutorial/searching.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# **Searching**
22

3-
**Django Ninja Extra** provides an intuitive searching model using `searching` decoration from the Django-Ninja-Extra searching module. It expects a Queryset from as a route function result.
3+
**Django Ninja Extra** provides an intuitive searching model using `searching` decoration from the Django-Ninja-Extra searching module. It expects a Queryset or a List from as a route function result.
44

55
> This feature was inspired by the [DRF SearchFilter](https://www.django-rest-framework.org/api-guide/filtering/#searchfilter)
66
@@ -69,7 +69,7 @@ class UserController:
6969
@route.get('/iexact-email', response=List[UserSchema])
7070
@searching(search_fields=['=email'])
7171
def get_users_with_search_iexact_email(self):
72-
return user_model.objects.all()
72+
return [u for u in user_model.objects.all()]
7373

7474

7575
api = NinjaExtraAPI(title='Searching Test')

ninja_extra/ordering.py

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
from abc import ABC, abstractmethod
44
from functools import wraps
5+
from operator import attrgetter, itemgetter
56
from typing import (
67
TYPE_CHECKING,
78
Any,
@@ -10,6 +11,7 @@
1011
Optional,
1112
Tuple,
1213
Type,
14+
Union,
1315
cast,
1416
overload,
1517
)
@@ -19,6 +21,7 @@
1921
from ninja import Field, Query, Schema
2022
from ninja.constants import NOT_SET
2123
from ninja.signature import is_async
24+
from pydantic import BaseModel
2225

2326
from ninja_extra.conf import settings
2427
from ninja_extra.shortcuts import add_ninja_contribute_args
@@ -47,7 +50,9 @@ def __init__(self, *, pass_parameter: Optional[str] = None, **kwargs: Any) -> No
4750
self.pass_parameter = pass_parameter
4851

4952
@abstractmethod
50-
def ordering_queryset(self, queryset: QuerySet, ordering_input: Any) -> QuerySet:
53+
def ordering_queryset(
54+
self, items: Union[QuerySet, List], ordering_input: Any
55+
) -> Union[QuerySet, List]:
5156
...
5257

5358

@@ -70,20 +75,42 @@ class DynamicInput(Ordering.Input):
7075

7176
return DynamicInput
7277

73-
def ordering_queryset(self, queryset: QuerySet, ordering_input: Input) -> QuerySet:
74-
ordering = self.get_ordering(queryset, ordering_input.ordering)
78+
def ordering_queryset(
79+
self, items: Union[QuerySet, List], ordering_input: Input
80+
) -> Union[QuerySet, List]:
81+
ordering = self.get_ordering(items, ordering_input.ordering)
7582
if ordering:
76-
return queryset.order_by(*ordering)
77-
return queryset
78-
79-
def get_ordering(self, queryset: QuerySet, value: Optional[str]) -> List[str]:
83+
if isinstance(items, QuerySet): # type:ignore
84+
return items.order_by(*ordering)
85+
elif isinstance(items, list) and items:
86+
87+
def multisort(xs: List, specs: List[Tuple[str, bool]]) -> List:
88+
orerator = itemgetter if isinstance(xs[0], dict) else attrgetter
89+
for key, reverse in specs:
90+
xs.sort(key=orerator(key), reverse=reverse)
91+
return xs
92+
93+
return multisort(
94+
items,
95+
[
96+
(o[int(o.startswith("-")) :], o.startswith("-"))
97+
for o in ordering
98+
],
99+
)
100+
return items
101+
102+
def get_ordering(
103+
self, items: Union[QuerySet, List], value: Optional[str]
104+
) -> List[str]:
80105
if value:
81106
fields = [param.strip() for param in value.split(",")]
82-
return self.remove_invalid_fields(queryset, fields)
107+
return self.remove_invalid_fields(items, fields)
83108
return []
84109

85-
def remove_invalid_fields(self, queryset: QuerySet, fields: List[str]) -> List[str]:
86-
valid_fields = [item for item in self.get_valid_fields(queryset)]
110+
def remove_invalid_fields(
111+
self, items: Union[QuerySet, List], fields: List[str]
112+
) -> List[str]:
113+
valid_fields = [item for item in self.get_valid_fields(items)]
87114

88115
def term_valid(term: str) -> bool:
89116
if term.startswith("-"):
@@ -92,15 +119,34 @@ def term_valid(term: str) -> bool:
92119

93120
return [term for term in fields if term_valid(term)]
94121

95-
def get_valid_fields(self, queryset: QuerySet) -> List[str]:
122+
def get_valid_fields(self, items: Union[QuerySet, List]) -> List[str]:
96123
valid_fields: List[str] = []
97124
if self.ordering_fields == "__all__":
98-
valid_fields = [str(field.name) for field in queryset.model._meta.fields]
99-
valid_fields += [str(key) for key in queryset.query.annotations]
125+
if isinstance(items, QuerySet): # type:ignore
126+
valid_fields = self.get_all_valid_fields_from_queryset(items)
127+
elif isinstance(items, list):
128+
valid_fields = self.get_all_valid_fields_from_list(items)
100129
else:
101130
valid_fields = [item for item in self.ordering_fields]
102131
return valid_fields
103132

133+
def get_all_valid_fields_from_queryset(self, items: QuerySet) -> List[str]:
134+
return [str(field.name) for field in items.model._meta.fields] + [
135+
str(key) for key in items.query.annotations
136+
]
137+
138+
def get_all_valid_fields_from_list(self, items: List) -> List[str]:
139+
if not items:
140+
return []
141+
item = items[0]
142+
if isinstance(item, BaseModel):
143+
return list(item.__fields__.keys())
144+
if isinstance(item, dict):
145+
return list(item.keys())
146+
if hasattr(item, "_meta") and hasattr(item._meta, "fields"):
147+
return [str(field.name) for field in item._meta.fields]
148+
return []
149+
104150

105151
@overload
106152
def ordering() -> Callable[..., Any]: # pragma: no cover
@@ -114,7 +160,9 @@ def ordering(
114160
...
115161

116162

117-
def ordering(func_or_ordering_class: Any = NOT_SET, **ordering_params: Any) -> Callable:
163+
def ordering(
164+
func_or_ordering_class: Any = NOT_SET, **ordering_params: Any
165+
) -> Callable[..., Any]:
118166
isfunction = inspect.isfunction(func_or_ordering_class)
119167
isnotset = func_or_ordering_class == NOT_SET
120168

ninja_extra/searching.py

Lines changed: 101 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import inspect
22
import operator
3+
import re
34
from abc import ABC, abstractmethod
45
from functools import reduce, wraps
56
from typing import (
67
TYPE_CHECKING,
78
Any,
89
Callable,
10+
Dict,
911
List,
1012
Optional,
1113
Tuple,
1214
Type,
15+
Union,
1316
cast,
1417
overload,
1518
)
@@ -36,33 +39,58 @@
3639
]
3740

3841

42+
def _istartswith(a: str, b: str) -> bool:
43+
return a.startswith(b)
44+
45+
46+
def _isiexact(a: str, b: str) -> bool:
47+
return a.lower() == b.lower()
48+
49+
50+
def _isiregex(a: str, b: str) -> bool:
51+
return bool(re.search(b, a, re.IGNORECASE))
52+
53+
54+
def _isicontains(a: str, b: str) -> bool:
55+
return b.lower() in a.lower()
56+
57+
3958
class SearchingBase(ABC):
4059
class Input(Schema):
4160
...
4261

43-
lookup_prefixes = {
44-
"^": "istartswith",
45-
"=": "iexact",
46-
"@": "search",
47-
"$": "iregex",
48-
}
4962
InputSource = Query(...)
5063

5164
def __init__(self, *, pass_parameter: Optional[str] = None, **kwargs: Any) -> None:
5265
self.pass_parameter = pass_parameter
5366

5467
@abstractmethod
55-
def searching_queryset(self, queryset: QuerySet, searching_input: Any) -> QuerySet:
68+
def searching_queryset(
69+
self, items: Union[QuerySet, List], searching_input: Any
70+
) -> Union[QuerySet, List]:
5671
...
5772

5873

5974
class Searching(SearchingBase):
6075
class Input(Schema):
6176
search: Optional[str] = Field(None)
6277

78+
lookup_prefixes = {
79+
"^": "istartswith",
80+
"=": "iexact",
81+
"@": "search",
82+
"$": "iregex",
83+
}
84+
85+
lookup_prefixes_list = {
86+
"^": _istartswith,
87+
"=": _isiexact,
88+
"$": _isiregex,
89+
}
90+
6391
def __init__(
6492
self,
65-
search_fields: Optional[List[str]] = None,
93+
search_fields: List[str] = [],
6694
pass_parameter: Optional[str] = None,
6795
) -> None:
6896
self.search_fields = search_fields
@@ -76,23 +104,23 @@ class DynamicInput(Searching.Input):
76104
return DynamicInput
77105

78106
def searching_queryset(
79-
self, queryset: QuerySet, searching_input: Input
80-
) -> QuerySet:
107+
self, items: Union[QuerySet, List], searching_input: Input
108+
) -> Union[QuerySet, List]:
81109

82110
search_terms = self.get_search_terms(searching_input.search)
83111

84-
if not self.search_fields or not search_terms:
85-
return queryset
86-
87-
orm_lookups = [
88-
self.construct_search(str(search_field))
89-
for search_field in self.search_fields
90-
]
91-
conditions = []
92-
for search_term in search_terms:
93-
queries = [Q(**{orm_lookup: search_term}) for orm_lookup in orm_lookups]
94-
conditions.append(reduce(operator.or_, queries))
95-
return queryset.filter(reduce(operator.and_, conditions))
112+
if self.search_fields and search_terms:
113+
if isinstance(items, QuerySet): # type:ignore
114+
conditions_queryset = self.construct_conditions_for_queryset(
115+
search_terms
116+
)
117+
return items.filter(reduce(operator.and_, conditions_queryset))
118+
elif isinstance(items, list):
119+
conditions_list = self.construct_conditions_for_list(search_terms)
120+
return [
121+
item for item in items if self.filter_spec(item, conditions_list)
122+
]
123+
return items
96124

97125
def get_search_terms(self, value: Optional[str]) -> List[str]:
98126
if value:
@@ -109,6 +137,56 @@ def construct_search(self, field_name: str) -> str:
109137
lookup = "icontains"
110138
return LOOKUP_SEP.join([field_name, lookup])
111139

140+
def construct_conditions_for_queryset(self, search_terms: List[str]) -> List[Q]:
141+
orm_lookups = [
142+
self.construct_search(str(search_field))
143+
for search_field in self.search_fields
144+
]
145+
conditions = []
146+
for search_term in search_terms:
147+
queries = [Q(**{orm_lookup: search_term}) for orm_lookup in orm_lookups]
148+
conditions.append(reduce(operator.or_, queries))
149+
return conditions
150+
151+
def construct_conditions_for_list(
152+
self, search_terms: List[str]
153+
) -> Dict[str, List[Tuple[Callable, str]]]:
154+
lookups = self.construct_search_for_list()
155+
conditions: Dict[str, List[Tuple[Callable, str]]] = {
156+
field_name: [] for field_name in lookups.keys()
157+
}
158+
for search_term in search_terms:
159+
for field_name, lookup in lookups.items():
160+
conditions[field_name].append((lookup, search_term))
161+
return conditions
162+
163+
def construct_search_for_list(self) -> Dict[str, Callable]:
164+
def get_lookup(prefix: str) -> Callable:
165+
return self.lookup_prefixes_list.get(prefix, _isicontains)
166+
167+
return {
168+
field_name[1:]
169+
if (self.lookup_prefixes_list.get(field_name[0]))
170+
else field_name: get_lookup(field_name[0])
171+
for field_name in self.search_fields
172+
}
173+
174+
def filter_spec(
175+
self, item: Any, conditions: Dict[str, List[Tuple[Callable, str]]]
176+
) -> bool:
177+
item_getter = (
178+
operator.itemgetter if isinstance(item, dict) else operator.attrgetter
179+
)
180+
for field, lookup in conditions.items():
181+
if not any(
182+
[
183+
lookup_func(item_getter(field)(item), lookup_value)
184+
for lookup_func, lookup_value in lookup
185+
]
186+
):
187+
return False
188+
return True
189+
112190

113191
@overload
114192
def searching() -> Callable[..., Any]: # pragma: no cover
@@ -124,7 +202,7 @@ def searching(
124202

125203
def searching(
126204
func_or_searching_class: Any = NOT_SET, **searching_params: Any
127-
) -> Callable:
205+
) -> Callable[..., Any]:
128206
isfunction = inspect.isfunction(func_or_searching_class)
129207
isnotset = func_or_searching_class == NOT_SET
130208

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ def pytest_configure(config):
77
from django.conf import settings
88

99
os.environ.setdefault("NINJA_SKIP_REGISTRY", "True")
10-
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "True")
10+
1111
settings.configure(
1212
ALLOWED_HOSTS=["*"],
1313
DEBUG_PROPAGATE_EXCEPTIONS=True,

0 commit comments

Comments
 (0)