diff --git a/dp3/api/routers/entity.py b/dp3/api/routers/entity.py index 7b3b5f52..e5c4231b 100644 --- a/dp3/api/routers/entity.py +++ b/dp3/api/routers/entity.py @@ -144,6 +144,7 @@ def _validate_sort_params(etype: str, sort: list[str] | None) -> list[tuple[str, sort: list of sort specifications in format 'attribute:direction' where direction is 1 (ascending) or -1 (descending) e.g., ['hostname:-1', 'ip:1'] + Special attribute 'eid' is also supported for sorting by entity ID. Returns: List of (attribute, direction) tuples for sorting, or None if no sorting specified @@ -172,6 +173,11 @@ def _validate_sort_params(etype: str, sort: list[str] | None) -> list[tuple[str, attr, direction_str = match.groups() direction = int(direction_str) if direction_str else 1 # Default to ascending (1) + # Support sorting by entity ID using pseudo-attribute 'eid' + if attr == "eid": + sort_criteria.append((attr, direction)) + continue + # Check if attribute exists if attr not in entity_attribs: raise RequestValidationError( @@ -306,11 +312,13 @@ async def get_entity_type_eids( Generic and fulltext filters are merged - fulltext overrides conflicting keys. - Sorting is supported for plain and observations attributes with primitive data types - (excluding json and multi_value observations). To sort by multiple attributes, provide - multiple sort parameters in the format 'attribute:direction' where direction is 1 (ascending) - or -1 (descending). Direction defaults to 1 (ascending) if not provided. Example: - `?sort=hostname:-1&sort=ip:1` + Sorting is supported by entity ID using the special attribute name `eid`, + as well as for plain and observations attributes with primitive data types + (excluding json and multi_value observations). To sort by multiple attributes, + provide multiple sort parameters in the format 'attribute:direction' where + direction is 1 (ascending) or -1 (descending). Direction defaults to 1 (ascending) + if not provided. Examples: + `?sort=eid:1`, `?sort=hostname:-1&sort=ip:1` """ fulltext_filters, generic_filter = _validate_snapshot_filters(fulltext_filters, generic_filter) sort_criteria = _validate_sort_params(etype, sort) diff --git a/tests/test_api/test_get_entity_eids.py b/tests/test_api/test_get_entity_eids.py index 43cd2b92..a8cc1f72 100644 --- a/tests/test_api/test_get_entity_eids.py +++ b/tests/test_api/test_get_entity_eids.py @@ -60,3 +60,69 @@ def test_get_entity_eids_generic_filters_eid(self): {5, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59}, {x["eid"] for x in eids.data}, ) + + def test_get_entity_eids_sort_by_eid_asc(self): + eids = self.get_entity_data("entity/A/get", EntityEidList, sort="eid:1", limit=0) + self.assertEqual(100, len(eids.data)) + self.assertEqual(list(range(100)), [x["eid"] for x in eids.data]) + + def test_get_entity_eids_sort_by_eid_desc(self): + eids = self.get_entity_data("entity/A/get", EntityEidList, sort="eid:-1", limit=0) + self.assertEqual(100, len(eids.data)) + self.assertEqual(list(range(99, -1, -1)), [x["eid"] for x in eids.data]) + + def test_get_entity_eids_pagination_with_sort(self): + received_eids = [] + for i in range(0, 100, 10): + eids = self.get_entity_data( + "entity/A/get", EntityEidList, sort="eid:1", skip=i, limit=10 + ) + self.assertEqual(10, len(eids.data), f"Failed at {i}") + received_eids.extend(x["eid"] for x in eids.data) + self.assertEqual(list(range(100)), received_eids) + + received_eids = [] + for i in range(0, 100, 10): + eids = self.get_entity_data( + "entity/A/get", EntityEidList, sort="eid:-1", skip=i, limit=10 + ) + self.assertEqual(10, len(eids.data), f"Failed at {i}") + received_eids.extend(x["eid"] for x in eids.data) + self.assertEqual(list(range(99, -1, -1)), received_eids) + + def _get_sorted_eids(self, sort_params: list[str], **kwargs) -> EntityEidList: + """Fetch entity EIDs with multiple sort query parameters. + + The shared `get_request` helper joins kwargs with '&' and cannot emit + multiple values for the same key, which is required for multi-column + sorting. Build the query string explicitly here. + """ + query_parts = [f"sort={param}" for param in sort_params] + for key, value in kwargs.items(): + query_parts.append(f"{key}={value}") + response = self.get_request(f"entity/A/get?{'&'.join(query_parts)}") + self.assertEqual(response.status_code, 200) + return EntityEidList.model_validate_json(response.content) + + def test_get_entity_eids_sort_by_multiple_attrs(self): + res = self.push_datapoints( + [ + {"src": "setup@test", "attr": "data2", "type": "A", "id": i, "v": f"g{i % 5}"} + for i in range(0, 100) + ] + ) + self.assertEqual(res.status_code, 200) + sleep(8) + self.get_request("control/make_snapshots") + sleep(6) + + expected = sorted(range(100), key=lambda i: (f"g{i % 5}", i)) + # omit :1 if sort is ascending + eids = self._get_sorted_eids(["data2", "eid"], limit=0) + self.assertEqual(100, len(eids.data)) + self.assertEqual(expected, [x["eid"] for x in eids.data]) + + expected = sorted(range(100), key=lambda i: (f"g{i % 5}", -i)) + eids = self._get_sorted_eids(["data2:1", "eid:-1"], limit=0) + self.assertEqual(100, len(eids.data)) + self.assertEqual(expected, [x["eid"] for x in eids.data])