From 23fcd0cc7237479cf164b4f42309a5276055509d Mon Sep 17 00:00:00 2001 From: Marius Conjeaud Date: Wed, 5 Jun 2024 17:23:24 +0200 Subject: [PATCH] Improve exception content; fix documentation --- doc/source/getting_started.rst | 23 ++++++++++------- neomodel/exceptions.py | 7 +++++- test/async_/test_match_api.py | 45 +++++++++++++++++++++++++++++++++- test/sync_/test_match_api.py | 45 +++++++++++++++++++++++++++++++++- 4 files changed, 108 insertions(+), 12 deletions(-) diff --git a/doc/source/getting_started.rst b/doc/source/getting_started.rst index f1af1c73..dfeca802 100644 --- a/doc/source/getting_started.rst +++ b/doc/source/getting_started.rst @@ -239,7 +239,7 @@ additional relations with a single call:: # The following call will generate one MATCH with traversal per # item in .fetch_relations() call - results = Person.nodes.all().fetch_relations('country') + results = Person.nodes.fetch_relations('country').all() for result in results: print(result[0]) # Person print(result[1]) # associated Country @@ -248,14 +248,23 @@ You can traverse more than one hop in your relations using the following syntax:: # Go from person to City then Country - Person.nodes.all().fetch_relations('city__country') + Person.nodes.fetch_relations('city__country').all() You can also force the use of an ``OPTIONAL MATCH`` statement using the following syntax:: from neomodel.match import Optional - results = Person.nodes.all().fetch_relations(Optional('country')) + results = Person.nodes.fetch_relations(Optional('country')).all() + +.. note:: + + Any relationship that you intend to traverse using this method **MUST have a model defined**, even if only the default StructuredRel, like:: + + class Person(StructuredNode): + country = RelationshipTo(Country, 'IS_FROM', model=StructuredRel) + + Otherwise, neomodel will not be able to determine which relationship model to resolve into, and will fail. .. note:: @@ -263,18 +272,14 @@ the following syntax:: to `.fetch_relations()` and you can mix optional and non-optional relations, like:: - Person.nodes.all().fetch_relations('city__country', Optional('country')) + Person.nodes.fetch_relations('city__country', Optional('country')).all() .. note:: - This feature is still a work in progress for extending path traversal and fecthing. - It currently stops at returning the resolved objects as they are returned in Cypher. - So for instance, if your path looks like ``(startNode:Person)-[r1]->(middleNode:City)<-[r2]-(endNode:Country)``, + If your path looks like ``(startNode:Person)-[r1]->(middleNode:City)<-[r2]-(endNode:Country)``, then you will get a list of results, where each result is a list of ``(startNode, r1, middleNode, r2, endNode)``. These will be resolved by neomodel, so ``startNode`` will be a ``Person`` class as defined in neomodel for example. - If you want to go further in the resolution process, you have to develop your own parser (for now). - Async neomodel ============== diff --git a/neomodel/exceptions.py b/neomodel/exceptions.py index 36b3ba5b..bb1e1f94 100644 --- a/neomodel/exceptions.py +++ b/neomodel/exceptions.py @@ -90,7 +90,12 @@ class RelationshipClassNotDefined(ModelDefinitionException): def __str__(self): relationship_type = self.db_node_rel_class.type - return f"Relationship of type {relationship_type} does not resolve to any of the known objects\n{self._get_node_class_registry_formatted()}\n" + return f""" + Relationship of type {relationship_type} does not resolve to any of the known objects + {self._get_node_class_registry_formatted()} + Note that when using the fetch_relations method, the relationship type must be defined in the model, even if only defined to StructuredRel. + Otherwise, neomodel will not be able to determine which relationship model to resolve into. + """ class RelationshipClassRedefined(ModelDefinitionException): diff --git a/test/async_/test_match_api.py b/test/async_/test_match_api.py index 9ce4234e..e3195448 100644 --- a/test/async_/test_match_api.py +++ b/test/async_/test_match_api.py @@ -14,6 +14,7 @@ IntegerProperty, Q, StringProperty, + UniqueIdProperty, ) from neomodel._async_compat.util import AsyncUtil from neomodel.async_.match import ( @@ -22,7 +23,7 @@ AsyncTraversal, Optional, ) -from neomodel.exceptions import MultipleNodesReturned +from neomodel.exceptions import MultipleNodesReturned, RelationshipClassNotDefined class SupplierRel(AsyncStructuredRel): @@ -56,6 +57,28 @@ class Extension(AsyncStructuredNode): extension = AsyncRelationshipTo("Extension", "extension") +class CountryX(AsyncStructuredNode): + code = StringProperty(unique_index=True, required=True) + inhabitant = AsyncRelationshipFrom("PersonX", "IS_FROM") + + +class CityX(AsyncStructuredNode): + name = StringProperty(required=True) + country = AsyncRelationshipTo(CountryX, "FROM_COUNTRY") + + +class PersonX(AsyncStructuredNode): + uid = UniqueIdProperty() + name = StringProperty(unique_index=True) + age = IntegerProperty(index=True, default=0) + + # traverse outgoing IS_FROM relations, inflate to Country objects + country = AsyncRelationshipTo(CountryX, "IS_FROM") + + # traverse outgoing LIVES_IN relations, inflate to City objects + city = AsyncRelationshipTo(CityX, "LIVES_IN") + + @mark_async_test async def test_filter_exclude_via_labels(): await Coffee(name="Java", price=99).save() @@ -532,6 +555,7 @@ async def test_fetch_relations(): .fetch_relations("coffees__species") .all() ) + assert len(result[0]) == 5 assert arabica in result[0] assert robusta not in result[0] assert tesco in result[0] @@ -571,6 +595,25 @@ async def test_fetch_relations(): ) +@mark_async_test +async def test_issue_795(): + jim = await PersonX(name="Jim", age=3).save() # Create + jim.age = 4 + await jim.save() # Update, (with validation) + + germany = await CountryX(code="DE").save() + await jim.country.connect(germany) + berlin = await CityX(name="Berlin").save() + await berlin.country.connect(germany) + await jim.city.connect(berlin) + + with raises( + RelationshipClassNotDefined, + match=r"[\s\S]*Note that when using the fetch_relations method, the relationship type must be defined in the model.*", + ): + _ = await PersonX.nodes.fetch_relations("country").all() + + @mark_async_test async def test_in_filter_with_array_property(): tags = ["smoother", "sweeter", "chocolate", "sugar"] diff --git a/test/sync_/test_match_api.py b/test/sync_/test_match_api.py index 57da468f..170a7363 100644 --- a/test/sync_/test_match_api.py +++ b/test/sync_/test_match_api.py @@ -14,9 +14,10 @@ StringProperty, StructuredNode, StructuredRel, + UniqueIdProperty, ) from neomodel._async_compat.util import Util -from neomodel.exceptions import MultipleNodesReturned +from neomodel.exceptions import MultipleNodesReturned, RelationshipClassNotDefined from neomodel.sync_.match import NodeSet, Optional, QueryBuilder, Traversal @@ -49,6 +50,28 @@ class Extension(StructuredNode): extension = RelationshipTo("Extension", "extension") +class CountryX(StructuredNode): + code = StringProperty(unique_index=True, required=True) + inhabitant = RelationshipFrom("PersonX", "IS_FROM") + + +class CityX(StructuredNode): + name = StringProperty(required=True) + country = RelationshipTo(CountryX, "FROM_COUNTRY") + + +class PersonX(StructuredNode): + uid = UniqueIdProperty() + name = StringProperty(unique_index=True) + age = IntegerProperty(index=True, default=0) + + # traverse outgoing IS_FROM relations, inflate to Country objects + country = RelationshipTo(CountryX, "IS_FROM") + + # traverse outgoing LIVES_IN relations, inflate to City objects + city = RelationshipTo(CityX, "LIVES_IN") + + @mark_sync_test def test_filter_exclude_via_labels(): Coffee(name="Java", price=99).save() @@ -521,6 +544,7 @@ def test_fetch_relations(): .fetch_relations("coffees__species") .all() ) + assert len(result[0]) == 5 assert arabica in result[0] assert robusta not in result[0] assert tesco in result[0] @@ -560,6 +584,25 @@ def test_fetch_relations(): ) +@mark_sync_test +def test_issue_795(): + jim = PersonX(name="Jim", age=3).save() # Create + jim.age = 4 + jim.save() # Update, (with validation) + + germany = CountryX(code="DE").save() + jim.country.connect(germany) + berlin = CityX(name="Berlin").save() + berlin.country.connect(germany) + jim.city.connect(berlin) + + with raises( + RelationshipClassNotDefined, + match=r"[\s\S]*Note that when using the fetch_relations method, the relationship type must be defined in the model.*", + ): + _ = PersonX.nodes.fetch_relations("country").all() + + @mark_sync_test def test_in_filter_with_array_property(): tags = ["smoother", "sweeter", "chocolate", "sugar"]