From 55e5c33d39ee30992bf22ac4803542024274404a Mon Sep 17 00:00:00 2001 From: Albert Sola Date: Tue, 5 Aug 2025 13:24:27 +0100 Subject: [PATCH] MPT-12329 Add RQL Query from SDK Extension - Add RQL Query - Refactored tests - Applied minor code/style fixes for Ruff, MyPy, and Flake8 --- mpt_api_client/rql/__init__.py | 3 + mpt_api_client/rql/constants.py | 7 + mpt_api_client/rql/query_builder.py | 480 ++++++++++++++++++ pyproject.toml | 1 + setup.cfg | 18 +- ...c_resource.py => test_generic_resource.py} | 0 tests/rql/query_builder/test_create_rql.py | 39 ++ tests/rql/query_builder/test_multiple_ops.py | 48 ++ tests/rql/query_builder/test_rql.py | 52 ++ tests/rql/query_builder/test_rql_and.py | 64 +++ tests/rql/query_builder/test_rql_dot_path.py | 97 ++++ tests/rql/query_builder/test_rql_eq.py | 45 ++ tests/rql/query_builder/test_rql_in.py | 16 + tests/rql/query_builder/test_rql_or.py | 81 +++ .../query_builder/test_rql_parse_kwargs.py | 41 ++ 15 files changed, 988 insertions(+), 4 deletions(-) create mode 100644 mpt_api_client/rql/__init__.py create mode 100644 mpt_api_client/rql/constants.py create mode 100644 mpt_api_client/rql/query_builder.py rename tests/http/models/{test_genric_resource.py => test_generic_resource.py} (100%) create mode 100644 tests/rql/query_builder/test_create_rql.py create mode 100644 tests/rql/query_builder/test_multiple_ops.py create mode 100644 tests/rql/query_builder/test_rql.py create mode 100644 tests/rql/query_builder/test_rql_and.py create mode 100644 tests/rql/query_builder/test_rql_dot_path.py create mode 100644 tests/rql/query_builder/test_rql_eq.py create mode 100644 tests/rql/query_builder/test_rql_in.py create mode 100644 tests/rql/query_builder/test_rql_or.py create mode 100644 tests/rql/query_builder/test_rql_parse_kwargs.py diff --git a/mpt_api_client/rql/__init__.py b/mpt_api_client/rql/__init__.py new file mode 100644 index 00000000..306117a2 --- /dev/null +++ b/mpt_api_client/rql/__init__.py @@ -0,0 +1,3 @@ +from mpt_api_client.rql.query_builder import RQLQuery + +__all__ = ["RQLQuery"] # noqa: WPS410 diff --git a/mpt_api_client/rql/constants.py b/mpt_api_client/rql/constants.py new file mode 100644 index 00000000..0ac70f7c --- /dev/null +++ b/mpt_api_client/rql/constants.py @@ -0,0 +1,7 @@ +COMP = ("eq", "ne", "lt", "le", "gt", "ge") +SEARCH = ("like", "ilike") +LIST = ("in", "out") +NULL = "null" +EMPTY = "empty" + +KEYWORDS = (*COMP, *SEARCH, *LIST, NULL, EMPTY) diff --git a/mpt_api_client/rql/query_builder.py b/mpt_api_client/rql/query_builder.py new file mode 100644 index 00000000..9f3d43be --- /dev/null +++ b/mpt_api_client/rql/query_builder.py @@ -0,0 +1,480 @@ +import datetime as dt +from decimal import Decimal +from typing import Any, Self, override + +from mpt_api_client.rql import constants + +Numeric = int | float | Decimal + +QueryValue = str | bool | dt.date | dt.datetime | Numeric + + +def parse_kwargs(query_dict: dict[str, QueryValue]) -> list[str]: # noqa: WPS210 WPS231 + """ + Parse keyword arguments into RQL query expressions. + + Converts a dictionary of field lookups and values into a list of RQL query + expressions. Supports field lookups with operators (e.g., 'field__eq', 'field__in') + and handles nested fields using dot notation. + + Args: + query_dict (dict): Dictionary where keys are field lookups (optionally with + operators separated by '__') and values are the comparison values. + + Returns: + list[str]: List of RQL query expression strings ready for use in queries. + + Examples: + parse_kwargs({'name': 'John', 'age__gt': 25}) + ['eq(name,John)', 'gt(age,25)'] + + parse_kwargs({'status__in': ['active', 'pending']}) + ['in(status,(active,pending))'] + """ + query = [] + for lookup, value in query_dict.items(): + tokens = lookup.split("__") + if len(tokens) == 1: + field = tokens[0] + str_value = rql_encode("eq", value) + query.append(f"eq({field},{str_value})") + continue + op = tokens[-1] + if op not in constants.KEYWORDS: + field = ".".join(tokens) + str_value = rql_encode("eq", value) + query.append(f"eq({field},{str_value})") + continue + field = ".".join(tokens[:-1]) + if op in constants.COMP or op in constants.SEARCH: + str_value = rql_encode(op, value) + query.append(f"{op}({field},{str_value})") + continue + if op in constants.LIST: + str_value = rql_encode(op, value) + query.append(f"{op}({field},({str_value}))") + continue + + cmpop = "eq" if value is True else "ne" + expr = "null()" if op == constants.NULL else "empty()" + query.append(f"{cmpop}({field},{expr})") + + return query + + +def query_value_str(value: QueryValue) -> str: + """Converts a value to string for use in RQL queries.""" + if isinstance(value, str): + return value + if isinstance(value, bool): + return "true" if value else "false" + + if isinstance(value, dt.date | dt.datetime): + return value.isoformat() + # Matching: if isinstance(value, int | float | Decimal): + return str(value) + + +def rql_encode(op: str, value: Any) -> str: + """ + Encode a value for use in RQL queries based on the operator type. + + Converts Python values to their RQL string representation. For non-list operators, + handles strings, booleans, numbers, dates, and datetimes. For list operators, + joins list/tuple values with commas. + + Args: + op (str): The RQL operator being used (e.g., 'eq', 'in', 'like'). + value: The value to encode. Can be str, bool, int, float, Decimal, + date, datetime, list, or tuple. + + Returns: + str: The RQL-encoded string representation of the value. + + Raises: + TypeError: If the operator doesn't support the given value type. + + Examples: + rql_encode('eq', 'hello') + 'hello' + + rql_encode('eq', True) + 'true' + + rql_encode('in', ['a', 'b', 'c']) + 'a,b,c' + """ + if op not in constants.LIST and isinstance(value, QueryValue): + return query_value_str(value) + if op in constants.LIST and isinstance(value, list | tuple): + return ",".join(value) + + raise TypeError(f"the `{op}` operator doesn't support the {type(value)} type.") + + +class RQLQuery: # noqa: WPS214 + """ + Helper class to construct complex RQL queries. + + Examples: + Creating a query + rql = RQLQuery(field='value', field2__in=('v1', 'v2'), field3__empty=True) + + Joining queries + rql = ( + RQLQuery().n('field').eq('value') + & RQLQuery().n('field2').anyof(('v1', 'v2')) + & RQLQuery().n('field3').empty(True) + ) + + Using attributes + rql = RQLQuery().field.eq('value') + & RQLQuery().field2.anyof(('v1', 'v2')) + & r.field3.empty(True) + + Comparation + rql = RQLQuery("field").eq("value") + + The R object support the bitwise operators `&`, `|` and `~`. + + Nested fields can be expressed using dot notation: + rql = RQLQuery().n('nested.field').eq('value') + rql = RQLQuery().nested.field.eq('value') + """ + + AND = "and" # noqa: WPS115 + OR = "or" # noqa: WPS115 + EXPRESSION = "expr" # noqa: WPS115 + + def __init__( # noqa: WPS211 + self, + _field: str | None = None, + *, + _op: str = EXPRESSION, + _children: list["RQLQuery"] | set["RQLQuery"] | None = None, + _negated: bool = False, + _expr: str | None = None, + **kwargs: QueryValue, + ) -> None: + self.op = _op + self.children: list[RQLQuery] = list(_children) if _children else [] + self.negated = _negated + self.expr = _expr + self._path: list[str] = [] + self._field: str | None = None + if _field: + self.n(_field) + if len(kwargs) == 1: + self.op = self.EXPRESSION + self.expr = parse_kwargs(kwargs)[0] + if len(kwargs) > 1: + self.op = self.AND + for token in parse_kwargs(kwargs): + self.children.append(self.__class__(_expr=token)) + + def __len__(self) -> int: + if self.op == self.EXPRESSION: + if self.expr: + return 1 + return 0 + return len(self.children) + + def __bool__(self) -> bool: + return bool(self.children) or bool(self.expr) + + @override + def __eq__(self, other: object) -> bool: + if not isinstance(other, type(self)): + return False + return ( + self.op == other.op + and self.children == other.children + and self.negated == other.negated + and self.expr == other.expr + ) + + @override + def __hash__(self) -> int: + return hash( + ( + self.op, + self.expr, + self.negated, + *(hash(value) for value in self.children), + ), + ) + + @override + def __repr__(self) -> str: + if self.op == self.EXPRESSION: + return f"" + return f"" + + def __and__(self, other: object) -> Self: + if not isinstance(other, type(self)): + return NotImplemented + return self._join(other, self.AND) + + def __or__(self, other: object) -> Self: + if not isinstance(other, type(self)): + return NotImplemented + return self._join(other, self.OR) + + def __invert__(self) -> Self: + inverted_query = self.__class__( + _op=self.AND, + _expr=self.expr, + _negated=True, + ) + inverted_query._append(self) # noqa: SLF001 + return inverted_query + + def __getattr__(self, name: str) -> Self: + return self.n(name) + + @override + def __str__(self) -> str: + return self._to_string(self) + + def n(self, name: str) -> Self: # noqa: WPS111 + """Set the current field for this `RQLQuery` object. + + Args: + name: Name of the field. + + Examples: + RQLQuery().n('field') + RQLQuery().n('field.nested.field') + """ + if self._field: + raise AttributeError("Already evaluated") + + self._path.extend(name.split(".")) + return self + + def ne(self, value: QueryValue) -> Self: + """Check if the value is NOT EQUAL to the field this `RQLQuery` object refers to. + + Args: + value: The value to which compare the field. + + Examples: + RQLQuery().field.ne(value) + """ + return self._bin("ne", value) + + def eq(self, value: QueryValue) -> Self: + """Check if the value is EQUAL to the field this `RQLQuery` object refers to. + + Args: + value: The value to which compare the field. + + Examples: + RQLQuery().field.eq(value) + """ + return self._bin("eq", value) + + def lt(self, value: QueryValue) -> Self: + """Check if the value is less than the field this `RQLQuery` object refers to. + + Args: + value: The value to which compare the field. + + Examples: + RQLQuery().field.lt(value) + """ + return self._bin("lt", value) + + def le(self, value: QueryValue) -> Self: + """Check if the value is less than or equal to the field this `RQLQuery` object refers to. + + Args: + value (str): The value to which compare the field. + + Examples: + RQLQuery().field.le(value) + """ + return self._bin("le", value) + + def gt(self, value: QueryValue) -> Self: + """Check if the value is greater than the field this `RQLQuery` object refers to. + + Args: + value: The value to which compare the field. + + Examples: + RQLQuery().field.gt(value) + """ + return self._bin("gt", value) + + def ge(self, value: QueryValue) -> Self: + """Check if the value is greater or equal than the field RQL refers to. + + Args: + value: The value to which compare the field. + + Examples: + RQLQuery().field.ge(value) + """ + return self._bin("ge", value) + + def out(self, value: list[QueryValue]) -> Self: + """Check if the `RQLQuery` objects refers it is NOT in the list of values. + + Args: + value: The list of values to which compare the field. + + Examples: + RQLQuery().field.out(['value1', 'value2']) + """ + return self._list("out", value) + + def in_(self, value: list[QueryValue]) -> Self: + """Check if the `RQLQuery` objects refers it is in the list of values. + + Args: + value: The list of values to which compare the field. + + Examples: + RQLQuery().field.in_(['value1', 'value2']) + """ + return self._list("in", value) + + def oneof(self, value: list[QueryValue]) -> Self: + """ + Apply the `in` operator to the field this `RQLQuery` object refers to. + + Args: + value: The list of values to which compare the field. + + Examples: + RQLQuery().field.oneof(['value1', 'value2']) + """ + return self._list("in", value) + + def null(self, value: bool) -> Self: # noqa: FBT001 + """Applies the `null` operator to the field this `RQLQuery` object refers to. + + Args: + value: True to check for null, False to check for not null. + + Examples: + To check if field is null: + RQLQuery().field.null() + + To check if field is not null: + RQLQuery().field.not_null() + """ + return self._bool("null", value) + + def empty(self, value: bool = True) -> Self: # noqa: FBT001 FBT002 + """Apply the `empty` operator to the field this `RQLQuery` object refers to. + + Args: + value: True to check for empty, False to check for not empty. + + Examples: + To check if field is empty: + RQLQuery().field.empty() + + For not empty: + RQLQuery().field.empty(False) or RQLQuery().field.not_empty() + """ + return self._bool("empty", value) + + def not_empty(self) -> Self: + """Apply the `not_empty` operator to the field this `RQLQuery` object refers to. + + Examples: + To check if the object `RQLQuery` refers to is like value: + RQLQuery().field.not_empty() + """ + return self._bool("empty", value=False) + + def like(self, value: QueryValue) -> Self: + """Apply the `like` operator to the field this `RQLQuery` object refers to. + + Args: + value: The value to which compare the field. + + Examples: + To check if the object `RQLQuery` refers to is like value: + RQLQuery().field.like(value) + """ + return self._bin("like", value) + + def ilike(self, value: QueryValue) -> Self: + """ + Apply the `ilike` operator to the field this `RQLQuery` object refers to. + + Args: + value: The value to which compare the field. + + Examples: + RQLQuery().field.ilike(value) + """ + return self._bin("ilike", value) + + def _bin(self, op: str, value: QueryValue) -> Self: + self._field = ".".join(self._path) + value = rql_encode(op, value) + self.expr = f"{op}({self._field},{value})" + return self + + def _list(self, op: str, value_list: list[QueryValue]) -> Self: + self._field = ".".join(self._path) + encoded_list = rql_encode(op, value_list) + self.expr = f"{op}({self._field},({encoded_list}))" + return self + + def _bool(self, expr: str, value: QueryValue) -> Self: + self._field = ".".join(self._path) + if bool(value) is False: + self.expr = f"ne({self._field},{expr}())" + return self + self.expr = f"eq({self._field},{expr}())" + return self + + def _to_string(self, query: "RQLQuery") -> str: + if query.expr: + if query.negated: + return f"not({query.expr})" + return query.expr + tokens = [self._to_string(query) for query in query.children] + if not tokens: + return "" + str_tokens = ",".join(tokens) + if query.negated: + return f"not({query.op}({str_tokens}))" + return f"{query.op}({str_tokens})" + + def _copy(self, other: "RQLQuery") -> Self: + return self.__class__( + _op=other.op, + _children=other.children.copy(), + _expr=other.expr, + ) + + def _join(self, other: "RQLQuery", op: str) -> Self: + if self == other: + return self._copy(self) + if not other: + return self._copy(self) + if not self: + return self._copy(other) + + query = self.__class__(_op=op) + query._append(self) # noqa: SLF001 + query._append(other) # noqa: SLF001 + return query + + def _append(self, query: "RQLQuery") -> "RQLQuery" | Self: + if query in self.children: + return query + single_operation = len(query) == 1 and query.op != self.EXPRESSION + if (query.op == self.op or single_operation) and not query.negated: + self.children.extend(query.children) + return self + + self.children.append(query) + return self diff --git a/pyproject.toml b/pyproject.toml index 94e09db7..4c2b6917 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -141,6 +141,7 @@ ignore = [ # Different doc rules that we don't really care about: "D100", "D104", + "D105", # docstring for magic methods "D106", "D107", "D203", diff --git a/setup.cfg b/setup.cfg index dad410b5..93343b2d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,7 +3,6 @@ # === Linter configuration === - # NOTE: You can use https://pypi.org/project/Flake8-pyproject/ # to move all your `flake8` configuration to `pyproject.toml` @@ -25,10 +24,21 @@ extend-exclude = # We only run `wemake-python-styleguide` with `flake8`: select = WPS, E999 +extend-ignore = + # Found string constant over-use + WPS226 + # Allow logic in WPS412 + WPS412 + + per-file-ignores = - tests/*: - # Allow string literal overuse - WPS226 + mpt_api_client/rql/query_builder.py: + # Forbid blacklisted variable names + WPS110 + # Found `noqa` comments overuse + WPS402 + tests/*: # Allow magic strings WPS432 + diff --git a/tests/http/models/test_genric_resource.py b/tests/http/models/test_generic_resource.py similarity index 100% rename from tests/http/models/test_genric_resource.py rename to tests/http/models/test_generic_resource.py diff --git a/tests/rql/query_builder/test_create_rql.py b/tests/rql/query_builder/test_create_rql.py new file mode 100644 index 00000000..feb55e52 --- /dev/null +++ b/tests/rql/query_builder/test_create_rql.py @@ -0,0 +1,39 @@ +from mpt_api_client.rql.query_builder import RQLQuery + + +def test_create(): + query = RQLQuery() + assert query.op == RQLQuery.EXPRESSION + assert query.children == [] + assert query.negated is False + + +def test_create_with_field(): + query = RQLQuery("field") + query.eq("value") + assert query.op == RQLQuery.EXPRESSION + assert str(query) == "eq(field,value)" + + +def test_create_single_kwarg(): + query = RQLQuery(id="ID") + assert query.op == RQLQuery.EXPRESSION + assert str(query) == "eq(id,ID)" + assert query.children == [] + assert query.negated is False + + +def test_create_multiple_kwargs(): # noqa: WPS218 + query = RQLQuery(id="ID", status__in=("a", "b"), ok=True) + assert query.op == RQLQuery.AND + assert str(query) == "and(eq(id,ID),in(status,(a,b)),eq(ok,true))" + assert len(query.children) == 3 + assert query.children[0].op == RQLQuery.EXPRESSION + assert query.children[0].children == [] + assert str(query.children[0]) == "eq(id,ID)" + assert query.children[1].op == RQLQuery.EXPRESSION + assert query.children[1].children == [] + assert str(query.children[1]) == "in(status,(a,b))" + assert query.children[2].op == RQLQuery.EXPRESSION + assert query.children[2].children == [] + assert str(query.children[2]) == "eq(ok,true)" diff --git a/tests/rql/query_builder/test_multiple_ops.py b/tests/rql/query_builder/test_multiple_ops.py new file mode 100644 index 00000000..3de85909 --- /dev/null +++ b/tests/rql/query_builder/test_multiple_ops.py @@ -0,0 +1,48 @@ +from mpt_api_client.rql import RQLQuery + + +def test_and_or(): # noqa: WPS218 WPS473 + r1 = RQLQuery(id="ID") + r2 = RQLQuery(field="value") + + r3 = RQLQuery(other="value2") + r4 = RQLQuery(inop__in=("a", "b")) + + r5 = r1 & r2 & (r3 | r4) + + assert r5.op == RQLQuery.AND + assert str(r5) == "and(eq(id,ID),eq(field,value),or(eq(other,value2),in(inop,(a,b))))" # noqa: WPS204 + + r5 = r1 & r2 | r3 + + assert str(r5) == "or(and(eq(id,ID),eq(field,value)),eq(other,value2))" + + r5 = r1 & (r2 | r3) + + assert str(r5) == "and(eq(id,ID),or(eq(field,value),eq(other,value2)))" + + r5 = (r1 & r2) | (r3 & r4) + + assert str(r5) == "or(and(eq(id,ID),eq(field,value)),and(eq(other,value2),in(inop,(a,b))))" + + r5 = (r1 & r2) | ~r3 + + assert str(r5) == "or(and(eq(id,ID),eq(field,value)),not(eq(other,value2)))" + + +def test_and_merge(): # noqa: WPS210 + r1 = RQLQuery(id="ID") + r2 = RQLQuery(name="name") + + r3 = RQLQuery(field="value") + r4 = RQLQuery(field__in=("v1", "v2")) + + and1 = r1 & r2 + + and2 = r3 & r4 + + and3 = and1 & and2 + + assert and3.op == RQLQuery.AND + assert len(and3.children) == 4 + assert [r1, r2, r3, r4] == and3.children diff --git a/tests/rql/query_builder/test_rql.py b/tests/rql/query_builder/test_rql.py new file mode 100644 index 00000000..5a97ab6f --- /dev/null +++ b/tests/rql/query_builder/test_rql.py @@ -0,0 +1,52 @@ +from mpt_api_client.rql import RQLQuery + + +def test_repr(): + products = ["PRD-1", "PRD-2"] + product_ids = ",".join(products) + expression_query = RQLQuery(product__id__in=products) + or_expression = RQLQuery(name="Albert") | RQLQuery(surname="Einstein") + + assert repr(expression_query) == f"" + assert repr(or_expression) == "" + + +def test_len(): + empty_query = RQLQuery() + simple_query = RQLQuery(id="ID") + complex_query = RQLQuery(id="ID", status__in=("a", "b")) + + assert len(empty_query) == 0 + assert len(simple_query) == 1 + assert len(complex_query) == 2 + + +def test_bool(): + assert bool(RQLQuery()) is False + assert bool(RQLQuery(id="ID")) is True + assert bool(RQLQuery(id="ID", status__in=("a", "b"))) is True + + +def test_str(): + assert str(RQLQuery(id="ID")) == "eq(id,ID)" + assert str(~RQLQuery(id="ID")) == "not(eq(id,ID))" + assert str(~RQLQuery(id="ID", field="value")) == "not(and(eq(id,ID),eq(field,value)))" + assert not str(RQLQuery()) + + +def test_hash(): + query_set = set() + + rql = RQLQuery(id="ID", field="value") + + query_set.add(rql) + query_set.add(rql) + + assert len(query_set) == 1 + + +def test_empty(): + assert RQLQuery("value").empty() == RQLQuery("value").empty() + assert str(RQLQuery("value1").empty()) == "eq(value1,empty())" + assert str(RQLQuery("value2").not_empty()) == "ne(value2,empty())" + assert RQLQuery("value3").empty(value=False) == RQLQuery("value3").not_empty() diff --git a/tests/rql/query_builder/test_rql_and.py b/tests/rql/query_builder/test_rql_and.py new file mode 100644 index 00000000..b6e7f30e --- /dev/null +++ b/tests/rql/query_builder/test_rql_and.py @@ -0,0 +1,64 @@ +from decimal import Decimal + +import pytest + +from mpt_api_client.rql import RQLQuery + + +def test_and_types(): + r1 = RQLQuery(id="ID") + r2 = Decimal("32983.328238273") + + with pytest.raises(TypeError): + r1 & r2 + + +def test_and_both_empty(): + r1 = RQLQuery() + r2 = RQLQuery() + + r3 = r1 & r2 + + assert r3 == r1 + assert r3 == r2 + + +def test_and_duplicates(): + r1 = RQLQuery(id="ID") + r2 = RQLQuery(id="ID") + + r3 = r1 & r2 + + assert r3 == r1 + assert r3 == r2 + + +def test_and_different(): + r1 = RQLQuery(id="ID") + r2 = RQLQuery(name="name") + + r3 = r1 & r2 + + assert r3 != r1 + assert r3 != r2 + assert r3.op == RQLQuery.AND + assert r1 in r3.children + assert r2 in r3.children + + +def test_and_with_empty(): + rql = RQLQuery(id="ID") + + assert rql & RQLQuery() == rql + assert RQLQuery() & rql == rql + + +def test_and_triple(): + r1 = RQLQuery(id="ID") + r2 = RQLQuery(field="value") + + r3 = r1 & r2 & r2 + + assert len(r3) == 2 + assert r3.op == RQLQuery.AND + assert [r1, r2] == r3.children diff --git a/tests/rql/query_builder/test_rql_dot_path.py b/tests/rql/query_builder/test_rql_dot_path.py new file mode 100644 index 00000000..89b4ff6c --- /dev/null +++ b/tests/rql/query_builder/test_rql_dot_path.py @@ -0,0 +1,97 @@ +import datetime as dt +from decimal import Decimal + +import pytest + +from mpt_api_client.rql import RQLQuery + + +@pytest.mark.parametrize("op", ["eq", "ne", "gt", "ge", "le", "lt"]) +def test_dotted_path_comp(op): + class Test: # noqa: WPS431 + pass # noqa: WPS604 WPS420 + + test = Test() + today = dt.datetime.now(dt.UTC).date() + now = dt.datetime.now(dt.UTC) + + today_expected_result = f"{op}(asset.id,{today.isoformat()})" + now_expected_result = f"{op}(asset.id,{now.isoformat()})" + + assert str(getattr(RQLQuery().asset.id, op)(today)) == today_expected_result + assert str(getattr(RQLQuery().asset.id, op)(now)) == now_expected_result + with pytest.raises(TypeError): + getattr(RQLQuery().asset.id, op)(test) + + +@pytest.mark.parametrize("op", ["eq", "ne", "gt", "ge", "le", "lt"]) +def test_dotted_path_comp_bool_and_str(op): + attribute_op_match = getattr(RQLQuery().asset.id, op) + assert str(attribute_op_match("value")) == f"{op}(asset.id,value)" + assert str(attribute_op_match(True)) == f"{op}(asset.id,true)" # noqa: FBT003 + assert str(attribute_op_match(False)) == f"{op}(asset.id,false)" # noqa: FBT003 + + +@pytest.mark.parametrize("op", ["eq", "ne", "gt", "ge", "le", "lt"]) +def test_dotted_path_comp_numerics(op): + decimal_object = Decimal("32983.328238273") + attribute_op_match = getattr(RQLQuery().asset.id, op) + + integer_result = str(attribute_op_match(10)) + result_float = str(attribute_op_match(10.678937)) + decimal_result = str(attribute_op_match(Decimal("32983.328238273"))) + + assert integer_result == f"{op}(asset.id,10)" + assert result_float == f"{op}(asset.id,10.678937)" + assert decimal_result == f"{op}(asset.id,{decimal_object!s})" + + +@pytest.mark.parametrize("op", ["like", "ilike"]) +def test_dotted_path_search(op): + attribute_op_match = getattr(RQLQuery().asset.id, op) + assert str(attribute_op_match("value")) == f"{op}(asset.id,value)" + assert str(attribute_op_match("*value")) == f"{op}(asset.id,*value)" + assert str(attribute_op_match("value*")) == f"{op}(asset.id,value*)" + assert str(attribute_op_match("*value*")) == f"{op}(asset.id,*value*)" + + +@pytest.mark.parametrize( + ("method", "op"), + [ + ("in_", "in"), + ("oneof", "in"), + ("out", "out"), + ], +) +def test_dotted_path_list(method, op): + rexpr_set = getattr(RQLQuery().asset.id, method)(("first", "second")) + rexpr_list = getattr(RQLQuery().asset.id, method)(["first", "second"]) + + assert str(rexpr_set) == f"{op}(asset.id,(first,second))" + assert str(rexpr_list) == f"{op}(asset.id,(first,second))" + with pytest.raises(TypeError): + getattr(RQLQuery().asset.id, method)("Test") + + +@pytest.mark.parametrize( + ("expr", "expression_param", "expected_op"), + [ + ("null", True, "eq"), + ("null", False, "ne"), + ("empty", True, "eq"), + ("empty", False, "ne"), + ], +) +def test_dotted_path_bool(expr, expression_param, expected_op): + expected_result = f"{expected_op}(asset.id,{expr}())" + attribute = getattr(RQLQuery().asset.id, expr) + result_dotted_path = str(attribute(expression_param)) + + assert result_dotted_path == expected_result + + +def test_dotted_path_already_evaluated(): + query = RQLQuery().first.second.eq("value") + + with pytest.raises(AttributeError): + query.third # noqa: B018 diff --git a/tests/rql/query_builder/test_rql_eq.py b/tests/rql/query_builder/test_rql_eq.py new file mode 100644 index 00000000..42bea867 --- /dev/null +++ b/tests/rql/query_builder/test_rql_eq.py @@ -0,0 +1,45 @@ +from decimal import Decimal + +from mpt_api_client.rql import RQLQuery + + +def test_eq_object(): + r1 = RQLQuery(id="ID") + r2 = Decimal("32983.328238273") + + assert r1 != r2 + + +def test_eq_empty(): + r1 = RQLQuery() + r2 = RQLQuery() + + assert r1 == r2 + + +def test_eq_id(): + r1 = RQLQuery(id="ID") + r2 = RQLQuery(id="ID") + + assert r1 == r2 + + +def test_eq_id_negated(): + r1 = ~RQLQuery(id="ID") + r2 = ~RQLQuery(id="ID") + + assert r1 == r2 + + +def test_eq_status_in(): + r1 = RQLQuery(id="ID", status__in=("a", "b")) + r2 = RQLQuery(id="ID", status__in=("a", "b")) + + assert r1 == r2 + + +def test_not_eq_status_in(): + r1 = RQLQuery() + r2 = RQLQuery(id="ID", status__in=("a", "b")) + + assert r1 != r2 diff --git a/tests/rql/query_builder/test_rql_in.py b/tests/rql/query_builder/test_rql_in.py new file mode 100644 index 00000000..438e993a --- /dev/null +++ b/tests/rql/query_builder/test_rql_in.py @@ -0,0 +1,16 @@ +from mpt_api_client.rql import RQLQuery + + +def test_in_and_namespaces(): + q1 = RQLQuery().n("agreement").n("product").n("id").in_(["PRD-1", "PRD-2"]) # noqa: WPS221 + q2 = RQLQuery().agreement.product.id.in_(["PRD-1", "PRD-2"]) + + assert str(q1) == str(q2) + + +def test_in(): + products = ["PRD-1", "PRD-2"] + product_ids = ",".join(products) + query = RQLQuery(product__id__in=products) + + assert str(query) == f"in(product.id,({product_ids}))" diff --git a/tests/rql/query_builder/test_rql_or.py b/tests/rql/query_builder/test_rql_or.py new file mode 100644 index 00000000..a1460454 --- /dev/null +++ b/tests/rql/query_builder/test_rql_or.py @@ -0,0 +1,81 @@ +from decimal import Decimal + +import pytest + +from mpt_api_client.rql import RQLQuery + + +def test_or_empty(): + r1 = RQLQuery() + r2 = RQLQuery() + + r3 = r1 | r2 + + assert r3 == r1 + assert r3 == r2 + + +def test_or_types(): + r1 = RQLQuery(id="ID") + r2 = Decimal("32983.328238273") + + with pytest.raises(TypeError): + r1 | r2 + + +def test_or_equals(): + r1 = RQLQuery(id="ID") + r2 = RQLQuery(id="ID") + + r3 = r1 | r2 + + assert r3 == r1 + assert r3 == r2 + + +def test_or_not_equals(): + r1 = RQLQuery(id="ID") + r2 = RQLQuery(name="name") + + r3 = r1 | r2 + + assert r3 != r1 + assert r3 != r2 + + assert r3.op == RQLQuery.OR + assert r1 in r3.children + assert r2 in r3.children + + +def test_or_with_empty(): + rql = RQLQuery(id="ID") + + assert rql | RQLQuery() == rql + assert RQLQuery() | rql == rql + + +def test_or_merge(): # noqa: WPS210 + r1 = RQLQuery(id="ID") + r2 = RQLQuery(name="name") + + r3 = RQLQuery(field="value") + r4 = RQLQuery(field__in=("v1", "v2")) + + or1 = r1 | r2 + or2 = r3 | r4 + or3 = or1 | or2 + + assert or3.op == RQLQuery.OR + assert len(or3.children) == 4 + assert [r1, r2, r3, r4] == or3.children + + +def test_or_merge_duplicates(): + r1 = RQLQuery(id="ID") + r2 = RQLQuery(field="value") + + r3 = r1 | r2 | r2 + + assert len(r3) == 2 + assert r3.op == RQLQuery.OR + assert [r1, r2] == r3.children diff --git a/tests/rql/query_builder/test_rql_parse_kwargs.py b/tests/rql/query_builder/test_rql_parse_kwargs.py new file mode 100644 index 00000000..08e5aeb4 --- /dev/null +++ b/tests/rql/query_builder/test_rql_parse_kwargs.py @@ -0,0 +1,41 @@ +import pytest + +from mpt_api_client.rql.query_builder import parse_kwargs + + +@pytest.fixture +def mock_product_ids_for_expression(): + return ["PRD-1", "PRD-2"] + + +@pytest.fixture +def mock_product_id_for_expression(): + return "PRD-1" + + +def test_improper_op(mock_product_id_for_expression): + products_expr = {"product__id__inn": mock_product_id_for_expression} + query = parse_kwargs(products_expr) + + assert str(query) == f"['eq(product.id.inn,{mock_product_id_for_expression})']" + + +def test_parse_eq(mock_product_id_for_expression): + products_expr = {"product__id__eq": mock_product_id_for_expression} + query = parse_kwargs(products_expr) + + assert str(query) == f"['eq(product.id,{mock_product_id_for_expression})']" + + +def test_parse_like(mock_product_id_for_expression): + products_expr = {"product__id__like": mock_product_id_for_expression} + query = parse_kwargs(products_expr) + + assert str(query) == f"['like(product.id,{mock_product_id_for_expression})']" + + +def test_parse_null_op(mock_product_id_for_expression): + products_expr = {"product__id__null": mock_product_id_for_expression} + query = parse_kwargs(products_expr) + + assert str(query) == "['ne(product.id,null())']"