diff --git a/mpt_api_client/rql/__init__.py b/mpt_api_client/rql/__init__.py index 306117a2..d1a0414c 100644 --- a/mpt_api_client/rql/__init__.py +++ b/mpt_api_client/rql/__init__.py @@ -1,3 +1,3 @@ -from mpt_api_client.rql.query_builder import RQLQuery +from mpt_api_client.rql.query_builder import Property, RQLQuery, Value -__all__ = ["RQLQuery"] # noqa: WPS410 +__all__ = ["Property", "RQLQuery", "Value"] # noqa: WPS410 diff --git a/mpt_api_client/rql/query_builder.py b/mpt_api_client/rql/query_builder.py index c1b05212..02135c97 100644 --- a/mpt_api_client/rql/query_builder.py +++ b/mpt_api_client/rql/query_builder.py @@ -9,6 +9,38 @@ QueryValue = str | bool | dt.date | dt.datetime | Numeric +class Property: + """Wrapper for model properties in RQL queries.""" + + def __init__(self, value: str) -> None: + self.value = value + + @override + def __str__(self) -> str: + return self.value + + +class Value: + """Wrapper for literal values in RQL queries.""" + + def __init__(self, value: QueryValue) -> None: + self.value = value + + @override + def __str__(self) -> str: + if isinstance(self.value, str): + return f"'{self.value}'" + if isinstance(self.value, bool): + return "true" if self.value else "false" + + if isinstance(self.value, dt.date | dt.datetime): + str_time = self.value.isoformat() + return f"'{str_time}'" + + # Matching: if isinstance(value, int | float | Decimal): + return str(self.value) + + def parse_kwargs(query_dict: dict[str, QueryValue]) -> list[str]: # noqa: WPS231 """ Parse keyword arguments into RQL query expressions. @@ -62,16 +94,14 @@ def parse_kwargs(query_dict: dict[str, QueryValue]) -> list[str]: # noqa: WPS23 return query -def query_value_str(value: QueryValue) -> str: +def query_value_str(value: Any) -> 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): + if isinstance(value, QueryValue): + value = Value(value) + if isinstance(value, Value): + return str(value) + if isinstance(value, Property): + return str(value) return str(value) @@ -104,10 +134,10 @@ def rql_encode(op: str, value: Any) -> str: rql_encode('in', ['a', 'b', 'c']) 'a,b,c' """ - if op not in constants.LIST and isinstance(value, QueryValue): + if op not in constants.LIST and isinstance(value, QueryValue | Value | Property): return query_value_str(value) if op in constants.LIST and isinstance(value, list | tuple | set): - return ",".join(str(el) for el in value) + return ",".join(query_value_str(el) for el in value) raise TypeError(f"the `{op}` operator doesn't support the {type(value)} type.") diff --git a/tests/unit/http/mixins/test_collection_mixin.py b/tests/unit/http/mixins/test_collection_mixin.py index bafb5acc..646b6fab 100644 --- a/tests/unit/http/mixins/test_collection_mixin.py +++ b/tests/unit/http/mixins/test_collection_mixin.py @@ -73,7 +73,7 @@ def test_col_mx_fetch_one_with_filters( assert first_request.url == ( "https://api.example.com/api/v1/test" "?limit=1&offset=0&order=created" - "&select=id,name&eq(status,active)" + "&select=id,name&eq(status,'active')" ) @@ -91,7 +91,7 @@ def test_col_mx_fetch_page_with_filter( "https://api.example.com/api/v1/test?limit=10&offset=5" "&order=-created,name" "&select=-audit,product.agreements,-product.agreements.product" - "&eq(status,active)" + "&eq(status,'active')" ) with respx.mock: mock_route = respx.get("https://api.example.com/api/v1/test").mock( @@ -213,7 +213,7 @@ def test_col_mx_iterate_with_filters( request = mock_route.calls[0].request assert ( str(request.url) == "https://api.example.com/api/v1/test" - "?limit=100&offset=0&order=created&select=id,name&eq(status,active)" + "?limit=100&offset=0&order=created&select=id,name&eq(status,'active')" ) @@ -322,7 +322,7 @@ async def test_async_col_mx_fetch_one_with_filters( assert first_request.url == ( "https://api.example.com/api/v1/test" "?limit=1&offset=0&order=created" - "&select=id,name&eq(status,active)" + "&select=id,name&eq(status,'active')" ) @@ -342,7 +342,7 @@ async def test_async_col_mx_fetch_page_with_filter( "https://api.example.com/api/v1/test?limit=10&offset=5" "&order=-created,name" "&select=-audit,product.agreements,-product.agreements.product" - "&eq(status,active)" + "&eq(status,'active')" ) with respx.mock: mock_route = respx.get("https://api.example.com/api/v1/test").mock( @@ -464,7 +464,7 @@ async def test_async_col_mx_iterate_with_filters( request = mock_route.calls[0].request assert ( str(request.url) == "https://api.example.com/api/v1/test" - "?limit=100&offset=0&order=created&select=id,name&eq(status,active)" + "?limit=100&offset=0&order=created&select=id,name&eq(status,'active')" ) diff --git a/tests/unit/http/test_base_service.py b/tests/unit/http/test_base_service.py index 70c2a15a..612bcc84 100644 --- a/tests/unit/http/test_base_service.py +++ b/tests/unit/http/test_base_service.py @@ -52,7 +52,7 @@ def test_build_url_with_query_state(http_client, filter_status_active): result = service_with_state.build_path() - assert result == "/api/v1/test?order=created,-name&select=id,name&eq(status,active)" + assert result == "/api/v1/test?order=created,-name&select=id,name&eq(status,'active')" def test_build_url_with_query_state_and_params(http_client, filter_status_active): @@ -65,7 +65,7 @@ def test_build_url_with_query_state_and_params(http_client, filter_status_active result = service_with_state.build_path(query_params) - assert result == "/api/v2/test/T-123?limit=5&eq(status,active)" + assert result == "/api/v2/test/T-123?limit=5&eq(status,'active')" def test_build_url_with_chained_methods(dummy_service, filter_status_active): @@ -79,6 +79,6 @@ def test_build_url_with_chained_methods(dummy_service, filter_status_active): result = chained_service.build_path({"limit": "10"}) expected_url = ( - "/api/v1/test?limit=10&order=-created,name&select=id,name,-audit&eq(status,active)" + "/api/v1/test?limit=10&order=-created,name&select=id,name,-audit&eq(status,'active')" ) assert result == expected_url diff --git a/tests/unit/http/test_query_state.py b/tests/unit/http/test_query_state.py index 98e131cf..210029be 100644 --- a/tests/unit/http/test_query_state.py +++ b/tests/unit/http/test_query_state.py @@ -30,7 +30,7 @@ def test_build_url(filter_status_active): assert result == ( "order=-created,name" "&select=-audit,product.agreements,-product.agreements.product" - "&eq(status,active)" + "&eq(status,'active')" ) @@ -46,4 +46,4 @@ def test_build_with_params(filter_status_active): result = query_state.build(query_params) - assert result == "limit=10&order=created&select=name&eq(status,active)" + assert result == "limit=10&order=created&select=name&eq(status,'active')" diff --git a/tests/unit/rql/query_builder/test_create_rql.py b/tests/unit/rql/query_builder/test_create_rql.py index 08f6e9cd..deb25e44 100644 --- a/tests/unit/rql/query_builder/test_create_rql.py +++ b/tests/unit/rql/query_builder/test_create_rql.py @@ -15,14 +15,14 @@ def test_create_with_field(): query.eq("value") # act assert query.op == RQLQuery.OP_EXPRESSION - assert str(query) == "eq(field,value)" + assert str(query) == "eq(field,'value')" def test_create_single_kwarg(): result = RQLQuery(id="ID") assert result.op == RQLQuery.OP_EXPRESSION - assert str(result) == "eq(id,ID)" + assert str(result) == "eq(id,'ID')" assert result.children == [] assert result.negated is False @@ -31,14 +31,14 @@ def test_create_multiple_kwargs(): # noqa: WPS218 result = RQLQuery(id="ID", status__in=("a", "b"), ok=True) assert result.op == RQLQuery.OP_AND - assert str(result) == "and(eq(id,ID),in(status,(a,b)),eq(ok,true))" + assert str(result) == "and(eq(id,'ID'),in(status,('a','b')),eq(ok,true))" assert len(result.children) == 3 assert result.children[0].op == RQLQuery.OP_EXPRESSION assert result.children[0].children == [] - assert str(result.children[0]) == "eq(id,ID)" + assert str(result.children[0]) == "eq(id,'ID')" assert result.children[1].op == RQLQuery.OP_EXPRESSION assert result.children[1].children == [] - assert str(result.children[1]) == "in(status,(a,b))" + assert str(result.children[1]) == "in(status,('a','b'))" assert result.children[2].op == RQLQuery.OP_EXPRESSION assert result.children[2].children == [] assert str(result.children[2]) == "eq(ok,true)" diff --git a/tests/unit/rql/query_builder/test_multiple_ops.py b/tests/unit/rql/query_builder/test_multiple_ops.py index c724773c..c7c1fb36 100644 --- a/tests/unit/rql/query_builder/test_multiple_ops.py +++ b/tests/unit/rql/query_builder/test_multiple_ops.py @@ -11,23 +11,26 @@ def test_and_or(): # noqa: WPS218 WPS473 AAA01 r5 = r1 & r2 & (r3 | r4) assert r5.op == RQLQuery.OP_AND - assert str(r5) == "and(eq(id,ID),eq(field,value),or(eq(other,value2),in(inop,(a,b))))" # noqa: WPS204 + 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))" + 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)))" + 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))))" + 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)))" + assert str(r5) == "or(and(eq(id,'ID'),eq(field,'value')),not(eq(other,'value2')))" def test_and_merge(): # noqa: WPS210 AAA01 diff --git a/tests/unit/rql/query_builder/test_properties_values.py b/tests/unit/rql/query_builder/test_properties_values.py new file mode 100644 index 00000000..d5611a34 --- /dev/null +++ b/tests/unit/rql/query_builder/test_properties_values.py @@ -0,0 +1,41 @@ +from mpt_api_client.rql import Property, RQLQuery, Value + + +def test_compare_default_value(): + query = RQLQuery(agreement__product__id="order.product.id") + + result = str(query) + + assert result == "eq(agreement.product.id,'order.product.id')" + + +def test_compare_quoted(): + query = RQLQuery(agreement__product__id=Value("order.product.id")) + + result = str(query) + + assert result == "eq(agreement.product.id,'order.product.id')" + + +def test_compare_property(): + query = RQLQuery(agreement__product__id=Property("order.product.id")) + + result = str(query) + + assert result == "eq(agreement.product.id,order.product.id)" + + +def test_ne_quoted(): + query = RQLQuery("agreement.product.id") + + result = str(query.ne(Value("order.product.id"))) + + assert result == "ne(agreement.product.id,'order.product.id')" + + +def test_ne_property(): + query = RQLQuery("agreement.product.id") + + result = str(query.ne(Property("order.product.id"))) + + assert result == "ne(agreement.product.id,order.product.id)" diff --git a/tests/unit/rql/query_builder/test_rql.py b/tests/unit/rql/query_builder/test_rql.py index 5e393a18..bb046e53 100644 --- a/tests/unit/rql/query_builder/test_rql.py +++ b/tests/unit/rql/query_builder/test_rql.py @@ -1,13 +1,15 @@ -from mpt_api_client.rql import RQLQuery +from mpt_api_client.rql import Property, RQLQuery, Value def test_repr(): # noqa: AAA01 - products = ["PRD-1", "PRD-2"] - product_ids = ",".join(products) + products = ["PRD-1", Value("PRD-2"), Property("agreement.product.id")] expression_query = RQLQuery(product__id__in=products) or_expression = RQLQuery(name="Albert") | RQLQuery(surname="Einstein") - assert repr(expression_query) == f"" + assert ( + repr(expression_query) + == "" + ) assert repr(or_expression) == "" @@ -28,9 +30,9 @@ def test_bool(): # noqa: AAA01 def test_str(): # noqa: AAA01 - 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 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()) diff --git a/tests/unit/rql/query_builder/test_rql_dot_path.py b/tests/unit/rql/query_builder/test_rql_dot_path.py index a32c819a..897230de 100644 --- a/tests/unit/rql/query_builder/test_rql_dot_path.py +++ b/tests/unit/rql/query_builder/test_rql_dot_path.py @@ -3,7 +3,7 @@ import pytest -from mpt_api_client.rql import RQLQuery +from mpt_api_client.rql import Property, RQLQuery @pytest.mark.parametrize("op", ["eq", "ne", "gt", "ge", "le", "lt"]) @@ -15,8 +15,8 @@ class Test: # noqa: WPS431 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()})" + today_expected_result = f"{op}(asset.id,'{today.isoformat()}')" + now_expected_result = f"{op}(asset.id,'{now.isoformat()}')" with pytest.raises(TypeError): getattr(RQLQuery().asset.id, op)(test) @@ -29,7 +29,7 @@ class Test: # noqa: WPS431 def test_dotted_path_comp_bool_and_str(op): result = getattr(RQLQuery().asset.id, op) - assert str(result("value")) == f"{op}(asset.id,value)" + assert str(result("value")) == f"{op}(asset.id,'value')" assert str(result(True)) == f"{op}(asset.id,true)" # noqa: FBT003 assert str(result(False)) == f"{op}(asset.id,false)" # noqa: FBT003 @@ -52,10 +52,10 @@ def test_dotted_path_comp_numerics(op): def test_dotted_path_search(op): result = getattr(RQLQuery().asset.id, op) - assert str(result("value")) == f"{op}(asset.id,value)" - assert str(result("*value")) == f"{op}(asset.id,*value)" - assert str(result("value*")) == f"{op}(asset.id,value*)" - assert str(result("*value*")) == f"{op}(asset.id,*value*)" + assert str(result("value")) == f"{op}(asset.id,'value')" + assert str(result("*value")) == f"{op}(asset.id,'*value')" + assert str(result("value*")) == f"{op}(asset.id,'value*')" + assert str(result("*value*")) == f"{op}(asset.id,'*value*')" @pytest.mark.parametrize( @@ -67,14 +67,15 @@ def test_dotted_path_search(op): ], ) def test_dotted_path_list(method, op): # noqa: AAA01 - rexpr_set = getattr(RQLQuery().asset.id, method)(("first", "second")) - rexpr_list = getattr(RQLQuery().asset.id, method)(["first", "second"]) + third = Property("third") + rexpr_set = getattr(RQLQuery().asset.id, method)(("first", "second", third)) + rexpr_list = getattr(RQLQuery().asset.id, method)(["first", "second", third]) with pytest.raises(TypeError): getattr(RQLQuery().asset.id, method)("Test") - assert str(rexpr_set) == f"{op}(asset.id,(first,second))" - assert str(rexpr_list) == f"{op}(asset.id,(first,second))" + assert str(rexpr_set) == f"{op}(asset.id,('first','second',third))" + assert str(rexpr_list) == f"{op}(asset.id,('first','second',third))" @pytest.mark.parametrize( diff --git a/tests/unit/rql/query_builder/test_rql_in.py b/tests/unit/rql/query_builder/test_rql_in.py index 196fb6d6..3deda1db 100644 --- a/tests/unit/rql/query_builder/test_rql_in.py +++ b/tests/unit/rql/query_builder/test_rql_in.py @@ -1,4 +1,4 @@ -from mpt_api_client.rql import RQLQuery +from mpt_api_client.rql import Property, RQLQuery def test_in_and_namespaces(): @@ -11,9 +11,8 @@ def test_in_and_namespaces(): def test_in(): - products = ["PRD-1", "PRD-2"] - product_ids = ",".join(products) + products = ["PRD-1", "PRD-2", Property("product.id")] result = RQLQuery(product__id__in=products) - assert str(result) == f"in(product.id,({product_ids}))" + assert str(result) == "in(product.id,('PRD-1','PRD-2',product.id))" diff --git a/tests/unit/rql/query_builder/test_rql_parse_kwargs.py b/tests/unit/rql/query_builder/test_rql_parse_kwargs.py index b6160eb6..6aae7453 100644 --- a/tests/unit/rql/query_builder/test_rql_parse_kwargs.py +++ b/tests/unit/rql/query_builder/test_rql_parse_kwargs.py @@ -18,7 +18,7 @@ def test_improper_op(mock_product_id_for_expression): result = parse_kwargs(products_expr) - assert str(result) == f"['eq(product.id.inn,{mock_product_id_for_expression})']" + assert str(result) == "[\"eq(product.id.inn,'PRD-1')\"]" def test_parse_eq(mock_product_id_for_expression): @@ -26,7 +26,7 @@ def test_parse_eq(mock_product_id_for_expression): result = parse_kwargs(products_expr) - assert str(result) == f"['eq(product.id,{mock_product_id_for_expression})']" + assert str(result) == "[\"eq(product.id,'PRD-1')\"]" def test_parse_like(mock_product_id_for_expression): @@ -34,7 +34,7 @@ def test_parse_like(mock_product_id_for_expression): result = parse_kwargs(products_expr) - assert str(result) == f"['like(product.id,{mock_product_id_for_expression})']" + assert str(result) == "[\"like(product.id,'PRD-1')\"]" def test_parse_null_op(mock_product_id_for_expression):