From 04da2412941f9c3ed9c97d604428f6a996a8f55f Mon Sep 17 00:00:00 2001 From: d3vyce <44915747+d3vyce@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:58:07 +0200 Subject: [PATCH] fix: coerce string values to bool for Boolean facet field filtering (#219) --- src/fastapi_toolsets/crud/search.py | 25 +++++++++++++----- tests/test_crud_search.py | 41 ++++++++++++++++++++++++----- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/fastapi_toolsets/crud/search.py b/src/fastapi_toolsets/crud/search.py index 3a2bef5..7f9b665 100644 --- a/src/fastapi_toolsets/crud/search.py +++ b/src/fastapi_toolsets/crud/search.py @@ -278,6 +278,18 @@ _EQUALITY_TYPES = (String, Integer, Numeric, Date, DateTime, Time, Enum, Uuid) """Column types that support equality / IN filtering in build_filter_by.""" +def _coerce_bool(value: Any) -> bool: + """Coerce a string value to a Python bool for Boolean column filtering.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + if value.lower() == "true": + return True + if value.lower() == "false": + return False + raise ValueError(f"Cannot coerce {value!r} to bool") + + def build_filter_by( filter_by: dict[str, Any], facet_fields: Sequence[FacetFieldType], @@ -324,16 +336,17 @@ def build_filter_by( added_join_keys.add(rel_key) col_type = column.property.columns[0].type - if isinstance(col_type, ARRAY): + if isinstance(col_type, Boolean): + coerce = _coerce_bool + if isinstance(value, list): + filters.append(column.in_([coerce(v) for v in value])) + else: + filters.append(column == coerce(value)) + elif isinstance(col_type, ARRAY): if isinstance(value, list): filters.append(column.overlap(value)) else: filters.append(column.any(value)) - elif isinstance(col_type, Boolean): - if isinstance(value, list): - filters.append(column.in_(value)) - else: - filters.append(column.is_(value)) elif isinstance(col_type, _EQUALITY_TYPES): if isinstance(value, list): filters.append(column.in_(value)) diff --git a/tests/test_crud_search.py b/tests/test_crud_search.py index 3774c69..d0413c5 100644 --- a/tests/test_crud_search.py +++ b/tests/test_crud_search.py @@ -971,7 +971,7 @@ class TestFilterBy: @pytest.mark.anyio async def test_bool_filter_false(self, db_session: AsyncSession): - """filter_by with a boolean False value correctly filters rows.""" + """filter_by with a string 'false' value correctly filters rows.""" UserBoolCrud = CrudFactory(User, facet_fields=[User.is_active]) await UserCrud.create( db_session, UserCreate(username="alice", email="a@test.com", is_active=True) @@ -982,7 +982,7 @@ class TestFilterBy: ) result = await UserBoolCrud.offset_paginate( - db_session, filter_by={"is_active": False}, schema=UserRead + db_session, filter_by={"is_active": "false"}, schema=UserRead ) assert isinstance(result.pagination, OffsetPagination) @@ -991,7 +991,7 @@ class TestFilterBy: @pytest.mark.anyio async def test_bool_filter_true(self, db_session: AsyncSession): - """filter_by with a boolean True value correctly filters rows.""" + """filter_by with a string 'true' value correctly filters rows.""" UserBoolCrud = CrudFactory(User, facet_fields=[User.is_active]) await UserCrud.create( db_session, UserCreate(username="alice", email="a@test.com", is_active=True) @@ -1002,7 +1002,7 @@ class TestFilterBy: ) result = await UserBoolCrud.offset_paginate( - db_session, filter_by={"is_active": True}, schema=UserRead + db_session, filter_by={"is_active": "true"}, schema=UserRead ) assert isinstance(result.pagination, OffsetPagination) @@ -1011,7 +1011,7 @@ class TestFilterBy: @pytest.mark.anyio async def test_bool_filter_list(self, db_session: AsyncSession): - """filter_by with a list of booleans produces an IN clause.""" + """filter_by with a list of string booleans produces an IN clause.""" UserBoolCrud = CrudFactory(User, facet_fields=[User.is_active]) await UserCrud.create( db_session, UserCreate(username="alice", email="a@test.com", is_active=True) @@ -1022,12 +1022,41 @@ class TestFilterBy: ) result = await UserBoolCrud.offset_paginate( - db_session, filter_by={"is_active": [True, False]}, schema=UserRead + db_session, filter_by={"is_active": ["true", "false"]}, schema=UserRead ) assert isinstance(result.pagination, OffsetPagination) assert result.pagination.total_count == 2 + @pytest.mark.anyio + async def test_bool_filter_native_bool(self, db_session: AsyncSession): + """filter_by with a native Python bool passes through coercion.""" + UserBoolCrud = CrudFactory(User, facet_fields=[User.is_active]) + await UserCrud.create( + db_session, UserCreate(username="alice", email="a@test.com", is_active=True) + ) + + result = await UserBoolCrud.offset_paginate( + db_session, filter_by={"is_active": True}, schema=UserRead + ) + + assert isinstance(result.pagination, OffsetPagination) + assert result.pagination.total_count == 1 + + def test_bool_coerce_invalid_value(self): + """_coerce_bool raises ValueError for non-bool, non-string values.""" + from fastapi_toolsets.crud.search import _coerce_bool + + with pytest.raises(ValueError, match="Cannot coerce"): + _coerce_bool(42) + + def test_bool_coerce_invalid_string(self): + """_coerce_bool raises ValueError for unrecognized string values.""" + from fastapi_toolsets.crud.search import _coerce_bool + + with pytest.raises(ValueError, match="Cannot coerce"): + _coerce_bool("maybe") + @pytest.mark.anyio async def test_array_contains_single_value(self, db_session: AsyncSession): """filter_by on an ARRAY column with a scalar checks containment."""