Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/filtering.rst
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,22 @@ You can also use boolean combination of operations:
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
Accept: application/vnd.api+json


Filtering records by a field that is null

.. sourcecode:: http

GET /user?filter=[{"name":"name","op":"is_","val":null}] HTTP/1.1
Accept: application/vnd.api+json

Filtering records by a field that is not null

.. sourcecode:: http

GET /user?filter=[{"name":"name","op":"isnot","val":null}] HTTP/1.1
Accept: application/vnd.api+json


Common available operators:

* any: used to filter on "to many" relationships
Expand Down
18 changes: 17 additions & 1 deletion fastapi_jsonapi/data_layers/filtering/sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ def __init__(self, model: Type[TypeModel], filter_: dict, schema: Type[TypeSchem
self.filter_ = filter_
self.schema = schema

def _check_can_be_none(self, fields: list[ModelField]) -> bool:
"""
Return True if None is possible value for target field
"""
return any(field_item.allow_none for field_item in fields)

def _cast_value_with_scheme(self, field_types: List[ModelField], value: Any) -> Tuple[Any, List[str]]:
errors: List[str] = []
casted_value = cast_failed
Expand Down Expand Up @@ -109,6 +115,15 @@ def create_filter(self, schema_field: ModelField, model_column, operator, value)
fields = list(schema_field.sub_fields)
else:
fields = [schema_field]

can_be_none = self._check_can_be_none(fields)

if value is None:
if can_be_none:
return getattr(model_column, self.operator)(value)

raise InvalidFilters(detail=f"The field `{schema_field.name}` can't be null")

types = [i.type_ for i in fields]
clear_value = None
errors: List[str] = []
Expand All @@ -133,8 +148,9 @@ def create_filter(self, schema_field: ModelField, model_column, operator, value)
)

# Если None, при этом поле обязательное (среди типов в аннотации нет None, то кидаем ошибку)
if clear_value is None and not any(not i_f.required for i_f in fields):
if clear_value is None and not can_be_none:
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]]:
Expand Down
83 changes: 83 additions & 0 deletions tests/test_api/test_api_sqla_with_includes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2025,6 +2025,89 @@ async def test_field_filters_with_values_from_different_models(
"meta": {"count": 0, "totalPages": 1},
}

@mark.parametrize(
("filter_dict", "expected_email_is_null"),
[
param([{"name": "email", "op": "is_", "val": None}], True),
param([{"name": "email", "op": "isnot", "val": None}], False),
],
)
async def test_filter_by_null(
self,
app: FastAPI,
async_session: AsyncSession,
client: AsyncClient,
user_1: User,
user_2: User,
filter_dict: dict,
expected_email_is_null: bool,
):
user_2.email = None
await async_session.commit()

target_user = user_2 if expected_email_is_null else user_1

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

response = await client.get(url, params=params)
assert response.status_code == status.HTTP_200_OK, response.text

response_json = response.json()

assert len(data := response_json["data"]) == 1
assert data[0]["id"] == str(target_user.id)
assert data[0]["attributes"]["email"] == target_user.email

async def test_filter_by_null_error_when_null_is_not_possible_value(
self,
async_session: AsyncSession,
user_1: User,
):
resource_type = "user_with_nullable_email"

class UserWithNotNullableEmailSchema(UserSchema):
email: str

app = build_app_custom(
model=User,
schema=UserWithNotNullableEmailSchema,
schema_in_post=UserWithNotNullableEmailSchema,
schema_in_patch=UserWithNotNullableEmailSchema,
resource_type=resource_type,
)
user_1.email = None
await async_session.commit()

url = app.url_path_for(f"get_{resource_type}_list")
params = {
"filter": dumps(
[
{
"name": "email",
"op": "is_",
"val": None,
},
],
),
}

async with AsyncClient(app=app, base_url="http://test") as client:
response = await client.get(url, params=params)
assert response.status_code == status.HTTP_400_BAD_REQUEST, response.text
assert response.json() == {
"detail": {
"errors": [
{
"detail": "The field `email` can't be null",
"source": {"parameter": "filters"},
"status_code": status.HTTP_400_BAD_REQUEST,
"title": "Invalid filters querystring parameter.",
},
],
},
}

async def test_composite_filter_by_one_field(
self,
app: FastAPI,
Expand Down