Skip to content

Fix Duration export in Result.data and Record.data #1000

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Dec 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions src/neo4j/_async/work/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
62 changes: 53 additions & 9 deletions src/neo4j/_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)))

Expand Down Expand Up @@ -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)
Expand Down
10 changes: 6 additions & 4 deletions src/neo4j/_sync/work/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
185 changes: 174 additions & 11 deletions tests/unit/common/test_record.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,28 @@
import traceback

import pytest
import pytz

from neo4j import Record
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
Expand Down Expand Up @@ -73,17 +89,164 @@ def test_record_repr() -> None:
assert repr(a_record) == "<Record name='Nigel' empire='The British Empire'>"


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),
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)),
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:
Expand Down