Skip to content

Commit b514f97

Browse files
committed
Raise hydration errors on value access
1 parent 8fd0572 commit b514f97

22 files changed

+444
-97
lines changed

docs/source/api.rst

+3-1
Original file line numberDiff line numberDiff line change
@@ -1248,7 +1248,6 @@ Neo4j Execution Errors
12481248
.. autoclass:: neo4j.exceptions.Neo4jError
12491249
:members: message, code, is_retriable, is_retryable
12501250

1251-
12521251
.. autoclass:: neo4j.exceptions.ClientError
12531252
:show-inheritance:
12541253

@@ -1330,6 +1329,9 @@ Connectivity Errors
13301329
.. autoclass:: neo4j.exceptions.ReadServiceUnavailable
13311330
:show-inheritance:
13321331

1332+
.. autoclass:: neo4j.exceptions.BrokenRecordError
1333+
:show-inheritance:
1334+
13331335
.. autoclass:: neo4j.exceptions.ConfigurationError
13341336
:show-inheritance:
13351337

neo4j/__init__.py

-4
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,6 @@
108108
"BoltDriver",
109109
"Bookmark",
110110
"Bookmarks",
111-
"Config",
112111
"custom_auth",
113112
"DEFAULT_DATABASE",
114113
"Driver",
@@ -120,15 +119,13 @@
120119
"kerberos_auth",
121120
"ManagedTransaction",
122121
"Neo4jDriver",
123-
"PoolConfig",
124122
"Query",
125123
"READ_ACCESS",
126124
"Record",
127125
"Result",
128126
"ResultSummary",
129127
"ServerInfo",
130128
"Session",
131-
"SessionConfig",
132129
"SummaryCounters",
133130
"Transaction",
134131
"TRUST_ALL_CERTIFICATES",
@@ -138,7 +135,6 @@
138135
"TrustSystemCAs",
139136
"unit_of_work",
140137
"Version",
141-
"WorkspaceConfig",
142138
"WRITE_ACCESS",
143139
]
144140

neo4j/_async/work/result.py

+6
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from warnings import warn
2121

