diff --git a/backend/src/controllers/schemaController.ts b/backend/src/controllers/schemaController.ts index a0aeab0..4749b0d 100644 --- a/backend/src/controllers/schemaController.ts +++ b/backend/src/controllers/schemaController.ts @@ -38,13 +38,15 @@ async function parsePostgresSchema(databaseId: string): Promise { throw new Error('Database not found or not active'); } - // Get all tables + // Get all tables with comments via pg_catalog const tablesResult = await pool.query(` SELECT t.table_schema, t.table_name, - obj_description((t.table_schema || '.' || t.table_name)::regclass, 'pg_class') as table_comment + pg_catalog.obj_description(c.oid, 'pg_class') as table_comment FROM information_schema.tables t + LEFT JOIN pg_catalog.pg_class c ON c.relname = t.table_name + LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace AND n.nspname = t.table_schema WHERE t.table_schema NOT IN ('pg_catalog', 'information_schema') AND t.table_type = 'BASE TABLE' ORDER BY t.table_schema, t.table_name @@ -61,8 +63,10 @@ async function parsePostgresSchema(databaseId: string): Promise { c.is_nullable, c.column_default, CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_primary, - col_description((c.table_schema || '.' || c.table_name)::regclass, c.ordinal_position) as column_comment + pg_catalog.col_description(pc.oid, c.ordinal_position) as column_comment FROM information_schema.columns c + LEFT JOIN pg_catalog.pg_class pc ON pc.relname = c.table_name + LEFT JOIN pg_catalog.pg_namespace pn ON pn.oid = pc.relnamespace AND pn.nspname = c.table_schema LEFT JOIN ( SELECT ku.column_name, ku.table_schema, ku.table_name FROM information_schema.table_constraints tc diff --git a/frontend/package.json b/frontend/package.json index c11f130..14f1139 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@monaco-editor/react": "^4.6.0", "@tanstack/react-query": "^5.14.2", "@xyflow/react": "^12.0.0", + "@dagrejs/dagre": "^1.1.4", "axios": "^1.6.2", "clsx": "^2.0.0", "cmdk": "^0.2.0", diff --git a/frontend/src/pages/DatabaseSchema.tsx b/frontend/src/pages/DatabaseSchema.tsx index 6ef1275..15f842d 100644 --- a/frontend/src/pages/DatabaseSchema.tsx +++ b/frontend/src/pages/DatabaseSchema.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { ReactFlow, @@ -10,8 +10,10 @@ import { useNodesState, useEdgesState, MarkerType, + Position, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; +import Dagre from '@dagrejs/dagre'; import { Database as DatabaseIcon, RefreshCw, Loader2, Key, Link } from 'lucide-react'; import { databasesApi, schemaApi, TableInfo, SchemaData } from '@/services/api'; import { Database } from '@/types'; @@ -19,34 +21,48 @@ import { Database } from '@/types'; // Custom node for table function TableNode({ data }: { data: TableInfo & Record }) { return ( -
+
{/* Table header */}
- {data.schema}.{data.name} + {data.name}
{data.comment && ( -
{data.comment}
+
+ {data.comment} +
)}
{/* Columns */} -
+
{data.columns.map((col) => (
- {col.is_primary && } - {data.foreign_keys.some(fk => fk.column === col.name) && ( - - )} - +
+ {col.is_primary && } + {data.foreign_keys.some(fk => fk.column === col.name) && ( + + )} +
+ {col.name} - {col.type} - {!col.nullable && NOT NULL} + {col.type} + {!col.nullable && NN} + + {/* Tooltip for column comment */} + {col.comment && ( +
+
+ {col.comment} +
+
+ )}
))} {data.columns.length === 0 && ( @@ -54,7 +70,12 @@ function TableNode({ data }: { data: TableInfo & Record }) { )}
- {/* Column comments tooltip would go here in a more advanced version */} + {/* FK summary */} + {data.foreign_keys.length > 0 && ( +
+ {data.foreign_keys.length} FK +
+ )}
); } @@ -63,50 +84,80 @@ const nodeTypes = { table: TableNode, }; -function generateNodesAndEdges(schema: SchemaData): { nodes: Node[]; edges: Edge[] } { - const nodes: Node[] = []; - const edges: Edge[] = []; - const tablePositions = new Map(); +// Calculate node height based on columns count +function getNodeHeight(table: TableInfo): number { + const headerHeight = 44; + const columnHeight = 28; + const fkBarHeight = table.foreign_keys.length > 0 ? 28 : 0; + const maxVisibleColumns = 10; + const visibleColumns = Math.min(table.columns.length, maxVisibleColumns); + return headerHeight + (visibleColumns * columnHeight) + fkBarHeight + 8; +} - // Calculate positions in a grid layout - const cols = Math.ceil(Math.sqrt(schema.tables.length)); - const nodeWidth = 280; - const nodeHeight = 200; - const gapX = 100; - const gapY = 80; +function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[] } { + const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); - schema.tables.forEach((table, index) => { - const col = index % cols; - const row = Math.floor(index / cols); - const x = col * (nodeWidth + gapX); - const y = row * (nodeHeight + gapY); + g.setGraph({ + rankdir: 'LR', // Left to Right + nodesep: 80, // Horizontal separation between nodes + ranksep: 120, // Vertical separation between ranks + marginx: 50, + marginy: 50, + }); - const fullName = `${table.schema}.${table.name}`; - tablePositions.set(table.name, { x, y }); - tablePositions.set(fullName, { x, y }); + // Add nodes + schema.tables.forEach((table) => { + const nodeId = `${table.schema}.${table.name}`; + const width = 280; + const height = getNodeHeight(table); + g.setNode(nodeId, { width, height }); + }); - nodes.push({ - id: fullName, - type: 'table', - position: { x, y }, - data: { ...table } as TableInfo & Record, + // Add edges + schema.tables.forEach((table) => { + const sourceId = `${table.schema}.${table.name}`; + table.foreign_keys.forEach((fk) => { + const targetTable = schema.tables.find(t => t.name === fk.references_table); + if (targetTable) { + const targetId = `${targetTable.schema}.${targetTable.name}`; + g.setEdge(sourceId, targetId); + } }); }); - // Create edges for foreign keys + // Run layout + Dagre.layout(g); + + // Create nodes with positions + const nodes: Node[] = schema.tables.map((table) => { + const nodeId = `${table.schema}.${table.name}`; + const nodeWithPosition = g.node(nodeId); + + return { + id: nodeId, + type: 'table', + position: { + x: nodeWithPosition.x - nodeWithPosition.width / 2, + y: nodeWithPosition.y - nodeWithPosition.height / 2, + }, + data: { ...table } as TableInfo & Record, + sourcePosition: Position.Right, + targetPosition: Position.Left, + }; + }); + + // Create edges + const edges: Edge[] = []; schema.tables.forEach((table) => { const sourceId = `${table.schema}.${table.name}`; - table.foreign_keys.forEach((fk) => { - // Try to find target table - let targetId = schema.tables.find(t => t.name === fk.references_table); - if (targetId) { + const targetTable = schema.tables.find(t => t.name === fk.references_table); + if (targetTable) { + const targetId = `${targetTable.schema}.${targetTable.name}`; edges.push({ id: `${fk.constraint_name}`, source: sourceId, - target: `${targetId.schema}.${targetId.name}`, - sourceHandle: fk.column, - targetHandle: fk.references_column, + target: targetId, type: 'smoothstep', animated: true, style: { stroke: '#3b82f6', strokeWidth: 2 }, @@ -114,9 +165,10 @@ function generateNodesAndEdges(schema: SchemaData): { nodes: Node[]; edges: Edge type: MarkerType.ArrowClosed, color: '#3b82f6', }, - label: fk.column, + label: `${fk.column} → ${fk.references_column}`, labelStyle: { fontSize: 10, fill: '#666' }, - labelBgStyle: { fill: 'white', fillOpacity: 0.8 }, + labelBgStyle: { fill: 'white', fillOpacity: 0.9 }, + labelBgPadding: [4, 2] as [number, number], }); } }); @@ -155,21 +207,21 @@ export default function DatabaseSchema() { const schema = schemaResponse?.data; - const { nodes: initialNodes, edges: initialEdges } = useMemo(() => { + const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(() => { if (!schema) return { nodes: [], edges: [] }; - return generateNodesAndEdges(schema); + return getLayoutedElements(schema); }, [schema]); - const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); // Update nodes when schema changes - useMemo(() => { - if (initialNodes.length > 0) { - setNodes(initialNodes); - setEdges(initialEdges); + useEffect(() => { + if (layoutedNodes.length > 0) { + setNodes(layoutedNodes); + setEdges(layoutedEdges); } - }, [initialNodes, initialEdges, setNodes, setEdges]); + }, [layoutedNodes, layoutedEdges, setNodes, setEdges]); if (isLoadingDatabases) { return ( @@ -180,7 +232,7 @@ export default function DatabaseSchema() { } return ( -
+
{/* Toolbar */}
@@ -235,7 +287,7 @@ export default function DatabaseSchema() {
)} - {selectedDbId && !isLoadingSchema && schema && ( + {selectedDbId && !isLoadingSchema && schema && nodes.length > 0 && (