From 17a22c96d6f7842ec7ffe35c5bad5191494286d2 Mon Sep 17 00:00:00 2001 From: Robsdedude Date: Wed, 6 Dec 2023 12:44:29 +0100 Subject: [PATCH 1/2] Fix Duration export in `Result.data` and `Record.data` --- src/neo4j/_async/work/result.py | 10 +- src/neo4j/_data.py | 62 +++++++++-- src/neo4j/_sync/work/result.py | 10 +- tests/unit/common/test_record.py | 178 +++++++++++++++++++++++++++++-- 4 files changed, 232 insertions(+), 28 deletions(-) diff --git a/src/neo4j/_async/work/result.py b/src/neo4j/_async/work/result.py index 6f1d8d2d2..704bb2bd6 100644 --- a/src/neo4j/_async/work/result.py +++ b/src/neo4j/_async/work/result.py @@ -619,15 +619,17 @@ async def values( async def data(self, *keys: _TResultKey) -> t.List[t.Dict[str, t.Any]]: """Return the remainder of the result as a list of dictionaries. + Each dictionary represents a record + This function provides a convenient but opinionated way to obtain the remainder of the result as mostly JSON serializable data. It is mainly useful for interactive sessions and rapid prototyping. - For instance, node and relationship labels are not included. You will - have to implement a custom serializer should you need more control over - the output format. + For details see :meth:`.Record.data`. - :param keys: fields to return for each remaining record. Optionally filtering to include only certain values by index or key. + :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/src/neo4j/_data.py b/src/neo4j/_data.py index c488a5ac7..a43238231 100644 --- a/src/neo4j/_data.py +++ b/src/neo4j/_data.py @@ -32,12 +32,19 @@ from ._codec.hydration import BrokenHydrationObject from ._conf import iter_items from ._meta import deprecated +from ._spatial import Point from .exceptions import BrokenRecordError from .graph import ( Node, Path, Relationship, ) +from .time import ( + Date, + DateTime, + Duration, + Time, +) _T = t.TypeVar("_T") @@ -241,18 +248,55 @@ def items(self, *keys): for i in range(len(self))) def data(self, *keys: _K) -> t.Dict[str, t.Any]: - """ Return the keys and values of this record as a dictionary, - optionally including only certain values by index or key. Keys - provided in the items that are not in the record will be - inserted with a value of :data:`None`; indexes provided - that are out of bounds will trigger an :exc:`IndexError`. + """Return the record as a dictionary. - :param keys: indexes or keys of the items to include; if none - are provided, all values will be included + Return the keys and values of this record as a dictionary, optionally + including only certain values by index or key. + Keys provided in the items that are not in the record will be inserted + with a value of :data:`None`; indexes provided that are out of bounds + will trigger an :exc:`IndexError`. + + This function provides a convenient but opinionated way to transform + the record into a mostly JSON serializable format. It is mainly useful + for interactive sessions and rapid prototyping. + + The transformation works as follows: + + * Nodes are transformed into dictionaries of their + properties. + + * No indication of their original type remains. + * Not all information is serialized (e.g., labels and element_id are + absent). + + * Relationships are transformed to a tuple of + ``(start_node, type, end_node)``, where the nodes are transformed + as described above, and type is the relationship type name + (:class:`str`). + + * No indication of their original type remains. + * No other information (properties, element_id, start_node, + end_node, ...) is serialized. + + * Paths are transformed into lists of nodes and relationships. No + indication of the original type remains. + * :class:`list` and :class:`dict` values are recursively transformed. + * Every other type remains unchanged. + + * Spatial types and durations inherit from :class:`tuple`. Hence, + they are JSON serializable, but, like graph types, type + information will be lost in the process. + * The remaining temporal types are not JSON serializable. + + You will have to implement a custom serializer should you need more + control over the output format. + + :param keys: Indexes or keys of the items to include. If none are + provided, all values will be included. :returns: dictionary of values, keyed by field name - :raises: :exc:`IndexError` if an out-of-bounds index is specified + :raises: :exc:`IndexError` if an out-of-bounds index is specified. """ return RecordExporter().transform(dict(self.items(*keys))) @@ -288,7 +332,7 @@ def transform(self, x): path.append(self.transform(relationship.__class__.__name__)) path.append(self.transform(x.nodes[i + 1])) return path - elif isinstance(x, str): + elif isinstance(x, (str, Point, Date, Time, DateTime, Duration)): return x elif isinstance(x, Sequence): typ = type(x) diff --git a/src/neo4j/_sync/work/result.py b/src/neo4j/_sync/work/result.py index 58fc704bd..632cd8c17 100644 --- a/src/neo4j/_sync/work/result.py +++ b/src/neo4j/_sync/work/result.py @@ -619,15 +619,17 @@ def values( def data(self, *keys: _TResultKey) -> t.List[t.Dict[str, t.Any]]: """Return the remainder of the result as a list of dictionaries. + Each dictionary represents a record + This function provides a convenient but opinionated way to obtain the remainder of the result as mostly JSON serializable data. It is mainly useful for interactive sessions and rapid prototyping. - For instance, node and relationship labels are not included. You will - have to implement a custom serializer should you need more control over - the output format. + For details see :meth:`.Record.data`. - :param keys: fields to return for each remaining record. Optionally filtering to include only certain values by index or key. + :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/tests/unit/common/test_record.py b/tests/unit/common/test_record.py index 973a58a20..0ef780332 100644 --- a/tests/unit/common/test_record.py +++ b/tests/unit/common/test_record.py @@ -24,7 +24,22 @@ from neo4j._codec.hydration import BrokenHydrationObject from neo4j._codec.hydration.v1 import HydrationHandler from neo4j.exceptions import BrokenRecordError -from neo4j.graph import Node +from neo4j.graph import ( + Graph, + Node, + Path, + Relationship, +) +from neo4j.spatial import ( + CartesianPoint, + WGS84Point, +) +from neo4j.time import ( + Date, + DateTime, + Duration, + Time, +) # python -m pytest -s -v tests/unit/test_record.py @@ -73,17 +88,158 @@ def test_record_repr() -> None: assert repr(a_record) == "" -def test_record_data() -> None: - r = Record(zip(["name", "age", "married"], ["Alice", 33, True])) - assert r.data() == {"name": "Alice", "age": 33, "married": True} - assert r.data("name") == {"name": "Alice"} - assert r.data("age", "name") == {"age": 33, "name": "Alice"} - assert r.data("age", "name", "shoe size") == {"age": 33, "name": "Alice", "shoe size": None} - assert r.data(0, "name") == {"name": "Alice"} - assert r.data(0) == {"name": "Alice"} - assert r.data(1, 0) == {"age": 33, "name": "Alice"} +_RECORD_DATA_ALICE_KEYS = ["name", "age", "married"] +_RECORD_DATA_ALICE_VALUES = ["Alice", 33, True] +_RECORD_DATA_GRAPH = Graph() +_RECORD_DATA_REL_KNOWS = _RECORD_DATA_GRAPH.relationship_type("KNOWS") +_RECORD_DATA_REL_FOLLOWS = _RECORD_DATA_GRAPH.relationship_type("FOLLOWS") + + +def _record_data_alice_know_bob() -> Relationship: + alice = Node(_RECORD_DATA_GRAPH, "1", 1, ["Person"], {"name": "Alice"}) + bob = Node(_RECORD_DATA_GRAPH, "2", 2, ["Person"], {"name": "Bob"}) + alice_knows_bob = _RECORD_DATA_REL_KNOWS(_RECORD_DATA_GRAPH, "4", 4, + {"proper": "tea"}) + alice_knows_bob._start_node = alice + alice_knows_bob._end_node = bob + return alice_knows_bob + + +def _record_data_make_path() -> Path: + alice_knows_bob = _record_data_alice_know_bob() + alice = alice_knows_bob.start_node + assert alice is not None + bob = alice_knows_bob.end_node + carlos = Node(_RECORD_DATA_GRAPH, "3", 3, ["Person"], {"name": "Carlos"}) + carlos_follows_bob = _RECORD_DATA_REL_FOLLOWS(_RECORD_DATA_GRAPH, "5", 5, + {"proper": "tea"}) + carlos_follows_bob._start_node = carlos + carlos_follows_bob._end_node = bob + return Path(alice, alice_knows_bob, carlos_follows_bob) + + +@pytest.mark.parametrize( + ("keys", "expected"), + ( + ( + (), + {"name": "Alice", "age": 33, "married": True}, + ), + ( + ("name",), + {"name": "Alice"}, + ), + ( + ("age", "name"), + {"age": 33, "name": "Alice"}, + ), + ( + ("age", "name", "shoe size"), + {"age": 33, "name": "Alice", "shoe size": None}, + ), + ( + ("age", "name", "shoe size"), + {"age": 33, "name": "Alice", "shoe size": None}, + ), + ( + ("age", "name", "shoe size"), + {"age": 33, "name": "Alice", "shoe size": None}, + ), + ( + (0, "name"), + {"name": "Alice"}, + ), + ( + (0,), + {"name": "Alice"}, + ), + ( + (1, 0), + {"age": 33, "name": "Alice"}, + ), + ), +) +def test_record_data_keys(keys, expected) -> None: + record = Record(zip(_RECORD_DATA_ALICE_KEYS, _RECORD_DATA_ALICE_VALUES)) + assert record.data(*keys) == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + ( + *( + (value, value) + for value in ( + None, + True, + False, + 0, + 1, + -1, + 2147483647, + -2147483648, + 3.141592653589, + "", + "Hello, world!", + "👋, 🌍!", + [], + [1, 2.0, "3", True, None], + {"foo": ["bar", 1]}, + b"", + b"foobar", + Date(2021, 1, 1), + Time(12, 34, 56, 123456789), + DateTime(2021, 1, 1, 12, 34, 56, 123456789), + Duration(1, 2, 3, 4, 5, 6, 7), + CartesianPoint((1, 2.0)), + CartesianPoint((1, 2.0, 3)), + WGS84Point((1, 2.0)), + WGS84Point((1, 2.0, 3)), + ) + ), + *( + (value, expected) + for value, expected in ( + ( + Node(_RECORD_DATA_GRAPH, "1", 1, ["Person"], + {"name": "Alice"}), + {"name": "Alice"}, + ), + ( + _record_data_alice_know_bob(), + ( + {"name": "Alice"}, + "KNOWS", + {"name": "Bob"}, + ) + ), + ( + _record_data_make_path(), + [ + {"name": "Alice"}, + "KNOWS", + {"name": "Bob"}, + "FOLLOWS", + {"name": "Carlos"}, + ] + ), + ) + ), + ) +) +@pytest.mark.parametrize("wrapper", (None, lambda x: [x], lambda x: {"x": x})) +def test_record_data_types(value, expected, wrapper) -> None: + if wrapper is not None: + value = wrapper(value) + expected = wrapper(expected) + record = Record([("key", value)]) + assert record.data("key") == {"key": expected} + + +def test_record_index_error() -> None: + record = Record(zip(_RECORD_DATA_ALICE_KEYS, _RECORD_DATA_ALICE_VALUES)) with pytest.raises(IndexError): - _ = r.data(1, 0, 999) + record.data(1, 0, 999) def test_record_keys() -> None: From ebad81e84c2d47ce55802959fafe176b3807ccd8 Mon Sep 17 00:00:00 2001 From: Grant Lodge <6323995+thelonelyvulpes@users.noreply.github.com> Date: Thu, 7 Dec 2023 12:01:32 +0100 Subject: [PATCH 2/2] Recrods.data(): add tests for temporal types with tzinfo Signed-off-by: Rouven Bauer --- tests/unit/common/test_record.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unit/common/test_record.py b/tests/unit/common/test_record.py index 0ef780332..132746c13 100644 --- a/tests/unit/common/test_record.py +++ b/tests/unit/common/test_record.py @@ -19,6 +19,7 @@ import traceback import pytest +import pytz from neo4j import Record from neo4j._codec.hydration import BrokenHydrationObject @@ -189,7 +190,13 @@ def test_record_data_keys(keys, expected) -> None: b"foobar", Date(2021, 1, 1), Time(12, 34, 56, 123456789), + Time(1, 2, 3, 4, pytz.FixedOffset(60)), DateTime(2021, 1, 1, 12, 34, 56, 123456789), + DateTime(2018, 10, 12, 11, 37, 41, 474716862, + pytz.FixedOffset(60)), + pytz.timezone("Europe/Stockholm").localize( + DateTime(2018, 10, 12, 11, 37, 41, 474716862) + ), Duration(1, 2, 3, 4, 5, 6, 7), CartesianPoint((1, 2.0)), CartesianPoint((1, 2.0, 3)),