Skip to content
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
2 changes: 2 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

## Bug fixes

* Warn when relationships reference node ids that are not in the graph. It is configurable via the `on_dangling` parameter (`"warn"` (default), `"error"`, or `"none"`) on `render`, `render_widget`, and `GraphWidget.add_data`

## Improvements

* Support Python 3.14
Expand Down
8 changes: 7 additions & 1 deletion docs/antora/modules/ROOT/pages/rendering.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ It defaults to 10.000, because rendering a large number of nodes can be slow and
However, you can increase this value if you are confident that your environment can handle the scale.
In this case you might also want to pass `Renderer.WEB_GL` as the `renderer` to improve performance.

Relationships whose `source` or `target` is not part of the graph's nodes ("dangling" relationships) cannot be
drawn, and a graph containing them may appear empty.
By default `render` emits a warning that lists the offending relationships so the problem is not silent.
You can change this with the `on_dangling` parameter: pass `"error"` to raise a `ValueError` instead, or `"none"`
to skip the check entirely.

By default a tooltip showing IDs and properties will be shown when mouse hovering over a node or relationship.
But you can disable this by passing `show_hover_tooltip=False`.

Expand All @@ -60,4 +66,4 @@ html = VG.render(...)

with open("my_graph.html", "w") as f:
f.write(html.data)
----
----
61 changes: 61 additions & 0 deletions python-wrapper/src/neo4j_viz/_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from __future__ import annotations

import warnings
from typing import Literal

from .node import Node
from .relationship import Relationship

OnDangling = Literal["error", "warn", "none"]

# Number of offending relationships to name in the message before truncating.
_MAX_REPORTED = 5


def check_dangling_relationships(
nodes: list[Node],
relationships: list[Relationship],
on_dangling: OnDangling = "warn",
) -> None:
"""Check for relationships referencing node ids that are not in ``nodes``.

The frontend silently renders an empty graph when a relationship's ``source`` or ``target``
is missing from the nodes, so by default we surface this as a warning. Node and relationship
ids are compared as strings, matching how they are serialized for the frontend (so e.g.
``Node(id=1)`` and ``Relationship(source="1")`` are considered to match).

Parameters
----------
nodes:
The nodes in the graph.
relationships:
The relationships to check against ``nodes``.
on_dangling:
What to do when a dangling relationship is found: ``"warn"`` (default) emits a warning,
``"error"`` raises a ``ValueError``, and ``"none"`` skips the check entirely.
"""
if on_dangling == "none":
return

node_ids = {str(node.id) for node in nodes}
dangling = [rel for rel in relationships if str(rel.source) not in node_ids or str(rel.target) not in node_ids]
if not dangling:
return

examples = []
for rel in dangling[:_MAX_REPORTED]:
missing = [str(end) for end in (rel.source, rel.target) if str(end) not in node_ids]
examples.append(f"relationship {rel.id!r} (source={rel.source!r}, target={rel.target!r}) -> missing {missing}")
if len(dangling) > _MAX_REPORTED:
examples.append(f"... and {len(dangling) - _MAX_REPORTED} more")

message = (
f"{len(dangling)} relationship(s) reference node ids that are not in the graph, "
"so they will not be drawn:\n " + "\n ".join(examples) + "\n"
"Add the missing nodes, or pass `on_dangling='none'` to silence this. "
"Pass `on_dangling='error'` to raise instead."
)

if on_dangling == "error":
raise ValueError(message)
warnings.warn(message)
12 changes: 12 additions & 0 deletions python-wrapper/src/neo4j_viz/visualization_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from IPython.display import HTML

from ._graph_entity_operations import GraphEntityOperations
from ._validation import OnDangling, check_dangling_relationships
from .colors import ColorSpace, ColorsType
from .node import Node, NodeIdType
from .node_size import RealNumber
Expand Down Expand Up @@ -324,6 +325,7 @@ def _build_render_options(
allow_dynamic_min_zoom: bool,
max_allowed_nodes: int,
show_layout_button: bool,
on_dangling: OnDangling,
) -> RenderOptions:
"""Shared validation + option building for render / render_widget."""
num_nodes = len(self.nodes)
Expand All @@ -334,6 +336,8 @@ def _build_render_options(
"overriding `max_allowed_nodes`, but rendering could then take a long time"
)

check_dangling_relationships(self.nodes, self.relationships, on_dangling)

if isinstance(renderer, str):
renderer = Renderer(renderer)

