From 03f9f451718c9f36d0f71d3caf653274a96518ed Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Sun, 7 Jun 2026 20:06:22 -0700 Subject: [PATCH 1/2] Add schema graph view --- src-tauri/src/lib.rs | 147 +++++++++++++++++++ src/App.css | 5 + src/App.tsx | 327 ++++++++++++++++++++++++++++++++++++------- 3 files changed, 431 insertions(+), 48 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 774c7f8..2663eab 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -46,6 +46,8 @@ struct GraphNode { offset: Option, #[serde(rename = "hiddenCount", skip_serializing_if = "Option::is_none")] hidden_count: Option, + #[serde(rename = "colorKey", skip_serializing_if = "Option::is_none")] + color_key: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -410,6 +412,7 @@ fn graph_node_from_value(val: &Value) -> Option { expand_node_id: None, offset: None, hidden_count: None, + color_key: None, }) } @@ -516,6 +519,7 @@ fn make_expander_node(parent_id: &str, hidden_count: usize, offset: usize) -> Gr expand_node_id: Some(parent_id.to_string()), offset: Some(offset), hidden_count: Some(hidden_count), + color_key: None, } } @@ -1071,6 +1075,7 @@ fn compute_cluster_levels( expand_node_id: None, offset: None, hidden_count: None, + color_key: None, }) .collect(); let cluster_node_index: HashMap = cluster_nodes @@ -1298,6 +1303,7 @@ fn collect_edge_graph(conn: &Connection, limit: usize) -> Result Result String { + value.replace('\\', "\\\\").replace('\'', "\\'") +} + +fn table_properties(conn: &Connection, table_name: &str) -> Vec<(String, String, bool)> { + let Ok(mut result) = conn.query(&format!( + "CALL TABLE_INFO('{}') RETURN *;", + escaped_identifier(table_name) + )) else { + return Vec::new(); + }; + + let mut properties = Vec::new(); + for row in &mut result { + let Some(name) = row.get(1).map(value_to_string) else { + continue; + }; + let property_type = row + .get(2) + .map(value_to_string) + .unwrap_or_else(|| "UNKNOWN".to_string()); + let is_primary_key = row + .get(4) + .map(|value| matches!(value, Value::Bool(true))) + .unwrap_or(false); + properties.push((name, property_type, is_primary_key)); + } + properties +} + +fn schema_node_properties( + conn: &Connection, + table_name: &str, + include_primary_key: bool, +) -> HashMap { + table_properties(conn, table_name) + .into_iter() + .map(|(name, property_type, is_primary_key)| { + let value = if include_primary_key && is_primary_key { + format!("{property_type} primary key") + } else { + property_type + }; + (name, value) + }) + .collect() +} + +fn collect_schema_graph(conn: &Connection) -> Result { + let mut tables = conn + .query("CALL show_tables() RETURN *;") + .map_err(|e| format!("Schema table query failed: {e}"))?; + + let mut node_tables = Vec::new(); + let mut rel_tables = Vec::new(); + for row in &mut tables { + if row.len() < 3 { + continue; + } + let table_name = value_to_string(&row[1]); + let table_type = value_to_string(&row[2]); + if table_type.eq_ignore_ascii_case("NODE") { + node_tables.push(table_name); + } else if table_type.eq_ignore_ascii_case("REL") { + rel_tables.push(table_name); + } + } + + node_tables.sort(); + rel_tables.sort(); + + let nodes: Vec = node_tables + .iter() + .map(|table_name| GraphNode { + id: table_name.clone(), + name: table_name.clone(), + label: "Node Table".to_string(), + properties: schema_node_properties(conn, table_name, true), + table_id: None, + rowid: None, + community: None, + expansion_kind: None, + expand_node_id: None, + offset: None, + hidden_count: None, + color_key: Some(format!("schema-node:{table_name}")), + }) + .collect(); + + let node_table_set: HashSet<&str> = node_tables.iter().map(String::as_str).collect(); + let mut links = Vec::new(); + let mut seen_links = HashSet::new(); + + for rel_table in &rel_tables { + let Ok(mut result) = conn.query(&format!( + "CALL SHOW_CONNECTION('{}') RETURN *;", + escaped_identifier(rel_table) + )) else { + continue; + }; + + for row in &mut result { + if row.len() < 2 { + continue; + } + let source = value_to_string(&row[0]); + let target = value_to_string(&row[1]); + if !node_table_set.contains(source.as_str()) + || !node_table_set.contains(target.as_str()) + { + continue; + } + merge_link( + &mut links, + &mut seen_links, + GraphLink { + source, + target, + label: rel_table.clone(), + }, + ); + } + } + + Ok(graph_data_without_clusters(nodes, links, "collect_schema_graph")) +} + fn add_expanders( graph: &GraphData, visible_ids: &HashSet, @@ -1678,6 +1811,18 @@ fn get_graph( .map(|full_graph| seed_graph_from_full(full_graph, llm_config.as_ref())) } +#[tauri::command] +fn get_schema_graph(state: State, id: usize) -> Result { + let databases = get_all_databases(&state); + let db_info = databases.get(id).ok_or("Database not found")?; + + let db = Database::new(&db_info.path, SystemConfig::default()) + .map_err(|e| format!("Failed to open database: {}", e))?; + let conn = Connection::new(&db).map_err(|e| format!("Failed to create connection: {}", e))?; + + collect_schema_graph(&conn) +} + #[tauri::command] fn search_nodes( state: State, @@ -1885,6 +2030,7 @@ fn execute_query( expand_node_id: None, offset: None, hidden_count: None, + color_key: None, }); } } @@ -1952,6 +2098,7 @@ pub fn run() { add_database, get_directories, get_graph, + get_schema_graph, search_nodes, get_node_neighborhood, expand_node, diff --git a/src/App.css b/src/App.css index bf823c7..66522d3 100644 --- a/src/App.css +++ b/src/App.css @@ -374,6 +374,11 @@ body { color: white; } +.cluster-controls button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + .cluster-controls button:hover:not(.active) { color: var(--text-primary); } diff --git a/src/App.tsx b/src/App.tsx index ddff77d..3daa547 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -97,6 +97,8 @@ interface LlmClusterNameResult { error?: string | null } +type GraphViewMode = 'data' | 'schema' + interface NormalizedGraphLink { source: string target: string @@ -122,6 +124,21 @@ interface ForceGraphLink { label: string } +type ForceGraphNodeObject = GraphNode & { + x?: number + y?: number +} + +type ForceGraphPosition = ForceGraphNodeObject & { + x: number + y: number +} + +type ForceGraphLinkObject = ForceGraphLink & { + source?: string | number | ForceGraphNodeObject + target?: string | number | ForceGraphNodeObject +} + interface SigmaNodeAttributes extends Record { x: number y: number @@ -145,6 +162,7 @@ interface SigmaGraphViewProps { labelNodeIds: Set newlyExpandedNodeIds: Set darkMode: boolean + alwaysShowEdgeLabels: boolean getNodeDisplayName: (node: GraphNode) => string getNodeColor: (node: GraphNode) => string getNodeSize: (node: GraphNode) => number @@ -245,6 +263,43 @@ function normalizeGraphData(graphData: GraphData): NormalizedGraphData { } } +function expandSchemaSelfLinks(graphData: NormalizedGraphData): NormalizedGraphData { + const nodes = graphData.nodes.map(node => ({ ...node })) + const nodeById = new Map(nodes.map(node => [node.id, node])) + const links: NormalizedGraphLink[] = [] + + graphData.links.forEach((link, index) => { + if (link.source !== link.target) { + links.push({ ...link }) + return + } + + const sourceNode = nodeById.get(link.source) + const duplicateId = `__schema_self_target__:${link.label}:${link.target}:${index}` + nodes.push({ + ...(sourceNode || { + id: link.target, + name: link.target, + label: 'Node Table', + }), + id: duplicateId, + properties: {}, + }) + links.push({ + ...link, + target: duplicateId, + }) + }) + + return { + ...graphData, + nodes, + links, + csr: buildGraphCsr({ ...graphData, nodes, links }), + csrArrowIpc: undefined, + } +} + const EXPANDER_PREFIX = '__expand__:' function isExpanderNode(node: GraphNode) { @@ -635,15 +690,25 @@ function drawSigmaEdgeLabel( sourceData: SigmaEdgeLabelNodeData, targetData: SigmaEdgeLabelNodeData, hoveredEdgeId: string | null, + alwaysShow: boolean, textColor: string, backgroundColor: string, ) { - if (edgeData.key !== hoveredEdgeId) return + if (!alwaysShow && edgeData.key !== hoveredEdgeId) return if (!edgeData.label) return const dx = targetData.x - sourceData.x const dy = targetData.y - sourceData.y const distance = Math.hypot(dx, dy) + + if (distance < 1) { + const loopRadius = sourceData.size + 18 + const labelX = sourceData.x + const labelY = sourceData.y - loopRadius - 8 + drawSigmaEdgeLabelBox(context, edgeData.label, labelX, labelY, 96, textColor, backgroundColor) + return + } + if (distance < sourceData.size + targetData.size + 18) return const fontSize = 11 @@ -686,6 +751,89 @@ function drawSigmaEdgeLabel( context.restore() } +function drawSigmaEdgeLabelBox( + context: CanvasRenderingContext2D, + text: string, + centerX: number, + centerY: number, + maxWidth: number, + textColor: string, + backgroundColor: string, +) { + const fontSize = 11 + const paddingX = 5 + const paddingY = 3 + + context.save() + context.font = `600 ${fontSize}px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif` + context.textBaseline = 'middle' + + let label = text + let textWidth = context.measureText(label).width + if (textWidth > maxWidth) { + while (label.length > 1 && context.measureText(`${label}...`).width > maxWidth) { + label = label.slice(0, -1) + } + label = `${label}...` + textWidth = context.measureText(label).width + } + + const boxWidth = textWidth + paddingX * 2 + const boxHeight = fontSize + paddingY * 2 + const boxX = centerX - boxWidth / 2 + const boxY = centerY - boxHeight / 2 + + context.fillStyle = backgroundColor + drawRoundedRect(context, boxX, boxY, boxWidth, boxHeight, 4) + context.fill() + context.fillStyle = textColor + context.fillText(label, boxX + paddingX, centerY) + context.restore() +} + +function getForceEndpointPosition(endpoint: ForceGraphLinkObject['source']): ForceGraphPosition | null { + if (!endpoint || typeof endpoint !== 'object') return null + if (typeof endpoint.x !== 'number' || typeof endpoint.y !== 'number') return null + return endpoint as ForceGraphPosition +} + +function drawForceSchemaLinkLabel( + link: ForceGraphLinkObject, + context: CanvasRenderingContext2D, + globalScale: number, + textColor: string, + backgroundColor: string, +) { + if (!link.label) return + const source = getForceEndpointPosition(link.source) + const target = getForceEndpointPosition(link.target) + if (!source || !target) return + + const midX = (source.x + target.x) / 2 + const midY = (source.y + target.y) / 2 + const fontSize = Math.max(2.5, 12 / globalScale) + const paddingX = 5 / globalScale + const paddingY = 3 / globalScale + + context.save() + context.font = `600 ${fontSize}px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif` + context.textAlign = 'center' + context.textBaseline = 'middle' + + const textWidth = context.measureText(link.label).width + const boxWidth = textWidth + paddingX * 2 + const boxHeight = fontSize + paddingY * 2 + const boxX = midX - boxWidth / 2 + const boxY = midY - boxHeight / 2 + + context.fillStyle = backgroundColor + drawRoundedRect(context, boxX, boxY, boxWidth, boxHeight, 4 / globalScale) + context.fill() + context.fillStyle = textColor + context.fillText(link.label, midX, midY) + context.restore() +} + function createInitialLayout(graphData: NormalizedGraphData) { const nodeCount = Math.max(1, graphData.nodes.length) const degrees: Record = {} @@ -715,7 +863,7 @@ function createInitialLayout(graphData: NormalizedGraphData) { return { degrees, positions } } -function SigmaGraphView({ graphData, labelNodeIds, newlyExpandedNodeIds, darkMode, getNodeDisplayName, getNodeColor, getNodeSize, getEdgeColor, onNodeClick }: SigmaGraphViewProps) { +function SigmaGraphView({ graphData, labelNodeIds, newlyExpandedNodeIds, darkMode, alwaysShowEdgeLabels, getNodeDisplayName, getNodeColor, getNodeSize, getEdgeColor, onNodeClick }: SigmaGraphViewProps) { const containerRef = useRef(null) const rendererRef = useRef(null) const hoveredEdgeRef = useRef(null) @@ -804,6 +952,7 @@ function SigmaGraphView({ graphData, labelNodeIds, newlyExpandedNodeIds, darkMod sourceData, targetData, hoveredEdgeRef.current, + alwaysShowEdgeLabels, edgeLabelTextColor, edgeLabelBackgroundColor, ) @@ -846,7 +995,7 @@ function SigmaGraphView({ graphData, labelNodeIds, newlyExpandedNodeIds, darkMod rendererRef.current?.kill() rendererRef.current = null } - }, [graph, darkMode, onNodeClick]) + }, [graph, darkMode, alwaysShowEdgeLabels, onNodeClick]) return
} @@ -855,8 +1004,13 @@ function App() { const [databases, setDatabases] = useState([]) const [selectedId, setSelectedId] = useState(0) const [graphData, setGraphData] = useState({ nodes: [], links: [] }) + const [schemaGraphData, setSchemaGraphData] = useState({ nodes: [], links: [] }) + const [schemaGraphDatabaseId, setSchemaGraphDatabaseId] = useState(null) + const [graphViewMode, setGraphViewMode] = useState('data') const [loading, setLoading] = useState(false) + const [schemaLoading, setSchemaLoading] = useState(false) const [error, setError] = useState(null) + const [schemaError, setSchemaError] = useState(null) const [sidebarOpen, setSidebarOpen] = useState(true) const [darkMode, setDarkMode] = useState(true) const [filePickerOpen, setFilePickerOpen] = useState(false) @@ -1060,6 +1214,32 @@ function App() { } }, [selectedId, databases.length, currentLlmClusterConfig, resetClusterNameRequests]) + const fetchSchemaGraphData = useCallback((force = false) => { + if (databases.length === 0) { + setSchemaGraphData({ nodes: [], links: [] }) + setSchemaGraphDatabaseId(null) + return + } + if (!force && schemaGraphDatabaseId === selectedId && schemaGraphData.nodes.length > 0) { + return + } + + setSchemaLoading(true) + setSchemaError(null) + invokeCommand('get_schema_graph', { id: selectedId }) + .then(data => { + setSchemaGraphData(data) + setSchemaGraphDatabaseId(selectedId) + setSchemaLoading(false) + }) + .catch(err => { + setSchemaError(String(err)) + setSchemaGraphData({ nodes: [], links: [] }) + setSchemaGraphDatabaseId(null) + setSchemaLoading(false) + }) + }, [databases.length, schemaGraphData.nodes.length, schemaGraphDatabaseId, selectedId]) + /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { fetchGraphData() @@ -1177,6 +1357,7 @@ function App() { }, [graphData, selectedId, currentLlmClusterConfig, resetClusterNameRequests]) const normalizedGraphData = useMemo(() => normalizeGraphData(graphData), [graphData]) + const normalizedSchemaGraphData = useMemo(() => normalizeGraphData(schemaGraphData), [schemaGraphData]) const displayColumnOptions = useMemo(() => { const columnsByLabel = new Map>() graphData.nodes.forEach(node => { @@ -1222,6 +1403,13 @@ function App() { ? buildClusterDrillGraph(normalizedGraphData, clusterLevels, visibleClusterPath) : normalizedGraphData ), [clusterViewEnabled, clusterLevels, normalizedGraphData, visibleClusterPath]) + const visibleSchemaGraphData = useMemo(() => ( + expandSchemaSelfLinks(normalizedSchemaGraphData) + ), [normalizedSchemaGraphData]) + const displayedGraphData = graphViewMode === 'schema' ? visibleSchemaGraphData : visibleGraphData + const displayedGraphSourceData = graphViewMode === 'schema' ? schemaGraphData : graphData + const displayedLoading = graphViewMode === 'schema' ? schemaLoading : loading + const displayedError = graphViewMode === 'schema' ? schemaError : error useEffect(() => { resetClusterNameRequests() @@ -1335,19 +1523,28 @@ function App() { /* eslint-enable react-hooks/set-state-in-effect */ const forceGraphData = useMemo>(() => ({ - nodes: visibleGraphData.nodes.map(node => ({ ...node })), - links: visibleGraphData.links.map(link => ({ ...link })), - }), [visibleGraphData]) + nodes: displayedGraphData.nodes.map(node => ({ ...node })), + links: displayedGraphData.links.map(link => ({ ...link })), + }), [displayedGraphData]) + + useEffect(() => { + if (renderer !== 'force' || !graphRef.current) return + const linkForce = graphRef.current.d3Force('link') as { + distance?: (distance: number | ((link: ForceGraphLinkObject) => number)) => unknown + } | undefined + linkForce?.distance?.(graphViewMode === 'schema' ? 150 : 40) + graphRef.current.d3ReheatSimulation?.() + }, [renderer, graphViewMode, forceGraphData]) const nodeDegree = useMemo(() => { const degrees: Record = {} - visibleGraphData.nodes.forEach(n => degrees[n.id] = 0) - visibleGraphData.links.forEach(link => { + displayedGraphData.nodes.forEach(n => degrees[n.id] = 0) + displayedGraphData.links.forEach(link => { degrees[link.source] = (degrees[link.source] || 0) + 1 degrees[link.target] = (degrees[link.target] || 0) + 1 }) return degrees - }, [visibleGraphData]) + }, [displayedGraphData]) const maxDegree = useMemo(() => Math.max(1, ...Object.values(nodeDegree)), [nodeDegree]) @@ -1355,15 +1552,15 @@ function App() { return new Set( [ ...lastExpandedNodeIds, - ...[...visibleGraphData.nodes] + ...[...displayedGraphData.nodes] .sort((a, b) => (nodeDegree[b.id] || 0) - (nodeDegree[a.id] || 0)) .filter(node => !isExpanderNode(node)) - .slice(0, 5) + .slice(0, graphViewMode === 'schema' ? 12 : 5) .map(node => node.id), ...(focusedNodeId ? [focusedNodeId] : []), ] ) - }, [lastExpandedNodeIds, visibleGraphData.nodes, nodeDegree, focusedNodeId]) + }, [lastExpandedNodeIds, displayedGraphData.nodes, nodeDegree, focusedNodeId, graphViewMode]) const getNodeColor = useCallback((node: GraphNode) => { const key = node.colorKey || (node.community === undefined ? node.label : `${node.label}:${node.community}`) @@ -1391,6 +1588,7 @@ function App() { }, [nodeDegree, maxDegree]) const handleVisibleNodeClick = useCallback((nodeId: string) => { + if (graphViewMode === 'schema') return const clusterNode = parseClusterNodeId(nodeId) if (clusterNode) { setClusterViewEnabled(true) @@ -1402,7 +1600,7 @@ function App() { return } handleNodeClick(nodeId) - }, [handleNodeClick]) + }, [graphViewMode, handleNodeClick]) // eslint-disable-next-line @typescript-eslint/no-explicit-any const paintNode = useCallback((node: any, ctx: CanvasRenderingContext2D) => { @@ -1472,6 +1670,8 @@ function App() { className={`file-item ${selectedId === db.id ? 'active' : ''}`} onClick={() => { setSelectedId(db.id) + setGraphViewMode('data') + setSchemaError(null) setNodeSearch('') setSearchResults([]) setSearchError(null) @@ -1606,50 +1806,69 @@ function App() {
- {loading + {displayedLoading ? 'Loading...' + : graphViewMode === 'schema' + ? `${schemaGraphData.nodes.length} schema tables, ${schemaGraphData.links.length} relationships` : clusterViewEnabled && clusterLevels.length > 0 ? currentClusterLevel ? `${visibleGraphData.nodes.length} clusters at level ${currentClusterLevel.level}, ${visibleGraphData.links.length} aggregate edges` : `${visibleGraphData.nodes.length} nodes in cluster, ${visibleGraphData.links.length} edges` : `${graphData.nodes.length} nodes, ${graphData.links.length} edges`} - {focusedNodeId && Focused node {focusedNodeId}} - {error && {error}} + {graphViewMode === 'data' && focusedNodeId && Focused node {focusedNodeId}} + {displayedError && {displayedError}}
- {clusterLevels.length > 0 && ( + {(clusterLevels.length > 0 || databases.length > 0) && (
+ {clusterLevels.length > 0 && ( + <> + + {clusterBreadcrumbs.map((item, index) => ( + + ))} + + + )} - {clusterBreadcrumbs.map((item, index) => ( - - ))} -
)} @@ -1677,13 +1896,14 @@ function App() {
- {!loading && !error && graphData.nodes.length > 0 && renderer === 'sigma' && ( + {!displayedLoading && !displayedError && displayedGraphSourceData.nodes.length > 0 && renderer === 'sigma' && ( )} - {!loading && !error && graphData.nodes.length > 0 && renderer === 'force' && ( + {!displayedLoading && !displayedError && displayedGraphSourceData.nodes.length > 0 && renderer === 'force' && ( `${node.label}: ${getDisplayName(node)}`} linkLabel={(link) => link.label} linkColor={(link) => getEdgeColor(link.label)} + linkCanvasObjectMode={() => graphViewMode === 'schema' ? 'after' : 'replace'} + linkCanvasObject={(link, context, globalScale) => { + if (graphViewMode !== 'schema') return + drawForceSchemaLinkLabel( + link, + context, + globalScale, + '#111827', + darkMode ? 'rgba(248, 250, 252, 0.92)' : 'rgba(255, 255, 255, 0.92)', + ) + }} linkWidth={2.5} linkDirectionalArrowLength={6} linkDirectionalArrowRelPos={1} From 18cfe783c11b707066fa79836b9a6648ff8cff11 Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Sun, 7 Jun 2026 21:29:33 -0700 Subject: [PATCH 2/2] cargo: add linker path for linux --- src-tauri/.cargo/config.toml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src-tauri/.cargo/config.toml b/src-tauri/.cargo/config.toml index de46d08..46be2bd 100644 --- a/src-tauri/.cargo/config.toml +++ b/src-tauri/.cargo/config.toml @@ -21,3 +21,15 @@ rustflags = [ "-C", "link-arg=-Wl,-rpath,@executable_path/../Frameworks", "-C", "link-arg=-Wl,-rpath,@executable_path/../../liblbug", ] + +[target.aarch64-unknown-linux-gnu] +rustflags = [ + "-C", "link-arg=-Wl,-rpath,$ORIGIN/../../liblbug", + "-C", "link-arg=-Wl,-rpath,$ORIGIN/../../icebug/lib", +] + +[target.x86_64-unknown-linux-gnu] +rustflags = [ + "-C", "link-arg=-Wl,-rpath,$ORIGIN/../../liblbug", + "-C", "link-arg=-Wl,-rpath,$ORIGIN/../../icebug/lib", +]