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
5 changes: 3 additions & 2 deletions bcb/odata/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from bcb.http import RequestTimeout
from bcb.odata.framework import (
ODataEntitySet,
ODataFilterExpression,
ODataFunctionImport,
ODataQuery,
ODataPropertyFilter,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
54 changes: 45 additions & 9 deletions bcb/odata/framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<filter: {str(self)}>"


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
Expand All @@ -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"<filter: {str(self)}>"


class ODataProperty:
def __init__(self, **kwargs: Any) -> None:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions docs/odata.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 46 additions & 0 deletions tests/test_odata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
[
Expand Down Expand Up @@ -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()
Expand Down
Loading