1- from typing import Any , List , Optional , Type
1+ from typing import Any , Dict , List , Optional , Type
22
3+ import sqlparse
34from httpx import Response
5+ from sqlparse .sql import Parenthesis , Where
6+ from sqlparse .tokens import DML , Keyword , Wildcard
47from uipath .core .tracing import traced
58
69from ..common ._base_service import BaseService
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
1734class 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 ()])
0 commit comments