Skip to content

Commit d4d451e

Browse files
CosmoVmahenzon
authored andcommitted
fixed filter by null condition
updated null filtering logic added example with filter by not null added example with filter by null
1 parent b56891f commit d4d451e

File tree

5 files changed

+107
-34
lines changed

5 files changed

+107
-34
lines changed

docs/filtering.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,22 @@ You can also use boolean combination of operations:
119119
GET /user?filter=[{"name":"group.name","op":"ilike","val":"%admin%"},{"or":[{"not":{"name":"first_name","op":"eq","val":"John"}},{"and":[{"name":"first_name","op":"like","val":"%Jim%"},{"name":"date_create","op":"gt","val":"1990-01-01"}]}]}] HTTP/1.1
120120
Accept: application/vnd.api+json
121121

122+
123+
Filtering records by a field that is null
124+
125+
.. sourcecode:: http
126+
127+
GET /user?filter=[{"name":"name","op":"is_","val":null}] HTTP/1.1
128+
Accept: application/vnd.api+json
129+
130+
Filtering records by a field that is not null
131+
132+
.. sourcecode:: http
133+
134+
GET /user?filter=[{"name":"name","op":"isnot","val":null}] HTTP/1.1
135+
Accept: application/vnd.api+json
136+
137+
122138
Common available operators:
123139

124140
* any: used to filter on "to many" relationships

fastapi_jsonapi/data_layers/filtering/sqlalchemy.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ def __init__(self, model: Type[TypeModel], filter_: dict, schema: Type[TypeSchem
6767
self.filter_ = filter_
6868
self.schema = schema
6969

70+
def _check_can_be_none(self, fields: list[ModelField]) -> bool:
71+
"""
72+
Return True if None is possible value for target field
73+
"""
74+
return any(field_item.allow_none for field_item in fields)
75+
7076
def _cast_value_with_scheme(self, field_types: List[ModelField], value: Any) -> Tuple[Any, List[str]]:
7177
errors: List[str] = []
7278
casted_value = cast_failed
@@ -109,8 +115,17 @@ def create_filter(self, schema_field: ModelField, model_column, operator, value)
109115
fields = list(schema_field.sub_fields)
110116
else:
111117
fields = [schema_field]
118+
119+
can_be_none = self._check_can_be_none(fields)
120+
121+
if value is None:
122+
if can_be_none:
123+
return getattr(model_column, self.operator)(value)
124+
125+
raise InvalidFilters(detail=f"The field `{schema_field.name}` can't be null")
126+
112127
types = [i.type_ for i in fields]
113-
clear_value = None
128+
clear_value = cast_failed
114129
errors: List[str] = []
115130

116131
pydantic_types, userspace_types = self._separate_types(types)
@@ -121,7 +136,7 @@ def create_filter(self, schema_field: ModelField, model_column, operator, value)
121136
else:
122137
clear_value, errors = self._cast_value_with_pydantic(pydantic_types, value)
123138

124-
if clear_value is None and userspace_types:
139+
if clear_value is cast_failed and userspace_types:
125140
log.warning("Filtering by user type values is not properly tested yet. Use this on your own risk.")
126141

127142
clear_value, errors = self._cast_value_with_scheme(types, value)
@@ -133,8 +148,9 @@ def create_filter(self, schema_field: ModelField, model_column, operator, value)
133148
)
134149

135150
# Если None, при этом поле обязательное (среди типов в аннотации нет None, то кидаем ошибку)
136-
if clear_value is None and not any(not i_f.required for i_f in fields):
151+
if clear_value is None and not can_be_none:
137152
raise InvalidType(detail=", ".join(errors))
153+
138154
return getattr(model_column, self.operator)(clear_value)
139155

140156
def _separate_types(self, types: List[Type]) -> Tuple[List[Type], List[Type]]:

tests/conftest.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@
4343
user_2_comment_for_one_u1_post,
4444
user_2_posts,
4545
user_3,
46-
user_4,
4746
workplace_1,
4847
workplace_2,
4948
)

tests/fixtures/entities.py

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -70,19 +70,6 @@ async def user_3(async_session: AsyncSession):
7070
await async_session.commit()
7171

7272

