|
1
|
""" |
|
2
|
Text-based graph export and import for navegador. |
|
3
|
|
|
4
|
Exports the full graph to a deterministic JSON Lines format (.jsonl) |
|
5
|
suitable for committing to version control. Each line is a self-contained |
|
6
|
JSON object — either a node or an edge. |
|
7
|
|
|
8
|
Format: |
|
9
|
{"kind": "node", "label": "Function", "props": {"name": "foo", ...}} |
|
10
|
{"kind": "edge", "type": "CALLS", "from": {...}, "to": {...}} |
|
11
|
""" |
|
12
|
|
|
13
|
import json |
|
14
|
import logging |
|
15
|
from pathlib import Path |
|
16
|
|
|
17
|
from navegador.graph.store import GraphStore |
|
18
|
|
|
19
|
logger = logging.getLogger(__name__) |
|
20
|
|
|
21
|
|
|
22
|
def export_graph(store: GraphStore, output_path: str | Path) -> dict[str, int]: |
|
23
|
""" |
|
24
|
Export the full graph to a JSONL file. |
|
25
|
|
|
26
|
Returns: |
|
27
|
Dict with counts: nodes, edges. |
|
28
|
""" |
|
29
|
output_path = Path(output_path) |
|
30
|
output_path.parent.mkdir(parents=True, exist_ok=True) |
|
31
|
|
|
32
|
nodes = _export_nodes(store) |
|
33
|
edges = _export_edges(store) |
|
34
|
|
|
35
|
# Sort for deterministic output |
|
36
|
nodes.sort(key=lambda n: (n["label"], json.dumps(n["props"], sort_keys=True))) |
|
37
|
edges.sort( |
|
38
|
key=lambda e: ( |
|
39
|
e["type"], |
|
40
|
json.dumps(e["from"], sort_keys=True), |
|
41
|
json.dumps(e["to"], sort_keys=True), |
|
42
|
) |
|
43
|
) |
|
44
|
|
|
45
|
with output_path.open("w", encoding="utf-8") as f: |
|
46
|
for node in nodes: |
|
47
|
f.write(json.dumps(node, sort_keys=True) + "\n") |
|
48
|
for edge in edges: |
|
49
|
f.write(json.dumps(edge, sort_keys=True) + "\n") |
|
50
|
|
|
51
|
logger.info("Exported %d nodes, %d edges to %s", len(nodes), len(edges), output_path) |
|
52
|
return {"nodes": len(nodes), "edges": len(edges)} |
|
53
|
|
|
54
|
|
|
55
|
def import_graph(store: GraphStore, input_path: str | Path, clear: bool = True) -> dict[str, int]: |
|
56
|
""" |
|
57
|
Import a graph from a JSONL file. |
|
58
|
|
|
59
|
Args: |
|
60
|
store: Target GraphStore. |
|
61
|
input_path: Path to the JSONL file. |
|
62
|
clear: If True (default), wipe the graph before importing. |
|
63
|
|
|
64
|
Returns: |
|
65
|
Dict with counts: nodes, edges. |
|
66
|
""" |
|
67
|
input_path = Path(input_path) |
|
68
|
if not input_path.exists(): |
|
69
|
raise FileNotFoundError(f"Export file not found: {input_path}") |
|
70
|
|
|
71
|
if clear: |
|
72
|
store.clear() |
|
73
|
|
|
74
|
node_count = 0 |
|
75
|
edge_count = 0 |
|
76
|
|
|
77
|
with input_path.open("r", encoding="utf-8") as f: |
|
78
|
for line in f: |
|
79
|
line = line.strip() |
|
80
|
if not line: |
|
81
|
continue |
|
82
|
record = json.loads(line) |
|
83
|
|
|
84
|
if record["kind"] == "node": |
|
85
|
_import_node(store, record) |
|
86
|
node_count += 1 |
|
87
|
elif record["kind"] == "edge": |
|
88
|
_import_edge(store, record) |
|
89
|
edge_count += 1 |
|
90
|
|
|
91
|
logger.info("Imported %d nodes, %d edges from %s", node_count, edge_count, input_path) |
|
92
|
return {"nodes": node_count, "edges": edge_count} |
|
93
|
|
|
94
|
|
|
95
|
def _export_nodes(store: GraphStore) -> list[dict]: |
|
96
|
"""Export all nodes with their labels and properties.""" |
|
97
|
result = store.query("MATCH (n) RETURN labels(n)[0] AS label, properties(n) AS props") |
|
98
|
nodes = [] |
|
99
|
for row in result.result_set or []: |
|
100
|
label = row[0] |
|
101
|
props = row[1] if isinstance(row[1], dict) else {} |
|
102
|
nodes.append({"kind": "node", "label": label, "props": props}) |
|
103
|
return nodes |
|
104
|
|
|
105
|
|
|
106
|
def _export_edges(store: GraphStore) -> list[dict]: |
|
107
|
"""Export all edges with type and endpoint identifiers.""" |
|
108
|
result = store.query( |
|
109
|
"MATCH (a)-[r]->(b) " |
|
110
|
"RETURN type(r) AS type, labels(a)[0] AS from_label, " |
|
111
|
"a.name AS from_name, coalesce(a.file_path, a.path, '') AS from_path, " |
|
112
|
"labels(b)[0] AS to_label, b.name AS to_name, " |
|
113
|
"coalesce(b.file_path, b.path, '') AS to_path" |
|
114
|
) |
|
115
|
edges = [] |
|
116
|
for row in result.result_set or []: |
|
117
|
edges.append( |
|
118
|
{ |
|
119
|
"kind": "edge", |
|
120
|
"type": row[0], |
|
121
|
"from": {"label": row[1], "name": row[2], "path": row[3]}, |
|
122
|
"to": {"label": row[4], "name": row[5], "path": row[6]}, |
|
123
|
} |
|
124
|
) |
|
125
|
return edges |
|
126
|
|
|
127
|
|
|
128
|
def _import_node(store: GraphStore, record: dict) -> None: |
|
129
|
"""Create a node from an export record.""" |
|
130
|
label = record["label"] |
|
131
|
props = record["props"] |
|
132
|
# Ensure required merge keys exist |
|
133
|
if "name" not in props: |
|
134
|
props["name"] = "" |
|
135
|
if "file_path" not in props and "path" not in props: |
|
136
|
props["file_path"] = "" |
|
137
|
|
|
138
|
prop_str = ", ".join(f"n.{k} = ${k}" for k in props) |
|
139
|
# Use name + file_path or path for merge key |
|
140
|
if "file_path" in props: |
|
141
|
cypher = f"MERGE (n:{label} {{name: $name, file_path: $file_path}}) SET {prop_str}" |
|
142
|
else: |
|
143
|
cypher = f"MERGE (n:{label} {{name: $name, path: $path}}) SET {prop_str}" |
|
144
|
store.query(cypher, props) |
|
145
|
|
|
146
|
|
|
147
|
def _import_edge(store: GraphStore, record: dict) -> None: |
|
148
|
"""Create an edge from an export record.""" |
|
149
|
edge_type = record["type"] |
|
150
|
from_info = record["from"] |
|
151
|
to_info = record["to"] |
|
152
|
|
|
153
|
from_key = "name: $from_name" |
|
154
|
to_key = "name: $to_name" |
|
155
|
|
|
156
|
params = { |
|
157
|
"from_name": from_info["name"], |
|
158
|
"to_name": to_info["name"], |
|
159
|
} |
|
160
|
|
|
161
|
if from_info.get("path"): |
|
162
|
from_key += ", file_path: $from_path" |
|
163
|
params["from_path"] = from_info["path"] |
|
164
|
|
|
165
|
if to_info.get("path"): |
|
166
|
to_key += ", path: $to_path" |
|
167
|
params["to_path"] = to_info["path"] |
|
168
|
|
|
169
|
cypher = ( |
|
170
|
f"MATCH (a:{from_info['label']} {{{from_key}}}), " |
|
171
|
f"(b:{to_info['label']} {{{to_key}}}) " |
|
172
|
f"MERGE (a)-[r:{edge_type}]->(b)" |
|
173
|
) |
|
174
|
store.query(cypher, params) |
|
175
|
|