Skip to content

Commit 09f3390

Browse files
committed
Check Result scope and raise RuntimeError if appropriate
Results are tied to transactions (except auto-commit transactions). When the transaction ends, the result becomes useless. Raising instead of silently ignoring this fact will help developers to find potential bugs faster. After consuming a Result, there can never be records left. There is meaning in trying to obtain them. Hence, we raise a RuntimeError to make the user aware of potentially wrong code.
1 parent 4a01b98 commit 09f3390

File tree

7 files changed

+170
-7
lines changed

7 files changed

+170
-7
lines changed

CHANGELOG.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
- Python 3.10 support added
66
- Python 3.6 support has been dropped.
77
- `Result`, `Session`, and `Transaction` can no longer be imported from
8-
`neo4j.work`. They should've been imported from `neo4j` all along.
8+
`neo4j.work`. They should've been imported from `neo4j` all along.
9+
Remark: It's recommended to import everything needed directly from `noe4j`,
10+
not its submodules or subpackages.
911
- Experimental pipelines feature has been removed.
1012
- Experimental async driver has been added.
1113
- `ResultSummary.server.version_info` has been removed.
@@ -54,6 +56,17 @@
5456
was silently ignored.
5557
- `Result.single` now raises `ResultNotSingleError` if not exactly one result is
5658
available.
59+
- Result scope:
60+
- Records of Results cannot be accessed (`peek`, `single`, `iter`, ...)
61+
after their owning transaction has been closed, committed, or rolled back.
62+
Previously, this would yield undefined behavior.
63+
It now raises a `RuntimeError`.
64+
- Records of Results cannot be accessed (`peek`, `single`, `iter`, ...)
65+
after the Result has been consumed (`Result.consume()`).
66+
Previously, this would always yield no records.
67+
It now raises a `RuntimeError`.
68+
- New method `Result.closed()` can be used to check for this condition if
69+
necessary.
5770

5871
## Version 4.4
5972

