Skip to content

Commit 4766e1b

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 4766e1b

File tree

5 files changed

+108
-34
lines changed

5 files changed

+108
-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: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
REGISTERED_PYDANTIC_TYPES: Dict[Type, List[Callable]] = dict(_VALIDATORS)
4141

4242
cast_failed = object()
43+
default = object()
4344

4445

4546
def create_filters(model: Type[TypeModel], filter_info: Union[list, dict], schema: Type[TypeSchema]):
@@ -67,6 +68,12 @@ def __init__(self, model: Type[TypeModel], filter_: dict, schema: Type[TypeSchem
6768
self.filter_ = filter_
6869
self.schema = schema
6970

71+
def _check_can_be_none(self, fields: list[ModelField]) -> bool:
72+
"""
73+
Return True if None is possible value for target field
74+
"""
75+
return any(field_item.allow_none for field_item in fields)
76+
7077
def _cast_value_with_scheme(self, field_types: List[ModelField], value: Any) -> Tuple[Any, List[str]]:
7178
errors: List[str] = []
7279
casted_value = cast_failed
@@ -109,8 +116,17 @@ def create_filter(self, schema_field: ModelField, model_column, operator, value)
109116
fields = list(schema_field.sub_fields)
110117
else:
111118
fields = [schema_field]
119+
120+
can_be_none = self._check_can_be_none(fields)
121+
122+
if value is None:
123+
if can_be_none:
124+
return getattr(model_column, self.operator)(value)
125+
126+
raise InvalidFilters(detail=f"The field `{schema_field.name}` can't be null")
127+
112128
types = [i.type_ for i in fields]
113-
clear_value = None
129+
clear_value = default
114130
errors: List[str] = []
115131

116132
pydantic_types, userspace_types = self._separate_types(types)
@@ -121,7 +137,7 @@ def create_filter(self, schema_field: ModelField, model_column, operator, value)
121137
else:
122138
clear_value, errors = self._cast_value_with_pydantic(pydantic_types, value)
123139

124-
if clear_value is None and userspace_types:
140+
if clear_value is default and userspace_types:
125141
log.warning("Filtering by user type values is not properly tested yet. Use this on your own risk.")
126142

127143
clear_value, errors = self._cast_value_with_scheme(types, value)
@@ -133,8 +149,9 @@ def create_filter(self, schema_field: ModelField, model_column, operator, value)
133149
)
134150

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

140157
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)