Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/uipath-platform/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-platform"
version = "0.2.18"
version = "0.2.19"
description = "HTTP client library for programmatic access to UiPath Platform"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
EntityField,
EntityFieldDataType,
EntityFieldMetadata,
EntityHavingCondition,
EntityHavingFilter,
EntityHavingOperator,
EntityImportRecordsResponse,
EntityJoin,
EntityMetadataUpdateOptions,
Expand Down Expand Up @@ -58,6 +61,9 @@
"EntityField",
"EntityFieldDataType",
"EntityFieldMetadata",
"EntityHavingCondition",
"EntityHavingFilter",
"EntityHavingOperator",
"EntityImportRecordsResponse",
"EntityJoin",
"EntityMetadataUpdateOptions",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
EntityBinning,
EntityCreateFieldOptions,
EntityCreateOptions,
EntityHavingFilter,
EntityImportRecordsResponse,
EntityJoin,
EntityMetadataUpdateOptions,
Expand Down Expand Up @@ -1627,6 +1628,7 @@ def retrieve_records(
expansion_level: Optional[int] = None,
aggregates: Optional[List[EntityAggregate]] = None,
group_by: Optional[List[str]] = None,
having_filter: Optional[EntityHavingFilter] = None,
joins: Optional[List[EntityJoin]] = None,
binnings: Optional[List[EntityBinning]] = None,
start: Optional[int] = None,
Expand Down Expand Up @@ -1656,6 +1658,11 @@ def retrieve_records(
group_by (Optional[List[str]]): Fields to group aggregate results
by. Maximum 5; required when both ``aggregates`` and
``selected_fields`` are supplied.
having_filter (Optional[EntityHavingFilter]): Post-aggregation
filter (SQL ``HAVING``) on declared aggregate aliases;
requires ``aggregates`` and ``group_by``. Maximum 5
conditions. Native entities only; gated by the
``enable-having-on-query`` feature flag on the backend.
joins (Optional[List[EntityJoin]]): Cross-entity joins. Maximum
3, all of the same type.
binnings (Optional[List[EntityBinning]]): Bucket numeric or date
Expand Down Expand Up @@ -1726,6 +1733,38 @@ def retrieve_records(
)
for row in result.items:
print(row.status, row.total)

HAVING (keep only statuses with more than 5 records)::

from uipath.platform.entities import (
EntityAggregate,
EntityAggregateFunction,
EntityHavingCondition,
EntityHavingFilter,
EntityHavingOperator,
)

result = entities_service.retrieve_records(
"Customers",
selected_fields=["status"],
group_by=["status"],
aggregates=[
EntityAggregate(
function=EntityAggregateFunction.Count,
field="Id",
alias="total",
)
],
having_filter=EntityHavingFilter(
aggregate_filters=[
EntityHavingCondition(
aggregate_alias="total",
operator=EntityHavingOperator.GreaterThan,
value="5",
)
],
),
)
"""
return self._data.retrieve_records(
entity_key,
Expand All @@ -1736,6 +1775,7 @@ def retrieve_records(
expansion_level=expansion_level,
aggregates=aggregates,
group_by=group_by,
having_filter=having_filter,
joins=joins,
binnings=binnings,
start=start,
Expand All @@ -1753,6 +1793,7 @@ async def retrieve_records_async(
expansion_level: Optional[int] = None,
aggregates: Optional[List[EntityAggregate]] = None,
group_by: Optional[List[str]] = None,
having_filter: Optional[EntityHavingFilter] = None,
joins: Optional[List[EntityJoin]] = None,
binnings: Optional[List[EntityBinning]] = None,
start: Optional[int] = None,
Expand Down Expand Up @@ -1780,6 +1821,11 @@ async def retrieve_records_async(
group_by (Optional[List[str]]): Fields to group aggregate results
by. Maximum 5; required when both ``aggregates`` and
``selected_fields`` are supplied.
having_filter (Optional[EntityHavingFilter]): Post-aggregation
filter (SQL ``HAVING``) on declared aggregate aliases;
requires ``aggregates`` and ``group_by``. Maximum 5
conditions. Native entities only; gated by the
``enable-having-on-query`` feature flag on the backend.
joins (Optional[List[EntityJoin]]): Cross-entity joins. Maximum
3, all of the same type.
binnings (Optional[List[EntityBinning]]): Bucket numeric or date
Expand Down Expand Up @@ -1825,6 +1871,7 @@ async def retrieve_records_async(
expansion_level=expansion_level,
aggregates=aggregates,
group_by=group_by,
having_filter=having_filter,
joins=joins,
binnings=binnings,
start=start,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
ChoiceSetValue,
EntityAggregate,
EntityBinning,
EntityHavingFilter,
EntityImportRecordsResponse,
EntityJoin,
EntityQueryFilterGroup,
Expand Down Expand Up @@ -451,6 +452,7 @@ def retrieve_records(
expansion_level: Optional[int] = None,
aggregates: Optional[List[EntityAggregate]] = None,
group_by: Optional[List[str]] = None,
having_filter: Optional[EntityHavingFilter] = None,
joins: Optional[List[EntityJoin]] = None,
binnings: Optional[List[EntityBinning]] = None,
start: Optional[int] = None,
Expand All @@ -466,6 +468,7 @@ def retrieve_records(
expansion_level=expansion_level,
aggregates=aggregates,
group_by=group_by,
having_filter=having_filter,
joins=joins,
binnings=binnings,
start=start,
Expand All @@ -486,6 +489,7 @@ async def retrieve_records_async(
expansion_level: Optional[int] = None,
aggregates: Optional[List[EntityAggregate]] = None,
group_by: Optional[List[str]] = None,
having_filter: Optional[EntityHavingFilter] = None,
joins: Optional[List[EntityJoin]] = None,
binnings: Optional[List[EntityBinning]] = None,
start: Optional[int] = None,
Expand All @@ -501,6 +505,7 @@ async def retrieve_records_async(
expansion_level=expansion_level,
aggregates=aggregates,
group_by=group_by,
having_filter=having_filter,
joins=joins,
binnings=binnings,
start=start,
Expand Down Expand Up @@ -890,15 +895,17 @@ def _retrieve_records_spec(
expansion_level: Optional[int] = None,
aggregates: Optional[List[Any]] = None,
group_by: Optional[List[str]] = None,
having_filter: Optional[EntityHavingFilter] = None,
joins: Optional[List[EntityJoin]] = None,
binnings: Optional[List[EntityBinning]] = None,
start: Optional[int] = None,
limit: Optional[int] = None,
) -> RequestSpec:
"""Build the request spec for the structured-query endpoint.

Filters, sorting, projection, expansions, aggregates, group-by, joins,
binnings, ``start``, and ``limit`` are placed in the JSON body;
Filters, sorting, projection, expansions, aggregates, group-by,
having, joins, binnings, ``start``, and ``limit`` are placed in the
JSON body;
``expansionLevel`` is a URL query parameter. The V2 endpoint is used
only when ``binnings`` are supplied.
"""
Expand Down Expand Up @@ -929,6 +936,15 @@ def _retrieve_records_spec(
]
if group_by:
body["groupBy"] = list(group_by)
if having_filter is not None:
if not aggregates or not group_by:
raise ValueError(
"having_filter requires aggregates and group_by; "
"row-level conditions belong in filter_group."
)
body["havingFilter"] = having_filter.model_dump(
by_alias=True, exclude_none=True
)
if joins:
body["joins"] = [
j.model_dump(by_alias=True, exclude_none=True) for j in joins
Expand Down
41 changes: 41 additions & 0 deletions packages/uipath-platform/src/uipath/platform/entities/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,47 @@ class EntityAggregate(BaseModel):
alias: Optional[str] = None


class EntityHavingOperator(str, Enum):
"""Comparison operators supported in HAVING conditions."""

Equals = "="
NotEquals = "!="
GreaterThan = ">"
GreaterThanOrEqual = ">="
LessThan = "<"
LessThanOrEqual = "<="


class EntityHavingCondition(BaseModel):
"""A single HAVING condition on a declared aggregate alias."""

model_config = ConfigDict(validate_by_name=True, validate_by_alias=True)

aggregate_alias: str = Field(alias="aggregateAlias")
operator: EntityHavingOperator
value: str


class EntityHavingFilter(BaseModel):
"""Post-aggregation filter (SQL ``HAVING``) over declared aggregate aliases.

Conditions may reference aggregate aliases only; row-level conditions
belong in ``filter_group``, which the database applies before grouping.
Supported for native entities only and gated by the
``enable-having-on-query`` feature flag on the backend. Maximum 5
conditions per query.
"""

model_config = ConfigDict(validate_by_name=True, validate_by_alias=True)

logical_operator: Optional[LogicalOperator] = Field(
default=None, alias="logicalOperator"
)
aggregate_filters: List[EntityHavingCondition] = Field(
alias="aggregateFilters", min_length=1
)


class EntityJoin(BaseModel):
"""Multi-entity JOIN definition for cross-entity queries."""

Expand Down
85 changes: 85 additions & 0 deletions packages/uipath-platform/tests/services/test_entities_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1475,6 +1475,91 @@ def test_query_v2_when_binnings_provided(
assert sent is not None
assert "/v2/EntityService/" in str(sent.url)

def test_query_having_filter_sent_in_body(
self,
httpx_mock: HTTPXMock,
service: EntitiesService,
base_url: str,
org: str,
tenant: str,
version: str,
) -> None:
"""``having_filter`` is serialized with camelCase wire keys."""
from uipath.platform.entities import (
EntityAggregate,
EntityAggregateFunction,
EntityHavingCondition,
EntityHavingFilter,
EntityHavingOperator,
LogicalOperator,
)

entity_key = uuid.uuid4()
httpx_mock.add_response(
url=re.compile(
rf"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{entity_key}/query.*"
),
status_code=200,
json={"value": [{"status": "active", "total": 12}], "totalRecordCount": 1},
)

service.retrieve_records(
entity_key=str(entity_key),
selected_fields=["status"],
group_by=["status"],
aggregates=[
EntityAggregate(
function=EntityAggregateFunction.Count,
field="Id",
alias="total",
)
],
having_filter=EntityHavingFilter(
logical_operator=LogicalOperator.And,
aggregate_filters=[
EntityHavingCondition(
aggregate_alias="total",
operator=EntityHavingOperator.GreaterThan,
value="5",
)
],
),
)

sent = httpx_mock.get_request()
assert sent is not None
body = json.loads(sent.content)
assert body["havingFilter"] == {
"logicalOperator": 0,
"aggregateFilters": [
{"aggregateAlias": "total", "operator": ">", "value": "5"}
],
}
Comment on lines +1531 to +1537

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hard-coded 0 is deliberate here: this test pins the exact wire contract the server sees. If it asserted LogicalOperator.And.value, the test would keep passing even if the enum's value were accidentally changed, which is precisely the regression it should catch.


def test_query_having_filter_requires_aggregates_and_group_by(
self,
service: EntitiesService,
) -> None:
"""``having_filter`` without aggregates + group_by fails locally, no HTTP."""
from uipath.platform.entities import (
EntityHavingCondition,
EntityHavingFilter,
EntityHavingOperator,
)

having = EntityHavingFilter(
aggregate_filters=[
EntityHavingCondition(
aggregate_alias="total",
operator=EntityHavingOperator.GreaterThan,
value="5",
)
],
)

with pytest.raises(ValueError, match="aggregates and group_by"):
service.retrieve_records(entity_key="ent-1", having_filter=having)

def test_upload_attachment_sends_multipart(
self,
httpx_mock: HTTPXMock,
Expand Down
2 changes: 1 addition & 1 deletion packages/uipath-platform/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/uipath/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading