diff --git a/docs/source/api.rst b/docs/source/api.rst index cd8bc421c..76b354e8f 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -1323,6 +1323,8 @@ Client-side errors * :class:`neo4j.exceptions.ResultNotSingleError` + * :class:`neo4j.exceptions.BrokenRecordError` + * :class:`neo4j.exceptions.SessionExpired` * :class:`neo4j.exceptions.ServiceUnavailable` @@ -1360,6 +1362,9 @@ Client-side errors .. autoclass:: neo4j.exceptions.ResultNotSingleError :show-inheritance: +.. autoclass:: neo4j.exceptions.BrokenRecordError + :show-inheritance: + .. autoclass:: neo4j.exceptions.SessionExpired :show-inheritance: diff --git a/neo4j/__init__.py b/neo4j/__init__.py index 89acc690d..8da6854d3 100644 --- a/neo4j/__init__.py +++ b/neo4j/__init__.py @@ -108,7 +108,6 @@ "BoltDriver", "Bookmark", "Bookmarks", - "Config", "custom_auth", "DEFAULT_DATABASE", "Driver", @@ -120,7 +119,6 @@ "kerberos_auth", "ManagedTransaction", "Neo4jDriver", - "PoolConfig", "Query", "READ_ACCESS", "Record", @@ -128,7 +126,6 @@ "ResultSummary", "ServerInfo", "Session", - "SessionConfig", "SummaryCounters", "Transaction", "TRUST_ALL_CERTIFICATES", @@ -138,7 +135,6 @@ "TrustSystemCAs", "unit_of_work", "Version", - "WorkspaceConfig", "WRITE_ACCESS", ] diff --git a/neo4j/_async/work/result.py b/neo4j/_async/work/result.py index 7f913cb72..0740cf58f 100644 --- a/neo4j/_async/work/result.py +++ b/neo4j/_async/work/result.py @@ -20,6 +20,7 @@ from warnings import warn from ..._async_compat.util import AsyncUtil +from ..._codec.hydration import BrokenHydrationObject from ..._data import ( Record, RecordTableRowExporter, @@ -145,6 +146,11 @@ async def on_failed_attach(metadata): def _pull(self): def on_records(records): if not self._discarding: + records = ( + record.raw_data + if isinstance(record, BrokenHydrationObject) else record + for record in records + ) self._record_buffer.extend(( Record(zip(self._keys, record)) for record in records diff --git a/neo4j/_codec/hydration/__init__.py b/neo4j/_codec/hydration/__init__.py index bd4fdb81f..1df9aaaa0 100644 --- a/neo4j/_codec/hydration/__init__.py +++ b/neo4j/_codec/hydration/__init__.py @@ -15,11 +15,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -from ._common import HydrationScope +from ._common import ( + BrokenHydrationObject, + HydrationScope, +) from ._interface import HydrationHandlerABC __all__ = [ + "BrokenHydrationObject", "HydrationHandlerABC", "HydrationScope", ] diff --git a/neo4j/_codec/hydration/_common.py b/neo4j/_codec/hydration/_common.py index 3a51b030d..2632f2117 100644 --- a/neo4j/_codec/hydration/_common.py +++ b/neo4j/_codec/hydration/_common.py @@ -16,10 +16,39 @@ # limitations under the License. +from copy import copy + from ...graph import Graph from ..packstream import Structure +class BrokenHydrationObject: + """ + Represents an object from the server, not understood by the driver. + + A :class:`neo4j.Record` might contain a ``BrokenHydrationObject`` object + if the driver received data from the server that it did not understand. + This can for instance happen if the server sends a zoned datetime with a + zone name unknown to the driver. + + There is no need to explicitly check for this type. Any method on the + :class:`neo4j.Record` that would return a ``BrokenHydrationObject``, will + raise a :exc:`neo4j.exceptions.BrokenRecordError` + with the original exception as cause. + """ + + def __init__(self, error, raw_data): + self.error = error + "The exception raised while decoding the received object." + self.raw_data = raw_data + """The raw data that caused the exception.""" + + def exception_copy(self): + exc_copy = copy(self.error) + exc_copy.with_traceback(self.error.__traceback__) + return exc_copy + + class GraphHydrator: def __init__(self): self.graph = Graph() @@ -27,7 +56,6 @@ def __init__(self): class HydrationScope: - def __init__(self, hydration_handler, graph_hydrator): self._hydration_handler = hydration_handler self._graph_hydrator = graph_hydrator @@ -37,14 +65,35 @@ def __init__(self, hydration_handler, graph_hydrator): } self.hydration_hooks = { Structure: self._hydrate_structure, + list: self._hydrate_list, + dict: self._hydrate_dict, } self.dehydration_hooks = hydration_handler.dehydration_functions def _hydrate_structure(self, value): f = self._struct_hydration_functions.get(value.tag) - if not f: - return value - return f(*value.fields) + try: + if not f: + raise ValueError( + f"Protocol error: unknown Structure tag: {value.tag!r}" + ) + return f(*value.fields) + except Exception as e: + return BrokenHydrationObject(e, value) + + @staticmethod + def _hydrate_list(value): + for v in value: + if isinstance(v, BrokenHydrationObject): + return BrokenHydrationObject(v.error, value) + return value + + @staticmethod + def _hydrate_dict(value): + for v in value.values(): + if isinstance(v, BrokenHydrationObject): + return BrokenHydrationObject(v.error, value) + return value def get_graph(self): return self._graph_hydrator.graph diff --git a/neo4j/_data.py b/neo4j/_data.py index 9207b50f5..84690573a 100644 --- a/neo4j/_data.py +++ b/neo4j/_data.py @@ -28,7 +28,10 @@ from functools import reduce from operator import xor as xor_operator +from ._codec.hydration import BrokenHydrationObject from ._conf import iter_items +from ._meta import deprecated +from .exceptions import BrokenRecordError from .graph import ( Node, Path, @@ -55,9 +58,26 @@ def __new__(cls, iterable=()): inst.__keys = tuple(keys) return inst + def _broken_record_error(self, index): + return BrokenRecordError( + f"Record contains broken data at {index} ('{self.__keys[index]}')" + ) + + def _super_getitem_single(self, index): + value = super().__getitem__(index) + if isinstance(value, BrokenHydrationObject): + raise self._broken_record_error(index) from value.error + return value + def __repr__(self): - return "<%s %s>" % (self.__class__.__name__, - " ".join("%s=%r" % (field, self[i]) for i, field in enumerate(self.__keys))) + return "<%s %s>" % ( + self.__class__.__name__, + " ".join("%s=%r" % (field, value) + for field, value in zip(self.__keys, super().__iter__())) + ) + + def __str__(self): + return self.__repr__() def __eq__(self, other): """ In order to be flexible regarding comparison, the equality rules @@ -83,18 +103,26 @@ def __ne__(self, other): def __hash__(self): return reduce(xor_operator, map(hash, self.items())) + def __iter__(self): + for i, v in enumerate(super().__iter__()): + if isinstance(v, BrokenHydrationObject): + raise self._broken_record_error(i) from v.error + yield v + def __getitem__(self, key): if isinstance(key, slice): keys = self.__keys[key] - values = super(Record, self).__getitem__(key) + values = super().__getitem__(key) return self.__class__(zip(keys, values)) try: index = self.index(key) except IndexError: return None else: - return super(Record, self).__getitem__(index) + return self._super_getitem_single(index) + # TODO: 6.0 - remove + @deprecated("This method is deprecated and will be removed in the future.") def __getslice__(self, start, stop): key = slice(start, stop) keys = self.__keys[key] @@ -114,7 +142,7 @@ def get(self, key, default=None): except ValueError: return default if 0 <= index < len(self): - return super(Record, self).__getitem__(index) + return self._super_getitem_single(index) else: return default @@ -197,7 +225,8 @@ def items(self, *keys): else: d.append((self.__keys[i], self[i])) return d - return list((self.__keys[i], super(Record, self).__getitem__(i)) for i in range(len(self))) + return list((self.__keys[i], self._super_getitem_single(i)) + for i in range(len(self))) def data(self, *keys): """ Return the keys and values of this record as a dictionary, diff --git a/neo4j/_sync/work/result.py b/neo4j/_sync/work/result.py index 888fd2701..bd9e56831 100644 --- a/neo4j/_sync/work/result.py +++ b/neo4j/_sync/work/result.py @@ -20,6 +20,7 @@ from warnings import warn from ..._async_compat.util import Util +from ..._codec.hydration import BrokenHydrationObject from ..._data import ( Record, RecordTableRowExporter, @@ -145,6 +146,11 @@ def on_failed_attach(metadata): def _pull(self): def on_records(records): if not self._discarding: + records = ( + record.raw_data + if isinstance(record, BrokenHydrationObject) else record + for record in records + ) self._record_buffer.extend(( Record(zip(self._keys, record)) for record in records diff --git a/neo4j/exceptions.py b/neo4j/exceptions.py index 36ba00958..15392c5e0 100644 --- a/neo4j/exceptions.py +++ b/neo4j/exceptions.py @@ -41,6 +41,7 @@ + ResultError + ResultConsumedError + ResultNotSingleError + + BrokenRecordError + SessionExpired + ServiceUnavailable + RoutingServiceUnavailable @@ -395,6 +396,15 @@ class ResultNotSingleError(ResultError): """Raised when a result should have exactly one record but does not.""" +# DriverError > BrokenRecordError +class BrokenRecordError(DriverError): + """ Raised when accessing a Record's field that couldn't be decoded. + + This can for instance happen when the server sends a zoned datetime with a + zone id unknown to the client. + """ + + # DriverError > SessionExpired class SessionExpired(DriverError): """ Raised when a session is no longer able to fulfil diff --git a/testkitbackend/_async/backend.py b/testkitbackend/_async/backend.py index b939e9803..c6aa85e6a 100644 --- a/testkitbackend/_async/backend.py +++ b/testkitbackend/_async/backend.py @@ -97,6 +97,21 @@ def _exc_stems_from_driver(exc): if DRIVER_PATH in p.parents: return True + @staticmethod + def _exc_msg(exc, max_depth=10): + if isinstance(exc, Neo4jError) and exc.message is not None: + return str(exc.message) + + depth = 0 + res = str(exc) + while getattr(exc, "__cause__", None) is not None: + depth += 1 + if depth >= max_depth: + break + res += f"\nCaused by: {exc.__cause__!r}" + exc = exc.__cause__ + return res + async def write_driver_exc(self, exc): log.debug(traceback.format_exc()) @@ -109,14 +124,10 @@ async def write_driver_exc(self, exc): wrapped_exc = exc.wrapped_exc payload["errorType"] = str(type(wrapped_exc)) if wrapped_exc.args: - payload["msg"] = str(wrapped_exc.args[0]) + payload["msg"] = self._exc_msg(wrapped_exc.args[0]) else: payload["errorType"] = str(type(exc)) - if isinstance(exc, Neo4jError) and exc.message is not None: - payload["msg"] = str(exc.message) - elif exc.args: - payload["msg"] = str(exc.args[0]) - + payload["msg"] = self._exc_msg(exc) if isinstance(exc, Neo4jError): payload["code"] = exc.code diff --git a/testkitbackend/_sync/backend.py b/testkitbackend/_sync/backend.py index 75a23d66d..a07a89d50 100644 --- a/testkitbackend/_sync/backend.py +++ b/testkitbackend/_sync/backend.py @@ -97,6 +97,21 @@ def _exc_stems_from_driver(exc): if DRIVER_PATH in p.parents: return True + @staticmethod + def _exc_msg(exc, max_depth=10): + if isinstance(exc, Neo4jError) and exc.message is not None: + return str(exc.message) + + depth = 0 + res = str(exc) + while getattr(exc, "__cause__", None) is not None: + depth += 1 + if depth >= max_depth: + break + res += f"\nCaused by: {exc.__cause__!r}" + exc = exc.__cause__ + return res + def write_driver_exc(self, exc): log.debug(traceback.format_exc()) @@ -109,14 +124,10 @@ def write_driver_exc(self, exc): wrapped_exc = exc.wrapped_exc payload["errorType"] = str(type(wrapped_exc)) if wrapped_exc.args: - payload["msg"] = str(wrapped_exc.args[0]) + payload["msg"] = self._exc_msg(wrapped_exc.args[0]) else: payload["errorType"] = str(type(exc)) - if isinstance(exc, Neo4jError) and exc.message is not None: - payload["msg"] = str(exc.message) - elif exc.args: - payload["msg"] = str(exc.args[0]) - + payload["msg"] = self._exc_msg(exc) if isinstance(exc, Neo4jError): payload["code"] = exc.code diff --git a/tests/unit/async_/work/test_result.py b/tests/unit/async_/work/test_result.py index 8981ef327..32b5cdcb1 100644 --- a/tests/unit/async_/work/test_result.py +++ b/tests/unit/async_/work/test_result.py @@ -41,7 +41,10 @@ Node, Relationship, ) -from neo4j.exceptions import ResultNotSingleError +from neo4j.exceptions import ( + BrokenRecordError, + ResultNotSingleError, +) from neo4j.graph import ( EntitySetView, Graph, @@ -55,18 +58,18 @@ def __init__(self, fields, records): self.fields = tuple(fields) self.hydration_scope = HydrationHandler().new_hydration_scope() self.records = tuple(records) - self._hydrate_records() - assert all(len(self.fields) == len(r) for r in self.records) + self._hydrate_records() + def _hydrate_records(self): def _hydrate(value): + if isinstance(value, (list, tuple)): + value = type(value)(_hydrate(v) for v in value) + elif isinstance(value, dict): + value = {k: _hydrate(v) for k, v in value.items()} if type(value) in self.hydration_scope.hydration_hooks: return self.hydration_scope.hydration_hooks[type(value)](value) - if isinstance(value, (list, tuple)): - return type(value)(_hydrate(v) for v in value) - if isinstance(value, dict): - return {k: _hydrate(v) for k, v in value.items()} return value self.records = tuple(_hydrate(r) for r in self.records) @@ -605,7 +608,6 @@ async def test_data(num_records): assert record.data.called_once_with("hello", "world") -# TODO: dehydration now happens on a much lower level @pytest.mark.parametrize("records", ( Records(["n"], []), Records(["n"], [[42], [69], [420], [1337]]), @@ -621,19 +623,9 @@ async def test_data(num_records): ]), )) @mark_async_test -async def test_result_graph(records, async_scripted_connection): - async_scripted_connection.set_script(( - ("run", {"on_success": ({"fields": records.fields},), - "on_summary": None}), - ("pull", { - "on_records": (records.records,), - "on_success": None, - "on_summary": None - }), - )) - async_scripted_connection.new_hydration_scope.return_value = \ - records.hydration_scope - result = AsyncResult(async_scripted_connection, 1, noop, noop) +async def test_result_graph(records): + connection = AsyncConnectionStub(records=records) + result = AsyncResult(connection, 1, noop, noop) await result._run("CYPHER", {}, None, None, "r", None) graph = await result.graph() assert isinstance(graph, Graph) @@ -1095,3 +1087,25 @@ async def test_to_df_parse_dates(keys, values, expected_df, expand): df = await result.to_df(expand=expand, parse_dates=True) pd.testing.assert_frame_equal(df, expected_df) + + +@pytest.mark.parametrize("nested", [True, False]) +@mark_async_test +async def test_broken_hydration(nested): + value_in = Structure(b"a", "broken") + if nested: + value_in = [value_in] + records_in = Records(["foo", "bar"], [["foobar", value_in]]) + connection = AsyncConnectionStub(records=records_in) + result = AsyncResult(connection, 1, noop, noop) + await result._run("CYPHER", {}, None, None, "r", None) + records_out = await AsyncUtil.list(result) + assert len(records_out) == 1 + record_out = records_out[0] + assert len(record_out) == 2 + assert record_out[0] == "foobar" + with pytest.raises(BrokenRecordError) as exc: + record_out[1] + cause = exc.value.__cause__ + assert isinstance(cause, ValueError) + assert repr(b"a") in str(cause) diff --git a/tests/unit/common/codec/hydration/v1/test_hydration_handler.py b/tests/unit/common/codec/hydration/v1/test_hydration_handler.py index eccc10e18..908678c9d 100644 --- a/tests/unit/common/codec/hydration/v1/test_hydration_handler.py +++ b/tests/unit/common/codec/hydration/v1/test_hydration_handler.py @@ -60,7 +60,7 @@ def hydration_scope(self, hydration_handler): def test_scope_hydration_keys(self, hydration_scope): hooks = hydration_scope.hydration_hooks assert isinstance(hooks, dict) - assert set(hooks.keys()) == {Structure} + assert set(hooks.keys()) == {Structure, list, dict} def test_scope_dehydration_keys(self, hydration_scope): hooks = hydration_scope.dehydration_hooks @@ -76,3 +76,23 @@ def test_scope_get_graph(self, hydration_scope): assert isinstance(graph, Graph) assert not graph.nodes assert not graph.relationships + + @pytest.mark.parametrize("data", ( + [1, 2, 3], + ["a", "b", "c"], + [object(), object()], + [ValueError(), 42, {}, b"foo"], + )) + def test_list_hydration(self, hydration_scope, data): + res = hydration_scope.hydration_hooks[list](data) + assert res == data + + @pytest.mark.parametrize("data", ( + {"a": 1, "b": 2, "c": 3}, + {"a": "a", "b": "b", "c": "c"}, + {"a": object(), "b": object()}, + {"a": ValueError(), "b": 42, "c": {}, "d": b"foo"}, + )) + def test_dict_hydration(self, hydration_scope, data): + res = hydration_scope.hydration_hooks[dict](data) + assert res == data diff --git a/tests/unit/common/codec/hydration/v1/test_time_dehydration.py b/tests/unit/common/codec/hydration/v1/test_temporal_dehydration.py similarity index 95% rename from tests/unit/common/codec/hydration/v1/test_time_dehydration.py rename to tests/unit/common/codec/hydration/v1/test_temporal_dehydration.py index 8315d7081..078fc6e7f 100644 --- a/tests/unit/common/codec/hydration/v1/test_time_dehydration.py +++ b/tests/unit/common/codec/hydration/v1/test_temporal_dehydration.py @@ -145,7 +145,7 @@ def hydration_handler(self): return handler def test_date_time(self, hydration_scope): - from ..v2.test_time_dehydration import ( + from ..v2.test_temporal_dehydration import ( TestTimeDehydration as TestTimeDehydrationV2, ) TestTimeDehydrationV2().test_date_time( @@ -153,7 +153,7 @@ def test_date_time(self, hydration_scope): ) def test_native_date_time(self, hydration_scope): - from ..v2.test_time_dehydration import ( + from ..v2.test_temporal_dehydration import ( TestTimeDehydration as TestTimeDehydrationV2, ) TestTimeDehydrationV2().test_native_date_time( @@ -161,7 +161,7 @@ def test_native_date_time(self, hydration_scope): ) def test_date_time_negative_offset(self, hydration_scope): - from ..v2.test_time_dehydration import ( + from ..v2.test_temporal_dehydration import ( TestTimeDehydration as TestTimeDehydrationV2, ) TestTimeDehydrationV2().test_date_time_negative_offset( @@ -169,7 +169,7 @@ def test_date_time_negative_offset(self, hydration_scope): ) def test_native_date_time_negative_offset(self, hydration_scope): - from ..v2.test_time_dehydration import ( + from ..v2.test_temporal_dehydration import ( TestTimeDehydration as TestTimeDehydrationV2, ) TestTimeDehydrationV2().test_native_date_time_negative_offset( @@ -177,7 +177,7 @@ def test_native_date_time_negative_offset(self, hydration_scope): ) def test_date_time_zone_id(self, hydration_scope): - from ..v2.test_time_dehydration import ( + from ..v2.test_temporal_dehydration import ( TestTimeDehydration as TestTimeDehydrationV2, ) TestTimeDehydrationV2().test_date_time_zone_id( @@ -185,7 +185,7 @@ def test_date_time_zone_id(self, hydration_scope): ) def test_native_date_time_zone_id(self, hydration_scope): - from ..v2.test_time_dehydration import ( + from ..v2.test_temporal_dehydration import ( TestTimeDehydration as TestTimeDehydrationV2, ) TestTimeDehydrationV2().test_native_date_time_zone_id( diff --git a/tests/unit/common/codec/hydration/v1/test_time_hydration.py b/tests/unit/common/codec/hydration/v1/test_temporal_hydration.py similarity index 75% rename from tests/unit/common/codec/hydration/v1/test_time_hydration.py rename to tests/unit/common/codec/hydration/v1/test_temporal_hydration.py index 3c04c253f..724baa2a2 100644 --- a/tests/unit/common/codec/hydration/v1/test_time_hydration.py +++ b/tests/unit/common/codec/hydration/v1/test_temporal_hydration.py @@ -19,6 +19,7 @@ import pytest import pytz +from neo4j._codec.hydration import BrokenHydrationObject from neo4j._codec.hydration.v1 import HydrationHandler from neo4j._codec.packstream import Structure from neo4j.time import ( @@ -31,7 +32,7 @@ from ._base import HydrationHandlerTestBase -class TestTimeHydration(HydrationHandlerTestBase): +class TestTemporalHydration(HydrationHandlerTestBase): @pytest.fixture def hydration_handler(self): return HydrationHandler() @@ -80,8 +81,8 @@ def test_hydrate_date_time_structure_v1(self, hydration_scope): def test_hydrate_date_time_structure_v2(self, hydration_scope): struct = Structure(b"I", 1539344261, 474716862, 3600) dt = hydration_scope.hydration_hooks[Structure](struct) - assert isinstance(dt, Structure) - assert dt == struct + assert isinstance(dt, BrokenHydrationObject) + assert repr(b"I") in str(dt.error) def test_hydrate_date_time_zone_id_structure_v1(self, hydration_scope): struct = Structure(b"f", 1539344261, 474716862, "Europe/Stockholm") @@ -98,11 +99,24 @@ def test_hydrate_date_time_zone_id_structure_v1(self, hydration_scope): .localize(dt.replace(tzinfo=None)).tzinfo assert dt.tzinfo == tz + def test_hydrate_date_time_unknown_zone_id_structure(self, + hydration_scope): + struct = Structure(b"f", 1539344261, 474716862, "Europe/Neo4j") + res = hydration_scope.hydration_hooks[Structure](struct) + assert isinstance(res, BrokenHydrationObject) + exc = None + try: + pytz.timezone("Europe/Neo4j") + except Exception as e: + exc = e + assert exc.__class__ == res.error.__class__ + assert str(exc) == str(res.error) + def test_hydrate_date_time_zone_id_structure_v2(self, hydration_scope): struct = Structure(b"i", 1539344261, 474716862, "Europe/Stockholm") dt = hydration_scope.hydration_hooks[Structure](struct) - assert isinstance(dt, Structure) - assert dt == struct + assert isinstance(dt, BrokenHydrationObject) + assert repr(b"i") in str(dt.error) def test_hydrate_local_date_time_structure(self, hydration_scope): struct = Structure(b"d", 1539344261, 474716862) @@ -127,7 +141,7 @@ def test_hydrate_duration_structure(self, hydration_scope): assert d.nanoseconds == 4 -class TestUTCPatchedTimeHydration(TestTimeHydration): +class TestUTCPatchedTemporalHydration(TestTemporalHydration): @pytest.fixture def hydration_handler(self): handler = HydrationHandler() @@ -135,33 +149,43 @@ def hydration_handler(self): return handler def test_hydrate_date_time_structure_v1(self, hydration_scope): - from ..v2.test_time_hydration import ( - TestTimeHydration as TestTimeHydrationV2, + from ..v2.test_temporal_hydration import ( + TestTemporalHydration as TestTimeHydrationV2, ) TestTimeHydrationV2().test_hydrate_date_time_structure_v1( hydration_scope ) def test_hydrate_date_time_structure_v2(self, hydration_scope): - from ..v2.test_time_hydration import ( - TestTimeHydration as TestTimeHydrationV2, + from ..v2.test_temporal_hydration import ( + TestTemporalHydration as TestTimeHydrationV2, ) TestTimeHydrationV2().test_hydrate_date_time_structure_v2( hydration_scope ) def test_hydrate_date_time_zone_id_structure_v1(self, hydration_scope): - from ..v2.test_time_hydration import ( - TestTimeHydration as TestTimeHydrationV2, + from ..v2.test_temporal_hydration import ( + TestTemporalHydration as TestTimeHydrationV2, ) TestTimeHydrationV2().test_hydrate_date_time_zone_id_structure_v1( hydration_scope ) def test_hydrate_date_time_zone_id_structure_v2(self, hydration_scope): - from ..v2.test_time_hydration import ( - TestTimeHydration as TestTimeHydrationV2, + from ..v2.test_temporal_hydration import ( + TestTemporalHydration as TestTimeHydrationV2, ) TestTimeHydrationV2().test_hydrate_date_time_zone_id_structure_v2( hydration_scope ) + + def test_hydrate_date_time_unknown_zone_id_structure(self, + hydration_scope): + + from ..v2.test_temporal_hydration import ( + TestTemporalHydration as TestTimeHydrationV2, + ) + TestTimeHydrationV2().test_hydrate_date_time_unknown_zone_id_structure( + hydration_scope + ) diff --git a/tests/unit/common/codec/hydration/v1/test_unknown_hydration.py b/tests/unit/common/codec/hydration/v1/test_unknown_hydration.py new file mode 100644 index 000000000..239c3ddd4 --- /dev/null +++ b/tests/unit/common/codec/hydration/v1/test_unknown_hydration.py @@ -0,0 +1,55 @@ +# Copyright (c) "Neo4j" +# Neo4j Sweden AB [https://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 +# +# https://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 + +from neo4j._codec.hydration import BrokenHydrationObject +from neo4j._codec.hydration.v1 import HydrationHandler +from neo4j._codec.packstream import Structure + +from ._base import HydrationHandlerTestBase + + +class TestUnknownHydration(HydrationHandlerTestBase): + @pytest.fixture + def hydration_handler(self): + return HydrationHandler() + + def test_unknown_structure_tag(self, hydration_scope): + struct = Structure(b"a", "lol wut?") + res = hydration_scope.hydration_hooks[Structure](struct) + assert isinstance(res, BrokenHydrationObject) + error = res.error + assert isinstance(error, ValueError) + assert repr(b"a") in str(error) + + def test_broken_object_propagates_through_lists(self, hydration_scope): + broken_obj = BrokenHydrationObject(Exception("test"), "b") + data = [1, broken_obj, 3] + res = hydration_scope.hydration_hooks[list](data) + assert isinstance(res, BrokenHydrationObject) + assert res.raw_data == data + assert res.error is broken_obj.error + + def test_broken_object_propagates_through_dicts(self, hydration_scope): + broken_obj = BrokenHydrationObject(Exception("test"), "b") + data = {"a": 1, "b": broken_obj, "c": 3} + res = hydration_scope.hydration_hooks[dict](data) + assert isinstance(res, BrokenHydrationObject) + assert res.raw_data == data + assert res.error is broken_obj.error diff --git a/tests/unit/common/codec/hydration/v2/test_graph_hydration.py b/tests/unit/common/codec/hydration/v2/test_graph_hydration.py index 1e3e41d2b..8184d4efd 100644 --- a/tests/unit/common/codec/hydration/v2/test_graph_hydration.py +++ b/tests/unit/common/codec/hydration/v2/test_graph_hydration.py @@ -18,10 +18,9 @@ import pytest -from neo4j._codec.hydration.v1 import HydrationHandler +from neo4j._codec.hydration.v2 import HydrationHandler from neo4j._codec.packstream import Structure from neo4j.graph import ( - Graph, Node, Relationship, ) diff --git a/tests/unit/common/codec/hydration/v2/test_hydration_handler.py b/tests/unit/common/codec/hydration/v2/test_hydration_handler.py index c28379ea6..d51c7edc2 100644 --- a/tests/unit/common/codec/hydration/v2/test_hydration_handler.py +++ b/tests/unit/common/codec/hydration/v2/test_hydration_handler.py @@ -18,7 +18,7 @@ import pytest -from neo4j._codec.hydration.v1 import HydrationHandler +from neo4j._codec.hydration.v2 import HydrationHandler from ..v1.test_hydration_handler import ( TestHydrationHandler as TestHydrationHandlerV1, diff --git a/tests/unit/common/codec/hydration/v2/test_spacial_dehydration.py b/tests/unit/common/codec/hydration/v2/test_spacial_dehydration.py index 85349dc50..d63de58b9 100644 --- a/tests/unit/common/codec/hydration/v2/test_spacial_dehydration.py +++ b/tests/unit/common/codec/hydration/v2/test_spacial_dehydration.py @@ -18,7 +18,7 @@ import pytest -from neo4j._codec.hydration.v1 import HydrationHandler +from neo4j._codec.hydration.v2 import HydrationHandler from ..v1.test_spacial_dehydration import ( TestSpatialDehydration as _TestSpatialDehydrationV1, diff --git a/tests/unit/common/codec/hydration/v2/test_spacial_hydration.py b/tests/unit/common/codec/hydration/v2/test_spacial_hydration.py index d905965ca..3e5beb36c 100644 --- a/tests/unit/common/codec/hydration/v2/test_spacial_hydration.py +++ b/tests/unit/common/codec/hydration/v2/test_spacial_hydration.py @@ -18,7 +18,7 @@ import pytest -from neo4j._codec.hydration.v1 import HydrationHandler +from neo4j._codec.hydration.v2 import HydrationHandler from ..v1.test_spacial_hydration import ( TestSpatialHydration as _TestSpatialHydrationV1, diff --git a/tests/unit/common/codec/hydration/v2/test_time_dehydration.py b/tests/unit/common/codec/hydration/v2/test_temporal_dehydration.py similarity index 94% rename from tests/unit/common/codec/hydration/v2/test_time_dehydration.py rename to tests/unit/common/codec/hydration/v2/test_temporal_dehydration.py index 021db2eb4..27d5f3d67 100644 --- a/tests/unit/common/codec/hydration/v2/test_time_dehydration.py +++ b/tests/unit/common/codec/hydration/v2/test_temporal_dehydration.py @@ -25,12 +25,12 @@ from neo4j._codec.packstream import Structure from neo4j.time import DateTime -from ..v1.test_time_dehydration import ( - TestTimeDehydration as _TestTimeDehydrationV1, +from ..v1.test_temporal_dehydration import ( + TestTimeDehydration as _TestTemporalDehydrationV1, ) -class TestTimeDehydration(_TestTimeDehydrationV1): +class TestTimeDehydration(_TestTemporalDehydrationV1): @pytest.fixture def hydration_handler(self): return HydrationHandler() diff --git a/tests/unit/common/codec/hydration/v2/test_time_hydration.py b/tests/unit/common/codec/hydration/v2/test_temporal_hydration.py similarity index 71% rename from tests/unit/common/codec/hydration/v2/test_time_hydration.py rename to tests/unit/common/codec/hydration/v2/test_temporal_hydration.py index 7fe308ec0..7c6177ab3 100644 --- a/tests/unit/common/codec/hydration/v2/test_time_hydration.py +++ b/tests/unit/common/codec/hydration/v2/test_temporal_hydration.py @@ -21,14 +21,17 @@ import pytest import pytz +from neo4j._codec.hydration import BrokenHydrationObject from neo4j._codec.hydration.v2 import HydrationHandler from neo4j._codec.packstream import Structure from neo4j.time import DateTime -from ..v1.test_time_hydration import TestTimeHydration as _TestTimeHydrationV1 +from ..v1.test_temporal_hydration import ( + TestTemporalHydration as _TestTemporalHydrationV1, +) -class TestTimeHydration(_TestTimeHydrationV1): +class TestTemporalHydration(_TestTemporalHydrationV1): @pytest.fixture def hydration_handler(self): return HydrationHandler() @@ -36,8 +39,8 @@ def hydration_handler(self): def test_hydrate_date_time_structure_v1(self, hydration_scope): struct = Structure(b"F", 1539344261, 474716862, 3600) dt = hydration_scope.hydration_hooks[Structure](struct) - assert isinstance(dt, Structure) - assert dt == struct + assert isinstance(dt, BrokenHydrationObject) + assert repr(b"F") in str(dt.error) def test_hydrate_date_time_structure_v2(self, hydration_scope): struct = Structure(b"I", 1539344261, 474716862, 3600) @@ -55,8 +58,8 @@ def test_hydrate_date_time_structure_v2(self, hydration_scope): def test_hydrate_date_time_zone_id_structure_v1(self, hydration_scope): struct = Structure(b"f", 1539344261, 474716862, "Europe/Stockholm") dt = hydration_scope.hydration_hooks[Structure](struct) - assert isinstance(dt, Structure) - assert dt == struct + assert isinstance(dt, BrokenHydrationObject) + assert repr(b"f") in str(dt.error) def test_hydrate_date_time_zone_id_structure_v2(self, hydration_scope): struct = Structure(b"i", 1539344261, 474716862, "Europe/Stockholm") @@ -72,3 +75,16 @@ def test_hydrate_date_time_zone_id_structure_v2(self, hydration_scope): tz = pytz.timezone("Europe/Stockholm") \ .localize(dt.replace(tzinfo=None)).tzinfo assert dt.tzinfo == tz + + def test_hydrate_date_time_unknown_zone_id_structure(self, + hydration_scope): + struct = Structure(b"i", 1539344261, 474716862, "Europe/Neo4j") + res = hydration_scope.hydration_hooks[Structure](struct) + assert isinstance(res, BrokenHydrationObject) + exc = None + try: + pytz.timezone("Europe/Neo4j") + except Exception as e: + exc = e + assert exc.__class__ == res.error.__class__ + assert str(exc) == str(res.error) diff --git a/tests/unit/common/codec/hydration/v2/test_unknown_hydration.py b/tests/unit/common/codec/hydration/v2/test_unknown_hydration.py new file mode 100644 index 000000000..7bf7782f8 --- /dev/null +++ b/tests/unit/common/codec/hydration/v2/test_unknown_hydration.py @@ -0,0 +1,31 @@ +# Copyright (c) "Neo4j" +# Neo4j Sweden AB [https://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 +# +# https://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 + +from neo4j._codec.hydration.v2 import HydrationHandler + +from ..v1.test_unknown_hydration import ( + TestUnknownHydration as _TestUnknownHydration, +) + + +class TestUnknownHydration(_TestUnknownHydration): + @pytest.fixture + def hydration_handler(self): + return HydrationHandler() diff --git a/tests/unit/common/test_record.py b/tests/unit/common/test_record.py index fbb79a957..26a258ebc 100644 --- a/tests/unit/common/test_record.py +++ b/tests/unit/common/test_record.py @@ -16,14 +16,15 @@ # limitations under the License. +import traceback + import pytest from neo4j import Record +from neo4j._codec.hydration import BrokenHydrationObject from neo4j._codec.hydration.v1 import HydrationHandler -from neo4j.graph import ( - Graph, - Node, -) +from neo4j.exceptions import BrokenRecordError +from neo4j.graph import Node # python -m pytest -s -v tests/unit/test_record.py @@ -330,3 +331,76 @@ def test_data_path(cyclic): assert record.data() == { "r": [dict(alice), "KNOWS", dict(bob), "DISLIKES", dict(carol)] } + + +@pytest.mark.parametrize(("accessor", "should_raise"), ( + (lambda r: r["a"], False), + (lambda r: r["b"], True), + (lambda r: r["c"], False), + (lambda r: r[0], False), + (lambda r: r[1], True), + (lambda r: r[2], False), + (lambda r: r.value("a"), False), + (lambda r: r.value("b"), True), + (lambda r: r.value("c"), False), + (lambda r: r.value("made-up"), False), + (lambda r: r.value(0), False), + (lambda r: r.value(1), True), + (lambda r: r.value(2), False), + (lambda r: r.value(3), False), + (lambda r: r.values(0, 2, "made-up"), False), + (lambda r: r.values(0, 1), True), + (lambda r: r.values(2, 1), True), + (lambda r: r.values(1), True), + (lambda r: r.values(1, "made-up"), True), + (lambda r: r.values(1, 0), True), + (lambda r: r.values("a", "c", "made-up"), False), + (lambda r: r.values("a", "b"), True), + (lambda r: r.values("c", "b"), True), + (lambda r: r.values("b"), True), + (lambda r: r.values("b", "made-up"), True), + (lambda r: r.values("b", "a"), True), + (lambda r: r.data(0, 2, "made-up"), False), + (lambda r: r.data(0, 1), True), + (lambda r: r.data(2, 1), True), + (lambda r: r.data(1), True), + (lambda r: r.data(1, "made-up"), True), + (lambda r: r.data(1, 0), True), + (lambda r: r.data("a", "c", "made-up"), False), + (lambda r: r.data("a", "b"), True), + (lambda r: r.data("c", "b"), True), + (lambda r: r.data("b"), True), + (lambda r: r.data("b", "made-up"), True), + (lambda r: r.data("b", "a"), True), + (lambda r: r.get("a"), False), + (lambda r: r.get("b"), True), + (lambda r: r.get("c"), False), + (lambda r: r.get("made-up"), False), + (lambda r: list(r), True), + (lambda r: list(r.items()), True), + (lambda r: r.index("a"), False), + (lambda r: r.index("b"), False), + (lambda r: r.index("c"), False), + (lambda r: r.index(0), False), + (lambda r: r.index(1), False), + (lambda r: r.index(2), False), +)) +def test_record_with_error(accessor, should_raise): + class TestException(Exception): + pass + + # raising and catching exceptions to get the stacktrace populated + try: + raise TestException("test") + except TestException as e: + exc = e + frames = list(traceback.walk_tb(exc.__traceback__)) + r = Record((("a", 1), ("b", BrokenHydrationObject(exc, None)), ("c", 3))) + if not should_raise: + accessor(r) + return + with pytest.raises(BrokenRecordError) as raised: + accessor(r) + raised = raised.value + assert raised.__cause__ is exc + assert list(traceback.walk_tb(raised.__cause__.__traceback__)) == frames diff --git a/tests/unit/sync/work/test_result.py b/tests/unit/sync/work/test_result.py index a801ca7e7..c9eb9165b 100644 --- a/tests/unit/sync/work/test_result.py +++ b/tests/unit/sync/work/test_result.py @@ -41,7 +41,10 @@ Node, Relationship, ) -from neo4j.exceptions import ResultNotSingleError +from neo4j.exceptions import ( + BrokenRecordError, + ResultNotSingleError, +) from neo4j.graph import ( EntitySetView, Graph, @@ -55,18 +58,18 @@ def __init__(self, fields, records): self.fields = tuple(fields) self.hydration_scope = HydrationHandler().new_hydration_scope() self.records = tuple(records) - self._hydrate_records() - assert all(len(self.fields) == len(r) for r in self.records) + self._hydrate_records() + def _hydrate_records(self): def _hydrate(value): + if isinstance(value, (list, tuple)): + value = type(value)(_hydrate(v) for v in value) + elif isinstance(value, dict): + value = {k: _hydrate(v) for k, v in value.items()} if type(value) in self.hydration_scope.hydration_hooks: return self.hydration_scope.hydration_hooks[type(value)](value) - if isinstance(value, (list, tuple)): - return type(value)(_hydrate(v) for v in value) - if isinstance(value, dict): - return {k: _hydrate(v) for k, v in value.items()} return value self.records = tuple(_hydrate(r) for r in self.records) @@ -605,7 +608,6 @@ def test_data(num_records): assert record.data.called_once_with("hello", "world") -# TODO: dehydration now happens on a much lower level @pytest.mark.parametrize("records", ( Records(["n"], []), Records(["n"], [[42], [69], [420], [1337]]), @@ -621,19 +623,9 @@ def test_data(num_records): ]), )) @mark_sync_test -def test_result_graph(records, scripted_connection): - scripted_connection.set_script(( - ("run", {"on_success": ({"fields": records.fields},), - "on_summary": None}), - ("pull", { - "on_records": (records.records,), - "on_success": None, - "on_summary": None - }), - )) - scripted_connection.new_hydration_scope.return_value = \ - records.hydration_scope - result = Result(scripted_connection, 1, noop, noop) +def test_result_graph(records): + connection = ConnectionStub(records=records) + result = Result(connection, 1, noop, noop) result._run("CYPHER", {}, None, None, "r", None) graph = result.graph() assert isinstance(graph, Graph) @@ -1095,3 +1087,25 @@ def test_to_df_parse_dates(keys, values, expected_df, expand): df = result.to_df(expand=expand, parse_dates=True) pd.testing.assert_frame_equal(df, expected_df) + + +@pytest.mark.parametrize("nested", [True, False]) +@mark_sync_test +def test_broken_hydration(nested): + value_in = Structure(b"a", "broken") + if nested: + value_in = [value_in] + records_in = Records(["foo", "bar"], [["foobar", value_in]]) + connection = ConnectionStub(records=records_in) + result = Result(connection, 1, noop, noop) + result._run("CYPHER", {}, None, None, "r", None) + records_out = Util.list(result) + assert len(records_out) == 1 + record_out = records_out[0] + assert len(record_out) == 2 + assert record_out[0] == "foobar" + with pytest.raises(BrokenRecordError) as exc: + record_out[1] + cause = exc.value.__cause__ + assert isinstance(cause, ValueError) + assert repr(b"a") in str(cause)