Skip to content

Commit 684009f

Browse files
feat(entities): relationships_as_scalar query option for FK joins (#1818)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d190868 commit 684009f

6 files changed

Lines changed: 71 additions & 36 deletions

File tree

packages/uipath-platform/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-platform"
3-
version = "0.2.11"
3+
version = "0.2.12"
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"

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

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1821,7 +1821,7 @@ async def retrieve_records_async(
18211821
@attach_datafabric_error_mapping("query_entity_records")
18221822
@traced(name="entity_query_records", run_type="uipath")
18231823
def query_entity_records(
1824-
self, sql_query: str, source: Optional[str] = None
1824+
self, sql_query: str, *, relationships_as_scalar: bool = False
18251825
) -> List[Dict[str, Any]]:
18261826
"""Query entity records using a validated SQL query.
18271827
@@ -1831,9 +1831,11 @@ def query_entity_records(
18311831
sql_query (str): A SQL SELECT query to execute against Data Service entities.
18321832
Only SELECT statements are allowed. Queries without WHERE must include
18331833
a LIMIT clause. Subqueries and multi-statement queries are not permitted.
1834-
source (str, optional): Value for the ``x-uipath-source`` header on the
1835-
query/execute request, identifying the calling surface (e.g.
1836-
``"LOW_CODE_AGENT"``). Omitted from the request when not set.
1834+
relationships_as_scalar (bool, optional): When ``True``, relationship (FK)
1835+
fields are typed as their underlying scalar id instead of ``VARIANT``,
1836+
so a query can join on ``relationshipField = Other.Id``. Sent as
1837+
``queryOptions.relationshipsAsScalar`` in the request body. Defaults to
1838+
``False`` (unchanged behaviour).
18371839
18381840
Notes:
18391841
A routing context is always derived from the configured ``folders_map``
@@ -1846,12 +1848,12 @@ def query_entity_records(
18461848
ValueError: If the SQL query fails validation (e.g., non-SELECT, missing
18471849
WHERE/LIMIT, forbidden keywords, subqueries).
18481850
"""
1849-
return self._data.query_entity_records(sql_query, source)
1851+
return self._data.query_entity_records(sql_query, relationships_as_scalar)
18501852

18511853
@attach_datafabric_error_mapping("query_entity_records_async")
18521854
@traced(name="entity_query_records", run_type="uipath")
18531855
async def query_entity_records_async(
1854-
self, sql_query: str, source: Optional[str] = None
1856+
self, sql_query: str, *, relationships_as_scalar: bool = False
18551857
) -> List[Dict[str, Any]]:
18561858
"""Asynchronously query entity records using a validated SQL query.
18571859
@@ -1861,9 +1863,11 @@ async def query_entity_records_async(
18611863
sql_query (str): A SQL SELECT query to execute against Data Service entities.
18621864
Only SELECT statements are allowed. Queries without WHERE must include
18631865
a LIMIT clause. Subqueries and multi-statement queries are not permitted.
1864-
source (str, optional): Value for the ``x-uipath-source`` header on the
1865-
query/execute request, identifying the calling surface (e.g.
1866-
``"LOW_CODE_AGENT"``). Omitted from the request when not set.
1866+
relationships_as_scalar (bool, optional): When ``True``, relationship (FK)
1867+
fields are typed as their underlying scalar id instead of ``VARIANT``,
1868+
so a query can join on ``relationshipField = Other.Id``. Sent as
1869+
``queryOptions.relationshipsAsScalar`` in the request body. Defaults to
1870+
``False`` (unchanged behaviour).
18671871
18681872
Notes:
18691873
A routing context is always derived from the configured ``folders_map``
@@ -1876,7 +1880,9 @@ async def query_entity_records_async(
18761880
ValueError: If the SQL query fails validation (e.g., non-SELECT, missing
18771881
WHERE/LIMIT, forbidden keywords, subqueries).
18781882
"""
1879-
return await self._data.query_entity_records_async(sql_query, source)
1883+
return await self._data.query_entity_records_async(
1884+
sql_query, relationships_as_scalar
1885+
)
18801886

18811887
@traced(name="entity_upload_attachment", run_type="uipath")
18821888
def upload_attachment(

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

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
from ..common._config import UiPathApiConfig
2323
from ..common._execution_context import UiPathExecutionContext
2424
from ..common._models import Endpoint, RequestSpec
25-
from ..constants import HEADER_SOURCE
2625
from ..errors._enriched_exception import EnrichedException
2726
from ..orchestrator._folder_service import FolderService
2827
from ._entity_resolution import RoutingStrategy, create_routing_strategy
@@ -519,18 +518,20 @@ async def retrieve_records_async(
519518
def query_entity_records(
520519
self,
521520
sql_query: str,
522-
source: Optional[str] = None,
521+
relationships_as_scalar: bool = False,
523522
) -> List[Dict[str, Any]]:
524523
"""Internal implementation; see :meth:`EntitiesService.query_entity_records`."""
525-
return self._query_entities_for_records(sql_query, source)
524+
return self._query_entities_for_records(sql_query, relationships_as_scalar)
526525

527526
async def query_entity_records_async(
528527
self,
529528
sql_query: str,
530-
source: Optional[str] = None,
529+
relationships_as_scalar: bool = False,
531530
) -> List[Dict[str, Any]]:
532531
"""Async variant of :meth:`query_entity_records`."""
533-
return await self._query_entities_for_records_async(sql_query, source)
532+
return await self._query_entities_for_records_async(
533+
sql_query, relationships_as_scalar
534+
)
534535

535536
# ------------------------------------------------------------------
536537
# Attachments
@@ -684,27 +685,27 @@ def validate_entity_batch(
684685
# ------------------------------------------------------------------
685686

686687
def _query_entities_for_records(
687-
self, sql_query: str, source: Optional[str] = None
688+
self, sql_query: str, relationships_as_scalar: bool = False
688689
) -> List[Dict[str, Any]]:
689690
"""Synchronously run a validated SQL query through the federated query engine."""
690691
self._validate_sql_query(sql_query)
691692
routing_context = self._routing_strategy.resolve()
692-
spec = self._query_entity_records_spec(sql_query, routing_context, source)
693-
response = self.request(
694-
spec.method, spec.endpoint, json=spec.json, headers=spec.headers
693+
spec = self._query_entity_records_spec(
694+
sql_query, routing_context, relationships_as_scalar
695695
)
696+
response = self.request(spec.method, spec.endpoint, json=spec.json)
696697
return response.json().get("results", [])
697698

698699
async def _query_entities_for_records_async(
699-
self, sql_query: str, source: Optional[str] = None
700+
self, sql_query: str, relationships_as_scalar: bool = False
700701
) -> List[Dict[str, Any]]:
701702
"""Asynchronously run a validated SQL query through the federated query engine."""
702703
self._validate_sql_query(sql_query)
703704
routing_context = await self._routing_strategy.resolve_async()
704-
spec = self._query_entity_records_spec(sql_query, routing_context, source)
705-
response = await self.request_async(
706-
spec.method, spec.endpoint, json=spec.json, headers=spec.headers
705+
spec = self._query_entity_records_spec(
706+
sql_query, routing_context, relationships_as_scalar
707707
)
708+
response = await self.request_async(spec.method, spec.endpoint, json=spec.json)
708709
return response.json().get("results", [])
709710

710711
@staticmethod
@@ -965,20 +966,20 @@ def _retrieve_records_spec(
965966
def _query_entity_records_spec(
966967
sql_query: str,
967968
routing_context: Optional[QueryRoutingOverrideContext] = None,
968-
source: Optional[str] = None,
969+
relationships_as_scalar: bool = False,
969970
) -> RequestSpec:
970971
"""Build the POST spec for the federated SQL query endpoint."""
971972
body: Dict[str, Any] = {"query": sql_query}
972973
if routing_context:
973974
body["routingContext"] = routing_context.model_dump(
974975
by_alias=True, exclude_none=True
975976
)
976-
headers = {HEADER_SOURCE: source} if source else {}
977+
if relationships_as_scalar:
978+
body["queryOptions"] = {"relationshipsAsScalar": True}
977979
return RequestSpec(
978980
method="POST",
979981
endpoint=Endpoint("datafabric_/api/v1/query/execute"),
980982
json=body,
981-
headers=headers,
982983
)
983984

984985
@staticmethod

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

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,7 @@ def test_query_entity_records_calls_request_for_valid_sql(
459459
assert result == [{"id": 1}, {"id": 2}]
460460
service._data.request.assert_called_once()
461461

462-
def test_query_entity_records_sets_source_header_when_provided(
462+
def test_query_entity_records_sets_relationships_as_scalar_option_when_true(
463463
self,
464464
service: EntitiesService,
465465
) -> None:
@@ -468,13 +468,14 @@ def test_query_entity_records_sets_source_header_when_provided(
468468
service._data.request = MagicMock(return_value=response) # type: ignore[method-assign]
469469

470470
service.query_entity_records(
471-
"SELECT id FROM Customers WHERE id > 0", source="LOW_CODE_AGENT"
471+
"SELECT id FROM Customers WHERE id > 0", relationships_as_scalar=True
472472
)
473473

474-
headers = service._data.request.call_args.kwargs.get("headers") or {}
475-
assert headers.get("x-uipath-source") == "LOW_CODE_AGENT"
474+
call_kwargs = service._data.request.call_args
475+
body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
476+
assert body["queryOptions"] == {"relationshipsAsScalar": True}
476477

477-
def test_query_entity_records_omits_source_header_by_default(
478+
def test_query_entity_records_omits_query_options_by_default(
478479
self,
479480
service: EntitiesService,
480481
) -> None:
@@ -484,8 +485,18 @@ def test_query_entity_records_omits_source_header_by_default(
484485

485486
service.query_entity_records("SELECT id FROM Customers WHERE id > 0")
486487

487-
headers = service._data.request.call_args.kwargs.get("headers") or {}
488-
assert "x-uipath-source" not in headers
488+
call_kwargs = service._data.request.call_args
489+
body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
490+
assert "queryOptions" not in body
491+
492+
def test_query_entity_records_flag_is_keyword_only(
493+
self,
494+
service: EntitiesService,
495+
) -> None:
496+
# A second positional arg (as an old ``source`` call would pass) must be
497+
# rejected rather than silently coerced into ``relationships_as_scalar``.
498+
with pytest.raises(TypeError):
499+
service.query_entity_records("SELECT id FROM Customers LIMIT 10", True)
489500

490501
@pytest.mark.anyio
491502
async def test_query_entity_records_async_rejects_invalid_sql_before_network_call(
@@ -518,6 +529,23 @@ async def test_query_entity_records_async_calls_request_for_valid_sql(
518529
assert result == [{"id": "c1"}]
519530
service._data.request_async.assert_called_once()
520531

532+
@pytest.mark.anyio
533+
async def test_query_entity_records_async_sets_relationships_as_scalar_option(
534+
self,
535+
service: EntitiesService,
536+
) -> None:
537+
response = MagicMock()
538+
response.json.return_value = {"results": []}
539+
service._data.request_async = AsyncMock(return_value=response) # type: ignore[method-assign]
540+
541+
await service.query_entity_records_async(
542+
"SELECT id FROM Customers WHERE id > 0", relationships_as_scalar=True
543+
)
544+
545+
call_kwargs = service._data.request_async.call_args
546+
body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
547+
assert body["queryOptions"] == {"relationshipsAsScalar": True}
548+
521549
def test_query_entity_records_builds_routing_context_from_folders_map(
522550
self,
523551
config: UiPathApiConfig,

packages/uipath-platform/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/uipath/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)