Skip to content

Commit 51fd78e

Browse files
committed
fix(rql): enforce single-quoted values for eq/ne/gt/ge/lt/le and align tests
1 parent 68c9e07 commit 51fd78e

11 files changed

Lines changed: 53 additions & 39 deletions

mpt_api_client/rql/query_builder.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@
99
QueryValue = str | bool | dt.date | dt.datetime | Numeric
1010

1111

12-
def parse_kwargs(query_dict: dict[str, QueryValue]) -> list[str]: # noqa: WPS231
12+
def quote_rql_value(value: str) -> str:
13+
"""Wrap value in single quotes for RQL comparison operators."""
14+
escaped_value = value.replace("'", r"\'")
15+
return f"'{escaped_value}'"
16+
17+
18+
def parse_kwargs(query_dict: dict[str, QueryValue]) -> list[str]: # noqa: WPS231 C901
1319
"""
1420
Parse keyword arguments into RQL query expressions.
1521
@@ -26,7 +32,7 @@ def parse_kwargs(query_dict: dict[str, QueryValue]) -> list[str]: # noqa: WPS23
2632
2733
Examples:
2834
parse_kwargs({'name': 'John', 'age__gt': 25})
29-
['eq(name,John)', 'gt(age,25)']
35+
["eq(name,'John')", "gt(age,'25')"]
3036
3137
parse_kwargs({'status__in': ['active', 'pending']})
3238
['in(status,(active,pending))']
@@ -37,17 +43,21 @@ def parse_kwargs(query_dict: dict[str, QueryValue]) -> list[str]: # noqa: WPS23
3743
if len(tokens) == 1:
3844
field = tokens[0]
3945
str_value = rql_encode("eq", value)
46+
str_value = quote_rql_value(str_value)
4047
query.append(f"eq({field},{str_value})")
4148
continue
4249
op = tokens[-1]
4350
if op not in constants.KEYWORDS:
4451
field = ".".join(tokens)
4552
str_value = rql_encode("eq", value)
53+
str_value = quote_rql_value(str_value)
4654
query.append(f"eq({field},{str_value})")
4755
continue
4856
field = ".".join(tokens[:-1])
4957
if op in constants.COMP or op in constants.SEARCH:
5058
str_value = rql_encode(op, value)
59+
if op in constants.COMP:
60+
str_value = quote_rql_value(str_value)
5161
query.append(f"{op}({field},{str_value})")
5262
continue
5363
if op in constants.LIST:
@@ -462,6 +472,8 @@ def ilike(self, value: QueryValue) -> Self:
462472
def _bin(self, op: str, value: QueryValue) -> Self:
463473
self._field = ".".join(self._path)
464474
value = rql_encode(op, value)
475+
if op in constants.COMP:
476+
value = quote_rql_value(value)
465477
self.expr = f"{op}({self._field},{value})"
466478
return self
467479

tests/unit/http/mixins/test_collection_mixin.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def test_col_mx_fetch_one_with_filters(
7373
assert first_request.url == (
7474
"https://api.example.com/api/v1/test"
7575
"?limit=1&offset=0&order=created"
76-
"&select=id,name&eq(status,active)"
76+
"&select=id,name&eq(status,'active')"
7777
)
7878

7979

@@ -91,7 +91,7 @@ def test_col_mx_fetch_page_with_filter(
9191
"https://api.example.com/api/v1/test?limit=10&offset=5"
9292
"&order=-created,name"
9393
"&select=-audit,product.agreements,-product.agreements.product"
94-
"&eq(status,active)"
94+
"&eq(status,'active')"
9595
)
9696
with respx.mock:
9797
mock_route = respx.get("https://api.example.com/api/v1/test").mock(
@@ -213,7 +213,7 @@ def test_col_mx_iterate_with_filters(
213213
request = mock_route.calls[0].request
214214
assert (
215215
str(request.url) == "https://api.example.com/api/v1/test"
216-
"?limit=100&offset=0&order=created&select=id,name&eq(status,active)"
216+
"?limit=100&offset=0&order=created&select=id,name&eq(status,'active')"
217217
)
218218

219219

@@ -322,7 +322,7 @@ async def test_async_col_mx_fetch_one_with_filters(
322322
assert first_request.url == (
323323
"https://api.example.com/api/v1/test"
324324
"?limit=1&offset=0&order=created"
325-
"&select=id,name&eq(status,active)"
325+
"&select=id,name&eq(status,'active')"
326326
)
327327

328328

@@ -342,7 +342,7 @@ async def test_async_col_mx_fetch_page_with_filter(
342342
"https://api.example.com/api/v1/test?limit=10&offset=5"
343343
"&order=-created,name"
344344
"&select=-audit,product.agreements,-product.agreements.product"
345-
"&eq(status,active)"
345+
"&eq(status,'active')"
346346
)
347347
with respx.mock:
348348
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(
464464
request = mock_route.calls[0].request
465465
assert (
466466
str(request.url) == "https://api.example.com/api/v1/test"
467-
"?limit=100&offset=0&order=created&select=id,name&eq(status,active)"
467+
"?limit=100&offset=0&order=created&select=id,name&eq(status,'active')"
468468
)
469469

470470

tests/unit/http/test_base_service.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def test_build_url_with_query_state(http_client, filter_status_active):
5252

5353
result = service_with_state.build_path()
5454

55-
assert result == "/api/v1/test?order=created,-name&select=id,name&eq(status,active)"
55+
assert result == "/api/v1/test?order=created,-name&select=id,name&eq(status,'active')"
5656

5757

5858
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
6565

6666
result = service_with_state.build_path(query_params)
6767

68-
assert result == "/api/v2/test/T-123?limit=5&eq(status,active)"
68+
assert result == "/api/v2/test/T-123?limit=5&eq(status,'active')"
6969

7070

7171
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):
7979
result = chained_service.build_path({"limit": "10"})
8080

8181
expected_url = (
82-
"/api/v1/test?limit=10&order=-created,name&select=id,name,-audit&eq(status,active)"
82+
"/api/v1/test?limit=10&order=-created,name&select=id,name,-audit&eq(status,'active')"
8383
)
8484
assert result == expected_url

tests/unit/http/test_query_state.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def test_build_url(filter_status_active):
3030
assert result == (
3131
"order=-created,name"
3232
"&select=-audit,product.agreements,-product.agreements.product"
33-
"&eq(status,active)"
33+
"&eq(status,'active')"
3434
)
3535

3636

@@ -46,4 +46,4 @@ def test_build_with_params(filter_status_active):
4646

4747
result = query_state.build(query_params)
4848

49-
assert result == "limit=10&order=created&select=name&eq(status,active)"
49+
assert result == "limit=10&order=created&select=name&eq(status,'active')"

tests/unit/rql/query_builder/test_create_rql.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@ def test_create_with_field():
1515
query.eq("value") # act
1616

1717
assert query.op == RQLQuery.OP_EXPRESSION
18-
assert str(query) == "eq(field,value)"
18+
assert str(query) == "eq(field,'value')"
1919

2020

2121
def test_create_single_kwarg():
2222
result = RQLQuery(id="ID")
2323

2424
assert result.op == RQLQuery.OP_EXPRESSION
25-
assert str(result) == "eq(id,ID)"
25+
assert str(result) == "eq(id,'ID')"
2626
assert result.children == []
2727
assert result.negated is False
2828

@@ -31,17 +31,17 @@ def test_create_multiple_kwargs(): # noqa: WPS218
3131
result = RQLQuery(id="ID", status__in=("a", "b"), ok=True)
3232

3333
assert result.op == RQLQuery.OP_AND
34-
assert str(result) == "and(eq(id,ID),in(status,(a,b)),eq(ok,true))"
34+
assert str(result) == "and(eq(id,'ID'),in(status,(a,b)),eq(ok,'true'))"
3535
assert len(result.children) == 3
3636
assert result.children[0].op == RQLQuery.OP_EXPRESSION
3737
assert result.children[0].children == []
38-
assert str(result.children[0]) == "eq(id,ID)"
38+
assert str(result.children[0]) == "eq(id,'ID')"
3939
assert result.children[1].op == RQLQuery.OP_EXPRESSION
4040
assert result.children[1].children == []
4141
assert str(result.children[1]) == "in(status,(a,b))"
4242
assert result.children[2].op == RQLQuery.OP_EXPRESSION
4343
assert result.children[2].children == []
44-
assert str(result.children[2]) == "eq(ok,true)"
44+
assert str(result.children[2]) == "eq(ok,'true')"
4545

4646

4747
def test_new_empty():

tests/unit/rql/query_builder/test_multiple_ops.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,25 @@ def test_and_or(): # noqa: WPS218 WPS473 AAA01
1111
r5 = r1 & r2 & (r3 | r4)
1212

1313
assert r5.op == RQLQuery.OP_AND
14-
assert str(r5) == "and(eq(id,ID),eq(field,value),or(eq(other,value2),in(inop,(a,b))))" # noqa: WPS204
14+
assert str(r5) == "and(eq(id,'ID'),eq(field,'value'),or(eq(other,'value2'),in(inop,(a,b))))" # noqa: WPS204
1515

1616
r5 = r1 & r2 | r3
1717

18-
assert str(r5) == "or(and(eq(id,ID),eq(field,value)),eq(other,value2))"
18+
assert str(r5) == "or(and(eq(id,'ID'),eq(field,'value')),eq(other,'value2'))"
1919

2020
r5 = r1 & (r2 | r3)
2121

22-
assert str(r5) == "and(eq(id,ID),or(eq(field,value),eq(other,value2)))"
22+
assert str(r5) == "and(eq(id,'ID'),or(eq(field,'value'),eq(other,'value2')))"
2323

2424
r5 = (r1 & r2) | (r3 & r4)
2525

26-
assert str(r5) == "or(and(eq(id,ID),eq(field,value)),and(eq(other,value2),in(inop,(a,b))))"
26+
assert (
27+
str(r5) == "or(and(eq(id,'ID'),eq(field,'value')),and(eq(other,'value2'),in(inop,(a,b))))"
28+
)
2729

2830
r5 = (r1 & r2) | ~r3
2931

30-
assert str(r5) == "or(and(eq(id,ID),eq(field,value)),not(eq(other,value2)))"
32+
assert str(r5) == "or(and(eq(id,'ID'),eq(field,'value')),not(eq(other,'value2')))"
3133

3234

3335
def test_and_merge(): # noqa: WPS210 AAA01

tests/unit/rql/query_builder/test_rql.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ def test_bool(): # noqa: AAA01
2828

2929

3030
def test_str(): # noqa: AAA01
31-
assert str(RQLQuery(id="ID")) == "eq(id,ID)"
32-
assert str(~RQLQuery(id="ID")) == "not(eq(id,ID))"
33-
assert str(~RQLQuery(id="ID", field="value")) == "not(and(eq(id,ID),eq(field,value)))"
31+
assert str(RQLQuery(id="ID")) == "eq(id,'ID')"
32+
assert str(~RQLQuery(id="ID")) == "not(eq(id,'ID'))"
33+
assert str(~RQLQuery(id="ID", field="value")) == "not(and(eq(id,'ID'),eq(field,'value')))"
3434
assert not str(RQLQuery())
3535

3636

tests/unit/rql/query_builder/test_rql_all_any.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ def test_all():
66

77
result = str(query)
88

9-
assert result == "all(gt(saleDetails.orderQty,1))"
9+
assert result == "all(gt(saleDetails.orderQty,'1'))"
1010

1111

1212
def test_any():
1313
query = RQLQuery(saleDetails__orderQty__gt=1).any()
1414

1515
result = str(query)
1616

17-
assert result == "any(gt(saleDetails.orderQty,1))"
17+
assert result == "any(gt(saleDetails.orderQty,'1'))"

tests/unit/rql/query_builder/test_rql_dot_path.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ class Test: # noqa: WPS431
1515
test = Test()
1616
today = dt.datetime.now(dt.UTC).date()
1717
now = dt.datetime.now(dt.UTC)
18-
today_expected_result = f"{op}(asset.id,{today.isoformat()})"
19-
now_expected_result = f"{op}(asset.id,{now.isoformat()})"
18+
today_expected_result = f"{op}(asset.id,'{today.isoformat()}')"
19+
now_expected_result = f"{op}(asset.id,'{now.isoformat()}')"
2020

2121
with pytest.raises(TypeError):
2222
getattr(RQLQuery().asset.id, op)(test)
@@ -29,9 +29,9 @@ class Test: # noqa: WPS431
2929
def test_dotted_path_comp_bool_and_str(op):
3030
result = getattr(RQLQuery().asset.id, op)
3131

32-
assert str(result("value")) == f"{op}(asset.id,value)"
33-
assert str(result(True)) == f"{op}(asset.id,true)" # noqa: FBT003
34-
assert str(result(False)) == f"{op}(asset.id,false)" # noqa: FBT003
32+
assert str(result("value")) == f"{op}(asset.id,'value')"
33+
assert str(result(True)) == f"{op}(asset.id,'true')" # noqa: FBT003
34+
assert str(result(False)) == f"{op}(asset.id,'false')" # noqa: FBT003
3535

3636

3737
@pytest.mark.parametrize("op", ["eq", "ne", "gt", "ge", "le", "lt"]) # noqa: AAA01
@@ -43,9 +43,9 @@ def test_dotted_path_comp_numerics(op):
4343
result_float = str(attribute_op_match(10.678937))
4444
decimal_result = str(attribute_op_match(Decimal("32983.328238273")))
4545

46-
assert integer_result == f"{op}(asset.id,10)"
47-
assert result_float == f"{op}(asset.id,10.678937)"
48-
assert decimal_result == f"{op}(asset.id,{decimal_object!s})"
46+
assert integer_result == f"{op}(asset.id,'10')"
47+
assert result_float == f"{op}(asset.id,'10.678937')"
48+
assert decimal_result == f"{op}(asset.id,'{decimal_object!s}')"
4949

5050

5151
@pytest.mark.parametrize("op", ["like", "ilike"])

tests/unit/rql/query_builder/test_rql_from_str.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33

44
def test_rql_from_str():
5-
str_query = "eq(id,ID)"
5+
str_query = "eq(id,'ID')"
66

77
result = RQLQuery.from_string(str_query)
88

0 commit comments

Comments
 (0)