anywidget-graph 0.1.0__py3-none-any.whl → 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- anywidget_graph/__init__.py +1 -1
- anywidget_graph/backends/__init__.py +71 -0
- anywidget_graph/backends/arango.py +201 -0
- anywidget_graph/backends/grafeo.py +70 -0
- anywidget_graph/backends/ladybug.py +94 -0
- anywidget_graph/backends/neo4j.py +143 -0
- anywidget_graph/converters/__init__.py +35 -0
- anywidget_graph/converters/base.py +77 -0
- anywidget_graph/converters/common.py +92 -0
- anywidget_graph/converters/cypher.py +107 -0
- anywidget_graph/converters/gql.py +15 -0
- anywidget_graph/converters/graphql.py +208 -0
- anywidget_graph/converters/gremlin.py +159 -0
- anywidget_graph/converters/sparql.py +167 -0
- anywidget_graph/ui/__init__.py +19 -0
- anywidget_graph/ui/icons.js +15 -0
- anywidget_graph/ui/index.js +199 -0
- anywidget_graph/ui/neo4j.js +193 -0
- anywidget_graph/ui/properties.js +137 -0
- anywidget_graph/ui/schema.js +178 -0
- anywidget_graph/ui/settings.js +299 -0
- anywidget_graph/ui/styles.css +584 -0
- anywidget_graph/ui/toolbar.js +106 -0
- anywidget_graph/widget.py +417 -226
- {anywidget_graph-0.1.0.dist-info → anywidget_graph-0.2.1.dist-info}/METADATA +4 -3
- anywidget_graph-0.2.1.dist-info/RECORD +28 -0
- anywidget_graph-0.1.0.dist-info/RECORD +0 -6
- {anywidget_graph-0.1.0.dist-info → anywidget_graph-0.2.1.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Common node and edge conversion utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def is_node(obj: Any) -> bool:
|
|
9
|
+
"""Check if an object represents a graph node."""
|
|
10
|
+
if hasattr(obj, "labels") and hasattr(obj, "element_id"):
|
|
11
|
+
return True
|
|
12
|
+
if hasattr(obj, "labels") and hasattr(obj, "properties"):
|
|
13
|
+
return True
|
|
14
|
+
if isinstance(obj, dict) and "id" in obj:
|
|
15
|
+
return True
|
|
16
|
+
return False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_relationship(obj: Any) -> bool:
|
|
20
|
+
"""Check if an object represents a graph relationship."""
|
|
21
|
+
if hasattr(obj, "type") and hasattr(obj, "start_node"):
|
|
22
|
+
return True
|
|
23
|
+
if hasattr(obj, "type") and hasattr(obj, "source") and hasattr(obj, "target"):
|
|
24
|
+
return True
|
|
25
|
+
if isinstance(obj, dict) and "source" in obj and "target" in obj:
|
|
26
|
+
return True
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_node_id(node: Any) -> str:
|
|
31
|
+
"""Extract the ID from a node object."""
|
|
32
|
+
if hasattr(node, "element_id"):
|
|
33
|
+
return str(node.element_id)
|
|
34
|
+
if hasattr(node, "id"):
|
|
35
|
+
return str(node.id)
|
|
36
|
+
if isinstance(node, dict):
|
|
37
|
+
return str(node.get("id", id(node)))
|
|
38
|
+
return str(id(node))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def node_to_dict(node: Any) -> dict[str, Any]:
|
|
42
|
+
"""Convert a node object to a dictionary."""
|
|
43
|
+
result: dict[str, Any] = {"id": get_node_id(node)}
|
|
44
|
+
|
|
45
|
+
if hasattr(node, "labels"):
|
|
46
|
+
labels = list(node.labels) if hasattr(node.labels, "__iter__") else [node.labels]
|
|
47
|
+
if labels:
|
|
48
|
+
result["label"] = labels[0]
|
|
49
|
+
result["labels"] = labels
|
|
50
|
+
|
|
51
|
+
if hasattr(node, "properties"):
|
|
52
|
+
props = node.properties if isinstance(node.properties, dict) else dict(node.properties)
|
|
53
|
+
result.update(props)
|
|
54
|
+
elif hasattr(node, "items"):
|
|
55
|
+
result.update(dict(node))
|
|
56
|
+
elif isinstance(node, dict):
|
|
57
|
+
result.update(node)
|
|
58
|
+
|
|
59
|
+
if "label" not in result and "name" in result:
|
|
60
|
+
result["label"] = result["name"]
|
|
61
|
+
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def relationship_to_dict(rel: Any) -> dict[str, Any]:
|
|
66
|
+
"""Convert a relationship object to a dictionary."""
|
|
67
|
+
result: dict[str, Any] = {}
|
|
68
|
+
|
|
69
|
+
if hasattr(rel, "start_node") and hasattr(rel, "end_node"):
|
|
70
|
+
result["source"] = get_node_id(rel.start_node)
|
|
71
|
+
result["target"] = get_node_id(rel.end_node)
|
|
72
|
+
elif hasattr(rel, "source") and hasattr(rel, "target"):
|
|
73
|
+
result["source"] = get_node_id(rel.source)
|
|
74
|
+
result["target"] = get_node_id(rel.target)
|
|
75
|
+
elif isinstance(rel, dict):
|
|
76
|
+
result["source"] = str(rel.get("source", ""))
|
|
77
|
+
result["target"] = str(rel.get("target", ""))
|
|
78
|
+
|
|
79
|
+
if hasattr(rel, "type"):
|
|
80
|
+
result["label"] = str(rel.type)
|
|
81
|
+
elif isinstance(rel, dict) and "type" in rel:
|
|
82
|
+
result["label"] = str(rel["type"])
|
|
83
|
+
elif isinstance(rel, dict) and "label" in rel:
|
|
84
|
+
result["label"] = str(rel["label"])
|
|
85
|
+
|
|
86
|
+
if hasattr(rel, "properties"):
|
|
87
|
+
props = rel.properties if isinstance(rel.properties, dict) else dict(rel.properties)
|
|
88
|
+
result.update(props)
|
|
89
|
+
elif hasattr(rel, "items") and not isinstance(rel, dict):
|
|
90
|
+
result.update(dict(rel))
|
|
91
|
+
|
|
92
|
+
return result
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Converter for Cypher/Neo4j query results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from anywidget_graph.converters.base import GraphData
|
|
8
|
+
from anywidget_graph.converters.common import (
|
|
9
|
+
get_node_id,
|
|
10
|
+
is_node,
|
|
11
|
+
is_relationship,
|
|
12
|
+
node_to_dict,
|
|
13
|
+
relationship_to_dict,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CypherConverter:
|
|
18
|
+
"""Convert Cypher query results to graph data.
|
|
19
|
+
|
|
20
|
+
Handles various result formats from Neo4j and compatible databases:
|
|
21
|
+
- Neo4j Python driver Result objects
|
|
22
|
+
- Record objects with .items() or .data() methods
|
|
23
|
+
- Path objects containing nodes and relationships
|
|
24
|
+
- Lists of nodes/relationships
|
|
25
|
+
|
|
26
|
+
Example
|
|
27
|
+
-------
|
|
28
|
+
>>> from neo4j import GraphDatabase
|
|
29
|
+
>>> driver = GraphDatabase.driver(uri, auth=auth)
|
|
30
|
+
>>> with driver.session() as session:
|
|
31
|
+
... result = session.run("MATCH (n)-[r]->(m) RETURN n, r, m LIMIT 10")
|
|
32
|
+
... converter = CypherConverter()
|
|
33
|
+
... data = converter.convert(result)
|
|
34
|
+
... print(f"Found {len(data['nodes'])} nodes")
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def convert(self, result: Any) -> GraphData:
|
|
38
|
+
"""Convert Cypher result to nodes and edges.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
result : Any
|
|
43
|
+
Query result from Neo4j driver or compatible database.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
GraphData
|
|
48
|
+
Dictionary with 'nodes' and 'edges' lists.
|
|
49
|
+
"""
|
|
50
|
+
nodes: dict[str, dict[str, Any]] = {}
|
|
51
|
+
edges: list[dict[str, Any]] = []
|
|
52
|
+
|
|
53
|
+
records = list(result) if hasattr(result, "__iter__") else [result]
|
|
54
|
+
|
|
55
|
+
for record in records:
|
|
56
|
+
for _key, value in self._extract_items(record):
|
|
57
|
+
self._process_value(value, nodes, edges)
|
|
58
|
+
|
|
59
|
+
return {"nodes": list(nodes.values()), "edges": edges}
|
|
60
|
+
|
|
61
|
+
def _extract_items(self, record: Any) -> list[tuple[str, Any]]:
|
|
62
|
+
"""Extract key-value items from a record.
|
|
63
|
+
|
|
64
|
+
Handles multiple record formats:
|
|
65
|
+
- Records with .items() method (Neo4j Record)
|
|
66
|
+
- Records with .data() method
|
|
67
|
+
- Plain dictionaries
|
|
68
|
+
"""
|
|
69
|
+
if hasattr(record, "items"):
|
|
70
|
+
items = record.items() if callable(record.items) else record.items
|
|
71
|
+
return list(items)
|
|
72
|
+
elif hasattr(record, "data"):
|
|
73
|
+
return list(record.data().items())
|
|
74
|
+
elif isinstance(record, dict):
|
|
75
|
+
return list(record.items())
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
def _process_value(
|
|
79
|
+
self,
|
|
80
|
+
value: Any,
|
|
81
|
+
nodes: dict[str, dict[str, Any]],
|
|
82
|
+
edges: list[dict[str, Any]],
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Process a single value, handling paths and collections recursively."""
|
|
85
|
+
if value is None:
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
# Handle Path objects (have .nodes and .relationships attributes)
|
|
89
|
+
if hasattr(value, "nodes") and hasattr(value, "relationships"):
|
|
90
|
+
for node in value.nodes:
|
|
91
|
+
node_id = get_node_id(node)
|
|
92
|
+
if node_id not in nodes:
|
|
93
|
+
nodes[node_id] = node_to_dict(node)
|
|
94
|
+
for rel in value.relationships:
|
|
95
|
+
edges.append(relationship_to_dict(rel))
|
|
96
|
+
# Handle individual nodes
|
|
97
|
+
elif is_node(value):
|
|
98
|
+
node_id = get_node_id(value)
|
|
99
|
+
if node_id not in nodes:
|
|
100
|
+
nodes[node_id] = node_to_dict(value)
|
|
101
|
+
# Handle individual relationships
|
|
102
|
+
elif is_relationship(value):
|
|
103
|
+
edges.append(relationship_to_dict(value))
|
|
104
|
+
# Handle lists/collections
|
|
105
|
+
elif isinstance(value, (list, tuple)):
|
|
106
|
+
for item in value:
|
|
107
|
+
self._process_value(item, nodes, edges)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Converter for ISO GQL query results.
|
|
2
|
+
|
|
3
|
+
GQL (Graph Query Language) is the ISO standard graph query language.
|
|
4
|
+
Its result format is similar to Cypher, so this module provides an
|
|
5
|
+
alias to the CypherConverter.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from anywidget_graph.converters.cypher import CypherConverter
|
|
11
|
+
|
|
12
|
+
# GQL uses the same result format as Cypher
|
|
13
|
+
GQLConverter = CypherConverter
|
|
14
|
+
|
|
15
|
+
__all__ = ["GQLConverter"]
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Converter for GraphQL query results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from anywidget_graph.converters.base import GraphData
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GraphQLConverter:
|
|
11
|
+
"""Convert GraphQL JSON responses to graph data.
|
|
12
|
+
|
|
13
|
+
Handles various GraphQL schema patterns:
|
|
14
|
+
- Relay-style connections with nodes/edges
|
|
15
|
+
- Simple lists of objects with references
|
|
16
|
+
- Nested object structures
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
id_field : str
|
|
21
|
+
Field name used for node IDs (default: "id").
|
|
22
|
+
label_field : str
|
|
23
|
+
Field name used for node labels (default: "name").
|
|
24
|
+
nodes_path : str | None
|
|
25
|
+
Dot-separated path to nodes list (e.g., "data.allPeople.nodes").
|
|
26
|
+
If None, auto-detection is used.
|
|
27
|
+
edges_path : str | None
|
|
28
|
+
Dot-separated path to edges list (e.g., "data.connections.edges").
|
|
29
|
+
If None, auto-detection is used.
|
|
30
|
+
source_field : str
|
|
31
|
+
Field name for edge source (default: "source").
|
|
32
|
+
target_field : str
|
|
33
|
+
Field name for edge target (default: "target").
|
|
34
|
+
|
|
35
|
+
Example
|
|
36
|
+
-------
|
|
37
|
+
>>> import requests
|
|
38
|
+
>>> response = requests.post(url, json={"query": query})
|
|
39
|
+
>>> result = response.json()
|
|
40
|
+
>>> converter = GraphQLConverter(
|
|
41
|
+
... nodes_path="data.characters.results",
|
|
42
|
+
... id_field="id",
|
|
43
|
+
... label_field="name",
|
|
44
|
+
... )
|
|
45
|
+
>>> data = converter.convert(result)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
id_field: str = "id",
|
|
51
|
+
label_field: str = "name",
|
|
52
|
+
nodes_path: str | None = None,
|
|
53
|
+
edges_path: str | None = None,
|
|
54
|
+
source_field: str = "source",
|
|
55
|
+
target_field: str = "target",
|
|
56
|
+
) -> None:
|
|
57
|
+
self.id_field = id_field
|
|
58
|
+
self.label_field = label_field
|
|
59
|
+
self.nodes_path = nodes_path
|
|
60
|
+
self.edges_path = edges_path
|
|
61
|
+
self.source_field = source_field
|
|
62
|
+
self.target_field = target_field
|
|
63
|
+
|
|
64
|
+
def convert(self, result: Any) -> GraphData:
|
|
65
|
+
"""Convert GraphQL result to nodes and edges.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
result : Any
|
|
70
|
+
GraphQL JSON response.
|
|
71
|
+
|
|
72
|
+
Returns
|
|
73
|
+
-------
|
|
74
|
+
GraphData
|
|
75
|
+
Dictionary with 'nodes' and 'edges' lists.
|
|
76
|
+
"""
|
|
77
|
+
# Unwrap data envelope if present
|
|
78
|
+
data = result.get("data", result) if isinstance(result, dict) else result
|
|
79
|
+
|
|
80
|
+
nodes: dict[str, dict[str, Any]] = {}
|
|
81
|
+
edges: list[dict[str, Any]] = []
|
|
82
|
+
|
|
83
|
+
# If explicit paths provided, use them
|
|
84
|
+
if self.nodes_path:
|
|
85
|
+
node_list = self._get_path(data, self.nodes_path) or []
|
|
86
|
+
for item in node_list:
|
|
87
|
+
if isinstance(item, dict):
|
|
88
|
+
node = self._item_to_node(item)
|
|
89
|
+
nodes[node["id"]] = node
|
|
90
|
+
|
|
91
|
+
if self.edges_path:
|
|
92
|
+
edge_list = self._get_path(data, self.edges_path) or []
|
|
93
|
+
for item in edge_list:
|
|
94
|
+
if isinstance(item, dict):
|
|
95
|
+
edge = self._item_to_edge(item)
|
|
96
|
+
if edge.get("source") and edge.get("target"):
|
|
97
|
+
edges.append(edge)
|
|
98
|
+
|
|
99
|
+
# Otherwise, auto-detect structure
|
|
100
|
+
if not self.nodes_path and not self.edges_path:
|
|
101
|
+
self._auto_extract(data, nodes, edges, None)
|
|
102
|
+
|
|
103
|
+
return {"nodes": list(nodes.values()), "edges": edges}
|
|
104
|
+
|
|
105
|
+
def _get_path(self, data: Any, path: str) -> Any:
|
|
106
|
+
"""Navigate nested dict by dot-separated path."""
|
|
107
|
+
for key in path.split("."):
|
|
108
|
+
if isinstance(data, dict):
|
|
109
|
+
data = data.get(key)
|
|
110
|
+
elif isinstance(data, list) and key.isdigit():
|
|
111
|
+
idx = int(key)
|
|
112
|
+
data = data[idx] if idx < len(data) else None
|
|
113
|
+
else:
|
|
114
|
+
return None
|
|
115
|
+
return data
|
|
116
|
+
|
|
117
|
+
def _item_to_node(self, item: dict[str, Any]) -> dict[str, Any]:
|
|
118
|
+
"""Convert a dict item to node format."""
|
|
119
|
+
node_id = str(item.get(self.id_field, id(item)))
|
|
120
|
+
label = item.get(self.label_field, "")
|
|
121
|
+
|
|
122
|
+
result: dict[str, Any] = {
|
|
123
|
+
"id": node_id,
|
|
124
|
+
"label": str(label) if label else node_id,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# Add scalar properties (skip nested objects and lists)
|
|
128
|
+
for key, value in item.items():
|
|
129
|
+
if key not in (self.id_field,) and not isinstance(value, (dict, list)):
|
|
130
|
+
result[key] = value
|
|
131
|
+
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
def _item_to_edge(self, item: dict[str, Any]) -> dict[str, Any]:
|
|
135
|
+
"""Convert a dict item to edge format."""
|
|
136
|
+
source = item.get(self.source_field, item.get("from", ""))
|
|
137
|
+
target = item.get(self.target_field, item.get("to", ""))
|
|
138
|
+
|
|
139
|
+
# Handle nested node references
|
|
140
|
+
if isinstance(source, dict):
|
|
141
|
+
source = source.get(self.id_field, "")
|
|
142
|
+
if isinstance(target, dict):
|
|
143
|
+
target = target.get(self.id_field, "")
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
"source": str(source),
|
|
147
|
+
"target": str(target),
|
|
148
|
+
"label": str(item.get("label", item.get("type", item.get("__typename", "")))),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
def _auto_extract(
|
|
152
|
+
self,
|
|
153
|
+
data: Any,
|
|
154
|
+
nodes: dict[str, dict[str, Any]],
|
|
155
|
+
edges: list[dict[str, Any]],
|
|
156
|
+
parent_id: str | None,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Auto-extract graph structure from nested data."""
|
|
159
|
+
if isinstance(data, dict):
|
|
160
|
+
# Check if this looks like a node (has ID field)
|
|
161
|
+
if self.id_field in data:
|
|
162
|
+
node = self._item_to_node(data)
|
|
163
|
+
nodes[node["id"]] = node
|
|
164
|
+
|
|
165
|
+
# Create edge from parent
|
|
166
|
+
if parent_id and parent_id != node["id"]:
|
|
167
|
+
edges.append(
|
|
168
|
+
{
|
|
169
|
+
"source": parent_id,
|
|
170
|
+
"target": node["id"],
|
|
171
|
+
"label": "contains",
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Process nested references
|
|
176
|
+
for key, value in data.items():
|
|
177
|
+
if isinstance(value, dict) and self.id_field in value:
|
|
178
|
+
# Direct reference to another node
|
|
179
|
+
ref_node = self._item_to_node(value)
|
|
180
|
+
nodes[ref_node["id"]] = ref_node
|
|
181
|
+
edges.append(
|
|
182
|
+
{
|
|
183
|
+
"source": node["id"],
|
|
184
|
+
"target": ref_node["id"],
|
|
185
|
+
"label": key,
|
|
186
|
+
}
|
|
187
|
+
)
|
|
188
|
+
elif isinstance(value, list):
|
|
189
|
+
# List of possible references
|
|
190
|
+
for item in value:
|
|
191
|
+
if isinstance(item, dict) and self.id_field in item:
|
|
192
|
+
ref_node = self._item_to_node(item)
|
|
193
|
+
nodes[ref_node["id"]] = ref_node
|
|
194
|
+
edges.append(
|
|
195
|
+
{
|
|
196
|
+
"source": node["id"],
|
|
197
|
+
"target": ref_node["id"],
|
|
198
|
+
"label": key,
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
else:
|
|
202
|
+
# Not a node, recurse into values
|
|
203
|
+
for value in data.values():
|
|
204
|
+
self._auto_extract(value, nodes, edges, parent_id)
|
|
205
|
+
|
|
206
|
+
elif isinstance(data, list):
|
|
207
|
+
for item in data:
|
|
208
|
+
self._auto_extract(item, nodes, edges, parent_id)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Converter for Gremlin/TinkerPop query results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from anywidget_graph.converters.base import GraphData
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GremlinConverter:
|
|
11
|
+
"""Convert Gremlin traversal results to graph data.
|
|
12
|
+
|
|
13
|
+
Handles various TinkerPop result types:
|
|
14
|
+
- Vertex objects
|
|
15
|
+
- Edge objects
|
|
16
|
+
- Path objects from path() traversals
|
|
17
|
+
- ValueMap results from valueMap() traversals
|
|
18
|
+
- Dictionaries from elementMap() traversals
|
|
19
|
+
|
|
20
|
+
Example
|
|
21
|
+
-------
|
|
22
|
+
>>> from gremlin_python.driver import client
|
|
23
|
+
>>> gremlin_client = client.Client('ws://localhost:8182/gremlin', 'g')
|
|
24
|
+
>>> result = gremlin_client.submit("g.V().limit(10)").all().result()
|
|
25
|
+
>>> converter = GremlinConverter()
|
|
26
|
+
>>> data = converter.convert(result)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def convert(self, result: Any) -> GraphData:
|
|
30
|
+
"""Convert Gremlin result to nodes and edges.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
result : Any
|
|
35
|
+
Query result from Gremlin server.
|
|
36
|
+
|
|
37
|
+
Returns
|
|
38
|
+
-------
|
|
39
|
+
GraphData
|
|
40
|
+
Dictionary with 'nodes' and 'edges' lists.
|
|
41
|
+
"""
|
|
42
|
+
nodes: dict[str, dict[str, Any]] = {}
|
|
43
|
+
edges: list[dict[str, Any]] = []
|
|
44
|
+
|
|
45
|
+
items = list(result) if hasattr(result, "__iter__") else [result]
|
|
46
|
+
|
|
47
|
+
for item in items:
|
|
48
|
+
self._process_item(item, nodes, edges)
|
|
49
|
+
|
|
50
|
+
return {"nodes": list(nodes.values()), "edges": edges}
|
|
51
|
+
|
|
52
|
+
def _process_item(
|
|
53
|
+
self,
|
|
54
|
+
item: Any,
|
|
55
|
+
nodes: dict[str, dict[str, Any]],
|
|
56
|
+
edges: list[dict[str, Any]],
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Process a single result item."""
|
|
59
|
+
if item is None:
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
# Path object (has .objects attribute)
|
|
63
|
+
if hasattr(item, "objects"):
|
|
64
|
+
for obj in item.objects:
|
|
65
|
+
self._process_item(obj, nodes, edges)
|
|
66
|
+
# Vertex
|
|
67
|
+
elif self._is_vertex(item):
|
|
68
|
+
node_id = str(item.id)
|
|
69
|
+
if node_id not in nodes:
|
|
70
|
+
nodes[node_id] = self._vertex_to_dict(item)
|
|
71
|
+
# Edge
|
|
72
|
+
elif self._is_edge(item):
|
|
73
|
+
edges.append(self._edge_to_dict(item))
|
|
74
|
+
# Dict (from valueMap, elementMap, etc.)
|
|
75
|
+
elif isinstance(item, dict):
|
|
76
|
+
self._process_dict(item, nodes, edges)
|
|
77
|
+
# List/collection
|
|
78
|
+
elif isinstance(item, (list, tuple)):
|
|
79
|
+
for sub_item in item:
|
|
80
|
+
self._process_item(sub_item, nodes, edges)
|
|
81
|
+
|
|
82
|
+
def _is_vertex(self, obj: Any) -> bool:
|
|
83
|
+
"""Check if object is a Gremlin Vertex."""
|
|
84
|
+
# Vertex has id and label but not outV/inV
|
|
85
|
+
return hasattr(obj, "id") and hasattr(obj, "label") and not hasattr(obj, "outV") and not hasattr(obj, "inV")
|
|
86
|
+
|
|
87
|
+
def _is_edge(self, obj: Any) -> bool:
|
|
88
|
+
"""Check if object is a Gremlin Edge."""
|
|
89
|
+
# Edge has outV and inV
|
|
90
|
+
return hasattr(obj, "outV") and hasattr(obj, "inV")
|
|
91
|
+
|
|
92
|
+
def _vertex_to_dict(self, vertex: Any) -> dict[str, Any]:
|
|
93
|
+
"""Convert Vertex to dict."""
|
|
94
|
+
result: dict[str, Any] = {
|
|
95
|
+
"id": str(vertex.id),
|
|
96
|
+
"label": str(vertex.label),
|
|
97
|
+
}
|
|
98
|
+
# Extract properties if available
|
|
99
|
+
if hasattr(vertex, "properties"):
|
|
100
|
+
props = vertex.properties
|
|
101
|
+
if callable(props):
|
|
102
|
+
props = props()
|
|
103
|
+
if hasattr(props, "__iter__"):
|
|
104
|
+
for prop in props:
|
|
105
|
+
if hasattr(prop, "key") and hasattr(prop, "value"):
|
|
106
|
+
result[prop.key] = prop.value
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
def _edge_to_dict(self, edge: Any) -> dict[str, Any]:
|
|
110
|
+
"""Convert Edge to dict."""
|
|
111
|
+
result: dict[str, Any] = {
|
|
112
|
+
"source": str(edge.outV.id if hasattr(edge.outV, "id") else edge.outV),
|
|
113
|
+
"target": str(edge.inV.id if hasattr(edge.inV, "id") else edge.inV),
|
|
114
|
+
"label": str(edge.label),
|
|
115
|
+
}
|
|
116
|
+
# Extract properties if available
|
|
117
|
+
if hasattr(edge, "properties"):
|
|
118
|
+
props = edge.properties
|
|
119
|
+
if callable(props):
|
|
120
|
+
props = props()
|
|
121
|
+
if isinstance(props, dict):
|
|
122
|
+
result.update(props)
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
def _process_dict(
|
|
126
|
+
self,
|
|
127
|
+
item: dict[str, Any],
|
|
128
|
+
nodes: dict[str, dict[str, Any]],
|
|
129
|
+
edges: list[dict[str, Any]],
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Process a dictionary result (from elementMap/valueMap)."""
|
|
132
|
+
# Check for edge indicators
|
|
133
|
+
if "IN" in item and "OUT" in item:
|
|
134
|
+
# This is an edge from elementMap()
|
|
135
|
+
edge = {
|
|
136
|
+
"source": str(item.get("OUT", {}).get("id", item.get("OUT", ""))),
|
|
137
|
+
"target": str(item.get("IN", {}).get("id", item.get("IN", ""))),
|
|
138
|
+
"label": str(item.get("label", "")),
|
|
139
|
+
}
|
|
140
|
+
# Add other properties
|
|
141
|
+
for key, value in item.items():
|
|
142
|
+
if key not in ("id", "label", "IN", "OUT"):
|
|
143
|
+
edge[key] = self._flatten_value(value)
|
|
144
|
+
edges.append(edge)
|
|
145
|
+
elif "id" in item:
|
|
146
|
+
# This is a vertex
|
|
147
|
+
node_id = str(item["id"])
|
|
148
|
+
if node_id not in nodes:
|
|
149
|
+
node = {"id": node_id}
|
|
150
|
+
for key, value in item.items():
|
|
151
|
+
if key != "id":
|
|
152
|
+
node[key] = self._flatten_value(value)
|
|
153
|
+
nodes[node_id] = node
|
|
154
|
+
|
|
155
|
+
def _flatten_value(self, value: Any) -> Any:
|
|
156
|
+
"""Flatten single-element lists (common in valueMap results)."""
|
|
157
|
+
if isinstance(value, list) and len(value) == 1:
|
|
158
|
+
return value[0]
|
|
159
|
+
return value
|