fix(records): validate aggregate and filter container shapes - #2748
fix(records): validate aggregate and filter container shapes#2748andersfylling wants to merge 8 commits into
Conversation
The aggregates argument is a mapping of client-defined ID to aggregate, and a Filters aggregate takes a list. Passing an aggregate, or a list of them, builds a request body the API cannot make sense of, with nothing pointing at the shape as the problem. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An aggregates mapping must be keyed by the client-defined aggregate IDs, and a Filters aggregate takes a sequence of filters. Both now raise TypeError naming the expected shape - at the top-level aggregates argument and in every bucket aggregate that nests further aggregates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2748 +/- ##
==========================================
- Coverage 93.06% 93.04% -0.03%
==========================================
Files 514 514
Lines 52959 53057 +98
==========================================
+ Hits 49286 49365 +79
- Misses 3673 3692 +19
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Code Review
This pull request introduces validation for the aggregates and filters arguments in the data modeling API and data classes. Specifically, it adds _validate_aggregates and _validate_filters helper functions to ensure that aggregates are passed as mappings and filters as sequences, raising appropriate TypeErrors with helpful hints otherwise. Unit tests have been added to verify these validations. There are no review comments, and I have no feedback to provide.
haakonvt
left a comment
There was a problem hiding this comment.
Great that you are adding user friendly validation! A few comments:
|
|
||
|
|
||
| def _validate_aggregates( | ||
| aggregates: Any, argument: str = "aggregates" |
There was a problem hiding this comment.
| aggregates: Any, argument: str = "aggregates" | |
| aggregates: Mapping[str, Aggregate | dict[str, Any]], argument: str = "aggregates" |
| for aggregate_id, aggregate in aggregates.items(): | ||
| if not isinstance(aggregate_id, str): | ||
| raise TypeError(f"{argument!r} keys must be strings, not {type(aggregate_id).__name__}. {_AGGREGATES_HINT}") | ||
| if not isinstance(aggregate, (Aggregate, Mapping)): | ||
| raise TypeError( | ||
| f"{argument!r}[{aggregate_id!r}] must be an Aggregate or dict, " | ||
| f"not {type(aggregate).__name__}. {_AGGREGATES_HINT}" | ||
| ) | ||
| return aggregates |
There was a problem hiding this comment.
Is the aggregate dict exactly one level deep? Just wondering if there are cases where we need to recurse.
Edit: I guess not - when we are inside the for aggregate_id, aggregate in aggregates.items(): and aggregate is a Mapping, then we just ship that off assuming it is valid anyway.
| return aggregates | ||
|
|
||
|
|
||
| def _validate_filters(filters: Any, argument: str = "filters") -> Sequence[Filter | dict[str, Any]]: |
There was a problem hiding this comment.
| def _validate_filters(filters: Any, argument: str = "filters") -> Sequence[Filter | dict[str, Any]]: | |
| def _validate_filters(filters: Sequence[Filter | dict[str, Any]], argument: str = "filters") -> Sequence[Filter | dict[str, Any]]: |
|
|
||
| def _validate_filters(filters: Any, argument: str = "filters") -> Sequence[Filter | dict[str, Any]]: | ||
| """Validate the list of filter expressions one bucket is created per.""" | ||
| hint = "Each filter creates one bucket, so a single filter must still be given as a list." |
There was a problem hiding this comment.
There is strong precedent in the SDK for allowing "singles" (and just wrapping in a list for the user)
|
|
||
|
|
||
| def _validate_filters(filters: Any, argument: str = "filters") -> Sequence[Filter | dict[str, Any]]: | ||
| """Validate the list of filter expressions one bucket is created per.""" |
There was a problem hiding this comment.
Validate the list of filter expressions one bucket is created per.
Can you rephrase this please?
…te-containers # Conflicts: # cognite/client/_sync_api/data_modeling/records.py # cognite/client/data_classes/data_modeling/aggregates.py # tests/tests_unit/test_api/test_data_modeling/test_records.py # tests/tests_unit/test_data_classes/test_data_models/test_aggregates.py
…te-containers # Conflicts: # cognite/client/_sync_api/data_modeling/records.py
| ) | ||
| if isinstance(aggregate, Mapping): | ||
| for body in aggregate.values(): | ||
| if isinstance(body, Mapping) and "aggregates" in body: |
There was a problem hiding this comment.
Someone is gonna use the key "aggregates", mark my words 😂
There was a problem hiding this comment.
but this isn't checking an object level where they are allowed custom keys?
| def test_sync_record_list_public_load(self) -> None: | ||
| items = [ | ||
| {"space": "sp", "externalId": f"rec-{i}", "createdTime": 1, "lastUpdatedTime": 2, "status": "created"} | ||
| for i in range(2) | ||
| ] | ||
| page = SyncRecordList.load(items) | ||
| assert isinstance(page, SyncRecordList) | ||
| assert [record.external_id for record in page] == ["rec-0", "rec-1"] | ||
| assert page.cursor is None | ||
| assert page.has_next is False | ||
| assert page.typing is None |
There was a problem hiding this comment.
load is tested automatically, no need for this
| @pytest.mark.parametrize("limit", [None, -1]) | ||
| def test_filter_rejects_unlimited(self, cognite_client: CogniteClient, stream_id: str, limit: object) -> None: | ||
| with pytest.raises((TypeError, ValueError), match="'limit'"): |
There was a problem hiding this comment.
Ideally you should be strict in what exception type is expected per paramterized input test
| def test_none_passes_through(self) -> None: | ||
| assert _validate_aggregates(None) is None | ||
|
|
||
| def test_mapping_of_aggregate_instances_passes_through_unchanged(self) -> None: | ||
| aggs = {"avg_temp": Average(["sp", "c", "temp"]), "n": Count()} | ||
| assert _validate_aggregates(aggs) is aggs | ||
|
|
||
| def test_mapping_of_raw_dicts_passes_through_unchanged(self) -> None: | ||
| aggs = {"avg_temp": {"avg": {"property": ["sp", "c", "temp"]}}} | ||
| assert _validate_aggregates(aggs) is aggs | ||
|
|
||
| def test_valid_nested_aggregates_inside_a_raw_dict_passes(self) -> None: | ||
| aggs = { | ||
| "buckets": { | ||
| "uniqueValues": {"property": ["sp", "c", "player"], "aggregates": {"n": Count()}}, | ||
| } | ||
| } | ||
| assert _validate_aggregates(aggs) is aggs |
| assert _validate_filters(raw) == [raw] | ||
|
|
||
| def test_sequence_of_filters_and_dicts_passes_through_unchanged(self) -> None: | ||
| seq: list[Filter | dict[str, Any]] = [filters.MatchAll(), {"matchAll": {}}] |
There was a problem hiding this comment.
mypy really needs this?
| seq: list[Filter | dict[str, Any]] = [filters.MatchAll(), {"matchAll": {}}] | |
| seq = [filters.MatchAll(), {"matchAll": {}}] |
aggregatesis a mapping of the aggregate IDs you choose to aggregates, and aFiltersaggregate takes a sequence of filters. Passing an aggregate directly (or a list of them) where a mapping is expected is silently serialized into a body the API cannot make sense of:Both shapes are now validated where they are taken — the top-level
aggregatesargument, and the nestedaggregatesof every bucket aggregate, recursively through raw dict values too — with a TypeError that shows the expected shape.Filters.filtersis validated too, but since a single filter is common and unambiguous, it's normalized into a one-item list rather than rejected — matching how the rest of the SDK treats single-vs-sequence arguments:First commit is the failing tests, second is the fix.
🤖 Generated with Claude Code