diff --git a/changelog.md b/changelog.md index c0902b3..7db9a34 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/docs/antora/modules/ROOT/pages/rendering.adoc b/docs/antora/modules/ROOT/pages/rendering.adoc index e3ae618..a76ae2c 100644 --- a/docs/antora/modules/ROOT/pages/rendering.adoc +++ b/docs/antora/modules/ROOT/pages/rendering.adoc @@ -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`. @@ -60,4 +66,4 @@ html = VG.render(...) with open("my_graph.html", "w") as f: f.write(html.data) ----- \ No newline at end of file +---- diff --git a/python-wrapper/src/neo4j_viz/_validation.py b/python-wrapper/src/neo4j_viz/_validation.py new file mode 100644 index 0000000..8d8b145 --- /dev/null +++ b/python-wrapper/src/neo4j_viz/_validation.py @@ -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) diff --git a/python-wrapper/src/neo4j_viz/visualization_graph.py b/python-wrapper/src/neo4j_viz/visualization_graph.py index 28ba65c..4b98637 100644 --- a/python-wrapper/src/neo4j_viz/visualization_graph.py +++ b/python-wrapper/src/neo4j_viz/visualization_graph.py @@ -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 @@ -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) @@ -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) @@ -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. @@ -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 ------- @@ -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( @@ -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). @@ -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, @@ -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( diff --git a/python-wrapper/src/neo4j_viz/widget.py b/python-wrapper/src/neo4j_viz/widget.py index c201f16..4a8d9d3 100644 --- a/python-wrapper/src/neo4j_viz/widget.py +++ b/python-wrapper/src/neo4j_viz/widget.py @@ -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 @@ -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. @@ -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] @@ -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, diff --git a/python-wrapper/tests/test_render.py b/python-wrapper/tests/test_render.py index 8b7d85b..abce9e1 100644 --- a/python-wrapper/tests/test_render.py +++ b/python-wrapper/tests/test_render.py @@ -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 " diff --git a/python-wrapper/tests/test_validation.py b/python-wrapper/tests/test_validation.py new file mode 100644 index 0000000..0f1915f --- /dev/null +++ b/python-wrapper/tests/test_validation.py @@ -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 diff --git a/python-wrapper/tests/test_widget.py b/python-wrapper/tests/test_widget.py index e66f071..de8caa6 100644 --- a/python-wrapper/tests/test_widget.py +++ b/python-wrapper/tests/test_widget.py @@ -1,4 +1,5 @@ import datetime +import re from typing import Any import pytest @@ -191,9 +192,9 @@ def test_add_data(self) -> None: rels = [Relationship(source="n1", target="n2")] widget = GraphWidget.from_graph_data(nodes, rels) - widget.add_data(Node(id="x1"), Relationship(source="x1", target="x2")) + widget.add_data([Node(id="x1"), Node(id="x2")], Relationship(source="x1", target="x2")) - assert len(widget.nodes) == 3 + assert len(widget.nodes) == 4 assert len(widget.relationships) == 2 def test_remove_data(self) -> None: @@ -212,6 +213,22 @@ def test_remove_data(self) -> None: assert {n.id for n in widget.nodes} == {"n3"} assert {r.id for r in widget.relationships} == {43} + def test_add_data_dangling_warns_by_default(self) -> None: + widget = GraphWidget.from_graph_data([Node(id="n1")], []) + with pytest.warns(UserWarning, match=re.escape("reference node ids that are not in the graph")): + widget.add_data(relationships=Relationship(source="n1", target="missing")) + + def test_add_data_dangling_error(self) -> None: + widget = GraphWidget.from_graph_data([Node(id="n1")], []) + with pytest.raises(ValueError, match=re.escape("reference node ids that are not in the graph")): + widget.add_data(relationships=Relationship(source="n1", target="missing"), on_dangling="error") + + def test_add_data_node_and_relationship_together_ok(self) -> None: + widget = GraphWidget.from_graph_data([Node(id="n1")], []) + # adding the endpoint node together with the relationship must not be flagged + widget.add_data(Node(id="n2"), Relationship(source="n1", target="n2")) + assert len(widget.relationships) == 1 + class TestWidgetUtilityMethods: def _spy_send_state(self, widget: GraphWidget) -> list[Any]: