Skip to content

Commit b4c8e5e

Browse files
Merge pull request #841 from neo4j-contrib/rc/5.4.1
Rc/5.4.1
2 parents d2e6704 + f9344d5 commit b4c8e5e

File tree

14 files changed

+378
-99
lines changed

14 files changed

+378
-99
lines changed

Changelog

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
Version 5.4.1 2024-11
2+
* Add support for Cypher parallel runtime
3+
* Add options for intermediate_transform : distinct, include_in_return, use a prop as source
4+
15
Version 5.4.0 2024-11
26
* Traversal option for filtering and ordering
37
* Insert raw Cypher for ordering

doc/source/advanced_query_operations.rst

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,41 @@ As discussed in the note above, this is for example useful when you need to orde
5454
# This will return all Coffee nodes, with their most expensive supplier
5555
Coffee.nodes.traverse_relations(suppliers="suppliers")
5656
.intermediate_transform(
57-
{"suppliers": "suppliers"}, ordering=["suppliers.delivery_cost"]
57+
{"suppliers": {"source": "suppliers"}}, ordering=["suppliers.delivery_cost"]
5858
)
5959
.annotate(supps=Last(Collect("suppliers")))
6060

61+
Options for `intermediate_transform` *variables* are:
62+
63+
- `source`: `string`or `Resolver` - the variable to use as source for the transformation. Works with resolvers (see below).
64+
- `source_prop`: `string` - optionally, a property of the source variable to use as source for the transformation.
65+
- `include_in_return`: `bool` - whether to include the variable in the return statement. Defaults to False.
66+
67+
Additional options for the `intermediate_transform` method are:
68+
- `distinct`: `bool` - whether to deduplicate the results. Defaults to False.
69+
70+
Here is a full example::
71+
72+
await Coffee.nodes.fetch_relations("suppliers")
73+
.intermediate_transform(
74+
{
75+
"coffee": "coffee",
76+
"suppliers": NodeNameResolver("suppliers"),
77+
"r": RelationNameResolver("suppliers"),
78+
"coffee": {"source": "coffee", "include_in_return": True}, # Only coffee will be returned
79+
"suppliers": {"source": NodeNameResolver("suppliers")},
80+
"r": {"source": RelationNameResolver("suppliers")},
81+
"cost": {
82+
"source": NodeNameResolver("suppliers"),
83+
"source_prop": "delivery_cost",
84+
},
85+
},
86+
distinct=True,
87+
ordering=["-r.since"],
88+
)
89+
.annotate(oldest_supplier=Last(Collect("suppliers")))
90+
.all()
91+
6192
Subqueries
6293
----------
6394

@@ -71,7 +102,7 @@ The `subquery` method allows you to perform a `Cypher subquery <https://neo4j.co
71102
.subquery(
72103
Coffee.nodes.traverse_relations(suppliers="suppliers")
73104
.intermediate_transform(
74-
{"suppliers": "suppliers"}, ordering=["suppliers.delivery_cost"]
105+
{"suppliers": {"source": "suppliers"}}, ordering=["suppliers.delivery_cost"]
75106
)
76107
.annotate(supps=Last(Collect("suppliers"))),
77108
["supps"],
@@ -108,4 +139,4 @@ In some cases though, it is not possible to set explicit aliases, for example wh
108139

109140
.. note::
110141

111-
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.
142+
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.

doc/source/configuration.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Adjust driver configuration - these options are only available for this connecti
3232
config.MAX_TRANSACTION_RETRY_TIME = 30.0 # default
3333
config.RESOLVER = None # default
3434
config.TRUST = neo4j.TRUST_SYSTEM_CA_SIGNED_CERTIFICATES # default
35-
config.USER_AGENT = neomodel/v5.4.0 # default
35+
config.USER_AGENT = neomodel/v5.4.1 # default
3636

3737
Setting the database name, if different from the default one::
3838

doc/source/transactions.rst

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ Explicit Transactions
5151
Neomodel also supports `explicit transactions <https://neo4j.com/docs/
5252
api/python-driver/current/transactions.html>`_ that are pre-designated as either *read* or *write*.
5353

54-
This is vital when using neomodel over a `Neo4J causal cluster <https://neo4j.com/docs/
54+
This is vital when using neomodel over a `Neo4j causal cluster <https://neo4j.com/docs/
5555
operations-manual/current/clustering/>`_ because internally, queries will be rerouted to different
5656
servers depending on their designation.
5757

