modified: frontend/src/pages/DatabaseSchema.tsx
This commit is contained in:
@@ -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 }}
|
||||||
|
|||||||
Reference in New Issue
Block a user