Skip to content

Commit

Permalink
Merge pull request #738 from neo4j-contrib/automatic_path_inflation
Browse files Browse the repository at this point in the history
Automatic path inflation
  • Loading branch information
mariusconjeaud authored Aug 24, 2023
2 parents f3eb90b + 3196f29 commit 0a85e0c
Show file tree
Hide file tree
Showing 8 changed files with 329 additions and 56 deletions.
10 changes: 10 additions & 0 deletions doc/source/module_documentation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ Relationships
:members:
:show-inheritance:

Paths
=====

.. automodule:: neomodel.path
:members:
:show-inheritance:




Match
=====
.. module:: neomodel.match
Expand Down
54 changes: 54 additions & 0 deletions doc/source/queries.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``NeomodelPath`` object.

``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
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).
1 change: 1 addition & 0 deletions neomodel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
)
from .relationship import StructuredRel
from .util import change_neo4j_password, clear_neo4j_database
from .path import NeomodelPath

__author__ = "Robin Edwards"
__email__ = "robin.ge@gmail.com"
Expand Down
53 changes: 53 additions & 0 deletions neomodel/path.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from neo4j.graph import Path
from .core import db
from .relationship import StructuredRel
from .exceptions import RelationshipClassNotDefined


class NeomodelPath(Path):
"""
Represents paths within neomodel.
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=[]
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)
self._relationships.append(new_rel)
@property
def nodes(self):
return self._nodes

@property
def relationships(self):
return self._relationships


1 change: 1 addition & 0 deletions neomodel/relationship.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

97 changes: 67 additions & 30 deletions neomodel/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from neo4j.api import Bookmarks
from neo4j.exceptions import ClientError, ServiceUnavailable, SessionExpired
from neo4j.graph import Node, Relationship
from neo4j.graph import Path

from neomodel import config, core
from neomodel.exceptions import (
Expand All @@ -21,8 +22,8 @@
UniqueProperty,
)

logger = logging.getLogger(__name__)

logger = logging.getLogger(__name__)

# make sure the connection url has been set prior to executing the wrapped function
def ensure_connection(func):
Expand Down Expand Up @@ -98,7 +99,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)
Expand Down Expand Up @@ -130,7 +134,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
Expand Down Expand Up @@ -249,7 +255,52 @@ def _update_database_version(self):
# The database server is not running yet
pass

Check warning on line 256 in neomodel/util.py

View check run for this annotation

Codecov / codecov/patch

neomodel/util.py#L256

Added line #L256 was not covered by tests

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):
rel_type = frozenset([object_to_resolve.type])
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):
"""
Performs in place automatic object resolution on a set of results
returned by cypher_query.
Expand All @@ -272,28 +323,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]
Expand Down Expand Up @@ -378,12 +408,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":
Expand Down Expand Up @@ -450,7 +482,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

Expand All @@ -473,7 +507,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 (

Check warning on line 510 in neomodel/util.py

View check run for this annotation

Codecov / codecov/patch

neomodel/util.py#L510

Added line #L510 was not covered by tests
exc_value.code
== "Neo.ClientError.Schema.ConstraintValidationFailed"
):
raise UniqueProperty(exc_value.message)

if not exc_value:
Expand Down
Loading

0 comments on commit 0a85e0c

Please sign in to comment.