From 67e7711fe3df4260780a482e158bd220a9a28cd8 Mon Sep 17 00:00:00 2001 From: German Bernadskiy Date: Tue, 12 Dec 2023 19:26:15 +1000 Subject: [PATCH 01/12] added test case --- tests/models.py | 7 +- tests/test_api/test_api_sqla_with_includes.py | 96 ++++++++++++++++++- 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/tests/models.py b/tests/models.py index 00a8bd16..53d50151 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING, Dict, List, Optional from uuid import UUID -from sqlalchemy import JSON, Column, ForeignKey, Index, Integer, String, Text +from sqlalchemy import JSON, Column, DateTime, ForeignKey, Index, Integer, String, Text from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import declared_attr, relationship from sqlalchemy.types import CHAR, TypeDecorator @@ -296,3 +296,8 @@ class SelfRelationship(Base): ) # parent = relationship("SelfRelationship", back_populates="s") self_relationship = relationship("SelfRelationship", remote_side=[id]) + + +class ContainsTimestamp(Base): + id = Column(Integer, primary_key=True) + timestamp = Column(DateTime(True), nullable=False) diff --git a/tests/test_api/test_api_sqla_with_includes.py b/tests/test_api/test_api_sqla_with_includes.py index c2a47956..434df0c1 100644 --- a/tests/test_api/test_api_sqla_with_includes.py +++ b/tests/test_api/test_api_sqla_with_includes.py @@ -1,14 +1,17 @@ +import json import logging +from datetime import datetime, timezone from collections import defaultdict from itertools import chain, zip_longest from json import dumps -from typing import Dict, List +from typing import Dict, List, Optional from uuid import UUID, uuid4 from fastapi import FastAPI, status from httpx import AsyncClient from pydantic import BaseModel, Field from pytest import fixture, mark, param # noqa PT013 +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from fastapi_jsonapi.views.view_base import ViewBase @@ -17,6 +20,7 @@ from tests.misc.utils import fake from tests.models import ( Computer, + ContainsTimestamp, IdCast, Post, PostComment, @@ -1215,6 +1219,96 @@ async def test_create_with_relationship_to_the_same_table(self): "meta": None, } + async def test_create_with_timestamp(self, async_session: AsyncSession): + resource_type = "contains_timestamp_model" + + class ContainsTimestampAttrsSchema(BaseModel): + timestamp: datetime + + app = build_app_custom( + model=ContainsTimestamp, + schema=ContainsTimestampAttrsSchema, + schema_in_post=ContainsTimestampAttrsSchema, + schema_in_patch=ContainsTimestampAttrsSchema, + resource_type=resource_type, + ) + + create_timestamp = datetime.now(tz=timezone.utc) + create_user_body = { + "data": { + "attributes": { + "timestamp": create_timestamp.isoformat(), + }, + }, + } + + async with AsyncClient(app=app, base_url="http://test") as client: + url = app.url_path_for(f"get_{resource_type}_list") + res = await client.post(url, json=create_user_body) + assert res.status_code == status.HTTP_201_CREATED, res.text + response_json = res.json() + + assert (entity_id := response_json["data"]["id"]) + assert response_json == { + "meta": None, + "jsonapi": {"version": "1.0"}, + "data": { + "type": "contains_timestamp_model", + "attributes": {"timestamp": create_timestamp.isoformat()}, + "id": entity_id, + }, + } + + stms = select(ContainsTimestamp).where(ContainsTimestamp.id == int(entity_id)) + entity_model: Optional[ContainsTimestamp] = (await async_session.execute(stms)).scalar_one_or_none() + assert entity_model + assert entity_model.timestamp == create_timestamp + + params = { + "filter": json.dumps( + [ + { + "name": "timestamp", + "op": "eq", + "val": create_timestamp.isoformat(), + }, + ], + ), + } + + # successfully filtered + res = await client.get(url, params=params) + assert res.status_code == status.HTTP_200_OK, res.text + assert res.json() == { + "meta": {"count": 1, "totalPages": 1}, + "jsonapi": {"version": "1.0"}, + "data": [ + { + "type": "contains_timestamp_model", + "attributes": {"timestamp": create_timestamp.isoformat()}, + "id": entity_id, + }, + ], + } + + # check filter really work + params = { + "filter": [ + { + "name": "timestamp", + "op": "eq", + "val": datetime.now(tz=timezone.utc).isoformat(), + }, + ], + } + res = await client.get(url, params=params) + assert res.status_code == status.HTTP_200_OK, res.text + assert res.json() == { + "meta": {"count": 0, "totalPages": 1}, + "jsonapi": {"version": "1.0"}, + "data": [], + } + class TestPatchObjects: async def test_patch_object( From 65aadb883ee1d08ba43145d1561ae8a9f83fdc31 Mon Sep 17 00:00:00 2001 From: German Bernadskiy Date: Tue, 12 Dec 2023 19:27:45 +1000 Subject: [PATCH 02/12] added test case --- fastapi_jsonapi/data_layers/filtering/sqlalchemy.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py b/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py index 0bdfc7aa..bee18e8a 100644 --- a/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py +++ b/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py @@ -83,6 +83,7 @@ def create_filter(self, schema_field: ModelField, model_column, operator, value) if isinstance(value, list): # noqa: SIM108 clear_value = [i_type(item) for item in value] else: + # pass clear_value = i_type(value) except (TypeError, ValueError) as ex: errors.append(str(ex)) From 39a3032f18516be41a586af9c5f4bef629a0f6bd Mon Sep 17 00:00:00 2001 From: German Bernadskiy Date: Thu, 14 Dec 2023 17:19:07 +1000 Subject: [PATCH 03/12] updated type cast logic in filters --- .../data_layers/filtering/sqlalchemy.py | 100 +++++++++++++++--- tests/test_api/test_api_sqla_with_includes.py | 20 ++-- 2 files changed, 99 insertions(+), 21 deletions(-) diff --git a/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py b/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py index bee18e8a..049438ff 100644 --- a/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py +++ b/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py @@ -1,8 +1,18 @@ """Helper to create sqlalchemy filters according to filter querystring parameter""" -from typing import Any, List, Tuple, Type, Union - -from pydantic import BaseModel +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Tuple, + Type, + Union, +) + +from pydantic import BaseConfig, BaseModel from pydantic.fields import ModelField +from pydantic.validators import _VALIDATORS, find_validators from sqlalchemy import and_, not_, or_ from sqlalchemy.orm import InstrumentedAttribute, aliased from sqlalchemy.sql.elements import BinaryExpression @@ -22,6 +32,9 @@ List[Join], ] +# The mapping with validators using by to cast raw value to instance of target type +REGISTERED_PYDANTIC_TYPES: Dict[Type, List[Callable]] = dict(_VALIDATORS) + def create_filters(model: Type[TypeModel], filter_info: Union[list, dict], schema: Type[TypeSchema]): """ @@ -78,20 +91,83 @@ def create_filter(self, schema_field: ModelField, model_column, operator, value) types = [i.type_ for i in fields] clear_value = None errors: List[str] = [] - for i_type in types: - try: - if isinstance(value, list): # noqa: SIM108 - clear_value = [i_type(item) for item in value] - else: - # pass - clear_value = i_type(value) - except (TypeError, ValueError) as ex: - errors.append(str(ex)) + + pydantic_types, userspace_types = self._separate_types(types) + + if pydantic_types: + if isinstance(value, list): + clear_value, errors = self._cast_iterable_with_pydantic(pydantic_types, value) + else: + clear_value, errors = self._cast_value_with_pydantic(pydantic_types, value) + + if clear_value is None and userspace_types: + for i_type in types: + try: + if isinstance(value, list): # noqa: SIM108 + clear_value = [i_type(item) for item in value] + else: + clear_value = i_type(value) + except (TypeError, ValueError) as ex: + errors.append(str(ex)) + # Если None, при этом поле обязательное (среди типов в аннотации нет None, то кидаем ошибку) if clear_value is None and not any(not i_f.required for i_f in fields): raise InvalidType(detail=", ".join(errors)) return getattr(model_column, self.operator)(clear_value) + def _separate_types(self, types: List[Type]) -> Tuple[List[Type], List[Type]]: + """ + Separates the types into two kinds. The first are those for which + there are already validators defined by pydantic - str, int, datetime + and some other built-in types. The second are all other types for which + the `arbitrary_types_allowed` config is applied when defining the pydantic model + """ + pydantic_types = filter(lambda type_: type_ in REGISTERED_PYDANTIC_TYPES, types) + userspace_types_types = filter(lambda type_: type_ not in REGISTERED_PYDANTIC_TYPES, types) + return list(pydantic_types), list(userspace_types_types) + + def _cast_value_with_pydantic( + self, + types: List[Type], + value: Any, + ) -> Tuple[Optional[Any], List[str]]: + result_value, errors = None, [] + + for type_to_cast in types: + for validator in find_validators(type_to_cast, BaseConfig): + try: + result_value = validator(value) + return result_value, errors + except Exception as ex: + errors.append(str(ex)) + + return None, errors + + def _cast_iterable_with_pydantic(self, types: List[Type], values: List) -> Tuple[List, List[str]]: + type_cast_failed = False + failed_values = [] + + result_values: List[Any] = [] + errors: List[str] = [] + + for value in values: + casted_value, cast_errors = self._cast_value_with_pydantic(types, value) + errors.extend(cast_errors) + + if casted_value is None: + type_cast_failed = True + failed_values.append(value) + + continue + + result_values.append(casted_value) + + if type_cast_failed: + msg = f"Can't parse items {failed_values} of value {values}" + raise InvalidFilters(msg) + + return result_values, errors + def resolve(self) -> FilterAndJoins: # noqa: PLR0911 """Create filter for a particular node of the filter tree""" if "or" in self.filter_: diff --git a/tests/test_api/test_api_sqla_with_includes.py b/tests/test_api/test_api_sqla_with_includes.py index 434df0c1..aa1f44d1 100644 --- a/tests/test_api/test_api_sqla_with_includes.py +++ b/tests/test_api/test_api_sqla_with_includes.py @@ -1262,7 +1262,7 @@ class ContainsTimestampAttrsSchema(BaseModel): stms = select(ContainsTimestamp).where(ContainsTimestamp.id == int(entity_id)) entity_model: Optional[ContainsTimestamp] = (await async_session.execute(stms)).scalar_one_or_none() assert entity_model - assert entity_model.timestamp == create_timestamp + assert entity_model.timestamp.isoformat() == create_timestamp.replace(tzinfo=None).isoformat() params = { "filter": json.dumps( @@ -1285,7 +1285,7 @@ class ContainsTimestampAttrsSchema(BaseModel): "data": [ { "type": "contains_timestamp_model", - "attributes": {"timestamp": create_timestamp.isoformat()}, + "attributes": {"timestamp": create_timestamp.replace(tzinfo=None).isoformat()}, "id": entity_id, }, ], @@ -1293,13 +1293,15 @@ class ContainsTimestampAttrsSchema(BaseModel): # check filter really work params = { - "filter": [ - { - "name": "timestamp", - "op": "eq", - "val": datetime.now(tz=timezone.utc).isoformat(), - }, - ], + "filter": json.dumps( + [ + { + "name": "timestamp", + "op": "eq", + "val": datetime.now(tz=timezone.utc).isoformat(), + }, + ], + ), } res = await client.get(url, params=params) assert res.status_code == status.HTTP_200_OK, res.text From 14b61f869a7cef98ebce139e57ed6f124eed71f3 Mon Sep 17 00:00:00 2001 From: German Bernadskiy Date: Thu, 14 Dec 2023 18:39:38 +1000 Subject: [PATCH 04/12] fix --- tests/common.py | 4 ++++ tests/test_api/test_api_sqla_with_includes.py | 18 +++++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/common.py b/tests/common.py index fb0e3931..966c2908 100644 --- a/tests/common.py +++ b/tests/common.py @@ -8,3 +8,7 @@ def sqla_uri(): db_dir = Path(__file__).resolve().parent testing_db_url = f"sqlite+aiosqlite:///{db_dir}/db.sqlite3" return testing_db_url + + +def is_postgres_tests() -> bool: + return "postgres" in sqla_uri() diff --git a/tests/test_api/test_api_sqla_with_includes.py b/tests/test_api/test_api_sqla_with_includes.py index aa1f44d1..52585816 100644 --- a/tests/test_api/test_api_sqla_with_includes.py +++ b/tests/test_api/test_api_sqla_with_includes.py @@ -15,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from fastapi_jsonapi.views.view_base import ViewBase +from tests.common import is_postgres_tests from tests.fixtures.app import build_app_custom from tests.fixtures.entities import build_workplace, create_user from tests.misc.utils import fake @@ -1219,7 +1220,7 @@ async def test_create_with_relationship_to_the_same_table(self): "meta": None, } - async def test_create_with_timestamp(self, async_session: AsyncSession): + async def test_create_with_timestamp_and_fetch(self, async_session: AsyncSession): resource_type = "contains_timestamp_model" class ContainsTimestampAttrsSchema(BaseModel): @@ -1253,7 +1254,7 @@ class ContainsTimestampAttrsSchema(BaseModel): "meta": None, "jsonapi": {"version": "1.0"}, "data": { - "type": "contains_timestamp_model", + "type": resource_type, "attributes": {"timestamp": create_timestamp.isoformat()}, "id": entity_id, }, @@ -1262,7 +1263,14 @@ class ContainsTimestampAttrsSchema(BaseModel): stms = select(ContainsTimestamp).where(ContainsTimestamp.id == int(entity_id)) entity_model: Optional[ContainsTimestamp] = (await async_session.execute(stms)).scalar_one_or_none() assert entity_model - assert entity_model.timestamp.isoformat() == create_timestamp.replace(tzinfo=None).isoformat() + assert ( + entity_model.timestamp.replace(tzinfo=None).isoformat() + == create_timestamp.replace(tzinfo=None).isoformat() + ) + + expected_response_timestamp = create_timestamp.replace(tzinfo=None).isoformat() + if is_postgres_tests(): + expected_response_timestamp = create_timestamp.replace().isoformat() params = { "filter": json.dumps( @@ -1284,8 +1292,8 @@ class ContainsTimestampAttrsSchema(BaseModel): "jsonapi": {"version": "1.0"}, "data": [ { - "type": "contains_timestamp_model", - "attributes": {"timestamp": create_timestamp.replace(tzinfo=None).isoformat()}, + "type": resource_type, + "attributes": {"timestamp": expected_response_timestamp}, "id": entity_id, }, ], From 6d7fbd1f266d362175279db464043e914b52659f Mon Sep 17 00:00:00 2001 From: German Bernadskiy Date: Fri, 15 Dec 2023 18:04:37 +1000 Subject: [PATCH 05/12] refactor --- .../data_layers/filtering/sqlalchemy.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py b/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py index 049438ff..485cd5ac 100644 --- a/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py +++ b/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py @@ -122,9 +122,19 @@ def _separate_types(self, types: List[Type]) -> Tuple[List[Type], List[Type]]: and some other built-in types. The second are all other types for which the `arbitrary_types_allowed` config is applied when defining the pydantic model """ - pydantic_types = filter(lambda type_: type_ in REGISTERED_PYDANTIC_TYPES, types) - userspace_types_types = filter(lambda type_: type_ not in REGISTERED_PYDANTIC_TYPES, types) - return list(pydantic_types), list(userspace_types_types) + pydantic_types = [ + # skip format + type_ + for type_ in types + if type_ in REGISTERED_PYDANTIC_TYPES + ] + userspace_types = [ + # skip format + type_ + for type_ in types + if type_ not in REGISTERED_PYDANTIC_TYPES + ] + return pydantic_types, userspace_types def _cast_value_with_pydantic( self, From 34b90db73ddead0dfa2a746c5b321c0445025a64 Mon Sep 17 00:00:00 2001 From: German Bernadskiy Date: Fri, 15 Dec 2023 19:14:17 +1000 Subject: [PATCH 06/12] added tests for userspace type cast --- .../data_layers/filtering/sqlalchemy.py | 13 ++- tests/test_api/test_api_sqla_with_includes.py | 80 ++++++++++++++++++- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py b/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py index 485cd5ac..16f53143 100644 --- a/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py +++ b/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py @@ -1,4 +1,5 @@ """Helper to create sqlalchemy filters according to filter querystring parameter""" +import logging from typing import ( Any, Callable, @@ -24,6 +25,8 @@ from fastapi_jsonapi.splitter import SPLIT_REL from fastapi_jsonapi.utils.sqla import get_related_model_cls +log = logging.getLogger(__name__) + Filter = BinaryExpression Join = List[Any] @@ -61,7 +64,7 @@ def __init__(self, model: Type[TypeModel], filter_: dict, schema: Type[TypeSchem self.filter_ = filter_ self.schema = schema - def create_filter(self, schema_field: ModelField, model_column, operator, value): + def create_filter(self, schema_field: ModelField, model_column, operator, value): # noqa: PLR0912 temporary """ Create sqlalchemy filter :param schema_field: @@ -101,6 +104,11 @@ def create_filter(self, schema_field: ModelField, model_column, operator, value) clear_value, errors = self._cast_value_with_pydantic(pydantic_types, value) if clear_value is None and userspace_types: + log.warning("Filtering by user type values is not properly tested yet. Use this on your own risk.") + + cast_failed = object() + clear_value = cast_failed + for i_type in types: try: if isinstance(value, list): # noqa: SIM108 @@ -110,6 +118,9 @@ def create_filter(self, schema_field: ModelField, model_column, operator, value) except (TypeError, ValueError) as ex: errors.append(str(ex)) + if clear_value is cast_failed: + raise InvalidType(detail=f"Can't cast filter value `{value}` to user type. {', '.join(errors)}") + # Если None, при этом поле обязательное (среди типов в аннотации нет None, то кидаем ошибку) if clear_value is None and not any(not i_f.required for i_f in fields): raise InvalidType(detail=", ".join(errors)) diff --git a/tests/test_api/test_api_sqla_with_includes.py b/tests/test_api/test_api_sqla_with_includes.py index 52585816..800220de 100644 --- a/tests/test_api/test_api_sqla_with_includes.py +++ b/tests/test_api/test_api_sqla_with_includes.py @@ -4,16 +4,19 @@ from collections import defaultdict from itertools import chain, zip_longest from json import dumps -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional +from unittest.mock import Mock from uuid import UUID, uuid4 from fastapi import FastAPI, status from httpx import AsyncClient from pydantic import BaseModel, Field -from pytest import fixture, mark, param # noqa PT013 +from pytest import fixture, mark, param, raises # noqa PT013 from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from fastapi_jsonapi.data_layers.filtering.sqlalchemy import Node +from fastapi_jsonapi.exceptions.json_api import InvalidType from fastapi_jsonapi.views.view_base import ViewBase from tests.common import is_postgres_tests from tests.fixtures.app import build_app_custom @@ -1235,7 +1238,7 @@ class ContainsTimestampAttrsSchema(BaseModel): ) create_timestamp = datetime.now(tz=timezone.utc) - create_user_body = { + create_body = { "data": { "attributes": { "timestamp": create_timestamp.isoformat(), @@ -1245,7 +1248,7 @@ class ContainsTimestampAttrsSchema(BaseModel): async with AsyncClient(app=app, base_url="http://test") as client: url = app.url_path_for(f"get_{resource_type}_list") - res = await client.post(url, json=create_user_body) + res = await client.post(url, json=create_body) assert res.status_code == status.HTTP_201_CREATED, res.text response_json = res.json() @@ -2355,4 +2358,73 @@ async def test_sort( } +# TODO: move to it's own test module +class TestSQLAFilteringModule: + def test_user_type_cast_success(self): + class UserType: + def __init__(self, *args, **kwargs): + self.value = "success" + + class ModelSchema(BaseModel): + user_type: UserType + + class Config: + arbitrary_types_allowed = True + + node = Node( + model=Mock(), + filter_={ + "name": "user_type", + "op": "eq", + "val": Any, + }, + schema=ModelSchema, + ) + + model_column_mock = Mock() + model_column_mock.eq = lambda clear_value: clear_value + + clear_value = node.create_filter( + schema_field=ModelSchema.__fields__["user_type"], + model_column=model_column_mock, + operator=Mock(), + value=Any, + ) + assert isinstance(clear_value, UserType) + assert clear_value.value == "success" + + def test_user_type_cast_fail(self): + class UserType: + def __init__(self, *args, **kwargs): + msg = "Cast failed" + raise ValueError(msg) + + class ModelSchema(BaseModel): + user_type: UserType + + class Config: + arbitrary_types_allowed = True + + node = Node( + model=Mock(), + filter_=Mock(), + schema=ModelSchema, + ) + + with raises(InvalidType) as exc_info: + node.create_filter( + schema_field=ModelSchema.__fields__["user_type"], + model_column=Mock(), + operator=Mock(), + value=Any, + ) + + assert exc_info.value.as_dict == { + "detail": "Can't cast filter value `typing.Any` to user type. Cast failed", + "source": {"pointer": ""}, + "status_code": status.HTTP_409_CONFLICT, + "title": "Invalid type.", + } + + # todo: test errors From 583fdf6574691e226187c9273bdce2640ef064cf Mon Sep 17 00:00:00 2001 From: German Bernadskiy Date: Fri, 15 Dec 2023 19:31:41 +1000 Subject: [PATCH 07/12] refactor --- fastapi_jsonapi/data_layers/filtering/sqlalchemy.py | 6 +++++- tests/test_api/test_api_sqla_with_includes.py | 11 +++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py b/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py index 16f53143..ef13d99c 100644 --- a/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py +++ b/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py @@ -21,6 +21,7 @@ from fastapi_jsonapi.data_layers.shared import create_filters_or_sorts from fastapi_jsonapi.data_typing import TypeModel, TypeSchema from fastapi_jsonapi.exceptions import InvalidFilters, InvalidType +from fastapi_jsonapi.exceptions.json_api import HTTPException from fastapi_jsonapi.schema import get_model_field, get_relationships from fastapi_jsonapi.splitter import SPLIT_REL from fastapi_jsonapi.utils.sqla import get_related_model_cls @@ -119,7 +120,10 @@ def create_filter(self, schema_field: ModelField, model_column, operator, value) errors.append(str(ex)) if clear_value is cast_failed: - raise InvalidType(detail=f"Can't cast filter value `{value}` to user type. {', '.join(errors)}") + raise InvalidType( + detail=f"Can't cast filter value `{value}` to user type.", + errors=[HTTPException(status_code=InvalidType.status_code, detail=str(err)) for err in errors], + ) # Если None, при этом поле обязательное (среди типов в аннотации нет None, то кидаем ошибку) if clear_value is None and not any(not i_f.required for i_f in fields): diff --git a/tests/test_api/test_api_sqla_with_includes.py b/tests/test_api/test_api_sqla_with_includes.py index 800220de..0bf47474 100644 --- a/tests/test_api/test_api_sqla_with_includes.py +++ b/tests/test_api/test_api_sqla_with_includes.py @@ -2420,8 +2420,15 @@ class Config: ) assert exc_info.value.as_dict == { - "detail": "Can't cast filter value `typing.Any` to user type. Cast failed", - "source": {"pointer": ""}, + "detail": "Can't cast filter value `typing.Any` to user type.", + "meta": [ + { + "detail": "Cast failed", + "source": {"pointer": ""}, + "status_code": status.HTTP_409_CONFLICT, + "title": "Conflict", + }, + ], "status_code": status.HTTP_409_CONFLICT, "title": "Invalid type.", } From e5e456949a47248866e3f980bf31568faff5f801 Mon Sep 17 00:00:00 2001 From: German Bernadskiy Date: Fri, 15 Dec 2023 19:43:54 +1000 Subject: [PATCH 08/12] refactor --- tests/test_api/test_api_sqla_with_includes.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/tests/test_api/test_api_sqla_with_includes.py b/tests/test_api/test_api_sqla_with_includes.py index 0bf47474..37d9171b 100644 --- a/tests/test_api/test_api_sqla_with_includes.py +++ b/tests/test_api/test_api_sqla_with_includes.py @@ -4,7 +4,7 @@ from collections import defaultdict from itertools import chain, zip_longest from json import dumps -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List from unittest.mock import Mock from uuid import UUID, uuid4 @@ -1264,12 +1264,7 @@ class ContainsTimestampAttrsSchema(BaseModel): } stms = select(ContainsTimestamp).where(ContainsTimestamp.id == int(entity_id)) - entity_model: Optional[ContainsTimestamp] = (await async_session.execute(stms)).scalar_one_or_none() - assert entity_model - assert ( - entity_model.timestamp.replace(tzinfo=None).isoformat() - == create_timestamp.replace(tzinfo=None).isoformat() - ) + (await async_session.execute(stms)).scalar_one() expected_response_timestamp = create_timestamp.replace(tzinfo=None).isoformat() if is_postgres_tests(): From 4492391d34b1b5781bc52433cafb0e7d022a5b75 Mon Sep 17 00:00:00 2001 From: German Bernadskiy Date: Fri, 15 Dec 2023 19:45:42 +1000 Subject: [PATCH 09/12] refactor --- fastapi_jsonapi/data_layers/filtering/sqlalchemy.py | 2 +- tests/test_api/test_api_sqla_with_includes.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py b/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py index ef13d99c..3746dfa6 100644 --- a/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py +++ b/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py @@ -121,7 +121,7 @@ def create_filter(self, schema_field: ModelField, model_column, operator, value) if clear_value is cast_failed: raise InvalidType( - detail=f"Can't cast filter value `{value}` to user type.", + detail=f"Can't cast filter value `{value}` to arbitrary type.", errors=[HTTPException(status_code=InvalidType.status_code, detail=str(err)) for err in errors], ) diff --git a/tests/test_api/test_api_sqla_with_includes.py b/tests/test_api/test_api_sqla_with_includes.py index 37d9171b..3ced3c97 100644 --- a/tests/test_api/test_api_sqla_with_includes.py +++ b/tests/test_api/test_api_sqla_with_includes.py @@ -2415,7 +2415,7 @@ class Config: ) assert exc_info.value.as_dict == { - "detail": "Can't cast filter value `typing.Any` to user type.", + "detail": "Can't cast filter value `typing.Any` to arbitrary type.", "meta": [ { "detail": "Cast failed", From 09f72f3164d4d479fdc58ccef369657b95606562 Mon Sep 17 00:00:00 2001 From: German Bernadskiy Date: Fri, 15 Dec 2023 19:49:45 +1000 Subject: [PATCH 10/12] refactor --- .../data_layers/filtering/sqlalchemy.py | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py b/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py index 3746dfa6..e676c021 100644 --- a/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py +++ b/fastapi_jsonapi/data_layers/filtering/sqlalchemy.py @@ -39,6 +39,8 @@ # The mapping with validators using by to cast raw value to instance of target type REGISTERED_PYDANTIC_TYPES: Dict[Type, List[Callable]] = dict(_VALIDATORS) +cast_failed = object() + def create_filters(model: Type[TypeModel], filter_info: Union[list, dict], schema: Type[TypeSchema]): """ @@ -65,7 +67,22 @@ def __init__(self, model: Type[TypeModel], filter_: dict, schema: Type[TypeSchem self.filter_ = filter_ self.schema = schema - def create_filter(self, schema_field: ModelField, model_column, operator, value): # noqa: PLR0912 temporary + def _cast_value_with_scheme(self, field_types: List[ModelField], value: Any) -> Tuple[Any, List[str]]: + errors: List[str] = [] + casted_value = cast_failed + + for field_type in field_types: + try: + if isinstance(value, list): # noqa: SIM108 + casted_value = [field_type(item) for item in value] + else: + casted_value = field_type(value) + except (TypeError, ValueError) as ex: + errors.append(str(ex)) + + return casted_value, errors + + def create_filter(self, schema_field: ModelField, model_column, operator, value): """ Create sqlalchemy filter :param schema_field: @@ -107,17 +124,7 @@ def create_filter(self, schema_field: ModelField, model_column, operator, value) if clear_value is None and userspace_types: log.warning("Filtering by user type values is not properly tested yet. Use this on your own risk.") - cast_failed = object() - clear_value = cast_failed - - for i_type in types: - try: - if isinstance(value, list): # noqa: SIM108 - clear_value = [i_type(item) for item in value] - else: - clear_value = i_type(value) - except (TypeError, ValueError) as ex: - errors.append(str(ex)) + clear_value, errors = self._cast_value_with_scheme(types, value) if clear_value is cast_failed: raise InvalidType( From a679a1a4a373eb861d2535bcc1b7bbf13ad3e1bd Mon Sep 17 00:00:00 2001 From: German Bernadskiy Date: Fri, 15 Dec 2023 19:56:40 +1000 Subject: [PATCH 11/12] refactor --- tests/test_api/test_api_sqla_with_includes.py | 81 +----------------- tests/test_data_layers/__init__.py | 0 .../test_filtering/__init__.py | 0 .../test_filtering/test_sqlalchemy.py | 84 +++++++++++++++++++ 4 files changed, 85 insertions(+), 80 deletions(-) create mode 100644 tests/test_data_layers/__init__.py create mode 100644 tests/test_data_layers/test_filtering/__init__.py create mode 100644 tests/test_data_layers/test_filtering/test_sqlalchemy.py diff --git a/tests/test_api/test_api_sqla_with_includes.py b/tests/test_api/test_api_sqla_with_includes.py index 3ced3c97..9af3fd60 100644 --- a/tests/test_api/test_api_sqla_with_includes.py +++ b/tests/test_api/test_api_sqla_with_includes.py @@ -4,8 +4,7 @@ from collections import defaultdict from itertools import chain, zip_longest from json import dumps -from typing import Any, Dict, List -from unittest.mock import Mock +from typing import Dict, List from uuid import UUID, uuid4 from fastapi import FastAPI, status @@ -15,8 +14,6 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from fastapi_jsonapi.data_layers.filtering.sqlalchemy import Node -from fastapi_jsonapi.exceptions.json_api import InvalidType from fastapi_jsonapi.views.view_base import ViewBase from tests.common import is_postgres_tests from tests.fixtures.app import build_app_custom @@ -2353,80 +2350,4 @@ async def test_sort( } -# TODO: move to it's own test module -class TestSQLAFilteringModule: - def test_user_type_cast_success(self): - class UserType: - def __init__(self, *args, **kwargs): - self.value = "success" - - class ModelSchema(BaseModel): - user_type: UserType - - class Config: - arbitrary_types_allowed = True - - node = Node( - model=Mock(), - filter_={ - "name": "user_type", - "op": "eq", - "val": Any, - }, - schema=ModelSchema, - ) - - model_column_mock = Mock() - model_column_mock.eq = lambda clear_value: clear_value - - clear_value = node.create_filter( - schema_field=ModelSchema.__fields__["user_type"], - model_column=model_column_mock, - operator=Mock(), - value=Any, - ) - assert isinstance(clear_value, UserType) - assert clear_value.value == "success" - - def test_user_type_cast_fail(self): - class UserType: - def __init__(self, *args, **kwargs): - msg = "Cast failed" - raise ValueError(msg) - - class ModelSchema(BaseModel): - user_type: UserType - - class Config: - arbitrary_types_allowed = True - - node = Node( - model=Mock(), - filter_=Mock(), - schema=ModelSchema, - ) - - with raises(InvalidType) as exc_info: - node.create_filter( - schema_field=ModelSchema.__fields__["user_type"], - model_column=Mock(), - operator=Mock(), - value=Any, - ) - - assert exc_info.value.as_dict == { - "detail": "Can't cast filter value `typing.Any` to arbitrary type.", - "meta": [ - { - "detail": "Cast failed", - "source": {"pointer": ""}, - "status_code": status.HTTP_409_CONFLICT, - "title": "Conflict", - }, - ], - "status_code": status.HTTP_409_CONFLICT, - "title": "Invalid type.", - } - - # todo: test errors diff --git a/tests/test_data_layers/__init__.py b/tests/test_data_layers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_data_layers/test_filtering/__init__.py b/tests/test_data_layers/test_filtering/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_data_layers/test_filtering/test_sqlalchemy.py b/tests/test_data_layers/test_filtering/test_sqlalchemy.py new file mode 100644 index 00000000..18f5e4ef --- /dev/null +++ b/tests/test_data_layers/test_filtering/test_sqlalchemy.py @@ -0,0 +1,84 @@ +from typing import Any +from unittest.mock import Mock + +from fastapi import status +from pydantic import BaseModel +from pytest import raises # noqa PT013 + +from fastapi_jsonapi.data_layers.filtering.sqlalchemy import Node +from fastapi_jsonapi.exceptions.json_api import InvalidType + + +class TestNode: + def test_user_type_cast_success(self): + class UserType: + def __init__(self, *args, **kwargs): + self.value = "success" + + class ModelSchema(BaseModel): + user_type: UserType + + class Config: + arbitrary_types_allowed = True + + node = Node( + model=Mock(), + filter_={ + "name": "user_type", + "op": "eq", + "val": Any, + }, + schema=ModelSchema, + ) + + model_column_mock = Mock() + model_column_mock.eq = lambda clear_value: clear_value + + clear_value = node.create_filter( + schema_field=ModelSchema.__fields__["user_type"], + model_column=model_column_mock, + operator=Mock(), + value=Any, + ) + assert isinstance(clear_value, UserType) + assert clear_value.value == "success" + + def test_user_type_cast_fail(self): + class UserType: + def __init__(self, *args, **kwargs): + msg = "Cast failed" + raise ValueError(msg) + + class ModelSchema(BaseModel): + user_type: UserType + + class Config: + arbitrary_types_allowed = True + + node = Node( + model=Mock(), + filter_=Mock(), + schema=ModelSchema, + ) + + with raises(InvalidType) as exc_info: + node.create_filter( + schema_field=ModelSchema.__fields__["user_type"], + model_column=Mock(), + operator=Mock(), + value=Any, + ) + + assert exc_info.value.as_dict == { + "detail": "Can't cast filter value `typing.Any` to arbitrary type.", + "meta": [ + { + "detail": "Cast failed", + "source": {"pointer": ""}, + "status_code": status.HTTP_409_CONFLICT, + "title": "Conflict", + }, + ], + "status_code": status.HTTP_409_CONFLICT, + "title": "Invalid type.", + } From e5dfddb8dcf83ca83c5738219e43af5e62597a2c Mon Sep 17 00:00:00 2001 From: German Bernadskiy Date: Fri, 15 Dec 2023 20:03:17 +1000 Subject: [PATCH 12/12] refactor --- tests/test_api/test_api_sqla_with_includes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_api/test_api_sqla_with_includes.py b/tests/test_api/test_api_sqla_with_includes.py index 9af3fd60..215a8e04 100644 --- a/tests/test_api/test_api_sqla_with_includes.py +++ b/tests/test_api/test_api_sqla_with_includes.py @@ -1,7 +1,7 @@ import json import logging -from datetime import datetime, timezone from collections import defaultdict +from datetime import datetime, timezone from itertools import chain, zip_longest from json import dumps from typing import Dict, List