Skip to content

Commit d241095

Browse files
authored
Merge pull request #170 from neo4j/better-integration-errors
Better error messages for convenience constructors
2 parents d47b875 + f5506dc commit d241095

File tree

9 files changed

+300
-24
lines changed

9 files changed

+300
-24
lines changed

changelog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,7 @@
1313

1414
## Improvements
1515

16+
* Improved error messages when constructing `VisualizationGraph`s using `from_dfs`, `from_neo4j`, `from_gds` and `from_gql_create` methods
17+
1618

1719
## Other changes

python-wrapper/src/neo4j_viz/gds.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,4 +97,13 @@ def from_gds(
9797
rel_df = _rel_df(gds, G)
9898
rel_df.rename(columns={"sourceNodeId": "source", "targetNodeId": "target"}, inplace=True)
9999

100-
return _from_dfs(node_df, rel_df, node_radius_min_max=node_radius_min_max, rename_properties={"__size": "size"})
100+
try:
101+
return _from_dfs(node_df, rel_df, node_radius_min_max=node_radius_min_max, rename_properties={"__size": "size"})
102+
except ValueError as e:
103+
err_msg = str(e)
104+
if "column" in err_msg:
105+
err_msg = err_msg.replace("column", "property")
106+
if ("'size'" in err_msg) and (size_property is not None):
107+
err_msg = err_msg.replace("'size'", f"'{size_property}'")
108+
raise ValueError(err_msg)
109+
raise e

python-wrapper/src/neo4j_viz/gql_create.py

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import uuid
33
from typing import Any, Optional
44

5+
from pydantic import BaseModel, ValidationError
6+
57
from neo4j_viz import Node, Relationship, VisualizationGraph
68

79

@@ -252,6 +254,20 @@ def from_gql_create(
252254
node_top_level_keys = Node.all_validation_aliases(exempted_fields=["id"])
253255
rel_top_level_keys = Relationship.all_validation_aliases(exempted_fields=["id", "source", "target"])
254256

257+
def _parse_validation_error(e: ValidationError, entity_type: type[BaseModel]) -> None:
258+
for err in e.errors():
259+
loc = err["loc"][0]
260+
if (loc == "size") and size_property is not None:
261+
loc = size_property
262+
if loc == "caption":
263+
if (entity_type == Node) and (node_caption is not None):
264+
loc = node_caption
265+
elif (entity_type == Relationship) and (relationship_caption is not None):
266+
loc = relationship_caption
267+
raise ValueError(
268+
f"Error for {entity_type.__name__.lower()} property '{loc}' with provided input '{err['input']}'. Reason: {err['msg']}"
269+
)
270+
255271
nodes = []
256272
relationships = []
257273
alias_to_id = {}
@@ -267,7 +283,10 @@ def from_gql_create(
267283
anonymous_count += 1
268284
if alias not in alias_to_id:
269285
alias_to_id[alias] = str(uuid.uuid4())
270-
nodes.append(Node(id=alias_to_id[alias], **top_level, properties=props))
286+
try:
287+
nodes.append(Node(id=alias_to_id[alias], **top_level, properties=props))
288+
except ValidationError as e:
289+
_parse_validation_error(e, Node)
271290

272291
continue
273292

@@ -283,7 +302,10 @@ def from_gql_create(
283302
anonymous_count += 1
284303
if left_alias not in alias_to_id:
285304
alias_to_id[left_alias] = str(uuid.uuid4())
286-
nodes.append(Node(id=alias_to_id[left_alias], **left_top_level, properties=left_props))
305+
try:
306+
nodes.append(Node(id=alias_to_id[left_alias], **left_top_level, properties=left_props))
307+
except ValidationError as e:
308+
_parse_validation_error(e, Node)
287309
elif left_alias not in alias_to_id:
288310
snippet = _get_snippet(query, query.index(left_node))
289311
raise ValueError(f"Relationship references unknown node alias: '{left_alias}' near: `{snippet}`.")
@@ -295,7 +317,10 @@ def from_gql_create(
295317
anonymous_count += 1
296318
if right_alias not in alias_to_id:
297319
alias_to_id[right_alias] = str(uuid.uuid4())
298-
nodes.append(Node(id=alias_to_id[right_alias], **right_top_level, properties=right_props))
320+
try:
321+
nodes.append(Node(id=alias_to_id[right_alias], **right_top_level, properties=right_props))
322+
except ValidationError as e:
323+
_parse_validation_error(e, Node)
299324
elif right_alias not in alias_to_id:
300325
snippet = _get_snippet(query, query.index(right_node))
301326
raise ValueError(f"Relationship references unknown node alias: '{right_alias}' near: `{snippet}`.")
@@ -313,15 +338,20 @@ def from_gql_create(
313338
if "type" in props:
314339
props["__type"] = props["type"]
315340
props["type"] = rel_type
316-
relationships.append(
317-
Relationship(
318-
id=rel_id,
319-
source=alias_to_id[left_alias],
320-
target=alias_to_id[right_alias],
321-
**top_level,
322-
properties=props,
341+
342+
try:
343+
relationships.append(
344+
Relationship(
345+
id=rel_id,
346+
source=alias_to_id[left_alias],
347+
target=alias_to_id[right_alias],
348+
**top_level,
349+
properties=props,
350+
)
323351
)
324-
)
352+
except ValidationError as e:
353+
_parse_validation_error(e, Relationship)
354+
325355
continue
326356

327357
snippet = part[:30]
@@ -346,6 +376,10 @@ def from_gql_create(
346376

347377
VG = VisualizationGraph(nodes=nodes, relationships=relationships)
348378
if (node_radius_min_max is not None) and (size_property is not None):
349-
VG.resize_nodes(node_radius_min_max=node_radius_min_max)
379+
try:
380+
VG.resize_nodes(node_radius_min_max=node_radius_min_max)
381+
except TypeError:
382+
loc = "size" if size_property is None else size_property
383+
raise ValueError(f"Error for node property '{loc}'. Reason: must be a numerical value")
350384

351385
return VG

python-wrapper/src/neo4j_viz/neo4j.py

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,21 @@
44

55
import neo4j.graph
66
from neo4j import Result
7+
from pydantic import BaseModel, ValidationError
78

89
from neo4j_viz.node import Node
910
from neo4j_viz.relationship import Relationship
1011
from neo4j_viz.visualization_graph import VisualizationGraph
1112

1213

14+
def _parse_validation_error(e: ValidationError, entity_type: type[BaseModel]) -> None:
15+
for err in e.errors():
16+
loc = err["loc"][0]
17+
raise ValueError(
18+
f"Error for {entity_type.__name__.lower()} property '{loc}' with provided input '{err['input']}'. Reason: {err['msg']}"
19+
)
20+
21+
1322
def from_neo4j(
1423
result: Union[neo4j.graph.Graph, Result],
1524
size_property: Optional[str] = None,
@@ -50,14 +59,30 @@ def from_neo4j(
5059
all_node_field_aliases = Node.all_validation_aliases()
5160
all_rel_field_aliases = Relationship.all_validation_aliases()
5261

53-
nodes = [
54-
_map_node(node, all_node_field_aliases, size_property, caption_property=node_caption) for node in graph.nodes
55-
]
62+
try:
63+
nodes = [
64+
_map_node(node, all_node_field_aliases, size_property, caption_property=node_caption)
65+
for node in graph.nodes
66+
]
67+
except ValueError as e:
68+
err_msg = str(e)
69+
if ("'size'" in err_msg) and (size_property is not None):
70+
err_msg = err_msg.replace("'size'", f"'{size_property}'")
71+
elif ("'caption'" in err_msg) and (node_caption is not None):
72+
err_msg = err_msg.replace("'caption'", f"'{node_caption}'")
73+
raise ValueError(err_msg)
74+
5675
relationships = []
57-
for rel in graph.relationships:
58-
mapped_rel = _map_relationship(rel, all_rel_field_aliases, caption_property=relationship_caption)
59-
if mapped_rel:
60-
relationships.append(mapped_rel)
76+
try:
77+
for rel in graph.relationships:
78+
mapped_rel = _map_relationship(rel, all_rel_field_aliases, caption_property=relationship_caption)
79+
if mapped_rel:
80+
relationships.append(mapped_rel)
81+
except ValueError as e:
82+
err_msg = str(e)
83+
if ("'caption'" in err_msg) and (relationship_caption is not None):
84+
err_msg = err_msg.replace("'caption'", f"'{relationship_caption}'")
85+
raise ValueError(err_msg)
6186

6287
VG = VisualizationGraph(nodes, relationships)
6388

@@ -102,7 +127,12 @@ def _map_node(
102127
properties["__labels"] = properties["labels"]
103128
properties["labels"] = labels
104129

105-
return Node(**top_level_fields, properties=properties)
130+
try:
131+
viz_node = Node(**top_level_fields, properties=properties)
132+
except ValidationError as e:
133+
_parse_validation_error(e, Node)
134+
135+
return viz_node
106136

107137

108138
def _map_relationship(
@@ -135,4 +165,9 @@ def _map_relationship(
135165
properties["__type"] = properties["type"]
136166
properties["type"] = rel.type
137167

138-
return Relationship(**top_level_fields, properties=properties)
168+
try:
169+
viz_rel = Relationship(**top_level_fields, properties=properties)
170+
except ValidationError as e:
171+
_parse_validation_error(e, Relationship)
172+
173+
return viz_rel

python-wrapper/src/neo4j_viz/pandas.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Optional, Union
55

66
from pandas import DataFrame
7+
from pydantic import BaseModel, ValidationError
78

89
from .node import Node
910
from .relationship import Relationship
@@ -12,6 +13,19 @@
1213
DFS_TYPE = Union[DataFrame, Iterable[DataFrame]]
1314

1415

16+
def _parse_validation_error(e: ValidationError, entity_type: type[BaseModel]) -> None:
17+
for err in e.errors():
18+
loc = err["loc"][0]
19+
if err["type"] == "missing":
20+
raise ValueError(
21+
f"Mandatory {entity_type.__name__.lower()} column '{loc}' is missing. Expected one of {entity_type.model_fields[loc].validation_alias.choices} to be present" # type: ignore
22+
)
23+
else:
24+
raise ValueError(
25+
f"Error for {entity_type.__name__.lower()} column '{loc}' with provided input '{err['input']}'. Reason: {err['msg']}"
26+
)
27+
28+
1529
def _from_dfs(
1630
node_dfs: Optional[DFS_TYPE],
1731
rel_dfs: DFS_TYPE,
@@ -63,7 +77,11 @@ def _parse_nodes(node_dfs: DFS_TYPE, rename_properties: Optional[dict[str, str]]
6377
key = rename_properties[key]
6478
properties[key] = value
6579

66-
nodes.append(Node(**top_level, properties=properties))
80+
try:
81+
nodes.append(Node(**top_level, properties=properties))
82+
except ValidationError as e:
83+
_parse_validation_error(e, Node)
84+
6785
return nodes, has_size
6886

6987

@@ -88,7 +106,11 @@ def _parse_relationships(rel_dfs: DFS_TYPE, rename_properties: Optional[dict[str
88106
key = rename_properties[key]
89107
properties[key] = value
90108

91-
relationships.append(Relationship(**top_level, properties=properties))
109+
try:
110+
relationships.append(Relationship(**top_level, properties=properties))
111+
except ValidationError as e:
112+
_parse_validation_error(e, Relationship)
113+
92114
return relationships
93115

94116

python-wrapper/tests/test_gds.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,50 @@ def test_from_gds_mocked(mocker: MockerFixture) -> None:
132132
(1, 2, "REL2"),
133133
(2, 0, "REL"),
134134
]
135+
136+
137+
@pytest.mark.requires_neo4j_and_gds
138+
def test_from_gds_node_errors(gds: Any) -> None:
139+
from neo4j_viz.gds import from_gds
140+
141+
nodes = pd.DataFrame(
142+
{
143+
"nodeId": [0, 1, 2],
144+
"labels": [["A"], ["C"], ["A", "B"]],
145+
"component": [1, 4, 2],
146+
"score": [1337, -42, 3.14],
147+
"size": [-0.1, 0.2, 0.3],
148+
}
149+
)
150+
rels = pd.DataFrame(
151+
{
152+
"sourceNodeId": [0, 1, 2],
153+
"targetNodeId": [1, 2, 0],
154+
"relationshipType": ["REL", "REL2", "REL"],
155+
}
156+
)
157+
158+
with gds.graph.construct("flo", nodes, rels) as G:
159+
with pytest.raises(
160+
ValueError,
161+
match=r"Error for node property 'size' with provided input '-0.1'. Reason: Input should be greater than or equal to 0",
162+
):
163+
from_gds(
164+
gds,
165+
G,
166+
additional_node_properties=["component", "size"],
167+
node_radius_min_max=None,
168+
)
169+
170+
with gds.graph.construct("flo", nodes, rels) as G:
171+
with pytest.raises(
172+
ValueError,
173+
match=r"Error for node property 'score' with provided input '-42.0'. Reason: Input should be greater than or equal to 0",
174+
):
175+
from_gds(
176+
gds,
177+
G,
178+
size_property="score",
179+
additional_node_properties=["component", "size"],
180+
node_radius_min_max=None,
181+
)

python-wrapper/tests/test_gql_create.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,30 @@ def test_no_create_keyword() -> None:
217217
query = "(a:User {y:4})"
218218
with pytest.raises(ValueError, match=r"Query must begin with 'CREATE' \(case insensitive\)."):
219219
from_gql_create(query)
220+
221+
222+
def test_illegal_node_x() -> None:
223+
query = "CREATE (a:User {x:'tennis'})"
224+
with pytest.raises(
225+
ValueError,
226+
match="Error for node property 'x' with provided input 'tennis'. Reason: Input should be a valid integer, unable to parse string as an integer",
227+
):
228+
from_gql_create(query)
229+
230+
231+
def test_illegal_node_size() -> None:
232+
query = "CREATE (a:User {hello: 'tennis'})"
233+
with pytest.raises(
234+
ValueError,
235+
match="Error for node property 'hello'. Reason: must be a numerical value",
236+
):
237+
from_gql_create(query, size_property="hello")
238+
239+
240+
def test_illegal_rel_caption_size() -> None:
241+
query = "CREATE ()-[:LINK {caption_size: -42}]->()"
242+
with pytest.raises(
243+
ValueError,
244+
match="Error for relationship property 'caption_size' with provided input '-42'. Reason: Input should be greater than 0",
245+
):
246+
from_gql_create(query)

python-wrapper/tests/test_neo4j.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,43 @@ def test_from_neo4j_graph_full(neo4j_session: Session) -> None:
161161
(node_ids[1], node_ids[0], "2015"),
162162
(node_ids[0], node_ids[1], "2025"),
163163
]
164+
165+
166+
@pytest.mark.requires_neo4j_and_gds
167+
def test_from_neo4j_node_error(neo4j_session: Session) -> None:
168+
neo4j_session.run("MATCH (n:_CI_A|_CI_B) DETACH DELETE n")
169+
neo4j_session.run(
170+
"CREATE (a:_CI_A {name:'Alice', height:20, id:42, _id: 1337, caption: 'hello', caption_size: -5})"
171+
)
172+
graph = neo4j_session.run("MATCH (a:_CI_A) RETURN a").graph()
173+
174+
with pytest.raises(
175+
ValueError,
176+
match="Error for node property 'caption_size' with provided input '-5'. Reason: Input should be greater than or equal to 1",
177+
):
178+
from_neo4j(graph)
179+
180+
neo4j_session.run("MATCH (n:_CI_A|_CI_B) DETACH DELETE n")
181+
neo4j_session.run("CREATE (a:_CI_A {name:'Alice', height:20, id:42, _id: 1337, hello: -5})")
182+
graph = neo4j_session.run("MATCH (a:_CI_A) RETURN a").graph()
183+
with pytest.raises(
184+
ValueError,
185+
match="Error for node property 'hello' with provided input '-5'. Reason: Input should be greater than or equal to 0",
186+
):
187+
from_neo4j(graph, size_property="hello")
188+
189+
190+
@pytest.mark.requires_neo4j_and_gds
191+
def test_from_neo4j_rel_error(neo4j_session: Session) -> None:
192+
neo4j_session.run("MATCH (n:_CI_A|_CI_B) DETACH DELETE n")
193+
neo4j_session.run(
194+
"CREATE (a:_CI_A {name:'Alice', height:20, id:42, _id: 1337, caption: 'hello'})-[:KNOWS {year: 2025, id: 41, source: 1, target: 2, caption_align: 'banana'}]->"
195+
"(b:_CI_A:_CI_B {name:'Bob', height:10, id: 84, size: 11, labels: [1,2]})"
196+
)
197+
graph = neo4j_session.run("MATCH (a:_CI_A|_CI_B)-[r]->(b) RETURN a, b, r ORDER BY a").graph()
198+
199+
with pytest.raises(
200+
ValueError,
201+
match="Error for relationship property 'caption_align' with provided input 'banana'. Reason: Input should be 'top', 'center' or 'bottom'",
202+
):
203+
from_neo4j(graph)

0 commit comments

Comments
 (0)