2222
from ..._async_compat.util import AsyncUtil
23+
from ..._codec.hydration import BrokenHydrationObject
2324
from ..._data import (
2425
Record,
2526
RecordTableRowExporter,
@@ -145,6 +146,11 @@ async def on_failed_attach(metadata):
145146
def _pull(self):
146147
def on_records(records):
147148
if not self._discarding:
149+
records = (
150+
record.raw_data
151+
if isinstance(record, BrokenHydrationObject) else record
152+
for record in records
153+
)
148154
self._record_buffer.extend((
149155
Record(zip(self._keys, record))
150156
for record in records

neo4j/_codec/hydration/__init__.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,15 @@
1515
# See the License for the specific language governing permissions and
1616
# limitations under the License.
1717

18-
from ._common import HydrationScope
18+
from ._common import (
19+
BrokenHydrationObject,
20+
HydrationScope,
21+
)
1922
from ._interface import HydrationHandlerABC
2023

2124

2225
__all__ = [
26+
"BrokenHydrationObject",
2327
"HydrationHandlerABC",
2428
"HydrationScope",
2529
]

neo4j/_codec/hydration/_common.py

+53-4
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,46 @@
1616
# limitations under the License.
1717

1818

19+
from copy import copy
20+
1921
from ...graph import Graph
2022
from ..packstream import Structure
2123

2224

25+
class BrokenHydrationObject:
26+
"""
27+
Represents an object from the server, not understood by the driver.
28+
29+
A :class:`neo4j.Record` might contain a ``BrokenHydrationObject`` object
30+
if the driver received data from the server that it did not understand.
31+
This can for instance happen if the server sends a zoned datetime with a
32+
zone name unknown to the driver.
33+
34+
There is no need to explicitly check for this type. Any method on the
35+
:class:`neo4j.Record` that would return a ``BrokenHydrationObject``, will
36+
raise a :exc:`neo4j.exceptions.BrokenRecordError`
37+
with the original exception as cause.
38+
"""
39+
40+
def __init__(self, error, raw_data):
41+
self.error = error
42+
"The exception raised while decoding the received object."
43+
self.raw_data = raw_data
44+
"""The raw data that caused the exception."""
45+
46+
def exception_copy(self):
47+
exc_copy = copy(self.error)
48+
exc_copy.with_traceback(self.error.__traceback__)
49+
return exc_copy
50+
51+
2352
class GraphHydrator:
2453
def __init__(self):
2554
self.graph = Graph()
2655
self.struct_hydration_functions = {}
2756

2857

2958
class HydrationScope:
30-
3159
def __init__(self, hydration_handler, graph_hydrator):
3260
self._hydration_handler = hydration_handler
3361
self._graph_hydrator = graph_hydrator
@@ -37,14 +65,35 @@ def __init__(self, hydration_handler, graph_hydrator):
3765
}
3866
self.hydration_hooks = {
3967
Structure: self._hydrate_structure,
68+
list: self._hydrate_list,
69+
dict: self._hydrate_dict,
4070
}
4171
self.dehydration_hooks = hydration_handler.dehydration_functions
4272

4373
def _hydrate_structure(self, value):
4474
f = self._struct_hydration_functions.get(value.tag)
45-
if not f:
46-
return value
47-
return f(*value.fields)
75+
try:
76+
if not f:
77+
raise ValueError(
78+
f"Protocol error: unknown Structure tag: {value.tag!r}"
79+
)
80+
return f(*value.fields)
81+
except Exception as e:
82+
return BrokenHydrationObject(e, value)
83+
84+
@staticmethod
85+
def _hydrate_list(value):
86+
for v in value:
87+
if isinstance(v, BrokenHydrationObject):
88+
return BrokenHydrationObject(v.error, value)
89+
return value
90+
91+
@staticmethod
92+
def _hydrate_dict(value):
93+
for v in value.values():
94+
if isinstance(v, BrokenHydrationObject):
95+
return BrokenHydrationObject(v.error, value)
96+
return value
4897

4998
def get_graph(self):
5099
return self._graph_hydrator.graph

neo4j/_data.py

+35-6
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@
2828
from functools import reduce
2929
from operator import xor as xor_operator
3030

31+
from ._codec.hydration import BrokenHydrationObject
3132
from ._conf import iter_items
33+
from ._meta import deprecated
34+
from .exceptions import BrokenRecordError
3235
from .graph import (
3336
Node,
3437
Path,
@@ -55,9 +58,26 @@ def __new__(cls, iterable=()):
5558
inst.__keys = tuple(keys)
5659
return inst
5760

61+
def _broken_record_error(self, index):
62+
return BrokenRecordError(
63+
f"Record contains broken data at {index} ('{self.__keys[index]}')"
64+
)
65+
66+
def _super_getitem_single(self, index):
67+
value = super().__getitem__(index)
68+
if isinstance(value, BrokenHydrationObject):
69+
raise self._broken_record_error(index) from value.error
70+
return value
71+
5872
def __repr__(self):
59-
return "<%s %s>" % (self.__class__.__name__,
60-
" ".join("%s=%r" % (field, self[i]) for i, field in enumerate(self.__keys)))
73+
return "<%s %s>" % (
74+
self.__class__.__name__,
75+
" ".join("%s=%r" % (field, value)
76+
for field, value in zip(self.__keys, super().__iter__()))
77+
)
78+
79+
def __str__(self):
80+
return self.__repr__()
6181

6282
def __eq__(self, other):
6383
""" In order to be flexible regarding comparison, the equality rules
@@ -83,18 +103,26 @@ def __ne__(self, other):
83103
def __hash__(self):
84104
return reduce(xor_operator, map(hash, self.items()))
85105

106+
def __iter__(self):
107+
for i, v in enumerate(super().__iter__()):
108+
if isinstance(v, BrokenHydrationObject):
109+
raise self._broken_record_error(i) from v.error
110+
yield v
111+
86112
def __getitem__(self, key):
87113
if isinstance(key, slice):
88114
keys = self.__keys[key]
89-
values = super(Record, self).__getitem__(key)
115+
values = super().__getitem__(key)
90116
return self.__class__(zip(keys, values))
91117
try:
92118
index = self.index(key)
93119
except IndexError:
94120
return None
95121
else:
96-
return super(Record, self).__getitem__(index)
122+
return self._super_getitem_single(index)
97123

124+
# TODO: 6.0 - remove
125+
@deprecated("This method is deprecated and will be removed in the future.")
98126
def __getslice__(self, start, stop):
99127
key = slice(start, stop)
100128
keys = self.__keys[key]
@@ -114,7 +142,7 @@ def get(self, key, default=None):
114142
except ValueError:
115143
return default
116144
if 0 <= index < len(self):
117-
return super(Record, self).__getitem__(index)
145+
return self._super_getitem_single(index)
118146
else:
119147
return default
120148

@@ -197,7 +225,8 @@ def items(self, *keys):
197225
else:
198226
d.append((self.__keys[i], self[i]))
199227
return d
200-
return list((self.__keys[i], super(Record, self).__getitem__(i)) for i in range(len(self)))
228+
return list((self.__keys[i], self._super_getitem_single(i))
229+
for i in range(len(self)))
201230

202231
def data(self, *keys):
203232
""" Return the keys and values of this record as a dictionary,

neo4j/_sync/work/result.py

+6
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from warnings import warn
2121

2222
from ..._async_compat.util import Util
23+
from ..._codec.hydration import BrokenHydrationObject
2324
from ..._data import (
2425
Record,
2526
RecordTableRowExporter,
@@ -145,6 +146,11 @@ def on_failed_attach(metadata):
145146
def _pull(self):
146147
def on_records(records):
147148
if not self._discarding:
149+
records = (
150+
record.raw_data
151+
if isinstance(record, BrokenHydrationObject) else record
152+
for record in records
153+
)
148154
self._record_buffer.extend((
149155
Record(zip(self._keys, record))
150156
for record in records

neo4j/exceptions.py

+8
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,14 @@ def is_retryable(self):
396396
return True
397397

398398

399+
class BrokenRecordError(DriverError):
400+
""" Raised when accessing a Record's field that couldn't be decoded.
401+
402+
This can for instance happen when the server sends a zoned datetime with a
403+
zone id unknown to the client.
404+
"""
405+
406+
399407
class RoutingServiceUnavailable(ServiceUnavailable):
400408
""" Raised when no routing service is available.
401409
"""

tests/unit/async_/work/test_result.py

+35-21
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@
4141
Node,
4242
Relationship,
4343
)
44-
from neo4j.exceptions import ResultNotSingleError
44+
from neo4j.exceptions import (
45+
BrokenRecordError,
46+
ResultNotSingleError,
47+
)
4548
from neo4j.graph import (
4649
EntitySetView,
4750
Graph,
@@ -55,18 +58,18 @@ def __init__(self, fields, records):
5558
self.fields = tuple(fields)
5659
self.hydration_scope = HydrationHandler().new_hydration_scope()
5760
self.records = tuple(records)
58-
self._hydrate_records()
59-
6061
assert all(len(self.fields) == len(r) for r in self.records)
6162

63+
self._hydrate_records()
64+
6265
def _hydrate_records(self):
6366
def _hydrate(value):
67+
if isinstance(value, (list, tuple)):
68+
value = type(value)(_hydrate(v) for v in value)
69+
elif isinstance(value, dict):
70+
value = {k: _hydrate(v) for k, v in value.items()}
6471
if type(value) in self.hydration_scope.hydration_hooks:
6572
return self.hydration_scope.hydration_hooks[type(value)](value)
66-
if isinstance(value, (list, tuple)):
67-
return type(value)(_hydrate(v) for v in value)
68-
if isinstance(value, dict):
69-
return {k: _hydrate(v) for k, v in value.items()}
7073
return value
7174

7275
self.records = tuple(_hydrate(r) for r in self.records)
@@ -605,7 +608,6 @@ async def test_data(num_records):
605608
assert record.data.called_once_with("hello", "world")
606609

607610

608-
# TODO: dehydration now happens on a much lower level
609611
@pytest.mark.parametrize("records", (
610612
Records(["n"], []),
611613
Records(["n"], [[42], [69], [420], [1337]]),
@@ -621,19 +623,9 @@ async def test_data(num_records):
621623
]),
622624
))
623625
@mark_async_test
624-
async def test_result_graph(records, async_scripted_connection):
625-
async_scripted_connection.set_script((
626-
("run", {"on_success": ({"fields": records.fields},),
627-
"on_summary": None}),
628-
("pull", {
629-
"on_records": (records.records,),
630-
"on_success": None,
631-
"on_summary": None
632-
}),
633-
))
634-
async_scripted_connection.new_hydration_scope.return_value = \
635-
records.hydration_scope
636-
result = AsyncResult(async_scripted_connection, 1, noop, noop)
626+
async def test_result_graph(records):
627+
connection = AsyncConnectionStub(records=records)
628+
result = AsyncResult(connection, 1, noop, noop)
637629
await result._run("CYPHER", {}, None, None, "r", None)
638630
graph = await result.graph()
639631
assert isinstance(graph, Graph)
@@ -1095,3 +1087,25 @@ async def test_to_df_parse_dates(keys, values, expected_df, expand):
10951087
df = await result.to_df(expand=expand, parse_dates=True)
10961088

10971089
pd.testing.assert_frame_equal(df, expected_df)
1090+
1091+
1092+
@pytest.mark.parametrize("nested", [True, False])
1093+
@mark_async_test
1094+
async def test_broken_hydration(nested):
1095+
value_in = Structure(b"a", "broken")
1096+
if nested:
1097+
value_in = [value_in]
1098+
records_in = Records(["foo", "bar"], [["foobar", value_in]])
1099+
connection = AsyncConnectionStub(records=records_in)
1100+
result = AsyncResult(connection, 1, noop, noop)
1101+
await result._run("CYPHER", {}, None, None, "r", None)
1102+
records_out = await AsyncUtil.list(result)
1103+
assert len(records_out) == 1
1104+
record_out = records_out[0]
1105+
assert len(record_out) == 2
1106+
assert record_out[0] == "foobar"
1107+
with pytest.raises(BrokenRecordError) as exc:
1108+
record_out[1]
1109+
cause = exc.value.__cause__
1110+
assert isinstance(cause, ValueError)
1111+
assert repr(b"a") in str(cause)

0 commit comments

Comments
 (0)