feat(data-grid): 完善 ER 图多层关系展开与字段浏览

- 支持按层扩展关联关系并重置为一层视图
- 支持节点字段展开收起与全部字段切换
- 补充 ER 图模型、Hook 与界面回归测试
This commit is contained in:
Syngnat
2026-06-21 11:37:45 +08:00
parent 5f56859898
commit 99b75378c3
13 changed files with 961 additions and 106 deletions

View 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 层关系');
});
});

View File

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

View File

@@ -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);
});
});

View File

@@ -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),
};
});

View 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);
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "テーブルを開く",

View File

@@ -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": "Открыть таблицу",

View File

@@ -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": "打开表",

View File

@@ -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": "打開表",