Skip to content

fix(records): validate aggregate and filter container shapes - #2748

Open
andersfylling wants to merge 8 commits into
masterfrom
fix/records-aggregate-containers
Open

fix(records): validate aggregate and filter container shapes#2748
andersfylling wants to merge 8 commits into
masterfrom
fix/records-aggregate-containers

Conversation

@andersfylling

@andersfylling andersfylling commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

aggregates is a mapping of the aggregate IDs you choose to aggregates, and a Filters aggregate 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:

client.data_modeling.records.aggregate(Count(), stream_id=STREAM_ID)
# sends {"aggregates": {"count": {}}} - "count" read as a client-defined ID

UniqueValues(path, aggregates=[Count()])   # aggregates as a list, sent as-is

UniqueValues(path, aggregates={"buckets": {"uniqueValues": {"property": [...], "aggregates": [Count()]}}})
# a nested `aggregates` inside a raw dict aggregate value was shipped off unchecked

Both shapes are now validated where they are taken — the top-level aggregates argument, and the nested aggregates of every bucket aggregate, recursively through raw dict values too — with a TypeError that shows the expected shape.

Filters.filters is 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:

Filters(filters=filters.MatchAll())   # now equivalent to Filters(filters=[filters.MatchAll()])

First commit is the failing tests, second is the fix.

🤖 Generated with Claude Code

andersfylling and others added 3 commits August 7, 2026 01:00
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

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.04%. Comparing base (38cbb51) to head (316d750).

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     
Files with missing lines Coverage Δ
cognite/client/_api/data_modeling/records.py 99.04% <100.00%> (ø)
cognite/client/_sync_api/data_modeling/records.py 100.00% <ø> (ø)
...te/client/data_classes/data_modeling/aggregates.py 99.64% <100.00%> (+0.03%) ⬆️
...s_unit/test_api/test_data_modeling/test_records.py 100.00% <100.00%> (ø)
...t_data_classes/test_data_models/test_aggregates.py 100.00% <100.00%> (ø)

... and 6 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@andersfylling
andersfylling marked this pull request as ready for review August 11, 2026 15:22
@andersfylling
andersfylling requested review from a team as code owners August 11, 2026 15:22

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 haakonvt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Great that you are adding user friendly validation! A few comments:



def _validate_aggregates(
aggregates: Any, argument: str = "aggregates"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
aggregates: Any, argument: str = "aggregates"
aggregates: Mapping[str, Aggregate | dict[str, Any]], argument: str = "aggregates"

Comment on lines +49 to +57
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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]]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
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."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Someone is gonna use the key "aggregates", mark my words 😂

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

but this isn't checking an object level where they are allowed custom keys?

Comment on lines +1162 to +1172
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

load is tested automatically, no need for this

Comment on lines +1190 to +1192
@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'"):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ideally you should be strict in what exception type is expected per paramterized input test

@haakonvt haakonvt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

lgtm, some nits:

Comment on lines +330 to +347
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could be parametrize'd

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": {}}]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

mypy really needs this?

Suggested change
seq: list[Filter | dict[str, Any]] = [filters.MatchAll(), {"matchAll": {}}]
seq = [filters.MatchAll(), {"matchAll": {}}]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants