Skip to content

Commit 13e180f

Browse files
Fix Duration export in Result.data and Record.data (#1000)
Signed-off-by: Rouven Bauer <[email protected]> Co-authored-by: Grant Lodge <[email protected]>
1 parent de7bfd2 commit 13e180f

File tree

4 files changed

+239
-28
lines changed

4 files changed

+239
-28
lines changed

src/neo4j/_async/work/result.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -619,15 +619,17 @@ async def values(
619619
async def data(self, *keys: _TResultKey) -> t.List[t.Dict[str, t.Any]]:
620620
"""Return the remainder of the result as a list of dictionaries.
621621
622+
Each dictionary represents a record
623+
622624
This function provides a convenient but opinionated way to obtain the
623625
remainder of the result as mostly JSON serializable data. It is mainly
624626
useful for interactive sessions and rapid prototyping.
625627
626-
For instance, node and relationship labels are not included. You will
627-
have to implement a custom serializer should you need more control over
628-
the output format.
628+
For details see :meth:`.Record.data`.
629629
630-
:param keys: fields to return for each remaining record. Optionally filtering to include only certain values by index or key.
630+
:param keys: Fields to return for each remaining record.
631+
Optionally filtering to include only certain values by index or
632+
key.
631633
632634
:returns: list of dictionaries
633635

src/neo4j/_data.py

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,19 @@
3232
from ._codec.hydration import BrokenHydrationObject
3333
from ._conf import iter_items
3434
from ._meta import deprecated
35+
from ._spatial import Point
3536
from .exceptions import BrokenRecordError
3637
from .graph import (
3738
Node,
3839
Path,
3940
Relationship,
4041
)
42+
from .time import (
43+
Date,
44+
DateTime,
45+
Duration,
46+
Time,
47+
)
4148

4249

4350
_T = t.TypeVar("_T")
@@ -241,18 +248,55 @@ def items(self, *keys):
241248
for i in range(len(self)))
242249

243250
def data(self, *keys: _K) -> t.Dict[str, t.Any]:
244-
""" Return the keys and values of this record as a dictionary,
245-
optionally including only certain values by index or key. Keys
246-
provided in the items that are not in the record will be
247-
inserted with a value of :data:`None`; indexes provided
248-
that are out of bounds will trigger an :exc:`IndexError`.
251+
"""Return the record as a dictionary.
249252
250-
:param keys: indexes or keys of the items to include; if none
251-
are provided, all values will be included
253+
Return the keys and values of this record as a dictionary, optionally
254+
including only certain values by index or key.
255+
Keys provided in the items that are not in the record will be inserted
256+
with a value of :data:`None`; indexes provided that are out of bounds
257+
will trigger an :exc:`IndexError`.
258+
259+
This function provides a convenient but opinionated way to transform
260+
the record into a mostly JSON serializable format. It is mainly useful
261+
for interactive sessions and rapid prototyping.
262+
263+
The transformation works as follows:
264+
265+
* Nodes are transformed into dictionaries of their
266+
properties.
267+
268+
* No indication of their original type remains.
269+
* Not all information is serialized (e.g., labels and element_id are
270+
absent).
271+
272+
* Relationships are transformed to a tuple of
273+
``(start_node, type, end_node)``, where the nodes are transformed
274+
as described above, and type is the relationship type name
275+
(:class:`str`).
276+
277+
* No indication of their original type remains.
278+
* No other information (properties, element_id, start_node,
279+
end_node, ...) is serialized.
280+
281+
* Paths are transformed into lists of nodes and relationships. No
282+
indication of the original type remains.
283+
* :class:`list` and :class:`dict` values are recursively transformed.
284+
* Every other type remains unchanged.
285+
286+
* Spatial types and durations inherit from :class:`tuple`. Hence,
287+
they are JSON serializable, but, like graph types, type
288+
information will be lost in the process.
289+
* The remaining temporal types are not JSON serializable.
290+
291+
You will have to implement a custom serializer should you need more
292+
control over the output format.
293+
294+
:param keys: Indexes or keys of the items to include. If none are
295+
provided, all values will be included.
252296
253297
:returns: dictionary of values, keyed by field name
254298
255-
:raises: :exc:`IndexError` if an out-of-bounds index is specified
299+
:raises: :exc:`IndexError` if an out-of-bounds index is specified.
256300
"""
257301
return RecordExporter().transform(dict(self.items(*keys)))
258302

@@ -288,7 +332,7 @@ def transform(self, x):
288332
path.append(self.transform(relationship.__class__.__name__))
289333
path.append(self.transform(x.nodes[i + 1]))
290334
return path
291-
elif isinstance(x, str):
335+
elif isinstance(x, (str, Point, Date, Time, DateTime, Duration)):
292336
return x
293337
elif isinstance(x, Sequence):
294338
typ = type(x)

src/neo4j/_sync/work/result.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -619,15 +619,17 @@ def values(
619619
def data(self, *keys: _TResultKey) -> t.List[t.Dict[str, t.Any]]:
620620
"""Return the remainder of the result as a list of dictionaries.
621621
622+
Each dictionary represents a record
623+
622624
This function provides a convenient but opinionated way to obtain the
623625
remainder of the result as mostly JSON serializable data. It is mainly
624626
useful for interactive sessions and rapid prototyping.
625627
626-
For instance, node and relationship labels are not included. You will
627-
have to implement a custom serializer should you need more control over
628-
the output format.
628+
For details see :meth:`.Record.data`.
629629
630-
:param keys: fields to return for each remaining record. Optionally filtering to include only certain values by index or key.
630+
:param keys: Fields to return for each remaining record.
631+
Optionally filtering to include only certain values by index or
632+
key.
631633
632634
:returns: list of dictionaries
633635

tests/unit/common/test_record.py

Lines changed: 174 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,28 @@
1919
import traceback
2020

2121
import pytest
22+
import pytz
2223

2324
from neo4j import Record
2425
from neo4j._codec.hydration import BrokenHydrationObject
2526
from neo4j._codec.hydration.v1 import HydrationHandler
2627
from neo4j.exceptions import BrokenRecordError
27-
from neo4j.graph import Node
28+
from neo4j.graph import (
29+
Graph,
30+
Node,
31+
Path,
32+
Relationship,
33+
)
34+
from neo4j.spatial import (
35+
CartesianPoint,
36+
WGS84Point,
37+
)
38+
from neo4j.time import (
39+
Date,
40+
DateTime,
41+
Duration,
42+
Time,
43+
)
2844

2945

3046
# python -m pytest -s -v tests/unit/test_record.py
@@ -73,17 +89,164 @@ def test_record_repr() -> None:
7389
assert repr(a_record) == "<Record name='Nigel' empire='The British Empire'>"
7490

7591

76-
def test_record_data() -> None:
77-
r = Record(zip(["name", "age", "married"], ["Alice", 33, True]))
78-
assert r.data() == {"name": "Alice", "age": 33, "married": True}
79-
assert r.data("name") == {"name": "Alice"}
80-
assert r.data("age", "name") == {"age": 33, "name": "Alice"}
81-
assert r.data("age", "name", "shoe size") == {"age": 33, "name": "Alice", "shoe size": None}
82-
assert r.data(0, "name") == {"name": "Alice"}
83-
assert r.data(0) == {"name": "Alice"}
84-
assert r.data(1, 0) == {"age": 33, "name": "Alice"}
92+
_RECORD_DATA_ALICE_KEYS = ["name", "age", "married"]
93+
_RECORD_DATA_ALICE_VALUES = ["Alice", 33, True]
94+
_RECORD_DATA_GRAPH = Graph()
95+
_RECORD_DATA_REL_KNOWS = _RECORD_DATA_GRAPH.relationship_type("KNOWS")
96+
_RECORD_DATA_REL_FOLLOWS = _RECORD_DATA_GRAPH.relationship_type("FOLLOWS")
97+
98+
99+
def _record_data_alice_know_bob() -> Relationship:
100+
alice = Node(_RECORD_DATA_GRAPH, "1", 1, ["Person"], {"name": "Alice"})
101+
bob = Node(_RECORD_DATA_GRAPH, "2", 2, ["Person"], {"name": "Bob"})
102+
alice_knows_bob = _RECORD_DATA_REL_KNOWS(_RECORD_DATA_GRAPH, "4", 4,
103+
{"proper": "tea"})
104+
alice_knows_bob._start_node = alice
105+
alice_knows_bob._end_node = bob
106+
return alice_knows_bob
107+
108+
109+
def _record_data_make_path() -> Path:
110+
alice_knows_bob = _record_data_alice_know_bob()
111+
alice = alice_knows_bob.start_node
112+
assert alice is not None
113+
bob = alice_knows_bob.end_node
114+
carlos = Node(_RECORD_DATA_GRAPH, "3", 3, ["Person"], {"name": "Carlos"})
115+
carlos_follows_bob = _RECORD_DATA_REL_FOLLOWS(_RECORD_DATA_GRAPH, "5", 5,
116+
{"proper": "tea"})
117+
carlos_follows_bob._start_node = carlos
118+
carlos_follows_bob._end_node = bob
119+
return Path(alice, alice_knows_bob, carlos_follows_bob)
120+
121+
122+
@pytest.mark.parametrize(
123+
("keys", "expected"),
124+
(
125+
(
126+
(),
127+
{"name": "Alice", "age": 33, "married": True},
128+
),
129+
(
130+
("name",),
131+
{"name": "Alice"},
132+
),
133+
(
134+
("age", "name"),
135+
{"age": 33, "name": "Alice"},
136+
),
137+
(
138+
("age", "name", "shoe size"),
139+
{"age": 33, "name": "Alice", "shoe size": None},
140+
),
141+
(
142+
("age", "name", "shoe size"),
143+
{"age": 33, "name": "Alice", "shoe size": None},
144+
),
145+
(
146+
("age", "name", "shoe size"),
147+
{"age": 33, "name": "Alice", "shoe size": None},
148+
),
149+
(
150+
(0, "name"),
151+
{"name": "Alice"},
152+
),
153+
(
154+
(0,),
155+
{"name": "Alice"},
156+
),
157+
(
158+
(1, 0),
159+
{"age": 33, "name": "Alice"},
160+
),
161+
),
162+
)
163+
def test_record_data_keys(keys, expected) -> None:
164+
record = Record(zip(_RECORD_DATA_ALICE_KEYS, _RECORD_DATA_ALICE_VALUES))
165+
assert record.data(*keys) == expected
166+
167+
168+
@pytest.mark.parametrize(
169+
("value", "expected"),
170+
(
171+
*(
172+
(value, value)
173+
for value in (
174+
None,
175+
True,
176+
False,
177+
0,
178+
1,
179+
-1,
180+
2147483647,
181+
-2147483648,
182+
3.141592653589,
183+
"",
184+
"Hello, world!",
185+
"👋, 🌍!",
186+
[],
187+
[1, 2.0, "3", True, None],
188+
{"foo": ["bar", 1]},
189+
b"",
190+
b"foobar",
191+
Date(2021, 1, 1),
192+
Time(12, 34, 56, 123456789),
193+
Time(1, 2, 3, 4, pytz.FixedOffset(60)),
194+
DateTime(2021, 1, 1, 12, 34, 56, 123456789),
195+
DateTime(2018, 10, 12, 11, 37, 41, 474716862,
196+
pytz.FixedOffset(60)),
197+
pytz.timezone("Europe/Stockholm").localize(
198+
DateTime(2018, 10, 12, 11, 37, 41, 474716862)
199+
),
200+
Duration(1, 2, 3, 4, 5, 6, 7),
201+
CartesianPoint((1, 2.0)),
202+
CartesianPoint((1, 2.0, 3)),
203+
WGS84Point((1, 2.0)),
204+
WGS84Point((1, 2.0, 3)),
205+
)
206+
),
207+
*(
208+
(value, expected)
209+
for value, expected in (
210+
(
211+
Node(_RECORD_DATA_GRAPH, "1", 1, ["Person"],
212+
{"name": "Alice"}),
213+
{"name": "Alice"},
214+
),
215+
(
216+
_record_data_alice_know_bob(),
217+
(
218+
{"name": "Alice"},
219+
"KNOWS",
220+
{"name": "Bob"},
221+
)
222+
),
223+
(
224+
_record_data_make_path(),
225+
[
226+
{"name": "Alice"},
227+
"KNOWS",
228+
{"name": "Bob"},
229+
"FOLLOWS",
230+
{"name": "Carlos"},
231+
]
232+
),
233+
)
234+
),
235+
)
236+
)
237+
@pytest.mark.parametrize("wrapper", (None, lambda x: [x], lambda x: {"x": x}))
238+
def test_record_data_types(value, expected, wrapper) -> None:
239+
if wrapper is not None:
240+
value = wrapper(value)
241+
expected = wrapper(expected)
242+
record = Record([("key", value)])
243+
assert record.data("key") == {"key": expected}
244+
245+
246+
def test_record_index_error() -> None:
247+
record = Record(zip(_RECORD_DATA_ALICE_KEYS, _RECORD_DATA_ALICE_VALUES))
85248
with pytest.raises(IndexError):
86-
_ = r.data(1, 0, 999)
249+
record.data(1, 0, 999)
87250

88251

89252
def test_record_keys() -> None:

0 commit comments

Comments
 (0)