Expand Down Expand Up @@ -378,6 +382,7 @@ def render(
allow_dynamic_min_zoom: bool = True,
max_allowed_nodes: int = 10_000,
theme: Literal["auto"] | Literal["light"] | Literal["dark"] = "auto",
on_dangling: OnDangling = "warn",
) -> HTML:
"""
Render the graph as an HTML object.
Expand Down Expand Up @@ -411,6 +416,8 @@ def render(
The maximum allowed number of nodes to render.
theme:
The theme of the rendered graph. Can be 'auto', 'light', or 'dark'
on_dangling:
What to do when a relationship references a node id that is not in the graph . One of "warn" (default), "error", or "none".

Example
-------
Expand All @@ -428,6 +435,7 @@ def render(
allow_dynamic_min_zoom,
max_allowed_nodes,
show_layout_button=False, # The button only works with the widget
on_dangling=on_dangling,
)

return NVL().render(
Expand All @@ -453,6 +461,7 @@ def render_widget(
allow_dynamic_min_zoom: bool = True,
max_allowed_nodes: int = 10_000,
theme: Literal["auto"] | Literal["light"] | Literal["dark"] = "auto",
on_dangling: OnDangling = "warn",
) -> GraphWidget:
"""
Render the graph as an interactive Jupyter widget (anywidget).
Expand Down Expand Up @@ -486,6 +495,8 @@ def render_widget(
The maximum allowed number of nodes to render.
theme:
The theme to use for the rendered graph.
on_dangling:
What to do when a relationship references a node id that is not in the graph. One of "warn" (default), "error", or "none".
"""
render_options = self._build_render_options(
layout,
Expand All @@ -498,6 +509,7 @@ def render_widget(
allow_dynamic_min_zoom,
max_allowed_nodes,
show_layout_button=True,
on_dangling=on_dangling,
)

return GraphWidget.from_graph_data(
Expand Down
12 changes: 11 additions & 1 deletion python-wrapper/src/neo4j_viz/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import traitlets

from ._graph_entity_operations import GraphEntityOperations
from ._validation import OnDangling, check_dangling_relationships
from .colors import ColorSpace, ColorsType
from .node import Node, NodeIdType
from .node_size import RealNumber
Expand Down Expand Up @@ -461,7 +462,10 @@ def set_show_layout_button(self, show: bool = True) -> None:
self.options = new

def add_data(
self, nodes: Node | list[Node] | None = None, relationships: Relationship | list[Relationship] | None = None
self,
nodes: Node | list[Node] | None = None,
relationships: Relationship | list[Relationship] | None = None,
on_dangling: OnDangling = "warn",
) -> None:
"""
Add nodes or relationships to the graph widget.
Expand All @@ -472,6 +476,10 @@ def add_data(
Nodes to add to the graph widget.
relationships:
Relationships to add to the graph widget.
on_dangling:
What to do when a resulting relationship references a node id that is not in the graph
(which the frontend would silently render as empty). One of "warn" (default), "error",
or "none".
"""
if isinstance(nodes, Node):
nodes = [nodes]
Expand All @@ -483,6 +491,8 @@ def add_data(
if relationships:
self.relationships = self.relationships + relationships

check_dangling_relationships(self.nodes, self.relationships, on_dangling)

def remove_data(
self,
nodes: Node | list[Node | NodeIdType] | NodeIdType | None = None,
Expand Down
40 changes: 40 additions & 0 deletions python-wrapper/tests/test_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,46 @@ def test_render_warnings() -> None:
):
VG.render(max_allowed_nodes=20_000, renderer=Renderer.CANVAS)


_DANGLING_MATCH = re.escape("reference node ids that are not in the graph")


def _dangling_graph() -> VisualizationGraph:
nodes = [Node(id="a"), Node(id="b")]
relationships = [
Relationship(source="a", target="b"),
Relationship(source="a", target="missing"), # `missing` is not a node
]
return VisualizationGraph(nodes=nodes, relationships=relationships)


def test_dangling_relationship_warns_by_default() -> None:
with pytest.warns(UserWarning, match=_DANGLING_MATCH):
_dangling_graph().render()


def test_dangling_relationship_error() -> None:
with pytest.raises(ValueError, match=_DANGLING_MATCH):
_dangling_graph().render(on_dangling="error")


def test_dangling_relationship_none_is_silent() -> None:
# `filterwarnings = error` (pyproject) would surface any warning; none should be emitted
_dangling_graph().render(on_dangling="none")


def test_dangling_relationship_render_widget_warns() -> None:
with pytest.warns(UserWarning, match=_DANGLING_MATCH):
_dangling_graph().render_widget()


def test_no_dangling_with_mixed_id_types() -> None:
# Node(id=1) (int) and Relationship(source="1") (str) must be treated as the same node
nodes = [Node(id=1), Node(id=2)]
relationships = [Relationship(source="1", target=2)]
VG = VisualizationGraph(nodes=nodes, relationships=relationships)
VG.render(on_dangling="error") # must not raise

with pytest.warns(
UserWarning,
match="Although better for performance, the WebGL renderer cannot render text, icons and arrowheads on "
Expand Down
113 changes: 113 additions & 0 deletions python-wrapper/tests/test_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import contextlib
import warnings
from collections.abc import Iterator

import pytest

from neo4j_viz._validation import check_dangling_relationships
from neo4j_viz.node import Node
from neo4j_viz.relationship import Relationship


@contextlib.contextmanager
def assert_no_warning() -> Iterator[None]:
"""Turn any emitted warning into an error, so the test fails if one is raised."""
with warnings.catch_warnings():
warnings.simplefilter("error")
yield


def test_no_dangling_is_silent() -> None:
nodes = [Node(id="a"), Node(id="b")]
rels = [Relationship(id="r1", source="a", target="b")]
with assert_no_warning():
check_dangling_relationships(nodes, rels) # must not warn or raise


def test_reports_only_the_missing_target() -> None:
nodes = [Node(id="a"), Node(id="b")]
rels = [Relationship(id="r1", source="a", target="missing")]

with pytest.warns(UserWarning) as record:
check_dangling_relationships(nodes, rels)

msg = str(record[0].message)
assert "1 relationship(s) reference node ids that are not in the graph" in msg
assert "relationship 'r1' (source='a', target='missing') -> missing ['missing']" in msg
# the present endpoint must not be reported as missing
assert "'a'" not in msg.split("missing [", 1)[1]


def test_reports_only_the_missing_source() -> None:
nodes = [Node(id="a"), Node(id="b")]
rels = [Relationship(id="r1", source="ghost", target="b")]

with pytest.warns(UserWarning) as record:
check_dangling_relationships(nodes, rels)

assert "-> missing ['ghost']" in str(record[0].message)


def test_reports_both_missing_endpoints_in_order() -> None:
nodes = [Node(id="a")]
rels = [Relationship(id="r1", source="x", target="y")]

with pytest.warns(UserWarning) as record:
check_dangling_relationships(nodes, rels)

# source is reported before target
assert "-> missing ['x', 'y']" in str(record[0].message)


def test_mixed_id_types_match() -> None:
# Node(id=1) (int) and Relationship(source="1") (str) refer to the same node -> not dangling
nodes = [Node(id=1), Node(id=2)]
rels = [Relationship(id="r1", source="1", target=2)]
with assert_no_warning():
check_dangling_relationships(nodes, rels)


def test_mixed_id_types_partial_miss() -> None:
nodes = [Node(id=1)]
rels = [Relationship(id="r1", source="1", target="x")]

with pytest.warns(UserWarning) as record:
check_dangling_relationships(nodes, rels)

# source "1" matches Node(id=1); only the genuinely missing target is reported
assert "-> missing ['x']" in str(record[0].message)


def test_error_mode_reports_missing_ids() -> None:
nodes = [Node(id="a")]
rels = [Relationship(id="r1", source="a", target="b")]

with pytest.raises(ValueError) as excinfo:
check_dangling_relationships(nodes, rels, on_dangling="error")

msg = str(excinfo.value)
assert "1 relationship(s) reference node ids that are not in the graph" in msg
assert "relationship 'r1' (source='a', target='b') -> missing ['b']" in msg


def test_none_mode_is_silent_even_with_dangling() -> None:
nodes = [Node(id="a")]
rels = [Relationship(id="r1", source="a", target="b")]
with assert_no_warning():
check_dangling_relationships(nodes, rels, on_dangling="none") # must not warn or raise


def test_multiple_dangling_are_all_counted_and_capped() -> None:
nodes = [Node(id="n")]
rels = [Relationship(id=f"r{i}", source="n", target=f"m{i}") for i in range(7)]

with pytest.warns(UserWarning) as record:
check_dangling_relationships(nodes, rels)

msg = str(record[0].message)
assert "7 relationship(s) reference node ids that are not in the graph" in msg
# first 5 are listed, the rest summarized
assert "-> missing ['m0']" in msg
assert "-> missing ['m4']" in msg
assert "'m5'" not in msg and "'m6'" not in msg
assert "... and 2 more" in msg
Loading
Loading