diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e5279b50a..f04a791984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ Changes are grouped as follows - `Fixed` for any bug fixes. - `Security` in case of vulnerabilities. +## [7.7.0] - 2023-12-20 +### Added +- Support for `ViewProperty` types `SingleReverseDirectRelation` and `MultiReverseDirectRelation` in data modeling. + ## [7.6.0] - 2023-12-13 ### Added - Support for querying data models through graphql. See `client.data_modeling.graphql.query`. diff --git a/cognite/client/_version.py b/cognite/client/_version.py index 0cad9e18cb..ba0b9e2f59 100644 --- a/cognite/client/_version.py +++ b/cognite/client/_version.py @@ -1,4 +1,4 @@ from __future__ import annotations -__version__ = "7.6.0" +__version__ = "7.7.0" __api_subversion__ = "V20220125" diff --git a/cognite/client/data_classes/data_modeling/__init__.py b/cognite/client/data_classes/data_modeling/__init__.py index a3e6225b40..3930450dbc 100644 --- a/cognite/client/data_classes/data_modeling/__init__.py +++ b/cognite/client/data_classes/data_modeling/__init__.py @@ -50,6 +50,7 @@ DataModelingId, EdgeId, NodeId, + PropertyId, VersionedDataModelingId, ViewId, ViewIdentifier, @@ -98,6 +99,7 @@ "ViewIdentifier", "ViewApply", "ViewApplyList", + "PropertyId", "MappedPropertyApply", "VersionedDataModelingId", "DataModelingId", diff --git a/cognite/client/data_classes/data_modeling/ids.py b/cognite/client/data_classes/data_modeling/ids.py index 385b5d46b0..b753c9dae4 100644 --- a/cognite/client/data_classes/data_modeling/ids.py +++ b/cognite/client/data_classes/data_modeling/ids.py @@ -2,13 +2,19 @@ from abc import ABC from dataclasses import asdict, dataclass, field -from typing import Any, ClassVar, Literal, Protocol, Sequence, Tuple, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Protocol, Sequence, Tuple, TypeVar, Union, cast +from typing_extensions import Self + +from cognite.client.data_classes._base import CogniteObject from cognite.client.utils._auxiliary import rename_and_exclude_keys from cognite.client.utils._identifier import DataModelingIdentifier, DataModelingIdentifierSequence from cognite.client.utils._text import convert_all_keys_recursive, convert_all_keys_to_snake_case from cognite.client.utils.useful_types import SequenceNotStr +if TYPE_CHECKING: + from cognite.client import CogniteClient + @dataclass(frozen=True) class AbstractDataclass(ABC): @@ -138,6 +144,33 @@ def as_property_ref(self, property: str) -> tuple[str, ...]: return (self.space, self.as_source_identifier(), property) +@dataclass(frozen=True) +class PropertyId(CogniteObject): + source: ViewId | ContainerId + property: str + + @classmethod + def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = None) -> Self: + return cls( + source=cls.__load_view_or_container_id(resource["source"]), + property=resource["identifier"], + ) + + @staticmethod + def __load_view_or_container_id(view_or_container_id: dict[str, Any]) -> ViewId | ContainerId: + if "type" in view_or_container_id and view_or_container_id["type"] in {"view", "container"}: + if view_or_container_id["type"] == "view": + return ViewId.load(view_or_container_id) + return ContainerId.load(view_or_container_id) + raise ValueError(f"Invalid type {view_or_container_id}") + + def dump(self, camel_case: bool = True) -> dict[str, Any]: + return { + "source": self.source.dump(camel_case=camel_case, include_type=True), + "identifier": self.property, + } + + @dataclass(frozen=True) class DataModelId(VersionedDataModelingId): _type = "datamodel" diff --git a/cognite/client/data_classes/data_modeling/views.py b/cognite/client/data_classes/data_modeling/views.py index b43b5158f5..fa2610d128 100644 --- a/cognite/client/data_classes/data_modeling/views.py +++ b/cognite/client/data_classes/data_modeling/views.py @@ -4,7 +4,7 @@ from dataclasses import asdict, dataclass from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast -from typing_extensions import Self +from typing_extensions import Self, TypeAlias from cognite.client.data_classes._base import ( CogniteFilter, @@ -18,11 +18,9 @@ DirectRelationReference, PropertyType, ) -from cognite.client.data_classes.data_modeling.ids import ContainerId, ViewId +from cognite.client.data_classes.data_modeling.ids import ContainerId, PropertyId, ViewId from cognite.client.data_classes.filters import Filter -from cognite.client.utils._text import ( - convert_all_keys_to_camel_case_recursive, -) +from cognite.client.utils._text import convert_all_keys_to_camel_case_recursive, to_snake_case if TYPE_CHECKING: from cognite.client import CogniteClient @@ -216,7 +214,16 @@ def as_apply(self) -> ViewApply: properties: dict[str, ViewPropertyApply] | None = None if self.properties: for k, v in self.properties.items(): - if isinstance(v, (MappedProperty, SingleHopConnectionDefinition)): + if isinstance( + v, + ( + MappedProperty, + SingleEdgeConnection, + MultiEdgeConnection, + SingleReverseDirectRelation, + MultiReverseDirectRelation, + ), + ): if properties is None: properties = {} properties[k] = v.as_apply() @@ -293,8 +300,8 @@ def __init__( class ViewProperty(CogniteObject, ABC): @classmethod def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = None) -> Self: - if "direction" in resource: - return cast(Self, SingleHopConnectionDefinition.load(resource)) + if "connectionType" in resource: + return cast(Self, ConnectionDefinition.load(resource)) else: return cast(Self, MappedProperty.load(resource)) @@ -306,8 +313,8 @@ def dump(self, camel_case: bool = True) -> dict[str, Any]: class ViewPropertyApply(CogniteObject, ABC): @classmethod def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = None) -> Self: - if "direction" in resource: - return cast(Self, SingleHopConnectionDefinitionApply.load(resource)) + if "connectionType" in resource: + return cast(Self, ConnectionDefinitionApply.load(resource)) else: return cast(Self, MappedPropertyApply.load(resource)) @@ -400,19 +407,54 @@ def as_apply(self) -> MappedPropertyApply: @dataclass -class ConnectionDefinition(ViewProperty): - ... +class ConnectionDefinition(ViewProperty, ABC): + @classmethod + def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = None) -> Self: + if "connectionType" not in resource: + raise ValueError(f"{cls.__name__} must have a connectionType") + connection_type = to_snake_case(resource["connectionType"]) + + if connection_type == "single_edge_connection": + return cast(Self, SingleEdgeConnection.load(resource)) + if connection_type == "multi_edge_connection": + return cast(Self, MultiEdgeConnection.load(resource)) + if connection_type == "single_reverse_direct_relation": + return cast(Self, SingleReverseDirectRelation.load(resource)) + if connection_type == "multi_reverse_direct_relation": + return cast(Self, MultiReverseDirectRelation.load(resource)) + + raise ValueError(f"Cannot load {cls.__name__}: Unknown connection type {connection_type}") + + @abstractmethod + def dump(self, camel_case: bool = True) -> dict[str, Any]: + raise NotImplementedError @dataclass -class SingleHopConnectionDefinition(ConnectionDefinition): +class EdgeConnection(ConnectionDefinition, ABC): + """Describes the edge(s) that are likely to exist to aid in discovery and documentation of the view. + A listed edge is not required. i.e. It does not have to exist when included in this list. + A connection has a max distance of one hop. + + Args: + type (DirectRelationReference): Reference to the node pointed to by the direct relation. The reference + consists of a space and an external-id. + source (ViewId): The target node(s) of this connection can be read through the view specified in 'source'. + name (str | None): Readable property name. + description (str | None): Description of the content and suggested use for this property. + edge_source (ViewId | None): The edge(s) of this connection can be read through the view specified in + 'edgeSource'. + direction (Literal["outwards", "inwards"]): The direction of the edge. The outward direction is used to + indicate that the edge points from the source to the target. The inward direction is used to indicate + that the edge points from the target to the source. + """ + type: DirectRelationReference source: ViewId name: str | None = None description: str | None = None edge_source: ViewId | None = None direction: Literal["outwards", "inwards"] = "outwards" - connection_type: Literal["multiEdgeConnection"] = "multiEdgeConnection" @classmethod def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = None) -> Self: @@ -425,29 +467,57 @@ def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = ) if "direction" in resource: instance.direction = resource["direction"] - if "connectionType" in resource: - instance.connection_type = resource["connectionType"] + return instance + @abstractmethod def dump(self, camel_case: bool = True) -> dict[str, Any]: output = asdict(self) - if self.type: output["type"] = self.type.dump(camel_case) - if self.source: output["source"] = self.source.dump(camel_case) - if self.edge_source: output["edge_source"] = self.edge_source.dump(camel_case) - if self.connection_type is not None: - output["connection_type"] = self.connection_type - return convert_all_keys_to_camel_case_recursive(output) if camel_case else output - def as_apply(self) -> SingleHopConnectionDefinitionApply: - return SingleHopConnectionDefinitionApply( + +@dataclass +class SingleEdgeConnection(EdgeConnection): + def dump(self, camel_case: bool = True) -> dict[str, Any]: + output: dict[str, Any] = super().dump(camel_case) + if camel_case: + output["connectionType"] = "single_edge_connection" + else: + output["connection_type"] = "single_edge_connection" + + return output + + def as_apply(self) -> SingleEdgeConnectionApply: + return SingleEdgeConnectionApply( + type=self.type, + source=self.source, + name=self.name, + description=self.description, + edge_source=self.edge_source, + direction=self.direction, + ) + + +@dataclass +class MultiEdgeConnection(EdgeConnection): + def dump(self, camel_case: bool = True) -> dict[str, Any]: + output: dict[str, Any] = super().dump(camel_case) + if camel_case: + output["connectionType"] = "multi_edge_connection" + else: + output["connection_type"] = "multi_edge_connection" + + return output + + def as_apply(self) -> MultiEdgeConnectionApply: + return MultiEdgeConnectionApply( type=self.type, source=self.source, name=self.name, @@ -457,23 +527,142 @@ def as_apply(self) -> SingleHopConnectionDefinitionApply: ) +SingleHopConnectionDefinition: TypeAlias = "MultiEdgeConnection" + + @dataclass -class ConnectionDefinitionApply(ViewPropertyApply): - ... +class ReverseDirectRelation(ConnectionDefinition, ABC): + """Describes the direct relation(s) pointing to instances read through this view. This connection type is used to + aid in discovery and documentation of the view + + It is called 'ReverseDirectRelationConnection' in the API spec. + + Args: + source (ViewId): The node(s) containing the direct relation property can be read through + the view specified in 'source'. + through (PropertyId): The view or container of the node containing the direct relation property. + name (str | None): Readable property name. + description (str | None): Description of the content and suggested use for this property. + + """ + + source: ViewId + through: PropertyId + name: str | None = None + description: str | None = None + + @classmethod + def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = None) -> Self: + return cls( + source=ViewId.load(resource["source"]), + through=PropertyId.load(resource["through"]), + name=resource.get("name"), + description=resource.get("description"), + ) + + @abstractmethod + def dump(self, camel_case: bool = True) -> dict[str, Any]: + return { + "source": self.source.dump(camel_case), + "through": self.through.dump(camel_case), + "name": self.name, + "description": self.description, + } + + +@dataclass +class SingleReverseDirectRelation(ReverseDirectRelation): + def dump(self, camel_case: bool = True) -> dict[str, Any]: + output: dict[str, Any] = super().dump(camel_case) + if camel_case: + output["connectionType"] = "single_reverse_direct_relation" + else: + output["connection_type"] = "single_reverse_direct_relation" + + return output + + def as_apply(self) -> SingleReverseDirectRelationApply: + return SingleReverseDirectRelationApply( + source=self.source, + through=self.through, + name=self.name, + description=self.description, + ) + + +@dataclass +class MultiReverseDirectRelation(ReverseDirectRelation): + def dump(self, camel_case: bool = True) -> dict[str, Any]: + output: dict[str, Any] = super().dump(camel_case) + if camel_case: + output["connectionType"] = "multi_reverse_direct_relation" + else: + output["connection_type"] = "multi_reverse_direct_relation" + + return output + + def as_apply(self) -> MultiReverseDirectRelationApply: + return MultiReverseDirectRelationApply( + source=self.source, + through=self.through, + name=self.name, + description=self.description, + ) + + +@dataclass +class ConnectionDefinitionApply(ViewPropertyApply, ABC): + @classmethod + def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = None) -> Self: + if "connectionType" not in resource: + raise ValueError(f"{cls.__name__} must have a connectionType") + connection_type = to_snake_case(resource["connectionType"]) + + if connection_type == "single_edge_connection": + return cast(Self, SingleEdgeConnectionApply.load(resource)) + if connection_type == "multi_edge_connection": + return cast(Self, MultiEdgeConnectionApply.load(resource)) + if connection_type == "single_reverse_direct_relation": + return cast(Self, SingleReverseDirectRelationApply.load(resource)) + if connection_type == "multi_reverse_direct_relation": + return cast(Self, MultiReverseDirectRelationApply.load(resource)) + raise ValueError(f"Cannot load {cls.__name__}: Unknown connection type {connection_type}") + + @abstractmethod + def dump(self, camel_case: bool = True) -> dict[str, Any]: + raise NotImplementedError T_ConnectionDefinitionApply = TypeVar("T_ConnectionDefinitionApply", bound=ConnectionDefinitionApply) @dataclass -class SingleHopConnectionDefinitionApply(ConnectionDefinitionApply): +class EdgeConnectionApply(ConnectionDefinitionApply, ABC): + """Describes the edge(s) that are likely to exist to aid in discovery and documentation of the view. + A listed edge is not required. i.e. It does not have to exist when included in this list. + A connection has a max distance of one hop. + + It is called 'EdgeConnection' in the API spec. + + Args: + type (DirectRelationReference): Reference to the node pointed to by the direct relation. The reference + consists of a space and an external-id. + source (ViewId): The target node(s) of this connection can be read through the view specified in 'source'. + name (str | None): Readable property name. + description (str | None): Description of the content and suggested use for this property. + edge_source (ViewId | None): The edge(s) of this connection can be read through the view specified in + 'edgeSource'. + direction (Literal["outwards", "inwards"]): The direction of the edge. The outward direction is used to + indicate that the edge points from the source to the target. The inward direction is used to indicate + that the edge points from the target to the source. + """ + type: DirectRelationReference source: ViewId name: str | None = None description: str | None = None edge_source: ViewId | None = None direction: Literal["outwards", "inwards"] = "outwards" - connection_type: Literal["multiEdgeConnection"] = "multiEdgeConnection" @classmethod def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = None) -> Self: @@ -486,10 +675,9 @@ def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = ) if "direction" in resource: instance.direction = resource["direction"] - if "connectionType" in resource: - instance.connection_type = resource["connectionType"] return instance + @abstractmethod def dump(self, camel_case: bool = True) -> dict: output: dict[str, Any] = { "type": self.type.dump(camel_case), @@ -504,7 +692,104 @@ def dump(self, camel_case: bool = True) -> dict: output[("edgeSource" if camel_case else "edge_source")] = self.edge_source.dump( camel_case, include_type=True ) - if self.connection_type is not None: - output[("connectionType" if camel_case else "connection_type")] = self.connection_type + + return output + + +@dataclass +class SingleEdgeConnectionApply(EdgeConnectionApply): + def dump(self, camel_case: bool = True) -> dict[str, Any]: + output: dict[str, Any] = super().dump(camel_case) + if camel_case: + output["connectionType"] = "single_edge_connection" + else: + output["connection_type"] = "single_edge_connection" + + return output + + +@dataclass +class MultiEdgeConnectionApply(EdgeConnectionApply): + def dump(self, camel_case: bool = True) -> dict[str, Any]: + output: dict[str, Any] = super().dump(camel_case) + if camel_case: + output["connectionType"] = "multi_edge_connection" + else: + output["connection_type"] = "multi_edge_connection" + + return output + + +SingleHopConnectionDefinitionApply: TypeAlias = "MultiEdgeConnectionApply" + + +@dataclass +class ReverseDirectRelationApply(ConnectionDefinitionApply, ABC): + """Describes the direct relation(s) pointing to instances read through this view. This connection type is used to + aid in discovery and documentation of the view. + + It is called 'ReverseDirectRelationConnection' in the API spec. + + Args: + source (ViewId): The node(s) containing the direct relation property can be read through + the view specified in 'source'. + through (PropertyId): The view or container of the node containing the direct relation property. + name (str | None): Readable property name. + description (str | None): Description of the content and suggested use for this property. + + """ + + source: ViewId + through: PropertyId + name: str | None = None + description: str | None = None + + @classmethod + def _load(cls, resource: dict[str, Any], cognite_client: CogniteClient | None = None) -> Self: + instance = cls( + source=ViewId.load(resource["source"]), + through=PropertyId.load(resource["through"]), + ) + if "name" in resource: + instance.name = resource["name"] + if "description" in resource: + instance.description = resource["description"] + + return instance + + @abstractmethod + def dump(self, camel_case: bool = True) -> dict[str, Any]: + output: dict[str, Any] = { + "source": self.source.dump(camel_case, include_type=True), + "through": self.through.dump(camel_case), + } + if self.name is not None: + output["name"] = self.name + if self.description is not None: + output["description"] = self.description + + return output + + +@dataclass +class SingleReverseDirectRelationApply(ReverseDirectRelationApply): + def dump(self, camel_case: bool = True) -> dict[str, Any]: + output: dict[str, Any] = super().dump(camel_case) + if camel_case: + output["connectionType"] = "single_reverse_direct_relation" + else: + output["connection_type"] = "single_reverse_direct_relation" + + return output + + +@dataclass +class MultiReverseDirectRelationApply(ReverseDirectRelationApply): + def dump(self, camel_case: bool = True) -> dict[str, Any]: + output: dict[str, Any] = super().dump(camel_case) + if camel_case: + output["connectionType"] = "multi_reverse_direct_relation" + else: + output["connection_type"] = "multi_reverse_direct_relation" return output diff --git a/pyproject.toml b/pyproject.toml index 433b59e7bc..0957b7447c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "cognite-sdk" -version = "7.6.0" +version = "7.7.0" description = "Cognite Python SDK" readme = "README.md" documentation = "https://cognite-sdk-python.readthedocs-hosted.com" diff --git a/tests/tests_integration/test_api/test_data_modeling/test_views.py b/tests/tests_integration/test_api/test_data_modeling/test_views.py index dd904d3fc1..d30b7a5b00 100644 --- a/tests/tests_integration/test_api/test_data_modeling/test_views.py +++ b/tests/tests_integration/test_api/test_data_modeling/test_views.py @@ -14,6 +14,7 @@ DirectRelation, DirectRelationReference, MappedPropertyApply, + PropertyId, Space, Text, View, @@ -21,7 +22,10 @@ ViewId, ViewList, ) -from cognite.client.data_classes.data_modeling.views import SingleHopConnectionDefinitionApply +from cognite.client.data_classes.data_modeling.views import ( + MultiEdgeConnectionApply, + SingleReverseDirectRelationApply, +) from cognite.client.exceptions import CogniteAPIError @@ -40,22 +44,23 @@ def movie_view(movie_views: ViewList) -> View: return cast(View, movie_views.get(external_id="Movie")) +@pytest.fixture() +def actor_view(movie_views: ViewList) -> View: + return cast(View, movie_views.get(external_id="Actor")) + + class TestViewsAPI: def test_list(self, cognite_client: CogniteClient, movie_views: ViewList, integration_test_space: Space) -> None: - # Arrange expected_views = ViewList([v for v in movie_views if v.space == integration_test_space.space]) expected_ids = set(expected_views.as_ids()) - # Act actual_views = cognite_client.data_modeling.views.list(space=integration_test_space.space, limit=-1) - # Assert assert expected_ids, "The movie model is missing views" assert expected_ids <= set(actual_views.as_ids()) assert all(v.space == integration_test_space.space for v in actual_views) def test_apply_retrieve_and_delete(self, cognite_client: CogniteClient, integration_test_space: Space) -> None: - # Arrange new_view = ViewApply( space=integration_test_space.space, external_id="IntegrationTestView", @@ -78,27 +83,22 @@ def test_apply_retrieve_and_delete(self, cognite_client: CogniteClient, integrat deleted_ids: list[ViewId] = [] new_id = new_view.as_id() try: - # Act created = cognite_client.data_modeling.views.apply(new_view) retrieved = cognite_client.data_modeling.views.retrieve(new_id) - # Assert assert created.created_time assert created.last_updated_time assert created.as_apply().dump() == new_view.dump() assert len(retrieved) == 1 assert retrieved[0].dump() == created.dump() - # Act deleted_ids = cognite_client.data_modeling.views.delete(new_id) retrieved_deleted = cognite_client.data_modeling.views.retrieve(new_id) - # Assert assert len(deleted_ids) == 1 assert deleted_ids[0] == new_id assert not retrieved_deleted finally: - # Cleanup if created and not deleted_ids: cognite_client.data_modeling.views.delete(new_id) @@ -110,34 +110,25 @@ def test_delete_non_existent(self, cognite_client: CogniteClient, integration_te ) def test_retrieve_without_inherited_properties(self, cognite_client: CogniteClient, movie_views: ViewList) -> None: - # Arrange view = movie_views[0] - # Act retrieved = cognite_client.data_modeling.views.retrieve(view.as_id(), include_inherited_properties=False) - # Assert assert len(retrieved) == 1 def test_retrieve_multiple(self, cognite_client: CogniteClient, movie_views: ViewList) -> None: - # Arrange ids = movie_views.as_ids() - # Act retrieved = cognite_client.data_modeling.views.retrieve(ids) - # Assert assert set(retrieved.as_ids()) == set(ids) def test_retrieve_multiple_with_missing(self, cognite_client: CogniteClient, movie_views: ViewList) -> None: - # Arrange ids_without_missing = movie_views.as_ids() ids_with_missing = [*ids_without_missing, ViewId("myNonExistingSpace", "myImaginaryView", "v0")] - # Act retrieved = cognite_client.data_modeling.views.retrieve(ids_with_missing) - # Assert assert set(retrieved.as_ids()) == set(ids_without_missing) def test_retrieve_non_existent(self, cognite_client: CogniteClient) -> None: @@ -169,14 +160,12 @@ def test_apply_invalid_view(self, cognite_client: CogniteClient, integration_tes ) ) - # Assert assert error.value.code == 400 assert "One or more spaces do not exist" in error.value.message def test_apply_failed_and_successful_task( self, cognite_client: CogniteClient, integration_test_space: Space, monkeypatch: Any ) -> None: - # Arrange valid_view = ViewApply( space=integration_test_space.space, external_id="myView", @@ -210,36 +199,29 @@ def test_apply_failed_and_successful_task( monkeypatch.setattr(cognite_client.data_modeling.views, "_CREATE_LIMIT", 1) try: - # Act with pytest.raises(CogniteAPIError) as error: cognite_client.data_modeling.views.apply([valid_view, invalid_view]) - # Assert assert "One or more spaces do not exist" in error.value.message assert error.value.code == 400 assert len(error.value.successful) == 1 assert len(error.value.failed) == 1 finally: - # Cleanup cognite_client.data_modeling.views.delete(valid_view.as_id()) def test_dump_json_serialize_load(self, movie_views: ViewList) -> None: - # Arrange view = movie_views.get(external_id="Movie") assert view is not None, "Movie view not found in test environment" - # Act view_dumped = view.dump(camel_case=True) view_json = json.dumps(view_dumped) view_loaded = View.load(view_json) - # Assert assert view == view_loaded def test_apply_different_property_types( self, cognite_client: CogniteClient, integration_test_space: Space, person_view: View, movie_view: View ) -> None: - # Arrange new_container = ContainerApply( space=integration_test_space.space, external_id="Critic", @@ -262,7 +244,7 @@ def test_apply_different_property_types( space=integration_test_space.space, external_id="Critic", version="v1", - description="This i a test view, and should not persist.", + description="This is a test view, and should not persist.", name="Critic", properties={ "name": MappedPropertyApply( @@ -284,7 +266,7 @@ def test_apply_different_property_types( container_property_identifier="reviews", name="reviews", ), - "movies": SingleHopConnectionDefinitionApply( + "movies": MultiEdgeConnectionApply( type=DirectRelationReference( space=integration_test_space.space, external_id="Critic.movies", @@ -297,14 +279,36 @@ def test_apply_different_property_types( ) try: - # Act created_container = cognite_client.data_modeling.containers.apply(new_container) created_view = cognite_client.data_modeling.views.apply(new_view) - # Assert assert created_container.created_time assert created_view.created_time finally: - # Cleanup cognite_client.data_modeling.views.delete(new_view.as_id()) cognite_client.data_modeling.containers.delete(new_container.as_id()) + + def test_apply_view_with_reverse_direct_relation( + self, cognite_client: CogniteClient, integration_test_space: Space, person_view: View, actor_view: View + ) -> None: + new_view = ViewApply( + space=integration_test_space.space, + external_id="Critic", + version="v3", + description="This is a test view, and should not persist.", + name="Critic", + properties={ + "persons": SingleReverseDirectRelationApply( + source=person_view.as_id(), + name="Person", + through=PropertyId(source=actor_view.as_id(), property="person"), + ) + }, + ) + + try: + created_view = cognite_client.data_modeling.views.apply(new_view) + + assert created_view.created_time + finally: + cognite_client.data_modeling.views.delete(new_view.as_id()) diff --git a/tests/tests_unit/test_data_classes/test_data_models/test_views.py b/tests/tests_unit/test_data_classes/test_data_models/test_views.py index 03b4771847..224c0709ed 100644 --- a/tests/tests_unit/test_data_classes/test_data_models/test_views.py +++ b/tests/tests_unit/test_data_classes/test_data_models/test_views.py @@ -52,42 +52,96 @@ def test_load_dumped_mapped_property_for_apply(self) -> None: "source": {"space": "mySpace", "external_id": "myExternalId", "version": "myVersion", "type": "view"}, } - def test_load_dump_connection_property(self) -> None: + def test_load_dump_single_reverse_direct_relation_property_with_container(self) -> None: input = { - "connectionType": "multiEdgeConnection", - "type": {"space": "mySpace", "externalId": "myExternalId"}, - "source": {"type": "view", "space": "mySpace", "externalId": "myExternalId", "version": "myVersion"}, - "direction": "outwards", + "connectionType": "single_reverse_direct_relation", + "through": { + "source": {"external_id": "myContainer", "space": "mySpace", "type": "container"}, + "identifier": "myIdentifier", + }, + "source": {"type": "view", "space": "mySpace", "externalId": "mySourceView", "version": "myVersion"}, "name": "fullName", - "edgeSource": None, + "description": "my single reverse direct relation property", } actual = ViewProperty.load(input) assert actual.dump(camel_case=False) == { - "connection_type": "multiEdgeConnection", + "connection_type": "single_reverse_direct_relation", + "description": "my single reverse direct relation property", + "name": "fullName", + "source": {"external_id": "mySourceView", "space": "mySpace", "type": "view", "version": "myVersion"}, + "through": { + "identifier": "myIdentifier", + "source": {"external_id": "myContainer", "space": "mySpace", "type": "container"}, + }, + } + + def test_load_dump_single_reverse_direct_relation_property_with_container_for_apply(self) -> None: + input = { + "through": { + "source": {"external_id": "myContainer", "space": "mySpace", "type": "container"}, + "identifier": "myIdentifier", + }, + "source": {"type": "view", "space": "mySpace", "externalId": "mySourceView", "version": "myVersion"}, + "name": "fullName", "description": None, - "direction": "outwards", - "edge_source": None, + "connectionType": "single_reverse_direct_relation", + } + actual = ViewPropertyApply.load(input) + + assert actual.dump(camel_case=False) == { "name": "fullName", - "source": {"external_id": "myExternalId", "space": "mySpace", "type": "view", "version": "myVersion"}, - "type": {"external_id": "myExternalId", "space": "mySpace"}, + "source": {"external_id": "mySourceView", "space": "mySpace", "type": "view", "version": "myVersion"}, + "through": { + "identifier": "myIdentifier", + "source": {"external_id": "myContainer", "space": "mySpace", "type": "container"}, + }, + "connection_type": "single_reverse_direct_relation", } - def test_load_dump_connection_property_for_apply(self) -> None: + def test_load_dump_multi_reverse_direct_relation_property(self) -> None: input = { - "type": {"space": "mySpace", "externalId": "myExternalId"}, - "source": {"type": "view", "space": "mySpace", "externalId": "myExternalId", "version": "myVersion"}, - "direction": "outwards", + "connectionType": "multi_reverse_direct_relation", + "through": { + "source": {"external_id": "myContainer", "space": "mySpace", "type": "container"}, + "identifier": "myIdentifier", + }, + "source": {"type": "view", "space": "mySpace", "externalId": "mySourceView", "version": "myVersion"}, + "name": "fullName", + "description": "my multi reverse direct relation property", + } + actual = ViewProperty.load(input) + + assert actual.dump(camel_case=False) == { + "connection_type": "multi_reverse_direct_relation", + "description": "my multi reverse direct relation property", + "name": "fullName", + "source": {"external_id": "mySourceView", "space": "mySpace", "type": "view", "version": "myVersion"}, + "through": { + "identifier": "myIdentifier", + "source": {"external_id": "myContainer", "space": "mySpace", "type": "container"}, + }, + } + + def test_load_dump_multi_reverse_direct_relation_property_for_apply(self) -> None: + input = { + "through": { + "source": {"external_id": "myContainer", "space": "mySpace", "type": "container"}, + "identifier": "myIdentifier", + }, + "source": {"type": "view", "space": "mySpace", "externalId": "mySourceView", "version": "myVersion"}, "name": "fullName", - "edgeSource": None, - "connectionType": "multiEdgeConnection", + "description": None, + "connectionType": "multi_reverse_direct_relation", } actual = ViewPropertyApply.load(input) assert actual.dump(camel_case=False) == { - "direction": "outwards", "name": "fullName", - "source": {"external_id": "myExternalId", "space": "mySpace", "type": "view", "version": "myVersion"}, - "type": {"external_id": "myExternalId", "space": "mySpace"}, - "connection_type": "multiEdgeConnection", + "source": {"external_id": "mySourceView", "space": "mySpace", "type": "view", "version": "myVersion"}, + "through": { + "identifier": "myIdentifier", + "source": {"external_id": "myContainer", "space": "mySpace", "type": "container"}, + }, + "connection_type": "multi_reverse_direct_relation", }