Skip to content

Commit 7139fc2

Browse files
Merge pull request #930 from neo4j-contrib/rc/6.0.1
Rc/6.0.1
2 parents 934f87e + 93fc39e commit 7139fc2

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

69 files changed

+683
-1698
lines changed

.github/workflows/integration-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
strategy:
1616
fail-fast: false
1717
matrix:
18-
python-version: ["3.13", "3.12", "3.11", "3.10"]
18+
python-version: ["3.14", "3.13", "3.12", "3.11", "3.10"]
1919
neo4j-version: ["community", "enterprise", "5.5-enterprise", "4.4-enterprise", "4.4-community"]
2020

2121
steps:

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ coverage_report/
2424
.DS_STORE
2525
cov.xml
2626
test/data/model_diagram.*
27+
.claude/settings.local.json

Changelog

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
Version 6.0.0 2025-xx
1+
Version 6.0.1 2025-12
2+
* Make async iterator fully async, like : async for node in MyNodeClass.nodes
3+
* Add thresholding to semantic indexes. Thanks to @greengori11a
4+
* Adds support for Python 3.14
5+
* Bumps to neo4j driver 6.x
6+
* Remove unnecessary typing .pyi files
7+
8+
Version 6.0.0 2025-12
29
* Modernize config object, using a dataclass with typing, runtime and update validation rules, and environment variables support
310
* Fix async support of parallel transactions, using ContextVar
411
* Introduces merge_by parameter for batch operations to customize merge behaviour (label and property keys)

doc/requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
sphinx_copybutton
2-
neo4j~=5.28.2
2+
neo4j~=6.0.3
33

neomodel/_version.py

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

neomodel/async_/database.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import sys
88
import time
99
from contextvars import ContextVar
10-
from typing import TYPE_CHECKING, Any, Callable, TextIO
10+
from typing import TYPE_CHECKING, Any, AsyncIterator, Callable, TextIO
1111
from urllib.parse import quote, unquote, urlparse
1212

