feat(ui): Neo4j graph explorer with interactive relationship visualization

Add Graph sub-tab in Data Sources UI for Neo4j: force-directed SVG view of
Product-Supplier nodes and SUPPLIES/RELATED_TO/PART_OF/COMPATIBLE_WITH edges.
Backend GET /api/sql/graph/neo4j with rel_type filter and edge limit.
This commit is contained in:
mo
2026-06-27 15:48:56 +00:00
parent a4c9b60079
commit 5828113f53
4 changed files with 495 additions and 3 deletions
+94
View File
@@ -502,6 +502,86 @@ def _sample_cassandra(object_name: str, limit: int) -> dict[str, Any]:
return _run_cassandra(f"SELECT * FROM {ks}.{table} LIMIT {limit}", limit)
def _graph_neo4j(edge_limit: int = 60, rel_type: str | None = None) -> dict[str, Any]:
"""Return a sampled subgraph (nodes + edges) for interactive graph visualization."""
t0 = time.perf_counter()
driver = _neo4j_driver()
try:
with driver.session() as session:
total = session.run("MATCH (n) RETURN count(n) AS c").single()["c"]
rel_types = [
r["relationshipType"]
for r in session.run(
"CALL db.relationshipTypes() YIELD relationshipType RETURN relationshipType"
)
]
if rel_type:
cypher = """
MATCH (a)-[r]->(b)
WHERE type(r) = $rel_type
WITH a, r, b LIMIT $limit
RETURN a, r, b, labels(a)[0] AS la, type(r) AS rt, labels(b)[0] AS lb
"""
params: dict[str, Any] = {"rel_type": rel_type, "limit": edge_limit}
else:
cypher = """
MATCH (a)-[r]->(b)
WITH a, r, b LIMIT $limit
RETURN a, r, b, labels(a)[0] AS la, type(r) AS rt, labels(b)[0] AS lb
"""
params = {"limit": edge_limit}
records = session.run(cypher, **params).data()
def _node_key(node: Any, label: str) -> str:
props = dict(node)
if label == "Product" and props.get("product_id") is not None:
return f"Product:{props['product_id']}"
if label == "Supplier" and props.get("supplier_id") is not None:
return f"Supplier:{props['supplier_id']}"
nid = getattr(node, "element_id", None) or getattr(node, "id", None)
return f"{label}:{nid}"
nodes_map: dict[str, dict[str, Any]] = {}
edges: list[dict[str, Any]] = []
for rec in records:
a, b, rt = rec["a"], rec["b"], rec["rt"]
la, lb = rec["la"], rec["lb"]
aid, bid = _node_key(a, la), _node_key(b, lb)
if aid not in nodes_map:
ap = dict(a)
nodes_map[aid] = {
"id": aid,
"label": la,
"name": ap.get("name") or str(ap.get("product_id") or ap.get("supplier_id") or aid),
"properties": {k: _fmt(v) for k, v in ap.items()},
}
if bid not in nodes_map:
bp = dict(b)
nodes_map[bid] = {
"id": bid,
"label": lb,
"name": bp.get("name") or str(bp.get("product_id") or bp.get("supplier_id") or bid),
"properties": {k: _fmt(v) for k, v in bp.items()},
}
edges.append({"id": f"{aid}|{rt}|{bid}", "source": aid, "target": bid, "type": rt})
elapsed_ms = int((time.perf_counter() - t0) * 1000)
return {
"ok": True,
"nodes": list(nodes_map.values()),
"edges": edges,
"rel_types": rel_types,
"stats": {
"total_nodes_db": total,
"returned_nodes": len(nodes_map),
"returned_edges": len(edges),
},
"elapsed_ms": elapsed_ms,
}
finally:
driver.close()
def _sample_neo4j(object_name: str, limit: int) -> dict[str, Any]:
label = object_name.split(".")[-1]
cypher = f"MATCH (n:`{label}`) RETURN n LIMIT {limit}"
@@ -590,6 +670,20 @@ async def get_sample(engine: str, object: str = Query(..., min_length=1), limit:
return JSONResponse({"ok": False, "error": str(exc)[:500]}, status_code=502)
@router.get("/graph/neo4j")
async def get_neo4j_graph(
limit: int = Query(60, ge=10, le=150),
rel_type: str | None = Query(None),
):
try:
result = _graph_neo4j(limit, rel_type or None)
if not result.get("ok"):
return JSONResponse(result, status_code=422)
return result
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)[:500]}, status_code=502)
@router.post("/execute")
async def execute_sql(body: SqlRequest):
sql = body.sql.strip().rstrip(";")