From 4e6231bb148ce22bc0554fb5683dd6cfc6aa224c Mon Sep 17 00:00:00 2001 From: Alex Petenchea Date: Sat, 24 May 2025 13:04:36 +0000 Subject: [PATCH 1/9] Highlighting boolean values --- arangoasync/database.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/arangoasync/database.py b/arangoasync/database.py index 60f6ee9..058daf0 100644 --- a/arangoasync/database.py +++ b/arangoasync/database.py @@ -679,7 +679,7 @@ async def has_graph(self, name: str) -> Result[bool]: name (str): Graph name. Returns: - bool: True if the graph exists, False otherwise. + bool: `True` if the graph exists, `False` otherwise. Raises: GraphListError: If the operation fails. From ba450287b701970a1df2fdcb7501bc466a0b5eca Mon Sep 17 00:00:00 2001 From: Alex Petenchea Date: Sat, 24 May 2025 13:49:16 +0000 Subject: [PATCH 2/9] Adding vertex and edge collection skeleton --- arangoasync/collection.py | 77 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/arangoasync/collection.py b/arangoasync/collection.py index 3b4e5a9..adbef25 100644 --- a/arangoasync/collection.py +++ b/arangoasync/collection.py @@ -1,4 +1,9 @@ -__all__ = ["Collection", "StandardCollection"] +__all__ = [ + "Collection", + "EdgeCollection", + "StandardCollection", + "VertexCollection", +] from typing import Any, Generic, List, Optional, Sequence, Tuple, TypeVar, cast @@ -1711,3 +1716,73 @@ def response_handler( return self.deserializer.loads_many(resp.raw_body) return await self._executor.execute(request, response_handler) + + +class VertexCollection(Collection[T, U, V]): + """Vertex collection API wrapper. + + Args: + executor (ApiExecutor): API executor. + name (str): Collection name + graph (str): Graph name. + doc_serializer (Serializer): Document serializer. + doc_deserializer (Deserializer): Document deserializer. + """ + + def __init__( + self, + executor: ApiExecutor, + graph: str, + name: str, + doc_serializer: Serializer[T], + doc_deserializer: Deserializer[U, V], + ) -> None: + super().__init__(executor, name, doc_serializer, doc_deserializer) + self._graph = graph + + def __repr__(self) -> str: + return f"" + + @property + def graph(self) -> str: + """Return the graph name. + + Returns: + str: Graph name. + """ + return self._graph + + +class EdgeCollection(Collection[T, U, V]): + """Edge collection API wrapper. + + Args: + executor (ApiExecutor): API executor. + name (str): Collection name + graph (str): Graph name. + doc_serializer (Serializer): Document serializer. + doc_deserializer (Deserializer): Document deserializer. + """ + + def __init__( + self, + executor: ApiExecutor, + graph: str, + name: str, + doc_serializer: Serializer[T], + doc_deserializer: Deserializer[U, V], + ) -> None: + super().__init__(executor, name, doc_serializer, doc_deserializer) + self._graph = graph + + def __repr__(self) -> str: + return f"" + + @property + def graph(self) -> str: + """Return the graph name. + + Returns: + str: Graph name. + """ + return self._graph From 9dc1353c4a8a9ba9d8cb1f26171de909112d7dfa Mon Sep 17 00:00:00 2001 From: Alex Petenchea Date: Sat, 24 May 2025 14:34:07 +0000 Subject: [PATCH 3/9] Refactoring serializers --- arangoasync/database.py | 100 +++++++++++++++++++++++++++++----------- arangoasync/graph.py | 99 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 170 insertions(+), 29 deletions(-) diff --git a/arangoasync/database.py b/arangoasync/database.py index 058daf0..3cac02d 100644 --- a/arangoasync/database.py +++ b/arangoasync/database.py @@ -88,6 +88,40 @@ class Database: def __init__(self, executor: ApiExecutor) -> None: self._executor = executor + def _get_doc_serializer( + self, + doc_serializer: Optional[Serializer[T]] = None, + ) -> Serializer[T]: + """Figure out the document serializer, defaulting to `Json`. + + Args: + doc_serializer (Serializer | None): Optional serializer. + + Returns: + Serializer: Either the passed serializer or the default one. + """ + if doc_serializer is None: + return cast(Serializer[T], self.serializer) + else: + return doc_serializer + + def _get_doc_deserializer( + self, + doc_deserializer: Optional[Deserializer[U, V]] = None, + ) -> Deserializer[U, V]: + """Figure out the document deserializer, defaulting to `Json`. + + Args: + doc_deserializer (Deserializer | None): Optional deserializer. + + Returns: + Deserializer: Either the passed deserializer or the default one. + """ + if doc_deserializer is None: + return cast(Deserializer[U, V], self.deserializer) + else: + return doc_deserializer + @property def connection(self) -> Connection: """Return the HTTP connection.""" @@ -390,17 +424,11 @@ def collection( Returns: StandardCollection: Collection API wrapper. """ - if doc_serializer is None: - serializer = cast(Serializer[T], self.serializer) - else: - serializer = doc_serializer - if doc_deserializer is None: - deserializer = cast(Deserializer[U, V], self.deserializer) - else: - deserializer = doc_deserializer - return StandardCollection[T, U, V]( - self._executor, name, serializer, deserializer + self._executor, + name, + self._get_doc_serializer(doc_serializer), + self._get_doc_deserializer(doc_deserializer), ) async def collections( @@ -604,16 +632,11 @@ async def create_collection( def response_handler(resp: Response) -> StandardCollection[T, U, V]: if not resp.is_success: raise CollectionCreateError(resp, request) - if doc_serializer is None: - serializer = cast(Serializer[T], self.serializer) - else: - serializer = doc_serializer - if doc_deserializer is None: - deserializer = cast(Deserializer[U, V], self.deserializer) - else: - deserializer = doc_deserializer return StandardCollection[T, U, V]( - self._executor, name, serializer, deserializer + self._executor, + name, + self._get_doc_serializer(doc_serializer), + self._get_doc_deserializer(doc_deserializer), ) return await self._executor.execute(request, response_handler) @@ -661,16 +684,30 @@ def response_handler(resp: Response) -> bool: return await self._executor.execute(request, response_handler) - def graph(self, name: str) -> Graph: + def graph( + self, + name: str, + doc_serializer: Optional[Serializer[T]] = None, + doc_deserializer: Optional[Deserializer[U, V]] = None, + ) -> Graph[T, U, V]: """Return the graph API wrapper. Args: name (str): Graph name. + doc_serializer (Serializer): Custom document serializer. + This will be used only for document operations. + doc_deserializer (Deserializer): Custom document deserializer. + This will be used only for document operations. Returns: Graph: Graph API wrapper. """ - return Graph(self._executor, name) + return Graph[T, U, V]( + self._executor, + name, + self._get_doc_serializer(doc_serializer), + self._get_doc_deserializer(doc_deserializer), + ) async def has_graph(self, name: str) -> Result[bool]: """Check if a graph exists in the database. @@ -720,17 +757,23 @@ def response_handler(resp: Response) -> List[GraphProperties]: async def create_graph( self, name: str, + doc_serializer: Optional[Serializer[T]] = None, + doc_deserializer: Optional[Deserializer[U, V]] = None, edge_definitions: Optional[Sequence[Json]] = None, is_disjoint: Optional[bool] = None, is_smart: Optional[bool] = None, options: Optional[GraphOptions | Json] = None, orphan_collections: Optional[Sequence[str]] = None, wait_for_sync: Optional[bool] = None, - ) -> Result[Graph]: + ) -> Result[Graph[T, U, V]]: """Create a new graph. Args: name (str): Graph name. + doc_serializer (Serializer): Custom document serializer. + This will be used only for document operations. + doc_deserializer (Deserializer): Custom document deserializer. + This will be used only for document operations. edge_definitions (list | None): List of edge definitions, where each edge definition entry is a dictionary with fields "collection" (name of the edge collection), "from" (list of vertex collection names) and "to" @@ -782,10 +825,15 @@ async def create_graph( params=params, ) - def response_handler(resp: Response) -> Graph: - if resp.is_success: - return Graph(self._executor, name) - raise GraphCreateError(resp, request) + def response_handler(resp: Response) -> Graph[T, U, V]: + if not resp.is_success: + raise GraphCreateError(resp, request) + return Graph[T, U, V]( + self._executor, + name, + self._get_doc_serializer(doc_serializer), + self._get_doc_deserializer(doc_deserializer), + ) return await self._executor.execute(request, response_handler) diff --git a/arangoasync/graph.py b/arangoasync/graph.py index 2047d96..6caea22 100644 --- a/arangoasync/graph.py +++ b/arangoasync/graph.py @@ -1,16 +1,43 @@ +__all__ = ["Graph"] + + +from typing import Generic, TypeVar + +from arangoasync.collection import EdgeCollection, VertexCollection +from arangoasync.exceptions import GraphListError from arangoasync.executor import ApiExecutor +from arangoasync.request import Method, Request +from arangoasync.response import Response +from arangoasync.result import Result +from arangoasync.serialization import Deserializer, Serializer +from arangoasync.typings import GraphProperties, Json, Jsons +T = TypeVar("T") # Serializer type +U = TypeVar("U") # Deserializer loads +V = TypeVar("V") # Deserializer loads_many -class Graph: + +class Graph(Generic[T, U, V]): """Graph API wrapper, representing a graph in ArangoDB. Args: - executor: API executor. Required to execute the API requests. + executor (APIExecutor): Required to execute the API requests. + name (str): Graph name. + doc_serializer (Serializer): Document serializer. + doc_deserializer (Deserializer): Document deserializer. """ - def __init__(self, executor: ApiExecutor, name: str) -> None: + def __init__( + self, + executor: ApiExecutor, + name: str, + doc_serializer: Serializer[T], + doc_deserializer: Deserializer[U, V], + ) -> None: self._executor = executor self._name = name + self._doc_serializer = doc_serializer + self._doc_deserializer = doc_deserializer def __repr__(self) -> str: return f"" @@ -19,3 +46,69 @@ def __repr__(self) -> str: def name(self) -> str: """Name of the graph.""" return self._name + + @property + def serializer(self) -> Serializer[Json]: + """Return the serializer.""" + return self._executor.serializer + + @property + def deserializer(self) -> Deserializer[Json, Jsons]: + """Return the deserializer.""" + return self._executor.deserializer + + async def properties(self) -> Result[GraphProperties]: + """Get the properties of the graph. + + Returns: + GraphProperties: Properties of the graph. + + Raises: + GraphListError: If the operation fails. + + References: + - `get-a-graph `__ + """ # noqa: E501 + request = Request(method=Method.GET, endpoint=f"/_api/gharial/{self._name}") + + def response_handler(resp: Response) -> GraphProperties: + if not resp.is_success: + raise GraphListError(resp, request) + body = self.deserializer.loads(resp.raw_body) + return GraphProperties(body["graph"]) + + return await self._executor.execute(request, response_handler) + + def vertex_collection(self, name: str) -> VertexCollection[T, U, V]: + """Returns the vertex collection API wrapper. + + Args: + name (str): Vertex collection name. + + Returns: + VertexCollection: Vertex collection API wrapper. + """ + return VertexCollection[T, U, V]( + executor=self._executor, + graph=self._name, + name=name, + doc_serializer=self._doc_serializer, + doc_deserializer=self._doc_deserializer, + ) + + def edge_collection(self, name: str) -> EdgeCollection[T, U, V]: + """Returns the edge collection API wrapper. + + Args: + name (str): Edge collection name. + + Returns: + EdgeCollection: Edge collection API wrapper. + """ + return EdgeCollection[T, U, V]( + executor=self._executor, + graph=self._name, + name=name, + doc_serializer=self._doc_serializer, + doc_deserializer=self._doc_deserializer, + ) From 128d328c9468f71713273e1258da8b2a718cb65b Mon Sep 17 00:00:00 2001 From: Alex Petenchea Date: Sat, 24 May 2025 14:38:09 +0000 Subject: [PATCH 4/9] Using randomized graph name --- tests/helpers.py | 9 +++++++++ tests/test_graph.py | 39 ++++++++++++++++++++++++++------------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index cf8b3cb..8e91c26 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -19,6 +19,15 @@ def generate_col_name(): return f"test_collection_{uuid4().hex}" +def generate_graph_name(): + """Generate and return a random graph name. + + Returns: + str: Random graph name. + """ + return f"test_graph_{uuid4().hex}" + + def generate_username(): """Generate and return a random username. diff --git a/tests/test_graph.py b/tests/test_graph.py index 0967ff9..6eb01f0 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -1,37 +1,50 @@ import pytest from arangoasync.exceptions import GraphCreateError, GraphDeleteError, GraphListError +from tests.helpers import generate_graph_name @pytest.mark.asyncio async def test_graph_basic(db, bad_db): + graph1_name = generate_graph_name() # Test the graph representation - graph = db.graph("test_graph") - assert graph.name == "test_graph" - assert "test_graph" in repr(graph) + graph = db.graph(graph1_name) + assert graph.name == graph1_name + assert graph1_name in repr(graph) # Cannot find any graph + graph2_name = generate_graph_name() assert await db.graphs() == [] - assert await db.has_graph("fake_graph") is False + assert await db.has_graph(graph2_name) is False with pytest.raises(GraphListError): - await bad_db.has_graph("fake_graph") + await bad_db.has_graph(graph2_name) with pytest.raises(GraphListError): await bad_db.graphs() # Create a graph - graph = await db.create_graph("test_graph", wait_for_sync=True) - assert graph.name == "test_graph" + graph = await db.create_graph(graph1_name, wait_for_sync=True) + assert graph.name == graph1_name with pytest.raises(GraphCreateError): - await bad_db.create_graph("test_graph") + await bad_db.create_graph(graph1_name) # Check if the graph exists - assert await db.has_graph("test_graph") is True + assert await db.has_graph(graph1_name) is True graphs = await db.graphs() assert len(graphs) == 1 - assert graphs[0].name == "test_graph" + assert graphs[0].name == graph1_name # Delete the graph - await db.delete_graph("test_graph") - assert await db.has_graph("test_graph") is False + await db.delete_graph(graph1_name) + assert await db.has_graph(graph1_name) is False with pytest.raises(GraphDeleteError): - await bad_db.delete_graph("test_graph") + await bad_db.delete_graph(graph1_name) + + +async def test_graph_properties(db): + # Create a graph + name = generate_graph_name() + graph = await db.create_graph(name) + + # Get the properties of the graph + properties = await graph.properties() + assert properties.name == name From 7a09541f75e05d8eace0deeb20471546d6544ace Mon Sep 17 00:00:00 2001 From: Alex Petenchea Date: Sat, 24 May 2025 17:31:03 +0000 Subject: [PATCH 5/9] Improving helper types --- arangoasync/typings.py | 93 +++++++++++++++++++++++++++++++++++++++--- tests/test_typings.py | 18 ++++++++ 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/arangoasync/typings.py b/arangoasync/typings.py index 86c32fd..4c0af22 100644 --- a/arangoasync/typings.py +++ b/arangoasync/typings.py @@ -1692,6 +1692,32 @@ def __init__(self, data: Json) -> None: def name(self) -> str: return cast(str, self._data["name"]) + @property + def is_smart(self) -> bool: + """Check if the graph is a smart graph.""" + return cast(bool, self._data.get("isSmart", False)) + + @property + def is_satellite(self) -> bool: + """Check if the graph is a satellite graph.""" + return cast(bool, self._data.get("isSatellite", False)) + + @property + def number_of_shards(self) -> Optional[int]: + return cast(Optional[int], self._data.get("numberOfShards")) + + @property + def replication_factor(self) -> Optional[int | str]: + return cast(Optional[int | str], self._data.get("replicationFactor")) + + @property + def min_replication_factor(self) -> Optional[int]: + return cast(Optional[int], self._data.get("minReplicationFactor")) + + @property + def write_concern(self) -> Optional[int]: + return cast(Optional[int], self._data.get("writeConcern")) + @property def edge_definitions(self) -> Jsons: return cast(Jsons, self._data.get("edgeDefinitions", list())) @@ -1720,15 +1746,18 @@ class GraphOptions(JsonWrapper): Enterprise Edition. write_concern (int | None): The write concern for new collections in the graph. + + References: + - `create-a-graph `__ """ # noqa: E501 def __init__( self, - number_of_shards: Optional[int], - replication_factor: Optional[int | str], - satellites: Optional[List[str]], - smart_graph_attribute: Optional[str], - write_concern: Optional[int], + number_of_shards: Optional[int] = None, + replication_factor: Optional[int | str] = None, + satellites: Optional[List[str]] = None, + smart_graph_attribute: Optional[str] = None, + write_concern: Optional[int] = None, ) -> None: data: Json = dict() if number_of_shards is not None: @@ -1762,3 +1791,57 @@ def smart_graph_attribute(self) -> Optional[str]: @property def write_concern(self) -> Optional[int]: return cast(Optional[int], self._data.get("writeConcern")) + + +class VertexCollectionOptions(JsonWrapper): + """Special options for vertex collection creation. + + Args: + satellites (list): An array of collection names that is used to create + SatelliteCollections for a (Disjoint) SmartGraph using + SatelliteCollections (Enterprise Edition only). Each array element must + be a string and a valid collection name. + + References: + - `add-a-vertex-collection `__ + """ # noqa: E501 + + def __init__( + self, + satellites: Optional[List[str]] = None, + ) -> None: + data: Json = dict() + if satellites is not None: + data["satellites"] = satellites + super().__init__(data) + + @property + def satellites(self) -> Optional[List[str]]: + return cast(Optional[List[str]], self._data.get("satellites")) + + +class EdgeDefinitionOptions(JsonWrapper): + """Special options for edge definition creation. + + Args: + satellites (list): An array of collection names that is used to create + SatelliteCollections for a (Disjoint) SmartGraph using + SatelliteCollections (Enterprise Edition only). Each array element must + be a string and a valid collection name. + + References: + - `add-an-edge-definition `__ + """ # noqa: E501 + + def __init__( + self, + satellites: Optional[List[str]] = None, + ) -> None: + data: Json = dict() + if satellites is not None: + data["satellites"] = satellites + super().__init__(data) + + @property + def satellites(self) -> Optional[List[str]]: + return cast(Optional[List[str]], self._data.get("satellites")) diff --git a/tests/test_typings.py b/tests/test_typings.py index 7a40c33..fd04fa1 100644 --- a/tests/test_typings.py +++ b/tests/test_typings.py @@ -4,6 +4,7 @@ CollectionInfo, CollectionStatus, CollectionType, + EdgeDefinitionOptions, GraphOptions, GraphProperties, JsonWrapper, @@ -17,6 +18,7 @@ QueryProperties, QueryTrackingConfiguration, UserInfo, + VertexCollectionOptions, ) @@ -368,3 +370,19 @@ def test_GraphOptions(): assert graph_options.satellites == ["satellite1", "satellite2"] assert graph_options.smart_graph_attribute == "region" assert graph_options.write_concern == 1 + + +def test_VertexCollectionOptions(): + options = VertexCollectionOptions( + satellites=["col1", "col2"], + ) + + assert options.satellites == ["col1", "col2"] + + +def test_EdgeDefinitionOptions(): + options = EdgeDefinitionOptions( + satellites=["col1", "col2"], + ) + + assert options.satellites == ["col1", "col2"] From 6ea9259e801bff65eff04cc72175ad95f5638e9d Mon Sep 17 00:00:00 2001 From: Alex Petenchea Date: Sat, 24 May 2025 18:02:13 +0000 Subject: [PATCH 6/9] Facilitating edge and vertex collection creation --- arangoasync/exceptions.py | 32 ++++++++++ arangoasync/graph.py | 123 ++++++++++++++++++++++++++++++++++++-- tests/test_graph.py | 43 ++++++++++++- 3 files changed, 191 insertions(+), 7 deletions(-) diff --git a/arangoasync/exceptions.py b/arangoasync/exceptions.py index a62e64e..1f09b6d 100644 --- a/arangoasync/exceptions.py +++ b/arangoasync/exceptions.py @@ -263,6 +263,26 @@ class DocumentUpdateError(ArangoServerError): """Failed to update document.""" +class EdgeDefinitionListError(ArangoServerError): + """Failed to retrieve edge definitions.""" + + +class EdgeDefinitionCreateError(ArangoServerError): + """Failed to create edge definition.""" + + +class EdgeDefinitionReplaceError(ArangoServerError): + """Failed to replace edge definition.""" + + +class EdgeDefinitionDeleteError(ArangoServerError): + """Failed to delete edge definition.""" + + +class EdgeListError(ArangoServerError): + """Failed to retrieve edges coming in and out of a vertex.""" + + class GraphCreateError(ArangoServerError): """Failed to create the graph.""" @@ -389,3 +409,15 @@ class UserReplaceError(ArangoServerError): class UserUpdateError(ArangoServerError): """Failed to update user.""" + + +class VertexCollectionCreateError(ArangoServerError): + """Failed to create vertex collection.""" + + +class VertexCollectionDeleteError(ArangoServerError): + """Failed to delete vertex collection.""" + + +class VertexCollectionListError(ArangoServerError): + """Failed to retrieve vertex collections.""" diff --git a/arangoasync/graph.py b/arangoasync/graph.py index 6caea22..2104ff3 100644 --- a/arangoasync/graph.py +++ b/arangoasync/graph.py @@ -1,16 +1,26 @@ __all__ = ["Graph"] -from typing import Generic, TypeVar +from typing import Generic, Optional, Sequence, TypeVar from arangoasync.collection import EdgeCollection, VertexCollection -from arangoasync.exceptions import GraphListError +from arangoasync.exceptions import ( + EdgeDefinitionCreateError, + GraphListError, + VertexCollectionCreateError, +) from arangoasync.executor import ApiExecutor from arangoasync.request import Method, Request from arangoasync.response import Response from arangoasync.result import Result from arangoasync.serialization import Deserializer, Serializer -from arangoasync.typings import GraphProperties, Json, Jsons +from arangoasync.typings import ( + EdgeDefinitionOptions, + GraphProperties, + Json, + Jsons, + VertexCollectionOptions, +) T = TypeVar("T") # Serializer type U = TypeVar("U") # Deserializer loads @@ -67,7 +77,7 @@ async def properties(self) -> Result[GraphProperties]: GraphListError: If the operation fails. References: - - `get-a-graph `__ + - `get-a-graph `__ """ # noqa: E501 request = Request(method=Method.GET, endpoint=f"/_api/gharial/{self._name}") @@ -96,6 +106,48 @@ def vertex_collection(self, name: str) -> VertexCollection[T, U, V]: doc_deserializer=self._doc_deserializer, ) + async def create_vertex_collection( + self, + name: str, + options: Optional[VertexCollectionOptions | Json] = None, + ) -> Result[VertexCollection[T, U, V]]: + """Create a vertex collection in the graph. + + Args: + name (str): Vertex collection name. + options (dict | VertexCollectionOptions | None): Extra options for + creating vertex collections. + + Returns: + VertexCollection: Vertex collection API wrapper. + + Raises: + VertexCollectionCreateError: If the operation fails. + + References: + - `add-a-vertex-collection `__ + """ # noqa: E501 + data: Json = {"collection": name} + + if options is not None: + if isinstance(options, VertexCollectionOptions): + data["options"] = options.to_dict() + else: + data["options"] = options + + request = Request( + method=Method.POST, + endpoint=f"/_api/gharial/{self._name}/vertex", + data=self.serializer.dumps(data), + ) + + def response_handler(resp: Response) -> VertexCollection[T, U, V]: + if not resp.is_success: + raise VertexCollectionCreateError(resp, request) + return self.vertex_collection(name) + + return await self._executor.execute(request, response_handler) + def edge_collection(self, name: str) -> EdgeCollection[T, U, V]: """Returns the edge collection API wrapper. @@ -112,3 +164,66 @@ def edge_collection(self, name: str) -> EdgeCollection[T, U, V]: doc_serializer=self._doc_serializer, doc_deserializer=self._doc_deserializer, ) + + async def create_edge_definition( + self, + edge_collection: str, + from_vertex_collections: Sequence[str], + to_vertex_collections: Sequence[str], + options: Optional[EdgeDefinitionOptions | Json] = None, + ) -> Result[EdgeCollection[T, U, V]]: + """Create an edge definition in the graph. + + This edge definition has to contain a collection and an array of each from + and to vertex collections. + + .. code-block:: python + + { + "edge_collection": "edge_collection_name", + "from_vertex_collections": ["from_vertex_collection_name"], + "to_vertex_collections": ["to_vertex_collection_name"] + } + + Args: + edge_collection (str): Edge collection name. + from_vertex_collections (list): List of vertex collections + that can be used as the "from" vertex in edges. + to_vertex_collections (list): List of vertex collections + that can be used as the "to" vertex in edges. + options (dict | EdgeDefinitionOptions | None): Extra options for + creating edge definitions. + + Returns: + EdgeCollection: Edge collection API wrapper. + + Raises: + EdgeDefinitionCreateError: If the operation fails. + + References: + - `add-an-edge-definition `__ + """ # noqa: E501 + data: Json = { + "collection": edge_collection, + "from": from_vertex_collections, + "to": to_vertex_collections, + } + + if options is not None: + if isinstance(options, VertexCollectionOptions): + data["options"] = options.to_dict() + else: + data["options"] = options + + request = Request( + method=Method.POST, + endpoint=f"/_api/gharial/{self._name}/edge", + data=self.serializer.dumps(data), + ) + + def response_handler(resp: Response) -> EdgeCollection[T, U, V]: + if not resp.is_success: + raise EdgeDefinitionCreateError(resp, request) + return self.edge_collection(edge_collection) + + return await self._executor.execute(request, response_handler) diff --git a/tests/test_graph.py b/tests/test_graph.py index 6eb01f0..5b0124a 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -1,7 +1,8 @@ import pytest from arangoasync.exceptions import GraphCreateError, GraphDeleteError, GraphListError -from tests.helpers import generate_graph_name +from arangoasync.typings import GraphOptions +from tests.helpers import generate_col_name, generate_graph_name @pytest.mark.asyncio @@ -40,11 +41,47 @@ async def test_graph_basic(db, bad_db): await bad_db.delete_graph(graph1_name) -async def test_graph_properties(db): +async def test_graph_properties(db, cluster, enterprise): # Create a graph name = generate_graph_name() - graph = await db.create_graph(name) + is_smart = cluster and enterprise + options = GraphOptions(number_of_shards=3) + graph = await db.create_graph(name, is_smart=is_smart, options=options) + + # Create first vertex collection + vcol_name = generate_col_name() + vcol = await graph.create_vertex_collection(vcol_name) + assert vcol.name == vcol_name # Get the properties of the graph properties = await graph.properties() assert properties.name == name + assert properties.is_smart == is_smart + assert properties.number_of_shards == options.number_of_shards + assert properties.orphan_collections == [vcol_name] + + # Create second vertex collection + vcol2_name = generate_col_name() + vcol2 = await graph.create_vertex_collection(vcol2_name) + assert vcol2.name == vcol2_name + properties = await graph.properties() + assert len(properties.orphan_collections) == 2 + + # Create an edge definition + edge_name = generate_col_name() + edge_col = await graph.create_edge_definition( + edge_name, + from_vertex_collections=[vcol_name], + to_vertex_collections=[vcol2_name], + ) + assert edge_col.name == edge_name + + # There should be no more orphan collections + properties = await graph.properties() + assert len(properties.orphan_collections) == 0 + assert len(properties.edge_definitions) == 1 + assert properties.edge_definitions[0]["collection"] == edge_name + assert len(properties.edge_definitions[0]["from"]) == 1 + assert properties.edge_definitions[0]["from"][0] == vcol_name + assert len(properties.edge_definitions[0]["to"]) == 1 + assert properties.edge_definitions[0]["to"][0] == vcol2_name From 526b1355f8a64381ebac90f13119784beefb755c Mon Sep 17 00:00:00 2001 From: Alex Petenchea Date: Sat, 24 May 2025 19:40:06 +0000 Subject: [PATCH 7/9] Vertex collection management --- arangoasync/exceptions.py | 4 ++ arangoasync/graph.py | 88 +++++++++++++++++++++++++++++++++++++-- tests/conftest.py | 14 ++++++- tests/test_graph.py | 45 +++++++++++++++++++- 4 files changed, 144 insertions(+), 7 deletions(-) diff --git a/arangoasync/exceptions.py b/arangoasync/exceptions.py index 1f09b6d..3024264 100644 --- a/arangoasync/exceptions.py +++ b/arangoasync/exceptions.py @@ -295,6 +295,10 @@ class GraphListError(ArangoServerError): """Failed to retrieve graphs.""" +class GraphPropertiesError(ArangoServerError): + """Failed to retrieve graph properties.""" + + class IndexCreateError(ArangoServerError): """Failed to create collection index.""" diff --git a/arangoasync/graph.py b/arangoasync/graph.py index 2104ff3..3fcc4bc 100644 --- a/arangoasync/graph.py +++ b/arangoasync/graph.py @@ -1,13 +1,15 @@ __all__ = ["Graph"] -from typing import Generic, Optional, Sequence, TypeVar +from typing import Generic, List, Optional, Sequence, TypeVar from arangoasync.collection import EdgeCollection, VertexCollection from arangoasync.exceptions import ( EdgeDefinitionCreateError, - GraphListError, + GraphPropertiesError, VertexCollectionCreateError, + VertexCollectionDeleteError, + VertexCollectionListError, ) from arangoasync.executor import ApiExecutor from arangoasync.request import Method, Request @@ -74,7 +76,7 @@ async def properties(self) -> Result[GraphProperties]: GraphProperties: Properties of the graph. Raises: - GraphListError: If the operation fails. + GraphProperties: If the operation fails. References: - `get-a-graph `__ @@ -83,7 +85,7 @@ async def properties(self) -> Result[GraphProperties]: def response_handler(resp: Response) -> GraphProperties: if not resp.is_success: - raise GraphListError(resp, request) + raise GraphPropertiesError(resp, request) body = self.deserializer.loads(resp.raw_body) return GraphProperties(body["graph"]) @@ -106,6 +108,56 @@ def vertex_collection(self, name: str) -> VertexCollection[T, U, V]: doc_deserializer=self._doc_deserializer, ) + async def vertex_collections(self) -> Result[List[str]]: + """Get the names of all vertex collections in the graph. + + Returns: + list: List of vertex collection names. + + Raises: + VertexCollectionListError: If the operation fails. + + References: + - `list-vertex-collections `__ + """ # noqa: E501 + request = Request( + method=Method.GET, + endpoint=f"/_api/gharial/{self._name}/vertex", + ) + + def response_handler(resp: Response) -> List[str]: + if not resp.is_success: + raise VertexCollectionListError(resp, request) + body = self.deserializer.loads(resp.raw_body) + return list(sorted(set(body["collections"]))) + + return await self._executor.execute(request, response_handler) + + async def has_vertex_collection(self, name: str) -> Result[bool]: + """Check if the graph has the given vertex collection. + + Args: + name (str): Vertex collection mame. + + Returns: + bool: `True` if the graph has the vertex collection, `False` otherwise. + + Raises: + VertexCollectionListError: If the operation fails. + """ + request = Request( + method=Method.GET, + endpoint=f"/_api/gharial/{self._name}/vertex", + ) + + def response_handler(resp: Response) -> bool: + if not resp.is_success: + raise VertexCollectionListError(resp, request) + body = self.deserializer.loads(resp.raw_body) + return name in body["collections"] + + return await self._executor.execute(request, response_handler) + async def create_vertex_collection( self, name: str, @@ -148,6 +200,34 @@ def response_handler(resp: Response) -> VertexCollection[T, U, V]: return await self._executor.execute(request, response_handler) + async def delete_vertex_collection(self, name: str, purge: bool = False) -> None: + """Remove a vertex collection from the graph. + + Args: + name (str): Vertex collection name. + purge (bool): If set to `True`, the vertex collection is not just deleted + from the graph but also from the database completely. Note that you + cannot remove vertex collections that are used in one of the edge + definitions of the graph. + + Raises: + VertexCollectionDeleteError: If the operation fails. + + References: + - `remove-a-vertex-collection `__ + """ # noqa: E501 + request = Request( + method=Method.DELETE, + endpoint=f"/_api/gharial/{self._name}/vertex/{name}", + params={"dropCollection": purge}, + ) + + def response_handler(resp: Response) -> None: + if not resp.is_success: + raise VertexCollectionDeleteError(resp, request) + + await self._executor.execute(request, response_handler) + def edge_collection(self, name: str) -> EdgeCollection[T, U, V]: """Returns the edge collection API wrapper. diff --git a/tests/conftest.py b/tests/conftest.py index e91a591..36d323e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,12 @@ from arangoasync.auth import Auth, JwtToken from arangoasync.client import ArangoClient from arangoasync.typings import UserInfo -from tests.helpers import generate_col_name, generate_db_name, generate_username +from tests.helpers import ( + generate_col_name, + generate_db_name, + generate_graph_name, + generate_username, +) @dataclass @@ -19,6 +24,7 @@ class GlobalData: secret: str = None token: JwtToken = None sys_db_name: str = "_system" + graph_name: str = "test_graph" username: str = generate_username() cluster: bool = False enterprise: bool = False @@ -64,6 +70,7 @@ def pytest_configure(config): global_data.token = JwtToken.generate_token(global_data.secret) global_data.cluster = config.getoption("cluster") global_data.enterprise = config.getoption("enterprise") + global_data.graph_name = generate_graph_name() async def get_db_version(): async with ArangoClient(hosts=global_data.url) as client: @@ -215,6 +222,11 @@ async def bad_db(arango_client): ) +@pytest_asyncio.fixture +def bad_graph(bad_db): + return bad_db.graph(global_data.graph_name) + + @pytest_asyncio.fixture async def doc_col(db): col_name = generate_col_name() diff --git a/tests/test_graph.py b/tests/test_graph.py index 5b0124a..98ad038 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -1,6 +1,14 @@ import pytest -from arangoasync.exceptions import GraphCreateError, GraphDeleteError, GraphListError +from arangoasync.exceptions import ( + GraphCreateError, + GraphDeleteError, + GraphListError, + GraphPropertiesError, + VertexCollectionCreateError, + VertexCollectionDeleteError, + VertexCollectionListError, +) from arangoasync.typings import GraphOptions from tests.helpers import generate_col_name, generate_graph_name @@ -41,13 +49,16 @@ async def test_graph_basic(db, bad_db): await bad_db.delete_graph(graph1_name) -async def test_graph_properties(db, cluster, enterprise): +async def test_graph_properties(db, bad_graph, cluster, enterprise): # Create a graph name = generate_graph_name() is_smart = cluster and enterprise options = GraphOptions(number_of_shards=3) graph = await db.create_graph(name, is_smart=is_smart, options=options) + with pytest.raises(GraphPropertiesError): + await bad_graph.properties() + # Create first vertex collection vcol_name = generate_col_name() vcol = await graph.create_vertex_collection(vcol_name) @@ -85,3 +96,33 @@ async def test_graph_properties(db, cluster, enterprise): assert properties.edge_definitions[0]["from"][0] == vcol_name assert len(properties.edge_definitions[0]["to"]) == 1 assert properties.edge_definitions[0]["to"][0] == vcol2_name + + +async def test_vertex_collections(db, bad_graph): + # Test errors + with pytest.raises(VertexCollectionCreateError): + await bad_graph.create_vertex_collection("bad_col") + with pytest.raises(VertexCollectionListError): + await bad_graph.vertex_collections() + with pytest.raises(VertexCollectionListError): + await bad_graph.has_vertex_collection("bad_col") + with pytest.raises(VertexCollectionDeleteError): + await bad_graph.delete_vertex_collection("bad_col") + + # Create graph + graph = await db.create_graph(generate_graph_name()) + + # Create vertex collections + names = [generate_col_name() for _ in range(3)] + cols = [await graph.create_vertex_collection(name) for name in names] + + # List vertex collection + col_list = await graph.vertex_collections() + assert len(col_list) == 3 + for c in cols: + assert c.name in col_list + assert await graph.has_vertex_collection(c.name) + + # Delete collections + await graph.delete_vertex_collection(names[0]) + assert await graph.has_vertex_collection(names[0]) is False From 9ccbe699e40b2dcb10c95fc62398f2b347b7a2b1 Mon Sep 17 00:00:00 2001 From: Alex Petenchea Date: Sat, 24 May 2025 21:25:35 +0000 Subject: [PATCH 8/9] Edge collection management --- arangoasync/exceptions.py | 4 + arangoasync/graph.py | 180 +++++++++++++++++++++++++++++++++++++- arangoasync/typings.py | 41 +++++++++ tests/test_graph.py | 67 ++++++++++++++ 4 files changed, 290 insertions(+), 2 deletions(-) diff --git a/arangoasync/exceptions.py b/arangoasync/exceptions.py index 3024264..c4ee40a 100644 --- a/arangoasync/exceptions.py +++ b/arangoasync/exceptions.py @@ -263,6 +263,10 @@ class DocumentUpdateError(ArangoServerError): """Failed to update document.""" +class EdgeCollectionListError(ArangoServerError): + """Failed to retrieve edge collections.""" + + class EdgeDefinitionListError(ArangoServerError): """Failed to retrieve edge definitions.""" diff --git a/arangoasync/graph.py b/arangoasync/graph.py index 3fcc4bc..edde3a2 100644 --- a/arangoasync/graph.py +++ b/arangoasync/graph.py @@ -1,11 +1,15 @@ __all__ = ["Graph"] -from typing import Generic, List, Optional, Sequence, TypeVar +from typing import Generic, List, Optional, Sequence, TypeVar, cast from arangoasync.collection import EdgeCollection, VertexCollection from arangoasync.exceptions import ( + EdgeCollectionListError, EdgeDefinitionCreateError, + EdgeDefinitionDeleteError, + EdgeDefinitionListError, + EdgeDefinitionReplaceError, GraphPropertiesError, VertexCollectionCreateError, VertexCollectionDeleteError, @@ -21,6 +25,7 @@ GraphProperties, Json, Jsons, + Params, VertexCollectionOptions, ) @@ -129,7 +134,7 @@ def response_handler(resp: Response) -> List[str]: if not resp.is_success: raise VertexCollectionListError(resp, request) body = self.deserializer.loads(resp.raw_body) - return list(sorted(set(body["collections"]))) + return list(sorted(body["collections"])) return await self._executor.execute(request, response_handler) @@ -245,6 +250,76 @@ def edge_collection(self, name: str) -> EdgeCollection[T, U, V]: doc_deserializer=self._doc_deserializer, ) + async def edge_definitions(self) -> Result[Jsons]: + """Return the edge definitions from the graph. + + Returns: + list: List of edge definitions. + + Raises: + EdgeDefinitionListError: If the operation fails. + """ + request = Request(method=Method.GET, endpoint=f"/_api/gharial/{self._name}") + + def response_handler(resp: Response) -> Jsons: + if not resp.is_success: + raise EdgeDefinitionListError(resp, request) + body = self.deserializer.loads(resp.raw_body) + properties = GraphProperties(body["graph"]) + edge_definitions = properties.format( + GraphProperties.compatibility_formatter + )["edge_definitions"] + return cast(Jsons, edge_definitions) + + return await self._executor.execute(request, response_handler) + + async def has_edge_definition(self, name: str) -> Result[bool]: + """Check if the graph has the given edge definition. + + Returns: + bool: `True` if the graph has the edge definitions, `False` otherwise. + + Raises: + EdgeDefinitionListError: If the operation fails. + """ + request = Request(method=Method.GET, endpoint=f"/_api/gharial/{self._name}") + + def response_handler(resp: Response) -> bool: + if not resp.is_success: + raise EdgeDefinitionListError(resp, request) + body = self.deserializer.loads(resp.raw_body) + return any( + edge_definition["collection"] == name + for edge_definition in body["graph"]["edgeDefinitions"] + ) + + return await self._executor.execute(request, response_handler) + + async def edge_collections(self) -> Result[List[str]]: + """Get the names of all edge collections in the graph. + + Returns: + list: List of edge collection names. + + Raises: + EdgeCollectionListError: If the operation fails. + + References: + - `list-edge-collections `__ + """ # noqa: E501 + request = Request( + method=Method.GET, + endpoint=f"/_api/gharial/{self._name}/edge", + ) + + def response_handler(resp: Response) -> List[str]: + if not resp.is_success: + raise EdgeCollectionListError(resp, request) + body = self.deserializer.loads(resp.raw_body) + return list(sorted(body["collections"])) + + return await self._executor.execute(request, response_handler) + async def create_edge_definition( self, edge_collection: str, @@ -307,3 +382,104 @@ def response_handler(resp: Response) -> EdgeCollection[T, U, V]: return self.edge_collection(edge_collection) return await self._executor.execute(request, response_handler) + + async def replace_edge_definition( + self, + edge_collection: str, + from_vertex_collections: Sequence[str], + to_vertex_collections: Sequence[str], + options: Optional[EdgeDefinitionOptions | Json] = None, + wait_for_sync: Optional[bool] = None, + drop_collections: Optional[bool] = None, + ) -> Result[EdgeCollection[T, U, V]]: + """Replace an edge definition. + + Args: + edge_collection (str): Edge collection name. + from_vertex_collections (list): Names of "from" vertex collections. + to_vertex_collections (list): Names of "to" vertex collections. + options (dict | EdgeDefinitionOptions | None): Extra options for + modifying collections withing this edge definition. + wait_for_sync (bool | None): If set to `True`, the operation waits for + data to be synced to disk before returning. + drop_collections (bool | None): Drop the edge collection in addition to + removing it from the graph. The collection is only dropped if it is + not used in other graphs. + + Returns: + EdgeCollection: API wrapper. + + Raises: + EdgeDefinitionReplaceError: If the operation fails. + + References: + - `replace-an-edge-definition `__ + """ # noqa: E501 + data: Json = { + "collection": edge_collection, + "from": from_vertex_collections, + "to": to_vertex_collections, + } + if options is not None: + if isinstance(options, VertexCollectionOptions): + data["options"] = options.to_dict() + else: + data["options"] = options + + params: Params = {} + if wait_for_sync is not None: + params["waitForSync"] = wait_for_sync + if drop_collections is not None: + params["dropCollections"] = drop_collections + + request = Request( + method=Method.PUT, + endpoint=f"/_api/gharial/{self._name}/edge/{edge_collection}", + data=self.serializer.dumps(data), + params=params, + ) + + def response_handler(resp: Response) -> EdgeCollection[T, U, V]: + if resp.is_success: + return self.edge_collection(edge_collection) + raise EdgeDefinitionReplaceError(resp, request) + + return await self._executor.execute(request, response_handler) + + async def delete_edge_definition( + self, + name: str, + purge: bool = False, + wait_for_sync: Optional[bool] = None, + ) -> None: + """Delete an edge definition from the graph. + + Args: + name (str): Edge collection name. + purge (bool): If set to `True`, the edge definition is not just removed + from the graph but the edge collection is also deleted completely + from the database. + wait_for_sync (bool | None): If set to `True`, the operation waits for + changes to be synced to disk before returning. + + Raises: + EdgeDefinitionDeleteError: If the operation fails. + + References: + - `remove-an-edge-definition `__ + """ # noqa: E501 + params: Params = {"dropCollections": purge} + if wait_for_sync is not None: + params["waitForSync"] = wait_for_sync + + request = Request( + method=Method.DELETE, + endpoint=f"/_api/gharial/{self._name}/edge/{name}", + params=params, + ) + + def response_handler(resp: Response) -> None: + if not resp.is_success: + raise EdgeDefinitionDeleteError(resp, request) + + await self._executor.execute(request, response_handler) diff --git a/arangoasync/typings.py b/arangoasync/typings.py index 4c0af22..280e27e 100644 --- a/arangoasync/typings.py +++ b/arangoasync/typings.py @@ -1726,6 +1726,47 @@ def edge_definitions(self) -> Jsons: def orphan_collections(self) -> List[str]: return cast(List[str], self._data.get("orphanCollections", list())) + @staticmethod + def compatibility_formatter(data: Json) -> Json: + result: Json = {} + + if "_id" in data: + result["id"] = data["_id"] + if "_key" in data: + result["key"] = data["_key"] + if "name" in data: + result["name"] = data["name"] + if "_rev" in data: + result["revision"] = data["_rev"] + if "orphanCollections" in data: + result["orphan_collection"] = data["orphanCollections"] + if "edgeDefinitions" in data: + result["edge_definitions"] = [ + { + "edge_collection": edge_definition["collection"], + "from_vertex_collections": edge_definition["from"], + "to_vertex_collections": edge_definition["to"], + } + for edge_definition in data["edgeDefinitions"] + ] + if "isSmart" in data: + result["smart"] = data["isSmart"] + if "isDisjoint" in data: + result["disjoint"] = data["isDisjoint"] + if "isSatellite" in data: + result["is_satellite"] = data["isSatellite"] + if "smartGraphAttribute" in data: + result["smart_field"] = data["smartGraphAttribute"] + if "numberOfShards" in data: + result["shard_count"] = data["numberOfShards"] + if "replicationFactor" in data: + result["replication_factor"] = data["replicationFactor"] + if "minReplicationFactor" in data: + result["min_replication_factor"] = data["minReplicationFactor"] + if "writeConcern" in data: + result["write_concern"] = data["writeConcern"] + return result + class GraphOptions(JsonWrapper): """Special options for graph creation. diff --git a/tests/test_graph.py b/tests/test_graph.py index 98ad038..4abda65 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -1,6 +1,10 @@ import pytest from arangoasync.exceptions import ( + EdgeCollectionListError, + EdgeDefinitionDeleteError, + EdgeDefinitionListError, + EdgeDefinitionReplaceError, GraphCreateError, GraphDeleteError, GraphListError, @@ -126,3 +130,66 @@ async def test_vertex_collections(db, bad_graph): # Delete collections await graph.delete_vertex_collection(names[0]) assert await graph.has_vertex_collection(names[0]) is False + + +async def test_edge_collections(db, bad_graph): + # Test errors + with pytest.raises(EdgeDefinitionListError): + await bad_graph.edge_definitions() + with pytest.raises(EdgeDefinitionListError): + await bad_graph.has_edge_definition("bad_col") + with pytest.raises(EdgeCollectionListError): + await bad_graph.edge_collections() + with pytest.raises(EdgeDefinitionReplaceError): + await bad_graph.replace_edge_definition("foo", ["bar1"], ["bar2"]) + with pytest.raises(EdgeDefinitionDeleteError): + await bad_graph.delete_edge_definition("foo") + + # Create full graph + name = generate_graph_name() + graph = await db.create_graph(name) + vcol_name = generate_col_name() + await graph.create_vertex_collection(vcol_name) + vcol2_name = generate_col_name() + await graph.create_vertex_collection(vcol2_name) + edge_name = generate_col_name() + edge_col = await graph.create_edge_definition( + edge_name, + from_vertex_collections=[vcol_name], + to_vertex_collections=[vcol2_name], + ) + assert edge_col.name == edge_name + + # List edge definitions + edge_definitions = await graph.edge_definitions() + assert len(edge_definitions) == 1 + assert "edge_collection" in edge_definitions[0] + assert "from_vertex_collections" in edge_definitions[0] + assert "to_vertex_collections" in edge_definitions[0] + assert await graph.has_edge_definition(edge_name) is True + assert await graph.has_edge_definition("bad_edge") is False + + edge_cols = await graph.edge_collections() + assert len(edge_cols) == 1 + assert edge_name in edge_cols + + # Replace the edge definition + new_from_collections = [vcol2_name] + new_to_collections = [vcol_name] + replaced_edge_col = await graph.replace_edge_definition( + edge_name, + from_vertex_collections=new_from_collections, + to_vertex_collections=new_to_collections, + ) + assert replaced_edge_col.name == edge_name + + # Verify the updated edge definition + edge_definitions = await graph.edge_definitions() + assert len(edge_definitions) == 1 + assert edge_definitions[0]["edge_collection"] == edge_name + assert edge_definitions[0]["from_vertex_collections"] == new_from_collections + assert edge_definitions[0]["to_vertex_collections"] == new_to_collections + + # Delete the edge definition + await graph.delete_edge_definition(edge_name) + assert await graph.has_edge_definition(edge_name) is False From aa7f0c55960f63c7b6ca3d7c467781653e00a72a Mon Sep 17 00:00:00 2001 From: Alex Petenchea Date: Sat, 24 May 2025 21:38:26 +0000 Subject: [PATCH 9/9] Adding cluster testcase --- tests/test_graph.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_graph.py b/tests/test_graph.py index 4abda65..c46e0ae 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -72,7 +72,8 @@ async def test_graph_properties(db, bad_graph, cluster, enterprise): properties = await graph.properties() assert properties.name == name assert properties.is_smart == is_smart - assert properties.number_of_shards == options.number_of_shards + if cluster: + assert properties.number_of_shards == options.number_of_shards assert properties.orphan_collections == [vcol_name] # Create second vertex collection