Skip to content

Commit 97fdf9f

Browse files
authored
Merge pull request #2 from BrainAnnex/dev
Dev 4.2.0
2 parents 1f36b0b + dcebc1b commit 97fdf9f

File tree

6 files changed

+179
-39
lines changed

6 files changed

+179
-39
lines changed

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "neoaccess"
7-
version = "4.1.0"
7+
version = "4.2.0"
88
authors = [
99
{ name = "Julian West BrainAnnex.org" },
1010
]
@@ -21,7 +21,7 @@ classifiers = [
2121
"Operating System :: OS Independent",
2222
]
2323
dependencies = [
24-
"neo4j==4.3.9",
24+
"neo4j==4.4.11",
2525
"numpy~=1.22.4",
2626
"pandas~=1.4.3",
2727
]

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
neo4j==4.3.9
1+
neo4j==4.4.11
22
numpy==1.22.4
33
pandas==1.4.3
44

src/neoaccess/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
__version__ = "4.1.0"
1+
__version__ = "4.2.0"
22

33
from .neoaccess import NeoAccess

src/neoaccess/cypher_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ def assert_valid_internal_id(cls, internal_id: int) -> None:
201201
:return: None
202202
"""
203203
assert type(internal_id) == int, \
204-
f"assert_valid_internal_id(): Neo4j internal ID's MUST be integers; the value passed was {type(internal_id)}"
204+
f"assert_valid_internal_id(): Neo4j internal ID's MUST be integers; the value passed ({internal_id}) was {type(internal_id)}"
205205

206206
# Note that 0 is a valid Neo4j ID (apparently inconsistently assigned, on occasion, by the database)
207207
assert internal_id >= 0, \

src/neoaccess/neoaccess.py

Lines changed: 85 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -315,13 +315,15 @@ def query(self, q: str, data_binding=None, single_row=False, single_cell="", sin
315315

316316

317317

318-
def query_extended(self, q: str, params = None, flatten = False, fields_to_exclude = None) -> [dict]:
318+
def query_extended(self, q: str, data_binding = None, flatten = False, fields_to_exclude = None) -> [dict]:
319319
"""
320-
Extended version of query(), meant to extract additional info for queries that return Graph Data Types,
320+
Extended version of query(), meant to extract additional info
321+
for queries that return Graph Data Types,
321322
i.e. nodes, relationships or paths,
322323
such as "MATCH (n) RETURN n", or "MATCH (n1)-[r]->(n2) RETURN r"
323324
324-
For example, useful in scenarios where nodes were returned, and their Neo4j internal IDs and/or labels are desired
325+
For example, useful in scenarios where nodes were returned,
326+
and their Neo4j internal IDs and/or labels are desired
325327
(in addition to all the properties and their values)
326328
327329
Unless the flatten flag is True, individual records are kept as separate lists.
@@ -334,7 +336,7 @@ def query_extended(self, q: str, params = None, flatten = False, fields_to_exclu
334336
Try running with flatten=True "MATCH (b:boat), (c:car) RETURN b, c" on data like "CREATE (b:boat), (c1:car1), (c2:car2)"
335337
336338
:param q: A Cypher query
337-
:param params: An optional Cypher dictionary
339+
:param data_binding: An optional Cypher dictionary
338340
EXAMPLE, assuming that the cypher string contains the substring "$age":
339341
{'age': 20}
340342
:param flatten: Flag indicating whether the Graph Data Types need to remain clustered by record,
@@ -345,20 +347,20 @@ def query_extended(self, q: str, params = None, flatten = False, fields_to_exclu
345347
:return: A (possibly empty) list of dictionaries, if flatten is True,
346348
or a list of list, if flatten is False.
347349
Each item in the lists is a dictionary, with details that will depend on which Graph Data Types
348-
were returned in the Cypher query.
349-
EXAMPLE of individual items - for a returned NODE
350-
{'gender': 'M', 'age': 20, 'internal_id': 123, 'neo4j_labels': ['patient']}
351-
EXAMPLE of individual items - for a returned RELATIONSHIP
352-
{'price': 7500, 'internal_id': 2,
353-
'neo4j_start_node': <Node id=11 labels=frozenset() properties={}>,
354-
'neo4j_end_node': <Node id=14 labels=frozenset() properties={}>,
355-
'neo4j_type': 'bought_by'}]
350+
were returned in the Cypher query.
351+
EXAMPLE of *individual items* - for a returned NODE
352+
{'gender': 'M', 'age': 20, 'internal_id': 123, 'neo4j_labels': ['patient']}
353+
EXAMPLE of *individual items* - for a returned RELATIONSHIP
354+
{'price': 7500, 'internal_id': 2,
355+
'neo4j_start_node': <Node id=11 labels=frozenset() properties={}>,
356+
'neo4j_end_node': <Node id=14 labels=frozenset() properties={}>,
357+
'neo4j_type': 'bought_by'}]
356358
"""
357359
# Start a new session, use it, and then immediately close it
358360
with self.driver.session() as new_session:
359-
result = new_session.run(q, params)
361+
result = new_session.run(q, data_binding)
360362
if self.profiling:
361-
print("-- query_extended() PROFILING ----------\n", q, "\n", params)
363+
print("-- query_extended() PROFILING ----------\n", q, "\n", data_binding)
362364

363365
# Note: A neo4j.Result iterable object (printing it, shows an object of type "neo4j.work.result.Result")
364366
# See https://neo4j.com/docs/api/python-driver/current/api.html#neo4j.Result
@@ -836,8 +838,6 @@ def get_node_internal_id(self, match: dict) -> int:
836838
(node, where, data_binding) = CypherUtils.unpack_match(match_structure, include_dummy=False)
837839

838840
q = f"MATCH {node} {CypherUtils.prepare_where(where)} RETURN id(n) AS INTERNAL_ID"
839-
print(q)
840-
print(data_binding)
841841
self.debug_query_print(q, data_binding, "get_node_internal_id")
842842

843843
result = self.query(q, data_binding, single_column="INTERNAL_ID")
@@ -879,11 +879,12 @@ def ________CREATE_NODES________(DIVIDER):
879879

880880
def create_node(self, labels, properties=None) -> int:
881881
"""
882-
Create a new node with the given label(s) and with the attributes/values specified in the properties dictionary.
882+
Create a new node with the given label(s),
883+
and with the attributes/values specified in the properties dictionary.
883884
Return the Neo4j internal ID of the node just created.
884885
885886
:param labels: A string, or list/tuple of strings, specifying Neo4j labels (ok to have blank spaces)
886-
:param properties: An optional (possibly empty or None) dictionary of properties to set for the new node.
887+
:param properties: OPTIONAL (possibly empty or None) dictionary of properties to set for the new node.
887888
EXAMPLE: {'age': 22, 'gender': 'F'}
888889
889890
:return: An integer with the Neo4j internal ID of the node just created
@@ -1030,7 +1031,7 @@ def create_attached_node(self, labels, properties = None,
10301031
def create_node_with_links(self, labels, properties=None, links=None, merge=False) -> int:
10311032
"""
10321033
Create a new node, with the given labels and optional properties,
1033-
and make it a parent of all the EXISTING nodes that are specified
1034+
and link it up to all the EXISTING nodes that are specified
10341035
in the (possibly empty) list of link nodes, identified by their Neo4j internal ID's.
10351036
10361037
The list of link nodes also contains the names to give to each link,
@@ -1729,14 +1730,14 @@ def add_links(self, match_from: Union[int, dict], match_to: Union[int, dict], re
17291730

17301731
def add_links_fast(self, match_from: int, match_to: int, rel_name:str) -> int:
17311732
"""
1732-
Experimental first method optimized for speed. Only internal database ID are used
1733+
Method optimized for speed. Only internal database ID are used.
17331734
17341735
Add a links (aka graph edges/relationships), with the specified rel_name,
17351736
originating in the node identified by match_from,
17361737
and terminating in the node identified by match_to
17371738
1738-
:param match_from: An integer with a Neo4j node id
1739-
:param match_to: An integer with a Neo4j node id
1739+
:param match_from: An integer with an internal Neo4j node id
1740+
:param match_to: An integer with an internal Neo4j node id
17401741
:param rel_name: The name to give to the new relationship between the 2 specified nodes. Blanks allowed
17411742
17421743
:return: The number of links added. If none got added, or in case of error, an Exception is raised
@@ -2131,20 +2132,21 @@ def count_links(self, match: Union[int, dict], rel_name: str, rel_dir="OUT", nei
21312132

21322133

21332134

2134-
def get_parents_and_children(self, node_id: int) -> ():
2135+
def get_parents_and_children(self, internal_id: int) -> ():
21352136
"""
21362137
Fetch all the nodes connected to the given one by INbound relationships to it (its "parents"),
21372138
as well as by OUTbound relationships to it (its "children")
2139+
TODO: allow specifying a relationship name to follow
21382140
2139-
:param node_id: An integer with a Neo4j internal node ID
2140-
:return: A dictionary with 2 keys: 'parent_list' and 'child_list'
2141-
The values are lists of dictionaries with 3 keys: "internal_id", "label", "rel"
2142-
EXAMPLE of individual items in either parent_list or child_list:
2143-
{'internal_id': 163, 'labels': ['Subject'], 'rel': 'HAS_TREATMENT'}
2141+
:param internal_id: An integer with a Neo4j internal node ID
2142+
:return: A dictionary with 2 keys: 'parent_list' and 'child_list'
2143+
The values are lists of dictionaries with 3 keys: "internal_id", "label", "rel"
2144+
EXAMPLE of individual items in either parent_list or child_list:
2145+
{'internal_id': 163, 'labels': ['Subject'], 'rel': 'HAS_TREATMENT'}
21442146
"""
21452147

21462148
# Fetch the parents
2147-
cypher = f"MATCH (parent)-[inbound]->(n) WHERE id(n) = {node_id} " \
2149+
cypher = f"MATCH (parent)-[inbound]->(n) WHERE id(n) = {internal_id} " \
21482150
"RETURN id(parent) AS internal_id, labels(parent) AS labels, type(inbound) AS rel"
21492151

21502152
parent_list = self.query(cypher)
@@ -2154,7 +2156,7 @@ def get_parents_and_children(self, node_id: int) -> ():
21542156

21552157

21562158
# Fetch the children
2157-
cypher = f"MATCH (n)-[outbound]->(child) WHERE id(n) = {node_id} " \
2159+
cypher = f"MATCH (n)-[outbound]->(child) WHERE id(n) = {internal_id} " \
21582160
"RETURN id(child) AS internal_id, labels(child) AS labels, type(outbound) AS rel"
21592161

21602162
child_list = self.query(cypher)
@@ -2167,6 +2169,56 @@ def get_parents_and_children(self, node_id: int) -> ():
21672169

21682170

21692171

2172+
def get_siblings(self, internal_id: int, rel_name: str, rel_dir="OUT") -> [int]:
2173+
"""
2174+
Return the data of all the "sibling" nodes of the given one.
2175+
"Siblings" is meant as "sharing a link (by default outbound) of the specified name,
2176+
to a common other node".
2177+
2178+
EXAMPLE: 2 nodes, "French" and "German",
2179+
each with a outbound link named "subcategory_of" to a third node,
2180+
will be considered "siblings" under rel_name="subcategory_of" and rel_dir="OUT
2181+
2182+
:param internal_id: Integer with the internal database ID of the node of interest
2183+
:param rel_name: The name of the relationship used to establish a "siblings" connection
2184+
:param rel_dir: Either "OUT" (default) or "IN". The link direction expected from the
2185+
start node to its "parents" - and then IN REVERSE to the parent's children
2186+
:return: A list of dictionaries, with one element for each "sibling";
2187+
each element contains the 'internal_id' and 'neo4j_labels' keys,
2188+
plus whatever attributes are stored on that node.
2189+
EXAMPLE of single element:
2190+
{'name': 'French', 'internal_id': 123, 'neo4j_labels': ['Categories']}
2191+
"""
2192+
CypherUtils.assert_valid_internal_id(internal_id)
2193+
2194+
assert type(rel_name) == str, \
2195+
f"get_siblings(): argument `rel_name` must be a string; " \
2196+
f"the given value ({rel_name}) is of type {type(rel_name)}"
2197+
2198+
# Follow the links with the specified name, in the indicated direction from the given link,
2199+
# and then in the reverse direction
2200+
if rel_dir == "OUT":
2201+
q = f"""
2202+
MATCH (n) - [:{rel_name}] -> (parent) <- [:{rel_name}] - (sibling)
2203+
WHERE id(n) = $internal_id
2204+
RETURN sibling
2205+
"""
2206+
elif rel_dir == "IN":
2207+
q = f"""
2208+
MATCH (n) <- [:{rel_name}] - (parent) - [:{rel_name}] -> (sibling)
2209+
WHERE id(n) = $internal_id
2210+
RETURN sibling
2211+
"""
2212+
else:
2213+
raise Exception(f"get_siblings(): unknown value for the `rel_dir` argument ({rel_dir}); "
2214+
f"allowed values are 'IN' and 'OUT'")
2215+
2216+
result = self.query_extended(q, data_binding={"internal_id": internal_id}, flatten=True)
2217+
return result
2218+
2219+
2220+
2221+
21702222

21712223
#####################################################################################################
21722224

@@ -2206,9 +2258,9 @@ def get_label_properties(self, label:str) -> list:
22062258
RETURN DISTINCT propertyName
22072259
ORDER BY propertyName
22082260
"""
2209-
params = {'label': label}
2261+
data_binding = {'label': label}
22102262

2211-
return [res['propertyName'] for res in self.query(q, params)]
2263+
return [res['propertyName'] for res in self.query(q, data_binding)]
22122264

22132265

22142266

@@ -3044,4 +3096,4 @@ def _debug_local(self) -> str:
30443096
30453097
:return:
30463098
"""
3047-
return "remote"
3099+
return "local"

tests/test_neoaccess.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def test_construction():
3838
obj1 = neo_access.NeoAccess(url, debug=False) # Rely on default username/pass
3939

4040
assert obj1.debug is False
41-
assert obj1.version() == "4.3.9" # Test the version of the Neo4j driver (this ought to match the value in requirements.txt)
41+
assert obj1.version() == "4.4.11" # Test the version of the Neo4j driver (this ought to match the value in requirements.txt)
4242

4343

4444
# Another way of instantiating the class
@@ -641,6 +641,94 @@ def test_get_parents_and_children(db):
641641

642642

643643

644+
def test_get_siblings(db):
645+
db.empty_dbase()
646+
647+
# Create "French" and "German" nodes, as subcategory of "Language"
648+
q = '''
649+
CREATE (c1 :Categories {name: "French"})-[:subcategory_of]->(p :Categories {name: "Language"})<-[:subcategory_of]-
650+
(c2 :Categories {name: "German"})
651+
RETURN id(c1) AS french_id, id(c2) AS german_id, id(p) AS language_id
652+
'''
653+
create_result = db.query(q, single_row=True)
654+
655+
(french_id, german_id, language_id) = list( map(create_result.get, ["french_id", "german_id", "language_id"]) )
656+
657+
658+
# Get all the sibling categories of a given language
659+
660+
with pytest.raises(Exception):
661+
db.get_siblings(internal_id=french_id, rel_name=666, rel_dir="OUT") # rel_name isn't a string
662+
663+
with pytest.raises(Exception):
664+
db.get_siblings(internal_id="Do I look like an ID?", rel_name="subcategory_of", rel_dir="OUT")
665+
666+
with pytest.raises(Exception):
667+
db.get_siblings(internal_id=french_id, rel_name="subcategory_of", rel_dir="Is it IN or OUT")
668+
669+
# The single sibling of "French" is "German"
670+
result = db.get_siblings(internal_id=french_id, rel_name="subcategory_of", rel_dir="OUT")
671+
assert result == [{'name': 'German', 'internal_id': german_id, 'neo4j_labels': ['Categories']}]
672+
673+
# Conversely, the single sibling of "German" is "French"
674+
result = db.get_siblings(internal_id=german_id, rel_name="subcategory_of", rel_dir="OUT")
675+
assert result == [{'name': 'French', 'internal_id': french_id, 'neo4j_labels': ['Categories']}]
676+
677+
# But attempting to follow the links in the opposite directions will yield no results
678+
result = db.get_siblings(internal_id=german_id, rel_name="subcategory_of", rel_dir="IN") # "wrong" direction
679+
assert result == []
680+
681+
# Add a 3rd language category, "Italian", as a subcategory of the "Language" node
682+
italian_id = db.create_attached_node(labels="Categories", properties={"name": "Italian"},
683+
attached_to=language_id, rel_name="subcategory_of")
684+
685+
# Now, "French" will have 2 siblings instead of 1
686+
result = db.get_siblings(internal_id=french_id, rel_name="subcategory_of", rel_dir="OUT")
687+
expected = [{'name': 'Italian', 'internal_id': italian_id, 'neo4j_labels': ['Categories']},
688+
{'name': 'German', 'internal_id': german_id, 'neo4j_labels': ['Categories']}]
689+
assert compare_recordsets(result, expected)
690+
691+
# "Italian" will also have 2 siblings
692+
result = db.get_siblings(internal_id=italian_id, rel_name="subcategory_of", rel_dir="OUT")
693+
expected = [{'name': 'French', 'internal_id': french_id, 'neo4j_labels': ['Categories']},
694+
{'name': 'German', 'internal_id': german_id, 'neo4j_labels': ['Categories']}]
695+
assert compare_recordsets(result, expected)
696+
697+
# Add a node that is a "parent" of "French" and "Italian" thru a different relationship
698+
db.create_attached_node(labels="Language Family", properties={"name": "Romance"},
699+
attached_to=[french_id, italian_id], rel_name="contains")
700+
701+
# Now, "French" will also have a sibling thru the "contains" relationship
702+
result = db.get_siblings(internal_id=french_id, rel_name="contains", rel_dir="IN")
703+
expected = [{'name': 'Italian', 'internal_id': italian_id, 'neo4j_labels': ['Categories']}]
704+
assert compare_recordsets(result, expected)
705+
706+
# Likewise for the "Italian" node
707+
result = db.get_siblings(internal_id=italian_id, rel_name="contains", rel_dir="IN")
708+
expected = [{'name': 'French', 'internal_id': french_id, 'neo4j_labels': ['Categories']}]
709+
assert compare_recordsets(result, expected)
710+
711+
# "Italian" still has 2 siblings thru the other relationship "subcategory_of"
712+
result = db.get_siblings(internal_id=italian_id, rel_name="subcategory_of", rel_dir="OUT")
713+
expected = [{'name': 'French', 'internal_id': french_id, 'neo4j_labels': ['Categories']},
714+
{'name': 'German', 'internal_id': german_id, 'neo4j_labels': ['Categories']}]
715+
assert compare_recordsets(result, expected)
716+
717+
# Add an unattached node
718+
brazilian_id = db.create_node(labels="Categories", properties={"name": "Brazilian"})
719+
result = db.get_siblings(internal_id=brazilian_id, rel_name="subcategory_of", rel_dir="OUT")
720+
assert result == [] # No siblings
721+
722+
# After connecting the "Brazilian" node to the "Language" node, it has 3 siblings
723+
db.add_links_fast(match_from=brazilian_id, match_to=language_id, rel_name="subcategory_of")
724+
result = db.get_siblings(internal_id=brazilian_id, rel_name="subcategory_of", rel_dir="OUT")
725+
expected = [{'name': 'French', 'internal_id': french_id, 'neo4j_labels': ['Categories']},
726+
{'name': 'German', 'internal_id': german_id, 'neo4j_labels': ['Categories']},
727+
{'name': 'Italian', 'internal_id': italian_id, 'neo4j_labels': ['Categories']}]
728+
assert compare_recordsets(result, expected)
729+
730+
731+
644732

645733
### ~ CREATE NODES ~
646734

0 commit comments

Comments
 (0)