73-
@async_fixture()
74-
async def user_4(async_session: AsyncSession):
75-
user = build_user(
76-
email=None
77-
)
78-
async_session.add(user)
79-
await async_session.commit()
80-
await async_session.refresh(user)
81-
yield user
82-
await async_session.delete(user)
83-
await async_session.commit()
84-
85-
8673
async def build_user_bio(async_session: AsyncSession, user: User, **fields):
8774
bio = UserBio(user=user, **fields)
8875
async_session.add(bio)

tests/test_api/test_api_sqla_with_includes.py

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2025,33 +2025,88 @@ async def test_field_filters_with_values_from_different_models(
20252025
"meta": {"count": 0, "totalPages": 1},
20262026
}
20272027

2028-
@mark.parametrize("filter_dict, expected_email_is_null", [
2029-
param([{"name": "email", "op": "is_", "val": None}], True),
2030-
param([{"name": "email", "op": "isnot", "val": None}], False)
2031-
])
2028+
@mark.parametrize(
2029+
("filter_dict", "expected_email_is_null"),
2030+
[
2031+
param([{"name": "email", "op": "is_", "val": None}], True),
2032+
param([{"name": "email", "op": "isnot", "val": None}], False),
2033+
],
2034+
)
20322035
async def test_filter_by_null(
2033-
self,
2034-
app: FastAPI,
2035-
client: AsyncClient,
2036-
user_1: User,
2037-
user_4: User,
2038-
filter_dict,
2039-
expected_email_is_null
2036+
self,
2037+
app: FastAPI,
2038+
async_session: AsyncSession,
2039+
client: AsyncClient,
2040+
user_1: User,
2041+
user_2: User,
2042+
filter_dict: dict,
2043+
expected_email_is_null: bool,
20402044
):
2041-
assert user_1.email is not None
2042-
assert user_4.email is None
2045+
user_2.email = None
2046+
await async_session.commit()
2047+
2048+
target_user = user_2 if expected_email_is_null else user_1
20432049

20442050
url = app.url_path_for("get_user_list")
20452051
params = {"filter": dumps(filter_dict)}
20462052

20472053
response = await client.get(url, params=params)
2048-
assert response.status_code == 200, response.text
2054+
assert response.status_code == status.HTTP_200_OK, response.text
2055+
2056+
response_json = response.json()
2057+
2058+
assert len(data := response_json["data"]) == 1
2059+
assert data[0]["id"] == str(target_user.id)
2060+
assert data[0]["attributes"]["email"] == target_user.email
2061+
2062+
async def test_filter_by_null_error_when_null_is_not_possible_value(
2063+
self,
2064+
async_session: AsyncSession,
2065+
user_1: User,
2066+
):
2067+
resource_type = "user_with_nullable_email"
20492068

2050-
data = response.json()
2069+
class UserWithNotNullableEmailSchema(UserSchema):
2070+
email: str
20512071

2052-
assert len(data['data']) == 1
2053-
assert (data['data'][0]['attributes']['email'] is None) == expected_email_is_null
2072+
app = build_app_custom(
2073+
model=User,
2074+
schema=UserWithNotNullableEmailSchema,
2075+
schema_in_post=UserWithNotNullableEmailSchema,
2076+
schema_in_patch=UserWithNotNullableEmailSchema,
2077+
resource_type=resource_type,
2078+
)
2079+
user_1.email = None
2080+
await async_session.commit()
20542081

2082+
url = app.url_path_for(f"get_{resource_type}_list")
2083+
params = {
2084+
"filter": dumps(
2085+
[
2086+
{
2087+
"name": "email",
2088+
"op": "is_",
2089+
"val": None,
2090+
},
2091+
],
2092+
),
2093+
}
2094+
2095+
async with AsyncClient(app=app, base_url="http://test") as client:
2096+
response = await client.get(url, params=params)
2097+
assert response.status_code == status.HTTP_400_BAD_REQUEST, response.text
2098+
assert response.json() == {
2099+
"detail": {
2100+
"errors": [
2101+
{
2102+
"detail": "The field `email` can't be null",
2103+
"source": {"parameter": "filters"},
2104+
"status_code": status.HTTP_400_BAD_REQUEST,
2105+
"title": "Invalid filters querystring parameter.",
2106+
},
2107+
],
2108+
},
2109+
}
20552110

20562111
async def test_composite_filter_by_one_field(
20572112
self,

0 commit comments

Comments
 (0)