1313
from neo4j import (
@@ -753,6 +753,73 @@ async def _run_cypher_query(
753753

754754
return results, meta
755755

756+
async def _stream_cypher_query(
757+
self,
758+
session: AsyncSession | AsyncTransaction,
759+
query: str,
760+
params: dict[str, Any],
761+
handle_unique: bool,
762+
resolve_objects: bool,
763+
) -> AsyncIterator[tuple[list, tuple[str, ...]]]:
764+
"""
765+
Stream query results one record at a time without loading all into memory.
766+
767+
This is an internal method used for async iteration. It yields results
768+
as they arrive from the database instead of collecting them all first.
769+
770+
:param session: Neo4j session or transaction
771+
:param query: Cypher query string
772+
:param params: Query parameters
773+
:param handle_unique: Whether to raise UniqueProperty on constraint violations
774+
:param resolve_objects: Whether to resolve nodes to neomodel objects
775+
:yields: Tuple of (values_list, keys_tuple) for each record
776+
"""
777+
try:
778+
start = time.time()
779+
if self._parallel_runtime:
780+
query = "CYPHER runtime=parallel " + query
781+
782+
response: AsyncResult = await session.run(query=query, parameters=params)
783+
keys = response.keys()
784+
785+
# Stream results one record at a time
786+
async for record in response:
787+
values = list(record.values())
788+
789+
if resolve_objects:
790+
# Resolve objects for this single record
791+
for idx, value in enumerate(values):
792+
values[idx] = self._object_resolution(value)
793+
794+
yield values, keys
795+
796+
end = time.time()
797+
tte = end - start
798+
if os.environ.get("NEOMODEL_CYPHER_DEBUG", False) and tte > float(
799+
os.environ.get("NEOMODEL_SLOW_QUERIES", 0)
800+
):
801+
logger.debug(
802+
"query: "
803+
+ query
804+
+ "\nparams: "
805+
+ repr(params)
806+
+ f"\ntook: {tte:.2g}s\n"
807+
)
808+
809+
except ClientError as e:
810+
if e.code == "Neo.ClientError.Schema.ConstraintValidationFailed":
811+
if hasattr(e, "message") and e.message is not None:
812+
if "already exists with label" in e.message and handle_unique:
813+
raise UniqueProperty(e.message) from e
814+
raise ConstraintValidationFailed(e.message) from e
815+
raise ConstraintValidationFailed(
816+
"A constraint validation failed"
817+
) from e
818+
819+
exc_info = sys.exc_info()
820+
if exc_info[1] is not None and exc_info[2] is not None:
821+
raise exc_info[1].with_traceback(exc_info[2])
822+
756823
async def get_id_method(self) -> str:
757824
db_version = await self.database_version
758825
if db_version is None:

neomodel/async_/match.py

Lines changed: 95 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from dataclasses import dataclass
55
from typing import Any, AsyncIterator, Optional, Union
66

7+
from neomodel._async_compat.util import AsyncUtil
78
from neomodel.async_ import relationship_manager
89
from neomodel.async_.database import adb
910
from neomodel.async_.node import AsyncStructuredNode
@@ -1218,25 +1219,87 @@ async def _execute(self, lazy: bool = False, dict_output: bool = False) -> Any:
12181219
for item in self._ast.additional_return
12191220
]
12201221
query = self.build_query()
1221-
results, prop_names = await adb.cypher_query(
1222-
query,
1223-
self._query_params,
1224-
resolve_objects=True,
1225-
)
1226-
if dict_output:
1227-
for item in results:
1228-
yield dict(zip(prop_names, item))
1229-
return
1230-
# The following is not as elegant as it could be but had to be copied from the
1231-
# version prior to cypher_query with the resolve_objects capability.
1232-
# It seems that certain calls are only supposed to be focusing to the first
1233-
# result item returned (?)
1234-
if results and len(results[0]) == 1:
1235-
for n in results:
1236-
yield n[0]
1222+
1223+
# Use streaming for async code to avoid loading all results into memory
1224+
if AsyncUtil.is_async_code:
1225+
# Helper to process streaming results
1226+
async def process_stream(stream_iterator):
1227+
first_result = True
1228+
result_has_single_column = False
1229+
async for values, prop_names in stream_iterator:
1230+
if first_result:
1231+
# Determine format on first result
1232+
result_has_single_column = len(values) == 1
1233+
first_result = False
1234+
1235+
if dict_output:
1236+
yield dict(zip(prop_names, values))
1237+
elif result_has_single_column:
1238+
yield values[0]
1239+
else:
1240+
yield values
1241+
1242+
# Stream results one by one from the database
1243+
if adb._active_transaction:
1244+
# Use current transaction if active
1245+
stream = adb._stream_cypher_query(
1246+
adb._active_transaction,
1247+
query,
1248+
self._query_params,
1249+
handle_unique=True,
1250+
resolve_objects=True,
1251+
)
1252+
async for item in process_stream(stream):
1253+
yield item
1254+
else:
1255+
# Create a session for streaming
1256+
# Note: We need to keep the session open during iteration
1257+
async with adb.driver.session(
1258+
database=adb._database_name,
1259+
impersonated_user=adb.impersonated_user,
1260+
) as session:
1261+
stream = adb._stream_cypher_query(
1262+
session,
1263+
query,
1264+
self._query_params,
1265+
handle_unique=True,
1266+
resolve_objects=True,
1267+
)
1268+
async for item in process_stream(stream):
1269+
yield item
12371270
else:
1238-
for result in results:
1239-
yield result
1271+
# Sync code path: use traditional approach (fetch all results)
1272+
results, prop_names = await adb.cypher_query(
1273+
query,
1274+
self._query_params,
1275+
resolve_objects=True,
1276+
)
1277+
if dict_output:
1278+
for item in results:
1279+
yield dict(zip(prop_names, item))
1280+
return
1281+
# The following is not as elegant as it could be but had to be copied from the
1282+
# version prior to cypher_query with the resolve_objects capability.
1283+
# It seems that certain calls are only supposed to be focusing to the first
1284+
# result item returned (?)
1285+
if results and len(results[0]) == 1:
1286+
for n in results:
1287+
yield n[0]
1288+
else:
1289+
for result in results:
1290+
yield result
1291+
1292+
1293+
@dataclass
1294+
class Path:
1295+
"""Path traversal definition."""
1296+
1297+
value: str
1298+
optional: bool = False
1299+
include_nodes_in_return: bool = True
1300+
include_rels_in_return: bool = True
1301+
relation_filtering: bool = False
1302+
alias: str | None = None
12401303

12411304

12421305
class AsyncBaseSet:
@@ -1249,6 +1312,10 @@ class AsyncBaseSet:
12491312
query_cls = AsyncQueryBuilder
12501313
source_class: type[AsyncStructuredNode]
12511314

1315+
# Attributes defined in subclasses (AsyncNodeSet)
1316+
_unique_variables: list[str]
1317+
relations_to_fetch: list[Path]
1318+
12521319
async def all(self, lazy: bool = False) -> list:
12531320
"""
12541321
Return all nodes belonging to the set
@@ -1263,6 +1330,16 @@ async def all(self, lazy: bool = False) -> list:
12631330
return results
12641331

12651332
async def __aiter__(self) -> AsyncIterator:
1333+
"""
1334+
Async iterator that streams results from the database one at a time.
1335+
1336+
This provides true async iteration without loading all results into memory first.
1337+
For large result sets, this is much more memory efficient than using all().
1338+
1339+
Example:
1340+
async for node in Coffee.nodes:
1341+
print(node.name) # Process each node as it arrives
1342+
"""
12661343
ast = await self.query_cls(self).build_ast()
12671344
async for item in ast._execute():
12681345
yield item
@@ -1320,18 +1397,6 @@ async def get_item(self, key: int | slice) -> Optional["AsyncBaseSet"]:
13201397
return _first_item
13211398

13221399

1323-
@dataclass
1324-
class Path:
1325-
"""Path traversal definition."""
1326-
1327-
value: str
1328-
optional: bool = False
1329-
include_nodes_in_return: bool = True
1330-
include_rels_in_return: bool = True
1331-
relation_filtering: bool = False
1332-
alias: str | None = None
1333-
1334-
13351400
@dataclass
13361401
class RelationNameResolver:
13371402
"""Helper to refer to a relation variable name.

neomodel/async_/relationship_manager.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727
# check source node is saved and not deleted
2828
def check_source(fn: Callable) -> Callable:
29-
fn_name = fn.func_name if hasattr(fn, "func_name") else fn.__name__
29+
fn_name = fn.__name__
3030

3131
@functools.wraps(fn)
3232
def checker(self: Any, *args: Any, **kwargs: Any) -> Callable:
@@ -617,6 +617,7 @@ def deflate_relationship_properties(
617617
:return: Dictionary mapping property names to parameter placeholders (e.g. {'since': '$since'})
618618
"""
619619
rel_model = relationship.definition.get("model")
620+
assert rel_model is not None, "Relationship model is required"
620621
tmp = rel_model(**rel_props)
621622

622623
rel_prop = {}

neomodel/async_/transaction.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
"""
44

55
import warnings
6-
from asyncio import iscoroutinefunction
76
from functools import wraps
7+
from inspect import iscoroutinefunction
88
from typing import Any, Callable
99

1010
from neo4j.api import Bookmarks

neomodel/integration/numpy.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@
1616
"""
1717

1818

19+
from typing import Any, Literal
1920
from warnings import warn
2021

2122
try:
2223
# noinspection PyPackageRequirements
2324
from numpy import array as nparray
25+
from numpy import ndarray
2426
except ImportError:
2527
warn(
2628
"The neomodel.integration.numpy module expects numpy to be installed "
@@ -29,7 +31,11 @@
2931
raise
3032

3133

32-
def to_ndarray(query_results: tuple, dtype=None, order="K"):
34+
def to_ndarray(
35+
query_results: tuple[list[list[Any]], list[str]],
36+
dtype: Any | None = None,
37+
order: Literal["K", "A", "C", "F"] = "K",
38+
) -> ndarray:
3339
"""Convert the results of a db.cypher_query call into a numpy array.
3440
Optionally, specify a datatype and/or an order for the columns.
3541
"""

0 commit comments

Comments
 (0)