Skip to content

Commit 4478986

Browse files
committed
Check Result scope and raise ResultError 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 ResultError to make the user aware of potentially wrong code.
1 parent 342a9f5 commit 4478986

File tree

8 files changed

+186
-11
lines changed

8 files changed

+186
-11
lines changed

CHANGELOG.md

+14-1
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.
@@ -65,6 +67,17 @@
6567
destructor will ever be called. A `ResourceWarning` is emitted instead.
6668
Make sure to configure Python to output those warnings when developing your
6769
application locally (it does not by default).
70+
- Result scope:
71+
- Records of Results cannot be accessed (`peek`, `single`, `iter`, ...)
72+
after their owning transaction has been closed, committed, or rolled back.
73+
Previously, this would yield undefined behavior.
74+
It now raises a `ResultError`.
75+
- Records of Results cannot be accessed (`peek`, `single`, `iter`, ...)
76+
after the Result has been consumed (`Result.consume()`).
77+
Previously, this would always yield no records.
78+
It now raises a `ResultError`.
79+
- New method `Result.closed()` can be used to check for this condition if
80+
necessary.
6881

6982

7083
## Version 4.4

docs/source/api.rst

+2
Original file line numberDiff line numberDiff line change
@@ -774,6 +774,8 @@ A :class:`neo4j.Result` is attached to an active connection, through a :class:`n
774774

775775
.. automethod:: data
776776

777+
.. automethod:: closed
778+
777779
See https://neo4j.com/docs/driver-manual/current/cypher-workflow/#driver-type-mapping for more about type mapping.
778780

779781

docs/source/async_api.rst

+2
Original file line numberDiff line numberDiff line change
@@ -501,4 +501,6 @@ A :class:`neo4j.AsyncResult` is attached to an active connection, through a :cla
501501

502502
.. automethod:: data
503503

504+
.. automethod:: closed
505+
504506
See https://neo4j.com/docs/driver-manual/current/cypher-workflow/#driver-type-mapping for more about type mapping.

neo4j/_async/work/result.py

+79-4
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,28 @@
1717

1818

1919
from collections import deque
20-
from warnings import warn
2120

2221
from ..._async_compat.util import AsyncUtil
2322
from ...data import DataDehydrator
24-
from ...exceptions import ResultNotSingleError
23+
from ...exceptions import (
24+
ResultError,
25+
ResultNotSingleError,
26+
)
2527
from ...work import ResultSummary
2628
from ..io import ConnectionErrorHandler
2729

2830

31+
_RESULT_OUT_OF_SCOPE_ERROR = (
32+
"The result is out of scope. The associated transaction "
33+
"has been closed. Results can only be used while the "
34+
"transaction is open."
35+
)
36+
_RESULT_CONSUMED_ERROR = (
37+
"The result has been consumed. Fetch all needed records before calling "
38+
"Result.consume()."
39+
)
40+
41+
2942
class AsyncResult:
3043
"""A handler for the result of Cypher query execution. Instances
3144
of this class are typically constructed and returned by
@@ -54,6 +67,10 @@ def __init__(self, connection, hydrant, fetch_size, on_closed,
5467
self._has_more = False
5568
# the result has been fully iterated or consumed
5669
self._closed = False
70+
# the result has been consumed
71+
self._consumed = False
72+
# the result has been closed as a result of closing the transaction
73+
self._out_of_scope = False
5774

5875
@property
5976
def _qid(self):
@@ -196,6 +213,10 @@ async def __aiter__(self):
196213
await self._connection.send_all()
197214

198215
self._closed = True
216+
if self._out_of_scope:
217+
raise ResultError(self, _RESULT_OUT_OF_SCOPE_ERROR)
218+
if self._consumed:
219+
raise ResultError(self, _RESULT_CONSUMED_ERROR)
199220

200221
async def __anext__(self):
201222
return await self.__aiter__().__anext__()
@@ -216,6 +237,10 @@ async def _buffer(self, n=None):
216237
Might ent up with fewer records in the buffer if there are not enough
217238
records available.
218239
"""
240+
if self._out_of_scope:
241+
raise ResultError(self, _RESULT_OUT_OF_SCOPE_ERROR)
242+
if self._consumed:
243+
raise ResultError(self, _RESULT_CONSUMED_ERROR)
219244
if n is not None and len(self._record_buffer) >= n:
220245
return
221246
record_buffer = deque()
@@ -261,6 +286,14 @@ def keys(self):
261286
"""
262287
return self._keys
263288

289+
async def _tx_end(self):
290+
# Handle closure of the associated transaction.
291+
#
292+
# This will consume the result and mark it at out of scope.
293+
# Subsequent calls to `next` will raise a ResultError.
294+
await self.consume()
295+
self._out_of_scope = True
296+
264297
async def consume(self):
265298
"""Consume the remainder of this result and return a :class:`neo4j.ResultSummary`.
266299
@@ -302,7 +335,9 @@ async def get_two_tx(tx):
302335
async for _ in self:
303336
pass
304337

305-
return self._obtain_summary()
338+
summary = self._obtain_summary()
339+
self._consumed = True
340+
return summary
306341

307342
async def single(self):
308343
"""Obtain the next and only remaining record from this result if available else return None.
@@ -312,7 +347,10 @@ async def single(self):
312347
the first of these is still returned.
313348
314349
:returns: the next :class:`neo4j.AsyncRecord`.
315-
:raises: ResultNotSingleError if not exactly one record is available.
350+
351+
:raises ResultNotSingleError: if not exactly one record is available.
352+
:raises ResultError: if the transaction from which this result was
353+
obtained has been closed.
316354
"""
317355
await self._buffer(2)
318356
if not self._record_buffer:
@@ -332,6 +370,10 @@ async def peek(self):
332370
This leaves the record in the buffer for further processing.
333371
334372
:returns: the next :class:`.Record` or :const:`None` if none remain
373+
374+
:raises ResultError: if the transaction from which this result was
375+
obtained has been closed or the Result has been explicitly
376+
consumed.
335377
"""
336378
await self._buffer(1)
337379
if self._record_buffer:
@@ -344,6 +386,10 @@ async def graph(self):
344386
345387
:returns: a result graph
346388
:rtype: :class:`neo4j.graph.Graph`
389+
390+
:raises ResultError: if the transaction from which this result was
391+
obtained has been closed or the Result has been explicitly
392+
consumed.
347393
"""
348394
await self._buffer_all()
349395
return self._hydrant.graph
@@ -355,8 +401,13 @@ async def value(self, key=0, default=None):
355401
356402
:param key: field to return for each remaining record. Obtain a single value from the record by index or key.
357403
:param default: default value, used if the index of key is unavailable
404+
358405
:returns: list of individual values
359406
:rtype: list
407+
408+
:raises ResultError: if the transaction from which this result was
409+
obtained has been closed or the Result has been explicitly
410+
consumed.
360411
"""
361412
return [record.value(key, default) async for record in self]
362413

@@ -366,8 +417,13 @@ async def values(self, *keys):
366417
See :class:`neo4j.AsyncRecord.values`
367418
368419
:param keys: fields to return for each remaining record. Optionally filtering to include only certain values by index or key.
420+
369421
:returns: list of values lists
370422
:rtype: list
423+
424+
:raises ResultError: if the transaction from which this result was
425+
obtained has been closed or the Result has been explicitly
426+
consumed.
371427
"""
372428
return [record.values(*keys) async for record in self]
373429

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

neo4j/_async/work/transaction.py

+1-1
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):

0 commit comments

Comments
 (0)