From a61218c5c93e5c055964465db189308c881d9252 Mon Sep 17 00:00:00 2001 From: Michael Hunger Date: Tue, 21 Apr 2026 02:24:28 +0200 Subject: [PATCH 1/4] Support EagerResult from driver.execute_query() in from_neo4j() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit from_neo4j() now accepts neo4j.EagerResult (the default return type of driver.execute_query()). The implementation recovers the bolt hydration Graph directly from the entity back-references on record values, giving the same complete graph that Result.graph() would return — including start/end nodes not explicitly selected. A _collect_graph_entities() helper handles the fallback for results with no direct graph columns (paths, nested lists/dicts). Unit tests for the helper are in test_collect_graph_entities.py; an integration test for the full EagerResult path is added to test_neo4j.py. --- python-wrapper/src/neo4j_viz/neo4j.py | 84 +++++++++++-- .../tests/test_collect_graph_entities.py | 111 ++++++++++++++++++ python-wrapper/tests/test_neo4j.py | 54 ++++++++- python-wrapper/uv.lock | 4 +- 4 files changed, 240 insertions(+), 13 deletions(-) create mode 100644 python-wrapper/tests/test_collect_graph_entities.py diff --git a/python-wrapper/src/neo4j_viz/neo4j.py b/python-wrapper/src/neo4j_viz/neo4j.py index 3a301ca4..3edd5559 100644 --- a/python-wrapper/src/neo4j_viz/neo4j.py +++ b/python-wrapper/src/neo4j_viz/neo4j.py @@ -4,7 +4,7 @@ from typing import Optional, Union import neo4j.graph -from neo4j import Driver, Result, RoutingControl +from neo4j import Driver, EagerResult, Result, RoutingControl from pydantic import BaseModel, ValidationError from neo4j_viz.colors import NEO4J_COLORS_DISCRETE, ColorSpace @@ -21,12 +21,62 @@ def _parse_validation_error(e: ValidationError, entity_type: type[BaseModel]) -> ) +def _collect_graph_entities(value: object, nodes: dict, rels: dict) -> None: + """Recursively extract Node and Relationship objects from any record value.""" + if isinstance(value, neo4j.graph.Node): + nodes[value.element_id] = value + elif isinstance(value, neo4j.graph.Relationship): + rels[value.element_id] = value + elif isinstance(value, neo4j.graph.Path): + for node in value.nodes: + nodes[node.element_id] = node + for rel in value.relationships: + rels[rel.element_id] = rel + elif isinstance(value, list): + for item in value: + _collect_graph_entities(item, nodes, rels) + elif isinstance(value, dict): + for item in value.values(): + _collect_graph_entities(item, nodes, rels) + + +def _graph_from_eager_result(data: "EagerResult") -> neo4j.graph.Graph: + """Return the bolt hydration Graph shared by all entities in an EagerResult. + + Every Node/Relationship produced by the same query references the same + internal Graph that the driver built during bolt hydration — identical to + what Result.graph() returns. We find the first entity in the records and + return its .graph. If the result contains no graph entities at all we fall + back to walking the records manually. + """ + for record in data.records: + for value in record.values(): + if isinstance(value, (neo4j.graph.Node, neo4j.graph.Relationship)): + return value.graph + if isinstance(value, neo4j.graph.Path) and value.nodes: + return value.nodes[0].graph + + # Fallback: no direct entity columns — walk everything recursively and + # build a synthetic Graph so the rest of from_neo4j can stay uniform. + nodes_dict: dict = {} + rels_dict: dict = {} + for record in data.records: + for value in record.values(): + _collect_graph_entities(value, nodes_dict, rels_dict) + graph = neo4j.graph.Graph() + graph._nodes = nodes_dict # type: ignore[attr-defined] + # Relationships are keyed by element_id in the internal dict + for rel in rels_dict.values(): + graph._relationships[rel.element_id] = rel # type: ignore[attr-defined] + return graph + + def from_neo4j( - data: Union[neo4j.graph.Graph, Result, Driver], + data: Union[neo4j.graph.Graph, Result, EagerResult, Driver], row_limit: int = 10_000, ) -> VisualizationGraph: """ - Create a VisualizationGraph from a Neo4j `Graph`, Neo4j `Result` or Neo4j `Driver`. + Create a VisualizationGraph from a Neo4j `Graph`, Neo4j `Result`, Neo4j `EagerResult` or Neo4j `Driver`. By default: @@ -39,9 +89,10 @@ def from_neo4j( Parameters ---------- - data : Union[neo4j.graph.Graph, neo4j.Result, neo4j.Driver] - Either a query result in the shape of a `neo4j.graph.Graph` or `neo4j.Result`, or a `neo4j.Driver` in - which case a simple default query will be executed internally to retrieve the graph data. + data : Union[neo4j.graph.Graph, neo4j.Result, neo4j.EagerResult, neo4j.Driver] + Either a query result in the shape of a `neo4j.graph.Graph`, `neo4j.Result`, or `neo4j.EagerResult` + (as returned by `driver.execute_query()`), or a `neo4j.Driver` in which case a simple default query + will be executed internally to retrieve the graph data. row_limit : int, optional Maximum number of rows to return from the query, by default 10_000. This is only used if a `neo4j.Driver` is passed as `result` argument, otherwise the limit is ignored. @@ -49,8 +100,19 @@ def from_neo4j( if isinstance(data, Result): graph = data.graph() + raw_nodes = graph.nodes + raw_relationships = graph.relationships elif isinstance(data, neo4j.graph.Graph): - graph = data + raw_nodes = data.nodes + raw_relationships = data.relationships + elif isinstance(data, EagerResult): + # Every Node/Relationship hydrated from the same query shares one Graph + # object (the bolt hydration graph). Grabbing it from the first entity + # gives us the complete graph — including start/end nodes of + # relationships that were never returned as explicit columns. + graph = _graph_from_eager_result(data) + raw_nodes = graph.nodes + raw_relationships = graph.relationships elif isinstance(data, Driver): rel_count = data.execute_query( "MATCH ()-[r]->() RETURN count(r) as count", @@ -66,14 +128,16 @@ def from_neo4j( routing_=RoutingControl.READ, result_transformer_=Result.graph, ) + raw_nodes = graph.nodes + raw_relationships = graph.relationships else: - raise ValueError(f"Invalid input type `{type(data)}`. Expected `neo4j.Graph`, `neo4j.Result` or `neo4j.Driver`") + raise ValueError(f"Invalid input type `{type(data)}`. Expected `neo4j.Graph`, `neo4j.Result`, `neo4j.EagerResult` or `neo4j.Driver`") - nodes = [_map_node(node) for node in graph.nodes] + nodes = [_map_node(node) for node in raw_nodes] relationships = [] - for rel in graph.relationships: + for rel in raw_relationships: mapped_rel = _map_relationship(rel) if mapped_rel: relationships.append(mapped_rel) diff --git a/python-wrapper/tests/test_collect_graph_entities.py b/python-wrapper/tests/test_collect_graph_entities.py new file mode 100644 index 00000000..1252581e --- /dev/null +++ b/python-wrapper/tests/test_collect_graph_entities.py @@ -0,0 +1,111 @@ +import neo4j.graph + +from neo4j_viz.neo4j import _collect_graph_entities + + +def _make_graph(): + return neo4j.graph.Graph() + + +def _make_node(graph, element_id: str, labels: list[str], props: dict): + return neo4j.graph.Node(graph, element_id, hash(element_id), labels, props) + + +def _make_rel(graph, element_id: str, rel_type: str, start, end, props: dict = {}): + RelType = graph.relationship_type(rel_type) + rel = RelType.__new__(RelType) + rel.__dict__.update({ + "_graph": graph, + "_element_id": element_id, + "_id": hash(element_id), + "_properties": props, + "_start_node": start, + "_end_node": end, + }) + return rel + + +def test_plain_node(): + g = _make_graph() + node = _make_node(g, "n1", ["A"], {"x": 1}) + nodes, rels = {}, {} + _collect_graph_entities(node, nodes, rels) + assert "n1" in nodes + assert rels == {} + + +def test_plain_relationship(): + g = _make_graph() + a = _make_node(g, "a", ["A"], {}) + b = _make_node(g, "b", ["B"], {}) + rel = _make_rel(g, "r1", "KNOWS", a, b) + nodes, rels = {}, {} + _collect_graph_entities(rel, nodes, rels) + assert "r1" in rels + assert nodes == {} + + +def test_path(): + g = _make_graph() + a = _make_node(g, "a", ["A"], {}) + b = _make_node(g, "b", ["B"], {}) + rel = _make_rel(g, "r1", "KNOWS", a, b) + path = neo4j.graph.Path(a, rel) + nodes, rels = {}, {} + _collect_graph_entities(path, nodes, rels) + assert set(nodes) == {"a", "b"} + assert set(rels) == {"r1"} + + +def test_list_of_nodes(): + g = _make_graph() + a = _make_node(g, "a", ["A"], {}) + b = _make_node(g, "b", ["B"], {}) + nodes, rels = {}, {} + _collect_graph_entities([a, b], nodes, rels) + assert set(nodes) == {"a", "b"} + + +def test_nested_list(): + g = _make_graph() + a = _make_node(g, "a", ["A"], {}) + nodes, rels = {}, {} + _collect_graph_entities([[a]], nodes, rels) + assert "a" in nodes + + +def test_dict_of_nodes(): + g = _make_graph() + a = _make_node(g, "a", ["A"], {}) + nodes, rels = {}, {} + _collect_graph_entities({"key": a}, nodes, rels) + assert "a" in nodes + + +def test_deduplication(): + g = _make_graph() + a = _make_node(g, "a", ["A"], {}) + nodes, rels = {}, {} + _collect_graph_entities([a, a], nodes, rels) + assert len(nodes) == 1 + + +def test_scalar_ignored(): + nodes, rels = {}, {} + _collect_graph_entities("hello", nodes, rels) + _collect_graph_entities(42, nodes, rels) + _collect_graph_entities(None, nodes, rels) + assert nodes == {} and rels == {} + + +def test_mixed_list_with_path_and_node(): + g = _make_graph() + a = _make_node(g, "a", ["A"], {}) + b = _make_node(g, "b", ["B"], {}) + c = _make_node(g, "c", ["C"], {}) + rel = _make_rel(g, "r1", "KNOWS", a, b) + path = neo4j.graph.Path(a, rel) + nodes, rels = {}, {} + _collect_graph_entities([path, c], nodes, rels) + assert set(nodes) == {"a", "b", "c"} + assert set(rels) == {"r1"} diff --git a/python-wrapper/tests/test_neo4j.py b/python-wrapper/tests/test_neo4j.py index 43fe8bcf..461ae076 100644 --- a/python-wrapper/tests/test_neo4j.py +++ b/python-wrapper/tests/test_neo4j.py @@ -3,7 +3,7 @@ import neo4j import pytest -from neo4j import Driver, Session +from neo4j import Driver, EagerResult, Session from neo4j_viz.colors import NEO4J_COLORS_DISCRETE from neo4j_viz.neo4j import from_neo4j @@ -123,6 +123,58 @@ def test_from_neo4j_result(neo4j_session: Session) -> None: ] +@pytest.mark.requires_neo4j_and_gds +def test_from_neo4j_eager_result(neo4j_session: Session, neo4j_driver: Driver) -> None: + graph = neo4j_session.run("MATCH (a:_CI_A|_CI_B)-[r]->(b) RETURN a, b, r ORDER BY a").graph() + + eager_result: EagerResult = neo4j_driver.execute_query("MATCH (a:_CI_A|_CI_B)-[r]->(b) RETURN a, b, r ORDER BY a") + assert isinstance(eager_result, EagerResult) + + VG = from_neo4j(eager_result) + + sorted_nodes: list[neo4j.graph.Node] = sorted(graph.nodes, key=lambda x: dict(x.items())["name"]) + node_ids: list[str] = [node.element_id for node in sorted_nodes] + + expected_nodes = [ + Node( + id=node_ids[0], + caption="_CI_A", + color=NEO4J_COLORS_DISCRETE[0], + properties=dict( + labels=["_CI_A"], + name="Alice", + height=20, + id=42, + _id=1337, + caption="hello", + ), + ), + Node( + id=node_ids[1], + caption="_CI_A:_CI_B", + color=NEO4J_COLORS_DISCRETE[1], + properties=dict( + size=11, + labels=["_CI_A", "_CI_B"], + name="Bob", + height=10, + id=84, + __labels=[1, 2], + ), + ), + ] + + assert len(VG.nodes) == 2 + assert sorted(VG.nodes, key=lambda x: x.properties["name"]) == expected_nodes + + assert len(VG.relationships) == 2 + vg_rels = sorted([(e.source, e.target, e.caption) for e in VG.relationships], key=lambda x: x[2] if x[2] else "foo") + assert vg_rels == [ + (node_ids[0], node_ids[1], "KNOWS"), + (node_ids[1], node_ids[0], "RELATED"), + ] + + @pytest.mark.requires_neo4j_and_gds def test_from_neo4j_graph_driver(neo4j_session: Session, neo4j_driver: Driver) -> None: graph = neo4j_session.run("MATCH (a:_CI_A|_CI_B)-[r]->(b) RETURN a, b, r ORDER BY a").graph() diff --git a/python-wrapper/uv.lock b/python-wrapper/uv.lock index 433ed9d4..ed1db820 100644 --- a/python-wrapper/uv.lock +++ b/python-wrapper/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14'", @@ -2389,7 +2389,7 @@ wheels = [ [[package]] name = "neo4j-viz" -version = "1.3.0" +version = "1.4.0" source = { editable = "." } dependencies = [ { name = "anywidget" }, From 883a625c47981f2934155426156c725ebe2a27cc Mon Sep 17 00:00:00 2001 From: Michael Hunger Date: Tue, 21 Apr 2026 09:36:30 +0200 Subject: [PATCH 2/4] Fix formatting & linting --- python-wrapper/src/neo4j_viz/neo4j.py | 19 +++-- .../tests/test_collect_graph_entities.py | 78 ++++++++++++------- 2 files changed, 61 insertions(+), 36 deletions(-) diff --git a/python-wrapper/src/neo4j_viz/neo4j.py b/python-wrapper/src/neo4j_viz/neo4j.py index 3edd5559..44867bcc 100644 --- a/python-wrapper/src/neo4j_viz/neo4j.py +++ b/python-wrapper/src/neo4j_viz/neo4j.py @@ -21,7 +21,11 @@ def _parse_validation_error(e: ValidationError, entity_type: type[BaseModel]) -> ) -def _collect_graph_entities(value: object, nodes: dict, rels: dict) -> None: +def _collect_graph_entities( + value: object, + nodes: dict[str, neo4j.graph.Node], + rels: dict[str, neo4j.graph.Relationship], +) -> None: """Recursively extract Node and Relationship objects from any record value.""" if isinstance(value, neo4j.graph.Node): nodes[value.element_id] = value @@ -58,16 +62,15 @@ def _graph_from_eager_result(data: "EagerResult") -> neo4j.graph.Graph: # Fallback: no direct entity columns — walk everything recursively and # build a synthetic Graph so the rest of from_neo4j can stay uniform. - nodes_dict: dict = {} - rels_dict: dict = {} + nodes_dict: dict[str, neo4j.graph.Node] = {} + rels_dict: dict[str, neo4j.graph.Relationship] = {} for record in data.records: for value in record.values(): _collect_graph_entities(value, nodes_dict, rels_dict) graph = neo4j.graph.Graph() - graph._nodes = nodes_dict # type: ignore[attr-defined] - # Relationships are keyed by element_id in the internal dict + graph._nodes = nodes_dict for rel in rels_dict.values(): - graph._relationships[rel.element_id] = rel # type: ignore[attr-defined] + graph._relationships[rel.element_id] = rel return graph @@ -131,7 +134,9 @@ def from_neo4j( raw_nodes = graph.nodes raw_relationships = graph.relationships else: - raise ValueError(f"Invalid input type `{type(data)}`. Expected `neo4j.Graph`, `neo4j.Result`, `neo4j.EagerResult` or `neo4j.Driver`") + raise ValueError( + f"Invalid input type `{type(data)}`. Expected `neo4j.Graph`, `neo4j.Result`, `neo4j.EagerResult` or `neo4j.Driver`" + ) nodes = [_map_node(node) for node in raw_nodes] diff --git a/python-wrapper/tests/test_collect_graph_entities.py b/python-wrapper/tests/test_collect_graph_entities.py index 1252581e..fb59e474 100644 --- a/python-wrapper/tests/test_collect_graph_entities.py +++ b/python-wrapper/tests/test_collect_graph_entities.py @@ -3,109 +3,129 @@ from neo4j_viz.neo4j import _collect_graph_entities -def _make_graph(): +def _make_graph() -> neo4j.graph.Graph: return neo4j.graph.Graph() -def _make_node(graph, element_id: str, labels: list[str], props: dict): +def _make_node( + graph: neo4j.graph.Graph, element_id: str, labels: list[str], props: dict[str, object] +) -> neo4j.graph.Node: return neo4j.graph.Node(graph, element_id, hash(element_id), labels, props) -def _make_rel(graph, element_id: str, rel_type: str, start, end, props: dict = {}): +def _make_rel( + graph: neo4j.graph.Graph, + element_id: str, + rel_type: str, + start: neo4j.graph.Node, + end: neo4j.graph.Node, + props: dict[str, object] | None = None, +) -> neo4j.graph.Relationship: RelType = graph.relationship_type(rel_type) rel = RelType.__new__(RelType) - rel.__dict__.update({ - "_graph": graph, - "_element_id": element_id, - "_id": hash(element_id), - "_properties": props, - "_start_node": start, - "_end_node": end, - }) + rel.__dict__.update( + { + "_graph": graph, + "_element_id": element_id, + "_id": hash(element_id), + "_properties": props or {}, + "_start_node": start, + "_end_node": end, + } + ) return rel -def test_plain_node(): +def test_plain_node() -> None: g = _make_graph() node = _make_node(g, "n1", ["A"], {"x": 1}) - nodes, rels = {}, {} + nodes: dict[str, neo4j.graph.Node] = {} + rels: dict[str, neo4j.graph.Relationship] = {} _collect_graph_entities(node, nodes, rels) assert "n1" in nodes assert rels == {} -def test_plain_relationship(): +def test_plain_relationship() -> None: g = _make_graph() a = _make_node(g, "a", ["A"], {}) b = _make_node(g, "b", ["B"], {}) rel = _make_rel(g, "r1", "KNOWS", a, b) - nodes, rels = {}, {} + nodes: dict[str, neo4j.graph.Node] = {} + rels: dict[str, neo4j.graph.Relationship] = {} _collect_graph_entities(rel, nodes, rels) assert "r1" in rels assert nodes == {} -def test_path(): +def test_path() -> None: g = _make_graph() a = _make_node(g, "a", ["A"], {}) b = _make_node(g, "b", ["B"], {}) rel = _make_rel(g, "r1", "KNOWS", a, b) path = neo4j.graph.Path(a, rel) - nodes, rels = {}, {} + nodes: dict[str, neo4j.graph.Node] = {} + rels: dict[str, neo4j.graph.Relationship] = {} _collect_graph_entities(path, nodes, rels) assert set(nodes) == {"a", "b"} assert set(rels) == {"r1"} -def test_list_of_nodes(): +def test_list_of_nodes() -> None: g = _make_graph() a = _make_node(g, "a", ["A"], {}) b = _make_node(g, "b", ["B"], {}) - nodes, rels = {}, {} + nodes: dict[str, neo4j.graph.Node] = {} + rels: dict[str, neo4j.graph.Relationship] = {} _collect_graph_entities([a, b], nodes, rels) assert set(nodes) == {"a", "b"} -def test_nested_list(): +def test_nested_list() -> None: g = _make_graph() a = _make_node(g, "a", ["A"], {}) - nodes, rels = {}, {} + nodes: dict[str, neo4j.graph.Node] = {} + rels: dict[str, neo4j.graph.Relationship] = {} _collect_graph_entities([[a]], nodes, rels) assert "a" in nodes -def test_dict_of_nodes(): +def test_dict_of_nodes() -> None: g = _make_graph() a = _make_node(g, "a", ["A"], {}) - nodes, rels = {}, {} + nodes: dict[str, neo4j.graph.Node] = {} + rels: dict[str, neo4j.graph.Relationship] = {} _collect_graph_entities({"key": a}, nodes, rels) assert "a" in nodes -def test_deduplication(): +def test_deduplication() -> None: g = _make_graph() a = _make_node(g, "a", ["A"], {}) - nodes, rels = {}, {} + nodes: dict[str, neo4j.graph.Node] = {} + rels: dict[str, neo4j.graph.Relationship] = {} _collect_graph_entities([a, a], nodes, rels) assert len(nodes) == 1 -def test_scalar_ignored(): - nodes, rels = {}, {} +def test_scalar_ignored() -> None: + nodes: dict[str, neo4j.graph.Node] = {} + rels: dict[str, neo4j.graph.Relationship] = {} _collect_graph_entities("hello", nodes, rels) _collect_graph_entities(42, nodes, rels) _collect_graph_entities(None, nodes, rels) assert nodes == {} and rels == {} -def test_mixed_list_with_path_and_node(): +def test_mixed_list_with_path_and_node() -> None: g = _make_graph() a = _make_node(g, "a", ["A"], {}) b = _make_node(g, "b", ["B"], {}) c = _make_node(g, "c", ["C"], {}) rel = _make_rel(g, "r1", "KNOWS", a, b) path = neo4j.graph.Path(a, rel) - nodes, rels = {}, {} + nodes: dict[str, neo4j.graph.Node] = {} + rels: dict[str, neo4j.graph.Relationship] = {} _collect_graph_entities([path, c], nodes, rels) assert set(nodes) == {"a", "b", "c"} assert set(rels) == {"r1"} From 80fe859c4674d7a3a237259607e4ecc728a7fe10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Mon, 27 Apr 2026 13:28:19 +0200 Subject: [PATCH 3/4] Apply review comments * dont rebuild graph object and avoid using internal APIs --- changelog.md | 12 +--- python-wrapper/src/neo4j_viz/neo4j.py | 47 ++++++------- ...ntities.py => test_find_graph_entities.py} | 69 +++++++------------ 3 files changed, 44 insertions(+), 84 deletions(-) rename python-wrapper/tests/{test_collect_graph_entities.py => test_find_graph_entities.py} (50%) diff --git a/changelog.md b/changelog.md index 097aee37..b6c2a10f 100644 --- a/changelog.md +++ b/changelog.md @@ -4,21 +4,11 @@ ## New features -- Add convenience method `add_data` and `remove_data` to `GraphWidget`. -- Added a selection button to the toolbar. -- Added a layout button to the toolbar if `VG.render_widget` is used. -- Support the new circular layout. - ## Bug fixes -- Fixed a bug with the theme detection inn VSCode. - ## Improvements -- Allow setting the theme manually in `VG.render(theme="light")` and `VG.render_widget(theme="dark")`. -- Use typed nodes and relationship traitlets in GraphWidget, i.e., list of Node and Relationship instead of dictionaries. -- `render` now allows to pass `layout` as a string as well. Previously expected to be a typed `neo4j_viz.Layout`. -- Fixed rendering in Marimo notebooks +- Support `neo4j.EagerResult` in the `from_neo4j` integration which is the default return type by `neo4j.Driver.execute_query()`. ## Other changes diff --git a/python-wrapper/src/neo4j_viz/neo4j.py b/python-wrapper/src/neo4j_viz/neo4j.py index 44867bcc..03d8344c 100644 --- a/python-wrapper/src/neo4j_viz/neo4j.py +++ b/python-wrapper/src/neo4j_viz/neo4j.py @@ -21,27 +21,23 @@ def _parse_validation_error(e: ValidationError, entity_type: type[BaseModel]) -> ) -def _collect_graph_entities( - value: object, - nodes: dict[str, neo4j.graph.Node], - rels: dict[str, neo4j.graph.Relationship], -) -> None: - """Recursively extract Node and Relationship objects from any record value.""" - if isinstance(value, neo4j.graph.Node): - nodes[value.element_id] = value - elif isinstance(value, neo4j.graph.Relationship): - rels[value.element_id] = value +def find_graph_entity(value: object) -> neo4j.graph.Graph | None: + """Recursively traverse lists and dicts to find a Graph entity.""" + if isinstance(value, neo4j.graph.Entity): + return value.graph elif isinstance(value, neo4j.graph.Path): - for node in value.nodes: - nodes[node.element_id] = node - for rel in value.relationships: - rels[rel.element_id] = rel + return value.graph elif isinstance(value, list): for item in value: - _collect_graph_entities(item, nodes, rels) + G = find_graph_entity(item) + if G: + return G elif isinstance(value, dict): for item in value.values(): - _collect_graph_entities(item, nodes, rels) + G = find_graph_entity(item) + if G: + return G + return None def _graph_from_eager_result(data: "EagerResult") -> neo4j.graph.Graph: @@ -60,18 +56,14 @@ def _graph_from_eager_result(data: "EagerResult") -> neo4j.graph.Graph: if isinstance(value, neo4j.graph.Path) and value.nodes: return value.nodes[0].graph - # Fallback: no direct entity columns — walk everything recursively and - # build a synthetic Graph so the rest of from_neo4j can stay uniform. - nodes_dict: dict[str, neo4j.graph.Node] = {} - rels_dict: dict[str, neo4j.graph.Relationship] = {} + # Fallback: no direct entity columns — walk everything recursively and return the first graph we find. for record in data.records: for value in record.values(): - _collect_graph_entities(value, nodes_dict, rels_dict) - graph = neo4j.graph.Graph() - graph._nodes = nodes_dict - for rel in rels_dict.values(): - graph._relationships[rel.element_id] = rel - return graph + G = find_graph_entity(value) + if G: + return G + + raise ValueError("No graph entities found in eager result") def from_neo4j( @@ -127,8 +119,9 @@ def from_neo4j( f"Database relationship count ({rel_count}) exceeds `row_limit` ({row_limit}), so limiting will be applied. Increase the `row_limit` if needed" ) graph = data.execute_query( - f"MATCH (n)-[r]->(m) RETURN n,r,m LIMIT {row_limit}", + "MATCH (n)-[r]->(m) RETURN n,r,m LIMIT $rowLimit", routing_=RoutingControl.READ, + parameters_={"rowLimit": row_limit}, result_transformer_=Result.graph, ) raw_nodes = graph.nodes diff --git a/python-wrapper/tests/test_collect_graph_entities.py b/python-wrapper/tests/test_find_graph_entities.py similarity index 50% rename from python-wrapper/tests/test_collect_graph_entities.py rename to python-wrapper/tests/test_find_graph_entities.py index fb59e474..d7b35b5d 100644 --- a/python-wrapper/tests/test_collect_graph_entities.py +++ b/python-wrapper/tests/test_find_graph_entities.py @@ -1,6 +1,6 @@ import neo4j.graph -from neo4j_viz.neo4j import _collect_graph_entities +from neo4j_viz.neo4j import find_graph_entity def _make_graph() -> neo4j.graph.Graph: @@ -36,14 +36,14 @@ def _make_rel( return rel +def _find_graph(value: object) -> neo4j.graph.Graph | None: + return find_graph_entity(value, {}, {}) + + def test_plain_node() -> None: g = _make_graph() node = _make_node(g, "n1", ["A"], {"x": 1}) - nodes: dict[str, neo4j.graph.Node] = {} - rels: dict[str, neo4j.graph.Relationship] = {} - _collect_graph_entities(node, nodes, rels) - assert "n1" in nodes - assert rels == {} + assert _find_graph(node) is g def test_plain_relationship() -> None: @@ -51,11 +51,7 @@ def test_plain_relationship() -> None: a = _make_node(g, "a", ["A"], {}) b = _make_node(g, "b", ["B"], {}) rel = _make_rel(g, "r1", "KNOWS", a, b) - nodes: dict[str, neo4j.graph.Node] = {} - rels: dict[str, neo4j.graph.Relationship] = {} - _collect_graph_entities(rel, nodes, rels) - assert "r1" in rels - assert nodes == {} + assert _find_graph(rel) is g def test_path() -> None: @@ -64,68 +60,49 @@ def test_path() -> None: b = _make_node(g, "b", ["B"], {}) rel = _make_rel(g, "r1", "KNOWS", a, b) path = neo4j.graph.Path(a, rel) - nodes: dict[str, neo4j.graph.Node] = {} - rels: dict[str, neo4j.graph.Relationship] = {} - _collect_graph_entities(path, nodes, rels) - assert set(nodes) == {"a", "b"} - assert set(rels) == {"r1"} + assert _find_graph(path) is g def test_list_of_nodes() -> None: g = _make_graph() a = _make_node(g, "a", ["A"], {}) b = _make_node(g, "b", ["B"], {}) - nodes: dict[str, neo4j.graph.Node] = {} - rels: dict[str, neo4j.graph.Relationship] = {} - _collect_graph_entities([a, b], nodes, rels) - assert set(nodes) == {"a", "b"} + assert _find_graph([a, b]) is g def test_nested_list() -> None: g = _make_graph() a = _make_node(g, "a", ["A"], {}) - nodes: dict[str, neo4j.graph.Node] = {} - rels: dict[str, neo4j.graph.Relationship] = {} - _collect_graph_entities([[a]], nodes, rels) - assert "a" in nodes + assert _find_graph([[a]]) is g def test_dict_of_nodes() -> None: g = _make_graph() a = _make_node(g, "a", ["A"], {}) - nodes: dict[str, neo4j.graph.Node] = {} - rels: dict[str, neo4j.graph.Relationship] = {} - _collect_graph_entities({"key": a}, nodes, rels) - assert "a" in nodes + assert _find_graph({"key": a}) is g def test_deduplication() -> None: g = _make_graph() a = _make_node(g, "a", ["A"], {}) - nodes: dict[str, neo4j.graph.Node] = {} - rels: dict[str, neo4j.graph.Relationship] = {} - _collect_graph_entities([a, a], nodes, rels) - assert len(nodes) == 1 + assert _find_graph([a, a]) is g def test_scalar_ignored() -> None: - nodes: dict[str, neo4j.graph.Node] = {} - rels: dict[str, neo4j.graph.Relationship] = {} - _collect_graph_entities("hello", nodes, rels) - _collect_graph_entities(42, nodes, rels) - _collect_graph_entities(None, nodes, rels) - assert nodes == {} and rels == {} + assert _find_graph("hello") is None + assert _find_graph(42) is None + assert _find_graph(None) is None -def test_mixed_list_with_path_and_node() -> None: +def test_mixed_list_with_graph_entities_and_scalars() -> None: g = _make_graph() a = _make_node(g, "a", ["A"], {}) b = _make_node(g, "b", ["B"], {}) - c = _make_node(g, "c", ["C"], {}) rel = _make_rel(g, "r1", "KNOWS", a, b) - path = neo4j.graph.Path(a, rel) - nodes: dict[str, neo4j.graph.Node] = {} - rels: dict[str, neo4j.graph.Relationship] = {} - _collect_graph_entities([path, c], nodes, rels) - assert set(nodes) == {"a", "b", "c"} - assert set(rels) == {"r1"} + assert _find_graph(["hello", rel, 42, None]) is g + + +def test_mixed_dict_with_graph_entities_and_scalars() -> None: + g = _make_graph() + a = _make_node(g, "a", ["A"], {}) + assert _find_graph({"text": "hello", "node": a, "count": 42, "empty": None}) is g From 5709407797154edce480d5a3a6cb54132bf86dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Mon, 27 Apr 2026 13:34:42 +0200 Subject: [PATCH 4/4] Simplify docs example --- .../modules/ROOT/pages/integration/neo4j.adoc | 7 ++-- python-wrapper/src/neo4j_viz/neo4j.py | 2 +- .../tests/test_find_graph_entities.py | 34 ++++++++++--------- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/docs/antora/modules/ROOT/pages/integration/neo4j.adoc b/docs/antora/modules/ROOT/pages/integration/neo4j.adoc index 9a031f5a..b171ebd0 100644 --- a/docs/antora/modules/ROOT/pages/integration/neo4j.adoc +++ b/docs/antora/modules/ROOT/pages/integration/neo4j.adoc @@ -11,7 +11,7 @@ pip install neo4j-viz[neo4j] Once you have installed the additional dependency, you can use the link:{api-docs-uri}/from_neo4j[`from_neo4j`] method to import query results from Neo4j. -The `from_neo4j` method takes one mandatory positional parameter: A `data` argument representing either a query result in the shape of a `neo4j.graph.Graph` or `neo4j.Result`, or a `neo4j.Driver` in which case a simple default query will be executed internally to retrieve the graph data. +The `from_neo4j` method takes one mandatory positional parameter: A `data` argument representing either a query result in the shape of a `neo4j.graph.Graph`, `neo4j.EagerResult` or `neo4j.Result`, or a `neo4j.Driver` in which case a simple default query will be executed internally to retrieve the graph data. The optional `max_rows` parameter can be used to limit the number of relationships shown in the visualization. By default, it is set to 10.000, meaning that if the database has more than 10.000 rows, a warning will be raised. @@ -35,11 +35,10 @@ with GraphDatabase.driver(URI, auth=auth) as driver: result = driver.execute_query( "MATCH (n)-[r]->(m) RETURN n,r,m", database_="neo4j", - routing_=RoutingControl.READ, - result_transformer_=Result.graph, + routing_=RoutingControl.READ ) VG = from_neo4j(result) ---- -See the link:{tutorials-docs-uri}/neo4j-example[Visualizing Neo4j Graphs tutorial] for a more extensive example. \ No newline at end of file +See the link:{tutorials-docs-uri}/neo4j-example[Visualizing Neo4j Graphs tutorial] for a more extensive example. diff --git a/python-wrapper/src/neo4j_viz/neo4j.py b/python-wrapper/src/neo4j_viz/neo4j.py index 03d8344c..bcee0d73 100644 --- a/python-wrapper/src/neo4j_viz/neo4j.py +++ b/python-wrapper/src/neo4j_viz/neo4j.py @@ -40,7 +40,7 @@ def find_graph_entity(value: object) -> neo4j.graph.Graph | None: return None -def _graph_from_eager_result(data: "EagerResult") -> neo4j.graph.Graph: +def _graph_from_eager_result(data: EagerResult) -> neo4j.graph.Graph: """Return the bolt hydration Graph shared by all entities in an EagerResult. Every Node/Relationship produced by the same query references the same diff --git a/python-wrapper/tests/test_find_graph_entities.py b/python-wrapper/tests/test_find_graph_entities.py index d7b35b5d..7e0ea59b 100644 --- a/python-wrapper/tests/test_find_graph_entities.py +++ b/python-wrapper/tests/test_find_graph_entities.py @@ -36,14 +36,10 @@ def _make_rel( return rel -def _find_graph(value: object) -> neo4j.graph.Graph | None: - return find_graph_entity(value, {}, {}) - - def test_plain_node() -> None: g = _make_graph() node = _make_node(g, "n1", ["A"], {"x": 1}) - assert _find_graph(node) is g + assert find_graph_entity(node) is g def test_plain_relationship() -> None: @@ -51,7 +47,7 @@ def test_plain_relationship() -> None: a = _make_node(g, "a", ["A"], {}) b = _make_node(g, "b", ["B"], {}) rel = _make_rel(g, "r1", "KNOWS", a, b) - assert _find_graph(rel) is g + assert find_graph_entity(rel) is g def test_path() -> None: @@ -60,38 +56,42 @@ def test_path() -> None: b = _make_node(g, "b", ["B"], {}) rel = _make_rel(g, "r1", "KNOWS", a, b) path = neo4j.graph.Path(a, rel) - assert _find_graph(path) is g + assert find_graph_entity(path) is g def test_list_of_nodes() -> None: g = _make_graph() a = _make_node(g, "a", ["A"], {}) b = _make_node(g, "b", ["B"], {}) - assert _find_graph([a, b]) is g + value = [a, b] + assert find_graph_entity(value) is g def test_nested_list() -> None: g = _make_graph() a = _make_node(g, "a", ["A"], {}) - assert _find_graph([[a]]) is g + value = [[a]] + assert find_graph_entity(value) is g def test_dict_of_nodes() -> None: g = _make_graph() a = _make_node(g, "a", ["A"], {}) - assert _find_graph({"key": a}) is g + value = {"key": a} + assert find_graph_entity(value) is g def test_deduplication() -> None: g = _make_graph() a = _make_node(g, "a", ["A"], {}) - assert _find_graph([a, a]) is g + value = [a, a] + assert find_graph_entity(value) is g def test_scalar_ignored() -> None: - assert _find_graph("hello") is None - assert _find_graph(42) is None - assert _find_graph(None) is None + assert find_graph_entity("hello") is None + assert find_graph_entity(42) is None + assert find_graph_entity(None) is None def test_mixed_list_with_graph_entities_and_scalars() -> None: @@ -99,10 +99,12 @@ def test_mixed_list_with_graph_entities_and_scalars() -> None: a = _make_node(g, "a", ["A"], {}) b = _make_node(g, "b", ["B"], {}) rel = _make_rel(g, "r1", "KNOWS", a, b) - assert _find_graph(["hello", rel, 42, None]) is g + value = ["hello", rel, 42, None] + assert find_graph_entity(value) is g def test_mixed_dict_with_graph_entities_and_scalars() -> None: g = _make_graph() a = _make_node(g, "a", ["A"], {}) - assert _find_graph({"text": "hello", "node": a, "count": 42, "empty": None}) is g + value = {"text": "hello", "node": a, "count": 42, "empty": None} + assert find_graph_entity(value) is g