From a68418be45a423af94a29f34f9766e3025abd4f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A5l=20R=C3=B8nning?= Date: Wed, 24 Jun 2026 15:22:13 +0200 Subject: [PATCH 1/2] chore(release): publish patch for #3074 ## Bump - [x] Patch - [ ] Skip ## Changelog ### Fixed - `cdf build` rejecting unquoted timestamp-like values in config.yaml variables From ef5ba7163948d7b8380263f6de4259677bf4964e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A5l=20R=C3=B8nning?= Date: Sat, 18 Jul 2026 17:13:38 +0200 Subject: [PATCH 2/2] fix(graphql): exclude extra YAML fields from upsertGraphQlDmlVersion mutation variables model_dump(exclude_unset=False) forwarded any extra key in the source YAML (stored in __pydantic_extra__ via extra="allow") as part of dmCreate. The CDF API rejects unknown fields on GraphQlDmlVersionUpsert and returns upsertGraphQlDmlVersion=null, producing a cryptic Pydantic "Input should be an object" error that masks the real rejection reason. Switch to item.dump(exclude_extra=True) which strips __pydantic_extra__ and also omits unset optional nulls (preserveDml, previousVersion, etc.) for a clean, minimal payload. Explicitly set YAML fields (previousVersion, preserveDml, name, description) are still forwarded as before. --- .../_cdf_tk/client/api/graphql_data_models.py | 41 ++++++--- .../test_cdf_tk/test_cruds/test_data_model.py | 83 +++++++++++++++++++ 2 files changed, 113 insertions(+), 11 deletions(-) diff --git a/cognite_toolkit/_cdf_tk/client/api/graphql_data_models.py b/cognite_toolkit/_cdf_tk/client/api/graphql_data_models.py index 8ef10d7be8..094143c4e3 100644 --- a/cognite_toolkit/_cdf_tk/client/api/graphql_data_models.py +++ b/cognite_toolkit/_cdf_tk/client/api/graphql_data_models.py @@ -3,6 +3,7 @@ This API provides a wrapper around the legacy DML API for managing GraphQL data models. """ +import json from collections.abc import Iterable, Sequence from typing import Any @@ -26,13 +27,20 @@ from cognite_toolkit._cdf_tk.utils import humanize_collection +class DMLError(BaseModel): + model_config = ConfigDict(extra="allow") + kind: str | None = None + message: str | None = None + hint: str | None = None + + class UpsertResponseData(BaseModel): - errors: dict[str, Any] | None = None - result: GraphQLDataModelResponse + errors: list[DMLError] | None = None + result: GraphQLDataModelResponse | None = None class GraphQLUpsertResponse(BaseModel): - upsert_graph_ql_dml_version: UpsertResponseData = Field(alias="upsertGraphQlDmlVersion") + upsert_graph_ql_dml_version: UpsertResponseData | None = Field(None, alias="upsertGraphQlDmlVersion") class GraphQLErrors(BaseModel): @@ -43,7 +51,7 @@ class GraphQLErrors(BaseModel): class GraphQLResponse(BaseModel): - data: GraphQLUpsertResponse + data: GraphQLUpsertResponse | None = None errors: list[GraphQLErrors] | None = None @@ -79,11 +87,19 @@ def _post_graphql(self, payload: dict[str, Any]) -> GraphQLUpsertResponse: ) result = self._http_client.request_single_retries(request) response = result.get_success_or_raise(request) - parsed = GraphQLResponse.model_validate_json(response.body) - if errors := parsed.errors: - raise ToolkitAPIError( - f"Failed GraphQL errors: {humanize_collection([error.message for error in errors if error.message])}" - ) + raw = json.loads(response.body) + if top_errors := raw.get("errors"): + messages = [e.get("message", str(e)) for e in top_errors if isinstance(e, dict)] + raise ToolkitAPIError(f"GraphQL mutation failed: {humanize_collection(messages)}") + parsed = GraphQLResponse.model_validate(raw) + if parsed.data is None: + raise ToolkitAPIError("GraphQL mutation returned no data and no errors.") + upsert = parsed.data.upsert_graph_ql_dml_version + if upsert is None: + raise ToolkitAPIError("GraphQL mutation returned no result and no errors.") + if upsert.errors: + messages = [e.message for e in upsert.errors if e.message] + raise ToolkitAPIError(f"DML validation failed: {humanize_collection(messages)}") return parsed.data def create(self, items: Sequence[GraphQLDataModelRequest]) -> list[GraphQLDataModelResponse]: @@ -99,10 +115,13 @@ def create(self, items: Sequence[GraphQLDataModelRequest]) -> list[GraphQLDataMo for item in items: payload = { "query": UPSERT_BODY, - "variables": {"dmCreate": item.model_dump(mode="json", by_alias=True, exclude_unset=False)}, + "variables": {"dmCreate": item.dump(exclude_extra=True)}, } response = self._post_graphql(payload) - results.append(response.upsert_graph_ql_dml_version.result) + upsert = response.upsert_graph_ql_dml_version + if upsert is None or upsert.result is None: + raise ToolkitAPIError("GraphQL mutation succeeded but returned no data model.") + results.append(upsert.result) return results def retrieve(self, items: Sequence[DataModelId], inline_views: bool = False) -> list[GraphQLDataModelResponse]: diff --git a/tests/test_unit/test_cdf_tk/test_cruds/test_data_model.py b/tests/test_unit/test_cdf_tk/test_cruds/test_data_model.py index d8ff34f6b1..76bf8308f7 100644 --- a/tests/test_unit/test_cdf_tk/test_cruds/test_data_model.py +++ b/tests/test_unit/test_cdf_tk/test_cruds/test_data_model.py @@ -627,6 +627,89 @@ def test_graphql_with_space_only(self) -> None: assert deps[0] == (SpaceCRUD, SpaceId(space="my_space")) +class TestGraphQLCreatePayload: + """Regression tests for the GraphQL upsert mutation payload and response parsing.""" + + def test_extra_yaml_fields_excluded_from_mutation_variables(self) -> None: + r = GraphQLDataModelRequest.model_validate( + {"space": "s", "externalId": "e", "version": "v", "unknownYamlKey": "leaks"} + ) + r_with_dml = r.model_copy(update={"graph_ql_dml": "type Foo { name: String }"}) + payload = r_with_dml.dump(exclude_extra=True) + + assert "unknownYamlKey" not in payload + assert "graphQlDml" in payload + + def test_null_optional_fields_not_sent_in_mutation_variables(self) -> None: + r = GraphQLDataModelRequest.model_validate({"space": "s", "externalId": "e", "version": "v"}) + r_with_dml = r.model_copy(update={"graph_ql_dml": "type Foo { name: String }"}) + payload = r_with_dml.dump(exclude_extra=True) + + assert "preserveDml" not in payload + assert "previousVersion" not in payload + assert "name" not in payload + assert "description" not in payload + + def test_explicitly_set_optional_fields_are_sent(self) -> None: + r = GraphQLDataModelRequest.model_validate( + {"space": "s", "externalId": "e", "version": "v", "previousVersion": "v0", "preserveDml": True} + ) + r_with_dml = r.model_copy(update={"graph_ql_dml": "type Foo { name: String }"}) + payload = r_with_dml.dump(exclude_extra=True) + + assert payload["previousVersion"] == "v0" + assert payload["preserveDml"] is True + + @staticmethod + def _make_api(response_body: str): # type: ignore[return] + from unittest.mock import MagicMock + + from cognite_toolkit._cdf_tk.client.api.graphql_data_models import GraphQLDataModelsAPI + + mock_success = MagicMock() + mock_success.body = response_body + mock_result = MagicMock() + mock_result.get_success_or_raise.return_value = mock_success + mock_http = MagicMock() + mock_http.request_single_retries.return_value = mock_result + + api = GraphQLDataModelsAPI(http_client=mock_http) + api._make_url = MagicMock(return_value="https://api.cognitedata.com/dml/graphql") # type: ignore[method-assign] + return api + + def test_top_level_graphql_error_surfaced_not_swallowed(self) -> None: + import json + + from cognite_toolkit._cdf_tk.client.http_client import ToolkitAPIError + + body = json.dumps( + { + "data": {"upsertGraphQlDmlVersion": None}, + "errors": [{"message": "Unknown argument 'dml' on field 'upsertGraphQlDmlVersion'"}], + } + ) + with pytest.raises(ToolkitAPIError, match="Unknown argument 'dml'"): + self._make_api(body)._post_graphql({"query": "...", "variables": {}}) + + def test_dml_compile_error_surfaced_as_actionable_message(self) -> None: + import json + + from cognite_toolkit._cdf_tk.client.http_client import ToolkitAPIError + + body = json.dumps( + { + "data": { + "upsertGraphQlDmlVersion": { + "errors": [{"kind": "COMPILE_ERROR", "message": "Type 'Foo' not found", "hint": None}], + "result": None, + } + }, + } + ) + with pytest.raises(ToolkitAPIError, match="Type 'Foo' not found"): + self._make_api(body)._post_graphql({"query": "...", "variables": {}}) + + class TestDataModelBuilder: """Regression tests for DataModelBuilder (build v1)."""