Skip to content

Commit f9f937a

Browse files
Entities moved to public-preview with warning. New Query API introduced. (#1432)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 03b641c commit f9f937a

8 files changed

Lines changed: 565 additions & 34 deletions

File tree

packages/uipath-platform/pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-platform"
3-
version = "0.0.18"
3+
version = "0.0.19"
44
description = "HTTP client library for programmatic access to UiPath Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
@@ -10,6 +10,7 @@ dependencies = [
1010
"truststore>=0.10.1",
1111
"uipath-core>=0.5.4, <0.6.0",
1212
"pydantic-function-models>=0.1.11",
13+
"sqlparse>=0.5.5",
1314
]
1415
classifiers = [
1516
"Intended Audience :: Developers",

packages/uipath-platform/src/uipath/platform/entities/_entities_service.py

Lines changed: 189 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
from typing import Any, List, Optional, Type
1+
from typing import Any, Dict, List, Optional, Type
22

3+
import sqlparse
34
from httpx import Response
5+
from sqlparse.sql import Parenthesis, Where
6+
from sqlparse.tokens import DML, Keyword, Wildcard
47
from uipath.core.tracing import traced
58

69
from ..common._base_service import BaseService
@@ -13,6 +16,20 @@
1316
EntityRecordsBatchResponse,
1417
)
1518

19+
_FORBIDDEN_DML = {"INSERT", "UPDATE", "DELETE", "MERGE", "REPLACE"}
20+
_FORBIDDEN_DDL = {"DROP", "ALTER", "CREATE", "TRUNCATE"}
21+
_DISALLOWED_KEYWORDS = [
22+
"WITH",
23+
"UNION",
24+
"INTERSECT",
25+
"EXCEPT",
26+
"OVER",
27+
"ROLLUP",
28+
"CUBE",
29+
"GROUPING",
30+
"PARTITION",
31+
]
32+
1633

1734
class EntitiesService(BaseService):
1835
"""Service for managing UiPath Data Service entities.
@@ -22,6 +39,10 @@ class EntitiesService(BaseService):
2239
2340
See Also:
2441
https://docs.uipath.com/data-service/automation-cloud/latest/user-guide/introduction
42+
43+
!!! warning "Preview Feature"
44+
This function is currently experimental.
45+
Behavior and parameters are subject to change in future versions.
2546
"""
2647

2748
def __init__(
@@ -391,6 +412,66 @@ class CustomerRecord:
391412
EntityRecord.from_data(data=record, model=schema) for record in records_data
392413
]
393414

415+
@traced(name="entity_query_records", run_type="uipath")
416+
def query_entity_records(
417+
self,
418+
sql_query: str,
419+
) -> List[Dict[str, Any]]:
420+
"""Query entity records using a validated SQL query.
421+
422+
PREVIEW: This method is in preview and may change in future releases.
423+
424+
Args:
425+
sql_query (str): A SQL SELECT query to execute against Data Service entities.
426+
Only SELECT statements are allowed. Queries without WHERE must include
427+
a LIMIT clause. Subqueries and multi-statement queries are not permitted.
428+
429+
Returns:
430+
List[Dict[str, Any]]: A list of result records as dictionaries.
431+
432+
Raises:
433+
ValueError: If the SQL query fails validation (e.g., non-SELECT, missing
434+
WHERE/LIMIT, forbidden keywords, subqueries).
435+
"""
436+
return self._query_entities_for_records(sql_query)
437+
438+
@traced(name="entity_query_records", run_type="uipath")
439+
async def query_entity_records_async(
440+
self,
441+
sql_query: str,
442+
) -> List[Dict[str, Any]]:
443+
"""Asynchronously query entity records using a validated SQL query.
444+
445+
PREVIEW: This method is in preview and may change in future releases.
446+
447+
Args:
448+
sql_query (str): A SQL SELECT query to execute against Data Service entities.
449+
Only SELECT statements are allowed. Queries without WHERE must include
450+
a LIMIT clause. Subqueries and multi-statement queries are not permitted.
451+
452+
Returns:
453+
List[Dict[str, Any]]: A list of result records as dictionaries.
454+
455+
Raises:
456+
ValueError: If the SQL query fails validation (e.g., non-SELECT, missing
457+
WHERE/LIMIT, forbidden keywords, subqueries).
458+
"""
459+
return await self._query_entities_for_records_async(sql_query)
460+
461+
def _query_entities_for_records(self, sql_query: str) -> List[Dict[str, Any]]:
462+
self._validate_sql_query(sql_query)
463+
spec = self._query_entity_records_spec(sql_query)
464+
response = self.request(spec.method, spec.endpoint, json=spec.json)
465+
return response.json().get("results", [])
466+
467+
async def _query_entities_for_records_async(
468+
self, sql_query: str
469+
) -> List[Dict[str, Any]]:
470+
self._validate_sql_query(sql_query)
471+
spec = self._query_entity_records_spec(sql_query)
472+
response = await self.request_async(spec.method, spec.endpoint, json=spec.json)
473+
return response.json().get("results", [])
474+
394475
@traced(name="entity_record_insert_batch", run_type="uipath")
395476
def insert_records(
396477
self,
@@ -874,6 +955,16 @@ def _list_records_spec(
874955
params=({"start": start, "limit": limit}),
875956
)
876957

958+
def _query_entity_records_spec(
959+
self,
960+
sql_query: str,
961+
) -> RequestSpec:
962+
return RequestSpec(
963+
method="POST",
964+
endpoint=Endpoint("datafabric_/api/v1/query/execute"),
965+
json={"query": sql_query},
966+
)
967+
877968
def _insert_batch_spec(self, entity_key: str, records: List[Any]) -> RequestSpec:
878969
return RequestSpec(
879970
method="POST",
@@ -902,3 +993,100 @@ def _delete_batch_spec(self, entity_key: str, record_ids: List[str]) -> RequestS
902993
),
903994
json=record_ids,
904995
)
996+
997+
def _validate_sql_query(self, sql_query: str) -> None:
998+
query = sql_query.strip().rstrip(";").strip()
999+
if not query:
1000+
raise ValueError("SQL query cannot be empty.")
1001+
1002+
statements = sqlparse.parse(query)
1003+
if len(statements) != 1 or not statements[0].tokens:
1004+
raise ValueError("Only a single SELECT statement is allowed.")
1005+
1006+
stmt = statements[0]
1007+
stmt_type = stmt.get_type()
1008+
1009+
if stmt_type != "SELECT":
1010+
raise ValueError("Only SELECT statements are allowed.")
1011+
1012+
keywords = set()
1013+
for token in stmt.flatten():
1014+
if token.ttype in Keyword:
1015+
keywords.add(token.normalized)
1016+
1017+
for kw in _FORBIDDEN_DML:
1018+
if kw in keywords:
1019+
raise ValueError(f"SQL keyword '{kw}' is not allowed.")
1020+
1021+
for kw in _FORBIDDEN_DDL:
1022+
if kw in keywords:
1023+
raise ValueError(f"SQL keyword '{kw}' is not allowed.")
1024+
1025+
for kw in _DISALLOWED_KEYWORDS:
1026+
if kw in keywords:
1027+
raise ValueError(
1028+
f"SQL construct '{kw}' is not allowed in entity queries."
1029+
)
1030+
1031+
if self._has_subquery(stmt):
1032+
raise ValueError("Subqueries are not allowed.")
1033+
1034+
has_where = any(isinstance(t, Where) for t in stmt.tokens)
1035+
has_limit = "LIMIT" in keywords
1036+
if not has_where and not has_limit:
1037+
raise ValueError("Queries without WHERE must include a LIMIT clause.")
1038+
1039+
projection = self._projection_tokens(stmt)
1040+
has_wildcard = any(t.ttype is Wildcard for t in projection)
1041+
if has_wildcard and not has_where:
1042+
raise ValueError("SELECT * without filtering is not allowed.")
1043+
if not has_where and self._projection_column_count(projection) > 4:
1044+
raise ValueError(
1045+
"Selecting more than 4 columns without filtering is not allowed."
1046+
)
1047+
1048+
@staticmethod
1049+
def _has_subquery(stmt: sqlparse.sql.Statement) -> bool:
1050+
"""Recursively walk the AST looking for SELECT inside parentheses."""
1051+
1052+
def _walk(token: sqlparse.sql.Token) -> bool:
1053+
if isinstance(token, Parenthesis):
1054+
for child in token.flatten():
1055+
if child.ttype is DML and child.normalized == "SELECT":
1056+
return True
1057+
if hasattr(token, "tokens"):
1058+
for child in token.tokens:
1059+
if _walk(child):
1060+
return True
1061+
return False
1062+
1063+
for token in stmt.tokens:
1064+
if _walk(token):
1065+
return True
1066+
return False
1067+
1068+
@staticmethod
1069+
def _projection_tokens(
1070+
stmt: sqlparse.sql.Statement,
1071+
) -> list[sqlparse.sql.Token]:
1072+
"""Extract tokens between the first SELECT and FROM."""
1073+
tokens: list[sqlparse.sql.Token] = []
1074+
collecting = False
1075+
for token in stmt.flatten():
1076+
if token.ttype is DML and token.normalized == "SELECT":
1077+
collecting = True
1078+
continue
1079+
if token.ttype is Keyword and token.normalized == "FROM":
1080+
break
1081+
if collecting:
1082+
tokens.append(token)
1083+
return tokens
1084+
1085+
@staticmethod
1086+
def _projection_column_count(
1087+
projection: list[sqlparse.sql.Token],
1088+
) -> int:
1089+
text = "".join(t.value for t in projection).strip()
1090+
if not text:
1091+
return 0
1092+
return len([part for part in text.split(",") if part.strip()])

packages/uipath-platform/tests/services/test_entities_service.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import re
12
import uuid
23
from dataclasses import make_dataclass
34
from typing import Optional
5+
from unittest.mock import AsyncMock, MagicMock
46

57
import pytest
68
from pytest_httpx import HTTPXMock
@@ -260,3 +262,130 @@ def test_retrieve_records_with_optional_fields(
260262
start=0,
261263
limit=1,
262264
)
265+
266+
@pytest.mark.parametrize(
267+
"sql_query",
268+
[
269+
"SELECT id FROM Customers WHERE id = 1",
270+
"SELECT id, name FROM Customers LIMIT 10",
271+
"SELECT * FROM Customers WHERE status = 'Active'",
272+
"SELECT id, name, email, phone FROM Customers LIMIT 5",
273+
"SELECT DISTINCT id FROM Customers WHERE id > 100",
274+
"SELECT id FROM Customers WHERE name = 'foo;bar'",
275+
"SELECT id FROM Customers WHERE id = 1;",
276+
"SELECT id FROM Customers WHERE name = 'DELETE'",
277+
"SELECT id FROM Customers WHERE status = 'UPDATE me'",
278+
],
279+
)
280+
def test_validate_sql_query_allows_supported_select_queries(
281+
self, sql_query: str, service: EntitiesService
282+
) -> None:
283+
service._validate_sql_query(sql_query)
284+
285+
@pytest.mark.parametrize(
286+
"sql_query,error_message",
287+
[
288+
("", "SQL query cannot be empty."),
289+
(" ", "SQL query cannot be empty."),
290+
(
291+
"SELECT id FROM Customers; SELECT id FROM Orders",
292+
"Only a single SELECT statement is allowed.",
293+
),
294+
("INSERT INTO Customers VALUES (1)", "Only SELECT statements are allowed."),
295+
(
296+
"WITH cte AS (SELECT id FROM Customers) SELECT id FROM cte",
297+
"SQL construct 'WITH' is not allowed in entity queries.",
298+
),
299+
(
300+
"SELECT id FROM Customers UNION SELECT id FROM Orders",
301+
"SQL construct 'UNION' is not allowed in entity queries.",
302+
),
303+
(
304+
"SELECT id, SUM(amount) OVER (PARTITION BY id) FROM Orders LIMIT 10",
305+
"SQL construct 'OVER' is not allowed in entity queries.",
306+
),
307+
(
308+
"SELECT id FROM (SELECT id FROM Customers) c",
309+
"Subqueries are not allowed.",
310+
),
311+
(
312+
"SELECT COALESCE((SELECT max(id) FROM Orders), 0) FROM Customers WHERE id = 1",
313+
"Subqueries are not allowed.",
314+
),
315+
(
316+
"SELECT id FROM Customers",
317+
"Queries without WHERE must include a LIMIT clause.",
318+
),
319+
(
320+
"SELECT * FROM Customers LIMIT 10",
321+
"SELECT * without filtering is not allowed.",
322+
),
323+
(
324+
"SELECT id, name, email, phone, address FROM Customers LIMIT 10",
325+
"Selecting more than 4 columns without filtering is not allowed.",
326+
),
327+
],
328+
)
329+
def test_validate_sql_query_rejects_disallowed_queries(
330+
self, sql_query: str, error_message: str, service: EntitiesService
331+
) -> None:
332+
with pytest.raises(ValueError, match=re.escape(error_message)):
333+
service._validate_sql_query(sql_query)
334+
335+
def test_query_entity_records_rejects_invalid_sql_before_network_call(
336+
self,
337+
service: EntitiesService,
338+
) -> None:
339+
service.request = MagicMock() # type: ignore[method-assign]
340+
341+
with pytest.raises(
342+
ValueError, match=re.escape("Only SELECT statements are allowed.")
343+
):
344+
service.query_entity_records("UPDATE Customers SET name = 'X'")
345+
346+
service.request.assert_not_called()
347+
348+
def test_query_entity_records_calls_request_for_valid_sql(
349+
self,
350+
service: EntitiesService,
351+
) -> None:
352+
response = MagicMock()
353+
response.json.return_value = {"results": [{"id": 1}, {"id": 2}]}
354+
355+
service.request = MagicMock(return_value=response) # type: ignore[method-assign]
356+
357+
result = service.query_entity_records("SELECT id FROM Customers WHERE id > 0")
358+
359+
assert result == [{"id": 1}, {"id": 2}]
360+
service.request.assert_called_once()
361+
362+
@pytest.mark.anyio
363+
async def test_query_entity_records_async_rejects_invalid_sql_before_network_call(
364+
self,
365+
service: EntitiesService,
366+
) -> None:
367+
service.request_async = AsyncMock() # type: ignore[method-assign]
368+
369+
with pytest.raises(ValueError, match=re.escape("Subqueries are not allowed.")):
370+
await service.query_entity_records_async(
371+
"SELECT id FROM Customers WHERE id IN (SELECT id FROM Orders)"
372+
)
373+
374+
service.request_async.assert_not_called()
375+
376+
@pytest.mark.anyio
377+
async def test_query_entity_records_async_calls_request_for_valid_sql(
378+
self,
379+
service: EntitiesService,
380+
) -> None:
381+
response = MagicMock()
382+
response.json.return_value = {"results": [{"id": "c1"}]}
383+
384+
service.request_async = AsyncMock(return_value=response) # type: ignore[method-assign]
385+
386+
result = await service.query_entity_records_async(
387+
"SELECT id FROM Customers WHERE id = 'c1'"
388+
)
389+
390+
assert result == [{"id": "c1"}]
391+
service.request_async.assert_called_once()

0 commit comments

Comments
 (0)