diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx index e42380d..5f7f937 100644 --- a/frontend/src/components/DataGrid.ddl.test.tsx +++ b/frontend/src/components/DataGrid.ddl.test.tsx @@ -69,8 +69,11 @@ const backendApp = vi.hoisted(() => ({ ImportData: vi.fn(), ExportTable: vi.fn(), ExportData: vi.fn(), + ExportDataWithOptions: vi.fn(), ExportQuery: vi.fn(), + ExportQueryWithOptions: vi.fn(), ApplyChanges: vi.fn(), + PreviewChanges: vi.fn(), DBGetColumns: vi.fn(), DBGetIndexes: vi.fn(), DBGetForeignKeys: vi.fn(), @@ -249,6 +252,7 @@ vi.mock('antd', () => { ); Modal.useModal = () => { const [infoConfig, setInfoConfig] = React.useState(null); + const [confirmConfig, setConfirmConfig] = React.useState(null); return [{ info: vi.fn((config: any) => { setInfoConfig(config); @@ -256,9 +260,53 @@ vi.mock('antd', () => { destroy: vi.fn(() => { setInfoConfig(null); }), + update: vi.fn(), }; }), - }, infoConfig ?
{infoConfig.content}
: null]; + confirm: vi.fn((config: any) => { + setConfirmConfig(config); + return { + destroy: vi.fn(() => { + setConfirmConfig(null); + }), + update: vi.fn(), + }; + }), + }, ( + <> + {infoConfig ?
{infoConfig.content}
: null} + {confirmConfig ? ( +
+

{confirmConfig.title}

+ {confirmConfig.content} +
+ + +
+
+ ) : null} + + )]; }; const passthrough = ({ children }: any) => <>{children}; @@ -330,7 +378,31 @@ vi.mock('antd', () => { Dropdown, Form, Pagination: () => null, - Select: ({ children }: any) =>
{children}
, + Select: ({ children, options, onChange, disabled, value }: any) => ( +
+ {children} + {(options || []).map((option: any) => ( + + ))} +
+ ), + InputNumber: ({ value, onChange, min, max }: any) => ( + onChange?.(Number(event.target.value))} + /> + ), Modal, Checkbox: ({ checked, onChange }: any) => , Segmented, @@ -644,9 +716,10 @@ describe('DataGrid commit change set', () => { it('keeps DataGrid AI insight prompt wrapper localized', () => { const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const dataGridShellSource = readFileSync(new URL('./DataGridShell.tsx', import.meta.url), 'utf8'); - expect(dataGridSource).not.toMatch(/请帮我分析以下查询结果数据|请分析数据特征|业务上的洞察/); - expect(dataGridSource).toContain('data_grid.ai_insight.prompt'); + expect(`${dataGridSource}\n${dataGridShellSource}`).not.toMatch(/请帮我分析以下查询结果数据|请分析数据特征|业务上的洞察/); + expect(dataGridShellSource).toContain('data_grid.ai_insight.prompt'); }); it('marks the active virtual editing row so shouldCellUpdate can reopen inline editors', () => { @@ -729,8 +802,11 @@ describe('DataGrid DDL interactions', () => { backendApp.ImportData.mockReset(); backendApp.ExportTable.mockReset(); backendApp.ExportData.mockReset(); + backendApp.ExportDataWithOptions.mockReset(); backendApp.ExportQuery.mockReset(); + backendApp.ExportQueryWithOptions.mockReset(); backendApp.ApplyChanges.mockReset(); + backendApp.PreviewChanges.mockReset(); backendApp.DBGetColumns.mockReset(); backendApp.DBGetIndexes.mockReset(); backendApp.DBGetForeignKeys.mockReset(); @@ -1264,8 +1340,8 @@ describe('DataGrid DDL interactions', () => { }); it('exports query-result rows from in-memory data without rerunning ExportQuery', async () => { - backendApp.ExportData.mockResolvedValue({ success: true }); - backendApp.ExportQuery.mockResolvedValue({ success: true }); + backendApp.ExportDataWithOptions.mockResolvedValue({ success: true }); + backendApp.ExportQueryWithOptions.mockResolvedValue({ success: true }); let renderer: ReactTestRenderer; await act(async () => { @@ -1286,24 +1362,34 @@ describe('DataGrid DDL interactions', () => { }); await waitForEffects(); - await act(async () => { - await findButton(renderer!, 'HTML').props.onClick(); - }); - - const exportAllButton = findButton(renderer!, t('data_grid.export.all_rows', { count: 2 })); - await act(async () => { - await exportAllButton.props.onClick(); + act(() => { + findButton(renderer!, t('data_grid.toolbar.export')).props.onClick(); }); await waitForEffects(); - expect(backendApp.ExportData).toHaveBeenCalledTimes(1); - expect(backendApp.ExportData).toHaveBeenCalledWith( + await act(async () => { + await renderer!.root.findByProps({ 'data-select-option': 'html' }).props.onClick(); + }); + await act(async () => { + await renderer!.root.findByProps({ 'data-select-option': 'page' }).props.onClick(); + }); + await act(async () => { + await findButton(renderer!, '开始导出').props.onClick(); + }); + await waitForEffects(); + + expect(backendApp.ExportDataWithOptions).toHaveBeenCalledTimes(1); + expect(backendApp.ExportDataWithOptions).toHaveBeenCalledWith( [{ owner: 'sa' }, { owner: 'dbo' }], ['owner'], 'export', - 'html', + expect.objectContaining({ + format: 'html', + totalRowsHint: 2, + totalRowsKnown: true, + }), ); - expect(backendApp.ExportQuery).not.toHaveBeenCalled(); + expect(backendApp.ExportQueryWithOptions).not.toHaveBeenCalled(); }); it('copies loaded column data from the v2 column header context menu', async () => { diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 3b05716..38a1f04 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -980,23 +980,27 @@ const DataGrid: React.FC = ({ [columnMetaMap, columnMetaMapByLowerName, dbType] ); - const openForeignKeyTarget = useCallback((target: ForeignKeyTarget) => { - const refTableName = String(target?.refTableName || '').trim(); - if (!connectionId || !refTableName || refTableName === '-') return; + const openTableByName = useCallback((nextTableName: string) => { + const normalizedTableName = String(nextTableName || '').trim(); + if (!connectionId || !normalizedTableName || normalizedTableName === '-') return; const targetDbName = String(dbName || '').trim(); - const tabId = `${connectionId}-${targetDbName}-table-${refTableName}`; + const tabId = `${connectionId}-${targetDbName}-table-${normalizedTableName}`; setActiveContext({ connectionId, dbName: targetDbName }); addTab({ id: tabId, - title: refTableName, + title: normalizedTableName, type: 'table', connectionId, dbName: targetDbName, - tableName: refTableName, + tableName: normalizedTableName, objectType: 'table', }); }, [addTab, connectionId, dbName, setActiveContext]); + const openForeignKeyTarget = useCallback((target: ForeignKeyTarget) => { + openTableByName(String(target?.refTableName || '').trim()); + }, [openTableByName]); + const renderColumnTitle = useCallback((name: string): React.ReactNode => { const normalizedName = String(name || ''); const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()]; @@ -4123,6 +4127,7 @@ const DataGrid: React.FC = ({ columnQuickFindOptions, columnQuickFindText, connectionId, + connections, containerRef, contextHolder, copiedCellPatch, @@ -4244,6 +4249,7 @@ const DataGrid: React.FC = ({ noAutoCapInputProps, normalizedPageFindText, onCancelTotalCount, + onOpenErTable: openTableByName, onPageChange, onReload, onRequestTotalCount, diff --git a/frontend/src/components/DataGridErDiagram.tsx b/frontend/src/components/DataGridErDiagram.tsx new file mode 100644 index 0000000..0826d82 --- /dev/null +++ b/frontend/src/components/DataGridErDiagram.tsx @@ -0,0 +1,424 @@ +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { Alert, Button, Spin, Tooltip } from 'antd'; +import { + ApartmentOutlined, + ArrowRightOutlined, + DatabaseOutlined, + LinkOutlined, + ReloadOutlined, +} from '@ant-design/icons'; +import dagre from 'dagre'; +import ReactFlow, { + Background, + BackgroundVariant, + Controls, + MarkerType, + Position, + ReactFlowProvider, + useEdgesState, + useNodesState, + useReactFlow, + type Edge, + type Node, + type NodeMouseHandler, +} from 'reactflow'; +import 'reactflow/dist/style.css'; +import { t as defaultTranslate, type I18nParams } from '../i18n'; +import type { BuildErDiagramGraphResult, ErDiagramEdge, ErDiagramNode } from './dataGridErDiagramModel'; +import { useDataGridErDiagram } from './useDataGridErDiagram'; + +type DataGridMetadataTranslate = (key: string, params?: I18nParams) => string; + +export interface DataGridErDiagramProps { + connections: any[]; + connectionId?: string; + dbName?: string; + tableName?: string; + translate?: DataGridMetadataTranslate; + onOpenTable?: (tableName: string) => void; +} + +type ErDiagramNodeData = { + node: ErDiagramNode; + selected: boolean; + translate: DataGridMetadataTranslate; + onOpenTable?: (tableName: string) => void; +}; + +const NODE_WIDTH = 320; +const NODE_BASE_HEIGHT = 108; +const NODE_ROW_HEIGHT = 28; +const NODE_FOOTER_HEIGHT = 28; + +const getRoleBadgeKey = (role: ErDiagramNode['role']): string => { + switch (role) { + case 'current': + return 'data_grid.metadata_view.er_current_badge'; + case 'incoming': + return 'data_grid.metadata_view.er_referenced_by_badge'; + case 'outgoing': + return 'data_grid.metadata_view.er_reference_badge'; + default: + return 'data_grid.metadata_view.er_table_badge'; + } +}; + +const estimateNodeHeight = (node: ErDiagramNode): number => ( + NODE_BASE_HEIGHT + + (node.columns.length * NODE_ROW_HEIGHT) + + (node.hiddenColumnCount > 0 ? NODE_FOOTER_HEIGHT : 0) +); + +const edgeColorByDirection: Record = { + incoming: '#2f9e44', + outgoing: '#1971c2', + self: '#7c3aed', +}; + +const layoutGraph = ( + graph: BuildErDiagramGraphResult, + translate: DataGridMetadataTranslate, + onOpenTable?: (tableName: string) => void, +): { nodes: Node[]; edges: Edge[] } => { + const dagreGraph = new dagre.graphlib.Graph(); + dagreGraph.setGraph({ + rankdir: 'LR', + nodesep: 56, + ranksep: 96, + marginx: 24, + marginy: 24, + }); + dagreGraph.setDefaultEdgeLabel(() => ({})); + + graph.nodes.forEach((node) => { + dagreGraph.setNode(node.id, { + width: NODE_WIDTH, + height: estimateNodeHeight(node), + }); + }); + graph.edges.forEach((edge) => { + dagreGraph.setEdge(edge.source, edge.target); + }); + dagre.layout(dagreGraph); + + return { + nodes: graph.nodes.map((node) => { + const height = estimateNodeHeight(node); + const position = dagreGraph.node(node.id); + return { + id: node.id, + type: 'erTable', + position: { + x: (position?.x ?? NODE_WIDTH / 2) - (NODE_WIDTH / 2), + y: (position?.y ?? height / 2) - (height / 2), + }, + data: { + node, + selected: false, + translate, + onOpenTable, + }, + draggable: true, + sourcePosition: Position.Right, + targetPosition: Position.Left, + }; + }), + edges: graph.edges.map((edge) => { + const color = edgeColorByDirection[edge.direction]; + return { + id: edge.id, + source: edge.source, + target: edge.target, + label: edge.label, + type: 'smoothstep', + animated: edge.direction === 'self', + markerEnd: { + type: MarkerType.ArrowClosed, + width: 18, + height: 18, + color, + }, + style: { + stroke: color, + strokeWidth: edge.direction === 'self' ? 2 : 1.7, + }, + labelStyle: { + fill: 'var(--gn-fg-4)', + fontSize: 11, + fontWeight: 600, + }, + labelBgPadding: [6, 3], + labelBgBorderRadius: 4, + labelBgStyle: { + fill: 'var(--gn-bg-panel-2)', + stroke: 'var(--gn-br-1)', + }, + }; + }), + }; +}; + +const ErTableNode = memo(function ErTableNode({ data }: { data: ErDiagramNodeData }) { + const { node, selected, translate, onOpenTable } = data; + const roleBadgeKey = getRoleBadgeKey(node.role); + const canOpen = !node.isCurrent && typeof onOpenTable === 'function'; + + return ( +
+
+
+ {translate(roleBadgeKey)} + {node.tableName} +
+ {canOpen && ( + +
+ +
+ + + + {node.incomingCount} + + + + + + {node.outgoingCount} + + +
+ +
+ {node.columns.map((column) => ( +
+
+ {column.isPrimary && PK} + {!column.isPrimary && column.isForeign && FK} + {column.name} +
+ {column.type || '-'} +
+ ))} +
+ + {node.hiddenColumnCount > 0 && ( +
+ {translate('data_grid.metadata_view.er_hidden_columns', { count: node.hiddenColumnCount })} +
+ )} +
+ ); +}); + +const DataGridErDiagramCanvasInner: React.FC<{ + graph: BuildErDiagramGraphResult; + translate: DataGridMetadataTranslate; + onOpenTable?: (tableName: string) => void; +}> = ({ + graph, + translate, + onOpenTable, +}) => { + const [selectedNodeId, setSelectedNodeId] = useState(graph.nodes[0]?.id || null); + const { fitView } = useReactFlow(); + + useEffect(() => { + setSelectedNodeId(graph.nodes[0]?.id || null); + }, [graph.nodes]); + + const layout = useMemo( + () => layoutGraph(graph, translate, onOpenTable), + [graph, onOpenTable, translate], + ); + + const [nodes, setNodes, onNodesChange] = useNodesState(layout.nodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(layout.edges); + + useEffect(() => { + setNodes( + layout.nodes.map((node) => ({ + ...node, + data: { + ...node.data, + selected: node.id === selectedNodeId, + }, + })), + ); + }, [layout.nodes, setNodes]); + + useEffect(() => { + setEdges(layout.edges); + }, [layout.edges, setEdges]); + + useEffect(() => { + setNodes((currentNodes) => currentNodes.map((node) => ({ + ...node, + data: { + ...node.data, + selected: node.id === selectedNodeId, + }, + }))); + }, [selectedNodeId, setNodes]); + + useEffect(() => { + const timer = window.setTimeout(() => { + void fitView({ padding: 0.18, duration: 220 }); + }, 0); + return () => { + window.clearTimeout(timer); + }; + }, [fitView, layout.edges, layout.nodes]); + + const handleNodeClick: NodeMouseHandler = useCallback((_event, node) => { + setSelectedNodeId(node.id); + }, []); + + const handleNodeDoubleClick: NodeMouseHandler = useCallback((_event, node) => { + const nodeData = node.data as ErDiagramNodeData | undefined; + if (nodeData?.node?.isCurrent) { + return; + } + nodeData?.onOpenTable?.(nodeData.node.tableName); + }, []); + + return ( + setSelectedNodeId(null)} + minZoom={0.35} + maxZoom={1.8} + fitView + fitViewOptions={{ padding: 0.18 }} + proOptions={{ hideAttribution: true }} + > + + + + ); +}; + +const DataGridErDiagramCanvas: React.FC<{ + graph: BuildErDiagramGraphResult; + translate: DataGridMetadataTranslate; + onOpenTable?: (tableName: string) => void; +}> = (props) => ( + + + +); + +const DataGridErDiagram: React.FC = ({ + connections, + connectionId, + dbName, + tableName, + translate = defaultTranslate, + onOpenTable, +}) => { + const { + graph, + loading, + reloading, + error, + partial, + reload, + } = useDataGridErDiagram({ + connections, + connectionId, + dbName, + tableName, + }); + + return ( +
+
+
+ {translate('data_grid.metadata_view.er_table_badge')} + {tableName || translate('data_grid.table_fallback.query_result')} +
+
+ + + {translate('data_grid.metadata_view.er_related_table_count', { count: graph?.relatedTableCount ?? 0 })} + + + + {translate('data_grid.metadata_view.er_relation_count', { count: graph?.relationCount ?? 0 })} + + +
+
+ + {partial && !error && ( +
+ +
+ )} + + {error ? ( +
+ +
+ ) : null} + +
+ + {graph ? ( + <> + {graph.isEmpty && ( +
+ {translate('data_grid.metadata_view.er_empty')} +
+ )} + + + ) : ( +
+ )} + +
+
+ ); +}; + +export default DataGridErDiagram; diff --git a/frontend/src/components/DataGridShell.tsx b/frontend/src/components/DataGridShell.tsx index 3f063e5..bb7f03e 100644 --- a/frontend/src/components/DataGridShell.tsx +++ b/frontend/src/components/DataGridShell.tsx @@ -92,6 +92,7 @@ const DataGridShell: React.FC = (props) => { columnQuickFindOptions, columnQuickFindText, connectionId, + connections, containerRef, contextHolder, copiedCellPatch, @@ -213,6 +214,7 @@ const DataGridShell: React.FC = (props) => { noAutoCapInputProps, normalizedPageFindText, onCancelTotalCount, + onOpenErTable, onPageChange, onReload, onRequestTotalCount, @@ -782,10 +784,14 @@ const renderDataTableView = () => ( /> ) : isV2Ui && viewMode === 'er' ? ( ) : viewMode === 'json' ? ( diff --git a/frontend/src/components/DataGridV2MetadataViews.tsx b/frontend/src/components/DataGridV2MetadataViews.tsx index 3902adf..a17ca55 100644 --- a/frontend/src/components/DataGridV2MetadataViews.tsx +++ b/frontend/src/components/DataGridV2MetadataViews.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { t as defaultTranslate, type I18nParams } from '../i18n'; +import DataGridErDiagram from './DataGridErDiagram'; type DataGridMetadataTranslate = (key: string, params?: I18nParams) => string; @@ -62,14 +63,18 @@ export const DataGridV2FieldsView: React.FC = ({ ); export interface DataGridV2ErViewProps { + connections?: any[]; + connectionId?: string; + dbName?: string; tableName?: string; displayOutputColumnNames: string[]; columnMetaMap: Record; columnMetaMapByLowerName: Record; + onOpenTable?: (tableName: string) => void; translate?: DataGridMetadataTranslate; } -export const DataGridV2ErView: React.FC = ({ +const StaticErPreview: React.FC> = ({ tableName, displayOutputColumnNames, columnMetaMap, @@ -97,3 +102,38 @@ export const DataGridV2ErView: React.FC = ({
); + +export const DataGridV2ErView: React.FC = ({ + connections, + connectionId, + dbName, + tableName, + displayOutputColumnNames, + columnMetaMap, + columnMetaMapByLowerName, + onOpenTable, + translate = defaultTranslate, +}) => { + if (!connectionId || !tableName || !Array.isArray(connections) || connections.length === 0) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/frontend/src/components/dataGridErDiagramModel.test.ts b/frontend/src/components/dataGridErDiagramModel.test.ts new file mode 100644 index 0000000..02ffe35 --- /dev/null +++ b/frontend/src/components/dataGridErDiagramModel.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { + buildErDiagramGraph, + extractErTableNames, + normalizeForeignKeyDefinitions, + resolveErActualTableName, + tableNamesMatch, + type ErDiagramTableSnapshot, +} from './dataGridErDiagramModel'; + +describe('dataGridErDiagramModel', () => { + it('builds a one-hop ER graph with incoming and outgoing relations', () => { + const currentSnapshot: ErDiagramTableSnapshot = { + tableName: 'orders', + columns: [ + { name: 'id', type: 'bigint', nullable: 'NO', key: 'PRI', extra: '', comment: 'pk' }, + { name: 'customer_id', type: 'bigint', nullable: 'NO', key: '', extra: '', comment: 'customer fk' }, + { name: 'order_no', type: 'varchar(32)', nullable: 'NO', key: '', extra: '', comment: '' }, + { name: 'created_at', type: 'datetime', nullable: 'NO', key: '', extra: '', comment: '' }, + ], + foreignKeys: [ + { + name: 'fk_orders_customer', + columnName: 'customer_id', + refTableName: 'customers', + refColumnName: 'id', + constraintName: 'fk_orders_customer', + }, + ], + uniqueKeyGroups: [['id']], + }; + + const relatedSnapshots: ErDiagramTableSnapshot[] = [ + { + tableName: 'customers', + columns: [ + { name: 'id', type: 'bigint', nullable: 'NO', key: 'PRI', extra: '', comment: '' }, + { name: 'name', type: 'varchar(120)', nullable: 'NO', key: '', extra: '', comment: '' }, + ], + foreignKeys: [], + uniqueKeyGroups: [['id']], + }, + { + tableName: 'order_items', + columns: [ + { name: 'id', type: 'bigint', nullable: 'NO', key: 'PRI', extra: '', comment: '' }, + { name: 'order_id', type: 'bigint', nullable: 'NO', key: '', extra: '', comment: '' }, + { name: 'sku', type: 'varchar(64)', nullable: 'NO', key: '', extra: '', comment: '' }, + ], + foreignKeys: [ + { + name: 'fk_items_order', + columnName: 'order_id', + refTableName: 'orders', + refColumnName: 'id', + constraintName: 'fk_items_order', + }, + ], + uniqueKeyGroups: [['id']], + }, + ]; + + const graph = buildErDiagramGraph({ + currentTableName: 'orders', + currentSnapshot, + relatedSnapshots, + relations: [ + { + sourceTableName: 'orders', + targetTableName: 'customers', + columnName: 'customer_id', + refColumnName: 'id', + constraintName: 'fk_orders_customer', + direction: 'outgoing', + }, + { + sourceTableName: 'order_items', + targetTableName: 'orders', + columnName: 'order_id', + refColumnName: 'id', + constraintName: 'fk_items_order', + direction: 'incoming', + }, + ], + }); + + expect(graph.relatedTableCount).toBe(2); + expect(graph.relationCount).toBe(2); + expect(graph.incomingTableCount).toBe(1); + expect(graph.outgoingTableCount).toBe(1); + + const ordersNode = graph.nodes.find((node) => node.tableName === 'orders'); + const customersNode = graph.nodes.find((node) => node.tableName === 'customers'); + const itemsNode = graph.nodes.find((node) => node.tableName === 'order_items'); + + expect(ordersNode?.role).toBe('current'); + expect(customersNode?.role).toBe('outgoing'); + expect(itemsNode?.role).toBe('incoming'); + expect(ordersNode?.columns.map((column) => column.name)).toEqual( + expect.arrayContaining(['id', 'customer_id']), + ); + expect(ordersNode?.columns.find((column) => column.name === 'id')?.isPrimary).toBe(true); + expect(ordersNode?.columns.find((column) => column.name === 'customer_id')?.isForeign).toBe(true); + expect(graph.edges.map((edge) => edge.label)).toEqual( + expect.arrayContaining(['customer_id -> id', 'order_id -> id']), + ); + }); + + it('normalizes qualified table names and foreign key rows', () => { + expect(tableNamesMatch('public.orders', 'orders')).toBe(true); + expect(resolveErActualTableName('orders', ['public.orders', 'audit.orders_archive'])).toBe('public.orders'); + expect(extractErTableNames([{ Tables_in_main: 'orders' }, { Table: 'public.customers' }])).toEqual([ + 'orders', + 'public.customers', + ]); + expect(normalizeForeignKeyDefinitions([ + { + ColumnName: 'customer_id', + RefTableName: 'public.customers', + RefColumnName: 'id', + ConstraintName: 'fk_orders_customer', + }, + ])).toEqual([ + { + name: '', + columnName: 'customer_id', + refTableName: 'public.customers', + refColumnName: 'id', + constraintName: 'fk_orders_customer', + }, + ]); + }); +}); diff --git a/frontend/src/components/dataGridErDiagramModel.ts b/frontend/src/components/dataGridErDiagramModel.ts new file mode 100644 index 0000000..7b3a753 --- /dev/null +++ b/frontend/src/components/dataGridErDiagramModel.ts @@ -0,0 +1,424 @@ +import type { ColumnDefinition, ForeignKeyDefinition } from '../types'; + +export type ErDiagramRelationDirection = 'incoming' | 'outgoing' | 'self'; + +export interface ErDiagramRelation { + sourceTableName: string; + targetTableName: string; + columnName: string; + refColumnName: string; + constraintName: string; + direction: ErDiagramRelationDirection; +} + +export interface ErDiagramTableSnapshot { + tableName: string; + columns: ColumnDefinition[]; + foreignKeys: ForeignKeyDefinition[]; + uniqueKeyGroups: string[][]; +} + +export interface ErDiagramNodeField { + name: string; + type: string; + comment: string; + nullable: boolean; + isPrimary: boolean; + isForeign: boolean; + isRelationField: boolean; +} + +export interface ErDiagramNode { + id: string; + tableName: string; + role: 'current' | 'incoming' | 'outgoing' | 'related'; + isCurrent: boolean; + incomingCount: number; + outgoingCount: number; + relationCount: number; + columns: ErDiagramNodeField[]; + hiddenColumnCount: number; +} + +export interface ErDiagramEdge { + id: string; + source: string; + target: string; + label: string; + direction: ErDiagramRelationDirection; + relationCount: number; +} + +export interface BuildErDiagramGraphResult { + nodes: ErDiagramNode[]; + edges: ErDiagramEdge[]; + relationCount: number; + relatedTableCount: number; + incomingTableCount: number; + outgoingTableCount: number; + isEmpty: boolean; +} + +const readText = (source: unknown, keys: string[]): string => { + const record = source as Record | null | undefined; + if (!record || typeof record !== 'object') { + return ''; + } + for (const key of keys) { + const raw = record[key]; + if (raw !== undefined && raw !== null) { + return String(raw).trim(); + } + } + for (const [sourceKey, raw] of Object.entries(record)) { + if (keys.some((key) => sourceKey.toLowerCase() === key.toLowerCase())) { + return raw === undefined || raw === null ? '' : String(raw).trim(); + } + } + return ''; +}; + +const stripWrappedIdentifier = (value: string): string => { + let next = String(value || '').trim(); + while (next.length > 1) { + if ( + (next.startsWith('`') && next.endsWith('`')) || + (next.startsWith('"') && next.endsWith('"')) || + (next.startsWith("'") && next.endsWith("'")) || + (next.startsWith('[') && next.endsWith(']')) + ) { + next = next.slice(1, -1).trim(); + continue; + } + break; + } + return next; +}; + +const splitQualifiedName = (value: string): string[] => ( + String(value || '') + .split('.') + .map((part) => stripWrappedIdentifier(part)) + .filter(Boolean) +); + +const normalizeColumnName = (value: string): string => stripWrappedIdentifier(value).toLowerCase(); + +export const normalizeErQualifiedName = (value: string): string => splitQualifiedName(value).join('.').toLowerCase(); + +export const getErTableNameCandidates = (value: string): string[] => { + const parts = splitQualifiedName(value); + if (parts.length === 0) { + return []; + } + const seen = new Set(); + const result: string[] = []; + for (let start = 0; start < parts.length; start += 1) { + const candidate = parts.slice(start).join('.').toLowerCase(); + if (!candidate || seen.has(candidate)) { + continue; + } + seen.add(candidate); + result.push(candidate); + } + return result; +}; + +export const tableNamesMatch = (left: string, right: string): boolean => { + const leftCandidates = getErTableNameCandidates(left); + const rightCandidates = new Set(getErTableNameCandidates(right)); + return leftCandidates.some((candidate) => rightCandidates.has(candidate)); +}; + +export const resolveErActualTableName = (tableName: string, candidates: string[]): string => { + const normalizedInput = normalizeErQualifiedName(tableName); + if (!normalizedInput) { + return String(tableName || '').trim(); + } + + const exactMatch = candidates.find((candidate) => normalizeErQualifiedName(candidate) === normalizedInput); + if (exactMatch) { + return exactMatch; + } + + const suffixCandidates = getErTableNameCandidates(tableName); + for (const suffix of suffixCandidates) { + const matches = candidates.filter((candidate) => getErTableNameCandidates(candidate).includes(suffix)); + if (matches.length === 1) { + return matches[0]; + } + } + + return String(tableName || '').trim(); +}; + +export const extractErTableNames = (rows: unknown): string[] => { + if (!Array.isArray(rows)) { + return []; + } + const seen = new Set(); + const result: string[] = []; + rows.forEach((row) => { + const candidate = readText(row, [ + 'table', + 'Table', + 'TABLE', + 'tableName', + 'TableName', + 'TABLE_NAME', + 'name', + 'Name', + ]) || String(Object.values((row as Record) || {})[0] || '').trim(); + const normalized = normalizeErQualifiedName(candidate); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + result.push(candidate); + }); + return result; +}; + +export const normalizeForeignKeyDefinitions = (rows: unknown): ForeignKeyDefinition[] => { + if (!Array.isArray(rows)) { + return []; + } + return rows + .map((row) => ({ + name: readText(row, ['name', 'Name']), + columnName: readText(row, ['columnName', 'ColumnName', 'column_name', 'COLUMN_NAME']), + refTableName: readText(row, ['refTableName', 'RefTableName', 'ref_table_name', 'REF_TABLE_NAME']), + refColumnName: readText(row, ['refColumnName', 'RefColumnName', 'ref_column_name', 'REF_COLUMN_NAME']), + constraintName: readText(row, ['constraintName', 'ConstraintName', 'constraint_name', 'CONSTRAINT_NAME', 'name', 'Name']), + })) + .filter((item) => item.columnName && item.refTableName && item.refTableName !== '-'); +}; + +const dedupeByNormalizedValue = (values: string[]): string[] => { + const seen = new Set(); + const result: string[] = []; + values.forEach((value) => { + const normalized = normalizeErQualifiedName(value); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + result.push(value); + }); + return result; +}; + +const matchColumnName = (left: string, right: string): boolean => normalizeColumnName(left) === normalizeColumnName(right); + +const isPrimaryColumn = (column: ColumnDefinition, snapshot: ErDiagramTableSnapshot): boolean => { + const key = String(column?.key || '').trim().toUpperCase(); + if (key === 'PRI') { + return true; + } + return snapshot.uniqueKeyGroups.some((group) => group.length === 1 && matchColumnName(group[0], column.name)); +}; + +const getNodeId = (tableName: string): string => `er:${normalizeErQualifiedName(tableName)}`; + +const buildEdgeLabel = (relations: ErDiagramRelation[]): string => { + if (relations.length === 0) { + return ''; + } + const labels = relations.map((relation) => { + const refColumnName = relation.refColumnName || '?'; + return `${relation.columnName} -> ${refColumnName}`; + }); + if (labels.length <= 2) { + return labels.join(', '); + } + return `${labels.slice(0, 2).join(', ')} +${labels.length - 2}`; +}; + +export const buildErDiagramGraph = (params: { + currentTableName: string; + currentSnapshot: ErDiagramTableSnapshot; + relatedSnapshots: ErDiagramTableSnapshot[]; + relations: ErDiagramRelation[]; + maxColumnsPerNode?: number; +}): BuildErDiagramGraphResult => { + const { + currentTableName, + currentSnapshot, + relatedSnapshots, + relations, + maxColumnsPerNode = 10, + } = params; + + const snapshotByTable = new Map(); + const setSnapshot = (snapshot: ErDiagramTableSnapshot) => { + const key = normalizeErQualifiedName(snapshot.tableName); + if (key) { + snapshotByTable.set(key, snapshot); + } + }; + setSnapshot(currentSnapshot); + relatedSnapshots.forEach(setSnapshot); + + const incomingCountByTable = new Map(); + const outgoingCountByTable = new Map(); + const relationFieldsByTable = new Map>(); + + const addRelationField = (tableName: string, columnName: string) => { + const key = normalizeErQualifiedName(tableName); + if (!key || !columnName) { + return; + } + if (!relationFieldsByTable.has(key)) { + relationFieldsByTable.set(key, new Set()); + } + relationFieldsByTable.get(key)?.add(normalizeColumnName(columnName)); + }; + + relations.forEach((relation) => { + const sourceKey = normalizeErQualifiedName(relation.sourceTableName); + const targetKey = normalizeErQualifiedName(relation.targetTableName); + if (!sourceKey || !targetKey) { + return; + } + addRelationField(relation.sourceTableName, relation.columnName); + addRelationField(relation.targetTableName, relation.refColumnName); + outgoingCountByTable.set(sourceKey, (outgoingCountByTable.get(sourceKey) || 0) + 1); + incomingCountByTable.set(targetKey, (incomingCountByTable.get(targetKey) || 0) + 1); + }); + + const relatedTableNames = dedupeByNormalizedValue( + relations.flatMap((relation) => [relation.sourceTableName, relation.targetTableName]), + ).filter((tableName) => !tableNamesMatch(tableName, currentTableName)); + const outgoingRelatedTableKeys = new Set( + relations + .filter((relation) => tableNamesMatch(relation.sourceTableName, currentTableName)) + .map((relation) => normalizeErQualifiedName(relation.targetTableName)), + ); + const incomingRelatedTableKeys = new Set( + relations + .filter((relation) => tableNamesMatch(relation.targetTableName, currentTableName)) + .map((relation) => normalizeErQualifiedName(relation.sourceTableName)), + ); + + const nodeNames = [currentSnapshot.tableName, ...relatedTableNames]; + const sortedNodeNames = nodeNames.sort((left, right) => { + if (tableNamesMatch(left, currentTableName)) return -1; + if (tableNamesMatch(right, currentTableName)) return 1; + return left.localeCompare(right); + }); + + const nodes = sortedNodeNames.map((tableName) => { + const snapshot = snapshotByTable.get(normalizeErQualifiedName(tableName)) || { + tableName, + columns: [], + foreignKeys: [], + uniqueKeyGroups: [], + }; + const key = normalizeErQualifiedName(tableName); + const incomingCount = incomingCountByTable.get(key) || 0; + const outgoingCount = outgoingCountByTable.get(key) || 0; + const foreignColumns = new Set( + snapshot.foreignKeys.map((foreignKey) => normalizeColumnName(foreignKey.columnName)), + ); + const relationFields = relationFieldsByTable.get(key) || new Set(); + const prioritized: ColumnDefinition[] = []; + const remainder: ColumnDefinition[] = []; + + snapshot.columns.forEach((column) => { + const normalizedColumn = normalizeColumnName(column.name); + const preferred = + isPrimaryColumn(column, snapshot) || + foreignColumns.has(normalizedColumn) || + relationFields.has(normalizedColumn); + if (preferred) { + prioritized.push(column); + } else { + remainder.push(column); + } + }); + + const visibleColumns = [...prioritized, ...remainder] + .filter((column, index, allColumns) => ( + allColumns.findIndex((candidate) => matchColumnName(candidate.name, column.name)) === index + )) + .slice(0, maxColumnsPerNode) + .map((column) => { + const normalizedColumn = normalizeColumnName(column.name); + return { + name: column.name, + type: column.type || '', + comment: column.comment || '', + nullable: String(column.nullable || '').toUpperCase() !== 'NO', + isPrimary: isPrimaryColumn(column, snapshot), + isForeign: foreignColumns.has(normalizedColumn), + isRelationField: relationFields.has(normalizedColumn), + }; + }); + + let role: ErDiagramNode['role'] = 'related'; + if (tableNamesMatch(tableName, currentTableName)) { + role = 'current'; + } else if (incomingRelatedTableKeys.has(key) && !outgoingRelatedTableKeys.has(key)) { + role = 'incoming'; + } else if (outgoingRelatedTableKeys.has(key) && !incomingRelatedTableKeys.has(key)) { + role = 'outgoing'; + } + + return { + id: getNodeId(tableName), + tableName, + role, + isCurrent: role === 'current', + incomingCount, + outgoingCount, + relationCount: incomingCount + outgoingCount, + columns: visibleColumns, + hiddenColumnCount: Math.max(0, snapshot.columns.length - visibleColumns.length), + }; + }); + + const edgeBuckets = new Map(); + relations.forEach((relation) => { + const sourceId = getNodeId(relation.sourceTableName); + const targetId = getNodeId(relation.targetTableName); + const bucketKey = `${sourceId}|${targetId}|${relation.direction}`; + if (!edgeBuckets.has(bucketKey)) { + edgeBuckets.set(bucketKey, []); + } + edgeBuckets.get(bucketKey)?.push(relation); + }); + + const edges = Array.from(edgeBuckets.entries()).map(([bucketKey, bucketRelations]) => { + const [source, target, direction] = bucketKey.split('|'); + return { + id: `${bucketKey}|${bucketRelations.length}`, + source, + target, + label: buildEdgeLabel(bucketRelations), + direction: direction as ErDiagramRelationDirection, + relationCount: bucketRelations.length, + }; + }); + + const currentKey = normalizeErQualifiedName(currentTableName); + const incomingTableCount = dedupeByNormalizedValue( + relations + .filter((relation) => normalizeErQualifiedName(relation.targetTableName) === currentKey) + .map((relation) => relation.sourceTableName), + ).filter((tableName) => !tableNamesMatch(tableName, currentTableName)).length; + const outgoingTableCount = dedupeByNormalizedValue( + relations + .filter((relation) => normalizeErQualifiedName(relation.sourceTableName) === currentKey) + .map((relation) => relation.targetTableName), + ).filter((tableName) => !tableNamesMatch(tableName, currentTableName)).length; + + return { + nodes, + edges, + relationCount: relations.length, + relatedTableCount: relatedTableNames.length, + incomingTableCount, + outgoingTableCount, + isEmpty: relations.length === 0, + }; +}; diff --git a/frontend/src/components/useDataGridErDiagram.ts b/frontend/src/components/useDataGridErDiagram.ts new file mode 100644 index 0000000..a4930d1 --- /dev/null +++ b/frontend/src/components/useDataGridErDiagram.ts @@ -0,0 +1,435 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { DBGetColumns, DBGetForeignKeys, DBGetIndexes, DBGetTables } from '../../wailsjs/go/app/App'; +import type { ColumnDefinition, ForeignKeyDefinition } from '../types'; +import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; +import { normalizeColumnDefinitions } from '../utils/columnDefinition'; +import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert'; +import { + buildErDiagramGraph, + extractErTableNames, + normalizeErQualifiedName, + normalizeForeignKeyDefinitions, + resolveErActualTableName, + tableNamesMatch, + type BuildErDiagramGraphResult, + type ErDiagramRelation, + type ErDiagramTableSnapshot, +} from './dataGridErDiagramModel'; + +type DataGridErDiagramParams = { + connections: any[]; + connectionId?: string; + dbName?: string; + tableName?: string; +}; + +type DataGridErDiagramState = { + graph: BuildErDiagramGraphResult | null; + loading: boolean; + reloading: boolean; + error: string; + partial: boolean; + warningCount: number; +}; + +type CacheValue = T | Promise; + +const schemaTableNamesCache = new Map>(); +const tableColumnsCache = new Map>(); +const tableForeignKeysCache = new Map>(); +const tableUniqueKeyGroupsCache = new Map>(); + +const DEFAULT_EMPTY_STATE: DataGridErDiagramState = { + graph: null, + loading: false, + reloading: false, + error: '', + partial: false, + warningCount: 0, +}; + +const normalizeConnectionConfig = (connection: any) => ({ + ...connection?.config, + port: Number(connection?.config?.port), + password: connection?.config?.password || '', + database: connection?.config?.database || '', + useSSH: connection?.config?.useSSH || false, + ssh: connection?.config?.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }, +}); + +const readCache = async ( + cache: Map>, + key: string, + loader: () => Promise, +): Promise => { + const cached = cache.get(key); + if (cached instanceof Promise) { + return cached; + } + if (cached !== undefined) { + return cached; + } + + const pending = loader() + .then((value) => { + cache.set(key, value); + return value; + }) + .catch((error) => { + cache.delete(key); + throw error; + }); + cache.set(key, pending); + return pending; +}; + +const invalidateCacheByPrefix = (prefix: string) => { + [schemaTableNamesCache, tableColumnsCache, tableForeignKeysCache, tableUniqueKeyGroupsCache].forEach((cache) => { + Array.from(cache.keys()).forEach((key) => { + if (key.startsWith(prefix)) { + cache.delete(key); + } + }); + }); +}; + +const runWithConcurrency = async ( + items: T[], + limit: number, + worker: (item: T) => Promise, +): Promise>> => { + if (items.length === 0) { + return []; + } + + const results: Array> = new Array(items.length); + let cursor = 0; + + const executeNext = async (): Promise => { + const index = cursor; + cursor += 1; + if (index >= items.length) { + return; + } + try { + const value = await worker(items[index]); + results[index] = { status: 'fulfilled', value }; + } catch (error) { + results[index] = { status: 'rejected', reason: error }; + } + await executeNext(); + }; + + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, () => executeNext()), + ); + + return results; +}; + +const loadSchemaTableNames = async ( + config: any, + dbName: string, + schemaCacheKey: string, +): Promise => readCache(schemaTableNamesCache, schemaCacheKey, async () => { + const response = await DBGetTables(buildRpcConnectionConfig(config) as any, dbName); + if (!response?.success) { + throw new Error(response?.message || 'Failed to load tables'); + } + return extractErTableNames(response.data); +}); + +const loadTableColumns = async ( + config: any, + dbName: string, + tableName: string, + cacheKey: string, +): Promise => readCache(tableColumnsCache, cacheKey, async () => { + const response = await DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName); + if (!response?.success) { + throw new Error(response?.message || `Failed to load columns for ${tableName}`); + } + return normalizeColumnDefinitions(response.data); +}); + +const loadTableForeignKeys = async ( + config: any, + dbName: string, + tableName: string, + cacheKey: string, +): Promise => readCache(tableForeignKeysCache, cacheKey, async () => { + const response = await DBGetForeignKeys(buildRpcConnectionConfig(config) as any, dbName, tableName); + if (!response?.success) { + throw new Error(response?.message || `Failed to load foreign keys for ${tableName}`); + } + return normalizeForeignKeyDefinitions(response.data); +}); + +const loadTableUniqueKeyGroups = async ( + config: any, + dbName: string, + tableName: string, + cacheKey: string, +): Promise => readCache(tableUniqueKeyGroupsCache, cacheKey, async () => { + const response = await DBGetIndexes(buildRpcConnectionConfig(config) as any, dbName, tableName); + if (!response?.success || !Array.isArray(response.data)) { + return []; + } + return resolveUniqueKeyGroupsFromIndexes(response.data); +}); + +const loadTableSnapshot = async ( + config: any, + dbName: string, + tableName: string, + tableCacheKey: string, +): Promise => { + const [columnsResult, foreignKeysResult, uniqueKeyGroupsResult] = await Promise.allSettled([ + loadTableColumns(config, dbName, tableName, `${tableCacheKey}|columns`), + loadTableForeignKeys(config, dbName, tableName, `${tableCacheKey}|foreignKeys`), + loadTableUniqueKeyGroups(config, dbName, tableName, `${tableCacheKey}|uniqueKeys`), + ]); + + if (columnsResult.status === 'rejected') { + throw columnsResult.reason; + } + + return { + tableName, + columns: columnsResult.value, + foreignKeys: foreignKeysResult.status === 'fulfilled' ? foreignKeysResult.value : [], + uniqueKeyGroups: uniqueKeyGroupsResult.status === 'fulfilled' ? uniqueKeyGroupsResult.value : [], + }; +}; + +const dedupeRelations = (relations: ErDiagramRelation[]): ErDiagramRelation[] => { + const seen = new Set(); + const result: ErDiagramRelation[] = []; + relations.forEach((relation) => { + const key = [ + normalizeErQualifiedName(relation.sourceTableName), + normalizeErQualifiedName(relation.targetTableName), + relation.columnName.toLowerCase(), + relation.refColumnName.toLowerCase(), + relation.direction, + relation.constraintName.toLowerCase(), + ].join('|'); + if (!key || seen.has(key)) { + return; + } + seen.add(key); + result.push(relation); + }); + return result; +}; + +const dedupeTableNames = (tableNames: string[]): string[] => { + const seen = new Set(); + const result: string[] = []; + tableNames.forEach((tableName) => { + const normalized = normalizeErQualifiedName(tableName); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + result.push(tableName); + }); + return result; +}; + +export const useDataGridErDiagram = (params: DataGridErDiagramParams) => { + const { + connections, + connectionId, + dbName, + tableName, + } = params; + + const [state, setState] = useState(DEFAULT_EMPTY_STATE); + const [reloadVersion, setReloadVersion] = useState(0); + const requestSeqRef = useRef(0); + const normalizedDbName = useMemo(() => String(dbName || '').trim(), [dbName]); + const normalizedTableName = useMemo(() => String(tableName || '').trim(), [tableName]); + const cachePrefix = useMemo( + () => `${String(connectionId || '').trim()}|${normalizedDbName}|`, + [connectionId, normalizedDbName], + ); + + const reload = useCallback(() => { + if (!cachePrefix) { + return; + } + invalidateCacheByPrefix(cachePrefix); + requestSeqRef.current += 1; + setReloadVersion((version) => version + 1); + setState((prev) => ({ + ...prev, + loading: !prev.graph, + reloading: Boolean(prev.graph), + error: '', + })); + }, [cachePrefix]); + + useEffect(() => { + if (!connectionId || !normalizedTableName) { + setState(DEFAULT_EMPTY_STATE); + return; + } + + const connection = connections.find((item) => item.id === connectionId); + if (!connection) { + setState({ + ...DEFAULT_EMPTY_STATE, + error: 'Connection not found', + }); + return; + } + + const seq = ++requestSeqRef.current; + const config = normalizeConnectionConfig(connection); + const schemaCacheKey = `${cachePrefix}schemaTables`; + const currentTableCacheKey = `${cachePrefix}${normalizedTableName}`; + + setState((prev) => ({ + ...prev, + loading: !prev.graph, + reloading: Boolean(prev.graph), + error: '', + partial: false, + warningCount: 0, + })); + + const loadGraph = async () => { + let warningCount = 0; + const currentSnapshot = await loadTableSnapshot(config, normalizedDbName, normalizedTableName, currentTableCacheKey); + + let schemaTableNames = [currentSnapshot.tableName]; + try { + schemaTableNames = await loadSchemaTableNames(config, normalizedDbName, schemaCacheKey); + } catch { + warningCount += 1; + } + + const resolvedSchemaTableNames = extractErTableNames([ + ...schemaTableNames.map((name) => ({ table: name })), + ...currentSnapshot.foreignKeys.map((foreignKey) => ({ table: foreignKey.refTableName })), + { table: currentSnapshot.tableName }, + ]); + + 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: [], + }; + }); + + return { + graph: buildErDiagramGraph({ + currentTableName: currentSnapshot.tableName, + currentSnapshot, + relatedSnapshots, + relations, + }), + partial: warningCount > 0, + warningCount, + }; + }; + + void loadGraph() + .then((result) => { + if (seq !== requestSeqRef.current) { + return; + } + setState({ + graph: result.graph, + loading: false, + reloading: false, + error: '', + partial: result.partial, + warningCount: result.warningCount, + }); + }) + .catch((error) => { + if (seq !== requestSeqRef.current) { + return; + } + setState({ + graph: null, + loading: false, + reloading: false, + error: error instanceof Error ? error.message : String(error || 'Failed to load ER diagram'), + partial: false, + warningCount: 0, + }); + }); + }, [cachePrefix, connectionId, connections, normalizedDbName, normalizedTableName, reloadVersion]); + + return { + ...state, + reload, + }; +}; diff --git a/frontend/src/v2-theme.css b/frontend/src/v2-theme.css index 9ae0397..2a4afe0 100644 --- a/frontend/src/v2-theme.css +++ b/frontend/src/v2-theme.css @@ -919,6 +919,286 @@ body[data-ui-version="v2"] .gn-v2-data-grid-er-side { gap: 10px; } +body[data-ui-version="v2"] .gn-v2-data-grid-er-diagram { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + background: var(--gn-bg-panel); +} + +body[data-ui-version="v2"] .gn-v2-data-grid-er-toolbar { + min-height: 52px; + padding: 10px 12px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + border-bottom: 0.5px solid var(--gn-br-1); + background: var(--gn-bg-panel-2); +} + +body[data-ui-version="v2"] .gn-v2-data-grid-er-toolbar > div { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +body[data-ui-version="v2"] .gn-v2-data-grid-er-toolbar > div:first-child { + flex-direction: column; + align-items: flex-start; + gap: 1px; +} + +body[data-ui-version="v2"] .gn-v2-data-grid-er-toolbar span { + color: var(--gn-fg-5); + font-family: var(--gn-font-sans); + font-size: 10.5px; + font-weight: 650; +} + +body[data-ui-version="v2"] .gn-v2-data-grid-er-toolbar strong { + max-width: 520px; + overflow: hidden; + color: var(--gn-fg-1); + font-family: var(--gn-font-sans); + font-size: 13px; + font-weight: 750; + text-overflow: ellipsis; + white-space: nowrap; +} + +body[data-ui-version="v2"] .gn-v2-data-grid-er-summary { + justify-content: flex-end; +} + +body[data-ui-version="v2"] .gn-v2-data-grid-er-chip { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 10px; + border: 0.5px solid var(--gn-br-2); + border-radius: 999px; + background: var(--gn-bg-panel); + color: var(--gn-fg-3); + font-size: 12px; +} + +body[data-ui-version="v2"] .gn-v2-data-grid-er-chip .anticon { + color: var(--gn-accent); +} + +body[data-ui-version="v2"] .gn-v2-data-grid-er-alert { + padding: 10px 12px 0; + background: var(--gn-bg-panel); +} + +body[data-ui-version="v2"] .gn-v2-data-grid-er-canvas { + position: relative; + flex: 1 1 auto; + min-height: 0; + padding: 12px; + background: var(--gn-bg-panel); +} + +body[data-ui-version="v2"] .gn-v2-data-grid-er-canvas > .ant-spin-nested-loading, +body[data-ui-version="v2"] .gn-v2-data-grid-er-canvas > .ant-spin-nested-loading > div, +body[data-ui-version="v2"] .gn-v2-data-grid-er-canvas .ant-spin-container { + height: 100%; +} + +body[data-ui-version="v2"] .gn-v2-data-grid-er-empty { + position: absolute; + top: 18px; + left: 50%; + z-index: 5; + transform: translateX(-50%); + padding: 6px 10px; + border: 0.5px solid color-mix(in srgb, var(--gn-accent) 18%, var(--gn-br-2)); + border-radius: 999px; + background: color-mix(in srgb, var(--gn-accent-soft) 72%, var(--gn-bg-panel-2)); + color: var(--gn-fg-3); + font-size: 12px; + box-shadow: var(--gn-shadow-sm); +} + +body[data-ui-version="v2"] .gn-v2-data-grid-er-placeholder { + min-height: 320px; +} + +body[data-ui-version="v2"] .gn-v2-data-grid-er-canvas .react-flow, +body[data-ui-version="v2"] .gn-v2-data-grid-er-canvas .react-flow__renderer, +body[data-ui-version="v2"] .gn-v2-data-grid-er-canvas .react-flow__viewport { + background: transparent; +} + +body[data-ui-version="v2"] .gn-v2-data-grid-er-canvas .react-flow__controls { + border-radius: 8px; + box-shadow: var(--gn-shadow-sm); +} + +body[data-ui-version="v2"] .gn-er-node-card { + width: 320px; + overflow: hidden; + border: 0.5px solid var(--gn-br-2); + border-radius: 8px; + background: var(--gn-bg-panel-2); + box-shadow: var(--gn-shadow-sm); +} + +body[data-ui-version="v2"] .gn-er-node-card.is-selected { + border-color: color-mix(in srgb, var(--gn-accent) 56%, var(--gn-br-2)); + box-shadow: + 0 0 0 2px color-mix(in srgb, var(--gn-accent) 22%, transparent), + var(--gn-shadow-sm); +} + +body[data-ui-version="v2"] .gn-er-node-card.is-current { + background: color-mix(in srgb, var(--gn-accent-soft) 68%, var(--gn-bg-panel-2)); +} + +body[data-ui-version="v2"] .gn-er-node-header { + padding: 12px 12px 10px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + border-bottom: 0.5px solid var(--gn-br-1); + cursor: grab; +} + +body[data-ui-version="v2"] .gn-er-node-title { + min-width: 0; +} + +body[data-ui-version="v2"] .gn-er-node-title strong { + display: block; + overflow: hidden; + color: var(--gn-fg-1); + font-family: var(--gn-font-mono); + font-size: 13px; + font-weight: 750; + text-overflow: ellipsis; + white-space: nowrap; +} + +body[data-ui-version="v2"] .gn-er-node-badge { + display: inline-flex; + align-items: center; + height: 18px; + margin-bottom: 6px; + padding: 0 7px; + border-radius: 999px; + background: color-mix(in srgb, var(--gn-accent) 14%, var(--gn-bg-panel)); + color: var(--gn-accent); + font-size: 10px; + font-weight: 700; +} + +body[data-ui-version="v2"] .gn-er-node-open { + color: var(--gn-fg-4); +} + +body[data-ui-version="v2"] .gn-er-node-stats { + display: flex; + gap: 8px; + padding: 8px 12px 0; +} + +body[data-ui-version="v2"] .gn-er-node-stats span { + display: inline-flex; + align-items: center; + gap: 5px; + height: 22px; + padding: 0 8px; + border-radius: 999px; + background: var(--gn-bg-panel); + color: var(--gn-fg-4); + font-size: 11px; + font-weight: 600; +} + +body[data-ui-version="v2"] .gn-er-node-columns { + padding: 8px 0 6px; +} + +body[data-ui-version="v2"] .gn-er-node-column { + min-height: 28px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 0 12px; +} + +body[data-ui-version="v2"] .gn-er-node-column + .gn-er-node-column { + border-top: 0.5px solid var(--gn-br-1); +} + +body[data-ui-version="v2"] .gn-er-node-column.is-relation { + background: color-mix(in srgb, var(--gn-accent-soft) 38%, transparent); +} + +body[data-ui-version="v2"] .gn-er-node-column-name { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 6px; +} + +body[data-ui-version="v2"] .gn-er-node-column-name em { + display: inline-flex; + align-items: center; + height: 18px; + padding: 0 6px; + border-radius: 4px; + font-family: var(--gn-font-mono); + font-size: 10px; + font-style: normal; + font-weight: 800; +} + +body[data-ui-version="v2"] .gn-er-node-column-name em.is-pk { + background: var(--gn-warn-soft); + color: var(--gn-warn); +} + +body[data-ui-version="v2"] .gn-er-node-column-name em.is-fk { + background: color-mix(in srgb, var(--gn-info) 16%, var(--gn-bg-panel)); + color: var(--gn-info); +} + +body[data-ui-version="v2"] .gn-er-node-column-name code { + min-width: 0; + overflow: hidden; + color: var(--gn-fg-1); + font-family: var(--gn-font-mono); + font-size: 12px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +body[data-ui-version="v2"] .gn-er-node-column-type { + max-width: 112px; + overflow: hidden; + color: var(--gn-fg-5); + font-family: var(--gn-font-mono); + font-size: 10.5px; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +body[data-ui-version="v2"] .gn-er-node-footer { + padding: 8px 12px 12px; + color: var(--gn-fg-5); + font-size: 11px; +} + /* ─── AI Chat Panel hooks (project's class names) ──────── */ body[data-ui-version="v2"] .ai-chat-panel { background: var(--gn-bg-panel) !important; diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 585796f..9e3fead 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -2559,6 +2559,15 @@ "data_grid.metadata_view.fields_badge": "Felder", "data_grid.metadata_view.er_table_badge": "Tabelle", "data_grid.metadata_view.er_field_badge": "Feld", + "data_grid.metadata_view.er_current_badge": "Aktuelle Tabelle", + "data_grid.metadata_view.er_reference_badge": "Referenziert", + "data_grid.metadata_view.er_referenced_by_badge": "Referenziert von", + "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_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", "data_grid.metadata_view.field_count": "{{count}} Felder", "data_grid.metadata_view.column_name": "Name", "data_grid.metadata_view.column_type": "Typ", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 7f4924c..26abaa2 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -2569,6 +2569,15 @@ "data_grid.metadata_view.fields_badge": "Fields", "data_grid.metadata_view.er_table_badge": "Table", "data_grid.metadata_view.er_field_badge": "Field", + "data_grid.metadata_view.er_current_badge": "Current", + "data_grid.metadata_view.er_reference_badge": "References", + "data_grid.metadata_view.er_referenced_by_badge": "Referenced by", + "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_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", "data_grid.metadata_view.field_count": "{{count}} fields", "data_grid.metadata_view.column_name": "Name", "data_grid.metadata_view.column_type": "Type", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index 003eab9..467313d 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -2559,6 +2559,15 @@ "data_grid.metadata_view.fields_badge": "フィールド", "data_grid.metadata_view.er_table_badge": "テーブル", "data_grid.metadata_view.er_field_badge": "フィールド", + "data_grid.metadata_view.er_current_badge": "現在の表", + "data_grid.metadata_view.er_reference_badge": "参照先", + "data_grid.metadata_view.er_referenced_by_badge": "参照元", + "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_empty": "このテーブルでは外部キー関係が見つかりませんでした", + "data_grid.metadata_view.er_partial_warning": "一部の関係を読み込めなかったため、図が不完全な可能性があります", + "data_grid.metadata_view.er_open_table": "テーブルを開く", "data_grid.metadata_view.field_count": "{{count}} フィールド", "data_grid.metadata_view.column_name": "名前", "data_grid.metadata_view.column_type": "型", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index 9308b71..d6ddc5c 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -2559,6 +2559,15 @@ "data_grid.metadata_view.fields_badge": "Поля", "data_grid.metadata_view.er_table_badge": "Таблица", "data_grid.metadata_view.er_field_badge": "Поле", + "data_grid.metadata_view.er_current_badge": "Текущая таблица", + "data_grid.metadata_view.er_reference_badge": "Ссылается на", + "data_grid.metadata_view.er_referenced_by_badge": "На нее ссылаются", + "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_empty": "Для этой таблицы не найдено связей по внешним ключам", + "data_grid.metadata_view.er_partial_warning": "Часть связей не удалось загрузить. Диаграмма может быть неполной.", + "data_grid.metadata_view.er_open_table": "Открыть таблицу", "data_grid.metadata_view.field_count": "{{count}} полей", "data_grid.metadata_view.column_name": "Имя", "data_grid.metadata_view.column_type": "Тип", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index d5b1deb..13b46a0 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -2569,6 +2569,15 @@ "data_grid.metadata_view.fields_badge": "字段", "data_grid.metadata_view.er_table_badge": "表", "data_grid.metadata_view.er_field_badge": "字段", + "data_grid.metadata_view.er_current_badge": "当前表", + "data_grid.metadata_view.er_reference_badge": "引用", + "data_grid.metadata_view.er_referenced_by_badge": "被引用", + "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_empty": "当前表未发现外键关系", + "data_grid.metadata_view.er_partial_warning": "部分关系未能完整加载,图中结果可能不完整", + "data_grid.metadata_view.er_open_table": "打开表", "data_grid.metadata_view.field_count": "{{count}} 个字段", "data_grid.metadata_view.column_name": "名称", "data_grid.metadata_view.column_type": "类型", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 24e42c1..495baa1 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -2559,6 +2559,15 @@ "data_grid.metadata_view.fields_badge": "欄位", "data_grid.metadata_view.er_table_badge": "表", "data_grid.metadata_view.er_field_badge": "欄位", + "data_grid.metadata_view.er_current_badge": "目前表", + "data_grid.metadata_view.er_reference_badge": "引用", + "data_grid.metadata_view.er_referenced_by_badge": "被引用", + "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_empty": "目前表尚未發現外鍵關係", + "data_grid.metadata_view.er_partial_warning": "部分關係未能完整載入,圖中結果可能不完整", + "data_grid.metadata_view.er_open_table": "打開表", "data_grid.metadata_view.field_count": "{{count}} 個欄位", "data_grid.metadata_view.column_name": "名稱", "data_grid.metadata_view.column_type": "型別",