diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx
index 506d44a..cfd8859 100644
--- a/frontend/src/components/DataGrid.ddl.test.tsx
+++ b/frontend/src/components/DataGrid.ddl.test.tsx
@@ -35,6 +35,8 @@ const storeState = vi.hoisted(() => ({
showColumnType: false,
},
setQueryOptions: vi.fn(),
+ addTab: vi.fn(),
+ setActiveContext: vi.fn(),
tableColumnOrders: {},
enableColumnOrderMemory: false,
setTableColumnOrder: vi.fn(),
@@ -57,9 +59,14 @@ const backendApp = vi.hoisted(() => ({
ApplyChanges: vi.fn(),
DBGetColumns: vi.fn(),
DBGetIndexes: vi.fn(),
+ DBGetForeignKeys: vi.fn(),
DBShowCreateTable: vi.fn(),
}));
+const testRenderState = vi.hoisted(() => ({
+ latestColumns: [] as any[],
+}));
+
const messageApi = vi.hoisted(() => ({
error: vi.fn(),
info: vi.fn(),
@@ -115,6 +122,7 @@ vi.mock('@ant-design/icons', () => {
RightOutlined: Icon,
RobotOutlined: Icon,
SearchOutlined: Icon,
+ LinkOutlined: Icon,
TableOutlined: Icon,
DatabaseOutlined: Icon,
NodeIndexOutlined: Icon,
@@ -212,7 +220,10 @@ vi.mock('antd', () => {
);
return {
- Table: () =>
,
+ Table: ({ columns }: any) => {
+ testRenderState.latestColumns = Array.isArray(columns) ? columns : [];
+ return ;
+ },
message: messageApi,
Input,
Button,
@@ -458,7 +469,12 @@ describe('DataGrid DDL interactions', () => {
beforeEach(() => {
backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
+ backendApp.DBGetForeignKeys.mockResolvedValue({ success: true, data: [] });
backendApp.DBShowCreateTable.mockResolvedValue({ success: true, data: 'CREATE TABLE users' });
+ storeState.appearance.uiVersion = 'legacy';
+ storeState.addTab.mockReset();
+ storeState.setActiveContext.mockReset();
+ testRenderState.latestColumns = [];
vi.stubGlobal('document', {
addEventListener: vi.fn(),
@@ -500,6 +516,7 @@ describe('DataGrid DDL interactions', () => {
backendApp.ApplyChanges.mockReset();
backendApp.DBGetColumns.mockReset();
backendApp.DBGetIndexes.mockReset();
+ backendApp.DBGetForeignKeys.mockReset();
backendApp.DBShowCreateTable.mockReset();
vi.unstubAllGlobals();
});
@@ -548,6 +565,58 @@ describe('DataGrid DDL interactions', () => {
expect(renderer!.root.findAll((node) => node.props['data-modal-title'] === 'DDL - orders')).toHaveLength(0);
});
+ it.each(['legacy', 'v2'] as const)(
+ 'opens the referenced table when clicking a foreign-key column header in %s UI',
+ async (uiVersion) => {
+ storeState.appearance.uiVersion = uiVersion;
+ backendApp.DBGetForeignKeys.mockResolvedValueOnce({
+ success: true,
+ data: [{
+ columnName: 'customer_id',
+ refTableName: 'customers',
+ refColumnName: 'id',
+ constraintName: 'fk_orders_customer',
+ }],
+ });
+
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create(
+ ,
+ );
+ });
+ await waitForEffects();
+
+ const fkColumn = testRenderState.latestColumns.find((column) => column.key === 'customer_id');
+ expect(fkColumn).toBeTruthy();
+ const headerRenderer = create(<>{fkColumn.title}>);
+ const fkJump = headerRenderer.root.findByProps({ 'data-grid-fk-jump': 'true' });
+ await act(async () => {
+ fkJump.props.onClick({
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
+ });
+ });
+
+ expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'main' });
+ expect(storeState.addTab).toHaveBeenCalledWith({
+ id: 'conn-1-main-table-customers',
+ title: 'customers',
+ type: 'table',
+ connectionId: 'conn-1',
+ dbName: 'main',
+ tableName: 'customers',
+ });
+ },
+ );
+
it('switches the v2 footer field tab into the main fields view', async () => {
storeState.appearance.uiVersion = 'v2';
diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx
index 8a9a1b7..5e301bc 100644
--- a/frontend/src/components/DataGrid.layout.test.tsx
+++ b/frontend/src/components/DataGrid.layout.test.tsx
@@ -20,12 +20,15 @@ vi.mock('../store', () => ({
blur: 0,
showDataTableVerticalBorders: false,
dataTableDensity: 'comfortable',
+ uiVersion: 'v2',
},
queryOptions: {
showColumnComment: false,
showColumnType: false,
},
setQueryOptions: vi.fn(),
+ addTab: vi.fn(),
+ setActiveContext: vi.fn(),
tableColumnOrders: {},
enableColumnOrderMemory: false,
setTableColumnOrder: vi.fn(),
@@ -49,6 +52,7 @@ vi.mock('../../wailsjs/go/app/App', () => ({
ApplyChanges: vi.fn(),
DBGetColumns: vi.fn(),
DBGetIndexes: vi.fn(),
+ DBGetForeignKeys: vi.fn(),
DBShowCreateTable: vi.fn(),
}));
diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx
index b7ab98c..58fae5f 100644
--- a/frontend/src/components/DataGrid.tsx
+++ b/frontend/src/components/DataGrid.tsx
@@ -4,7 +4,7 @@ import { createPortal } from 'react-dom';
import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover, DatePicker, TimePicker, AutoComplete } from 'antd';
import dayjs from 'dayjs';
import type { SortOrder, ColumnType } from 'antd/es/table/interface';
-import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined, RobotOutlined, SearchOutlined } from '@ant-design/icons';
+import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined, RobotOutlined, SearchOutlined, LinkOutlined } from '@ant-design/icons';
import Editor from './MonacoEditor';
import {
DndContext,
@@ -23,10 +23,10 @@ import {
arrayMove
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
-import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, PreviewChanges, DBGetColumns, DBGetIndexes, DBShowCreateTable } from '../../wailsjs/go/app/App';
+import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, PreviewChanges, DBGetColumns, DBGetIndexes, DBGetForeignKeys, DBShowCreateTable } from '../../wailsjs/go/app/App';
import ImportPreviewModal from './ImportPreviewModal';
import { useStore } from '../store';
-import type { ColumnDefinition, IndexDefinition } from '../types';
+import type { ColumnDefinition, ForeignKeyDefinition, IndexDefinition } from '../types';
import { v4 as generateUuid } from 'uuid';
import 'react-resizable/css/styles.css';
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, hasExplicitSort, quoteIdentPart, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
@@ -1040,6 +1040,13 @@ type ColumnMeta = {
comment: string;
};
+type ForeignKeyTarget = {
+ columnName: string;
+ refTableName: string;
+ refColumnName: string;
+ constraintName: string;
+};
+
const EXACT_GRID_FILTER_OPERATOR = '=';
const CONTAINS_GRID_FILTER_OPERATOR = 'CONTAINS';
const STRING_LIKE_GRID_FILTER_TYPES = new Set([
@@ -1212,6 +1219,8 @@ const DataGrid: React.FC = ({
scrollSnapshot, onScrollSnapshotChange
}) => {
const connections = useStore(state => state.connections);
+ const addTab = useStore(state => state.addTab);
+ const setActiveContext = useStore(state => state.setActiveContext);
const addSqlLog = useStore(state => state.addSqlLog);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
@@ -1681,9 +1690,12 @@ const DataGrid: React.FC = ({
const [sortInfo, setSortInfo] = useState>([]);
const [columnWidths, setColumnWidths] = useState>({});
const [columnMetaMap, setColumnMetaMap] = useState>({});
+ const [foreignKeyMap, setForeignKeyMap] = useState>({});
const [uniqueKeyGroups, setUniqueKeyGroups] = useState([]);
const columnMetaCacheRef = useRef>>({});
const columnMetaSeqRef = useRef(0);
+ const foreignKeyCacheRef = useRef>>({});
+ const foreignKeySeqRef = useRef(0);
const uniqueKeyGroupsCacheRef = useRef>({});
const uniqueKeyGroupsSeqRef = useRef(0);
@@ -1700,13 +1712,16 @@ const DataGrid: React.FC = ({
const normalizedDbName = String(dbName || '').trim();
if (!connectionId || !normalizedTableName) {
setColumnMetaMap({});
+ setForeignKeyMap({});
setUniqueKeyGroups([]);
return;
}
const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`;
setColumnMetaMap(columnMetaCacheRef.current[cacheKey] || {});
+ foreignKeySeqRef.current += 1;
+ setForeignKeyMap(exportScope === 'table' ? (foreignKeyCacheRef.current[cacheKey] || {}) : {});
setUniqueKeyGroups(uniqueKeyGroupsCacheRef.current[cacheKey] || []);
- }, [connectionId, dbName, tableName]);
+ }, [connectionId, dbName, tableName, exportScope]);
useEffect(() => {
const normalizedTableName = String(tableName || '').trim();
@@ -1756,6 +1771,59 @@ const DataGrid: React.FC = ({
});
}, [connections, connectionId, dbName, tableName]);
+ useEffect(() => {
+ const normalizedTableName = String(tableName || '').trim();
+ const normalizedDbName = String(dbName || '').trim();
+ if (!connectionId || !normalizedTableName || exportScope !== 'table') return;
+
+ const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`;
+ if (foreignKeyCacheRef.current[cacheKey]) return;
+
+ const conn = connections.find(c => c.id === connectionId);
+ if (!conn) {
+ setForeignKeyMap({});
+ return;
+ }
+
+ const config = {
+ ...conn.config,
+ port: Number(conn.config.port),
+ password: conn.config.password || "",
+ database: conn.config.database || "",
+ useSSH: conn.config.useSSH || false,
+ ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
+ };
+
+ const seq = ++foreignKeySeqRef.current;
+ DBGetForeignKeys(buildRpcConnectionConfig(config) as any, normalizedDbName, normalizedTableName)
+ .then((res) => {
+ if (seq !== foreignKeySeqRef.current) return;
+ if (!res.success || !Array.isArray(res.data)) {
+ setForeignKeyMap({});
+ return;
+ }
+ const nextMap: Record = {};
+ (res.data as ForeignKeyDefinition[]).forEach((fk: any) => {
+ const columnName = String(fk?.columnName ?? fk?.ColumnName ?? '').trim();
+ const refTableName = String(fk?.refTableName ?? fk?.RefTableName ?? '').trim();
+ if (!columnName || !refTableName || refTableName === '-') return;
+ const target: ForeignKeyTarget = {
+ columnName,
+ refTableName,
+ refColumnName: String(fk?.refColumnName ?? fk?.RefColumnName ?? '').trim(),
+ constraintName: String(fk?.constraintName ?? fk?.ConstraintName ?? fk?.name ?? fk?.Name ?? '').trim(),
+ };
+ nextMap[columnName] = target;
+ });
+ foreignKeyCacheRef.current[cacheKey] = nextMap;
+ setForeignKeyMap(nextMap);
+ })
+ .catch(() => {
+ if (seq !== foreignKeySeqRef.current) return;
+ setForeignKeyMap({});
+ });
+ }, [connections, connectionId, dbName, tableName, exportScope]);
+
useEffect(() => {
const normalizedTableName = String(tableName || '').trim();
const normalizedDbName = String(dbName || '').trim();
@@ -1817,6 +1885,16 @@ const DataGrid: React.FC = ({
return next;
}, [columnMetaMapByLowerName]);
+ const foreignKeyMapByLowerName = useMemo(() => {
+ const next: Record = {};
+ Object.entries(foreignKeyMap).forEach(([name, target]) => {
+ const lowerName = String(name || '').toLowerCase();
+ if (!lowerName || next[lowerName]) return;
+ next[lowerName] = target;
+ });
+ return next;
+ }, [foreignKeyMap]);
+
const getColumnFilterType = useCallback((columnName: string): string => {
const normalizedName = String(columnName || '').trim();
if (!normalizedName) return '';
@@ -1863,16 +1941,72 @@ const DataGrid: React.FC = ({
[columnMetaMap, columnMetaMapByLowerName]
);
+ const openForeignKeyTarget = useCallback((target: ForeignKeyTarget) => {
+ const refTableName = String(target?.refTableName || '').trim();
+ if (!connectionId || !refTableName || refTableName === '-') return;
+ const targetDbName = String(dbName || '').trim();
+ const tabId = `${connectionId}-${targetDbName}-table-${refTableName}`;
+ setActiveContext({ connectionId, dbName: targetDbName });
+ addTab({
+ id: tabId,
+ title: refTableName,
+ type: 'table',
+ connectionId,
+ dbName: targetDbName,
+ tableName: refTableName,
+ });
+ }, [addTab, connectionId, dbName, setActiveContext]);
+
const renderColumnTitle = useCallback((name: string): React.ReactNode => {
const normalizedName = String(name || '');
const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()];
+ const foreignKeyTarget = foreignKeyMap[normalizedName] || foreignKeyMapByLowerName[normalizedName.toLowerCase()];
const hoverLines: string[] = [];
if (meta?.type) hoverLines.push(`类型:${meta.type}`);
if (meta?.comment) hoverLines.push(`备注:${meta.comment}`);
+ if (foreignKeyTarget?.refTableName) {
+ const refColumnText = foreignKeyTarget.refColumnName ? `.${foreignKeyTarget.refColumnName}` : '';
+ hoverLines.push(`外键:${foreignKeyTarget.refTableName}${refColumnText}`);
+ }
+
+ const fieldLabel = foreignKeyTarget?.refTableName ? (
+
+ ) : (
+ {normalizedName}
+ );
const titleNode = (
- {normalizedName}
+ {fieldLabel}
{showColumnType && meta?.type && (
= ({
{titleNode}
);
- }, [columnMetaHintColor, columnMetaTooltipColor, columnMetaMap, columnMetaMapByLowerName, showColumnComment, showColumnType, densityParams]);
+ }, [columnMetaHintColor, columnMetaTooltipColor, columnMetaMap, columnMetaMapByLowerName, foreignKeyMap, foreignKeyMapByLowerName, showColumnComment, showColumnType, densityParams, openForeignKeyTarget]);
const closeCellEditor = useCallback(() => {
setCellEditorOpen(false);
@@ -4169,6 +4303,8 @@ const DataGrid: React.FC = ({
onResizeAutoFit: handleResizeAutoFit(key),
onClickCapture: (event: React.MouseEvent) => {
if (!onSort) return;
+ const eventTarget = event.target as HTMLElement | null;
+ if (eventTarget?.closest?.('[data-grid-fk-jump="true"]')) return;
const headerCell = event.currentTarget as HTMLElement;
const upArrow = headerCell.querySelector('.ant-table-column-sorter-up') as HTMLElement | null;
const downArrow = headerCell.querySelector('.ant-table-column-sorter-down') as HTMLElement | null;