Skip to content

Commit 5adc69c

Browse files
committed
fix filtering by nested entities
1 parent ca735ad commit 5adc69c

File tree

8 files changed

+124
-26
lines changed

8 files changed

+124
-26
lines changed

examples/api_for_sqlalchemy/api/user.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from fastapi import Depends
88
from sqlalchemy import select, desc
99
from sqlalchemy.ext.asyncio import AsyncSession
10+
from sqlalchemy.sql import Select
1011
from tortoise.exceptions import DoesNotExist
1112
from tortoise.queryset import QuerySet
1213

@@ -76,7 +77,7 @@ async def patch(cls, obj_id, data: UserPatchSchema, query_params: QueryStringMan
7677

7778
class UserList:
7879
@classmethod
79-
async def get(cls, query_params: QueryStringManager, session: AsyncSession = Depends(Connector.get_session)) -> Union[QuerySet, JSONAPIResultListSchema]:
80+
async def get(cls, query_params: QueryStringManager, session: AsyncSession = Depends(Connector.get_session)) -> Union[Select, JSONAPIResultListSchema]:
8081
user_query = select(User).order_by(desc(User.id))
8182
dl = SqlalchemyEngine(query=user_query, schema=UserSchema, model=User, session=session)
8283
count, users_db = await dl.get_collection(qs=query_params)

fastapi_rest_jsonapi/api.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@
1313
from fastapi import APIRouter
1414
from pydantic import BaseModel, Field
1515

16-
from fastapi_rest_jsonapi.data_layers.data_typing import TypeModel
16+
from fastapi_rest_jsonapi.data_layers.data_typing import TypeModel, TypeSchema
1717
from fastapi_rest_jsonapi.data_layers.orm import DBORMType
1818
from fastapi_rest_jsonapi.exceptions import ExceptionResponseSchema
1919
from fastapi_rest_jsonapi.methods import (
2020
delete_detail_jsonapi,
2121
get_detail_jsonapi,
2222
get_list_jsonapi,
2323
patch_detail_jsonapi,
24-
post_list_jsonapi,
24+
post_list_jsonapi, delete_list_jsonapi,
2525
)
2626
from fastapi_rest_jsonapi.schema import BasePatchJSONAPISchema, BasePostJSONAPISchema, JSONAPIObjectSchema, \
2727
JSONAPIResultDetailSchema
@@ -39,10 +39,10 @@ def __init__( # noqa: WPS211
3939
tags: List[str],
4040
class_detail: Any,
4141
class_list: Any,
42-
schema: Type[BaseModel],
42+
schema: Type[TypeSchema],
4343
type_resource: str,
44-
schema_in_patch: Type[BaseModel],
45-
schema_in_post: Type[BaseModel],
44+
schema_in_patch: Type[TypeSchema],
45+
schema_in_post: Type[TypeSchema],
4646
model: Type[TypeModel],
4747
engine: DBORMType = DBORMType.sqlalchemy,
4848
) -> None:
@@ -166,6 +166,21 @@ def _add_routers(self, path: str):
166166
)(self.class_list.post)
167167
)
168168

169+
if hasattr(self.class_list, "delete"):
170+
self._routers.delete(
171+
path,
172+
tags=self._tags,
173+
summary=f"Delete list objects of type `{self._type}`"
174+
)(
175+
delete_list_jsonapi(
176+
schema=self._schema,
177+
model=self._model,
178+
engine=self._engine,
179+
)(
180+
self.class_list.delete
181+
)
182+
)
183+
169184
if hasattr(self.class_detail, "get"):
170185
self._routers.get(
171186
path + "/{obj_id}",
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from typing import TypeVar
22

3-
from pydantic import BaseModel
43

54
TypeQuery = TypeVar("TypeQuery")
65
TypeModel = TypeVar("TypeModel")
7-
TypeSchema = TypeVar("TypeSchema", bound=BaseModel)
6+
TypeSchema = TypeVar("TypeSchema")

fastapi_rest_jsonapi/data_layers/filtering/sqlalchemy.py

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from sqlalchemy.sql.elements import BinaryExpression
99

1010
from fastapi_rest_jsonapi.data_layers.shared import create_filters_or_sorts
11-
from fastapi_rest_jsonapi.exceptions import InvalidFilters
11+
from fastapi_rest_jsonapi.exceptions import InvalidFilters, InvalidType
1212

1313
from fastapi_rest_jsonapi.data_layers.data_typing import TypeSchema, TypeModel
1414
from fastapi_rest_jsonapi.schema import get_relationships, get_model_field
@@ -78,8 +78,24 @@ def create_filter(self, schema_field: ModelField, model_column, operator, value)
7878
)
7979
# Here we have to deserialize and validate fields, that are used in filtering,
8080
# so the Enum fields are loaded correctly
81-
value = schema_field.type_(value)
82-
return getattr(model_column, self.operator)(value)
81+
82+
if schema_field.sub_fields:
83+
# Для случаев когда в схеме тип Union
84+
fields = [i for i in schema_field.sub_fields]
85+
else:
86+
fields = [schema_field]
87+
types = [i.type_ for i in fields]
88+
clear_value = None
89+
errors: List[str] = []
90+
for i_type in types:
91+
try:
92+
clear_value = i_type(value)
93+
except (TypeError, ValueError) as ex:
94+
errors.append(str(ex))
95+
# Если None, при этом поле обязательное (среди типов в аннотации нет None, то кидаем ошибку)
96+
if clear_value is None and not any([not i_f.required for i_f in fields]):
97+
raise InvalidType(detail=", ".join(errors))
98+
return getattr(model_column, self.operator)(clear_value)
8399

