From e7b4975ca7c9c0be87c70418e7e42465aa5332e6 Mon Sep 17 00:00:00 2001 From: Mael Pedretti Date: Mon, 10 Jul 2023 11:00:04 +0200 Subject: [PATCH 1/7] Added automatic path inflation --- neomodel/util.py | 113 +++++++++++++++++++++++++++++++----------- test/test_issue283.py | 90 +++++++++++++++++++++++---------- 2 files changed, 148 insertions(+), 55 deletions(-) diff --git a/neomodel/util.py b/neomodel/util.py index acdb193a..4386dbef 100644 --- a/neomodel/util.py +++ b/neomodel/util.py @@ -12,6 +12,7 @@ from neo4j.debug import watch from neo4j.exceptions import ClientError, ServiceUnavailable, SessionExpired from neo4j.graph import Node, Relationship +from neo4j.graph import Path as NeoPath from neomodel import config, core from neomodel.exceptions import ( @@ -61,6 +62,18 @@ def clear_neo4j_database(db, clear_constraints=False, clear_indexes=False): core.drop_indexes() +class Path(NeoPath): + def __init__(self, nodes, *relationships): + for i, relationship in enumerate(relationships, start=1): + start = relationship.start_node() + end = relationship.end_node() + if start not in nodes and end not in nodes: + raise ValueError( + "Relationship %d does not connect to all of the nodes" % i + ) + self._nodes = tuple(nodes) + self._relationships = relationships + class Database(local): """ A singleton object via which all operations from neomodel to the Neo4j backend are handled with. @@ -100,7 +113,10 @@ def set_connection(self, url): "neo4j+ssc", ] - if parsed_url.netloc.find("@") > -1 and parsed_url.scheme in valid_schemas: + if ( + parsed_url.netloc.find("@") > -1 + and parsed_url.scheme in valid_schemas + ): credentials, hostname = parsed_url.netloc.rsplit("@", 1) username, password = credentials.split(":") password = unquote(password) @@ -132,7 +148,9 @@ def set_connection(self, url): self.url = url self._pid = os.getpid() self._active_transaction = None - self._database_name = DEFAULT_DATABASE if database_name == "" else database_name + self._database_name = ( + DEFAULT_DATABASE if database_name == "" else database_name + ) # Getting the information about the database version requires a connection to the database self._database_version = None @@ -251,7 +269,58 @@ def _update_database_version(self): # The database server is not running yet pass - def _object_resolution(self, result_list): + def _object_resolution(self, object_to_resolve): + """ + Performs in place automatic object resolution on a result + returned by cypher_query. + + The function operates recursively in order to be able to resolve Nodes + within nested list structures and Path objects. Not meant to be called + directly, used primarily by _result_resolution. + + :param object_to_resolve: A result as returned by cypher_query. + :type Any: + + :return: An instantiated object. + """ + # Below is the original comment that came with the code extracted in + # this method. It is not very clear but I decided to keep it just in + # case + # + # + # For some reason, while the type of `a_result_attribute[1]` + # as reported by the neo4j driver is `Node` for Node-type data + # retrieved from the database. + # When the retrieved data are Relationship-Type, + # the returned type is `abc.[REL_LABEL]` which is however + # a descendant of Relationship. + # Consequently, the type checking was changed for both + # Node, Relationship objects + if isinstance(object_to_resolve, Node): + return self._NODE_CLASS_REGISTRY[ + frozenset(object_to_resolve.labels) + ].inflate(object_to_resolve) + + if isinstance(object_to_resolve, Relationship): + return self._NODE_CLASS_REGISTRY[ + frozenset([object_to_resolve.type]) + ].inflate(object_to_resolve) + + if isinstance(object_to_resolve, NeoPath): + new_nodes = [] + for node in object_to_resolve.nodes: + new_nodes.append(self._object_resolution(node)) + new_relationships = [] + for relationship in object_to_resolve.relationships: + new_rel = self._object_resolution(relationship) + new_relationships.append(new_rel) + return Path(new_nodes, *new_relationships) + + if isinstance(object_to_resolve, list): + return self._result_resolution([object_to_resolve]) + return object_to_resolve + + def _result_resolution(self, result_list): """ Performs in place automatic object resolution on a set of results returned by cypher_query. @@ -274,28 +343,7 @@ def _object_resolution(self, result_list): # Nodes to be resolved to native objects resolved_object = a_result_attribute[1] - # For some reason, while the type of `a_result_attribute[1]` - # as reported by the neo4j driver is `Node` for Node-type data - # retrieved from the database. - # When the retrieved data are Relationship-Type, - # the returned type is `abc.[REL_LABEL]` which is however - # a descendant of Relationship. - # Consequently, the type checking was changed for both - # Node, Relationship objects - if isinstance(a_result_attribute[1], Node): - resolved_object = self._NODE_CLASS_REGISTRY[ - frozenset(a_result_attribute[1].labels) - ].inflate(a_result_attribute[1]) - - if isinstance(a_result_attribute[1], Relationship): - resolved_object = self._NODE_CLASS_REGISTRY[ - frozenset([a_result_attribute[1].type]) - ].inflate(a_result_attribute[1]) - - if isinstance(a_result_attribute[1], list): - resolved_object = self._object_resolution( - [a_result_attribute[1]] - ) + resolved_object = self._object_resolution(resolved_object) result_list[a_result_item[0]][ a_result_attribute[0] @@ -380,12 +428,14 @@ def _run_cypher_query( # Retrieve the data start = time.time() response = session.run(query, params) - results, meta = [list(r.values()) for r in response], response.keys() + results, meta = [ + list(r.values()) for r in response + ], response.keys() end = time.time() if resolve_objects: # Do any automatic resolution required - results = self._object_resolution(results) + results = self._result_resolution(results) except ClientError as e: if e.code == "Neo.ClientError.Schema.ConstraintValidationFailed": @@ -452,7 +502,9 @@ def list_constraints(self) -> Sequence[dict]: Sequence[dict]: List of dictionaries, each entry being a constraint definition """ constraints, meta_constraints = self.cypher_query("SHOW CONSTRAINTS") - constraints_as_dict = [dict(zip(meta_constraints, row)) for row in constraints] + constraints_as_dict = [ + dict(zip(meta_constraints, row)) for row in constraints + ] return constraints_as_dict @@ -475,7 +527,10 @@ def __exit__(self, exc_type, exc_value, traceback): self.db.rollback() if exc_type is ClientError: - if exc_value.code == "Neo.ClientError.Schema.ConstraintValidationFailed": + if ( + exc_value.code + == "Neo.ClientError.Schema.ConstraintValidationFailed" + ): raise UniqueProperty(exc_value.message) if not exc_value: diff --git a/test/test_issue283.py b/test/test_issue283.py index af285aeb..b7a63f8f 100644 --- a/test/test_issue283.py +++ b/test/test_issue283.py @@ -81,16 +81,22 @@ class SomePerson(BaseOtherPerson): # Test cases -def test_automatic_object_resolution(): +def test_automatic_result_resolution(): """ Node objects at the end of relationships are instantiated to their corresponding Python object. """ # Create a few entities - A = TechnicalPerson.get_or_create({"name": "Grumpy", "expertise": "Grumpiness"})[0] - B = TechnicalPerson.get_or_create({"name": "Happy", "expertise": "Unicorns"})[0] - C = TechnicalPerson.get_or_create({"name": "Sleepy", "expertise": "Pillows"})[0] + A = TechnicalPerson.get_or_create( + {"name": "Grumpy", "expertise": "Grumpiness"} + )[0] + B = TechnicalPerson.get_or_create( + {"name": "Happy", "expertise": "Unicorns"} + )[0] + C = TechnicalPerson.get_or_create( + {"name": "Sleepy", "expertise": "Pillows"} + )[0] # Add connections A.friends_with.connect(B) @@ -106,7 +112,7 @@ def test_automatic_object_resolution(): C.delete() -def test_recursive_automatic_object_resolution(): +def test_recursive_automatic_result_resolution(): """ Node objects are instantiated to native Python objects, both at the top level of returned results and in the case where they are returned within @@ -114,12 +120,18 @@ def test_recursive_automatic_object_resolution(): """ # Create a few entities - A = TechnicalPerson.get_or_create({"name": "Grumpier", "expertise": "Grumpiness"})[ - 0 - ] - B = TechnicalPerson.get_or_create({"name": "Happier", "expertise": "Grumpiness"})[0] - C = TechnicalPerson.get_or_create({"name": "Sleepier", "expertise": "Pillows"})[0] - D = TechnicalPerson.get_or_create({"name": "Sneezier", "expertise": "Pillows"})[0] + A = TechnicalPerson.get_or_create( + {"name": "Grumpier", "expertise": "Grumpiness"} + )[0] + B = TechnicalPerson.get_or_create( + {"name": "Happier", "expertise": "Grumpiness"} + )[0] + C = TechnicalPerson.get_or_create( + {"name": "Sleepier", "expertise": "Pillows"} + )[0] + D = TechnicalPerson.get_or_create( + {"name": "Sneezier", "expertise": "Pillows"} + )[0] # Retrieve mixed results, both at the top level and nested L, _ = neomodel.db.cypher_query( @@ -154,9 +166,15 @@ def test_validation_with_inheritance_from_db(): # Create a few entities # Technical Persons - A = TechnicalPerson.get_or_create({"name": "Grumpy", "expertise": "Grumpiness"})[0] - B = TechnicalPerson.get_or_create({"name": "Happy", "expertise": "Unicorns"})[0] - C = TechnicalPerson.get_or_create({"name": "Sleepy", "expertise": "Pillows"})[0] + A = TechnicalPerson.get_or_create( + {"name": "Grumpy", "expertise": "Grumpiness"} + )[0] + B = TechnicalPerson.get_or_create( + {"name": "Happy", "expertise": "Unicorns"} + )[0] + C = TechnicalPerson.get_or_create( + {"name": "Sleepy", "expertise": "Pillows"} + )[0] # Pilot Persons D = PilotPerson.get_or_create( @@ -205,9 +223,15 @@ def test_validation_enforcement_to_db(): # Create a few entities # Technical Persons - A = TechnicalPerson.get_or_create({"name": "Grumpy", "expertise": "Grumpiness"})[0] - B = TechnicalPerson.get_or_create({"name": "Happy", "expertise": "Unicorns"})[0] - C = TechnicalPerson.get_or_create({"name": "Sleepy", "expertise": "Pillows"})[0] + A = TechnicalPerson.get_or_create( + {"name": "Grumpy", "expertise": "Grumpiness"} + )[0] + B = TechnicalPerson.get_or_create( + {"name": "Happy", "expertise": "Unicorns"} + )[0] + C = TechnicalPerson.get_or_create( + {"name": "Sleepy", "expertise": "Pillows"} + )[0] # Pilot Persons D = PilotPerson.get_or_create( @@ -241,7 +265,7 @@ def test_validation_enforcement_to_db(): F.delete() -def test_failed_object_resolution(): +def test_failed_result_resolution(): """ A Neo4j driver node FROM the database contains labels that are unaware to neomodel's Database class. This condition raises ClassDefinitionNotFound @@ -252,7 +276,9 @@ class RandomPerson(BasePerson): randomness = neomodel.FloatProperty(default=random.random) # A Technical Person... - A = TechnicalPerson.get_or_create({"name": "Grumpy", "expertise": "Grumpiness"})[0] + A = TechnicalPerson.get_or_create( + {"name": "Grumpy", "expertise": "Grumpiness"} + )[0] # A Random Person... B = RandomPerson.get_or_create({"name": "Mad Hatter"})[0] @@ -261,10 +287,14 @@ class RandomPerson(BasePerson): # Simulate the condition where the definition of class RandomPerson is not # known yet. - del neomodel.db._NODE_CLASS_REGISTRY[frozenset(["RandomPerson", "BasePerson"])] + del neomodel.db._NODE_CLASS_REGISTRY[ + frozenset(["RandomPerson", "BasePerson"]) + ] # Now try to instantiate a RandomPerson - A = TechnicalPerson.get_or_create({"name": "Grumpy", "expertise": "Grumpiness"})[0] + A = TechnicalPerson.get_or_create( + {"name": "Grumpy", "expertise": "Grumpiness"} + )[0] with pytest.raises( neomodel.exceptions.NodeClassNotDefined, match=r"Node with labels .* does not resolve to any of the known objects.*", @@ -289,9 +319,13 @@ class UltraTechnicalPerson(SuperTechnicalPerson): ultraness = neomodel.FloatProperty(default=3.1415928) # Create a TechnicalPerson... - A = TechnicalPerson.get_or_create({"name": "Grumpy", "expertise": "Grumpiness"})[0] + A = TechnicalPerson.get_or_create( + {"name": "Grumpy", "expertise": "Grumpiness"} + )[0] # ...that is connected to an UltraTechnicalPerson - F = UltraTechnicalPerson(name="Chewbaka", expertise="Aarrr wgh ggwaaah").save() + F = UltraTechnicalPerson( + name="Chewbaka", expertise="Aarrr wgh ggwaaah" + ).save() A.friends_with.connect(F) # Forget about the UltraTechnicalPerson @@ -309,7 +343,9 @@ class UltraTechnicalPerson(SuperTechnicalPerson): # Recall a TechnicalPerson and enumerate its friends. # One of them is UltraTechnicalPerson which would be returned as a valid # node to a friends_with query but is currently unknown to the node class registry. - A = TechnicalPerson.get_or_create({"name": "Grumpy", "expertise": "Grumpiness"})[0] + A = TechnicalPerson.get_or_create( + {"name": "Grumpy", "expertise": "Grumpiness"} + )[0] with pytest.raises(neomodel.exceptions.NodeClassNotDefined): for some_friend in A.friends_with: print(some_friend.name) @@ -334,12 +370,14 @@ class SomePerson(BaseOtherPerson): redefine_class_locally() -def test_relationship_object_resolution(): +def test_relationship_result_resolution(): """ A query returning a "Relationship" object can now instantiate it to a data model class """ # Test specific data - A = PilotPerson(name="Zantford Granville", airplane="Gee Bee Model R").save() + A = PilotPerson( + name="Zantford Granville", airplane="Gee Bee Model R" + ).save() B = PilotPerson(name="Thomas Granville", airplane="Gee Bee Model R").save() C = PilotPerson(name="Robert Granville", airplane="Gee Bee Model R").save() D = PilotPerson(name="Mark Granville", airplane="Gee Bee Model R").save() From e155d93c770894850a166ce4c9a5363a9d13a052 Mon Sep 17 00:00:00 2001 From: Athanasios Anastasiou Date: Tue, 22 Aug 2023 07:50:42 +0000 Subject: [PATCH 2/7] Added relationship import mid-code :/ --- neomodel/relationship.py | 1 + neomodel/util.py | 26 +++++++++++++++----------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/neomodel/relationship.py b/neomodel/relationship.py index 9320bfe8..31eba41c 100644 --- a/neomodel/relationship.py +++ b/neomodel/relationship.py @@ -153,3 +153,4 @@ def inflate(cls, rel): srel._end_node_element_id_property = rel.end_node.element_id srel.element_id_property = rel.element_id return srel + diff --git a/neomodel/util.py b/neomodel/util.py index 4386dbef..f366efb7 100644 --- a/neomodel/util.py +++ b/neomodel/util.py @@ -64,14 +64,9 @@ def clear_neo4j_database(db, clear_constraints=False, clear_indexes=False): class Path(NeoPath): def __init__(self, nodes, *relationships): - for i, relationship in enumerate(relationships, start=1): - start = relationship.start_node() - end = relationship.end_node() - if start not in nodes and end not in nodes: - raise ValueError( - "Relationship %d does not connect to all of the nodes" % i - ) + # Resolve node objects self._nodes = tuple(nodes) + # Resolve relationship objects self._relationships = relationships class Database(local): @@ -302,15 +297,24 @@ def _object_resolution(self, object_to_resolve): ].inflate(object_to_resolve) if isinstance(object_to_resolve, Relationship): - return self._NODE_CLASS_REGISTRY[ - frozenset([object_to_resolve.type]) - ].inflate(object_to_resolve) + rel_type = frozenset([object_to_resolve.type]) + # This check is required here because if the relationship does not bear data + # then it does not have an entry in the registry. In that case, we instantiate + # an "unspecified" StructuredRel. + if rel_type in self._NODE_CLASS_REGISTRY: + return self._NODE_CLASS_REGISTRY[rel_type].inflate(object_to_resolve) + else: + # TODO: HIGH, if this import is moved to the header, it causes a circular import + from .relationship import StructuredRel + return StructuredRel.inflate(object_to_resolve) if isinstance(object_to_resolve, NeoPath): new_nodes = [] + new_relationships = [] + for node in object_to_resolve.nodes: new_nodes.append(self._object_resolution(node)) - new_relationships = [] + for relationship in object_to_resolve.relationships: new_rel = self._object_resolution(relationship) new_relationships.append(new_rel) From da411b0780de2f32be744616786f77daade583fb Mon Sep 17 00:00:00 2001 From: Athanasios Anastasiou Date: Tue, 22 Aug 2023 08:47:28 +0000 Subject: [PATCH 3/7] Added a very simple test for paths --- neomodel/__init__.py | 1 + neomodel/path.py | 15 +++++++++ neomodel/util.py | 8 +---- test/test_paths.py | 80 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 neomodel/path.py create mode 100644 test/test_paths.py diff --git a/neomodel/__init__.py b/neomodel/__init__.py index a110cf35..d25adfca 100644 --- a/neomodel/__init__.py +++ b/neomodel/__init__.py @@ -33,6 +33,7 @@ ) from .relationship import StructuredRel from .util import change_neo4j_password, clear_neo4j_database +from .path import Path __author__ = "Robin Edwards" __email__ = "robin.ge@gmail.com" diff --git a/neomodel/path.py b/neomodel/path.py new file mode 100644 index 00000000..43e049a8 --- /dev/null +++ b/neomodel/path.py @@ -0,0 +1,15 @@ +from neo4j.graph import Path as NeoPath + +class Path(NeoPath): + """ + Represents paths within neomodel. + + Paths reference their nodes and relationships, each of which is already + resolved to their neomodel objects if such mapping is possible. + """ + def __init__(self, nodes, *relationships): + # Resolve node objects + self._nodes = tuple(nodes) + # Resolve relationship objects + self._relationships = relationships + diff --git a/neomodel/util.py b/neomodel/util.py index f366efb7..40befb83 100644 --- a/neomodel/util.py +++ b/neomodel/util.py @@ -22,6 +22,7 @@ RelationshipClassNotDefined, UniqueProperty, ) +from .path import Path logger = logging.getLogger(__name__) watch("neo4j") @@ -62,13 +63,6 @@ def clear_neo4j_database(db, clear_constraints=False, clear_indexes=False): core.drop_indexes() -class Path(NeoPath): - def __init__(self, nodes, *relationships): - # Resolve node objects - self._nodes = tuple(nodes) - # Resolve relationship objects - self._relationships = relationships - class Database(local): """ A singleton object via which all operations from neomodel to the Neo4j backend are handled with. diff --git a/test/test_paths.py b/test/test_paths.py new file mode 100644 index 00000000..c7ea4b28 --- /dev/null +++ b/test/test_paths.py @@ -0,0 +1,80 @@ +from neomodel import (StringProperty, StructuredNode, UniqueIdProperty, + db, RelationshipTo, IntegerProperty, Path, StructuredRel) + +class PersonLivesInCity(StructuredRel): + """ + Relationship with data that will be instantiated as "stand-alone" + """ + some_num = IntegerProperty(index=True, default=12) + +class CountryOfOrigin(StructuredNode): + code = StringProperty(unique_index=True, required=True) + +class CityOfResidence(StructuredNode): + name = StringProperty(required=True) + country = RelationshipTo(CountryOfOrigin, 'FROM_COUNTRY') + +class PersonOfInterest(StructuredNode): + uid = UniqueIdProperty() + name = StringProperty(unique_index=True) + age = IntegerProperty(index=True, default=0) + + country = RelationshipTo(CountryOfOrigin, 'IS_FROM') + + city = RelationshipTo(CityOfResidence, 'LIVES_IN', model=PersonLivesInCity) + + +def test_path_instantiation(): + """ + Neo4j driver paths should be instantiated as neomodel paths, with all of + their nodes and relationships resolved to their Python objects wherever + such a mapping is available. + """ + + c1=CountryOfOrigin(code="GR").save() + c2=CountryOfOrigin(code="FR").save() + + ct1 = CityOfResidence(name="Athens", country = c1).save() + ct2 = CityOfResidence(name="Paris", country = c2).save() + + + p1 = PersonOfInterest(name="Bill", age=22).save() + p1.country.connect(c1) + p1.city.connect(ct1) + + p2 = PersonOfInterest(name="Jean", age=28).save() + p2.country.connect(c2) + p2.city.connect(ct2) + + p3 = PersonOfInterest(name="Bo", age=32).save() + p3.country.connect(c1) + p3.city.connect(ct2) + + p4 = PersonOfInterest(name="Drop", age=16).save() + p4.country.connect(c1) + p4.city.connect(ct2) + + # Retrieve a single path + q = db.cypher_query("MATCH p=(:CityOfResidence)<-[:LIVES_IN]-(:PersonOfInterest)-[:IS_FROM]->(:CountryOfOrigin) RETURN p LIMIT 1", resolve_objects = True) + + path_object = q[0][0][0] + path_nodes = path_object.nodes + path_rels = path_object.relationships + + assert type(path_object) is Path + assert type(path_nodes[0]) is CityOfResidence + assert type(path_nodes[1]) is PersonOfInterest + assert type(path_nodes[2]) is CountryOfOrigin + + assert type(path_rels[0]) is PersonLivesInCity + assert type(path_rels[1]) is StructuredRel + + c1.delete() + c2.delete() + ct1.delete() + ct2.delete() + p1.delete() + p2.delete() + p3.delete() + p4.delete() + From 9c7283ce79715aa0a32b5a7b06a2ac07a4ac2bcd Mon Sep 17 00:00:00 2001 From: Athanasios Anastasiou Date: Tue, 22 Aug 2023 09:06:39 +0000 Subject: [PATCH 4/7] Added documentation --- doc/source/queries.rst | 54 ++++++++++++++++++++++++++++++++++++++++++ test/test_paths.py | 1 - 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/doc/source/queries.rst b/doc/source/queries.rst index 7362c856..f43d9edc 100644 --- a/doc/source/queries.rst +++ b/doc/source/queries.rst @@ -186,3 +186,57 @@ For random ordering simply pass '?' to the order_by method:: Coffee.nodes.order_by('?') +Retrieving paths +================ + +You can retrieve a whole path of already instantiated objects corresponding to +the nodes and relationship classes with a single query. + +Suppose the following schema: + +:: + + class PersonLivesInCity(StructuredRel): + some_num = IntegerProperty(index=True, + default=12) + + class CountryOfOrigin(StructuredNode): + code = StringProperty(unique_index=True, + required=True) + + class CityOfResidence(StructuredNode): + name = StringProperty(required=True) + country = RelationshipTo(CountryOfOrigin, + 'FROM_COUNTRY') + + class PersonOfInterest(StructuredNode): + uid = UniqueIdProperty() + name = StringProperty(unique_index=True) + age = IntegerProperty(index=True, + default=0) + + country = RelationshipTo(CountryOfOrigin, + 'IS_FROM') + city = RelationshipTo(CityOfResidence, + 'LIVES_IN', + model=PersonLivesInCity) + +Then, paths can be retrieved with: + +:: + + q = db.cypher_query("MATCH p=(:CityOfResidence)<-[:LIVES_IN]-(:PersonOfInterest)-[:IS_FROM]->(:CountryOfOrigin) RETURN p LIMIT 1", + resolve_objects = True) + +Notice here that ``resolve_objects`` is set to ``True``. This results in ``q`` being a +list of ``result, result_name`` and ``q[0][0][0]`` being a ``Path`` object. + +Path's ``nodes, relationships`` attributes contain already instantiated objects of the +nodes and relationships in the query, *in order of appearance*. + +It would be particularly useful to note here that each object is read exactly once from +the database. Therefore, nodes will be instantiated to their neomodel node objects and +relationships to their relationship models *if such a model exists*. In other words, +relationships with data (such as ``PersonLivesInCity`` above) will be instantiated to their +respective objects or ``StrucuredRel`` otherwise. Relationships do not "reload" their +end-points (unless this is required). diff --git a/test/test_paths.py b/test/test_paths.py index c7ea4b28..5638315b 100644 --- a/test/test_paths.py +++ b/test/test_paths.py @@ -20,7 +20,6 @@ class PersonOfInterest(StructuredNode): age = IntegerProperty(index=True, default=0) country = RelationshipTo(CountryOfOrigin, 'IS_FROM') - city = RelationshipTo(CityOfResidence, 'LIVES_IN', model=PersonLivesInCity) From f26481b666693c4430c591d67e326065c6064337 Mon Sep 17 00:00:00 2001 From: Athanasios Anastasiou Date: Wed, 23 Aug 2023 09:31:08 +0000 Subject: [PATCH 5/7] Restructured the path initialisation --- neomodel/path.py | 37 ++++++++++++++++++++++++++++++------- neomodel/util.py | 34 +++++++++------------------------- 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/neomodel/path.py b/neomodel/path.py index 43e049a8..f8b0064f 100644 --- a/neomodel/path.py +++ b/neomodel/path.py @@ -1,15 +1,38 @@ -from neo4j.graph import Path as NeoPath +from neo4j.graph import Path +from .core import db +from .relationship import StructuredRel +from .exceptions import RelationshipClassNotDefined -class Path(NeoPath): + +class NeomodelPath(Path): """ Represents paths within neomodel. Paths reference their nodes and relationships, each of which is already resolved to their neomodel objects if such mapping is possible. """ - def __init__(self, nodes, *relationships): - # Resolve node objects - self._nodes = tuple(nodes) - # Resolve relationship objects - self._relationships = relationships + def __init__(self, a_neopath): + self._nodes=[] + self._relationships = [] + + for a_node in a_neopath.nodes: + self._nodes.append(db._object_resolution(a_node)) + + for a_relationship in a_neopath.relationships: + # This check is required here because if the relationship does not bear data + # then it does not have an entry in the registry. In that case, we instantiate + # an "unspecified" StructuredRel. + rel_type = frozenset([a_relationship.type]) + if rel_type in db._NODE_CLASS_REGISTRY: + new_rel = db._object_resolution(a_relationship) + else: + new_rel = StructuredRel.inflate(a_relationship) + # import pdb + # pdb.set_trace() + # try: + # new_rel = db._object_resolution(a_relationship) + # except RelationshipClassNotDefined: + # new_rel = StructuredRel.inflate(a_relationship) + # + self._relationships.append(new_rel) diff --git a/neomodel/util.py b/neomodel/util.py index 40befb83..3bc1f09b 100644 --- a/neomodel/util.py +++ b/neomodel/util.py @@ -12,7 +12,7 @@ from neo4j.debug import watch from neo4j.exceptions import ClientError, ServiceUnavailable, SessionExpired from neo4j.graph import Node, Relationship -from neo4j.graph import Path as NeoPath +from neo4j.graph import Path from neomodel import config, core from neomodel.exceptions import ( @@ -22,10 +22,9 @@ RelationshipClassNotDefined, UniqueProperty, ) -from .path import Path -logger = logging.getLogger(__name__) -watch("neo4j") +#logger = logging.getLogger(__name__) +#watch("neo4j") # make sure the connection url has been set prior to executing the wrapped function @@ -292,30 +291,15 @@ def _object_resolution(self, object_to_resolve): if isinstance(object_to_resolve, Relationship): rel_type = frozenset([object_to_resolve.type]) - # This check is required here because if the relationship does not bear data - # then it does not have an entry in the registry. In that case, we instantiate - # an "unspecified" StructuredRel. - if rel_type in self._NODE_CLASS_REGISTRY: - return self._NODE_CLASS_REGISTRY[rel_type].inflate(object_to_resolve) - else: - # TODO: HIGH, if this import is moved to the header, it causes a circular import - from .relationship import StructuredRel - return StructuredRel.inflate(object_to_resolve) - - if isinstance(object_to_resolve, NeoPath): - new_nodes = [] - new_relationships = [] - - for node in object_to_resolve.nodes: - new_nodes.append(self._object_resolution(node)) - - for relationship in object_to_resolve.relationships: - new_rel = self._object_resolution(relationship) - new_relationships.append(new_rel) - return Path(new_nodes, *new_relationships) + return self._NODE_CLASS_REGISTRY[rel_type].inflate(object_to_resolve) + + if isinstance(object_to_resolve, Path): + from .path import NeomodelPath + return NeomodelPath(object_to_resolve) if isinstance(object_to_resolve, list): return self._result_resolution([object_to_resolve]) + return object_to_resolve def _result_resolution(self, result_list): From 7f416bcb9039bb9a82e3f5b251facf81f55ac8ca Mon Sep 17 00:00:00 2001 From: Athanasios Anastasiou Date: Wed, 23 Aug 2023 13:06:58 +0000 Subject: [PATCH 6/7] Corrected bug in instantiation of NeomodelPath. It is now completely 'self-contained' in its own source code file and instantiated by its neo4j driver Path counterpart. Finalised documentation for 'NeomodelPath' --- doc/source/module_documentation.rst | 10 ++++++++++ doc/source/queries.rst | 4 ++-- neomodel/__init__.py | 2 +- neomodel/path.py | 24 +++++++++++++++++++++++- test/test_paths.py | 4 ++-- 5 files changed, 38 insertions(+), 6 deletions(-) diff --git a/doc/source/module_documentation.rst b/doc/source/module_documentation.rst index 9ba9a31d..16a4acf2 100644 --- a/doc/source/module_documentation.rst +++ b/doc/source/module_documentation.rst @@ -46,6 +46,16 @@ Relationships :members: :show-inheritance: +Paths +===== + +.. automodule:: neomodel.path + :members: + :show-inheritance: + + + + Match ===== .. module:: neomodel.match diff --git a/doc/source/queries.rst b/doc/source/queries.rst index f43d9edc..c3e37629 100644 --- a/doc/source/queries.rst +++ b/doc/source/queries.rst @@ -229,9 +229,9 @@ Then, paths can be retrieved with: resolve_objects = True) Notice here that ``resolve_objects`` is set to ``True``. This results in ``q`` being a -list of ``result, result_name`` and ``q[0][0][0]`` being a ``Path`` object. +list of ``result, result_name`` and ``q[0][0][0]`` being a ``NeomodelPath`` object. -Path's ``nodes, relationships`` attributes contain already instantiated objects of the +``NeomodelPath`` ``nodes, relationships`` attributes contain already instantiated objects of the nodes and relationships in the query, *in order of appearance*. It would be particularly useful to note here that each object is read exactly once from diff --git a/neomodel/__init__.py b/neomodel/__init__.py index d25adfca..860f0d08 100644 --- a/neomodel/__init__.py +++ b/neomodel/__init__.py @@ -33,7 +33,7 @@ ) from .relationship import StructuredRel from .util import change_neo4j_password, clear_neo4j_database -from .path import Path +from .path import NeomodelPath __author__ = "Robin Edwards" __email__ = "robin.ge@gmail.com" diff --git a/neomodel/path.py b/neomodel/path.py index f8b0064f..cbf2d183 100644 --- a/neomodel/path.py +++ b/neomodel/path.py @@ -8,8 +8,22 @@ class NeomodelPath(Path): """ Represents paths within neomodel. - Paths reference their nodes and relationships, each of which is already + This object is instantiated when you include whole paths in your ``cypher_query()`` + result sets and turn ``resolve_objects`` to True. + + That is, any query of the form: + :: + + MATCH p=(:SOME_NODE_LABELS)-[:SOME_REL_LABELS]-(:SOME_OTHER_NODE_LABELS) return p + + ``NeomodelPath`` are simple objects that reference their nodes and relationships, each of which is already resolved to their neomodel objects if such mapping is possible. + + + :param nodes: Neomodel nodes appearing in the path in order of appearance. + :param relationships: Neomodel relationships appearing in the path in order of appearance. + :type nodes: List[StructuredNode] + :type relationships: List[StructuredRel] """ def __init__(self, a_neopath): self._nodes=[] @@ -35,4 +49,12 @@ def __init__(self, a_neopath): # new_rel = StructuredRel.inflate(a_relationship) # self._relationships.append(new_rel) + @property + def nodes(self): + return self._nodes + + @property + def relationships(self): + return self._relationships + diff --git a/test/test_paths.py b/test/test_paths.py index 5638315b..8c6fef28 100644 --- a/test/test_paths.py +++ b/test/test_paths.py @@ -1,5 +1,5 @@ from neomodel import (StringProperty, StructuredNode, UniqueIdProperty, - db, RelationshipTo, IntegerProperty, Path, StructuredRel) + db, RelationshipTo, IntegerProperty, NeomodelPath, StructuredRel) class PersonLivesInCity(StructuredRel): """ @@ -60,7 +60,7 @@ def test_path_instantiation(): path_nodes = path_object.nodes path_rels = path_object.relationships - assert type(path_object) is Path + assert type(path_object) is NeomodelPath assert type(path_nodes[0]) is CityOfResidence assert type(path_nodes[1]) is PersonOfInterest assert type(path_nodes[2]) is CountryOfOrigin From 69242d8aa3a1afed458a50afe5b91dd381d73e0b Mon Sep 17 00:00:00 2001 From: Athanasios Anastasiou Date: Wed, 23 Aug 2023 13:13:32 +0000 Subject: [PATCH 7/7] Removed commented out code --- neomodel/path.py | 7 ------- neomodel/util.py | 3 --- 2 files changed, 10 deletions(-) diff --git a/neomodel/path.py b/neomodel/path.py index cbf2d183..5f063d11 100644 --- a/neomodel/path.py +++ b/neomodel/path.py @@ -41,13 +41,6 @@ def __init__(self, a_neopath): new_rel = db._object_resolution(a_relationship) else: new_rel = StructuredRel.inflate(a_relationship) - # import pdb - # pdb.set_trace() - # try: - # new_rel = db._object_resolution(a_relationship) - # except RelationshipClassNotDefined: - # new_rel = StructuredRel.inflate(a_relationship) - # self._relationships.append(new_rel) @property def nodes(self): diff --git a/neomodel/util.py b/neomodel/util.py index 3bc1f09b..946d29da 100644 --- a/neomodel/util.py +++ b/neomodel/util.py @@ -23,9 +23,6 @@ UniqueProperty, ) -#logger = logging.getLogger(__name__) -#watch("neo4j") - # make sure the connection url has been set prior to executing the wrapped function def ensure_connection(func):