Skip to content

Commit 8e4b9cf

Browse files
authored
Result.single() throws error if not exactly one record (#646)
Also include minor performance improvement for repeated calls of `Result.peek()`, `.peek()`, and `.graph()`.
1 parent 5b0a5ad commit 8e4b9cf

File tree

10 files changed

+77
-112
lines changed

10 files changed

+77
-112
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@
5252
- Creation of a driver with `bolt[+s[sc]]://` scheme now raises an error if the
5353
URI contains a query part (a routing context). Previously, the routing context
5454
was silently ignored.
55+
- `Result.single` now raises `ResultNotSingleError` if not exactly one result is
56+
available.
5557

5658
## Version 4.4
5759

neo4j/_async/work/result.py

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from ..._async_compat.util import AsyncUtil
2323
from ...data import DataDehydrator
24+
from ...exceptions import ResultNotSingleError
2425
from ...work import ResultSummary
2526
from ..io import ConnectionErrorHandler
2627

@@ -37,6 +38,7 @@ def __init__(self, connection, hydrant, fetch_size, on_closed,
3738
self._hydrant = hydrant
3839
self._on_closed = on_closed
3940
self._metadata = None
41+
self._keys = None
4042
self._record_buffer = deque()
4143
self._summary = None
4244
self._bookmark = None
@@ -179,7 +181,7 @@ def on_success(summary_metadata):
179181
async def __aiter__(self):
180182
"""Iterator returning Records.
181183
:returns: Record, it is an immutable ordered collection of key-value pairs.
182-
:rtype: :class:`neo4j.Record`
184+
:rtype: :class:`neo4j.AsyncRecord`
183185
"""
184186
while self._record_buffer or self._attached:
185187
if self._record_buffer:
@@ -211,6 +213,8 @@ async def _buffer(self, n=None):
211213
Might ent up with fewer records in the buffer if there are not enough
212214
records available.
213215
"""
216+
if n is not None and len(self._record_buffer) >= n:
217+
return
214218
record_buffer = deque()
215219
async for record in self:
216220
record_buffer.append(record)
@@ -304,24 +308,21 @@ async def single(self):
304308
A warning is generated if more than one record is available but
305309
the first of these is still returned.
306310
307-
:returns: the next :class:`neo4j.Record` or :const:`None` if none remain
308-
:warns: if more than one record is available
311+
:returns: the next :class:`neo4j.AsyncRecord`.
312+
:raises: ResultNotSingleError if not exactly one record is available.
309313
"""
310-
# TODO in 5.0 replace with this code that raises an error if there's not
311-
# exactly one record in the left result stream.
312-
# self._buffer(2).
313-
# if len(self._record_buffer) != 1:
314-
# raise SomeError("Expected exactly 1 record, found %i"
315-
# % len(self._record_buffer))
316-
# return self._record_buffer.popleft()
317-
# TODO: exhausts the result with self.consume if there are more records.
318-
records = await AsyncUtil.list(self)
319-
size = len(records)
320-
if size == 0:
321-
return None
322-
if size != 1:
323-
warn("Expected a result with a single record, but this result contains %d" % size)
324-
return records[0]
314+
await self._buffer(2)
315+
if not self._record_buffer:
316+
raise ResultNotSingleError(
317+
"No records found. "
318+
"Make sure your query returns exactly one record."
319+
)
320+
elif len(self._record_buffer) > 1:
321+
raise ResultNotSingleError(
322+
"More than one record found. "
323+
"Make sure your query returns exactly one record."
324+
)
325+
return self._record_buffer.popleft()
325326

326327
async def peek(self):
327328
"""Obtain the next record from this result without consuming it.
@@ -347,7 +348,7 @@ async def graph(self):
347348
async def value(self, key=0, default=None):
348349
"""Helper function that return the remainder of the result as a list of values.
349350
350-
See :class:`neo4j.Record.value`
351+
See :class:`neo4j.AsyncRecord.value`
351352
352353
:param key: field to return for each remaining record. Obtain a single value from the record by index or key.
353354
:param default: default value, used if the index of key is unavailable
@@ -359,7 +360,7 @@ async def value(self, key=0, default=None):
359360
async def values(self, *keys):
360361
"""Helper function that return the remainder of the result as a list of values lists.
361362
362-
See :class:`neo4j.Record.values`
363+
See :class:`neo4j.AsyncRecord.values`
363364
364365
:param keys: fields to return for each remaining record. Optionally filtering to include only certain values by index or key.
365366
:returns: list of values lists
@@ -370,7 +371,7 @@ async def values(self, *keys):
370371
async def data(self, *keys):
371372
"""Helper function that return the remainder of the result as a list of dictionaries.
372373
373-
See :class:`neo4j.Record.data`
374+
See :class:`neo4j.AsyncRecord.data`
374375
375376
:param keys: fields to return for each remaining record. Optionally filtering to include only certain values by index or key.
376377
:returns: list of dictionaries

neo4j/_sync/work/result.py

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from ..._async_compat.util import Util
2323
from ...data import DataDehydrator
24+
from ...exceptions import ResultNotSingleError
2425
from ...work import ResultSummary
2526
from ..io import ConnectionErrorHandler
2627

@@ -37,6 +38,7 @@ def __init__(self, connection, hydrant, fetch_size, on_closed,
3738
self._hydrant = hydrant
3839
self._on_closed = on_closed
3940
self._metadata = None
41+
self._keys = None
4042
self._record_buffer = deque()
4143
self._summary = None
4244
self._bookmark = None
@@ -211,6 +213,8 @@ def _buffer(self, n=None):
211213
Might ent up with fewer records in the buffer if there are not enough
212214
records available.
213215
"""
216+
if n is not None and len(self._record_buffer) >= n:
217+
return
214218
record_buffer = deque()
215219
for record in self:
216220
record_buffer.append(record)
@@ -304,24 +308,21 @@ def single(self):
304308
A warning is generated if more than one record is available but
305309
the first of these is still returned.
306310
307-
:returns: the next :class:`neo4j.Record` or :const:`None` if none remain
308-
:warns: if more than one record is available
311+
:returns: the next :class:`neo4j.Record`.
312+
:raises: ResultNotSingleError if not exactly one record is available.
309313
"""
310-
# TODO in 5.0 replace with this code that raises an error if there's not
311-
# exactly one record in the left result stream.
312-
# self._buffer(2).
313-
# if len(self._record_buffer) != 1:
314-
# raise SomeError("Expected exactly 1 record, found %i"
315-
# % len(self._record_buffer))
316-
# return self._record_buffer.popleft()
317-
# TODO: exhausts the result with self.consume if there are more records.
318-
records = Util.list(self)
319-
size = len(records)
320-
if size == 0:
321-
return None
322-
if size != 1:
323-
warn("Expected a result with a single record, but this result contains %d" % size)
324-
return records[0]
314+
self._buffer(2)
315+
if not self._record_buffer:
316+
raise ResultNotSingleError(
317+
"No records found. "
318+
"Make sure your query returns exactly one record."
319+
)
320+
elif len(self._record_buffer) > 1:
321+
raise ResultNotSingleError(
322+
"More than one record found. "
323+
"Make sure your query returns exactly one record."
324+
)
325+
return self._record_buffer.popleft()
325326

326327
def peek(self):
327328
"""Obtain the next record from this result without consuming it.

neo4j/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,10 @@ class ResultConsumedError(DriverError):
328328
"""
329329

330330

331+
class ResultNotSingleError(DriverError):
332+
"""Raised when result.single() detects not exactly one record in result."""
333+
334+
331335
class ConfigurationError(DriverError):
332336
""" Raised when there is an error concerning a configuration.
333337
"""

testkitbackend/_async/requests.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,9 @@ async def ResultNext(backend, data):
366366

367367
async def ResultSingle(backend, data):
368368
result = backend.results[data["resultId"]]
369-
await backend.send_response("Record", totestkit.record(result.single()))
369+
await backend.send_response("Record", totestkit.record(
370+
await result.single()
371+
))
370372

371373

372374
async def ResultPeek(backend, data):

testkitbackend/_sync/requests.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,9 @@ def ResultNext(backend, data):
366366

367367
def ResultSingle(backend, data):
368368
result = backend.results[data["resultId"]]
369-
backend.send_response("Record", totestkit.record(result.single()))
369+
backend.send_response("Record", totestkit.record(
370+
result.single()
371+
))
370372

371373

372374
def ResultPeek(backend, data):

testkitbackend/test_config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"Feature:API:Liveness.Check": false,
3030
"Feature:API:Result.List": true,
3131
"Feature:API:Result.Peek": true,
32-
"Feature:API:Result.Single": "Does not raise error when not exactly one record is available. To be fixed in 5.0.",
32+
"Feature:API:Result.Single": true,
3333
"Feature:API:SSLConfig": true,
3434
"Feature:API:SSLSchemes": true,
3535
"Feature:Auth:Bearer": true,

tests/integration/test_result.py

Lines changed: 0 additions & 55 deletions
This file was deleted.

tests/unit/async_/work/test_result.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
)
3232
from neo4j._async_compat.util import AsyncUtil
3333
from neo4j.data import DataHydrator
34+
from neo4j.exceptions import ResultNotSingleError
3435

3536
from ...._async_compat import mark_async_test
3637

@@ -323,16 +324,19 @@ async def test_result_single(records, fetch_size):
323324
connection = AsyncConnectionStub(records=Records(["x"], records))
324325
result = AsyncResult(connection, HydratorStub(), fetch_size, noop, noop)
325326
await result._run("CYPHER", {}, None, None, "r", None)
326-
with pytest.warns(None) as warning_record:
327+
try:
327328
record = await result.single()
328-
if not records:
329-
assert not warning_record
330-
assert record is None
329+
except ResultNotSingleError as exc:
330+
assert len(records) != 1
331+
if len(records) == 0:
332+
assert exc is not None
333+
assert "no records" in str(exc).lower()
334+
elif len(records) > 1:
335+
assert exc is not None
336+
assert "more than one record" in str(exc).lower()
337+
331338
else:
332-
if len(records) > 1:
333-
assert len(warning_record) == 1
334-
else:
335-
assert not warning_record
339+
assert len(records) == 1
336340
assert isinstance(record, Record)
337341
assert record.get("x") == records[0][0]
338342

tests/unit/sync/work/test_result.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
)
3232
from neo4j._async_compat.util import Util
3333
from neo4j.data import DataHydrator
34+
from neo4j.exceptions import ResultNotSingleError
3435

3536
from ...._async_compat import mark_sync_test
3637

@@ -323,16 +324,19 @@ def test_result_single(records, fetch_size):
323324
connection = ConnectionStub(records=Records(["x"], records))
324325
result = Result(connection, HydratorStub(), fetch_size, noop, noop)
325326
result._run("CYPHER", {}, None, None, "r", None)
326-
with pytest.warns(None) as warning_record:
327+
try:
327328
record = result.single()
328-
if not records:
329-
assert not warning_record
330-
assert record is None
329+
except ResultNotSingleError as exc:
330+
assert len(records) != 1
331+
if len(records) == 0:
332+
assert exc is not None
333+
assert "no records" in str(exc).lower()
334+
elif len(records) > 1:
335+
assert exc is not None
336+
assert "more than one record" in str(exc).lower()
337+
331338
else:
332-
if len(records) > 1:
333-
assert len(warning_record) == 1
334-
else:
335-
assert not warning_record
339+
assert len(records) == 1
336340
assert isinstance(record, Record)
337341
assert record.get("x") == records[0][0]
338342

0 commit comments

Comments
 (0)