docs/source/api.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,8 @@ A :class:`neo4j.Result` is attached to an active connection, through a :class:`n
765765

766766
.. automethod:: data
767767

768+
.. automethod:: closed
769+
768770
See https://neo4j.com/docs/driver-manual/current/cypher-workflow/#driver-type-mapping for more about type mapping.
769771

770772

docs/source/async_api.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,4 +499,6 @@ A :class:`neo4j.AsyncResult` is attached to an active connection, through a :cla
499499

500500
.. automethod:: data
501501

502+
.. automethod:: closed
503+
502504
See https://neo4j.com/docs/driver-manual/current/cypher-workflow/#driver-type-mapping for more about type mapping.

neo4j/_async/work/result.py

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@
2626
from ..io import ConnectionErrorHandler
2727

2828

29+
_RESULT_OUT_OF_SCOPE_ERROR = (
30+
"The result is out of scope. The associated transaction "
31+
"has been closed. Results can only be used while the "
32+
"transaction is open."
33+
)
34+
_RESULT_CONSUMED_ERROR = (
35+
"The result has been consumed. Fetch all needed records before calling "
36+
"Result.consume()."
37+
)
38+
39+
2940
class AsyncResult:
3041
"""A handler for the result of Cypher query execution. Instances
3142
of this class are typically constructed and returned by
@@ -54,6 +65,10 @@ def __init__(self, connection, hydrant, fetch_size, on_closed,
5465
self._has_more = False
5566
# the result has been fully iterated or consumed
5667
self._closed = False
68+
# the result has been consumed
69+
self._consumed = False
70+
# the result has been closed as a result of closing the transaction
71+
self._out_of_scope = False
5772

5873
@property
5974
def _qid(self):
@@ -196,6 +211,10 @@ async def __aiter__(self):
196211
await self._connection.send_all()
197212

198213
self._closed = True
214+
if self._out_of_scope:
215+
raise RuntimeError(_RESULT_OUT_OF_SCOPE_ERROR)
216+
if self._consumed:
217+
raise RuntimeError(_RESULT_CONSUMED_ERROR)
199218

200219
async def __anext__(self):
201220
return await self.__aiter__().__anext__()
@@ -216,6 +235,10 @@ async def _buffer(self, n=None):
216235
Might ent up with fewer records in the buffer if there are not enough
217236
records available.
218237
"""
238+
if self._out_of_scope:
239+
raise RuntimeError(_RESULT_OUT_OF_SCOPE_ERROR)
240+
if self._consumed:
241+
raise RuntimeError(_RESULT_CONSUMED_ERROR)
219242
if n is not None and len(self._record_buffer) >= n:
220243
return
221244
record_buffer = deque()
@@ -261,6 +284,14 @@ def keys(self):
261284
"""
262285
return self._keys
263286

287+
async def _tx_end(self):
288+
# Handle closure of the associated transaction.
289+
#
290+
# This will consume the result and mark it at out of scope.
291+
# Subsequent calls to `next` will raise a RuntimeError.
292+
await self.consume()
293+
self._out_of_scope = True
294+
264295
async def consume(self):
265296
"""Consume the remainder of this result and return a :class:`neo4j.ResultSummary`.
266297
@@ -302,7 +333,9 @@ async def get_two_tx(tx):
302333
async for _ in self:
303334
pass
304335

305-
return self._obtain_summary()
336+
summary = self._obtain_summary()
337+
self._consumed = True
338+
return summary
306339

307340
async def single(self):
308341
"""Obtain the next and only remaining record from this result if available else return None.
@@ -312,7 +345,10 @@ async def single(self):
312345
the first of these is still returned.
313346
314347
:returns: the next :class:`neo4j.AsyncRecord`.
315-
:raises: ResultNotSingleError if not exactly one record is available.
348+
349+
:raises ResultNotSingleError: if not exactly one record is available.
350+
:raises RuntimeError: if the transaction from which this result was
351+
obtained has been closed.
316352
"""
317353
await self._buffer(2)
318354
if not self._record_buffer:
@@ -332,6 +368,10 @@ async def peek(self):
332368
This leaves the record in the buffer for further processing.
333369
334370
:returns: the next :class:`.Record` or :const:`None` if none remain
371+
372+
:raises RuntimeError: if the transaction from which this result was
373+
obtained has been closed or the Result has been explicitly
374+
consumed.
335375
"""
336376
await self._buffer(1)
337377
if self._record_buffer:
@@ -344,6 +384,10 @@ async def graph(self):
344384
345385
:returns: a result graph
346386
:rtype: :class:`neo4j.graph.Graph`
387+
388+
:raises RuntimeError: if the transaction from which this result was
389+
obtained has been closed or the Result has been explicitly
390+
consumed.
347391
"""
348392
await self._buffer_all()
349393
return self._hydrant.graph
@@ -355,8 +399,13 @@ async def value(self, key=0, default=None):
355399
356400
:param key: field to return for each remaining record. Obtain a single value from the record by index or key.
357401
:param default: default value, used if the index of key is unavailable
402+
358403
:returns: list of individual values
359404
:rtype: list
405+
406+
:raises RuntimeError: if the transaction from which this result was
407+
obtained has been closed or the Result has been explicitly
408+
consumed.
360409
"""
361410
return [record.value(key, default) async for record in self]
362411

@@ -366,8 +415,13 @@ async def values(self, *keys):
366415
See :class:`neo4j.AsyncRecord.values`
367416
368417
:param keys: fields to return for each remaining record. Optionally filtering to include only certain values by index or key.
418+
369419
:returns: list of values lists
370420
:rtype: list
421+
422+
:raises RuntimeError: if the transaction from which this result was
423+
obtained has been closed or the Result has been explicitly
424+
consumed.
371425
"""
372426
return [record.values(*keys) async for record in self]
373427

@@ -377,7 +431,26 @@ async def data(self, *keys):
377431
See :class:`neo4j.AsyncRecord.data`
378432
379433
:param keys: fields to return for each remaining record. Optionally filtering to include only certain values by index or key.
434+
380435
:returns: list of dictionaries
381436
:rtype: list
437+
438+
:raises RuntimeError: if the transaction from which this result was
439+
obtained has been closed.
382440
"""
383441
return [record.data(*keys) async for record in self]
442+
443+
def closed(self):
444+
"""Return True if the result is still valid (not closed).
445+
446+
When a result gets consumed :meth:`consume` or the transaction that
447+
owns the result gets closed (committed, rolled back, closed), the
448+
result cannot be used to acquire further records.
449+
450+
In such case, all methods that need to access the Result's records,
451+
will raise a :exc:`RuntimeError` when called.
452+
453+
:returns: whether the result is closed.
454+
:rtype: bool
455+
"""
456+
return self._out_of_scope or self._consumed

neo4j/_async/work/transaction.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ async def _error_handler(self, exc):
7878

7979
async def _consume_results(self):
8080
for result in self._results:
81-
await result.consume()
81+
await result._tx_end()
8282
self._results = []
8383

8484
async def run(self, query, parameters=None, **kwparameters):

neo4j/_sync/work/result.py

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@
2626
from ..io import ConnectionErrorHandler
2727

2828

29+
_RESULT_OUT_OF_SCOPE_ERROR = (
30+
"The result is out of scope. The associated transaction "
31+
"has been closed. Results can only be used while the "
32+
"transaction is open."
33+
)
34+
_RESULT_CONSUMED_ERROR = (
35+
"The result has been consumed. Fetch all needed records before calling "
36+
"Result.consume()."
37+
)
38+
39+
2940
class Result:
3041
"""A handler for the result of Cypher query execution. Instances
3142
of this class are typically constructed and returned by
@@ -54,6 +65,10 @@ def __init__(self, connection, hydrant, fetch_size, on_closed,
5465
self._has_more = False
5566
# the result has been fully iterated or consumed
5667
self._closed = False
68+
# the result has been consumed
69+
self._consumed = False
70+
# the result has been closed as a result of closing the transaction
71+
self._out_of_scope = False
5772

5873
@property
5974
def _qid(self):
@@ -196,6 +211,10 @@ def __iter__(self):
196211
self._connection.send_all()
197212

198213
self._closed = True
214+
if self._out_of_scope:
215+
raise RuntimeError(_RESULT_OUT_OF_SCOPE_ERROR)
216+
if self._consumed:
217+
raise RuntimeError(_RESULT_CONSUMED_ERROR)
199218

200219
def __next__(self):
201220
return self.__iter__().__next__()
@@ -216,6 +235,10 @@ def _buffer(self, n=None):
216235
Might ent up with fewer records in the buffer if there are not enough
217236
records available.
218237
"""
238+
if self._out_of_scope:
239+
raise RuntimeError(_RESULT_OUT_OF_SCOPE_ERROR)
240+
if self._consumed:
241+
raise RuntimeError(_RESULT_CONSUMED_ERROR)
219242
if n is not None and len(self._record_buffer) >= n:
220243
return
221244
record_buffer = deque()
@@ -261,6 +284,14 @@ def keys(self):
261284
"""
262285
return self._keys
263286

287+
def _tx_end(self):
288+
# Handle closure of the associated transaction.
289+
#
290+
# This will consume the result and mark it at out of scope.
291+
# Subsequent calls to `next` will raise a RuntimeError.
292+
self.consume()
293+
self._out_of_scope = True
294+
264295
def consume(self):
265296
"""Consume the remainder of this result and return a :class:`neo4j.ResultSummary`.
266297
@@ -302,7 +333,9 @@ def get_two_tx(tx):
302333
for _ in self:
303334
pass
304335

305-
return self._obtain_summary()
336+
summary = self._obtain_summary()
337+
self._consumed = True
338+
return summary
306339

307340
def single(self):
308341
"""Obtain the next and only remaining record from this result if available else return None.
@@ -312,7 +345,10 @@ def single(self):
312345
the first of these is still returned.
313346
314347
:returns: the next :class:`neo4j.Record`.
315-
:raises: ResultNotSingleError if not exactly one record is available.
348+
349+
:raises ResultNotSingleError: if not exactly one record is available.
350+
:raises RuntimeError: if the transaction from which this result was
351+
obtained has been closed.
316352
"""
317353
self._buffer(2)
318354
if not self._record_buffer:
@@ -332,6 +368,10 @@ def peek(self):
332368
This leaves the record in the buffer for further processing.
333369
334370
:returns: the next :class:`.Record` or :const:`None` if none remain
371+
372+
:raises RuntimeError: if the transaction from which this result was
373+
obtained has been closed or the Result has been explicitly
374+
consumed.
335375
"""
336376
self._buffer(1)
337377
if self._record_buffer:
@@ -344,6 +384,10 @@ def graph(self):
344384
345385
:returns: a result graph
346386
:rtype: :class:`neo4j.graph.Graph`
387+
388+
:raises RuntimeError: if the transaction from which this result was
389+
obtained has been closed or the Result has been explicitly
390+
consumed.
347391
"""
348392
self._buffer_all()
349393
return self._hydrant.graph
@@ -355,8 +399,13 @@ def value(self, key=0, default=None):
355399
356400
:param key: field to return for each remaining record. Obtain a single value from the record by index or key.
357401
:param default: default value, used if the index of key is unavailable
402+
358403
:returns: list of individual values
359404
:rtype: list
405+
406+
:raises RuntimeError: if the transaction from which this result was
407+
obtained has been closed or the Result has been explicitly
408+
consumed.
360409
"""
361410
return [record.value(key, default) for record in self]
362411

@@ -366,8 +415,13 @@ def values(self, *keys):
366415
See :class:`neo4j.Record.values`
367416
368417
:param keys: fields to return for each remaining record. Optionally filtering to include only certain values by index or key.
418+
369419
:returns: list of values lists
370420
:rtype: list
421+
422+
:raises RuntimeError: if the transaction from which this result was
423+
obtained has been closed or the Result has been explicitly
424+
consumed.
371425
"""
372426
return [record.values(*keys) for record in self]
373427

@@ -377,7 +431,26 @@ def data(self, *keys):
377431
See :class:`neo4j.Record.data`
378432
379433
:param keys: fields to return for each remaining record. Optionally filtering to include only certain values by index or key.
434+
380435
:returns: list of dictionaries
381436
:rtype: list
437+
438+
:raises RuntimeError: if the transaction from which this result was
439+
obtained has been closed.
382440
"""
383441
return [record.data(*keys) for record in self]
442+
443+
def closed(self):
444+
"""Return True if the result is still valid (not closed).
445+
446+
When a result gets consumed :meth:`consume` or the transaction that
447+
owns the result gets closed (committed, rolled back, closed), the
448+
result cannot be used to acquire further records.
449+
450+
In such case, all methods that need to access the Result's records,
451+
will raise a :exc:`RuntimeError` when called.
452+
453+
:returns: whether the result is closed.
454+
:rtype: bool
455+
"""
456+
return self._out_of_scope or self._consumed

neo4j/_sync/work/transaction.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def _error_handler(self, exc):
7878

7979
def _consume_results(self):
8080
for result in self._results:
81-
result.consume()
81+
result._tx_end()
8282
self._results = []
8383

8484
def run(self, query, parameters=None, **kwparameters):

0 commit comments

Comments
 (0)