@@ -168,7 +168,7 @@ Impersonation
168168

169169
*Neo4j Enterprise feature*
170170

171-
Impersonation (`see Neo4j driver documentation <https://neo4j.com/docs/api/python-driver/current/api.html#impersonated-user-ref>``)
171+
Impersonation (`see Neo4j driver documentation <https://neo4j.com/docs/api/python-driver/current/api.html#impersonated-user-ref>`_)
172172
can be enabled via a context manager::
173173

174174
from neomodel import db
@@ -197,4 +197,22 @@ This can be mixed with other context manager like transactions::
197197

198198
@db.transaction()
199199
def func2():
200-
...
200+
...
201+
202+
203+
Parallel runtime
204+
----------------
205+
206+
As of version 5.13, Neo4j *Enterprise Edition* supports parallel runtime for read transactions.
207+
208+
To use it, you can simply use the `parallel_read_transaction` context manager::
209+
210+
from neomodel import db
211+
212+
with db.parallel_read_transaction:
213+
# It works for both neomodel-generated and custom Cypher queries
214+
parallel_count_1 = len(Coffee.nodes)
215+
parallel_count_2 = db.cypher_query("MATCH (n:Coffee) RETURN count(n)")
216+
217+
It is worth noting that the parallel runtime is only available for read transactions and that it is not enabled by default, because it is not always the fastest option. It is recommended to test it in your specific use case to see if it improves performance, and read the general considerations in the `Neo4j official documentation <https://neo4j.com/docs/cypher-manual/current/planning-and-tuning/runtimes/concepts/#runtimes-parallel-runtime-considerations>`_.
218+

neomodel/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "5.4.0"
1+
__version__ = "5.4.1"

neomodel/async_/core.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ def __init__(self):
104104
self._database_version = None
105105
self._database_edition = None
106106
self.impersonated_user = None
107+
self._parallel_runtime = False
107108

108109
async def set_connection(self, url: str = None, driver: AsyncDriver = None):
109110
"""
@@ -239,6 +240,10 @@ def write_transaction(self):
239240
def read_transaction(self):
240241
return AsyncTransactionProxy(self, access_mode="READ")
241242

243+
@property
244+
def parallel_read_transaction(self):
245+
return AsyncTransactionProxy(self, access_mode="READ", parallel_runtime=True)
246+
242247
async def impersonate(self, user: str) -> "ImpersonationHandler":
243248
"""All queries executed within this context manager will be executed as impersonated user
244249
@@ -454,7 +459,6 @@ async def cypher_query(
454459
455460
:return: A tuple containing a list of results and a tuple of headers.
456461
"""
457-
458462
if self._active_transaction:
459463
# Use current session is a transaction is currently active
460464
results, meta = await self._run_cypher_query(
@@ -493,6 +497,8 @@ async def _run_cypher_query(
493497
try:
494498
# Retrieve the data
495499
start = time.time()
500+
if self._parallel_runtime:
501+
query = "CYPHER runtime=parallel " + query
496502
response: AsyncResult = await session.run(query, params)
497503
results, meta = [list(r.values()) async for r in response], response.keys()
498504
end = time.time()
@@ -598,6 +604,18 @@ async def edition_is_enterprise(self) -> bool:
598604
edition = await self.database_edition
599605
return edition == "enterprise"
600606

607+
@ensure_connection
608+
async def parallel_runtime_available(self) -> bool:
609+
"""Returns true if the database supports parallel runtime
610+
611+
Returns:
612+
bool: True if the database supports parallel runtime
613+
"""
614+
return (
615+
await self.version_is_higher_than("5.13")
616+
and await self.edition_is_enterprise()
617+
)
618+
601619
async def change_neo4j_password(self, user, new_password):
602620
await self.cypher_query(f"ALTER USER {user} SET PASSWORD '{new_password}'")
603621

@@ -1168,17 +1186,29 @@ async def install_all_labels(stdout=None):
11681186
class AsyncTransactionProxy:
11691187
bookmarks: Optional[Bookmarks] = None
11701188

1171-
def __init__(self, db: AsyncDatabase, access_mode=None):
1189+
def __init__(
1190+
self, db: AsyncDatabase, access_mode: str = None, parallel_runtime: bool = False
1191+
):
11721192
self.db = db
11731193
self.access_mode = access_mode
1194+
self.parallel_runtime = parallel_runtime
11741195

11751196
@ensure_connection
11761197
async def __aenter__(self):
1198+
if self.parallel_runtime and not await self.db.parallel_runtime_available():
1199+
warnings.warn(
1200+
"Parallel runtime is only available in Neo4j Enterprise Edition 5.13 and above. "
1201+
"Reverting to default runtime.",
1202+
UserWarning,
1203+
)
1204+
self.parallel_runtime = False
1205+
self.db._parallel_runtime = self.parallel_runtime
11771206
await self.db.begin(access_mode=self.access_mode, bookmarks=self.bookmarks)
11781207
self.bookmarks = None
11791208
return self
11801209

11811210
async def __aexit__(self, exc_type, exc_value, traceback):
1211+
self.db._parallel_runtime = False
11821212
if exc_value:
11831213
await self.db.rollback()
11841214

neomodel/async_/match.py

Lines changed: 51 additions & 36 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, Dict, List
67
from typing import Optional as TOptional
@@ -12,6 +13,7 @@
1213
from neomodel.exceptions import MultipleNodesReturned
1314
from neomodel.match_q import Q, QBase
1415
from neomodel.properties import AliasProperty, ArrayProperty, Property
16+
from neomodel.typing import Transformation
1517
from neomodel.util import INCOMING, OUTGOING
1618

1719
CYPHER_ACTIONS_WITH_SIDE_EFFECT_EXPR = re.compile(r"(?i:MERGE|CREATE|DELETE|DETACH)")
@@ -588,9 +590,11 @@ def build_traversal_from_path(
588590
}
589591
else:
590592
existing_rhs_name = subgraph[part][
591-
"rel_variable_name"
592-
if relation.get("relation_filtering")
593-
else "variable_name"
593+
(
594+
"rel_variable_name"
595+
if relation.get("relation_filtering")
596+
else "variable_name"
597+
)
594598
]
595599
if relation["include_in_return"] and not already_present:
596600
self._additional_return(rel_ident)
@@ -838,32 +842,27 @@ def build_query(self) -> str:
838842
query += " WITH "
839843
query += self._ast.with_clause
840844

845+
returned_items: list[str] = []
841846
if hasattr(self.node_set, "_intermediate_transforms"):
842847
for transform in self.node_set._intermediate_transforms:
843848
query += " WITH "
849+
query += "DISTINCT " if transform.get("distinct") else ""
844850
injected_vars: list = []
845851
# Reset return list since we'll probably invalidate most variables
846852
self._ast.return_clause = ""
847853
self._ast.additional_return = []
848-
for name, source in transform["vars"].items():
849-
if type(source) is str:
850-
injected_vars.append(f"{source} AS {name}")
851-
elif isinstance(source, RelationNameResolver):
852-
result = self.lookup_query_variable(
853-
source.relation, return_relation=True
854-
)
855-
if not result:
856-
raise ValueError(
857-
f"Unable to resolve variable name for relation {source.relation}."
858-
)
859-
injected_vars.append(f"{result[0]} AS {name}")
860-
elif isinstance(source, NodeNameResolver):
861-
result = self.lookup_query_variable(source.node)
862-
if not result:
863-
raise ValueError(
864-
f"Unable to resolve variable name for node {source.node}."
865-
)
866-
injected_vars.append(f"{result[0]} AS {name}")
854+
for name, varprops in transform["vars"].items():
855+
source = varprops["source"]
856+
if isinstance(source, (NodeNameResolver, RelationNameResolver)):
857+
transformation = source.resolve(self)
858+
else:
859+
transformation = source
860+
if varprops.get("source_prop"):
861+
transformation += f".{varprops['source_prop']}"
862+
transformation += f" AS {name}"
863+
if varprops.get("include_in_return"):
864+
returned_items += [name]
865+
injected_vars.append(transformation)
867866
query += ",".join(injected_vars)
868867
if not transform["ordering"]:
869868
continue
@@ -879,7 +878,6 @@ def build_query(self) -> str:
879878
ordering.append(item)
880879
query += ",".join(ordering)
881880

882-
returned_items: list[str] = []
883881
if hasattr(self.node_set, "_subqueries"):
884882
for subquery, return_set in self.node_set._subqueries:
885883
outer_primary_var = self._ast.return_clause
@@ -978,7 +976,9 @@ async def _execute(self, lazy: bool = False, dict_output: bool = False):
978976
]
979977
query = self.build_query()
980978
results, prop_names = await adb.cypher_query(
981-
query, self._query_params, resolve_objects=True
979+
query,
980+
self._query_params,
981+
resolve_objects=True,
982982
)
983983
if dict_output:
984984
for item in results:
@@ -1098,6 +1098,14 @@ class RelationNameResolver:
10981098

10991099
relation: str
11001100

1101+
def resolve(self, qbuilder: AsyncQueryBuilder) -> str:
1102+
result = qbuilder.lookup_query_variable(self.relation, True)
1103+
if result is None:
1104+
raise ValueError(
1105+
f"Unable to resolve variable name for relation {self.relation}"
1106+
)
1107+
return result[0]
1108+
11011109

11021110
@dataclass
11031111
class NodeNameResolver:
@@ -1111,6 +1119,12 @@ class NodeNameResolver:
11111119

11121120
node: str
11131121

1122+
def resolve(self, qbuilder: AsyncQueryBuilder) -> str:
1123+
result = qbuilder.lookup_query_variable(self.node)
1124+
if result is None:
1125+
raise ValueError(f"Unable to resolve variable name for node {self.node}")
1126+
return result[0]
1127+
11141128

11151129
@dataclass
11161130
class BaseFunction:
@@ -1123,15 +1137,10 @@ def get_internal_name(self) -> str:
11231137
return self._internal_name
11241138

11251139
def resolve_internal_name(self, qbuilder: AsyncQueryBuilder) -> str:
1126-
if isinstance(self.input_name, NodeNameResolver):
1127-
result = qbuilder.lookup_query_variable(self.input_name.node)
1128-
elif isinstance(self.input_name, RelationNameResolver):
1129-
result = qbuilder.lookup_query_variable(self.input_name.relation, True)
1140+
if isinstance(self.input_name, (NodeNameResolver, RelationNameResolver)):
1141+
self._internal_name = self.input_name.resolve(qbuilder)
11301142
else:
1131-
result = (str(self.input_name), None)
1132-
if result is None:
1133-
raise ValueError(f"Unknown variable {self.input_name} used in Collect()")
1134-
self._internal_name = result[0]
1143+
self._internal_name = str(self.input_name)
11351144
return self._internal_name
11361145

11371146
def render(self, qbuilder: AsyncQueryBuilder) -> str:
@@ -1538,20 +1547,26 @@ async def subquery(
15381547
return self
15391548

15401549
def intermediate_transform(
1541-
self, vars: Dict[str, Any], ordering: TOptional[list] = None
1550+
self,
1551+
vars: Dict[str, Transformation],
1552+
distinct: bool = False,
1553+
ordering: TOptional[list] = None,
15421554
) -> "AsyncNodeSet":
15431555
if not vars:
15441556
raise ValueError(
15451557
"You must provide one variable at least when calling intermediate_transform()"
15461558
)
1547-
for name, source in vars.items():
1559+
for name, props in vars.items():
1560+
source = props["source"]
15481561
if type(source) is not str and not isinstance(
1549-
source, (NodeNameResolver, RelationNameResolver)
1562+
source, (NodeNameResolver, RelationNameResolver, RawCypher)
15501563
):
15511564
raise ValueError(
15521565
f"Wrong source type specified for variable '{name}', should be a string or an instance of NodeNameResolver or RelationNameResolver"
15531566
)
1554-
self._intermediate_transforms.append({"vars": vars, "ordering": ordering})
1567+
self._intermediate_transforms.append(
1568+
{"vars": vars, "distinct": distinct, "ordering": ordering}
1569+
)
15551570
return self
15561571

15571572

0 commit comments

Comments
 (0)