diff --git a/CHANGELOG.md b/CHANGELOG.md index d58f9dfba..2d7dd37b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,8 @@ - Creation of a driver with `bolt[+s[sc]]://` scheme now raises an error if the URI contains a query part (a routing context). Previously, the routing context was silently ignored. +- `Result.single` now raises `ResultNotSingleError` if not exactly one result is + available. ## Version 4.4 diff --git a/neo4j/_async/work/result.py b/neo4j/_async/work/result.py index 6184d9634..dc3b31a4e 100644 --- a/neo4j/_async/work/result.py +++ b/neo4j/_async/work/result.py @@ -21,6 +21,7 @@ from ..._async_compat.util import AsyncUtil from ...data import DataDehydrator +from ...exceptions import ResultNotSingleError from ...work import ResultSummary from ..io import ConnectionErrorHandler @@ -37,6 +38,7 @@ def __init__(self, connection, hydrant, fetch_size, on_closed, self._hydrant = hydrant self._on_closed = on_closed self._metadata = None + self._keys = None self._record_buffer = deque() self._summary = None self._bookmark = None @@ -179,7 +181,7 @@ def on_success(summary_metadata): async def __aiter__(self): """Iterator returning Records. :returns: Record, it is an immutable ordered collection of key-value pairs. - :rtype: :class:`neo4j.Record` + :rtype: :class:`neo4j.AsyncRecord` """ while self._record_buffer or self._attached: if self._record_buffer: @@ -211,6 +213,8 @@ async def _buffer(self, n=None): Might ent up with fewer records in the buffer if there are not enough records available. """ + if n is not None and len(self._record_buffer) >= n: + return record_buffer = deque() async for record in self: record_buffer.append(record) @@ -304,24 +308,21 @@ async def single(self): A warning is generated if more than one record is available but the first of these is still returned. - :returns: the next :class:`neo4j.Record` or :const:`None` if none remain - :warns: if more than one record is available + :returns: the next :class:`neo4j.AsyncRecord`. + :raises: ResultNotSingleError if not exactly one record is available. """ - # TODO in 5.0 replace with this code that raises an error if there's not - # exactly one record in the left result stream. - # self._buffer(2). - # if len(self._record_buffer) != 1: - # raise SomeError("Expected exactly 1 record, found %i" - # % len(self._record_buffer)) - # return self._record_buffer.popleft() - # TODO: exhausts the result with self.consume if there are more records. - records = await AsyncUtil.list(self) - size = len(records) - if size == 0: - return None - if size != 1: - warn("Expected a result with a single record, but this result contains %d" % size) - return records[0] + await self._buffer(2) + if not self._record_buffer: + raise ResultNotSingleError( + "No records found. " + "Make sure your query returns exactly one record." + ) + elif len(self._record_buffer) > 1: + raise ResultNotSingleError( + "More than one record found. " + "Make sure your query returns exactly one record." + ) + return self._record_buffer.popleft() async def peek(self): """Obtain the next record from this result without consuming it. @@ -347,7 +348,7 @@ async def graph(self): async def value(self, key=0, default=None): """Helper function that return the remainder of the result as a list of values. - See :class:`neo4j.Record.value` + See :class:`neo4j.AsyncRecord.value` :param key: field to return for each remaining record. Obtain a single value from the record by index or key. :param default: default value, used if the index of key is unavailable @@ -359,7 +360,7 @@ async def value(self, key=0, default=None): async def values(self, *keys): """Helper function that return the remainder of the result as a list of values lists. - See :class:`neo4j.Record.values` + See :class:`neo4j.AsyncRecord.values` :param keys: fields to return for each remaining record. Optionally filtering to include only certain values by index or key. :returns: list of values lists @@ -370,7 +371,7 @@ async def values(self, *keys): async def data(self, *keys): """Helper function that return the remainder of the result as a list of dictionaries. - See :class:`neo4j.Record.data` + See :class:`neo4j.AsyncRecord.data` :param keys: fields to return for each remaining record. Optionally filtering to include only certain values by index or key. :returns: list of dictionaries diff --git a/neo4j/_sync/work/result.py b/neo4j/_sync/work/result.py index 8d6342fdc..2f9cddcaa 100644 --- a/neo4j/_sync/work/result.py +++ b/neo4j/_sync/work/result.py @@ -21,6 +21,7 @@ from ..._async_compat.util import Util from ...data import DataDehydrator +from ...exceptions import ResultNotSingleError from ...work import ResultSummary from ..io import ConnectionErrorHandler @@ -37,6 +38,7 @@ def __init__(self, connection, hydrant, fetch_size, on_closed, self._hydrant = hydrant self._on_closed = on_closed self._metadata = None + self._keys = None self._record_buffer = deque() self._summary = None self._bookmark = None @@ -211,6 +213,8 @@ def _buffer(self, n=None): Might ent up with fewer records in the buffer if there are not enough records available. """ + if n is not None and len(self._record_buffer) >= n: + return record_buffer = deque() for record in self: record_buffer.append(record) @@ -304,24 +308,21 @@ def single(self): A warning is generated if more than one record is available but the first of these is still returned. - :returns: the next :class:`neo4j.Record` or :const:`None` if none remain - :warns: if more than one record is available + :returns: the next :class:`neo4j.Record`. + :raises: ResultNotSingleError if not exactly one record is available. """ - # TODO in 5.0 replace with this code that raises an error if there's not - # exactly one record in the left result stream. - # self._buffer(2). - # if len(self._record_buffer) != 1: - # raise SomeError("Expected exactly 1 record, found %i" - # % len(self._record_buffer)) - # return self._record_buffer.popleft() - # TODO: exhausts the result with self.consume if there are more records. - records = Util.list(self) - size = len(records) - if size == 0: - return None - if size != 1: - warn("Expected a result with a single record, but this result contains %d" % size) - return records[0] + self._buffer(2) + if not self._record_buffer: + raise ResultNotSingleError( + "No records found. " + "Make sure your query returns exactly one record." + ) + elif len(self._record_buffer) > 1: + raise ResultNotSingleError( + "More than one record found. " + "Make sure your query returns exactly one record." + ) + return self._record_buffer.popleft() def peek(self): """Obtain the next record from this result without consuming it. diff --git a/neo4j/exceptions.py b/neo4j/exceptions.py index 2b944bcfd..ee481c60a 100644 --- a/neo4j/exceptions.py +++ b/neo4j/exceptions.py @@ -328,6 +328,10 @@ class ResultConsumedError(DriverError): """ +class ResultNotSingleError(DriverError): + """Raised when result.single() detects not exactly one record in result.""" + + class ConfigurationError(DriverError): """ Raised when there is an error concerning a configuration. """ diff --git a/testkitbackend/test_config.json b/testkitbackend/test_config.json index d3c9ff02b..4fcfd43a4 100644 --- a/testkitbackend/test_config.json +++ b/testkitbackend/test_config.json @@ -29,7 +29,7 @@ "Feature:API:Liveness.Check": false, "Feature:API:Result.List": true, "Feature:API:Result.Peek": true, - "Feature:API:Result.Single": "Does not raise error when not exactly one record is available. To be fixed in 5.0.", + "Feature:API:Result.Single": true, "Feature:API:SSLConfig": true, "Feature:API:SSLSchemes": true, "Feature:Auth:Bearer": true, diff --git a/tests/integration/test_result.py b/tests/integration/test_result.py deleted file mode 100644 index b4af833ad..000000000 --- a/tests/integration/test_result.py +++ /dev/null @@ -1,55 +0,0 @@ -# Copyright (c) "Neo4j" -# Neo4j Sweden AB [http://neo4j.com] -# -# This file is part of Neo4j. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -import pytest - - -# TODO: this test will stay until a uniform behavior for `.single()` across the -# drivers has been specified and tests are created in testkit -def test_result_single_with_no_records(session): - result = session.run("CREATE ()") - record = result.single() - assert record is None - - -# TODO: this test will stay until a uniform behavior for `.single()` across the -# drivers has been specified and tests are created in testkit -def test_result_single_with_one_record(session): - result = session.run("UNWIND [1] AS n RETURN n") - record = result.single() - assert record["n"] == 1 - - -# TODO: this test will stay until a uniform behavior for `.single()` across the -# drivers has been specified and tests are created in testkit -def test_result_single_with_multiple_records(session): - import warnings - result = session.run("UNWIND [1, 2, 3] AS n RETURN n") - with pytest.warns(UserWarning, match="Expected a result with a single record"): - record = result.single() - assert record[0] == 1 - - -# TODO: this test will stay until a uniform behavior for `.single()` across the -# drivers has been specified and tests are created in testkit -def test_result_single_consumes_the_result(session): - result = session.run("UNWIND [1, 2, 3] AS n RETURN n") - with pytest.warns(UserWarning, match="Expected a result with a single record"): - _ = result.single() - records = list(result) - assert records == []