mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-03 10:51:24 +08:00
✨ feat(data-grid): 完善 ER 图多层关系展开与字段浏览
- 支持按层扩展关联关系并重置为一层视图 - 支持节点字段展开收起与全部字段切换 - 补充 ER 图模型、Hook 与界面回归测试
This commit is contained in:
283
frontend/src/components/DataGridErDiagram.test.tsx
Normal file
283
frontend/src/components/DataGridErDiagram.test.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import DataGridErDiagram from './DataGridErDiagram';
|
||||
|
||||
const hookState = vi.hoisted(() => ({
|
||||
graph: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'er:messages',
|
||||
tableName: 'messages',
|
||||
role: 'current',
|
||||
isCurrent: true,
|
||||
incomingCount: 1,
|
||||
outgoingCount: 0,
|
||||
relationCount: 1,
|
||||
columns: Array.from({ length: 12 }, (_, index) => ({
|
||||
name: `field_${index + 1}`,
|
||||
type: 'varchar(32)',
|
||||
comment: '',
|
||||
nullable: index !== 0,
|
||||
isPrimary: index === 0,
|
||||
isForeign: false,
|
||||
isRelationField: index <= 1,
|
||||
})),
|
||||
previewColumnCount: 10,
|
||||
hiddenColumnCount: 2,
|
||||
},
|
||||
{
|
||||
id: 'er:message_tags',
|
||||
tableName: 'message_tags',
|
||||
role: 'incoming',
|
||||
isCurrent: false,
|
||||
incomingCount: 0,
|
||||
outgoingCount: 1,
|
||||
relationCount: 1,
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'bigint',
|
||||
comment: '',
|
||||
nullable: false,
|
||||
isPrimary: true,
|
||||
isForeign: false,
|
||||
isRelationField: true,
|
||||
},
|
||||
{
|
||||
name: 'message_id',
|
||||
type: 'bigint',
|
||||
comment: '',
|
||||
nullable: false,
|
||||
isPrimary: false,
|
||||
isForeign: true,
|
||||
isRelationField: true,
|
||||
},
|
||||
],
|
||||
previewColumnCount: 2,
|
||||
hiddenColumnCount: 0,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
relationCount: 1,
|
||||
relatedTableCount: 1,
|
||||
incomingTableCount: 1,
|
||||
outgoingTableCount: 0,
|
||||
isEmpty: false,
|
||||
},
|
||||
loading: false,
|
||||
reloading: false,
|
||||
error: '',
|
||||
partial: false,
|
||||
reload: vi.fn(),
|
||||
canExpandRelations: true,
|
||||
}));
|
||||
|
||||
vi.mock('./useDataGridErDiagram', () => ({
|
||||
useDataGridErDiagram: () => hookState,
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Alert: ({ message }: { message?: React.ReactNode }) => <div>{message}</div>,
|
||||
Button: ({
|
||||
children,
|
||||
icon,
|
||||
onClick,
|
||||
disabled,
|
||||
...props
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
onClick?: (...args: any[]) => void;
|
||||
disabled?: boolean;
|
||||
[key: string]: any;
|
||||
}) => (
|
||||
<button type="button" onClick={onClick} disabled={disabled} {...props}>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Spin: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock('@ant-design/icons', () => {
|
||||
const Icon = ({ label }: { label: string }) => <span>{label}</span>;
|
||||
return {
|
||||
ApartmentOutlined: () => <Icon label="ApartmentOutlined" />,
|
||||
ArrowRightOutlined: () => <Icon label="ArrowRightOutlined" />,
|
||||
CompressOutlined: () => <Icon label="CompressOutlined" />,
|
||||
DatabaseOutlined: () => <Icon label="DatabaseOutlined" />,
|
||||
ExpandOutlined: () => <Icon label="ExpandOutlined" />,
|
||||
LinkOutlined: () => <Icon label="LinkOutlined" />,
|
||||
PlusOutlined: () => <Icon label="PlusOutlined" />,
|
||||
ReloadOutlined: () => <Icon label="ReloadOutlined" />,
|
||||
UndoOutlined: () => <Icon label="UndoOutlined" />,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('reactflow', async () => {
|
||||
const ReactModule = await import('react');
|
||||
|
||||
const ReactFlow = ({
|
||||
nodes,
|
||||
nodeTypes,
|
||||
children,
|
||||
}: {
|
||||
nodes: Array<{ id: string; type: string; data: any }>;
|
||||
nodeTypes: Record<string, React.ComponentType<any>>;
|
||||
children?: React.ReactNode;
|
||||
}) => (
|
||||
<div data-react-flow="true">
|
||||
{nodes.map((node) => {
|
||||
const NodeComponent = nodeTypes[node.type];
|
||||
return (
|
||||
<div key={node.id} data-node-id={node.id}>
|
||||
<NodeComponent data={node.data} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ReactFlow,
|
||||
Background: () => null,
|
||||
Controls: () => null,
|
||||
ReactFlowProvider: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||
useNodesState: (initialNodes: any[]) => {
|
||||
const [nodes, setNodes] = ReactModule.useState(initialNodes);
|
||||
return [nodes, setNodes, vi.fn()] as const;
|
||||
},
|
||||
useEdgesState: (initialEdges: any[]) => {
|
||||
const [edges, setEdges] = ReactModule.useState(initialEdges);
|
||||
return [edges, setEdges, vi.fn()] as const;
|
||||
},
|
||||
BackgroundVariant: { Dots: 'dots' },
|
||||
MarkerType: { ArrowClosed: 'arrowclosed' },
|
||||
Position: { Left: 'left', Right: 'right' },
|
||||
};
|
||||
});
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
'data_grid.metadata_view.er_table_badge': '表',
|
||||
'data_grid.metadata_view.er_current_badge': '当前表',
|
||||
'data_grid.metadata_view.er_referenced_by_badge': '被引用',
|
||||
'data_grid.metadata_view.er_reference_badge': '引用',
|
||||
'data_grid.metadata_view.er_open_table': '打开表',
|
||||
'data_grid.metadata_view.er_related_table_count': '{{count}} 张关联表',
|
||||
'data_grid.metadata_view.er_relation_count': '{{count}} 条关系',
|
||||
'data_grid.metadata_view.er_relation_depth': '{{count}} 层关系',
|
||||
'data_grid.metadata_view.er_expand_relations': '展开下一层关系',
|
||||
'data_grid.metadata_view.er_reset_relations': '重置为一层',
|
||||
'data_grid.metadata_view.er_expand_fields': '展开全部字段',
|
||||
'data_grid.metadata_view.er_collapse_fields': '收起字段摘要',
|
||||
'data_grid.metadata_view.er_expand_hidden_columns': '展开剩余 {{count}} 个字段',
|
||||
'data_grid.metadata_view.er_empty': '当前表未发现外键关系',
|
||||
'data_grid.metadata_view.er_partial_warning': '部分关系未能完整加载,图中结果可能不完整',
|
||||
'data_grid.table_fallback.query_result': '查询结果',
|
||||
'common.refresh': '刷新',
|
||||
};
|
||||
|
||||
const translate = (key: string, params?: Record<string, unknown>) => {
|
||||
let template = messages[key] || key;
|
||||
Object.entries(params || {}).forEach(([paramKey, paramValue]) => {
|
||||
template = template.replace(`{{${paramKey}}}`, String(paramValue));
|
||||
});
|
||||
return template;
|
||||
};
|
||||
|
||||
const textContent = (node: any): string => {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') {
|
||||
return '';
|
||||
}
|
||||
if (typeof node === 'string' || typeof node === 'number') {
|
||||
return String(node);
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
return node.map((child) => textContent(child)).join('');
|
||||
}
|
||||
if ('children' in node) {
|
||||
return textContent(node.children);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const findButton = (renderer: ReactTestRenderer, matcher: (node: any) => boolean) => (
|
||||
renderer.root.find((node) => node.type === 'button' && matcher(node))
|
||||
);
|
||||
|
||||
describe('DataGridErDiagram', () => {
|
||||
beforeEach(() => {
|
||||
hookState.reload.mockReset();
|
||||
hookState.canExpandRelations = true;
|
||||
});
|
||||
|
||||
it('shows hidden fields on demand and lets the toolbar collapse them again', async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<DataGridErDiagram
|
||||
connections={[]}
|
||||
connectionId="conn-1"
|
||||
dbName="main"
|
||||
tableName="messages"
|
||||
translate={translate}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(textContent(renderer.toJSON())).not.toContain('field_11');
|
||||
expect(
|
||||
findButton(renderer, (node) => node.props['data-er-node-toggle'] === 'er:messages').props.className,
|
||||
).toContain('nodrag');
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer, (node) => node.props['data-er-node-toggle'] === 'er:messages').props.onClick({
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
});
|
||||
});
|
||||
|
||||
expect(textContent(renderer.toJSON())).toContain('field_11');
|
||||
expect(textContent(renderer.toJSON())).toContain('收起字段摘要');
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer, (node) => node.props['data-er-action'] === 'collapse-fields').props.onClick();
|
||||
});
|
||||
|
||||
expect(textContent(renderer.toJSON())).not.toContain('field_11');
|
||||
});
|
||||
|
||||
it('tracks relation depth from the toolbar controls', async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<DataGridErDiagram
|
||||
connections={[]}
|
||||
connectionId="conn-1"
|
||||
dbName="main"
|
||||
tableName="messages"
|
||||
translate={translate}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(textContent(renderer.toJSON())).toContain('1 层关系');
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer, (node) => node.props['data-er-action'] === 'expand-relations').props.onClick();
|
||||
});
|
||||
|
||||
expect(textContent(renderer.toJSON())).toContain('2 层关系');
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer, (node) => node.props['data-er-action'] === 'reset-relations').props.onClick();
|
||||
});
|
||||
|
||||
expect(textContent(renderer.toJSON())).toContain('1 层关系');
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,13 @@ import { Alert, Button, Spin, Tooltip } from 'antd';
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
ArrowRightOutlined,
|
||||
CompressOutlined,
|
||||
DatabaseOutlined,
|
||||
ExpandOutlined,
|
||||
LinkOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
UndoOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dagre from 'dagre';
|
||||
import ReactFlow, {
|
||||
@@ -41,14 +45,17 @@ export interface DataGridErDiagramProps {
|
||||
type ErDiagramNodeData = {
|
||||
node: ErDiagramNode;
|
||||
selected: boolean;
|
||||
expanded: boolean;
|
||||
translate: DataGridMetadataTranslate;
|
||||
onToggleExpanded?: (nodeId: string) => void;
|
||||
onOpenTable?: (tableName: string) => void;
|
||||
};
|
||||
|
||||
const NODE_WIDTH = 320;
|
||||
const NODE_BASE_HEIGHT = 108;
|
||||
const NODE_ROW_HEIGHT = 28;
|
||||
const NODE_FOOTER_HEIGHT = 28;
|
||||
const NODE_FOOTER_HEIGHT = 36;
|
||||
const NODE_EXPANDED_MAX_VISIBLE_ROWS = 14;
|
||||
|
||||
const getRoleBadgeKey = (role: ErDiagramNode['role']): string => {
|
||||
switch (role) {
|
||||
@@ -63,9 +70,22 @@ const getRoleBadgeKey = (role: ErDiagramNode['role']): string => {
|
||||
}
|
||||
};
|
||||
|
||||
const estimateNodeHeight = (node: ErDiagramNode): number => (
|
||||
const getVisibleNodeColumns = (node: ErDiagramNode, expanded: boolean) => (
|
||||
expanded ? node.columns : node.columns.slice(0, node.previewColumnCount)
|
||||
);
|
||||
|
||||
const getVisibleNodeRowCount = (node: ErDiagramNode, expanded: boolean): number => Math.min(
|
||||
getVisibleNodeColumns(node, expanded).length,
|
||||
expanded ? NODE_EXPANDED_MAX_VISIBLE_ROWS : node.previewColumnCount,
|
||||
);
|
||||
|
||||
const getNodeColumnViewportHeight = (node: ErDiagramNode, expanded: boolean): number => (
|
||||
getVisibleNodeRowCount(node, expanded) * NODE_ROW_HEIGHT
|
||||
);
|
||||
|
||||
const estimateNodeHeight = (node: ErDiagramNode, expanded: boolean): number => (
|
||||
NODE_BASE_HEIGHT +
|
||||
(node.columns.length * NODE_ROW_HEIGHT) +
|
||||
getNodeColumnViewportHeight(node, expanded) +
|
||||
(node.hiddenColumnCount > 0 ? NODE_FOOTER_HEIGHT : 0)
|
||||
);
|
||||
|
||||
@@ -78,6 +98,8 @@ const edgeColorByDirection: Record<ErDiagramEdge['direction'], string> = {
|
||||
const layoutGraph = (
|
||||
graph: BuildErDiagramGraphResult,
|
||||
translate: DataGridMetadataTranslate,
|
||||
expandedNodeIds: Set<string>,
|
||||
onToggleExpanded?: (nodeId: string) => void,
|
||||
onOpenTable?: (tableName: string) => void,
|
||||
): { nodes: Node<ErDiagramNodeData>[]; edges: Edge[] } => {
|
||||
const dagreGraph = new dagre.graphlib.Graph();
|
||||
@@ -91,9 +113,10 @@ const layoutGraph = (
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
graph.nodes.forEach((node) => {
|
||||
const expanded = expandedNodeIds.has(node.id);
|
||||
dagreGraph.setNode(node.id, {
|
||||
width: NODE_WIDTH,
|
||||
height: estimateNodeHeight(node),
|
||||
height: estimateNodeHeight(node, expanded),
|
||||
});
|
||||
});
|
||||
graph.edges.forEach((edge) => {
|
||||
@@ -103,7 +126,8 @@ const layoutGraph = (
|
||||
|
||||
return {
|
||||
nodes: graph.nodes.map((node) => {
|
||||
const height = estimateNodeHeight(node);
|
||||
const expanded = expandedNodeIds.has(node.id);
|
||||
const height = estimateNodeHeight(node, expanded);
|
||||
const position = dagreGraph.node(node.id);
|
||||
return {
|
||||
id: node.id,
|
||||
@@ -115,7 +139,9 @@ const layoutGraph = (
|
||||
data: {
|
||||
node,
|
||||
selected: false,
|
||||
expanded,
|
||||
translate,
|
||||
onToggleExpanded,
|
||||
onOpenTable,
|
||||
},
|
||||
draggable: true,
|
||||
@@ -159,14 +185,20 @@ const layoutGraph = (
|
||||
};
|
||||
|
||||
const ErTableNode = memo(function ErTableNode({ data }: { data: ErDiagramNodeData }) {
|
||||
const { node, selected, translate, onOpenTable } = data;
|
||||
const { node, selected, expanded, translate, onToggleExpanded, onOpenTable } = data;
|
||||
const roleBadgeKey = getRoleBadgeKey(node.role);
|
||||
const canOpen = !node.isCurrent && typeof onOpenTable === 'function';
|
||||
const visibleColumns = getVisibleNodeColumns(node, expanded);
|
||||
const footerLabel = expanded
|
||||
? translate('data_grid.metadata_view.er_collapse_fields')
|
||||
: translate('data_grid.metadata_view.er_expand_hidden_columns', { count: node.hiddenColumnCount });
|
||||
const columnsScrollable = expanded && visibleColumns.length > NODE_EXPANDED_MAX_VISIBLE_ROWS;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`gn-er-node-card${selected ? ' is-selected' : ''}${node.isCurrent ? ' is-current' : ''}`}
|
||||
data-role={node.role}
|
||||
data-er-node-table={node.tableName}
|
||||
>
|
||||
<div className="gn-er-node-header">
|
||||
<div className="gn-er-node-title">
|
||||
@@ -178,7 +210,7 @@ const ErTableNode = memo(function ErTableNode({ data }: { data: ErDiagramNodeDat
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
className="gn-er-node-open"
|
||||
className="gn-er-node-open nodrag nopan"
|
||||
icon={<ArrowRightOutlined />}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
@@ -205,11 +237,15 @@ const ErTableNode = memo(function ErTableNode({ data }: { data: ErDiagramNodeDat
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="gn-er-node-columns">
|
||||
{node.columns.map((column) => (
|
||||
<div
|
||||
className={`gn-er-node-columns nodrag nopan${columnsScrollable ? ' is-scrollable' : ''}`}
|
||||
style={{ '--er-node-columns-max-height': `${getNodeColumnViewportHeight(node, expanded)}px` } as React.CSSProperties}
|
||||
>
|
||||
{visibleColumns.map((column) => (
|
||||
<div
|
||||
key={column.name}
|
||||
className={`gn-er-node-column${column.isRelationField ? ' is-relation' : ''}`}
|
||||
data-er-node-column={column.name}
|
||||
>
|
||||
<div className="gn-er-node-column-name">
|
||||
{column.isPrimary && <em className="is-pk">PK</em>}
|
||||
@@ -223,7 +259,19 @@ const ErTableNode = memo(function ErTableNode({ data }: { data: ErDiagramNodeDat
|
||||
|
||||
{node.hiddenColumnCount > 0 && (
|
||||
<div className="gn-er-node-footer">
|
||||
{translate('data_grid.metadata_view.er_hidden_columns', { count: node.hiddenColumnCount })}
|
||||
<button
|
||||
type="button"
|
||||
className="gn-er-node-footer-toggle nodrag nopan"
|
||||
data-er-node-toggle={node.id}
|
||||
title={footerLabel}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onToggleExpanded?.(node.id);
|
||||
}}
|
||||
>
|
||||
{footerLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -233,10 +281,14 @@ const ErTableNode = memo(function ErTableNode({ data }: { data: ErDiagramNodeDat
|
||||
const DataGridErDiagramCanvasInner: React.FC<{
|
||||
graph: BuildErDiagramGraphResult;
|
||||
translate: DataGridMetadataTranslate;
|
||||
expandedNodeIds: Set<string>;
|
||||
onToggleNodeExpanded: (nodeId: string) => void;
|
||||
onOpenTable?: (tableName: string) => void;
|
||||
}> = ({
|
||||
graph,
|
||||
translate,
|
||||
expandedNodeIds,
|
||||
onToggleNodeExpanded,
|
||||
onOpenTable,
|
||||
}) => {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(graph.nodes[0]?.id || null);
|
||||
@@ -247,8 +299,8 @@ const DataGridErDiagramCanvasInner: React.FC<{
|
||||
}, [graph.nodes]);
|
||||
|
||||
const layout = useMemo(
|
||||
() => layoutGraph(graph, translate, onOpenTable),
|
||||
[graph, onOpenTable, translate],
|
||||
() => layoutGraph(graph, translate, expandedNodeIds, onToggleNodeExpanded, onOpenTable),
|
||||
[expandedNodeIds, graph, onOpenTable, onToggleNodeExpanded, translate],
|
||||
);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(layout.nodes);
|
||||
@@ -281,11 +333,11 @@ const DataGridErDiagramCanvasInner: React.FC<{
|
||||
}, [selectedNodeId, setNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
void fitView({ padding: 0.18, duration: 220 });
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
globalThis.clearTimeout(timer);
|
||||
};
|
||||
}, [fitView, layout.edges, layout.nodes]);
|
||||
|
||||
@@ -326,6 +378,8 @@ const DataGridErDiagramCanvasInner: React.FC<{
|
||||
const DataGridErDiagramCanvas: React.FC<{
|
||||
graph: BuildErDiagramGraphResult;
|
||||
translate: DataGridMetadataTranslate;
|
||||
expandedNodeIds: Set<string>;
|
||||
onToggleNodeExpanded: (nodeId: string) => void;
|
||||
onOpenTable?: (tableName: string) => void;
|
||||
}> = (props) => (
|
||||
<ReactFlowProvider>
|
||||
@@ -341,6 +395,8 @@ const DataGridErDiagram: React.FC<DataGridErDiagramProps> = ({
|
||||
translate = defaultTranslate,
|
||||
onOpenTable,
|
||||
}) => {
|
||||
const [relationDepth, setRelationDepth] = useState(1);
|
||||
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<string>>(new Set());
|
||||
const {
|
||||
graph,
|
||||
loading,
|
||||
@@ -348,13 +404,64 @@ const DataGridErDiagram: React.FC<DataGridErDiagramProps> = ({
|
||||
error,
|
||||
partial,
|
||||
reload,
|
||||
canExpandRelations,
|
||||
} = useDataGridErDiagram({
|
||||
connections,
|
||||
connectionId,
|
||||
dbName,
|
||||
tableName,
|
||||
relationDepth,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setRelationDepth(1);
|
||||
setExpandedNodeIds(new Set());
|
||||
}, [connectionId, dbName, tableName]);
|
||||
|
||||
const expandableNodeIds = useMemo(
|
||||
() => graph?.nodes.filter((node) => node.hiddenColumnCount > 0).map((node) => node.id) || [],
|
||||
[graph],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const nextExpandableIds = new Set(expandableNodeIds);
|
||||
setExpandedNodeIds((current) => new Set(Array.from(current).filter((nodeId) => nextExpandableIds.has(nodeId))));
|
||||
}, [expandableNodeIds]);
|
||||
|
||||
const expandedFieldCount = useMemo(
|
||||
() => expandableNodeIds.filter((nodeId) => expandedNodeIds.has(nodeId)).length,
|
||||
[expandableNodeIds, expandedNodeIds],
|
||||
);
|
||||
const allFieldsExpanded = expandableNodeIds.length > 0 && expandedFieldCount === expandableNodeIds.length;
|
||||
|
||||
const handleToggleNodeExpanded = useCallback((nodeId: string) => {
|
||||
setExpandedNodeIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(nodeId)) {
|
||||
next.delete(nodeId);
|
||||
} else {
|
||||
next.add(nodeId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleExpandAllFields = useCallback(() => {
|
||||
setExpandedNodeIds(new Set(expandableNodeIds));
|
||||
}, [expandableNodeIds]);
|
||||
|
||||
const handleCollapseAllFields = useCallback(() => {
|
||||
setExpandedNodeIds(new Set());
|
||||
}, []);
|
||||
|
||||
const handleExpandRelations = useCallback(() => {
|
||||
setRelationDepth((currentDepth) => currentDepth + 1);
|
||||
}, []);
|
||||
|
||||
const handleResetRelationDepth = useCallback(() => {
|
||||
setRelationDepth(1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="gn-v2-data-grid-er-diagram">
|
||||
<div className="gn-v2-data-grid-er-toolbar">
|
||||
@@ -362,22 +469,68 @@ const DataGridErDiagram: React.FC<DataGridErDiagramProps> = ({
|
||||
<span>{translate('data_grid.metadata_view.er_table_badge')}</span>
|
||||
<strong>{tableName || translate('data_grid.table_fallback.query_result')}</strong>
|
||||
</div>
|
||||
<div className="gn-v2-data-grid-er-summary">
|
||||
<span className="gn-v2-data-grid-er-chip">
|
||||
<DatabaseOutlined />
|
||||
{translate('data_grid.metadata_view.er_related_table_count', { count: graph?.relatedTableCount ?? 0 })}
|
||||
</span>
|
||||
<span className="gn-v2-data-grid-er-chip">
|
||||
<ApartmentOutlined />
|
||||
{translate('data_grid.metadata_view.er_relation_count', { count: graph?.relationCount ?? 0 })}
|
||||
</span>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={reload}
|
||||
loading={reloading}
|
||||
>
|
||||
{translate('common.refresh')}
|
||||
</Button>
|
||||
<div>
|
||||
<div className="gn-v2-data-grid-er-summary">
|
||||
<span className="gn-v2-data-grid-er-chip">
|
||||
<DatabaseOutlined />
|
||||
{translate('data_grid.metadata_view.er_related_table_count', { count: graph?.relatedTableCount ?? 0 })}
|
||||
</span>
|
||||
<span className="gn-v2-data-grid-er-chip">
|
||||
<ApartmentOutlined />
|
||||
{translate('data_grid.metadata_view.er_relation_count', { count: graph?.relationCount ?? 0 })}
|
||||
</span>
|
||||
<span className="gn-v2-data-grid-er-chip">
|
||||
<LinkOutlined />
|
||||
{translate('data_grid.metadata_view.er_relation_depth', { count: relationDepth })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="gn-v2-data-grid-er-actions">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
data-er-action="expand-relations"
|
||||
onClick={handleExpandRelations}
|
||||
disabled={!graph || loading || reloading || !canExpandRelations}
|
||||
>
|
||||
{translate('data_grid.metadata_view.er_expand_relations')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
data-er-action="reset-relations"
|
||||
onClick={handleResetRelationDepth}
|
||||
disabled={relationDepth <= 1}
|
||||
>
|
||||
{translate('data_grid.metadata_view.er_reset_relations')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ExpandOutlined />}
|
||||
data-er-action="expand-fields"
|
||||
onClick={handleExpandAllFields}
|
||||
disabled={expandableNodeIds.length === 0 || allFieldsExpanded}
|
||||
>
|
||||
{translate('data_grid.metadata_view.er_expand_fields')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CompressOutlined />}
|
||||
data-er-action="collapse-fields"
|
||||
onClick={handleCollapseAllFields}
|
||||
disabled={expandedFieldCount === 0}
|
||||
>
|
||||
{translate('data_grid.metadata_view.er_collapse_fields')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
data-er-action="refresh"
|
||||
onClick={reload}
|
||||
loading={reloading}
|
||||
>
|
||||
{translate('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -409,6 +562,8 @@ const DataGridErDiagram: React.FC<DataGridErDiagramProps> = ({
|
||||
<DataGridErDiagramCanvas
|
||||
graph={graph}
|
||||
translate={translate}
|
||||
expandedNodeIds={expandedNodeIds}
|
||||
onToggleNodeExpanded={handleToggleNodeExpanded}
|
||||
onOpenTable={onOpenTable}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -130,4 +130,32 @@ describe('dataGridErDiagramModel', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps all fields in the node model while exposing a collapsed preview count', () => {
|
||||
const currentSnapshot: ErDiagramTableSnapshot = {
|
||||
tableName: 'messages',
|
||||
columns: Array.from({ length: 12 }, (_, index) => ({
|
||||
name: `col_${index + 1}`,
|
||||
type: 'varchar(32)',
|
||||
nullable: index === 0 ? 'NO' : 'YES',
|
||||
key: index === 0 ? 'PRI' : '',
|
||||
extra: '',
|
||||
comment: '',
|
||||
})),
|
||||
foreignKeys: [],
|
||||
uniqueKeyGroups: [['col_1']],
|
||||
};
|
||||
|
||||
const graph = buildErDiagramGraph({
|
||||
currentTableName: 'messages',
|
||||
currentSnapshot,
|
||||
relatedSnapshots: [],
|
||||
relations: [],
|
||||
});
|
||||
|
||||
expect(graph.nodes).toHaveLength(1);
|
||||
expect(graph.nodes[0]?.columns).toHaveLength(12);
|
||||
expect(graph.nodes[0]?.previewColumnCount).toBe(10);
|
||||
expect(graph.nodes[0]?.hiddenColumnCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface ErDiagramNode {
|
||||
outgoingCount: number;
|
||||
relationCount: number;
|
||||
columns: ErDiagramNodeField[];
|
||||
previewColumnCount: number;
|
||||
hiddenColumnCount: number;
|
||||
}
|
||||
|
||||
@@ -337,11 +338,10 @@ export const buildErDiagramGraph = (params: {
|
||||
}
|
||||
});
|
||||
|
||||
const visibleColumns = [...prioritized, ...remainder]
|
||||
const allColumns = [...prioritized, ...remainder]
|
||||
.filter((column, index, allColumns) => (
|
||||
allColumns.findIndex((candidate) => matchColumnName(candidate.name, column.name)) === index
|
||||
))
|
||||
.slice(0, maxColumnsPerNode)
|
||||
.map<ErDiagramNodeField>((column) => {
|
||||
const normalizedColumn = normalizeColumnName(column.name);
|
||||
return {
|
||||
@@ -354,6 +354,7 @@ export const buildErDiagramGraph = (params: {
|
||||
isRelationField: relationFields.has(normalizedColumn),
|
||||
};
|
||||
});
|
||||
const previewColumnCount = Math.min(allColumns.length, maxColumnsPerNode);
|
||||
|
||||
let role: ErDiagramNode['role'] = 'related';
|
||||
if (tableNamesMatch(tableName, currentTableName)) {
|
||||
@@ -372,8 +373,9 @@ export const buildErDiagramGraph = (params: {
|
||||
incomingCount,
|
||||
outgoingCount,
|
||||
relationCount: incomingCount + outgoingCount,
|
||||
columns: visibleColumns,
|
||||
hiddenColumnCount: Math.max(0, snapshot.columns.length - visibleColumns.length),
|
||||
columns: allColumns,
|
||||
previewColumnCount,
|
||||
hiddenColumnCount: Math.max(0, allColumns.length - previewColumnCount),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
120
frontend/src/components/useDataGridErDiagram.test.ts
Normal file
120
frontend/src/components/useDataGridErDiagram.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { ErDiagramTableSnapshot } from './dataGridErDiagramModel';
|
||||
import { collectErDiagramNeighborhood } from './useDataGridErDiagram';
|
||||
|
||||
const SNAPSHOTS: Record<string, ErDiagramTableSnapshot> = {
|
||||
orders: {
|
||||
tableName: 'orders',
|
||||
columns: [
|
||||
{ name: 'id', type: 'bigint', nullable: 'NO', key: 'PRI', extra: '', comment: '' },
|
||||
{ name: 'customer_id', type: 'bigint', nullable: 'NO', key: '', extra: '', comment: '' },
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
name: 'fk_orders_customer',
|
||||
columnName: 'customer_id',
|
||||
refTableName: 'customers',
|
||||
refColumnName: 'id',
|
||||
constraintName: 'fk_orders_customer',
|
||||
},
|
||||
],
|
||||
uniqueKeyGroups: [['id']],
|
||||
},
|
||||
customers: {
|
||||
tableName: 'customers',
|
||||
columns: [
|
||||
{ name: 'id', type: 'bigint', nullable: 'NO', key: 'PRI', extra: '', comment: '' },
|
||||
{ name: 'region_id', type: 'bigint', nullable: 'NO', key: '', extra: '', comment: '' },
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
name: 'fk_customers_region',
|
||||
columnName: 'region_id',
|
||||
refTableName: 'regions',
|
||||
refColumnName: 'id',
|
||||
constraintName: 'fk_customers_region',
|
||||
},
|
||||
],
|
||||
uniqueKeyGroups: [['id']],
|
||||
},
|
||||
order_items: {
|
||||
tableName: 'order_items',
|
||||
columns: [
|
||||
{ name: 'id', type: 'bigint', nullable: 'NO', key: 'PRI', extra: '', comment: '' },
|
||||
{ name: 'order_id', type: 'bigint', nullable: 'NO', key: '', extra: '', comment: '' },
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
name: 'fk_items_order',
|
||||
columnName: 'order_id',
|
||||
refTableName: 'orders',
|
||||
refColumnName: 'id',
|
||||
constraintName: 'fk_items_order',
|
||||
},
|
||||
],
|
||||
uniqueKeyGroups: [['id']],
|
||||
},
|
||||
regions: {
|
||||
tableName: 'regions',
|
||||
columns: [
|
||||
{ name: 'id', type: 'bigint', nullable: 'NO', key: 'PRI', extra: '', comment: '' },
|
||||
{ name: 'name', type: 'varchar(64)', nullable: 'NO', key: '', extra: '', comment: '' },
|
||||
],
|
||||
foreignKeys: [],
|
||||
uniqueKeyGroups: [['id']],
|
||||
},
|
||||
};
|
||||
|
||||
describe('collectErDiagramNeighborhood', () => {
|
||||
it('expands the graph hop by hop and reports whether another layer exists', async () => {
|
||||
const loadSnapshot = vi.fn(async (tableName: string) => {
|
||||
const snapshot = SNAPSHOTS[tableName];
|
||||
if (!snapshot) {
|
||||
throw new Error(`Unknown snapshot: ${tableName}`);
|
||||
}
|
||||
return snapshot;
|
||||
});
|
||||
const loadForeignKeys = vi.fn(async (tableName: string) => {
|
||||
const snapshot = SNAPSHOTS[tableName];
|
||||
if (!snapshot) {
|
||||
throw new Error(`Unknown foreign keys: ${tableName}`);
|
||||
}
|
||||
return snapshot.foreignKeys;
|
||||
});
|
||||
|
||||
const oneHop = await collectErDiagramNeighborhood({
|
||||
currentSnapshot: SNAPSHOTS.orders,
|
||||
schemaTableNames: ['orders', 'customers', 'order_items', 'regions'],
|
||||
relationDepth: 1,
|
||||
loadSnapshot,
|
||||
loadForeignKeys,
|
||||
resolveTableName: (tableName) => tableName,
|
||||
});
|
||||
|
||||
expect(oneHop.relatedSnapshots.map((snapshot) => snapshot.tableName)).toEqual(
|
||||
expect.arrayContaining(['customers', 'order_items']),
|
||||
);
|
||||
expect(oneHop.relatedSnapshots.map((snapshot) => snapshot.tableName)).not.toContain('regions');
|
||||
expect(oneHop.relations.map((relation) => `${relation.sourceTableName}->${relation.targetTableName}`)).toEqual(
|
||||
expect.arrayContaining(['orders->customers', 'order_items->orders']),
|
||||
);
|
||||
expect(oneHop.canExpandRelations).toBe(true);
|
||||
|
||||
const twoHop = await collectErDiagramNeighborhood({
|
||||
currentSnapshot: SNAPSHOTS.orders,
|
||||
schemaTableNames: ['orders', 'customers', 'order_items', 'regions'],
|
||||
relationDepth: 2,
|
||||
loadSnapshot,
|
||||
loadForeignKeys,
|
||||
resolveTableName: (tableName) => tableName,
|
||||
});
|
||||
|
||||
expect(twoHop.relatedSnapshots.map((snapshot) => snapshot.tableName)).toEqual(
|
||||
expect.arrayContaining(['customers', 'order_items', 'regions']),
|
||||
);
|
||||
expect(twoHop.relations.map((relation) => `${relation.sourceTableName}->${relation.targetTableName}`)).toEqual(
|
||||
expect.arrayContaining(['customers->regions']),
|
||||
);
|
||||
expect(twoHop.canExpandRelations).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
normalizeErQualifiedName,
|
||||
normalizeForeignKeyDefinitions,
|
||||
resolveErActualTableName,
|
||||
tableNamesMatch,
|
||||
type BuildErDiagramGraphResult,
|
||||
type ErDiagramRelation,
|
||||
type ErDiagramTableSnapshot,
|
||||
@@ -21,6 +20,7 @@ type DataGridErDiagramParams = {
|
||||
connectionId?: string;
|
||||
dbName?: string;
|
||||
tableName?: string;
|
||||
relationDepth?: number;
|
||||
};
|
||||
|
||||
type DataGridErDiagramState = {
|
||||
@@ -30,6 +30,7 @@ type DataGridErDiagramState = {
|
||||
error: string;
|
||||
partial: boolean;
|
||||
warningCount: number;
|
||||
canExpandRelations: boolean;
|
||||
};
|
||||
|
||||
type CacheValue<T> = T | Promise<T>;
|
||||
@@ -46,6 +47,7 @@ const DEFAULT_EMPTY_STATE: DataGridErDiagramState = {
|
||||
error: '',
|
||||
partial: false,
|
||||
warningCount: 0,
|
||||
canExpandRelations: false,
|
||||
};
|
||||
|
||||
const normalizeConnectionConfig = (connection: any) => ({
|
||||
@@ -237,12 +239,256 @@ const dedupeTableNames = (tableNames: string[]): string[] => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const createEmptySnapshot = (tableName: string): ErDiagramTableSnapshot => ({
|
||||
tableName,
|
||||
columns: [],
|
||||
foreignKeys: [],
|
||||
uniqueKeyGroups: [],
|
||||
});
|
||||
|
||||
type CollectErDiagramNeighborhoodParams = {
|
||||
currentSnapshot: ErDiagramTableSnapshot;
|
||||
schemaTableNames: string[];
|
||||
relationDepth: number;
|
||||
loadSnapshot: (tableName: string) => Promise<ErDiagramTableSnapshot>;
|
||||
loadForeignKeys: (tableName: string) => Promise<ForeignKeyDefinition[]>;
|
||||
resolveTableName: (tableName: string) => string;
|
||||
};
|
||||
|
||||
type CollectErDiagramNeighborhoodResult = {
|
||||
relatedSnapshots: ErDiagramTableSnapshot[];
|
||||
relations: ErDiagramRelation[];
|
||||
warningCount: number;
|
||||
canExpandRelations: boolean;
|
||||
};
|
||||
|
||||
export const collectErDiagramNeighborhood = async (
|
||||
params: CollectErDiagramNeighborhoodParams,
|
||||
): Promise<CollectErDiagramNeighborhoodResult> => {
|
||||
const maxDepth = Math.max(1, Math.floor(Number(params.relationDepth) || 1));
|
||||
const currentTableName = String(params.currentSnapshot.tableName || '').trim();
|
||||
const currentKey = normalizeErQualifiedName(currentTableName);
|
||||
const tableNameByKey = new Map<string, string>();
|
||||
const snapshotByKey = new Map<string, ErDiagramTableSnapshot>();
|
||||
const foreignKeysByKey = new Map<string, ForeignKeyDefinition[]>();
|
||||
const visitedKeys = new Set<string>();
|
||||
const relations: ErDiagramRelation[] = [];
|
||||
let warningCount = 0;
|
||||
|
||||
const registerTableName = (tableName: string): string => {
|
||||
const actualTableName = String(tableName || '').trim();
|
||||
const normalized = normalizeErQualifiedName(actualTableName);
|
||||
if (!normalized) {
|
||||
return actualTableName;
|
||||
}
|
||||
if (!tableNameByKey.has(normalized)) {
|
||||
tableNameByKey.set(normalized, actualTableName);
|
||||
}
|
||||
return tableNameByKey.get(normalized) || actualTableName;
|
||||
};
|
||||
|
||||
const registerRelationTarget = (tableName: string): string => registerTableName(params.resolveTableName(tableName));
|
||||
|
||||
registerTableName(currentTableName);
|
||||
params.schemaTableNames.forEach(registerTableName);
|
||||
params.currentSnapshot.foreignKeys.forEach((foreignKey) => {
|
||||
registerRelationTarget(foreignKey.refTableName);
|
||||
});
|
||||
snapshotByKey.set(currentKey, {
|
||||
...params.currentSnapshot,
|
||||
tableName: registerTableName(params.currentSnapshot.tableName),
|
||||
});
|
||||
foreignKeysByKey.set(currentKey, params.currentSnapshot.foreignKeys || []);
|
||||
visitedKeys.add(currentKey);
|
||||
|
||||
const loadSnapshotByKey = async (tableKey: string): Promise<ErDiagramTableSnapshot> => {
|
||||
const cached = snapshotByKey.get(tableKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const tableName = tableNameByKey.get(tableKey) || tableKey;
|
||||
try {
|
||||
const snapshot = await params.loadSnapshot(tableName);
|
||||
const actualTableName = registerTableName(snapshot.tableName || tableName);
|
||||
const normalizedActualTableName = normalizeErQualifiedName(actualTableName);
|
||||
const nextSnapshot = {
|
||||
...snapshot,
|
||||
tableName: actualTableName,
|
||||
};
|
||||
snapshotByKey.set(tableKey, nextSnapshot);
|
||||
foreignKeysByKey.set(tableKey, nextSnapshot.foreignKeys || []);
|
||||
snapshotByKey.set(normalizedActualTableName, nextSnapshot);
|
||||
foreignKeysByKey.set(normalizedActualTableName, nextSnapshot.foreignKeys || []);
|
||||
return nextSnapshot;
|
||||
} catch {
|
||||
warningCount += 1;
|
||||
const emptySnapshot = createEmptySnapshot(tableName);
|
||||
snapshotByKey.set(tableKey, emptySnapshot);
|
||||
foreignKeysByKey.set(tableKey, []);
|
||||
return emptySnapshot;
|
||||
}
|
||||
};
|
||||
|
||||
const loadForeignKeysByKey = async (tableKey: string): Promise<ForeignKeyDefinition[]> => {
|
||||
const cached = foreignKeysByKey.get(tableKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const tableName = tableNameByKey.get(tableKey) || tableKey;
|
||||
try {
|
||||
const foreignKeys = await params.loadForeignKeys(tableName);
|
||||
foreignKeysByKey.set(tableKey, foreignKeys);
|
||||
return foreignKeys;
|
||||
} catch {
|
||||
warningCount += 1;
|
||||
foreignKeysByKey.set(tableKey, []);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const frontierHasUndiscoveredNeighbors = async (frontierKeys: string[]): Promise<boolean> => {
|
||||
if (frontierKeys.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const frontierSet = new Set(frontierKeys);
|
||||
await runWithConcurrency(frontierKeys, 4, async (frontierKey) => {
|
||||
await loadSnapshotByKey(frontierKey);
|
||||
return frontierKey;
|
||||
});
|
||||
|
||||
for (const frontierKey of frontierKeys) {
|
||||
const snapshot = snapshotByKey.get(frontierKey) || createEmptySnapshot(tableNameByKey.get(frontierKey) || frontierKey);
|
||||
for (const foreignKey of snapshot.foreignKeys) {
|
||||
const targetTableName = registerRelationTarget(foreignKey.refTableName);
|
||||
const targetKey = normalizeErQualifiedName(targetTableName);
|
||||
if (targetKey && targetKey !== frontierKey && !visitedKeys.has(targetKey)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const candidateKeys = Array.from(tableNameByKey.keys()).filter(
|
||||
(candidateKey) => !visitedKeys.has(candidateKey) && !frontierSet.has(candidateKey),
|
||||
);
|
||||
const incomingResults = await runWithConcurrency(candidateKeys, 6, async (candidateKey) => ({
|
||||
candidateKey,
|
||||
foreignKeys: await loadForeignKeysByKey(candidateKey),
|
||||
}));
|
||||
return incomingResults.some((result) => {
|
||||
if (result.status !== 'fulfilled') {
|
||||
return false;
|
||||
}
|
||||
return result.value.foreignKeys.some((foreignKey) => {
|
||||
const targetTableName = registerRelationTarget(foreignKey.refTableName);
|
||||
const targetKey = normalizeErQualifiedName(targetTableName);
|
||||
return Boolean(targetKey) && frontierSet.has(targetKey);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
let frontierKeys = [currentKey];
|
||||
|
||||
for (let depth = 0; depth < maxDepth; depth += 1) {
|
||||
if (frontierKeys.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const frontierSet = new Set(frontierKeys);
|
||||
await runWithConcurrency(frontierKeys, 4, async (frontierKey) => {
|
||||
await loadSnapshotByKey(frontierKey);
|
||||
return frontierKey;
|
||||
});
|
||||
|
||||
const nextFrontierKeys = new Set<string>();
|
||||
|
||||
frontierKeys.forEach((frontierKey) => {
|
||||
const snapshot = snapshotByKey.get(frontierKey) || createEmptySnapshot(tableNameByKey.get(frontierKey) || frontierKey);
|
||||
snapshot.foreignKeys.forEach((foreignKey) => {
|
||||
const targetTableName = registerRelationTarget(foreignKey.refTableName);
|
||||
const targetKey = normalizeErQualifiedName(targetTableName);
|
||||
if (!targetKey) {
|
||||
return;
|
||||
}
|
||||
relations.push({
|
||||
sourceTableName: snapshot.tableName,
|
||||
targetTableName,
|
||||
columnName: foreignKey.columnName,
|
||||
refColumnName: foreignKey.refColumnName,
|
||||
constraintName: foreignKey.constraintName || foreignKey.name || '',
|
||||
direction: targetKey === frontierKey ? 'self' : 'outgoing',
|
||||
});
|
||||
if (targetKey !== frontierKey && !visitedKeys.has(targetKey)) {
|
||||
visitedKeys.add(targetKey);
|
||||
nextFrontierKeys.add(targetKey);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const incomingResults = await runWithConcurrency(
|
||||
Array.from(tableNameByKey.keys()).filter((candidateKey) => !frontierSet.has(candidateKey)),
|
||||
6,
|
||||
async (candidateKey) => ({
|
||||
candidateKey,
|
||||
foreignKeys: await loadForeignKeysByKey(candidateKey),
|
||||
}),
|
||||
);
|
||||
|
||||
incomingResults.forEach((result) => {
|
||||
if (result.status !== 'fulfilled') {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceTableName = tableNameByKey.get(result.value.candidateKey) || result.value.candidateKey;
|
||||
result.value.foreignKeys.forEach((foreignKey) => {
|
||||
const targetTableName = registerRelationTarget(foreignKey.refTableName);
|
||||
const targetKey = normalizeErQualifiedName(targetTableName);
|
||||
if (!targetKey || !frontierSet.has(targetKey)) {
|
||||
return;
|
||||
}
|
||||
relations.push({
|
||||
sourceTableName,
|
||||
targetTableName: tableNameByKey.get(targetKey) || targetTableName,
|
||||
columnName: foreignKey.columnName,
|
||||
refColumnName: foreignKey.refColumnName,
|
||||
constraintName: foreignKey.constraintName || foreignKey.name || '',
|
||||
direction: result.value.candidateKey === targetKey ? 'self' : 'incoming',
|
||||
});
|
||||
if (result.value.candidateKey !== targetKey && !visitedKeys.has(result.value.candidateKey)) {
|
||||
visitedKeys.add(result.value.candidateKey);
|
||||
nextFrontierKeys.add(result.value.candidateKey);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
frontierKeys = Array.from(nextFrontierKeys);
|
||||
}
|
||||
|
||||
const relatedKeys = Array.from(visitedKeys).filter((tableKey) => tableKey !== currentKey);
|
||||
await runWithConcurrency(relatedKeys, 4, async (relatedKey) => {
|
||||
await loadSnapshotByKey(relatedKey);
|
||||
return relatedKey;
|
||||
});
|
||||
|
||||
return {
|
||||
relatedSnapshots: relatedKeys.map((relatedKey) => (
|
||||
snapshotByKey.get(relatedKey) || createEmptySnapshot(tableNameByKey.get(relatedKey) || relatedKey)
|
||||
)),
|
||||
relations: dedupeRelations(relations),
|
||||
warningCount,
|
||||
canExpandRelations: await frontierHasUndiscoveredNeighbors(frontierKeys),
|
||||
};
|
||||
};
|
||||
|
||||
export const useDataGridErDiagram = (params: DataGridErDiagramParams) => {
|
||||
const {
|
||||
connections,
|
||||
connectionId,
|
||||
dbName,
|
||||
tableName,
|
||||
relationDepth = 1,
|
||||
} = params;
|
||||
|
||||
const [state, setState] = useState<DataGridErDiagramState>(DEFAULT_EMPTY_STATE);
|
||||
@@ -297,6 +543,7 @@ export const useDataGridErDiagram = (params: DataGridErDiagramParams) => {
|
||||
error: '',
|
||||
partial: false,
|
||||
warningCount: 0,
|
||||
canExpandRelations: false,
|
||||
}));
|
||||
|
||||
const loadGraph = async () => {
|
||||
@@ -318,84 +565,32 @@ export const useDataGridErDiagram = (params: DataGridErDiagramParams) => {
|
||||
|
||||
const resolveTableName = (name: string) => resolveErActualTableName(name, resolvedSchemaTableNames);
|
||||
|
||||
const outgoingRelations: ErDiagramRelation[] = currentSnapshot.foreignKeys.map((foreignKey) => {
|
||||
const targetTableName = resolveTableName(foreignKey.refTableName);
|
||||
return {
|
||||
sourceTableName: currentSnapshot.tableName,
|
||||
targetTableName,
|
||||
columnName: foreignKey.columnName,
|
||||
refColumnName: foreignKey.refColumnName,
|
||||
constraintName: foreignKey.constraintName || foreignKey.name || '',
|
||||
direction: tableNamesMatch(targetTableName, currentSnapshot.tableName) ? 'self' : 'outgoing',
|
||||
};
|
||||
});
|
||||
|
||||
const scanCandidates = resolvedSchemaTableNames.filter((candidate) => !tableNamesMatch(candidate, currentSnapshot.tableName));
|
||||
const incomingScanResults = await runWithConcurrency(scanCandidates, 6, async (candidateTableName) => {
|
||||
const tableCacheKey = `${cachePrefix}${candidateTableName}`;
|
||||
const foreignKeys = await loadTableForeignKeys(
|
||||
config,
|
||||
normalizedDbName,
|
||||
candidateTableName,
|
||||
`${tableCacheKey}|foreignKeys`,
|
||||
);
|
||||
return { tableName: candidateTableName, foreignKeys };
|
||||
});
|
||||
|
||||
const incomingRelations: ErDiagramRelation[] = [];
|
||||
incomingScanResults.forEach((result) => {
|
||||
if (result.status === 'rejected') {
|
||||
warningCount += 1;
|
||||
return;
|
||||
}
|
||||
result.value.foreignKeys.forEach((foreignKey) => {
|
||||
const targetTableName = resolveTableName(foreignKey.refTableName);
|
||||
if (!tableNamesMatch(targetTableName, currentSnapshot.tableName)) {
|
||||
return;
|
||||
}
|
||||
incomingRelations.push({
|
||||
sourceTableName: result.value.tableName,
|
||||
targetTableName: currentSnapshot.tableName,
|
||||
columnName: foreignKey.columnName,
|
||||
refColumnName: foreignKey.refColumnName,
|
||||
constraintName: foreignKey.constraintName || foreignKey.name || '',
|
||||
direction: 'incoming',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const relations = dedupeRelations([...outgoingRelations, ...incomingRelations]);
|
||||
const relatedTableNames = dedupeTableNames(
|
||||
relations.flatMap((relation) => [relation.sourceTableName, relation.targetTableName]),
|
||||
).filter((candidate) => !tableNamesMatch(candidate, currentSnapshot.tableName));
|
||||
|
||||
const relatedSnapshotResults = await runWithConcurrency(relatedTableNames, 4, async (relatedTableName) => {
|
||||
const tableCacheKey = `${cachePrefix}${relatedTableName}`;
|
||||
return loadTableSnapshot(config, normalizedDbName, relatedTableName, tableCacheKey);
|
||||
});
|
||||
|
||||
const relatedSnapshots: ErDiagramTableSnapshot[] = relatedSnapshotResults.map((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
return result.value;
|
||||
}
|
||||
warningCount += 1;
|
||||
return {
|
||||
tableName: relatedTableNames[index],
|
||||
columns: [],
|
||||
foreignKeys: [],
|
||||
uniqueKeyGroups: [],
|
||||
};
|
||||
const neighborhood = await collectErDiagramNeighborhood({
|
||||
currentSnapshot,
|
||||
schemaTableNames: resolvedSchemaTableNames,
|
||||
relationDepth,
|
||||
loadSnapshot: async (relatedTableName) => {
|
||||
const tableCacheKey = `${cachePrefix}${relatedTableName}`;
|
||||
return loadTableSnapshot(config, normalizedDbName, relatedTableName, tableCacheKey);
|
||||
},
|
||||
loadForeignKeys: async (relatedTableName) => {
|
||||
const tableCacheKey = `${cachePrefix}${relatedTableName}`;
|
||||
return loadTableForeignKeys(config, normalizedDbName, relatedTableName, `${tableCacheKey}|foreignKeys`);
|
||||
},
|
||||
resolveTableName,
|
||||
});
|
||||
warningCount += neighborhood.warningCount;
|
||||
|
||||
return {
|
||||
graph: buildErDiagramGraph({
|
||||
currentTableName: currentSnapshot.tableName,
|
||||
currentSnapshot,
|
||||
relatedSnapshots,
|
||||
relations,
|
||||
relatedSnapshots: neighborhood.relatedSnapshots,
|
||||
relations: neighborhood.relations,
|
||||
}),
|
||||
partial: warningCount > 0,
|
||||
warningCount,
|
||||
canExpandRelations: neighborhood.canExpandRelations,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -411,6 +606,7 @@ export const useDataGridErDiagram = (params: DataGridErDiagramParams) => {
|
||||
error: '',
|
||||
partial: result.partial,
|
||||
warningCount: result.warningCount,
|
||||
canExpandRelations: result.canExpandRelations,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -424,9 +620,10 @@ export const useDataGridErDiagram = (params: DataGridErDiagramParams) => {
|
||||
error: error instanceof Error ? error.message : String(error || 'Failed to load ER diagram'),
|
||||
partial: false,
|
||||
warningCount: 0,
|
||||
canExpandRelations: false,
|
||||
});
|
||||
});
|
||||
}, [cachePrefix, connectionId, connections, normalizedDbName, normalizedTableName, reloadVersion]);
|
||||
}, [cachePrefix, connectionId, connections, normalizedDbName, normalizedTableName, relationDepth, reloadVersion]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -971,6 +971,18 @@ body[data-ui-version="v2"] .gn-v2-data-grid-er-toolbar strong {
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-data-grid-er-summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-data-grid-er-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -1123,6 +1135,14 @@ body[data-ui-version="v2"] .gn-er-node-stats span {
|
||||
|
||||
body[data-ui-version="v2"] .gn-er-node-columns {
|
||||
padding: 8px 0 6px;
|
||||
max-height: var(--er-node-columns-max-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-er-node-columns.is-scrollable {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-er-node-column {
|
||||
@@ -1194,11 +1214,25 @@ body[data-ui-version="v2"] .gn-er-node-column-type {
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-er-node-footer {
|
||||
padding: 8px 12px 12px;
|
||||
padding: 6px 12px 12px;
|
||||
color: var(--gn-fg-5);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-er-node-footer-toggle {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--gn-accent);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-er-node-footer-toggle:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ─── AI Chat Panel hooks (project's class names) ──────── */
|
||||
body[data-ui-version="v2"] .ai-chat-panel {
|
||||
background: var(--gn-bg-panel) !important;
|
||||
|
||||
@@ -2565,6 +2565,12 @@
|
||||
"data_grid.metadata_view.er_related_table_count": "{{count}} verknuepfte Tabellen",
|
||||
"data_grid.metadata_view.er_relation_count": "{{count}} Beziehungen",
|
||||
"data_grid.metadata_view.er_hidden_columns": "{{count}} weitere Felder",
|
||||
"data_grid.metadata_view.er_expand_hidden_columns": "{{count}} weitere Felder anzeigen",
|
||||
"data_grid.metadata_view.er_expand_fields": "Alle Felder erweitern",
|
||||
"data_grid.metadata_view.er_collapse_fields": "Feldzusammenfassung einklappen",
|
||||
"data_grid.metadata_view.er_relation_depth": "Ebene {{count}}",
|
||||
"data_grid.metadata_view.er_expand_relations": "Naechste Ebene erweitern",
|
||||
"data_grid.metadata_view.er_reset_relations": "Auf eine Ebene zuruecksetzen",
|
||||
"data_grid.metadata_view.er_empty": "Fuer diese Tabelle wurden keine Fremdschluesselbeziehungen gefunden",
|
||||
"data_grid.metadata_view.er_partial_warning": "Ein Teil der Beziehungen konnte nicht geladen werden. Das Diagramm ist moeglicherweise unvollstaendig.",
|
||||
"data_grid.metadata_view.er_open_table": "Tabelle oeffnen",
|
||||
|
||||
@@ -2575,6 +2575,12 @@
|
||||
"data_grid.metadata_view.er_related_table_count": "{{count}} related tables",
|
||||
"data_grid.metadata_view.er_relation_count": "{{count}} relations",
|
||||
"data_grid.metadata_view.er_hidden_columns": "{{count}} more fields",
|
||||
"data_grid.metadata_view.er_expand_hidden_columns": "Show {{count}} more fields",
|
||||
"data_grid.metadata_view.er_expand_fields": "Expand all fields",
|
||||
"data_grid.metadata_view.er_collapse_fields": "Collapse field summary",
|
||||
"data_grid.metadata_view.er_relation_depth": "Depth {{count}}",
|
||||
"data_grid.metadata_view.er_expand_relations": "Expand next layer",
|
||||
"data_grid.metadata_view.er_reset_relations": "Reset to one hop",
|
||||
"data_grid.metadata_view.er_empty": "No foreign key relations were found for this table",
|
||||
"data_grid.metadata_view.er_partial_warning": "Some relations could not be loaded. The diagram may be incomplete.",
|
||||
"data_grid.metadata_view.er_open_table": "Open table",
|
||||
|
||||
@@ -2565,6 +2565,12 @@
|
||||
"data_grid.metadata_view.er_related_table_count": "関連テーブル {{count}} 件",
|
||||
"data_grid.metadata_view.er_relation_count": "リレーション {{count}} 件",
|
||||
"data_grid.metadata_view.er_hidden_columns": "ほか {{count}} フィールド",
|
||||
"data_grid.metadata_view.er_expand_hidden_columns": "残り {{count}} フィールドを表示",
|
||||
"data_grid.metadata_view.er_expand_fields": "すべてのフィールドを展開",
|
||||
"data_grid.metadata_view.er_collapse_fields": "フィールド要約を折りたたむ",
|
||||
"data_grid.metadata_view.er_relation_depth": "{{count}} 層の関連",
|
||||
"data_grid.metadata_view.er_expand_relations": "次の層を展開",
|
||||
"data_grid.metadata_view.er_reset_relations": "1 層に戻す",
|
||||
"data_grid.metadata_view.er_empty": "このテーブルでは外部キー関係が見つかりませんでした",
|
||||
"data_grid.metadata_view.er_partial_warning": "一部の関係を読み込めなかったため、図が不完全な可能性があります",
|
||||
"data_grid.metadata_view.er_open_table": "テーブルを開く",
|
||||
|
||||
@@ -2565,6 +2565,12 @@
|
||||
"data_grid.metadata_view.er_related_table_count": "Связанных таблиц: {{count}}",
|
||||
"data_grid.metadata_view.er_relation_count": "Связей: {{count}}",
|
||||
"data_grid.metadata_view.er_hidden_columns": "Еще {{count}} полей",
|
||||
"data_grid.metadata_view.er_expand_hidden_columns": "Показать еще {{count}} полей",
|
||||
"data_grid.metadata_view.er_expand_fields": "Развернуть все поля",
|
||||
"data_grid.metadata_view.er_collapse_fields": "Свернуть сводку полей",
|
||||
"data_grid.metadata_view.er_relation_depth": "Глубина {{count}}",
|
||||
"data_grid.metadata_view.er_expand_relations": "Развернуть следующий уровень",
|
||||
"data_grid.metadata_view.er_reset_relations": "Сбросить до одного уровня",
|
||||
"data_grid.metadata_view.er_empty": "Для этой таблицы не найдено связей по внешним ключам",
|
||||
"data_grid.metadata_view.er_partial_warning": "Часть связей не удалось загрузить. Диаграмма может быть неполной.",
|
||||
"data_grid.metadata_view.er_open_table": "Открыть таблицу",
|
||||
|
||||
@@ -2575,6 +2575,12 @@
|
||||
"data_grid.metadata_view.er_related_table_count": "{{count}} 张关联表",
|
||||
"data_grid.metadata_view.er_relation_count": "{{count}} 条关系",
|
||||
"data_grid.metadata_view.er_hidden_columns": "还有 {{count}} 个字段",
|
||||
"data_grid.metadata_view.er_expand_hidden_columns": "展开剩余 {{count}} 个字段",
|
||||
"data_grid.metadata_view.er_expand_fields": "展开全部字段",
|
||||
"data_grid.metadata_view.er_collapse_fields": "收起字段摘要",
|
||||
"data_grid.metadata_view.er_relation_depth": "{{count}} 层关系",
|
||||
"data_grid.metadata_view.er_expand_relations": "展开下一层关系",
|
||||
"data_grid.metadata_view.er_reset_relations": "重置为一层",
|
||||
"data_grid.metadata_view.er_empty": "当前表未发现外键关系",
|
||||
"data_grid.metadata_view.er_partial_warning": "部分关系未能完整加载,图中结果可能不完整",
|
||||
"data_grid.metadata_view.er_open_table": "打开表",
|
||||
|
||||
@@ -2565,6 +2565,12 @@
|
||||
"data_grid.metadata_view.er_related_table_count": "{{count}} 張關聯表",
|
||||
"data_grid.metadata_view.er_relation_count": "{{count}} 條關係",
|
||||
"data_grid.metadata_view.er_hidden_columns": "還有 {{count}} 個欄位",
|
||||
"data_grid.metadata_view.er_expand_hidden_columns": "展開剩餘 {{count}} 個欄位",
|
||||
"data_grid.metadata_view.er_expand_fields": "展開全部欄位",
|
||||
"data_grid.metadata_view.er_collapse_fields": "收起欄位摘要",
|
||||
"data_grid.metadata_view.er_relation_depth": "{{count}} 層關係",
|
||||
"data_grid.metadata_view.er_expand_relations": "展開下一層關係",
|
||||
"data_grid.metadata_view.er_reset_relations": "重置為一層",
|
||||
"data_grid.metadata_view.er_empty": "目前表尚未發現外鍵關係",
|
||||
"data_grid.metadata_view.er_partial_warning": "部分關係未能完整載入,圖中結果可能不完整",
|
||||
"data_grid.metadata_view.er_open_table": "打開表",
|
||||
|
||||
Reference in New Issue
Block a user