Skip to content
41 changes: 30 additions & 11 deletions cognite_toolkit/_cdf_tk/client/api/graphql_data_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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):
Expand All @@ -43,7 +51,7 @@ class GraphQLErrors(BaseModel):


class GraphQLResponse(BaseModel):
data: GraphQLUpsertResponse
data: GraphQLUpsertResponse | None = None
errors: list[GraphQLErrors] | None = None


Expand Down Expand Up @@ -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]:
Expand All @@ -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]:
Expand Down
83 changes: 83 additions & 0 deletions tests/test_unit/test_cdf_tk/test_cruds/test_data_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""

Expand Down