From 9235e696e3a9371dd0798ed8c7c17791a3e2b4f0 Mon Sep 17 00:00:00 2001 From: Wilson Freitas Date: Sun, 14 Jun 2026 20:58:46 -0300 Subject: [PATCH] Add OR support for OData filters --- bcb/odata/api.py | 5 ++-- bcb/odata/framework.py | 54 +++++++++++++++++++++++++++++++++++------- docs/odata.rst | 16 +++++++++++++ tests/test_odata.py | 46 +++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 11 deletions(-) diff --git a/bcb/odata/api.py b/bcb/odata/api.py index e23b5d5..7aa0441 100644 --- a/bcb/odata/api.py +++ b/bcb/odata/api.py @@ -5,6 +5,7 @@ from bcb.http import RequestTimeout from bcb.odata.framework import ( ODataEntitySet, + ODataFilterExpression, ODataFunctionImport, ODataQuery, ODataPropertyFilter, @@ -167,7 +168,7 @@ def __init__( def get( self, *args: Any, - filter: Optional[ODataPropertyFilter] = None, + filter: Optional[ODataFilterExpression] = None, orderby: Optional[ODataPropertyOrderBy] = None, select: Optional[ODataProperty] = None, limit: Optional[int] = None, @@ -273,7 +274,7 @@ def async_query(self) -> EndpointQuery: async def async_get( self, *args: Any, - filter: Optional[ODataPropertyFilter] = None, + filter: Optional[ODataFilterExpression] = None, orderby: Optional[ODataPropertyOrderBy] = None, select: Optional[ODataProperty] = None, limit: Optional[int] = None, diff --git a/bcb/odata/framework.py b/bcb/odata/framework.py index 2a22929..069b667 100644 --- a/bcb/odata/framework.py +++ b/bcb/odata/framework.py @@ -254,7 +254,49 @@ def __repr__(self) -> str: return f"<{str(self)}>" -class ODataPropertyFilter: +class ODataFilterExpression: + def statement(self) -> str: + raise NotImplementedError + + def __and__(self, other: object) -> Any: + if not isinstance(other, ODataFilterExpression): + return NotImplemented + return ODataCombinedFilter(self, other, "and") + + def __or__(self, other: object) -> Any: + if not isinstance(other, ODataFilterExpression): + return NotImplemented + return ODataCombinedFilter(self, other, "or") + + def __bool__(self) -> bool: + raise TypeError( + "OData filters cannot be evaluated as booleans. " + "Use & for AND and | for OR, with parentheses around comparisons." + ) + + def __str__(self) -> str: + return self.statement() + + def __repr__(self) -> str: + return f"" + + +class ODataCombinedFilter(ODataFilterExpression): + def __init__( + self, + left: ODataFilterExpression, + right: ODataFilterExpression, + operator: str, + ) -> None: + self.left = left + self.right = right + self.operator = operator + + def statement(self) -> str: + return f"({self.left.statement()} {self.operator} {self.right.statement()})" + + +class ODataPropertyFilter(ODataFilterExpression): def __init__(self, obj: "ODataProperty", oth: Any, operator: str) -> None: self.obj = obj self.other = oth @@ -264,12 +306,6 @@ def statement(self) -> str: literal = _format_odata_literal(self.obj.type, self.other) return f"{self.obj.name} {self.operator} {literal}" - def __str__(self) -> str: - return self.statement() - - def __repr__(self) -> str: - return f"" - class ODataProperty: def __init__(self, **kwargs: Any) -> None: @@ -576,7 +612,7 @@ def __init__( self._timeout = timeout self._params: dict[str, Any] = {} self.function_parameters: dict[str, Any] = {} - self._filter: list[ODataPropertyFilter] = [] + self._filter: list[ODataFilterExpression] = [] self._select: list[ODataProperty] = [] self._orderby: list[ODataPropertyOrderBy] = [] self._raw = False @@ -608,7 +644,7 @@ def parameters(self, **kwargs: Any) -> Self: raise ODataError(f"Unknown parameter: {arg}") return self - def filter(self, *args: ODataPropertyFilter) -> Self: + def filter(self, *args: ODataFilterExpression) -> Self: if len(args): self._filter.extend(args) return self diff --git a/docs/odata.rst b/docs/odata.rst index ffde87c..b7a6b03 100644 --- a/docs/odata.rst +++ b/docs/odata.rst @@ -220,6 +220,22 @@ Mais filtros podem ser adicionados ao método ``filter``, e também podemos anin Todos os filtros estão no atributo ``$filter`` da consulta e são concatenados com o operador booleano ``and``. +Para combinar condições com ``or``, use o operador ``|`` entre filtros. Use +parênteses em cada comparação, pois ``|`` tem precedência diferente dos +operadores de comparação do Python. + +.. ipython:: python + + query = (ep.query() + .filter((ep.Indicador == 'IPCA') | (ep.Indicador == 'IGP-M')) + .filter(ep.DataReferencia == 2023) + .limit(5)) + query.show() + +Também é possível combinar filtros explicitamente com ``&``. O operador nativo +``or`` do Python não deve ser usado, porque ele avalia objetos em contexto +booleano em vez de construir uma expressão OData. + É necessário conhecer o tipo da propriedade para saber como passar o objeto para a consulta. Os tipos de propriedade podem ser: str, float, int e datetime. Para propriedades OData ``Edm.Date``, passe um objeto ``datetime.date`` ou ``datetime.datetime`` para o método ``filter``; strings de data não são convertidas automaticamente pelo construtor de filtros. diff --git a/tests/test_odata.py b/tests/test_odata.py index a4fd7e8..05ad61d 100644 --- a/tests/test_odata.py +++ b/tests/test_odata.py @@ -398,6 +398,29 @@ def test_boolean_property_filter_formats_booleans(): assert str(ativo == True) == "Ativo eq true" # noqa: E712 +def test_property_filters_support_explicit_and_or_composition(): + indicador = ODataProperty(Name="Indicador", Type="Edm.String") + mediana = ODataProperty(Name="Mediana", Type="Edm.Decimal") + + or_filter = (indicador == "IPCA") | (indicador == "IGP-M") + and_filter = (indicador == "IPCA") & (mediana > 4) + nested_filter = or_filter & (mediana > 4) + + assert str(or_filter) == "(Indicador eq 'IPCA' or Indicador eq 'IGP-M')" + assert str(and_filter) == "(Indicador eq 'IPCA' and Mediana gt 4.0)" + assert ( + str(nested_filter) + == "((Indicador eq 'IPCA' or Indicador eq 'IGP-M') and Mediana gt 4.0)" + ) + + +def test_property_filters_reject_python_boolean_context(): + indicador = ODataProperty(Name="Indicador", Type="Edm.String") + + with pytest.raises(TypeError, match="Use & for AND and \\| for OR"): + bool(indicador == "IPCA") + + @pytest.mark.parametrize( ("prop", "value", "message"), [ @@ -521,6 +544,29 @@ def test_query_serializes_filter_orderby_select_and_pagination(httpx_mock): assert request.url.params["$skip"] == "10" +def test_query_serializes_or_filter_expression(httpx_mock): + add_service_mocks(httpx_mock) + httpx_mock.add_response( + url=ENTITY_URL_PATTERN, + text=ODATA_QUERY_RESPONSE_JSON, + status_code=200, + ) + api = Expectativas() + entity = api.service["ExpectativasMercadoAnuais"] + + query = api.service.query(entity).filter( + (entity.Indicador == "IPCA") | (entity.Indicador == "IGP-M"), + entity.Mediana > 4, + ) + query.collect() + + request = httpx_mock.get_requests()[-1] + assert ( + request.url.params["$filter"] + == "(Indicador eq 'IPCA' or Indicador eq 'IGP-M') and Mediana gt 4.0" + ) + + def test_query_reset_clears_filters_ordering_and_pagination(httpx_mock): add_service_mocks(httpx_mock) api = Expectativas()