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:
@@ -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(";")
|
||||
|
||||
Reference in New Issue
Block a user