From 978051ae4665de943078d74aa891777d51011cdf Mon Sep 17 00:00:00 2001 From: aayushuipath Date: Fri, 14 Aug 2026 11:24:28 +0530 Subject: [PATCH 1/3] feat(entities): support havingFilter post-aggregation filter [DS-9078] --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/entities/__init__.py | 6 ++ .../platform/entities/_entities_service.py | 47 ++++++++++ .../platform/entities/_entity_data_service.py | 20 ++++- .../src/uipath/platform/entities/entities.py | 41 +++++++++ .../tests/services/test_entities_service.py | 85 +++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 8 files changed, 200 insertions(+), 5 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index b12e287e3..97528d9be 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -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" diff --git a/packages/uipath-platform/src/uipath/platform/entities/__init__.py b/packages/uipath-platform/src/uipath/platform/entities/__init__.py index 8c323eca3..16d208848 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/entities/__init__.py @@ -18,6 +18,9 @@ EntityField, EntityFieldDataType, EntityFieldMetadata, + EntityHavingCondition, + EntityHavingFilter, + EntityHavingOperator, EntityImportRecordsResponse, EntityJoin, EntityMetadataUpdateOptions, @@ -58,6 +61,9 @@ "EntityField", "EntityFieldDataType", "EntityFieldMetadata", + "EntityHavingCondition", + "EntityHavingFilter", + "EntityHavingOperator", "EntityImportRecordsResponse", "EntityJoin", "EntityMetadataUpdateOptions", 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 fa2845e63..e72d5e04c 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py @@ -45,6 +45,7 @@ EntityBinning, EntityCreateFieldOptions, EntityCreateOptions, + EntityHavingFilter, EntityImportRecordsResponse, EntityJoin, EntityMetadataUpdateOptions, @@ -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, @@ -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 @@ -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, @@ -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, @@ -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, @@ -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 @@ -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, 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 4dc09855d..8894b33e5 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 @@ -30,6 +30,7 @@ ChoiceSetValue, EntityAggregate, EntityBinning, + EntityHavingFilter, EntityImportRecordsResponse, EntityJoin, EntityQueryFilterGroup, @@ -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, @@ -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, @@ -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, @@ -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, @@ -890,6 +895,7 @@ 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, @@ -897,8 +903,9 @@ def _retrieve_records_spec( ) -> 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. """ @@ -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 diff --git a/packages/uipath-platform/src/uipath/platform/entities/entities.py b/packages/uipath-platform/src/uipath/platform/entities/entities.py index 51eea2d1b..a2159707e 100644 --- a/packages/uipath-platform/src/uipath/platform/entities/entities.py +++ b/packages/uipath-platform/src/uipath/platform/entities/entities.py @@ -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.""" diff --git a/packages/uipath-platform/tests/services/test_entities_service.py b/packages/uipath-platform/tests/services/test_entities_service.py index 9a63bbc46..3d6b7554b 100644 --- a/packages/uipath-platform/tests/services/test_entities_service.py +++ b/packages/uipath-platform/tests/services/test_entities_service.py @@ -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"} + ], + } + + 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, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 99067a7a6..68123d499 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.18" +version = "0.2.19" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index f9c60b86a..04bcd6311 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2760,7 +2760,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.18" +version = "0.2.19" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From 0406ac33cbad8b6a8fe557346e5ab138a04512d3 Mon Sep 17 00:00:00 2001 From: aayushuipath Date: Fri, 14 Aug 2026 11:34:40 +0530 Subject: [PATCH 2/3] chore(entities): defer version bump to a follow-up release pr --- packages/uipath-platform/pyproject.toml | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 97528d9be..b12e287e3 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.19" +version = "0.2.18" 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/uv.lock b/packages/uipath-platform/uv.lock index 68123d499..99067a7a6 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.19" +version = "0.2.18" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 04bcd6311..f9c60b86a 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2760,7 +2760,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.19" +version = "0.2.18" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From 00313b68ea30f5d34a23d6d73f37c43bd6ee98cd Mon Sep 17 00:00:00 2001 From: aayushuipath Date: Fri, 14 Aug 2026 12:03:51 +0530 Subject: [PATCH 3/3] chore(entities): restore version bump, check-version-availability requires it --- packages/uipath-platform/pyproject.toml | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index b12e287e3..97528d9be 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -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" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 99067a7a6..68123d499 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.18" +version = "0.2.19" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index f9c60b86a..04bcd6311 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2760,7 +2760,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.18" +version = "0.2.19" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" },