Skip to content

Commit dcb337c

Browse files
authored
Fix Duration export in Result.data and Record.data (#1041)
1 parent 5da08ae commit dcb337c

File tree

3 files changed

+232
-25
lines changed

3 files changed

+232
-25
lines changed

neo4j/data.py

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -206,16 +206,55 @@ def items(self, *keys):
206206
return list((self.__keys[i], super(Record, self).__getitem__(i)) for i in range(len(self)))
207207

208208
def data(self, *keys):
209-
""" Return the keys and values of this record as a dictionary,
210-
optionally including only certain values by index or key. Keys
211-
provided in the items that are not in the record will be
212-
inserted with a value of :const:`None`; indexes provided
213-
that are out of bounds will trigger an :exc:`IndexError`.
209+
"""Return the record as a dictionary.
210+
211+
Return the keys and values of this record as a dictionary, optionally
212+
including only certain values by index or key.
213+
Keys provided in the items that are not in the record will be inserted
214+
with a value of :data:`None`; indexes provided that are out of bounds
215+
will trigger an :exc:`IndexError`.
216+
217+
This function provides a convenient but opinionated way to transform
218+
the record into a mostly JSON serializable format. It is mainly useful
219+
for interactive sessions and rapid prototyping.
220+
221+
The transformation works as follows:
222+
223+
* Nodes are transformed into dictionaries of their
224+
properties.
225+
226+
* No indication of their original type remains.
227+
* Not all information is serialized (e.g., labels and element_id are
228+
absent).
229+
230+
* Relationships are transformed to a tuple of
231+
``(start_node, type, end_node)``, where the nodes are transformed
232+
as described above, and type is the relationship type name
233+
(:class:`str`).
234+
235+
* No indication of their original type remains.
236+
* No other information (properties, element_id, start_node,
237+
end_node, ...) is serialized.
238+
239+
* Paths are transformed into lists of nodes and relationships. No
240+
indication of the original type remains.
241+
* :class:`list` and :class:`dict` values are recursively transformed.
242+
* Every other type remains unchanged.
243+
244+
* Spatial types and durations inherit from :class:`tuple`. Hence,
245+
they are JSON serializable, but, like graph types, type
246+
information will be lost in the process.
247+
* The remaining temporal types are not JSON serializable.
248+
249+
You will have to implement a custom serializer should you need more
250+
control over the output format.
251+
252+
:param keys: Indexes or keys of the items to include. If none are
253+
provided, all values will be included.
214254
215-
:param keys: indexes or keys of the items to include; if none
216-
are provided, all values will be included
217255
:return: dictionary of values, keyed by field name
218-
:raises: :exc:`IndexError` if an out-of-bounds index is specified
256+
257+
:raises: :exc:`IndexError` if an out-of-bounds index is specified.
219258
"""
220259
return RecordExporter().transform(dict(self.items(*keys)))
221260

@@ -251,7 +290,7 @@ def transform(self, x):
251290
path.append(self.transform(relationship.__class__.__name__))
252291
path.append(self.transform(x.nodes[i + 1]))
253292
return path
254-
elif isinstance(x, str):
293+
elif isinstance(x, (str, Point, Date, Time, DateTime, Duration)):
255294
return x
256295
elif isinstance(x, Sequence):
257296
t = type(x)

neo4j/work/result.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -366,11 +366,17 @@ def values(self, *keys):
366366
return [record.values(*keys) for record in self]
367367

368368
def data(self, *keys):
369-
"""Helper function that return the remainder of the result as a list of dictionaries.
369+
"""Return the remainder of the result as a list of dictionaries.
370370
371-
See :class:`neo4j.Record.data`
371+
Each dictionary represents a record.
372+
373+
For details see :meth:`.Record.data`.
374+
375+
:param keys: Fields to return for each remaining record.
376+
377+
Optionally filtering to include only certain values by index or
378+
key.
372379
373-
:param keys: fields to return for each remaining record. Optionally filtering to include only certain values by index or key.
374380
:returns: list of dictionaries
375381
:rtype: list
376382
"""

tests/unit/test_record.py

Lines changed: 175 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,24 @@
2020

2121

2222
import pytest
23+
import pytz
2324

2425
from neo4j.data import (
2526
Graph,
2627
Node,
28+
Path,
2729
Record,
30+
Relationship,
31+
)
32+
from neo4j.spatial import (
33+
CartesianPoint,
34+
WGS84Point,
35+
)
36+
from neo4j.time import (
37+
Date,
38+
DateTime,
39+
Duration,
40+
Time,
2841
)
2942

3043
# python -m pytest -s -v tests/unit/test_record.py
@@ -73,17 +86,163 @@ def test_record_repr():
7386
assert repr(a_record) == "<Record name='Nigel' empire='The British Empire'>"
7487

7588

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

88247

89248
def test_record_keys():
@@ -212,13 +371,13 @@ def test_record_get_item():
212371

213372

214373
@pytest.mark.parametrize("len_", (0, 1, 2, 42))
215-
def test_record_len(len_):
374+
def test_record_len_generic(len_):
216375
r = Record(("key_%i" % i, "val_%i" % i) for i in range(len_))
217376
assert len(r) == len_
218377

219378

220379
@pytest.mark.parametrize("len_", range(3))
221-
def test_record_repr(len_):
380+
def test_record_repr_generic(len_):
222381
r = Record(("key_%i" % i, "val_%i" % i) for i in range(len_))
223382
assert repr(r)
224383

@@ -275,7 +434,10 @@ def test_record_repr(len_):
275434
{"x": {"one": 1, "two": 2}}
276435
),
277436
(
278-
zip(["a"], [Node("graph", 42, "Person", {"name": "Alice"})]),
437+
zip(
438+
["a"],
439+
[Node(None, 42, "Person", {"name": "Alice"})]
440+
),
279441
(),
280442
{"a": {"name": "Alice"}}
281443
),

0 commit comments

Comments
 (0)