modified: backend/src/controllers/schemaController.ts

modified:   frontend/package.json
	modified:   frontend/src/pages/DatabaseSchema.tsx
This commit is contained in:
2026-01-27 23:49:47 +03:00
parent d8dffb5ee1
commit c780979b57
3 changed files with 119 additions and 61 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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">
{data.foreign_keys.some(fk => fk.column === col.name) && ( {col.is_primary && <Key size={11} className="text-yellow-500" />}
<Link size={12} className="text-blue-500 flex-shrink-0" /> {data.foreign_keys.some(fk => fk.column === col.name) && (
)} <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}`; // Add nodes
tablePositions.set(table.name, { x, y }); schema.tables.forEach((table) => {
tablePositions.set(fullName, { x, y }); const nodeId = `${table.schema}.${table.name}`;
const width = 280;
const height = getNodeHeight(table);
g.setNode(nodeId, { width, height });
});
nodes.push({ // Add edges
id: fullName, schema.tables.forEach((table) => {
type: 'table', const sourceId = `${table.schema}.${table.name}`;
position: { x, y }, table.foreign_keys.forEach((fk) => {
data: { ...table } as TableInfo & Record<string, unknown>, 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<string, unknown>,
sourcePosition: Position.Right,
targetPosition: Position.Left,
};
});
// Create edges
const edges: Edge[] = [];
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}`;
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={{