diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 3e979d24c..d2c8eba69 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.27" +version = "0.2.28" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py index 4307a1469..147aae78f 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py @@ -2319,7 +2319,11 @@ async def retrieve_records_v3_async( @attach_datafabric_error_mapping("query_entity_records") @traced(name="entity_query_records", run_type="uipath") def query_entity_records( - self, sql_query: str, *, relationships_as_scalar: bool = False + self, + sql_query: str, + *, + relationships_as_scalar: bool = False, + resolve_choice_sets: bool = False, ) -> List[Dict[str, Any]]: """Query entity records using a validated SQL query. @@ -2334,6 +2338,10 @@ def query_entity_records( so a query can join on ``relationshipField = Other.Id``. Sent as ``queryOptions.relationshipsAsScalar`` in the request body. Defaults to ``False`` (unchanged behaviour). + resolve_choice_sets (bool, optional): When ``True``, choice-set fields + in results are returned as key-value pairs (label + NumberId) instead + of bare integers. Sent as ``queryOptions.resolveChoiceSets`` in the + request body. Defaults to ``False``. Notes: A routing context is always derived from the configured ``folders_map`` @@ -2346,12 +2354,18 @@ def query_entity_records( ValueError: If the SQL query fails validation (e.g., non-SELECT, missing WHERE/LIMIT, forbidden keywords, subqueries). """ - return self._data.query_entity_records(sql_query, relationships_as_scalar) + return self._data.query_entity_records( + sql_query, relationships_as_scalar, resolve_choice_sets + ) @attach_datafabric_error_mapping("query_entity_records_async") @traced(name="entity_query_records", run_type="uipath") async def query_entity_records_async( - self, sql_query: str, *, relationships_as_scalar: bool = False + self, + sql_query: str, + *, + relationships_as_scalar: bool = False, + resolve_choice_sets: bool = False, ) -> List[Dict[str, Any]]: """Asynchronously query entity records using a validated SQL query. @@ -2366,6 +2380,10 @@ async def query_entity_records_async( so a query can join on ``relationshipField = Other.Id``. Sent as ``queryOptions.relationshipsAsScalar`` in the request body. Defaults to ``False`` (unchanged behaviour). + resolve_choice_sets (bool, optional): When ``True``, choice-set fields + in results are returned as key-value pairs (label + NumberId) instead + of bare integers. Sent as ``queryOptions.resolveChoiceSets`` in the + request body. Defaults to ``False``. Notes: A routing context is always derived from the configured ``folders_map`` @@ -2379,7 +2397,7 @@ async def query_entity_records_async( WHERE/LIMIT, forbidden keywords, subqueries). """ return await self._data.query_entity_records_async( - sql_query, relationships_as_scalar + sql_query, relationships_as_scalar, resolve_choice_sets ) @traced(name="entity_upload_attachment", run_type="uipath") diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py index 68465c3cb..61f29a3e0 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entity_data_service.py @@ -22,6 +22,7 @@ from ..common._config import UiPathApiConfig from ..common._execution_context import UiPathExecutionContext from ..common._models import Endpoint, RequestSpec +from ..errors._datafabric_error import DataFabricSqlValidationError from ..errors._enriched_exception import EnrichedException from ..orchestrator._folder_service import FolderService from ._entity_resolution import RoutingStrategy, create_routing_strategy @@ -554,18 +555,22 @@ def query_entity_records( self, sql_query: str, relationships_as_scalar: bool = False, + resolve_choice_sets: bool = False, ) -> List[Dict[str, Any]]: """Internal implementation; see :meth:`EntitiesService.query_entity_records`.""" - return self._query_entities_for_records(sql_query, relationships_as_scalar) + return self._query_entities_for_records( + sql_query, relationships_as_scalar, resolve_choice_sets + ) async def query_entity_records_async( self, sql_query: str, relationships_as_scalar: bool = False, + resolve_choice_sets: bool = False, ) -> List[Dict[str, Any]]: """Async variant of :meth:`query_entity_records`.""" return await self._query_entities_for_records_async( - sql_query, relationships_as_scalar + sql_query, relationships_as_scalar, resolve_choice_sets ) # ------------------------------------------------------------------ @@ -720,25 +725,31 @@ def validate_entity_batch( # ------------------------------------------------------------------ def _query_entities_for_records( - self, sql_query: str, relationships_as_scalar: bool = False + self, + sql_query: str, + relationships_as_scalar: bool = False, + resolve_choice_sets: bool = False, ) -> List[Dict[str, Any]]: """Synchronously run a validated SQL query through the federated query engine.""" self._validate_sql_query(sql_query) routing_context = self._routing_strategy.resolve() spec = self._query_entity_records_spec( - sql_query, routing_context, relationships_as_scalar + sql_query, routing_context, relationships_as_scalar, resolve_choice_sets ) response = self.request(spec.method, spec.endpoint, json=spec.json) return response.json().get("results", []) async def _query_entities_for_records_async( - self, sql_query: str, relationships_as_scalar: bool = False + self, + sql_query: str, + relationships_as_scalar: bool = False, + resolve_choice_sets: bool = False, ) -> List[Dict[str, Any]]: """Asynchronously run a validated SQL query through the federated query engine.""" self._validate_sql_query(sql_query) routing_context = await self._routing_strategy.resolve_async() spec = self._query_entity_records_spec( - sql_query, routing_context, relationships_as_scalar + sql_query, routing_context, relationships_as_scalar, resolve_choice_sets ) response = await self.request_async(spec.method, spec.endpoint, json=spec.json) return response.json().get("results", []) @@ -996,6 +1007,7 @@ def _query_entity_records_spec( sql_query: str, routing_context: Optional[QueryRoutingOverrideContext] = None, relationships_as_scalar: bool = False, + resolve_choice_sets: bool = False, ) -> RequestSpec: """Build the POST spec for the federated SQL query endpoint.""" body: Dict[str, Any] = {"query": sql_query} @@ -1003,8 +1015,13 @@ def _query_entity_records_spec( body["routingContext"] = routing_context.model_dump( by_alias=True, exclude_none=True ) + query_options: Dict[str, Any] = {} if relationships_as_scalar: - body["queryOptions"] = {"relationshipsAsScalar": True} + query_options["relationshipsAsScalar"] = True + if resolve_choice_sets: + query_options["resolveChoiceSets"] = True + if query_options: + body["queryOptions"] = query_options return RequestSpec( method="POST", endpoint=Endpoint("datafabric_/api/v1/query/execute"), @@ -1256,20 +1273,37 @@ def _extract_batch_response_from_error( # ------------------------------------------------------------------ def _validate_sql_query(self, sql_query: str) -> None: - """Validate a SQL string for the federated query endpoint client-side.""" + """Validate a SQL string for the federated query endpoint client-side. + + Raises: + DataFabricSqlValidationError: The statement violates the + entity-query subset. Its :class:`DataFabricError` category + distinguishes a mechanically fixable statement (``BAD_SQL``) + from one whose shape the subset cannot express at all + (``UNSUPPORTED_CONSTRUCT``), so a retry loop can stop instead + of re-trying variants of an impossible approach. + """ query = sql_query.strip().rstrip(";").strip() if not query: - raise ValueError("SQL query cannot be empty.") + raise DataFabricSqlValidationError( + "SQL query cannot be empty.", code="SQL_EMPTY" + ) statements = sqlparse.parse(query) if len(statements) != 1 or not statements[0].tokens: - raise ValueError("Only a single SELECT statement is allowed.") + raise DataFabricSqlValidationError( + "Only a single SELECT statement is allowed.", + code="SQL_MULTIPLE_STATEMENTS", + ) stmt = statements[0] stmt_type = stmt.get_type() if stmt_type != "SELECT": - raise ValueError("Only SELECT statements are allowed.") + raise DataFabricSqlValidationError( + "Only SELECT statements are allowed.", + code="SQL_STATEMENT_NOT_SELECT", + ) keywords = set() for token in stmt.flatten(): @@ -1278,46 +1312,65 @@ def _validate_sql_query(self, sql_query: str) -> None: for kw in _FORBIDDEN_DML: if kw in keywords: - raise ValueError(f"SQL keyword '{kw}' is not allowed.") + raise DataFabricSqlValidationError( + f"SQL keyword '{kw}' is not allowed.", + code="SQL_KEYWORD_NOT_ALLOWED", + ) for kw in _FORBIDDEN_DDL: if kw in keywords: - raise ValueError(f"SQL keyword '{kw}' is not allowed.") + raise DataFabricSqlValidationError( + f"SQL keyword '{kw}' is not allowed.", + code="SQL_KEYWORD_NOT_ALLOWED", + ) for kw in _DISALLOWED_KEYWORDS: if kw in keywords: - raise ValueError( - f"SQL construct '{kw}' is not allowed in entity queries." + raise DataFabricSqlValidationError( + f"SQL construct '{kw}' is not allowed in entity queries.", + code="SQL_CONSTRUCT_NOT_ALLOWED", ) if self._has_subquery(stmt): - raise ValueError("Subqueries are not allowed.") + raise DataFabricSqlValidationError( + "Subqueries are not allowed.", code="SQL_SUBQUERY_NOT_ALLOWED" + ) has_where = any(isinstance(t, Where) for t in stmt.tokens) has_limit = "LIMIT" in keywords has_from = "FROM" in keywords if not has_from: - raise ValueError("Queries must include a FROM clause.") + raise DataFabricSqlValidationError( + "Queries must include a FROM clause.", code="SQL_MISSING_FROM" + ) projection = self._projection_tokens(stmt) if self._projection_has_count_star(projection): - raise ValueError( - "COUNT(*) is not supported. Use COUNT(column_name) instead." + raise DataFabricSqlValidationError( + "COUNT(*) is not supported. Use COUNT(column_name) instead.", + code="SQL_COUNT_STAR_NOT_SUPPORTED", ) has_aggregate = self._projection_has_aggregate(projection) if not has_where and not has_limit and not has_aggregate: - raise ValueError("Queries without WHERE must include a LIMIT clause.") + raise DataFabricSqlValidationError( + "Queries without WHERE must include a LIMIT clause.", + code="SQL_LIMIT_REQUIRED", + ) has_bare_wildcard = self._projection_has_bare_wildcard(projection) if has_bare_wildcard: - raise ValueError("SELECT * is not allowed. Specify column names instead.") + raise DataFabricSqlValidationError( + "SELECT * is not allowed. Specify column names instead.", + code="SQL_SELECT_STAR_NOT_ALLOWED", + ) if not has_where and self._projection_column_count(projection) > 4: - raise ValueError( - "Selecting more than 4 columns without filtering is not allowed." + raise DataFabricSqlValidationError( + "Selecting more than 4 columns without filtering is not allowed.", + code="SQL_TOO_MANY_COLUMNS", ) @staticmethod diff --git a/packages/uipath-platform/src/uipath/platform/entities/entities.py b/packages/uipath-platform/src/uipath/platform/entities/entities.py index 44b76e9d4..eacec20b1 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/entities.py +++ b/packages/uipath-platform/src/uipath/platform/entities/entities.py @@ -205,7 +205,11 @@ class FieldMetadata(BaseModel): field_display_type: Optional[str] = Field( default=None, alias="fieldDisplayType" ) # Should be FieldDisplayType enum - choiceset_id: Optional[str] = Field(default=None, alias="choicesetId") + choiceset_id: Optional[str] = Field( + default=None, + validation_alias=AliasChoices("choiceSetId", "choicesetId", "ChoiceSetId"), + alias="choiceSetId", + ) default_value: Optional[str] = Field(default=None, alias="defaultValue") is_attachment: bool = Field(alias="isAttachment") is_rbac_enabled: bool = Field(alias="isRbacEnabled") diff --git a/packages/uipath-platform/src/uipath/platform/errors/_datafabric_error.py b/packages/uipath-platform/src/uipath/platform/errors/_datafabric_error.py index e697ac164..989cc41f6 100644 --- a/packages/uipath-platform/src/uipath/platform/errors/_datafabric_error.py +++ b/packages/uipath-platform/src/uipath/platform/errors/_datafabric_error.py @@ -59,6 +59,48 @@ def is_retryable(self) -> bool: def is_bad_sql(self) -> bool: return self.category == DataFabricErrorCategory.BAD_SQL + @property + def is_unsupported_construct(self) -> bool: + """True when the entity-query subset cannot express this query shape. + + Distinct from :attr:`is_bad_sql`: a bad statement can be fixed by + rewriting the SQL, whereas an unsupported construct means retrying a + variant of the same approach will fail again. + """ + return self.category == DataFabricErrorCategory.UNSUPPORTED_CONSTRUCT + + @staticmethod + def from_exception(exc: BaseException) -> DataFabricError | None: + """Extract a DataFabricError from any Data Fabric query failure. + + Covers both origins of a failed query so callers need one branch: + client-side validation rejections raised before the request, and + server-side errors returned by the query engine. + + Returns None if the exception is not a Data Fabric query failure. + """ + if isinstance(exc, DataFabricSqlValidationError): + return exc.error + from ._enriched_exception import EnrichedException as _EnrichedException + + if isinstance(exc, _EnrichedException): + return DataFabricError.from_enriched_exception(exc) + return None + + @staticmethod + def from_validation(code: str, message: str) -> DataFabricError: + """Build a DataFabricError for a client-side validation rejection. + + These never reach the query engine, so there is no trace id; the code + is classified through the same table as server-returned codes. + """ + return DataFabricError( + code=code, + message=message, + trace_id=None, + category=classify_error_code(code), + ) + @staticmethod def from_enriched_exception(exc: EnrichedException) -> DataFabricError | None: """Extract a DataFabricError from an EnrichedException, if applicable. @@ -107,3 +149,25 @@ def from_response_body(body: dict[str, Any]) -> DataFabricError: trace_id=trace_id, category=classify_error_code(code), ) + + +class DataFabricSqlValidationError(ValueError): + """A SQL statement rejected by client-side entity-query validation. + + A thin carrier: the classification callers act on is the + :class:`DataFabricError` on :attr:`error`, the same type server-side + failures produce. Remains a :class:`ValueError` subclass so existing + callers catching ``ValueError`` are unaffected; reach the structured form + with :meth:`DataFabricError.from_exception`. + """ + + def __init__(self, message: str, *, code: str) -> None: + """Initialise the error. + + Args: + message: Human-readable rejection reason. + code: Stable code for this rejection, classified into a + :class:`DataFabricErrorCategory` by the shared code table. + """ + super().__init__(message) + self.error = DataFabricError.from_validation(code=code, message=message) diff --git a/packages/uipath-platform/src/uipath/platform/errors/datafabric_error_codes.py b/packages/uipath-platform/src/uipath/platform/errors/datafabric_error_codes.py index 81130db95..336c01a64 100644 --- a/packages/uipath-platform/src/uipath/platform/errors/datafabric_error_codes.py +++ b/packages/uipath-platform/src/uipath/platform/errors/datafabric_error_codes.py @@ -16,6 +16,27 @@ { "SQL_PARSING", "SQL_VALIDATION", + # Client-side pre-flight rejections that a rewrite can satisfy. + "SQL_EMPTY", + "SQL_MULTIPLE_STATEMENTS", + "SQL_MISSING_FROM", + "SQL_LIMIT_REQUIRED", + "SQL_SELECT_STAR_NOT_ALLOWED", + "SQL_COUNT_STAR_NOT_SUPPORTED", + "SQL_TOO_MANY_COLUMNS", + } +) + +_UNSUPPORTED_CONSTRUCT_CODES: frozenset[str] = frozenset( + { + # Client-side pre-flight rejections of the query *shape*. The entity + # query subset cannot express these at all, so retrying a variant of + # the same approach fails again — the caller must change strategy or + # tell the user the question is not answerable here. + "SQL_STATEMENT_NOT_SELECT", + "SQL_KEYWORD_NOT_ALLOWED", + "SQL_CONSTRUCT_NOT_ALLOWED", + "SQL_SUBQUERY_NOT_ALLOWED", } ) @@ -39,7 +60,13 @@ ) _QUERY_ENTITY_RECORDS_ERROR_CODES: frozenset[str] = frozenset( - {*_RETRYABLE_CODES, *_BAD_SQL_CODES, *_INFRASTRUCTURE_CODES, *_DATA_ISSUE_CODES} + { + *_RETRYABLE_CODES, + *_BAD_SQL_CODES, + *_UNSUPPORTED_CONSTRUCT_CODES, + *_INFRASTRUCTURE_CODES, + *_DATA_ISSUE_CODES, + } ) @@ -48,6 +75,7 @@ class DataFabricErrorCategory(str, Enum): RETRYABLE = "retryable" BAD_SQL = "bad_sql" + UNSUPPORTED_CONSTRUCT = "unsupported_construct" INFRASTRUCTURE = "infrastructure" DATA_ISSUE = "data_issue" UNKNOWN = "unknown" @@ -62,6 +90,8 @@ def classify_error_code(code: str | None) -> DataFabricErrorCategory: return DataFabricErrorCategory.RETRYABLE if upper in _BAD_SQL_CODES: return DataFabricErrorCategory.BAD_SQL + if upper in _UNSUPPORTED_CONSTRUCT_CODES: + return DataFabricErrorCategory.UNSUPPORTED_CONSTRUCT if upper in _INFRASTRUCTURE_CODES: return DataFabricErrorCategory.INFRASTRUCTURE if upper in _DATA_ISSUE_CODES: diff --git a/packages/uipath-platform/tests/errors/test_datafabric_errors.py b/packages/uipath-platform/tests/errors/test_datafabric_errors.py index 39c477ab5..6214475e7 100644 --- a/packages/uipath-platform/tests/errors/test_datafabric_errors.py +++ b/packages/uipath-platform/tests/errors/test_datafabric_errors.py @@ -9,6 +9,7 @@ DataFabricErrorCategory, EnrichedException, ) +from uipath.platform.errors._datafabric_error import DataFabricSqlValidationError from uipath.platform.errors._extractors._datafabric import extract_datafabric from uipath.platform.errors._extractors._router import extract_error_info from uipath.platform.errors.datafabric_error_codes import classify_error_code @@ -46,6 +47,32 @@ def test_bad_sql_codes(self) -> None: for code in ("SQL_PARSING", "SQL_VALIDATION"): assert classify_error_code(code) == DataFabricErrorCategory.BAD_SQL + def test_client_validation_bad_sql_codes(self) -> None: + """Pre-flight rejections a rewrite can satisfy classify as bad SQL.""" + for code in ( + "SQL_EMPTY", + "SQL_MULTIPLE_STATEMENTS", + "SQL_MISSING_FROM", + "SQL_LIMIT_REQUIRED", + "SQL_SELECT_STAR_NOT_ALLOWED", + "SQL_COUNT_STAR_NOT_SUPPORTED", + "SQL_TOO_MANY_COLUMNS", + ): + assert classify_error_code(code) == DataFabricErrorCategory.BAD_SQL + + def test_unsupported_construct_codes(self) -> None: + """Rejections of the query shape are distinct from fixable bad SQL.""" + for code in ( + "SQL_STATEMENT_NOT_SELECT", + "SQL_KEYWORD_NOT_ALLOWED", + "SQL_CONSTRUCT_NOT_ALLOWED", + "SQL_SUBQUERY_NOT_ALLOWED", + ): + assert ( + classify_error_code(code) + == DataFabricErrorCategory.UNSUPPORTED_CONSTRUCT + ) + def test_infrastructure_codes(self) -> None: for code in ( "SQLITE_MEMORY_FULL", @@ -187,3 +214,62 @@ def test_routes_to_datafabric_extractor(self) -> None: def test_non_json_returns_none(self) -> None: assert extract_error_info(_DATAFABRIC_URL, "not json") is None + + +# ---------- Client-side SQL validation ---------- + + +class TestDataFabricSqlValidationError: + def test_is_a_value_error(self) -> None: + """Callers catching ValueError keep working.""" + exc = DataFabricSqlValidationError( + "Subqueries are not allowed.", code="SQL_SUBQUERY_NOT_ALLOWED" + ) + assert isinstance(exc, ValueError) + assert str(exc) == "Subqueries are not allowed." + + def test_carries_a_datafabric_error(self) -> None: + exc = DataFabricSqlValidationError( + "Subqueries are not allowed.", code="SQL_SUBQUERY_NOT_ALLOWED" + ) + assert exc.error.code == "SQL_SUBQUERY_NOT_ALLOWED" + assert exc.error.message == "Subqueries are not allowed." + assert exc.error.trace_id is None + assert exc.error.category == DataFabricErrorCategory.UNSUPPORTED_CONSTRUCT + assert exc.error.is_unsupported_construct is True + assert exc.error.is_bad_sql is False + + def test_fixable_rejection_is_bad_sql(self) -> None: + exc = DataFabricSqlValidationError( + "Queries without WHERE must include a LIMIT clause.", + code="SQL_LIMIT_REQUIRED", + ) + assert exc.error.is_bad_sql is True + assert exc.error.is_unsupported_construct is False + + +class TestFromException: + def test_extracts_from_validation_error(self) -> None: + exc = DataFabricSqlValidationError( + "SQL construct 'UNION' is not allowed in entity queries.", + code="SQL_CONSTRUCT_NOT_ALLOWED", + ) + err = DataFabricError.from_exception(exc) + assert err is not None + assert err.category == DataFabricErrorCategory.UNSUPPORTED_CONSTRUCT + + def test_extracts_from_enriched_exception(self) -> None: + body = json.dumps( + {"error": "bad sql", "code": "SQL_VALIDATION", "traceId": "t-9"} + ) + err = DataFabricError.from_exception(_make_enriched(body=body)) + assert err is not None + assert err.code == "SQL_VALIDATION" + assert err.category == DataFabricErrorCategory.BAD_SQL + + def test_non_datafabric_enriched_exception_returns_none(self) -> None: + assert DataFabricError.from_exception(_make_enriched(url=_NON_DF_URL)) is None + + def test_unrelated_exception_returns_none(self) -> None: + assert DataFabricError.from_exception(RuntimeError("boom")) is None + assert DataFabricError.from_exception(ValueError("plain")) is None diff --git a/packages/uipath-platform/tests/services/test_entities_service.py b/packages/uipath-platform/tests/services/test_entities_service.py index dc683fb66..cec8cba54 100644 --- a/packages/uipath-platform/tests/services/test_entities_service.py +++ b/packages/uipath-platform/tests/services/test_entities_service.py @@ -16,7 +16,9 @@ from uipath.platform.entities import ChoiceSetValue, DataFabricEntityItem, Entity from uipath.platform.entities._entities_service import EntitiesService from uipath.platform.entities._entity_data_service import EntityDataService +from uipath.platform.entities.entities import FieldMetadata from uipath.platform.errors import EnrichedException +from uipath.platform.errors._datafabric_error import DataFabricSqlValidationError @pytest.fixture @@ -446,6 +448,44 @@ def test_validate_sql_query_rejects_disallowed_queries( with pytest.raises(ValueError, match=re.escape(error_message)): service._data._validate_sql_query(sql_query) + @pytest.mark.parametrize( + "sql_query,expected_code", + [ + ("", "SQL_EMPTY"), + ("INSERT INTO Customers VALUES (1)", "SQL_STATEMENT_NOT_SELECT"), + ( + "SELECT id FROM (SELECT id FROM Customers) c", + "SQL_SUBQUERY_NOT_ALLOWED", + ), + ("SELECT 1 LIMIT 1", "SQL_MISSING_FROM"), + ( + "SELECT COUNT(*) FROM Customers", + "SQL_COUNT_STAR_NOT_SUPPORTED", + ), + ( + "SELECT * FROM Customers LIMIT 10", + "SQL_SELECT_STAR_NOT_ALLOWED", + ), + ( + "SELECT id FROM Customers", + "SQL_LIMIT_REQUIRED", + ), + ( + "SELECT id, name, email, phone, address FROM Customers LIMIT 10", + "SQL_TOO_MANY_COLUMNS", + ), + ], + ) + def test_validate_sql_query_raises_datafabric_error_with_code( + self, + sql_query: str, + expected_code: str, + service: EntitiesService, + ) -> None: + with pytest.raises(DataFabricSqlValidationError) as exc_info: + service._data._validate_sql_query(sql_query) + assert exc_info.value.error.code == expected_code + def test_query_entity_records_rejects_invalid_sql_before_network_call( self, service: EntitiesService, @@ -489,6 +529,43 @@ def test_query_entity_records_sets_relationships_as_scalar_option_when_true( body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") assert body["queryOptions"] == {"relationshipsAsScalar": True} + def test_query_entity_records_sets_resolve_choice_sets_option_when_true( + self, + service: EntitiesService, + ) -> None: + response = MagicMock() + response.json.return_value = {"results": []} + service._data.request = MagicMock(return_value=response) # type: ignore[method-assign] + + service.query_entity_records( + "SELECT id FROM Customers WHERE id > 0", resolve_choice_sets=True + ) + + call_kwargs = service._data.request.call_args + body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert body["queryOptions"] == {"resolveChoiceSets": True} + + def test_query_entity_records_sets_both_query_options( + self, + service: EntitiesService, + ) -> None: + response = MagicMock() + response.json.return_value = {"results": []} + service._data.request = MagicMock(return_value=response) # type: ignore[method-assign] + + service.query_entity_records( + "SELECT id FROM Customers WHERE id > 0", + relationships_as_scalar=True, + resolve_choice_sets=True, + ) + + call_kwargs = service._data.request.call_args + body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert body["queryOptions"] == { + "relationshipsAsScalar": True, + "resolveChoiceSets": True, + } + def test_query_entity_records_omits_query_options_by_default( self, service: EntitiesService, @@ -560,6 +637,45 @@ async def test_query_entity_records_async_sets_relationships_as_scalar_option( body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") assert body["queryOptions"] == {"relationshipsAsScalar": True} + @pytest.mark.anyio + async def test_query_entity_records_async_sets_resolve_choice_sets_option( + self, + service: EntitiesService, + ) -> None: + response = MagicMock() + response.json.return_value = {"results": []} + service._data.request_async = AsyncMock(return_value=response) # type: ignore[method-assign] + + await service.query_entity_records_async( + "SELECT id FROM Customers WHERE id > 0", resolve_choice_sets=True + ) + + call_kwargs = service._data.request_async.call_args + body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert body["queryOptions"] == {"resolveChoiceSets": True} + + @pytest.mark.anyio + async def test_query_entity_records_async_sets_both_query_options( + self, + service: EntitiesService, + ) -> None: + response = MagicMock() + response.json.return_value = {"results": []} + service._data.request_async = AsyncMock(return_value=response) # type: ignore[method-assign] + + await service.query_entity_records_async( + "SELECT id FROM Customers WHERE id > 0", + relationships_as_scalar=True, + resolve_choice_sets=True, + ) + + call_kwargs = service._data.request_async.call_args + body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json") + assert body["queryOptions"] == { + "relationshipsAsScalar": True, + "resolveChoiceSets": True, + } + def test_query_entity_records_builds_routing_context_from_folders_map( self, config: UiPathApiConfig, @@ -1183,6 +1299,51 @@ def test_get_choiceset_values_empty( assert values == [] +class TestFieldMetadataChoiceSetAlias: + """Verify FieldMetadata.choiceset_id accepts all server-side casing variants.""" + + _BASE_PAYLOAD: dict[str, object] = { + "name": "status", + "isPrimaryKey": False, + "isForeignKey": False, + "isExternalField": False, + "isHiddenField": False, + "isUnique": False, + "sqlType": {"name": "NVARCHAR"}, + "isRequired": False, + "displayName": "Status", + "isSystemField": False, + "isAttachment": False, + "isRbacEnabled": False, + } + + def test_parses_camel_case_choiceSetId(self) -> None: + payload = {**self._BASE_PAYLOAD, "choiceSetId": "cs-1"} + meta = FieldMetadata.model_validate(payload) + assert meta.choiceset_id == "cs-1" + + def test_parses_lowercase_choicesetId(self) -> None: + payload = {**self._BASE_PAYLOAD, "choicesetId": "cs-2"} + meta = FieldMetadata.model_validate(payload) + assert meta.choiceset_id == "cs-2" + + def test_parses_pascal_case_ChoiceSetId(self) -> None: + payload = {**self._BASE_PAYLOAD, "ChoiceSetId": "cs-3"} + meta = FieldMetadata.model_validate(payload) + assert meta.choiceset_id == "cs-3" + + def test_defaults_to_none_when_absent(self) -> None: + meta = FieldMetadata.model_validate(self._BASE_PAYLOAD) + assert meta.choiceset_id is None + + def test_serializes_as_choiceSetId(self) -> None: + payload = {**self._BASE_PAYLOAD, "choicesetId": "cs-4"} + meta = FieldMetadata.model_validate(payload) + dumped = meta.model_dump(by_alias=True) + assert "choiceSetId" in dumped + assert dumped["choiceSetId"] == "cs-4" + + class TestEntitiesServiceNewMethods: """Single-record, structured-query, attachment, schema and bulk-import tests.""" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 7402b42b4..93559e572 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.27" +version = "0.2.28" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index adc2c346e..18734c70a 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.27" +version = "0.2.28" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" },