Skip to content

Commit ed8f4f8

Browse files
Merge branch 'rc/5.5.0' of https://github.com/neo4j-contrib/neomodel into rc/5.5.0
2 parents 4db05cc + 9bfb933 commit ed8f4f8

File tree

7 files changed

+347
-78
lines changed

7 files changed

+347
-78
lines changed

doc/source/advanced_query_operations.rst

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,9 @@ Here is a full example::
7272
await Coffee.nodes.fetch_relations("suppliers")
7373
.intermediate_transform(
7474
{
75-
"coffee": "coffee",
76-
"suppliers": NodeNameResolver("suppliers"),
77-
"r": RelationNameResolver("suppliers"),
75+
"coffee": {"source": "coffee"},
76+
"suppliers": {"source": NodeNameResolver("suppliers")},
77+
"r": {"source": RelationNameResolver("suppliers")},
7878
"coffee": {"source": "coffee", "include_in_return": True}, # Only coffee will be returned
7979
"suppliers": {"source": NodeNameResolver("suppliers")},
8080
"r": {"source": RelationNameResolver("suppliers")},
@@ -146,3 +146,15 @@ In some cases though, it is not possible to set explicit aliases, for example wh
146146
.. note::
147147

148148
When using the resolvers in combination with a traversal as in the example above, it will resolve the variable name of the last element in the traversal - the Species node for NodeNameResolver, and Coffee--Species relationship for RelationshipNameResolver.
149+
150+
Another example is to reference the root node itself::
151+
152+
subquery = await Coffee.nodes.subquery(
153+
Coffee.nodes.traverse_relations(suppliers="suppliers")
154+
.intermediate_transform(
155+
{"suppliers": {"source": "suppliers"}}, ordering=["suppliers.delivery_cost"]
156+
)
157+
.annotate(supps=Last(Collect("suppliers"))),
158+
["supps"],
159+
[NodeNameResolver("self")], # This is the root Coffee node
160+
)

doc/source/getting_started.rst

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -261,10 +261,10 @@ Retrieving additional relations
261261
.. note::
262262

263263
You can fetch one or more relations within the same call
264-
to `.fetch_relations()` and you can mix optional and non-optional
264+
to `.traverse()` and you can mix optional and non-optional
265265
relations, like::
266266

267-
Person.nodes.fetch_relations('city__country', Optional('country')).all()
267+
Person.nodes.traverse('city__country', Path(value='country', optional=True)).all()
268268

269269
.. note::
270270

@@ -368,12 +368,12 @@ The example below will show you how you can mix and match query operations, as d
368368
full_nodeset = (
369369
await Student.nodes.filter(name__istartswith="m", lives_in__name="Eiffel Tower") # Combine filters
370370
.order_by("name")
371-
.fetch_relations(
371+
.traverse(
372372
"parents",
373-
Optional("children__preferred_course"),
374-
) # Combine fetch_relations
373+
Path(value="children__preferred_course", optional=True)
374+
) # Combine traversals
375375
.subquery(
376-
Student.nodes.fetch_relations("courses") # Root variable student will be auto-injected here
376+
Student.nodes.traverse("courses") # Root variable student will be auto-injected here
377377
.intermediate_transform(
378378
{"rel": RelationNameResolver("courses")},
379379
ordering=[

doc/source/traversal.rst

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ Path traversal
66

77
Neo4j is about traversing the graph, which means leveraging nodes and relations between them. This section will show you how to traverse the graph using neomodel.
88

9-
We will cover two methods : `traverse_relations` and `fetch_relations`. Those two methods are *mutually exclusive*, so you cannot chain them.
9+
For this, the method to use is `traverse`.
10+
11+
Note that until version 6, two other methods are available, but deprecated : `traverse_relations` and `fetch_relations`. Those two methods are *mutually exclusive*, so you cannot chain them.
1012

1113
For the examples in this section, we will be using the following model::
1214

@@ -27,6 +29,59 @@ For the examples in this section, we will be using the following model::
2729
Traverse relations
2830
------------------
2931

32+
The `traverse` allows you to define multiple, multi-hop traversals, optionally returning traversed elements.
33+
34+
For example, to find all `Coffee` nodes that have a supplier, and retrieve the country of that supplier, you can do::
35+
36+
Coffee.nodes.traverse("suppliers__country").all()
37+
38+
This will generate a Cypher MATCH clause which traverses `Coffee<--Supplier-->Country`, and by default will return all traversed nodes and relationships.
39+
40+
This method allows you to define a more complex `Path` object, giving you more control over the traversal.
41+
42+
You can specify which elements to return, like::
43+
44+
# Return only the traversed nodes, not the relationships
45+
Coffee.nodes.traverse(Path(value="suppliers__country", include_rels_in_return=False))
46+
47+
# Return only the traversed relationships, not the nodes
48+
Coffee.nodes.traverse(Path(value="suppliers__country", include_nodes_in_return=False))
49+
50+
You can specify that your traversal should be optional, like::
51+
52+
# Return only the traversed nodes, not the relationships
53+
Coffee.nodes.traverse(Path(value="suppliers__country", optional=True))
54+
55+
You can also alias the path, so that you can reference it later in the query, like::
56+
57+
Coffee.nodes.traverse(Path(value="suppliers__country", alias="supplier_country"))
58+
59+
The `Country` nodes matched will be made available for the rest of the query, with the variable name `country`. Note that this aliasing is optional. See :ref:`Advanced query operations` for examples of how to use this aliasing.
60+
61+
.. note::
62+
63+
The `traverse` method can be used to traverse multiple paths, like::
64+
65+
Coffee.nodes.traverse('suppliers__country', 'pub__city').all()
66+
67+
This will generate a Cypher MATCH clause that traverses both paths `Coffee<--Supplier-->Country` and `Coffee<--Pub-->City`.
68+
69+
.. note::
70+
71+
When using `include_rels_in_return=True` (default), any relationship that you traverse using this method **MUST have a model defined**, even if only the default StructuredRel, like::
72+
73+
class Person(StructuredNode):
74+
country = RelationshipTo(Country, 'IS_FROM', model=StructuredRel)
75+
76+
Otherwise, neomodel will not be able to determine which relationship model to resolve into, and will fail.
77+
78+
Traverse relations (deprecated)
79+
-------------------------------
80+
81+
.. deprecated:: 5.5.0
82+
83+
This method is set to disappear in version 6, use `traverse` instead.
84+
3085
The `traverse_relations` method allows you to filter on the existence of more complex traversals. For example, to find all `Coffee` nodes that have a supplier, and retrieve the country of that supplier, you can do::
3186

3287
Coffee.nodes.traverse_relations(country='suppliers__country').all()
@@ -43,8 +98,12 @@ The `Country` nodes matched will be made available for the rest of the query, wi
4398

4499
This will generate a Cypher MATCH clause that enforces the existence of at least one path like `Coffee<--Supplier-->Country` and `Coffee<--Pub-->City`.
45100

46-
Fetch relations
47-
---------------
101+
Fetch relations (deprecated)
102+
----------------------------
103+
104+
.. deprecated:: 5.5.0
105+
106+
This method is set to disappear in version 6, use `traverse` instead.
48107

49108
The syntax for `fetch_relations` is similar to `traverse_relations`, except that the generated Cypher will return all traversed objects (nodes and relations)::
50109

@@ -59,8 +118,12 @@ The syntax for `fetch_relations` is similar to `traverse_relations`, except that
59118

60119
Otherwise, neomodel will not be able to determine which relationship model to resolve into, and will fail.
61120

62-
Optional match
63-
--------------
121+
Optional match (deprecated)
122+
---------------------------
123+
124+
.. deprecated:: 5.5.50
125+
126+
This method is set to disappear in version 6, use `traverse` instead.
64127

65128
With both `traverse_relations` and `fetch_relations`, you can force the use of an ``OPTIONAL MATCH`` statement using the following syntax::
66129

neomodel/async_/match.py

Lines changed: 77 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import inspect
22
import re
33
import string
4+
import warnings
45
from dataclasses import dataclass
56
from typing import Any, AsyncIterator
67
from typing import Optional as TOptional
@@ -576,9 +577,9 @@ def _additional_return(self, name: str) -> None:
576577
self._ast.additional_return.append(name)
577578

578579
def build_traversal_from_path(
579-
self, relation: dict, source_class: Any
580+
self, relation: "Path", source_class: Any
580581
) -> Tuple[str, Any]:
581-
path: str = relation["path"]
582+
path: str = relation.value
582583
stmt: str = ""
583584
source_class_iterator = source_class
584585
parts = re.split(path_split_regex, path)
@@ -606,27 +607,27 @@ def build_traversal_from_path(
606607
if self._subquery_namespace:
607608
# Don't include label in identifier if we are in a subquery
608609
lhs_ident = lhs_name
609-
elif relation["include_in_return"]:
610+
elif relation.include_nodes_in_return:
610611
self._additional_return(lhs_name)
611612
else:
612613
lhs_ident = stmt
613614

614615
already_present = part in subgraph
615616
rel_ident = self.create_relation_identifier()
616617
rhs_label = relationship.definition["node_class"].__label__
617-
if relation.get("relation_filtering"):
618+
if relation.relation_filtering:
618619
rhs_name = rel_ident
619620
rhs_ident = f":{rhs_label}"
620621
else:
621-
if index + 1 == len(parts) and "alias" in relation:
622+
if index + 1 == len(parts) and relation.alias:
622623
# If an alias is defined, use it to store the last hop in the path
623-
rhs_name = relation["alias"]
624+
rhs_name = relation.alias
624625
else:
625626
rhs_name = f"{rhs_label.lower()}_{rel_iterator}"
626627
rhs_name = self.create_node_identifier(rhs_name, rel_iterator)
627628
rhs_ident = f"{rhs_name}:{rhs_label}"
628629

629-
if relation["include_in_return"] and not already_present:
630+
if relation.include_nodes_in_return and not already_present:
630631
self._additional_return(rhs_name)
631632

632633
if not already_present:
@@ -640,11 +641,11 @@ def build_traversal_from_path(
640641
existing_rhs_name = subgraph[part][
641642
(
642643
"rel_variable_name"
643-
if relation.get("relation_filtering")
644+
if relation.relation_filtering
644645
else "variable_name"
645646
)
646647
]
647-
if relation["include_in_return"] and not already_present:
648+
if relation.include_rels_in_return and not already_present:
648649
self._additional_return(rel_ident)
649650
stmt = _rel_helper(
650651
lhs=lhs_ident,
@@ -657,7 +658,7 @@ def build_traversal_from_path(
657658
subgraph = subgraph[part]["children"]
658659

659660
if not already_present:
660-
if relation.get("optional"):
661+
if relation.optional:
661662
self._ast.optional_match.append(stmt)
662663
else:
663664
self._ast.match.append(stmt)
@@ -745,11 +746,10 @@ def _parse_path(
745746
is_optional_relation = False
746747
if not result:
747748
ident, target_class = self.build_traversal_from_path(
748-
{
749-
"path": path,
750-
"include_in_return": True,
751-
"relation_filtering": is_rel_filter,
752-
},
749+
Path(
750+
value=path,
751+
relation_filtering=is_rel_filter,
752+
),
753753
source_class,
754754
)
755755
else:
@@ -906,8 +906,8 @@ def lookup_query_variable(
906906
# (declared using fetch|traverse_relations)
907907
is_optional_relation = False
908908
for relation in self.node_set.relations_to_fetch:
909-
if relation["path"] == path:
910-
is_optional_relation = relation.get("optional", False)
909+
if relation.value == path:
910+
is_optional_relation = relation.optional
911911
break
912912

913913
subgraph = subgraph[traversals[0]]
@@ -1219,6 +1219,18 @@ class Optional: # type: ignore[no-redef]
12191219
relation: str
12201220

12211221

1222+
@dataclass
1223+
class Path:
1224+
"""Path traversal definition."""
1225+
1226+
value: str
1227+
optional: bool = False
1228+
include_nodes_in_return: bool = True
1229+
include_rels_in_return: bool = True
1230+
relation_filtering: bool = False
1231+
alias: TOptional[str] = None
1232+
1233+
12221234
@dataclass
12231235
class RelationNameResolver:
12241236
"""Helper to refer to a relation variable name.
@@ -1386,7 +1398,7 @@ def __init__(self, source: Any) -> None:
13861398
self.must_match: dict = {}
13871399
self.dont_match: dict = {}
13881400

1389-
self.relations_to_fetch: list = []
1401+
self.relations_to_fetch: list[Path] = []
13901402
self._extra_results: list = []
13911403
self._subqueries: list[Subquery] = []
13921404
self._intermediate_transforms: list = []
@@ -1547,30 +1559,47 @@ def order_by(self, *props: Any) -> "AsyncBaseSet":
15471559
return self
15481560

15491561
def _register_relation_to_fetch(
1550-
self,
1551-
relation_def: Any,
1552-
alias: TOptional[str] = None,
1553-
include_in_return: bool = True,
1554-
) -> dict:
1555-
if isinstance(relation_def, Optional):
1556-
item = {"path": relation_def.relation, "optional": True}
1562+
self, relation_def: Any, alias: TOptional[str] = None
1563+
) -> "Path":
1564+
if isinstance(relation_def, Path):
1565+
item = relation_def
15571566
else:
1558-
item = {"path": relation_def}
1559-
item["include_in_return"] = include_in_return
1560-
1567+
item = Path(
1568+
value=relation_def,
1569+
)
15611570
if alias:
1562-
item["alias"] = alias
1571+
item.alias = alias
15631572
return item
15641573

15651574
def unique_variables(self, *pathes: tuple[str, ...]) -> "AsyncNodeSet":
15661575
"""Generate unique variable names for the given pathes."""
15671576
self._unique_variables = pathes
15681577
return self
15691578

1579+
def traverse(
1580+
self, *pathes: tuple[str, ...], **aliased_pathes: dict
1581+
) -> "AsyncNodeSet":
1582+
"""Specify a set of pathes to traverse."""
1583+
relations = []
1584+
for path in pathes:
1585+
relations.append(self._register_relation_to_fetch(path))
1586+
for alias, aliased_path in aliased_pathes.items():
1587+
relations.append(
1588+
self._register_relation_to_fetch(aliased_path, alias=alias)
1589+
)
1590+
self.relations_to_fetch = relations
1591+
return self
1592+
15701593
def fetch_relations(self, *relation_names: tuple[str, ...]) -> "AsyncNodeSet":
15711594
"""Specify a set of relations to traverse and return."""
1595+
warnings.warn(
1596+
"fetch_relations() will be deprecated in version 6, use traverse() instead.",
1597+
DeprecationWarning,
1598+
)
15721599
relations = []
15731600
for relation_name in relation_names:
1601+
if isinstance(relation_name, Optional):
1602+
relation_name = Path(value=relation_name.relation, optional=True)
15741603
relations.append(self._register_relation_to_fetch(relation_name))
15751604
self.relations_to_fetch = relations
15761605
return self
@@ -1579,15 +1608,30 @@ def traverse_relations(
15791608
self, *relation_names: tuple[str, ...], **aliased_relation_names: dict
15801609
) -> "AsyncNodeSet":
15811610
"""Specify a set of relations to traverse only."""
1611+
1612+
warnings.warn(
1613+
"traverse_relations() will be deprecated in version 6, use traverse() instead.",
1614+
DeprecationWarning,
1615+
)
1616+
1617+
def convert_to_path(input: Union[str, Optional]) -> Path:
1618+
if isinstance(input, Optional):
1619+
path = Path(value=input.relation, optional=True)
1620+
else:
1621+
path = Path(value=input)
1622+
path.include_nodes_in_return = False
1623+
path.include_rels_in_return = False
1624+
return path
1625+
15821626
relations = []
15831627
for relation_name in relation_names:
15841628
relations.append(
1585-
self._register_relation_to_fetch(relation_name, include_in_return=False)
1629+
self._register_relation_to_fetch(convert_to_path(relation_name))
15861630
)
15871631
for alias, relation_def in aliased_relation_names.items():
15881632
relations.append(
15891633
self._register_relation_to_fetch(
1590-
relation_def, alias, include_in_return=False
1634+
convert_to_path(relation_def), alias=alias
15911635
)
15921636
)
15931637

@@ -1660,7 +1704,8 @@ async def resolve_subgraph(self) -> list:
16601704
"""
16611705
if (
16621706
self.relations_to_fetch
1663-
and not self.relations_to_fetch[0]["include_in_return"]
1707+
and not self.relations_to_fetch[0].include_nodes_in_return
1708+
and not self.relations_to_fetch[0].include_rels_in_return
16641709
):
16651710
raise NotImplementedError(
16661711
"You cannot use traverse_relations() with resolve_subgraph(), use fetch_relations() instead."

0 commit comments

Comments
 (0)