84100
def resolve(self) -> FilterAndJoins:
85101
"""Create filter for a particular node of the filter tree"""
@@ -105,13 +121,22 @@ def resolve(self) -> FilterAndJoins:
105121
return self._relationship_filtering(value)
106122

107123
schema_field: ModelField = self.schema.__fields__[self.name]
108-
if issubclass(schema_field.type_, BaseModel):
109-
value = {
110-
'name': self.name,
111-
'op': self.filter_['op'],
112-
'val': value,
113-
}
114-
return self._relationship_filtering(value)
124+
if schema_field.sub_fields:
125+
# Для случаев когда в схеме тип Union
126+
types = [i.type_ for i in schema_field.sub_fields]
127+
else:
128+
types = [schema_field.type_]
129+
for i_type in types:
130+
try:
131+
if issubclass(i_type, BaseModel):
132+
value = {
133+
'name': self.name,
134+
'op': self.filter_['op'],
135+
'val': value,
136+
}
137+
return self._relationship_filtering(value)
138+
except (TypeError, ValueError):
139+
pass
115140

116141
return self.create_filter(
117142
schema_field=schema_field,

fastapi_rest_jsonapi/data_layers/sqlalchemy_engine.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ def paginate_query(self, query: Select, paginate_info: PaginationQueryStringMana
339339
:params paginate_info: pagination information.
340340
:return: the paginated query
341341
"""
342-
if paginate_info.size == 0:
342+
if paginate_info.size == 0 or paginate_info.size is None:
343343
return query
344344

345345
query = query.limit(paginate_info.size)

fastapi_rest_jsonapi/methods.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ async def wrapper(request: Request, obj_id: int, **kwargs):
4747

4848
params_function = OrderedDict(signature(func).parameters)
4949
data_dict.update({i_k: i_v for i_k, i_v in kwargs.items() if i_k in params_function})
50+
data_dict = {i_k: i_v for i_k, i_v in data_dict.items() if i_k in params_function}
5051
data_schema: Any = await func(**data_dict)
5152
return schema_resp(
5253
data={
@@ -91,6 +92,7 @@ async def wrapper(request: Request, obj_id: int, data: schema_in, **kwargs): #
9192

9293
params_function = OrderedDict(signature(func).parameters)
9394
data_dict.update({i_k: i_v for i_k, i_v in kwargs.items() if i_k in params_function})
95+
data_dict = {i_k: i_v for i_k, i_v in data_dict.items() if i_k in params_function}
9496
data_schema: Any = await func(**data_dict)
9597
return schema_resp(
9698
data={
@@ -126,6 +128,7 @@ async def wrapper(request: Request, obj_id: int, **kwargs): # type: ignore
126128

127129
params_function = OrderedDict(signature(func).parameters)
128130
data_dict.update({i_k: i_v for i_k, i_v in kwargs.items() if i_k in params_function})
131+
data_dict = {i_k: i_v for i_k, i_v in data_dict.items() if i_k in params_function}
129132
await func(**data_dict)
130133
return Response(status_code=status.HTTP_204_NO_CONTENT)
131134

@@ -139,6 +142,48 @@ async def wrapper(request: Request, obj_id: int, **kwargs): # type: ignore
139142
return inner
140143

141144

145+
def delete_list_jsonapi(
146+
schema: Type[BaseModel],
147+
model: Type[TypeModel],
148+
engine: DBORMType,
149+
) -> Callable:
150+
"""DELETE method router (Decorator for JSON API)."""
151+
152+
def inner(func: Callable) -> Callable:
153+
async def wrapper(
154+
request: Request,
155+
filters_list: Optional[str] = Query(
156+
None,
157+
alias="filter",
158+
description="[Filtering docs](https://flask-combo-jsonapi.readthedocs.io/en/latest/filtering.html)"
159+
"\nExamples:\n* filter for timestamp interval: "
160+
'`[{"name": "timestamp", "op": "ge", "val": "2020-07-16T11:35:33.383"},'
161+
'{"name": "timestamp", "op": "le", "val": "2020-07-21T11:35:33.383"}]`',
162+
),
163+
**kwargs,
164+
):
165+
query_params = QueryStringManager(request=request, schema=schema)
166+
data_dict: dict = dict(query_params=query_params)
167+
if is_necessary_request(func):
168+
data_dict["request"] = request
169+
170+
params_function = OrderedDict(signature(func).parameters)
171+
data_dict.update({i_k: i_v for i_k, i_v in kwargs.items() if i_k in params_function})
172+
data_dict = {i_k: i_v for i_k, i_v in data_dict.items() if i_k in params_function}
173+
await func(**data_dict)
174+
return Response(status_code=status.HTTP_204_NO_CONTENT)
175+
176+
# mypy ругается что нет метода __signature__, как это обойти красиво- не знаю
177+
wrapper.__signature__ = update_signature( # type: ignore
178+
sig=signature(wrapper),
179+
schema=schema,
180+
other=OrderedDict(signature(func).parameters),
181+
)
182+
return wrapper
183+
184+
return inner
185+
186+
142187
async def _get_single_response(
143188
query,
144189
query_params: QueryStringManager,
@@ -203,6 +248,7 @@ async def wrapper(
203248

204249
params_function = OrderedDict(signature(func).parameters)
205250
data.update({i_k: i_v for i_k, i_v in kwargs.items() if i_k in params_function})
251+
data = {i_k: i_v for i_k, i_v in data.items() if i_k in params_function}
206252
query = await func(**data)
207253

208254
if engine is DBORMType.sqlalchemy:
@@ -263,6 +309,7 @@ async def wrapper(request: Request, data: schema_in, **kwargs): # type: ignore
263309

264310
params_function = OrderedDict(signature(func).parameters)
265311
data_dict.update({i_k: i_v for i_k, i_v in kwargs.items() if i_k in params_function})
312+
data_dict = {i_k: i_v for i_k, i_v in data_dict.items() if i_k in params_function}
266313
data_pydantic: Any = await func(**data_dict)
267314
return schema_resp(
268315
data={

fastapi_rest_jsonapi/schema.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""Helpers to deal with marshmallow schemas. Base JSON:API schemas."""
2+
import uuid
23
from typing import (
34
Dict,
45
Type,
56
List,
67
Optional,
7-
Sequence, TYPE_CHECKING,
8+
Sequence, TYPE_CHECKING, Union,
89
)
910

1011
from fastapi import FastAPI
@@ -33,7 +34,7 @@ class BasePostJSONAPISchema(BaseJSONAPIItemSchema):
3334
class BaseJSONAPIObjectSchema(BaseJSONAPIItemSchema):
3435
"""Base JSON:API object schema."""
3536

36-
id: int = Field(description="ID объекта")
37+
id: Union[uuid.UUID, int] = Field(description="ID объекта")
3738

3839

3940
class BasePatchJSONAPISchema(BaseJSONAPIObjectSchema):
@@ -179,7 +180,13 @@ def get_relationships(schema: Type["TypeSchema"], model_field: bool = False) ->
179180
:param schema: a pydantic schema
180181
:param model_field: list of relationship fields of a schema
181182
"""
182-
relationships = [i_name for i_name, i_type in schema.__fields__.items() if issubclass(i_type.type_, BaseModel)]
183+
relationships: List[str] = []
184+
for i_name, i_type in schema.__fields__.items():
185+
try:
186+
if issubclass(i_type.type_, BaseModel):
187+
relationships.append(i_name)
188+
except TypeError:
189+
pass
183190

184191
if model_field is True:
185192
relationships = [get_model_field(schema, key) for key in relationships]

fastapi_rest_jsonapi/signature.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ def update_signature(
4545
params_dict.pop("cls", None)
4646
params_no_default = [
4747
i_param
48-
for i_param in other.values()
49-
if isinstance(i_param.default, type) and other["query_params"].annotation is not QueryStringManager
48+
for i_name, i_param in other.items()
49+
if isinstance(i_param.default, type) and other[i_name].annotation is not QueryStringManager
5050
]
5151
params_default = [
5252
i_param
@@ -56,7 +56,10 @@ def update_signature(
5656
params: list = list(params_dict.values())
5757
for name, field in (schema and schema.__fields__ or {}).items():
5858
try:
59-
if issubclass(field.type_, (dict, BaseModel)):
59+
if field.sub_fields:
60+
default = Query(None, alias="filter[{alias}]".format(alias=field.alias))
61+
type_field = field.type_
62+
elif issubclass(field.type_, (dict, BaseModel)):
6063
continue
6164
elif issubclass(field.type_, Enum):
6265
default = Query(None, alias="filter[{alias}]".format(alias=field.alias), enum=field.type_.values())
@@ -75,9 +78,10 @@ def update_signature(
7578
except Exception as ex:
7679
pass
7780

78-
params: list = params_no_default + params + params_default
81+
params: list = params + params_no_default + params_default
7982
# Убираем дубликаты
8083
params_dict: dict = {i_p.name: i_p for i_p in params}
8184
params: list = list(params_dict.values())
85+
params.sort(key=lambda x: not isinstance(x.default, type))
8286

8387
return sig.replace(parameters=params)

0 commit comments

Comments
 (0)