modified: frontend/src/pages/DatabaseSchema.tsx

This commit is contained in:
2026-01-28 00:47:55 +03:00
parent 553202c1b2
commit 49938815fe

View File

@@ -55,22 +55,39 @@ function TableNode({ data }: { data: TableInfo & Record<string, unknown> }) {
className="px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-gray-50 cursor-default relative" className="px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-gray-50 cursor-default relative"
title={col.comment || undefined} title={col.comment || undefined}
> >
{/* Handles on both sides for dynamic edge routing */}
{pkColumns.has(col.name) && ( {pkColumns.has(col.name) && (
<Handle <>
type="target" <Handle
position={Position.Left} type="target"
id={`target-${col.name}`} position={Position.Left}
className="!bg-yellow-500 !w-2 !h-2 !border-0 !-left-1" id={`target-left-${col.name}`}
/> className="!bg-yellow-500 !w-2 !h-2 !border-0 !-left-1"
/>
<Handle
type="target"
position={Position.Right}
id={`target-right-${col.name}`}
className="!bg-yellow-500 !w-2 !h-2 !border-0 !-right-1"
/>
</>
)} )}
{fkColumns.has(col.name) && ( {fkColumns.has(col.name) && (
<Handle <>
type="source" <Handle
position={Position.Right} type="source"
id={`source-${col.name}`} position={Position.Left}
className="!bg-blue-500 !w-2 !h-2 !border-0 !-right-1" id={`source-left-${col.name}`}
/> className="!bg-blue-500 !w-2 !h-2 !border-0 !-left-1"
/>
<Handle
type="source"
position={Position.Right}
id={`source-right-${col.name}`}
className="!bg-blue-500 !w-2 !h-2 !border-0 !-right-1"
/>
</>
)} )}
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 flex-shrink-0">
@@ -132,7 +149,67 @@ function calculateTableWeights(schema: SchemaData): Map<string, number> {
return weights; return weights;
} }
function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[] } { // Build edges with optimal handle selection based on node positions
function buildEdgesWithPositions(
schema: SchemaData,
nodePositions: Map<string, { x: number; y: number; width: number }>
): Edge[] {
const edges: Edge[] = [];
schema.tables.forEach((table) => {
const sourceId = `${table.schema}.${table.name}`;
const sourcePos = nodePositions.get(sourceId);
if (!sourcePos) return;
table.foreign_keys.forEach((fk) => {
let targetTable = schema.tables.find(t =>
t.name === fk.references_table && t.schema === table.schema
);
if (!targetTable) {
targetTable = schema.tables.find(t => t.name === fk.references_table);
}
if (targetTable) {
const targetId = `${targetTable.schema}.${targetTable.name}`;
const targetPos = nodePositions.get(targetId);
if (!targetPos) return;
// Calculate center positions
const sourceCenterX = sourcePos.x + sourcePos.width / 2;
const targetCenterX = targetPos.x + targetPos.width / 2;
// Choose handles based on relative positions
const sourceOnRight = sourceCenterX < targetCenterX;
const sourceHandle = sourceOnRight
? `source-right-${fk.column}`
: `source-left-${fk.column}`;
const targetHandle = sourceOnRight
? `target-left-${fk.references_column}`
: `target-right-${fk.references_column}`;
edges.push({
id: `${fk.constraint_name}`,
source: sourceId,
target: targetId,
sourceHandle,
targetHandle,
type: 'smoothstep',
style: { stroke: '#3b82f6', strokeWidth: 1.5 },
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#3b82f6',
width: 15,
height: 15,
},
});
}
});
});
return edges;
}
function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[]; nodePositions: Map<string, { x: number; y: number; width: number }> } {
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
const weights = calculateTableWeights(schema); const weights = calculateTableWeights(schema);
@@ -144,19 +221,20 @@ function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[]
}); });
g.setGraph({ g.setGraph({
rankdir: 'TB', // Top-to-bottom for wider spread rankdir: 'TB',
nodesep: 40, // Horizontal spacing between nodes nodesep: 40,
ranksep: 80, // Vertical spacing between ranks ranksep: 80,
marginx: 30, marginx: 30,
marginy: 30, marginy: 30,
ranker: 'network-simplex', // Better distribution across plane ranker: 'network-simplex',
}); });
const nodeWidth = 280;
sortedTables.forEach((table) => { sortedTables.forEach((table) => {
const nodeId = `${table.schema}.${table.name}`; const nodeId = `${table.schema}.${table.name}`;
const width = 280;
const height = getNodeHeight(table); const height = getNodeHeight(table);
g.setNode(nodeId, { width, height }); g.setNode(nodeId, { width: nodeWidth, height });
}); });
schema.tables.forEach((table) => { schema.tables.forEach((table) => {
@@ -172,57 +250,27 @@ function getLayoutedElements(schema: SchemaData): { nodes: Node[]; edges: Edge[]
Dagre.layout(g); Dagre.layout(g);
const nodePositions = new Map<string, { x: number; y: number; width: number }>();
const nodes: Node[] = schema.tables.map((table) => { const nodes: Node[] = schema.tables.map((table) => {
const nodeId = `${table.schema}.${table.name}`; const nodeId = `${table.schema}.${table.name}`;
const nodeWithPosition = g.node(nodeId); const nodeWithPosition = g.node(nodeId);
const x = nodeWithPosition.x - nodeWithPosition.width / 2;
const y = nodeWithPosition.y - nodeWithPosition.height / 2;
nodePositions.set(nodeId, { x, y, width: nodeWidth });
return { return {
id: nodeId, id: nodeId,
type: 'table', type: 'table',
position: { position: { x, y },
x: nodeWithPosition.x - nodeWithPosition.width / 2,
y: nodeWithPosition.y - nodeWithPosition.height / 2,
},
data: { ...table } as TableInfo & Record<string, unknown>, data: { ...table } as TableInfo & Record<string, unknown>,
}; };
}); });
const edges: Edge[] = []; const edges = buildEdgesWithPositions(schema, nodePositions);
schema.tables.forEach((table) => { return { nodes, edges, nodePositions };
const sourceId = `${table.schema}.${table.name}`;
table.foreign_keys.forEach((fk) => {
let targetTable = schema.tables.find(t =>
t.name === fk.references_table && t.schema === table.schema
);
if (!targetTable) {
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,
sourceHandle: `source-${fk.column}`,
targetHandle: `target-${fk.references_column}`,
type: 'smoothstep',
style: { stroke: '#3b82f6', strokeWidth: 1.5 },
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#3b82f6',
width: 15,
height: 15,
},
});
}
});
});
return { nodes, edges };
} }
export default function DatabaseSchema() { export default function DatabaseSchema() {
@@ -264,8 +312,10 @@ export default function DatabaseSchema() {
const results: SearchResult[] = []; const results: SearchResult[] = [];
schema.tables.forEach(table => { schema.tables.forEach(table => {
const tableNameLower = table.name.toLowerCase();
// Search by table name // Search by table name
if (table.name.toLowerCase().includes(query)) { if (tableNameLower.includes(query)) {
results.push({ results.push({
type: 'table', type: 'table',
tableName: table.name, tableName: table.name,
@@ -313,6 +363,35 @@ export default function DatabaseSchema() {
}); });
}); });
// Calculate relevance score (lower = better match)
const getRelevanceScore = (result: SearchResult): number => {
const searchTarget = result.columnName?.toLowerCase() || result.tableName.toLowerCase();
// Exact match
if (searchTarget === query) return 0;
// Starts with query
if (searchTarget.startsWith(query)) return 1;
// Query position in string (earlier = better)
const position = searchTarget.indexOf(query);
return 2 + position;
};
// Sort: by type (table -> column -> comment), then by relevance
const typeOrder: Record<SearchResult['type'], number> = {
'table': 0,
'column': 1,
'comment': 2,
};
results.sort((a, b) => {
// First by type
const typeDiff = typeOrder[a.type] - typeOrder[b.type];
if (typeDiff !== 0) return typeDiff;
// Then by relevance within same type
return getRelevanceScore(a) - getRelevanceScore(b);
});
return results.slice(0, 50); return results.slice(0, 50);
}, [schema, searchQuery]); }, [schema, searchQuery]);
@@ -351,11 +430,55 @@ export default function DatabaseSchema() {
}; };
}, [schema, selectedTables, getRelatedTables]); }, [schema, selectedTables, getRelatedTables]);
const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(() => { const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
if (!filteredSchema || filteredSchema.tables.length === 0) return { nodes: [], edges: [] }; const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [nodePositions, setNodePositions] = useState<Map<string, { x: number; y: number; width: number }>>(new Map());
const { nodes: layoutedNodes, edges: layoutedEdges, nodePositions: initialPositions } = useMemo(() => {
if (!filteredSchema || filteredSchema.tables.length === 0) {
return { nodes: [], edges: [], nodePositions: new Map() };
}
return getLayoutedElements(filteredSchema); return getLayoutedElements(filteredSchema);
}, [filteredSchema]); }, [filteredSchema]);
// Update positions when layout changes
useEffect(() => {
setNodePositions(initialPositions);
}, [initialPositions]);
// Reset and rebuild when filtered schema changes
useEffect(() => {
// Clear first to avoid stale edges
setNodes([]);
setEdges([]);
// Then set new layout after a tick
if (layoutedNodes.length > 0) {
requestAnimationFrame(() => {
setNodes(layoutedNodes);
// Edges will be set by nodePositions effect
});
}
}, [layoutedNodes, layoutedEdges, setNodes, setEdges]);
// Recalculate edges when positions change
useEffect(() => {
if (!filteredSchema || nodePositions.size === 0) return;
const newEdges = buildEdgesWithPositions(filteredSchema, nodePositions);
setEdges(newEdges);
}, [nodePositions, filteredSchema, setEdges]);
// Recalculate edges when nodes are dragged
const handleNodeDragStop = useCallback((_event: React.MouseEvent, node: Node) => {
if (!filteredSchema) return;
setNodePositions(prev => {
const updated = new Map(prev);
updated.set(node.id, { x: node.position.x, y: node.position.y, width: 280 });
return updated;
});
}, [filteredSchema]);
const addTable = (tableName: string) => { const addTable = (tableName: string) => {
setSelectedTables(prev => new Set([...prev, tableName])); setSelectedTables(prev => new Set([...prev, tableName]));
setSearchQuery(''); setSearchQuery('');
@@ -373,24 +496,6 @@ export default function DatabaseSchema() {
setSelectedTables(new Set()); setSelectedTables(new Set());
}; };
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
// Reset and rebuild when filtered schema changes
useEffect(() => {
// Clear first to avoid stale edges
setNodes([]);
setEdges([]);
// Then set new layout after a tick
if (layoutedNodes.length > 0) {
requestAnimationFrame(() => {
setNodes(layoutedNodes);
setEdges(layoutedEdges);
});
}
}, [layoutedNodes, layoutedEdges, setNodes, setEdges]);
const getSearchIcon = (type: SearchResult['type']) => { const getSearchIcon = (type: SearchResult['type']) => {
switch (type) { switch (type) {
case 'table': case 'table':
@@ -548,6 +653,7 @@ export default function DatabaseSchema() {
edges={edges} edges={edges}
onNodesChange={onNodesChange} onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
onNodeDragStop={handleNodeDragStop}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
fitView fitView
fitViewOptions={{ padding: 0.2 }} fitViewOptions={{ padding: 0.2 }}