modified: backend/src/controllers/schemaController.ts
modified: frontend/package.json modified: frontend/src/pages/DatabaseSchema.tsx
This commit is contained in:
@@ -38,13 +38,15 @@ async function parsePostgresSchema(databaseId: string): Promise<SchemaData> {
|
|||||||
throw new Error('Database not found or not active');
|
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(`
|
const tablesResult = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
t.table_schema,
|
t.table_schema,
|
||||||
t.table_name,
|
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
|
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')
|
WHERE t.table_schema NOT IN ('pg_catalog', 'information_schema')
|
||||||
AND t.table_type = 'BASE TABLE'
|
AND t.table_type = 'BASE TABLE'
|
||||||
ORDER BY t.table_schema, t.table_name
|
ORDER BY t.table_schema, t.table_name
|
||||||
@@ -61,8 +63,10 @@ async function parsePostgresSchema(databaseId: string): Promise<SchemaData> {
|
|||||||
c.is_nullable,
|
c.is_nullable,
|
||||||
c.column_default,
|
c.column_default,
|
||||||
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_primary,
|
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
|
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 (
|
LEFT JOIN (
|
||||||
SELECT ku.column_name, ku.table_schema, ku.table_name
|
SELECT ku.column_name, ku.table_schema, ku.table_name
|
||||||
FROM information_schema.table_constraints tc
|
FROM information_schema.table_constraints tc
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@monaco-editor/react": "^4.6.0",
|
"@monaco-editor/react": "^4.6.0",
|
||||||
"@tanstack/react-query": "^5.14.2",
|
"@tanstack/react-query": "^5.14.2",
|
||||||
"@xyflow/react": "^12.0.0",
|
"@xyflow/react": "^12.0.0",
|
||||||
|
"@dagrejs/dagre": "^1.1.4",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"cmdk": "^0.2.0",
|
"cmdk": "^0.2.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
@@ -10,8 +10,10 @@ import {
|
|||||||
useNodesState,
|
useNodesState,
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
MarkerType,
|
MarkerType,
|
||||||
|
Position,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
|
import Dagre from '@dagrejs/dagre';
|
||||||
import { Database as DatabaseIcon, RefreshCw, Loader2, Key, Link } from 'lucide-react';
|
import { Database as DatabaseIcon, RefreshCw, Loader2, Key, Link } from 'lucide-react';
|
||||||
import { databasesApi, schemaApi, TableInfo, SchemaData } from '@/services/api';
|
import { databasesApi, schemaApi, TableInfo, SchemaData } from '@/services/api';
|
||||||
import { Database } from '@/types';
|
import { Database } from '@/types';
|
||||||
@@ -19,34 +21,48 @@ import { Database } from '@/types';
|
|||||||
// Custom node for table
|
// Custom node for table
|
||||||
function TableNode({ data }: { data: TableInfo & Record<string, unknown> }) {
|
function TableNode({ data }: { data: TableInfo & Record<string, unknown> }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border-2 border-gray-300 rounded-lg shadow-lg min-w-64 overflow-hidden">
|
<div className="bg-white border-2 border-gray-300 rounded-lg shadow-lg min-w-64 max-w-80 overflow-hidden">
|
||||||
{/* Table header */}
|
{/* Table header */}
|
||||||
<div className="bg-primary-600 text-white px-3 py-2 font-semibold text-sm">
|
<div className="bg-primary-600 text-white px-3 py-2 font-semibold text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DatabaseIcon size={14} />
|
<DatabaseIcon size={14} />
|
||||||
<span>{data.schema}.{data.name}</span>
|
<span className="truncate">{data.name}</span>
|
||||||
</div>
|
</div>
|
||||||
{data.comment && (
|
{data.comment && (
|
||||||
<div className="text-xs text-primary-200 mt-1 font-normal">{data.comment}</div>
|
<div className="text-xs text-primary-200 mt-1 font-normal truncate" title={data.comment}>
|
||||||
|
{data.comment}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Columns */}
|
{/* Columns */}
|
||||||
<div className="divide-y divide-gray-100">
|
<div className="divide-y divide-gray-100 max-h-64 overflow-y-auto">
|
||||||
{data.columns.map((col) => (
|
{data.columns.map((col) => (
|
||||||
<div
|
<div
|
||||||
key={col.name}
|
key={col.name}
|
||||||
className="px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-gray-50"
|
className="px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-gray-50 cursor-default group relative"
|
||||||
|
title={col.comment || undefined}
|
||||||
>
|
>
|
||||||
{col.is_primary && <Key size={12} className="text-yellow-500 flex-shrink-0" />}
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{col.is_primary && <Key size={11} className="text-yellow-500" />}
|
||||||
{data.foreign_keys.some(fk => fk.column === col.name) && (
|
{data.foreign_keys.some(fk => fk.column === col.name) && (
|
||||||
<Link size={12} className="text-blue-500 flex-shrink-0" />
|
<Link size={11} className="text-blue-500" />
|
||||||
)}
|
)}
|
||||||
<span className={`font-medium ${col.is_primary ? 'text-yellow-700' : 'text-gray-700'}`}>
|
</div>
|
||||||
|
<span className={`font-medium truncate ${col.is_primary ? 'text-yellow-700' : 'text-gray-700'}`}>
|
||||||
{col.name}
|
{col.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-400 flex-1">{col.type}</span>
|
<span className="text-gray-400 truncate flex-1 text-right">{col.type}</span>
|
||||||
{!col.nullable && <span className="text-red-400 text-[10px]">NOT NULL</span>}
|
{!col.nullable && <span className="text-red-400 text-[10px] flex-shrink-0">NN</span>}
|
||||||
|
|
||||||
|
{/* Tooltip for column comment */}
|
||||||
|
{col.comment && (
|
||||||
|
<div className="absolute left-full ml-2 top-0 z-50 hidden group-hover:block">
|
||||||
|
<div className="bg-gray-900 text-white text-xs rounded px-2 py-1 max-w-64 whitespace-normal shadow-lg">
|
||||||
|
{col.comment}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{data.columns.length === 0 && (
|
{data.columns.length === 0 && (
|
||||||
@@ -54,7 +70,12 @@ function TableNode({ data }: { data: TableInfo & Record<string, unknown> }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Column comments tooltip would go here in a more advanced version */}
|
{/* FK summary */}
|
||||||
|
{data.foreign_keys.length > 0 && (
|
||||||
|
<div className="bg-blue-50 px-3 py-1.5 text-xs text-blue-600 border-t border-blue-100">
|
||||||
|
{data.foreign_keys.length} FK
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -63,50 +84,80 @@ const nodeTypes = {
|
|||||||
table: TableNode,
|
table: TableNode,
|
||||||
};
|
};
|
||||||
|
|
||||||
function generateNodesAndEdges(schema: SchemaData): { nodes: Node[]; edges: Edge[] } {
|
// Calculate node height based on columns count
|
||||||
const nodes: Node[] = [];
|
function getNodeHeight(table: TableInfo): number {
|
||||||
const edges: Edge[] = [];
|
const headerHeight = 44;
|
||||||
const tablePositions = new Map<string, { x: number; y: number }>();
|
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
|
function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[] } {
|
||||||
const cols = Math.ceil(Math.sqrt(schema.tables.length));
|
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||||
const nodeWidth = 280;
|
|
||||||
const nodeHeight = 200;
|
|
||||||
const gapX = 100;
|
|
||||||
const gapY = 80;
|
|
||||||
|
|
||||||
schema.tables.forEach((table, index) => {
|
g.setGraph({
|
||||||
const col = index % cols;
|
rankdir: 'LR', // Left to Right
|
||||||
const row = Math.floor(index / cols);
|
nodesep: 80, // Horizontal separation between nodes
|
||||||
const x = col * (nodeWidth + gapX);
|
ranksep: 120, // Vertical separation between ranks
|
||||||
const y = row * (nodeHeight + gapY);
|
marginx: 50,
|
||||||
|
marginy: 50,
|
||||||
const fullName = `${table.schema}.${table.name}`;
|
|
||||||
tablePositions.set(table.name, { x, y });
|
|
||||||
tablePositions.set(fullName, { x, y });
|
|
||||||
|
|
||||||
nodes.push({
|
|
||||||
id: fullName,
|
|
||||||
type: 'table',
|
|
||||||
position: { x, y },
|
|
||||||
data: { ...table } as TableInfo & Record<string, unknown>,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create edges for foreign keys
|
// Add nodes
|
||||||
|
schema.tables.forEach((table) => {
|
||||||
|
const nodeId = `${table.schema}.${table.name}`;
|
||||||
|
const width = 280;
|
||||||
|
const height = getNodeHeight(table);
|
||||||
|
g.setNode(nodeId, { width, height });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add edges
|
||||||
schema.tables.forEach((table) => {
|
schema.tables.forEach((table) => {
|
||||||
const sourceId = `${table.schema}.${table.name}`;
|
const sourceId = `${table.schema}.${table.name}`;
|
||||||
|
|
||||||
table.foreign_keys.forEach((fk) => {
|
table.foreign_keys.forEach((fk) => {
|
||||||
// Try to find target table
|
const targetTable = schema.tables.find(t => t.name === fk.references_table);
|
||||||
let targetId = schema.tables.find(t => t.name === fk.references_table);
|
if (targetTable) {
|
||||||
if (targetId) {
|
const targetId = `${targetTable.schema}.${targetTable.name}`;
|
||||||
|
g.setEdge(sourceId, targetId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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<string, unknown>,
|
||||||
|
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) => {
|
||||||
|
const targetTable = schema.tables.find(t => t.name === fk.references_table);
|
||||||
|
if (targetTable) {
|
||||||
|
const targetId = `${targetTable.schema}.${targetTable.name}`;
|
||||||
edges.push({
|
edges.push({
|
||||||
id: `${fk.constraint_name}`,
|
id: `${fk.constraint_name}`,
|
||||||
source: sourceId,
|
source: sourceId,
|
||||||
target: `${targetId.schema}.${targetId.name}`,
|
target: targetId,
|
||||||
sourceHandle: fk.column,
|
|
||||||
targetHandle: fk.references_column,
|
|
||||||
type: 'smoothstep',
|
type: 'smoothstep',
|
||||||
animated: true,
|
animated: true,
|
||||||
style: { stroke: '#3b82f6', strokeWidth: 2 },
|
style: { stroke: '#3b82f6', strokeWidth: 2 },
|
||||||
@@ -114,9 +165,10 @@ function generateNodesAndEdges(schema: SchemaData): { nodes: Node[]; edges: Edge
|
|||||||
type: MarkerType.ArrowClosed,
|
type: MarkerType.ArrowClosed,
|
||||||
color: '#3b82f6',
|
color: '#3b82f6',
|
||||||
},
|
},
|
||||||
label: fk.column,
|
label: `${fk.column} → ${fk.references_column}`,
|
||||||
labelStyle: { fontSize: 10, fill: '#666' },
|
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 schema = schemaResponse?.data;
|
||||||
|
|
||||||
const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
|
const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(() => {
|
||||||
if (!schema) return { nodes: [], edges: [] };
|
if (!schema) return { nodes: [], edges: [] };
|
||||||
return generateNodesAndEdges(schema);
|
return getLayoutedElements(schema);
|
||||||
}, [schema]);
|
}, [schema]);
|
||||||
|
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||||
|
|
||||||
// Update nodes when schema changes
|
// Update nodes when schema changes
|
||||||
useMemo(() => {
|
useEffect(() => {
|
||||||
if (initialNodes.length > 0) {
|
if (layoutedNodes.length > 0) {
|
||||||
setNodes(initialNodes);
|
setNodes(layoutedNodes);
|
||||||
setEdges(initialEdges);
|
setEdges(layoutedEdges);
|
||||||
}
|
}
|
||||||
}, [initialNodes, initialEdges, setNodes, setEdges]);
|
}, [layoutedNodes, layoutedEdges, setNodes, setEdges]);
|
||||||
|
|
||||||
if (isLoadingDatabases) {
|
if (isLoadingDatabases) {
|
||||||
return (
|
return (
|
||||||
@@ -180,7 +232,7 @@ export default function DatabaseSchema() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col -m-8 bg-white" style={{ height: 'calc(100vh - 65px)' }}>
|
<div className="flex flex-col -m-8 bg-white overflow-hidden" style={{ height: 'calc(100vh - 65px)' }}>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center gap-4 p-3 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
<div className="flex items-center gap-4 p-3 border-b border-gray-200 bg-gray-50 flex-shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -235,7 +287,7 @@ export default function DatabaseSchema() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedDbId && !isLoadingSchema && schema && (
|
{selectedDbId && !isLoadingSchema && schema && nodes.length > 0 && (
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
@@ -243,6 +295,7 @@ export default function DatabaseSchema() {
|
|||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
fitView
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.2 }}
|
||||||
minZoom={0.1}
|
minZoom={0.1}
|
||||||
maxZoom={2}
|
maxZoom={2}
|
||||||
defaultEdgeOptions={{
|
defaultEdgeOptions={{
|
||||||
|
|||||||
Reference in New Issue
Block a user