feat(data-grid): 支持无主键表安全编辑

- 定位策略:新增主键、唯一索引和 Oracle ROWID 三类安全行定位能力
- 查询编辑器:简单单表 SELECT 自动补充隐藏定位列,复杂结果保持只读
- 表预览:无主键表可通过唯一索引或 Oracle ROWID 安全编辑
- 提交流程:移除无主键整行 WHERE fallback,隐藏定位列不参与展示和写入
- 后端保护:Oracle、MySQL、PostgreSQL 更新删除必须恰好影响 1 行
- 测试覆盖:补充 QueryEditor、DataViewer、DataGrid 和 ApplyChanges 相关用例
Refs #419
This commit is contained in:
Syngnat
2026-04-29 12:33:35 +08:00
parent 05a913ccb2
commit b1ef52f62e
18 changed files with 1823 additions and 228 deletions

View File

@@ -2,7 +2,8 @@ import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import DataGrid from './DataGrid';
import DataGrid, { buildDataGridCommitChangeSet, GONAVI_ROW_KEY } from './DataGrid';
import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
const storeState = vi.hoisted(() => ({
connections: [
@@ -216,6 +217,115 @@ const waitForEffects = async () => {
});
};
const normalizeValue = (_columnName: string, value: any) => value;
const rowKeyToString = (key: any) => String(key);
const commitColumnGuard = (columnName: string) => (
columnName !== GONAVI_ROW_KEY && columnName !== ORACLE_ROWID_LOCATOR_COLUMN
);
describe('DataGrid commit change set', () => {
it('uses unique locator values instead of falling back to the whole row', () => {
const result = buildDataGridCommitChangeSet({
addedRows: [],
modifiedRows: {
'row-1': { [GONAVI_ROW_KEY]: 'row-1', EMAIL: 'a@example.com', NAME: 'new-name', AGE: 42 },
},
deletedRowKeys: new Set(),
data: [{ [GONAVI_ROW_KEY]: 'row-1', EMAIL: 'a@example.com', NAME: 'old-name', AGE: 42 }],
editLocator: {
strategy: 'unique-key',
columns: ['EMAIL'],
valueColumns: ['EMAIL'],
readOnly: false,
},
visibleColumnNames: ['EMAIL', 'NAME', 'AGE'],
rowKeyToString,
normalizeCommitCellValue: normalizeValue,
shouldCommitColumn: commitColumnGuard,
});
expect(result).toEqual({
ok: true,
changes: {
inserts: [],
updates: [{ keys: { EMAIL: 'a@example.com' }, values: { NAME: 'new-name' } }],
deletes: [],
},
});
});
it('uses hidden Oracle ROWID only as locator and excludes it from update values', () => {
const result = buildDataGridCommitChangeSet({
addedRows: [],
modifiedRows: {
'row-1': { [GONAVI_ROW_KEY]: 'row-1', NAME: 'new-name', [ORACLE_ROWID_LOCATOR_COLUMN]: 'BBBB' },
},
deletedRowKeys: new Set(),
data: [{ [GONAVI_ROW_KEY]: 'row-1', NAME: 'old-name', [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAAA' }],
editLocator: {
strategy: 'oracle-rowid',
columns: ['ROWID'],
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
readOnly: false,
},
visibleColumnNames: ['NAME'],
rowKeyToString,
normalizeCommitCellValue: normalizeValue,
shouldCommitColumn: commitColumnGuard,
});
expect(result).toEqual({
ok: true,
changes: {
inserts: [],
updates: [{ keys: { ROWID: 'AAAA' }, values: { NAME: 'new-name' } }],
deletes: [],
},
});
});
it('fails closed when no safe locator is available', () => {
const result = buildDataGridCommitChangeSet({
addedRows: [],
modifiedRows: {
'row-1': { [GONAVI_ROW_KEY]: 'row-1', NAME: 'new-name' },
},
deletedRowKeys: new Set(),
data: [{ [GONAVI_ROW_KEY]: 'row-1', NAME: 'old-name' }],
editLocator: undefined,
visibleColumnNames: ['NAME'],
rowKeyToString,
normalizeCommitCellValue: normalizeValue,
shouldCommitColumn: commitColumnGuard,
});
expect(result).toEqual({ ok: false, error: '当前结果没有可用的安全行定位方式,无法提交修改。' });
});
it('rejects delete rows when unique locator value is null', () => {
const result = buildDataGridCommitChangeSet({
addedRows: [],
modifiedRows: {},
deletedRowKeys: new Set(['row-1']),
data: [{ [GONAVI_ROW_KEY]: 'row-1', EMAIL: null, NAME: 'old-name' }],
editLocator: {
strategy: 'unique-key',
columns: ['EMAIL'],
valueColumns: ['EMAIL'],
readOnly: false,
},
visibleColumnNames: ['EMAIL', 'NAME'],
rowKeyToString,
normalizeCommitCellValue: normalizeValue,
shouldCommitColumn: commitColumnGuard,
});
expect(result).toEqual({ ok: false, error: '定位列 EMAIL 的值为空,无法安全提交修改。' });
});
});
describe('DataGrid DDL interactions', () => {
beforeEach(() => {
backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] });

View File

@@ -159,6 +159,7 @@ describe('DataGrid layout', () => {
columnNames={['id', 'name']}
loading={false}
tableName="users"
pkColumns={['id']}
/>,
);

View File

@@ -79,6 +79,12 @@ import {
type DataGridFindMatch,
type DataGridFindNavigationDirection,
} from '../utils/dataGridFind';
import {
filterHiddenLocatorColumns,
isHiddenLocatorColumn,
resolveRowLocatorValues,
type EditRowLocator,
} from '../utils/rowLocator';
// --- Error Boundary ---
interface DataGridErrorBoundaryState {
@@ -916,6 +922,7 @@ interface DataGridProps {
dbName?: string;
connectionId?: string;
pkColumns?: string[];
editLocator?: EditRowLocator;
readOnly?: boolean;
onReload?: () => void;
onSort?: (field: string, order: string) => void;
@@ -960,12 +967,110 @@ type ColumnMeta = {
comment: string;
};
type NormalizeCommitCellValue = (columnName: string, value: any, mode: 'insert' | 'update') => any;
type DataGridCommitChangeSet = {
inserts: any[];
updates: any[];
deletes: any[];
};
export const buildDataGridCommitChangeSet = ({
addedRows,
modifiedRows,
deletedRowKeys,
data,
editLocator,
visibleColumnNames,
rowKeyToString,
normalizeCommitCellValue,
shouldCommitColumn,
}: {
addedRows: any[];
modifiedRows: Record<string, any>;
deletedRowKeys: Set<string>;
data: any[];
editLocator?: EditRowLocator;
visibleColumnNames: string[];
rowKeyToString: (key: any) => string;
normalizeCommitCellValue: NormalizeCommitCellValue;
shouldCommitColumn: (columnName: string) => boolean;
}): { ok: true; changes: DataGridCommitChangeSet } | { ok: false; error: string } => {
if (!editLocator || editLocator.readOnly || editLocator.strategy === 'none') {
return { ok: false, error: editLocator?.reason || '当前结果没有可用的安全行定位方式,无法提交修改。' };
}
const normalizeValues = (values: Record<string, any>, mode: 'insert' | 'update') => {
const normalizedValues: Record<string, any> = {};
Object.entries(values).forEach(([col, val]) => {
if (!shouldCommitColumn(col)) return;
const normalizedVal = normalizeCommitCellValue(col, val, mode);
if (normalizedVal !== undefined) {
normalizedValues[col] = normalizedVal;
}
});
return normalizedValues;
};
const originalRowsByKey = new Map<string, any>();
data.forEach((row) => {
const key = row?.[GONAVI_ROW_KEY];
if (key === undefined || key === null) return;
originalRowsByKey.set(rowKeyToString(key), row);
});
const inserts: any[] = [];
const updates: any[] = [];
const deletes: any[] = [];
addedRows.forEach(row => {
const key = row?.[GONAVI_ROW_KEY];
if (key !== undefined && key !== null && deletedRowKeys.has(rowKeyToString(key))) return;
inserts.push(normalizeValues(row, 'insert'));
});
for (const keyStr of deletedRowKeys) {
const originalRow = originalRowsByKey.get(keyStr);
if (!originalRow) continue;
const locatorValues = resolveRowLocatorValues(editLocator, originalRow);
if (!locatorValues.ok) return { ok: false, error: locatorValues.error };
deletes.push(locatorValues.values);
}
for (const [keyStr, newRow] of Object.entries(modifiedRows)) {
if (deletedRowKeys.has(keyStr)) continue;
const originalRow = originalRowsByKey.get(keyStr);
if (!originalRow) continue;
const locatorValues = resolveRowLocatorValues(editLocator, originalRow);
if (!locatorValues.ok) return { ok: false, error: locatorValues.error };
const hasRowKey = Object.prototype.hasOwnProperty.call(newRow as any, GONAVI_ROW_KEY);
let values: Record<string, any> = {};
if (!hasRowKey) {
values = { ...(newRow as any) };
} else {
visibleColumnNames.forEach((col) => {
const nextVal = (newRow as any)?.[col];
const prevVal = (originalRow as any)?.[col];
if (!isCellValueEqualForDiff(prevVal, nextVal)) values[col] = nextVal;
});
}
const normalizedValues = normalizeValues(values, 'update');
if (Object.keys(normalizedValues).length === 0) continue;
updates.push({ keys: locatorValues.values, values: normalizedValues });
}
return { ok: true, changes: { inserts, updates, deletes } };
};
// P2 性能优化:提取内联 style 对象为模块级常量,避免每次 render 创建新对象
const CELL_ELLIPSIS_STYLE: React.CSSProperties = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
const VIRTUAL_CELL_WRAPPER_STYLE: React.CSSProperties = { margin: -8, padding: '8px 8px 8px 8px' };
const DataGrid: React.FC<DataGridProps> = ({
data, columnNames, loading, tableName, exportScope = 'table', resultSql, dbName, connectionId, pkColumns = [], readOnly = false,
data, columnNames, loading, tableName, exportScope = 'table', resultSql, dbName, connectionId, pkColumns = [], editLocator, readOnly = false,
onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition,
onApplyQuickWhereCondition,
scrollSnapshot, onScrollSnapshotChange
@@ -999,7 +1104,25 @@ const DataGrid: React.FC<DataGridProps> = ({
darkMode,
visible: showDataTableVerticalBorders,
});
const canModifyData = !readOnly && !!tableName;
const effectiveEditLocator = useMemo<EditRowLocator | undefined>(() => {
if (editLocator) return editLocator;
if (pkColumns.length === 0) return undefined;
return {
strategy: 'primary-key',
columns: pkColumns,
valueColumns: pkColumns,
readOnly: false,
};
}, [editLocator, pkColumns]);
const visibleColumnNames = useMemo(
() => filterHiddenLocatorColumns(columnNames, effectiveEditLocator),
[columnNames, effectiveEditLocator]
);
const shouldCommitColumn = useCallback((columnName: string): boolean => {
const normalized = String(columnName || '').trim();
return normalized !== GONAVI_ROW_KEY && !isHiddenLocatorColumn(normalized, effectiveEditLocator);
}, [effectiveEditLocator]);
const canModifyData = !readOnly && !!tableName && !!effectiveEditLocator && !effectiveEditLocator.readOnly && effectiveEditLocator.strategy !== 'none';
const showColumnComment = queryOptions?.showColumnComment ?? true;
const showColumnType = queryOptions?.showColumnType ?? true;
@@ -1053,7 +1176,7 @@ const DataGrid: React.FC<DataGridProps> = ({
// Sync display order from incoming prop and store memory
useEffect(() => {
let nextOrder = [...columnNames];
let nextOrder = [...visibleColumnNames];
if (enableColumnOrderMemory && connectionId && dbName && tableName) {
const storedOrder = tableColumnOrders[`${connectionId}-${dbName}-${tableName}`];
if (Array.isArray(storedOrder) && storedOrder.length > 0) {
@@ -1066,7 +1189,7 @@ const DataGrid: React.FC<DataGridProps> = ({
}
}
setAllOrderedColumnNames(nextOrder);
}, [columnNames, tableColumnOrders, enableColumnOrderMemory, connectionId, dbName, tableName]);
}, [visibleColumnNames, tableColumnOrders, enableColumnOrderMemory, connectionId, dbName, tableName]);
// Compute final display columns
useEffect(() => {
@@ -1378,7 +1501,13 @@ const DataGrid: React.FC<DataGridProps> = ({
const exportData = async (rows: any[], format: string) => {
const hide = message.loading(`正在导出 ${rows.length} 条数据...`, 0);
try {
const cleanRows = rows.map(({ [GONAVI_ROW_KEY]: _rowKey, ...rest }) => rest);
const cleanRows = rows.map((row) => {
const next: Record<string, any> = {};
displayColumnNames.forEach((columnName) => {
next[columnName] = row?.[columnName];
});
return next;
});
// Pass tableName (or 'export') as default filename
const res = await ExportData(cleanRows, displayColumnNames, tableName || 'export', format);
if (res.success) {
@@ -1538,10 +1667,10 @@ const DataGrid: React.FC<DataGridProps> = ({
return metaColumns;
}
if (exportScope === 'table') {
return columnNames.filter((columnName) => columnName !== GONAVI_ROW_KEY);
return visibleColumnNames.filter((columnName) => columnName !== GONAVI_ROW_KEY);
}
return [];
}, [columnMetaMap, exportScope, columnNames]);
}, [columnMetaMap, exportScope, visibleColumnNames]);
const normalizeCommitCellValue = useCallback(
(columnName: string, value: any, mode: 'insert' | 'update') => {
@@ -3298,19 +3427,25 @@ const DataGrid: React.FC<DataGridProps> = ({
const jsonViewText = useMemo(() => {
if (viewMode !== 'json') return '';
const cleanRows = mergedDisplayData.map((row) => {
const { [GONAVI_ROW_KEY]: _rowKey, ...rest } = row || {};
return normalizeValueForJsonView(rest);
const next: Record<string, any> = {};
visibleColumnNames.forEach((columnName) => {
next[columnName] = row?.[columnName];
});
return normalizeValueForJsonView(next);
});
return JSON.stringify(cleanRows, null, 2);
}, [viewMode, mergedDisplayData]);
}, [viewMode, mergedDisplayData, visibleColumnNames]);
const textViewRows = useMemo(() => {
if (viewMode !== 'text') return [];
return mergedDisplayData.map((row) => {
const { [GONAVI_ROW_KEY]: _rowKey, ...rest } = row || {};
return rest;
const next: Record<string, any> = {};
visibleColumnNames.forEach((columnName) => {
next[columnName] = row?.[columnName];
});
return next;
});
}, [viewMode, mergedDisplayData]);
}, [viewMode, mergedDisplayData, visibleColumnNames]);
const currentTextRow = useMemo(() => {
if (viewMode !== 'text') return null;
@@ -3363,7 +3498,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const formMap: Record<string, any> = {};
const nullCols = new Set<string>();
columnNames.forEach((col) => {
visibleColumnNames.forEach((col) => {
const baseVal = (baseRow as any)?.[col];
const displayVal = (displayRow as any)?.[col];
baseRawMap[col] = baseVal;
@@ -3511,7 +3646,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const keyStr = rowKeyStr(rowKey);
const normalizedNext: Record<string, any> = {};
let hasAnyVisibleChange = false;
columnNames.forEach((col) => {
visibleColumnNames.forEach((col) => {
const currentVal = (currentRow as any)?.[col];
const editedVal = Object.prototype.hasOwnProperty.call(nextItem, col) ? (nextItem as any)[col] : currentVal;
if (!isJsonViewValueEqual(currentVal, editedVal)) hasAnyVisibleChange = true;
@@ -3530,7 +3665,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const originalRow = originalMap.get(keyStr);
if (!originalRow) continue;
const patch: Record<string, any> = {};
columnNames.forEach((col) => {
visibleColumnNames.forEach((col) => {
const prevVal = (originalRow as any)?.[col];
const nextVal = normalizedNext[col];
if (!isCellValueEqualForDiff(prevVal, nextVal)) patch[col] = nextVal;
@@ -3595,7 +3730,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const baseRawMap = rowEditorBaseRawRef.current || {};
const patch: Record<string, any> = {};
columnNames.forEach((col) => {
visibleColumnNames.forEach((col) => {
let nextVal = values[col];
// 日期时间类型: 将 dayjs 对象转回格式化字符串
if (nextVal && dayjs.isDayjs(nextVal)) {
@@ -3615,7 +3750,7 @@ const DataGrid: React.FC<DataGridProps> = ({
});
closeRowEditor();
}, [rowEditorRowKey, rowEditorForm, addedRows, columnNames, rowKeyStr, closeRowEditor]);
}, [rowEditorRowKey, rowEditorForm, addedRows, visibleColumnNames, rowKeyStr, closeRowEditor]);
const enableVirtual = viewMode === 'table';
@@ -3761,7 +3896,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const handleAddRow = () => {
const newKey = `new-${Date.now()}`;
const newRow: any = { [GONAVI_ROW_KEY]: newKey };
columnNames.forEach(col => newRow[col] = '');
visibleColumnNames.forEach(col => newRow[col] = '');
pendingScrollToBottomRef.current = true;
setAddedRows(prev => [...prev, newRow]);
};
@@ -3775,7 +3910,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const copiedRows = buildCopiedRowsForPaste({
rows: mergedDisplayData as Array<Record<string, any>>,
selectedRowKeys,
columnNames,
columnNames: visibleColumnNames,
rowKeyField: GONAVI_ROW_KEY,
rowKeyToString: rowKeyStr,
});
@@ -3786,7 +3921,7 @@ const DataGrid: React.FC<DataGridProps> = ({
setCopiedRowsForPaste(copiedRows);
void message.success(`已复制 ${copiedRows.length} 行,可粘贴为新增行`);
}, [selectedRowKeys, mergedDisplayData, columnNames, rowKeyStr]);
}, [selectedRowKeys, mergedDisplayData, visibleColumnNames, rowKeyStr]);
const handlePasteCopiedRowsAsNew = useCallback(() => {
if (copiedRowsForPaste.length === 0) {
@@ -3796,7 +3931,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const nextRows = buildPastedRowsFromCopiedRows({
rows: copiedRowsForPaste,
columnNames,
columnNames: visibleColumnNames,
rowKeyField: GONAVI_ROW_KEY,
createRowKey: (index) => {
pastedRowSequenceRef.current += 1;
@@ -3812,7 +3947,7 @@ const DataGrid: React.FC<DataGridProps> = ({
setAddedRows(prev => [...prev, ...nextRows]);
setSelectedRowKeys(nextRows.map(row => row[GONAVI_ROW_KEY]));
void message.success(`已粘贴 ${nextRows.length} 行为新增行,请检查后提交事务`);
}, [copiedRowsForPaste, columnNames]);
}, [copiedRowsForPaste, visibleColumnNames]);
const handleDeleteSelected = () => {
setDeletedRowKeys(prev => {
@@ -3827,66 +3962,23 @@ const DataGrid: React.FC<DataGridProps> = ({
if (!connectionId || !tableName) return;
const conn = connections.find(c => c.id === connectionId);
if (!conn) return;
const inserts: any[] = [];
const updates: any[] = [];
const deletes: any[] = [];
addedRows.forEach(row => {
const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = row;
const normalizedValues: Record<string, any> = {};
Object.entries(vals).forEach(([col, val]) => {
const normalizedVal = normalizeCommitCellValue(col, val, 'insert');
if (normalizedVal !== undefined) {
normalizedValues[col] = normalizedVal;
}
});
inserts.push(normalizedValues);
});
deletedRowKeys.forEach(keyStr => {
// Find original data
const originalRow = data.find(d => rowKeyStr(d?.[GONAVI_ROW_KEY]) === keyStr) || addedRows.find(d => rowKeyStr(d?.[GONAVI_ROW_KEY]) === keyStr);
if (originalRow) {
const pkData: any = {};
if (pkColumns.length > 0) pkColumns.forEach(k => pkData[k] = originalRow[k]);
else { const { [GONAVI_ROW_KEY]: _rowKey, ...rest } = originalRow; Object.assign(pkData, rest); }
deletes.push(pkData);
}
});
Object.entries(modifiedRows).forEach(([keyStr, newRow]) => {
if (deletedRowKeys.has(keyStr)) return;
const originalRow = data.find(d => rowKeyStr(d?.[GONAVI_ROW_KEY]) === keyStr);
if (!originalRow) return; // Should not happen for modified rows unless deleted
const pkData: any = {};
if (pkColumns.length > 0) pkColumns.forEach(k => pkData[k] = originalRow[k]);
else { const { [GONAVI_ROW_KEY]: _rowKey, ...rest } = originalRow; Object.assign(pkData, rest); }
const hasRowKey = Object.prototype.hasOwnProperty.call(newRow as any, GONAVI_ROW_KEY);
let values: any = {};
if (!hasRowKey) {
values = { ...(newRow as any) };
} else {
columnNames.forEach((col) => {
const nextVal = (newRow as any)?.[col];
const prevVal = (originalRow as any)?.[col];
if (!isCellValueEqualForDiff(prevVal, nextVal)) values[col] = nextVal;
});
}
const normalizedValues: Record<string, any> = {};
Object.entries(values).forEach(([col, val]) => {
const normalizedVal = normalizeCommitCellValue(col, val, 'update');
if (normalizedVal !== undefined) {
normalizedValues[col] = normalizedVal;
}
});
if (Object.keys(normalizedValues).length === 0) return;
updates.push({ keys: pkData, values: normalizedValues });
const changeSetResult = buildDataGridCommitChangeSet({
addedRows,
modifiedRows,
deletedRowKeys,
data,
editLocator: effectiveEditLocator,
visibleColumnNames,
rowKeyToString: rowKeyStr,
normalizeCommitCellValue,
shouldCommitColumn,
});
if (!changeSetResult.ok) {
void message.error(changeSetResult.error);
return;
}
const { inserts, updates, deletes } = changeSetResult.changes;
if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) {
void message.info("没有可提交的变更");
return;
@@ -3902,7 +3994,7 @@ const DataGrid: React.FC<DataGridProps> = ({
};
const startTime = Date.now();
const res = await ApplyChanges(buildRpcConnectionConfig(config) as any, dbName || '', tableName, { inserts, updates, deletes } as any);
const res = await ApplyChanges(buildRpcConnectionConfig(config) as any, dbName || '', tableName, { inserts, updates, deletes, locatorStrategy: effectiveEditLocator?.strategy } as any);
const duration = Date.now() - startTime;
// Construct a pseudo-SQL representation for the log
@@ -4051,7 +4143,7 @@ const DataGrid: React.FC<DataGridProps> = ({
return null;
}
const records = getTargets(record);
const orderedCols = columnNames.filter(c => c !== GONAVI_ROW_KEY);
const orderedCols = visibleColumnNames.filter(c => c !== GONAVI_ROW_KEY);
if (mode === 'insert') {
return records.map((row: any) => buildCopyInsertSQL({
dbType,
@@ -4100,7 +4192,7 @@ const DataGrid: React.FC<DataGridProps> = ({
}, [
supportsCopyInsert,
getTargets,
columnNames,
visibleColumnNames,
dbType,
tableName,
columnTypeMapByLowerName,
@@ -4130,16 +4222,18 @@ const DataGrid: React.FC<DataGridProps> = ({
const handleCopyJson = useCallback((record: any) => {
const records = getTargets(record);
const cleanRecords = records.map((r: any) => {
const { [GONAVI_ROW_KEY]: _rowKey, ...rest } = r;
return rest;
const next: Record<string, any> = {};
visibleColumnNames.forEach((columnName) => {
next[columnName] = r?.[columnName];
});
return next;
});
copyToClipboard(JSON.stringify(cleanRecords, null, 2));
}, [getTargets, copyToClipboard]);
}, [getTargets, visibleColumnNames, copyToClipboard]);
const handleCopyCsv = useCallback((record: any) => {
const records = getTargets(record);
// 使用 columnNames 保持表定义的字段顺序
const orderedCols = columnNames.filter(c => c !== GONAVI_ROW_KEY);
const orderedCols = visibleColumnNames.filter(c => c !== GONAVI_ROW_KEY);
const header = orderedCols.map(c => `"${c}"`).join(',');
const lines = records.map((r: any) => {
const values = orderedCols.map(c => {
@@ -4152,7 +4246,7 @@ const DataGrid: React.FC<DataGridProps> = ({
return values.join(',');
});
copyToClipboard([header, ...lines].join('\n'));
}, [getTargets, columnNames, copyToClipboard]);
}, [getTargets, visibleColumnNames, copyToClipboard]);
const buildConnConfig = useCallback(() => {
if (!connectionId) return null;

View File

@@ -0,0 +1,199 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TabData } from '../types';
import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
import DataViewer from './DataViewer';
const storeState = vi.hoisted(() => ({
connections: [
{
id: 'conn-1',
name: 'oracle',
config: {
type: 'oracle',
host: '127.0.0.1',
port: 1521,
user: 'scott',
password: '',
database: 'ORCLPDB1',
},
},
],
addSqlLog: vi.fn(),
}));
const backendApp = vi.hoisted(() => ({
DBQuery: vi.fn(),
DBGetColumns: vi.fn(),
DBGetIndexes: vi.fn(),
}));
const messageApi = vi.hoisted(() => ({
error: vi.fn(),
warning: vi.fn(),
}));
const dataGridState = vi.hoisted(() => ({
latestProps: null as any,
}));
vi.mock('../store', () => {
const useStore = Object.assign(
(selector: (state: typeof storeState) => any) => selector(storeState),
{ getState: () => storeState },
);
return { useStore };
});
vi.mock('../../wailsjs/go/app/App', () => backendApp);
vi.mock('antd', () => ({
message: messageApi,
}));
vi.mock('./DataGrid', () => ({
default: (props: any) => {
dataGridState.latestProps = props;
return <div data-grid="true" />;
},
GONAVI_ROW_KEY: '__gonavi_row_key__',
}));
const createTab = (overrides: Partial<TabData> = {}): TabData => ({
id: 'tab-1',
title: 'EDC_LOG',
type: 'table',
connectionId: 'conn-1',
dbName: 'MYCIMLED',
tableName: 'EDC_LOG',
...overrides,
});
const flushPromises = async () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
};
describe('DataViewer safe editing locator', () => {
const renderAndReload = async (tab: TabData = createTab()) => {
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<DataViewer tab={tab} />);
});
await act(async () => {
await dataGridState.latestProps.onReload();
});
await flushPromises();
return renderer!;
};
beforeEach(() => {
vi.clearAllMocks();
dataGridState.latestProps = null;
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQuery.mockResolvedValue({
success: true,
fields: ['ID', 'NAME'],
data: [{ ID: 7, NAME: 'old-name' }],
});
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
});
it('enables table preview editing after primary keys are loaded', async () => {
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
});
const renderer = await renderAndReload();
expect(dataGridState.latestProps?.pkColumns).toEqual(['ID']);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'primary-key',
columns: ['ID'],
valueColumns: ['ID'],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.warning).not.toHaveBeenCalled();
renderer.unmount();
});
it('uses a unique index when the table has no primary key', async () => {
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'EMAIL', key: '' }, { name: 'NAME', key: '' }],
});
backendApp.DBGetIndexes.mockResolvedValue({
success: true,
data: [{ name: 'UK_EMAIL', columnName: 'EMAIL', nonUnique: 0, seqInIndex: 1, indexType: 'BTREE' }],
});
const renderer = await renderAndReload();
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'unique-key',
columns: ['EMAIL'],
valueColumns: ['EMAIL'],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.warning).not.toHaveBeenCalled();
renderer.unmount();
});
it('uses hidden Oracle ROWID when no primary or unique key is available', async () => {
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'ID', key: '' }, { name: 'NAME', key: '' }],
});
backendApp.DBQuery.mockResolvedValue({
success: true,
fields: ['ID', 'NAME', ORACLE_ROWID_LOCATOR_COLUMN],
data: [{ ID: 7, NAME: 'old-name', [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAAA' }],
});
const renderer = await renderAndReload();
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'oracle-rowid',
columns: ['ROWID'],
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.warning).not.toHaveBeenCalled();
expect(backendApp.DBQuery.mock.calls.some((call: any[]) => String(call[2]).includes(`ROWID AS "${ORACLE_ROWID_LOCATOR_COLUMN}"`))).toBe(true);
renderer.unmount();
});
it('keeps non-Oracle table preview read-only when no safe locator exists', async () => {
storeState.connections[0].config.type = 'mysql';
storeState.connections[0].config.database = 'main';
backendApp.DBGetColumns.mockResolvedValue({
success: true,
data: [{ name: 'ID', key: '' }, { name: 'NAME', key: '' }],
});
const renderer = await renderAndReload(createTab({ dbName: 'main', tableName: 'users', title: 'users' }));
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'none',
readOnly: true,
reason: '未检测到主键或可用唯一索引,无法安全提交修改。',
});
expect(dataGridState.latestProps?.readOnly).toBe(true);
expect(messageApi.warning).toHaveBeenCalledWith('表 main.users 保持只读:未检测到主键或可用唯一索引,无法安全提交修改。');
renderer.unmount();
});
});

View File

@@ -1,8 +1,8 @@
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { message } from 'antd';
import { TabData, ColumnDefinition } from '../types';
import { TabData, ColumnDefinition, IndexDefinition } from '../types';
import { useStore } from '../store';
import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
import { DBQuery, DBGetColumns, DBGetIndexes } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, hasExplicitSort, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
import { buildMongoCountCommand, buildMongoFilter, buildMongoFindCommand, buildMongoSort } from '../utils/mongodb';
@@ -15,6 +15,12 @@ import {
normalizeQuickWhereCondition,
validateQuickWhereCondition,
} from '../utils/dataGridWhereFilter';
import {
ORACLE_ROWID_LOCATOR_COLUMN,
resolveEditRowLocator,
type EditRowLocator,
} from '../utils/rowLocator';
import { isOracleLikeDialect } from '../utils/sqlDialect';
type ViewerPaginationState = {
current: number;
@@ -79,6 +85,47 @@ const parseTotalFromCountRow = (row: any): number | null => {
return null;
};
const buildDataViewerReadOnlyLocator = (reason: string): EditRowLocator => ({
strategy: 'none',
columns: [],
valueColumns: [],
readOnly: true,
reason,
});
const formatDataViewerTableName = (dbName: string, tableName: string): string => (
dbName ? `${dbName}.${tableName}` : tableName
);
const getTableColumnNames = (columns: ColumnDefinition[] | undefined): string[] => (
(columns || [])
.map((column) => String(column?.name || '').trim())
.filter(Boolean)
);
const resolveDataViewerOrderFallbackColumns = (locator: EditRowLocator | undefined, pkColumns: string[]): string[] => {
if (locator && !locator.readOnly && locator.strategy !== 'oracle-rowid') {
return locator.valueColumns.length > 0 ? locator.valueColumns : locator.columns;
}
return pkColumns;
};
const buildDataViewerBaseSelectSQL = (
dbType: string,
tableName: string,
whereSQL: string,
locator?: EditRowLocator,
): string => {
const quotedTableName = quoteQualifiedIdent(dbType, tableName);
if (locator?.strategy !== 'oracle-rowid') {
return `SELECT * FROM ${quotedTableName} ${whereSQL}`;
}
const alias = 'gonavi_row_source';
const rowIDAlias = quoteIdentPart(dbType, ORACLE_ROWID_LOCATOR_COLUMN);
return `SELECT ${alias}.*, ${alias}.ROWID AS ${rowIDAlias} FROM ${quotedTableName} ${alias} ${whereSQL}`;
};
const normalizeDuckDBIdentifier = (raw: string): string => {
const text = String(raw || '').trim();
if (text.length >= 2) {
@@ -193,6 +240,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const [data, setData] = useState<any[]>([]);
const [columnNames, setColumnNames] = useState<string[]>([]);
const [pkColumns, setPkColumns] = useState<string[]>([]);
const [editLocator, setEditLocator] = useState<EditRowLocator | undefined>(undefined);
const [loading, setLoading] = useState(false);
const connections = useStore(state => state.connections);
const addSqlLog = useStore(state => state.addSqlLog);
@@ -280,6 +328,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
useEffect(() => {
const snapshot = getViewerFilterSnapshot(tab.id);
setPkColumns([]);
setEditLocator(undefined);
pkKeyRef.current = '';
countKeyRef.current = '';
duckdbApproxKeyRef.current = '';
@@ -435,10 +484,84 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const whereSQL = isMongoDB
? JSON.stringify(mongoFilter || {})
: buildWhereSQL(dbType, effectiveFilterConditions);
let pkColumnsForQuery = pkColumns;
let editLocatorForQuery = editLocator;
if (!isMongoDB && !forceReadOnly && tableName) {
const locatorKey = `${tab.connectionId}|${dbTypeLower}|${dbName}|${tableName}`;
if (pkKeyRef.current !== locatorKey || !editLocatorForQuery) {
pkKeyRef.current = locatorKey;
const locatorSeq = ++pkSeqRef.current;
try {
const [resCols, resIndexes] = await Promise.all([
DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName),
DBGetIndexes(buildRpcConnectionConfig(config) as any, dbName, tableName)
.catch((error: any) => ({ success: false, message: String(error?.message || error || '加载索引失败'), data: [] })),
]);
if (fetchSeqRef.current !== seq) return;
if (pkSeqRef.current !== locatorSeq) return;
if (pkKeyRef.current !== locatorKey) return;
if (!resCols?.success || !Array.isArray(resCols.data)) {
const nextLocator = buildDataViewerReadOnlyLocator('无法加载主键/唯一索引元数据,无法安全提交修改。');
pkColumnsForQuery = [];
editLocatorForQuery = nextLocator;
setPkColumns([]);
setEditLocator(nextLocator);
message.warning(`${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason}`);
} else {
const columnDefs = resCols.data as ColumnDefinition[];
const primaryKeys = columnDefs
.filter((column: any) => column?.key === 'PRI')
.map((column: any) => String(column?.name || '').trim())
.filter(Boolean);
const indexes = resIndexes?.success && Array.isArray(resIndexes.data)
? resIndexes.data as IndexDefinition[]
: [];
const resultColumns = getTableColumnNames(columnDefs);
const locatorColumns = isOracleLikeDialect(dbType)
? [...resultColumns, ORACLE_ROWID_LOCATOR_COLUMN]
: resultColumns;
let nextLocator = resolveEditRowLocator({
dbType,
resultColumns: locatorColumns,
primaryKeys,
indexes,
allowOracleRowID: true,
});
if (nextLocator.readOnly && primaryKeys.length === 0 && !resIndexes?.success && !isOracleLikeDialect(dbType)) {
nextLocator = buildDataViewerReadOnlyLocator('无法加载唯一索引元数据,无法安全提交修改。');
}
pkColumnsForQuery = primaryKeys;
editLocatorForQuery = nextLocator;
setPkColumns(primaryKeys);
setEditLocator(nextLocator);
if (nextLocator.readOnly) {
message.warning(`${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason || '当前结果没有可用的安全行定位方式,无法提交修改。'}`);
}
}
} catch {
if (fetchSeqRef.current !== seq) return;
if (pkSeqRef.current !== locatorSeq) return;
if (pkKeyRef.current !== locatorKey) return;
const nextLocator = buildDataViewerReadOnlyLocator('无法加载主键/唯一索引元数据,无法安全提交修改。');
pkColumnsForQuery = [];
editLocatorForQuery = nextLocator;
setPkColumns([]);
setEditLocator(nextLocator);
message.warning(`${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason}`);
}
}
}
const countSql = isMongoDB
? buildMongoCountCommand(tableName, mongoFilter || {})
: `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
const orderBySQL = isMongoDB ? '' : buildOrderBySQL(dbType, sortInfo, pkColumns);
const orderBySQL = isMongoDB
? ''
: buildOrderBySQL(dbType, sortInfo, resolveDataViewerOrderFallbackColumns(editLocatorForQuery, pkColumnsForQuery));
const totalRows = Number(pagination.total);
const hasFiniteTotal = Number.isFinite(totalRows) && totalRows >= 0;
const totalKnown = pagination.totalKnown && hasFiniteTotal;
@@ -469,7 +592,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
skip: offset,
});
} else {
const baseSql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
const baseSql = buildDataViewerBaseSelectSQL(dbType, tableName, whereSQL, editLocatorForQuery);
sql = `${baseSql}${orderBySQL}`;
// ClickHouse 深分页在超大 OFFSET 下容易超时。对于总数已知且存在 ORDER BY 的场景,
// 当“尾部偏移”小于“头部偏移”时,改为反向 ORDER BY + 小 OFFSET并在前端翻转结果。
@@ -557,7 +680,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
if (safeSelect) {
let fallbackSql = `SELECT ${safeSelect} FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
fallbackSql = buildPaginatedSelectSQL(dbType, fallbackSql, buildOrderBySQL(dbType, sortInfo, pkColumns), size + 1, offset);
fallbackSql = buildPaginatedSelectSQL(dbType, fallbackSql, buildOrderBySQL(dbType, sortInfo, resolveDataViewerOrderFallbackColumns(editLocatorForQuery, pkColumnsForQuery)), size + 1, offset);
executedSql = fallbackSql;
resData = await executeDataQuery(fallbackSql, '复杂类型降级重试');
}
@@ -580,26 +703,6 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
message.warning('已自动提升排序缓冲并重试成功。');
}
}
if (pkColumns.length === 0) {
const pkKey = `${tab.connectionId}|${dbName}|${tableName}`;
if (pkKeyRef.current !== pkKey) {
pkKeyRef.current = pkKey;
const pkSeq = ++pkSeqRef.current;
DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName)
.then((resCols: any) => {
if (pkSeqRef.current !== pkSeq) return;
if (pkKeyRef.current !== pkKey) return;
if (!resCols?.success) return;
const pks = (resCols.data as ColumnDefinition[]).filter((c: any) => c.key === 'PRI').map((c: any) => c.name);
setPkColumns(pks);
})
.catch(() => {
if (pkSeqRef.current !== pkSeq) return;
if (pkKeyRef.current !== pkKey) return;
});
}
}
if (resData.success) {
let resultData = resData.data as any[];
@@ -842,9 +945,9 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
});
}
if (fetchSeqRef.current === seq) setLoading(false);
}, [connections, tab, sortInfo, filterConditions, quickWhereCondition, pkColumns, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]);
// 依赖 pkColumns:在无手动排序时可回退到主键稳定排序。
// 主键信息只会在首次加载后更新一次,避免循环查询。
}, [connections, tab, sortInfo, filterConditions, quickWhereCondition, pkColumns, editLocator, forceReadOnly, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]);
// 依赖定位列:在无手动排序时可回退到安全定位列稳定排序。
// 定位信息只会在表上下文变化后重新加载,避免循环查询。
// Handlers memoized
const handleReload = useCallback(() => {
@@ -890,14 +993,14 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
if (!whereSQL) return '';
let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
sql += buildOrderBySQL(dbType, sortInfo, pkColumns);
sql += buildOrderBySQL(dbType, sortInfo, resolveDataViewerOrderFallbackColumns(editLocator, pkColumns));
const normalizedType = dbType.toLowerCase();
const hasSortForBuffer = hasExplicitSort(sortInfo);
if (hasSortForBuffer && (normalizedType === 'mysql' || normalizedType === 'mariadb')) {
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
}
return sql;
}, [tab.tableName, currentConnConfig?.type, currentConnConfig?.driver, filterConditions, quickWhereCondition, sortInfo, pkColumns]);
}, [tab.tableName, currentConnConfig?.type, currentConnConfig?.driver, filterConditions, quickWhereCondition, sortInfo, editLocator, pkColumns]);
useEffect(() => {
const action = resolveDataViewerAutoFetchAction({
@@ -927,6 +1030,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
dbName={tab.dbName}
connectionId={tab.connectionId}
pkColumns={pkColumns}
editLocator={editLocator}
onReload={handleReload}
onSort={handleSort}
onPageChange={handlePageChange}
@@ -939,7 +1043,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
appliedFilterConditions={filterConditions}
quickWhereCondition={quickWhereCondition}
onApplyQuickWhereCondition={handleApplyQuickWhereCondition}
readOnly={forceReadOnly}
readOnly={forceReadOnly || !editLocator || editLocator.readOnly}
sortInfoExternal={sortInfo}
exportSqlWithFilter={exportSqlWithFilter || undefined}
scrollSnapshot={scrollSnapshotRef.current}

View File

@@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SavedQuery, TabData } from '../types';
import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
import QueryEditor from './QueryEditor';
const storeState = vi.hoisted(() => ({
@@ -44,6 +45,7 @@ const backendApp = vi.hoisted(() => ({
DBGetAllColumns: vi.fn(),
DBGetDatabases: vi.fn(),
DBGetColumns: vi.fn(),
DBGetIndexes: vi.fn(),
CancelQuery: vi.fn(),
GenerateQueryID: vi.fn(),
WriteSQLFile: vi.fn(),
@@ -56,6 +58,10 @@ const messageApi = vi.hoisted(() => ({
warning: vi.fn(),
}));
const dataGridState = vi.hoisted(() => ({
latestProps: null as any,
}));
const editorState = vi.hoisted(() => {
const state = {
value: '',
@@ -114,7 +120,10 @@ vi.mock('@monaco-editor/react', () => ({
}));
vi.mock('./DataGrid', () => ({
default: () => null,
default: (props: any) => {
dataGridState.latestProps = props;
return <div data-grid="true" />;
},
GONAVI_ROW_KEY: '__gonavi_row_key__',
}));
@@ -152,7 +161,7 @@ vi.mock('antd', () => {
Dropdown: ({ children }: any) => <>{children}</>,
Tooltip: ({ children }: any) => <>{children}</>,
Select: () => null,
Tabs: () => null,
Tabs: ({ items }: any) => <div>{items?.[0]?.children}</div>,
};
});
@@ -187,7 +196,15 @@ describe('QueryEditor external SQL save', () => {
storeState.activeTabId = 'tab-1';
messageApi.success.mockReset();
messageApi.error.mockReset();
messageApi.warning.mockReset();
backendApp.WriteSQLFile.mockResolvedValue({ success: true });
backendApp.DBQueryMulti.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
backendApp.GenerateQueryID.mockResolvedValue('query-1');
storeState.connections[0].config.type = 'mysql';
storeState.connections[0].config.database = 'main';
dataGridState.latestProps = null;
editorState.value = '';
editorState.editor.getValue.mockClear();
editorState.editor.setValue.mockClear();
@@ -276,4 +293,156 @@ describe('QueryEditor external SQL save', () => {
createdAt: 100,
}));
});
it('automatically appends hidden primary key locator columns for editable query results', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME', '__gonavi_locator_1_ID'], rows: [{ NAME: 'old-name', __gonavi_locator_1_ID: 7 }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ANONYMOUS', query: 'SELECT NAME FROM MYCIMLED.EDC_LOG' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe('MYCIMLED.EDC_LOG');
expect(dataGridState.latestProps?.pkColumns).toEqual(['ID']);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'primary-key',
columns: ['ID'],
valueColumns: ['__gonavi_locator_1_ID'],
hiddenColumns: ['__gonavi_locator_1_ID'],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(dataGridState.latestProps?.resultSql).toBe('SELECT NAME FROM MYCIMLED.EDC_LOG');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('"ID" AS "__gonavi_locator_1_ID"');
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('uses a unique index locator for query results without primary keys', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME', '__gonavi_locator_1_EMAIL'], rows: [{ NAME: 'old-name', __gonavi_locator_1_EMAIL: 'a@example.com' }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'EMAIL', key: '' }, { name: 'NAME', key: '' }],
});
backendApp.DBGetIndexes.mockResolvedValueOnce({
success: true,
data: [{ name: 'UK_EMAIL', columnName: 'EMAIL', nonUnique: 0, seqInIndex: 1, indexType: 'BTREE' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ANONYMOUS', query: 'SELECT NAME FROM MYCIMLED.EDC_LOG' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'unique-key',
columns: ['EMAIL'],
valueColumns: ['__gonavi_locator_1_EMAIL'],
hiddenColumns: ['__gonavi_locator_1_EMAIL'],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain('"EMAIL" AS "__gonavi_locator_1_EMAIL"');
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('uses hidden Oracle ROWID for query results without primary or unique keys', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME', ORACLE_ROWID_LOCATOR_COLUMN], rows: [{ NAME: 'old-name', [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAAA' }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'NAME', key: '' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ANONYMOUS', query: 'SELECT NAME FROM MYCIMLED.EDC_LOG' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'oracle-rowid',
columns: ['ROWID'],
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).toContain(`ROWID AS "${ORACLE_ROWID_LOCATOR_COLUMN}"`);
expect(messageApi.warning).not.toHaveBeenCalled();
});
it('keeps non-Oracle query results read-only when no safe locator exists', async () => {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME'], rows: [{ NAME: 'old-name' }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'NAME', key: '' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'SELECT NAME FROM users' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.tableName).toBe('users');
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'none',
readOnly: true,
reason: '未检测到主键或可用唯一索引,无法安全提交修改。',
});
expect(dataGridState.latestProps?.readOnly).toBe(true);
expect(messageApi.warning).toHaveBeenCalledWith('查询结果保持只读main.users 未检测到主键或可用唯一索引,无法安全提交修改。');
});
});

View File

@@ -4,17 +4,21 @@ import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Sele
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined, RobotOutlined } from '@ant-design/icons';
import { format } from 'sql-formatter';
import { v4 as uuidv4 } from 'uuid';
import { TabData, ColumnDefinition } from '../types';
import { TabData, ColumnDefinition, IndexDefinition } from '../types';
import { useStore } from '../store';
import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID, WriteSQLFile } from '../../wailsjs/go/app/App';
import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, DBGetIndexes, CancelQuery, GenerateQueryID, WriteSQLFile } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from '../utils/mongodb';
import { getShortcutDisplay, isEditableElement, isShortcutMatch } from '../utils/shortcuts';
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect';
import { isOracleLikeDialect, resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect';
import { applyQueryAutoLimit } from '../utils/queryAutoLimit';
import { extractQueryResultTableRef, type QueryResultTableRef } from '../utils/queryResultTable';
import { quoteIdentPart } from '../utils/sql';
import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert';
import { ORACLE_ROWID_LOCATOR_COLUMN, type EditRowLocator } from '../utils/rowLocator';
const SQL_KEYWORDS = [
'SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT',
@@ -187,6 +191,290 @@ let sharedAllColumnsData: {dbName: string, tableName: string, name: string, type
let sharedVisibleDbs: string[] = [];
let sharedColumnsCacheData: Record<string, any[]> = {};
const QUERY_LOCATOR_ALIAS_PREFIX = '__gonavi_locator_';
const buildQueryReadOnlyLocator = (reason: string): EditRowLocator => ({
strategy: 'none',
columns: [],
valueColumns: [],
readOnly: true,
reason,
});
type SimpleSelectInfo = {
selectsAll: boolean;
resultColumns: string[];
};
type QueryStatementPlan = {
originalSql: string;
executedSql: string;
tableRef?: QueryResultTableRef;
pkColumns: string[];
editLocator?: EditRowLocator;
warning?: string;
};
const stripQueryIdentifierQuotes = (part: string): string => {
const text = String(part || '').trim();
if (!text) return '';
if ((text.startsWith('`') && text.endsWith('`')) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1).trim();
}
if (text.startsWith('[') && text.endsWith(']')) {
return text.slice(1, -1).trim();
}
return text;
};
const splitTopLevelComma = (text: string): string[] => {
const parts: string[] = [];
let current = '';
let parenDepth = 0;
let inSingle = false;
let inDouble = false;
let inBacktick = false;
let escaped = false;
for (let index = 0; index < text.length; index++) {
const ch = text[index];
if (escaped) {
current += ch;
escaped = false;
continue;
}
if ((inSingle || inDouble) && ch === '\\') {
current += ch;
escaped = true;
continue;
}
if (!inDouble && !inBacktick && ch === "'") {
inSingle = !inSingle;
current += ch;
continue;
}
if (!inSingle && !inBacktick && ch === '"') {
inDouble = !inDouble;
current += ch;
continue;
}
if (!inSingle && !inDouble && ch === '`') {
inBacktick = !inBacktick;
current += ch;
continue;
}
if (!inSingle && !inDouble && !inBacktick) {
if (ch === '(') parenDepth++;
if (ch === ')' && parenDepth > 0) parenDepth--;
if (ch === ',' && parenDepth === 0) {
parts.push(current.trim());
current = '';
continue;
}
}
current += ch;
}
if (current.trim()) parts.push(current.trim());
return parts;
};
const SIMPLE_IDENTIFIER_PATH_RE = /^(?:[`"\[]?[A-Za-z_][\w$]*[`"\]]?\s*\.\s*){0,2}[`"\[]?[A-Za-z_][\w$]*[`"\]]?$/;
const QUERY_ALIAS_RESERVED = new Set([
'where', 'group', 'order', 'having', 'limit', 'fetch', 'offset', 'join', 'left', 'right', 'inner', 'outer', 'on', 'union',
]);
const getLastIdentifierPart = (path: string): string => {
const parts = String(path || '').split('.').map((part) => stripQueryIdentifierQuotes(part.trim())).filter(Boolean);
return parts[parts.length - 1] || '';
};
const resolveSimpleSelectItemColumn = (item: string): { name: string } | 'all' | undefined => {
const text = String(item || '').trim();
if (!text) return undefined;
if (text === '*' || /\.\s*\*$/.test(text)) return 'all';
let expr = text;
let alias = '';
const asMatch = text.match(/^(.*?)\s+AS\s+([`"\[]?[A-Za-z_][\w$]*[`"\]]?)$/i);
if (asMatch) {
expr = asMatch[1].trim();
alias = stripQueryIdentifierQuotes(asMatch[2]);
} else {
const bareAliasMatch = text.match(/^(.*?)\s+([`"\[]?[A-Za-z_][\w$]*[`"\]]?)$/);
if (bareAliasMatch && SIMPLE_IDENTIFIER_PATH_RE.test(bareAliasMatch[1].trim())) {
const candidateAlias = stripQueryIdentifierQuotes(bareAliasMatch[2]);
if (candidateAlias && !QUERY_ALIAS_RESERVED.has(candidateAlias.toLowerCase())) {
expr = bareAliasMatch[1].trim();
alias = candidateAlias;
}
}
}
if (!SIMPLE_IDENTIFIER_PATH_RE.test(expr)) return undefined;
const name = alias || getLastIdentifierPart(expr);
return name ? { name } : undefined;
};
const parseSimpleSelectInfo = (sql: string): SimpleSelectInfo | undefined => {
const match = String(sql || '').match(/^\s*SELECT\s+([\s\S]+?)\s+FROM\s+/i);
if (!match) return undefined;
const selectList = match[1].trim();
if (!selectList || /^DISTINCT\b/i.test(selectList)) return undefined;
const resultColumns: string[] = [];
let selectsAll = false;
for (const item of splitTopLevelComma(selectList)) {
const resolved = resolveSimpleSelectItemColumn(item);
if (!resolved) return undefined;
if (resolved === 'all') {
selectsAll = true;
continue;
}
resultColumns.push(resolved.name);
}
return { selectsAll, resultColumns };
};
const appendQuerySelectExpressions = (sql: string, expressions: string[]): string => {
if (expressions.length === 0) return sql;
return String(sql || '').replace(
/^(\s*SELECT\s+)([\s\S]+?)(\s+FROM\s+[\s\S]*)$/i,
(_match, prefix, selectList, rest) => `${prefix}${String(selectList).trimEnd()}, ${expressions.join(', ')}${rest}`,
);
};
const findQueryResultColumn = (columns: string[], target: string): string | undefined => {
const normalizedTarget = String(target || '').trim().toLowerCase();
return (columns || []).find((column) => String(column || '').trim().toLowerCase() === normalizedTarget);
};
const buildQueryLocatorAlias = (column: string, index: number): string => {
const normalized = String(column || '').trim().replace(/[^A-Za-z0-9_]/g, '_').slice(0, 48) || 'column';
return `${QUERY_LOCATOR_ALIAS_PREFIX}${index}_${normalized}`;
};
const buildQueryLocatorColumnExpression = (dbType: string, column: string, alias: string): string => (
`${quoteIdentPart(dbType, column)} AS ${quoteIdentPart(dbType, alias)}`
);
const buildQueryRowIDExpression = (dbType: string): string => (
`ROWID AS ${quoteIdentPart(dbType, ORACLE_ROWID_LOCATOR_COLUMN)}`
);
const resolveQueryLocatorPlan = async ({
statement,
dbType,
currentDb,
config,
forceReadOnly,
}: {
statement: string;
dbType: string;
currentDb: string;
config: any;
forceReadOnly: boolean;
}): Promise<QueryStatementPlan> => {
const plan: QueryStatementPlan = {
originalSql: statement,
executedSql: statement,
pkColumns: [],
};
if (forceReadOnly) return plan;
const tableRef = extractQueryResultTableRef(statement, dbType, currentDb);
if (!tableRef) return plan;
plan.tableRef = tableRef;
const selectInfo = parseSimpleSelectInfo(statement);
if (!selectInfo) {
const reason = '当前 SELECT 列表不是简单列或 *,无法安全提交修改。';
plan.editLocator = buildQueryReadOnlyLocator(reason);
plan.warning = `查询结果保持只读:${reason}`;
return plan;
}
try {
const [resCols, resIndexes] = await Promise.all([
DBGetColumns(buildRpcConnectionConfig(config) as any, tableRef.metadataDbName, tableRef.metadataTableName),
DBGetIndexes(buildRpcConnectionConfig(config) as any, tableRef.metadataDbName, tableRef.metadataTableName)
.catch((error: any) => ({ success: false, message: String(error?.message || error || '加载索引失败'), data: [] })),
]);
if (!resCols?.success || !Array.isArray(resCols.data)) {
const reason = `无法加载 ${tableRef.metadataDbName}.${tableRef.metadataTableName} 的主键/唯一索引元数据,无法安全提交修改。`;
plan.editLocator = buildQueryReadOnlyLocator(reason);
plan.warning = `查询结果保持只读:${reason}`;
return plan;
}
const tableColumns = resCols.data as ColumnDefinition[];
const primaryKeys = tableColumns
.filter((column: any) => column?.key === 'PRI')
.map((column: any) => String(column?.name || '').trim())
.filter(Boolean);
const indexes = resIndexes?.success && Array.isArray(resIndexes.data)
? resIndexes.data as IndexDefinition[]
: [];
const selectedColumns = selectInfo.selectsAll
? tableColumns.map((column) => String(column?.name || '').trim()).filter(Boolean)
: selectInfo.resultColumns;
const appendExpressions: string[] = [];
const hiddenColumns: string[] = [];
const buildColumnLocator = (strategy: 'primary-key' | 'unique-key', locatorColumns: string[]): EditRowLocator => {
const valueColumns = locatorColumns.map((column, index) => {
const selectedColumn = findQueryResultColumn(selectedColumns, column);
if (selectedColumn) return selectedColumn;
const alias = buildQueryLocatorAlias(column, index + 1);
appendExpressions.push(buildQueryLocatorColumnExpression(dbType, column, alias));
hiddenColumns.push(alias);
return alias;
});
return {
strategy,
columns: locatorColumns,
valueColumns,
hiddenColumns: hiddenColumns.length > 0 ? [...hiddenColumns] : undefined,
readOnly: false,
};
};
if (primaryKeys.length > 0) {
plan.pkColumns = primaryKeys;
plan.editLocator = buildColumnLocator('primary-key', primaryKeys);
} else {
const uniqueKeyGroups = resolveUniqueKeyGroupsFromIndexes(indexes);
const uniqueKeyGroup = uniqueKeyGroups.find((group) => group.length > 0);
if (uniqueKeyGroup) {
plan.editLocator = buildColumnLocator('unique-key', uniqueKeyGroup);
} else if (isOracleLikeDialect(dbType)) {
appendExpressions.push(buildQueryRowIDExpression(dbType));
plan.editLocator = {
strategy: 'oracle-rowid',
columns: ['ROWID'],
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
readOnly: false,
};
} else {
const reason = !resIndexes?.success
? '无法加载唯一索引元数据,无法安全提交修改。'
: '未检测到主键或可用唯一索引,无法安全提交修改。';
plan.editLocator = buildQueryReadOnlyLocator(reason);
plan.warning = `查询结果保持只读:${tableRef.metadataDbName}.${tableRef.metadataTableName} ${reason}`;
}
}
plan.executedSql = appendQuerySelectExpressions(statement, appendExpressions);
return plan;
} catch {
const reason = `无法加载 ${tableRef.metadataDbName}.${tableRef.metadataTableName} 的主键/唯一索引元数据,无法安全提交修改。`;
plan.editLocator = buildQueryReadOnlyLocator(reason);
plan.warning = `查询结果保持只读:${reason}`;
return plan;
}
};
const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => {
const [query, setQuery] = useState(tab.query || 'SELECT * FROM ');
@@ -198,6 +486,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
columns: string[];
tableName?: string;
pkColumns: string[];
editLocator?: EditRowLocator;
readOnly: boolean;
truncated?: boolean;
pkLoading?: boolean;
@@ -1439,26 +1728,36 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
} else {
// 非 MongoDB使用 DBQueryMulti 一次性执行多条 SQL后端返回多结果集
let fullSQL = normalizedRawSQL;
if (!fullSQL.trim()) {
const sourceStatements = splitSQLStatements(normalizedRawSQL);
if (sourceStatements.length === 0) {
message.info('没有可执行的 SQL。');
setResultSets([]);
setActiveResultKey('');
return;
}
const forceReadOnlyResult = connCaps.forceReadOnlyQueryResult;
const statementPlans: QueryStatementPlan[] = [];
for (const statement of sourceStatements) {
statementPlans.push(await resolveQueryLocatorPlan({
statement,
dbType: normalizedDbType,
currentDb,
config,
forceReadOnly: forceReadOnlyResult,
}));
}
// 自动给 SELECT 语句注入行数限制(防止大结果集卡死)
const maxRowsForLimit = Number(queryOptions?.maxRows) || 0;
let anyLimitApplied = false;
if (Number.isFinite(maxRowsForLimit) && maxRowsForLimit > 0) {
const stmts = splitSQLStatements(fullSQL);
const limitedStmts = stmts.map(s => {
const result = applyQueryAutoLimit(s, normalizedDbType, maxRowsForLimit, driver);
if (result.applied) anyLimitApplied = true;
return result.sql;
});
fullSQL = limitedStmts.join(';\n');
}
const executablePlans = statementPlans.map((plan) => {
if (!Number.isFinite(maxRowsForLimit) || maxRowsForLimit <= 0) return plan;
const result = applyQueryAutoLimit(plan.executedSql, normalizedDbType, maxRowsForLimit, driver);
if (result.applied) anyLimitApplied = true;
return { ...plan, executedSql: result.sql };
});
const fullSQL = executablePlans.map((plan) => plan.executedSql).join(';\n');
const startTime = Date.now();
let queryId: string;
@@ -1515,16 +1814,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const resultSetDataArray = Array.isArray(res.data) ? (res.data as any[]) : [];
const nextResultSets: ResultSet[] = [];
const maxRows = Number(queryOptions?.maxRows) || 0;
const forceReadOnlyResult = connCaps.forceReadOnlyQueryResult;
let anyTruncated = false;
const pendingPk: Array<{ resultKey: string; tableName: string }> = [];
// 前端也拆分语句用于匹配原始 SQL展示和表名检测
const statements = splitSQLStatements(fullSQL);
for (let idx = 0; idx < resultSetDataArray.length; idx++) {
const rsData = resultSetDataArray[idx];
const rawStatement = (idx < statements.length) ? statements[idx] : '';
const plan = executablePlans[idx];
const originalSql = plan?.originalSql || '';
const executedSql = plan?.executedSql || originalSql;
// 检查是否为 affectedRows 类结果集
const isAffectedResult = Array.isArray(rsData.rows) && rsData.rows.length === 1
@@ -1537,8 +1833,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
(row as any)[GONAVI_ROW_KEY] = 0;
nextResultSets.push({
key: `result-${idx + 1}`,
sql: rawStatement,
exportSql: rawStatement,
sql: executedSql,
exportSql: originalSql,
rows: [row],
columns: ['affectedRows'],
pkColumns: [],
@@ -1561,32 +1857,18 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i;
});
let simpleTableName: string | undefined = undefined;
if (rawStatement) {
// 支持多行 SQLSELECT [cols] FROM [schema.]table [WHERE...] [ORDER BY...] [LIMIT...] 等
// JOIN 查询表名歧义,不提取
const hasJoin = /\bJOIN\b/i.test(rawStatement);
const tableMatch = !hasJoin
? rawStatement.match(/^\s*SELECT\s+.+?\s+FROM\s+(?:[\w`"\[\].]+\.)?[`"\[]?(\w+)[`"\]]?\s*(?:$|[\s;])/im)
: null;
if (tableMatch) {
simpleTableName = tableMatch[1];
if (!forceReadOnlyResult) {
pendingPk.push({ resultKey: `result-${idx + 1}`, tableName: simpleTableName });
}
}
}
const tableRef = plan?.tableRef;
const editLocator = plan?.editLocator;
nextResultSets.push({
key: `result-${idx + 1}`,
sql: rawStatement,
exportSql: rawStatement,
sql: executedSql,
exportSql: originalSql,
rows,
columns: cols,
tableName: simpleTableName,
pkColumns: [],
readOnly: true,
pkLoading: !!simpleTableName,
tableName: tableRef?.tableName,
pkColumns: plan?.pkColumns || [],
editLocator,
readOnly: forceReadOnlyResult || !editLocator || editLocator.readOnly,
truncated
});
}
@@ -1595,21 +1877,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
setResultSets(nextResultSets);
setActiveResultKey(nextResultSets[0]?.key || '');
pendingPk.forEach(({ resultKey, tableName }) => {
DBGetColumns(buildRpcConnectionConfig(config) as any, currentDb, tableName)
.then((resCols: any) => {
if (runSeqRef.current !== runSeq) return;
if (!resCols?.success) {
setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkLoading: false, readOnly: false } : rs));
return;
}
const primaryKeys = (resCols.data as ColumnDefinition[]).filter(c => c.key === 'PRI').map(c => c.name);
setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkColumns: primaryKeys, pkLoading: false, readOnly: false } : rs));
})
.catch(() => {
if (runSeqRef.current !== runSeq) return;
setResultSets(prev => prev.map(rs => rs.key === resultKey ? { ...rs, pkLoading: false, readOnly: false } : rs));
});
executablePlans.forEach((plan) => {
if (plan.warning) message.warning(plan.warning);
});
// 后端附带的提示信息(如数据源不支持原生多语句执行的回退提示)
@@ -2142,6 +2411,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
dbName={currentDb}
connectionId={currentConnectionId}
pkColumns={rs.pkColumns}
editLocator={rs.editLocator}
onReload={() => handleReloadResult(rs.key, rs.sql)}
readOnly={rs.readOnly}
/>

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { extractQueryResultTableRef } from './queryResultTable';
describe('extractQueryResultTableRef', () => {
it('preserves Oracle schema-qualified table names for editing', () => {
expect(extractQueryResultTableRef('SELECT * FROM MYCIMLED.EDC_LOG FETCH FIRST 500 ROWS ONLY', 'oracle', 'ANONYMOUS'))
.toEqual({
tableName: 'MYCIMLED.EDC_LOG',
metadataDbName: 'MYCIMLED',
metadataTableName: 'EDC_LOG',
});
});
it('uses current schema for unqualified Oracle tables', () => {
expect(extractQueryResultTableRef('SELECT * FROM EDC_LOG', 'oracle', 'MYCIMLED'))
.toEqual({
tableName: 'EDC_LOG',
metadataDbName: 'MYCIMLED',
metadataTableName: 'EDC_LOG',
});
});
it('keeps existing simple table behavior for MySQL-style qualified names', () => {
expect(extractQueryResultTableRef('SELECT * FROM app.users LIMIT 500', 'mysql', 'app'))
.toEqual({
tableName: 'users',
metadataDbName: 'app',
metadataTableName: 'users',
});
});
it('does not mark join results as editable table refs', () => {
expect(extractQueryResultTableRef('SELECT * FROM users u JOIN orders o ON u.id = o.user_id', 'oracle', 'APP'))
.toBeUndefined();
});
it('does not mark grouped or distinct results as editable table refs', () => {
expect(extractQueryResultTableRef('SELECT ID FROM users GROUP BY ID', 'mysql', 'app'))
.toBeUndefined();
expect(extractQueryResultTableRef('SELECT DISTINCT ID FROM users', 'mysql', 'app'))
.toBeUndefined();
});
});

View File

@@ -0,0 +1,64 @@
export type QueryResultTableRef = {
tableName: string;
metadataDbName: string;
metadataTableName: string;
};
const stripIdentifierQuotes = (part: string): string => {
const text = String(part || '').trim();
if (!text) return '';
if ((text.startsWith('`') && text.endsWith('`')) || (text.startsWith('"') && text.endsWith('"'))) {
return text.slice(1, -1).trim();
}
if (text.startsWith('[') && text.endsWith(']')) {
return text.slice(1, -1).trim();
}
return text;
};
const normalizeQualifiedName = (raw: string): string => (
String(raw || '')
.split('.')
.map((part) => stripIdentifierQuotes(part.trim()))
.filter(Boolean)
.join('.')
);
const isOracleLikeDialect = (dialect: string): boolean => {
const normalized = String(dialect || '').trim().toLowerCase();
return normalized === 'oracle' || normalized === 'dameng' || normalized === 'dm' || normalized === 'dm8';
};
export const extractQueryResultTableRef = (
sql: string,
dialect: string,
currentDb: string,
): QueryResultTableRef | undefined => {
const text = String(sql || '').trim();
if (!text) return undefined;
if (/\b(JOIN|UNION|INTERSECT|EXCEPT|MINUS)\b/i.test(text)) return undefined;
if (/^\s*SELECT\s+DISTINCT\b/i.test(text)) return undefined;
if (/\bGROUP\s+BY\b|\bHAVING\b/i.test(text)) return undefined;
const tableMatch = text.match(/^\s*SELECT\s+.+?\s+FROM\s+((?:[`"\[]?\w+[`"\]]?)(?:\s*\.\s*(?:[`"\[]?\w+[`"\]]?)){0,2})\s*(?:$|[\s;])/im);
if (!tableMatch) return undefined;
const qualifiedName = normalizeQualifiedName(tableMatch[1]);
if (!qualifiedName) return undefined;
const parts = qualifiedName.split('.').filter(Boolean);
const metadataTableName = parts[parts.length - 1] || '';
if (!metadataTableName) return undefined;
const owner = parts.length >= 2 ? parts[parts.length - 2] : '';
const metadataDbName = owner || currentDb || '';
const tableName = isOracleLikeDialect(dialect) && owner
? `${owner}.${metadataTableName}`
: metadataTableName;
return {
tableName,
metadataDbName,
metadataTableName,
};
};

View File

@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest';
import {
ORACLE_ROWID_LOCATOR_COLUMN,
filterHiddenLocatorColumns,
resolveEditRowLocator,
resolveRowLocatorValues,
} from './rowLocator';
const uniqueIndex = (name: string, columnName: string, seqInIndex = 1) => ({
name,
columnName,
seqInIndex,
nonUnique: 0,
indexType: 'BTREE',
});
const normalIndex = (name: string, columnName: string, seqInIndex = 1) => ({
name,
columnName,
seqInIndex,
nonUnique: 1,
indexType: 'BTREE',
});
describe('resolveEditRowLocator', () => {
it('prefers primary keys over unique indexes', () => {
expect(resolveEditRowLocator({
dbType: 'mysql',
resultColumns: ['ID', 'EMAIL'],
primaryKeys: ['ID'],
indexes: [uniqueIndex('uk_email', 'EMAIL')],
})).toEqual({
strategy: 'primary-key',
columns: ['ID'],
valueColumns: ['ID'],
readOnly: false,
});
});
it('uses a unique index when there is no primary key', () => {
expect(resolveEditRowLocator({
dbType: 'mysql',
resultColumns: ['EMAIL', 'NAME'],
indexes: [uniqueIndex('uk_email', 'EMAIL')],
})).toEqual({
strategy: 'unique-key',
columns: ['EMAIL'],
valueColumns: ['EMAIL'],
readOnly: false,
});
});
it('sorts composite unique index columns by sequence', () => {
expect(resolveEditRowLocator({
dbType: 'postgres',
resultColumns: ['TENANT_ID', 'CODE', 'NAME'],
indexes: [
uniqueIndex('uk_tenant_code', 'CODE', 2),
uniqueIndex('uk_tenant_code', 'TENANT_ID', 1),
],
})).toMatchObject({
strategy: 'unique-key',
columns: ['TENANT_ID', 'CODE'],
valueColumns: ['TENANT_ID', 'CODE'],
readOnly: false,
});
});
it('ignores non-unique indexes', () => {
expect(resolveEditRowLocator({
dbType: 'mysql',
resultColumns: ['NAME'],
indexes: [normalIndex('idx_name', 'NAME')],
})).toMatchObject({
strategy: 'none',
readOnly: true,
});
});
it('keeps results read-only when primary key columns are missing from result columns', () => {
expect(resolveEditRowLocator({
dbType: 'oracle',
resultColumns: ['NAME'],
primaryKeys: ['ID'],
})).toMatchObject({
strategy: 'none',
readOnly: true,
reason: '结果集中缺少主键列 ID无法安全提交修改。',
});
});
it('uses Oracle ROWID when no primary or unique key is available', () => {
expect(resolveEditRowLocator({
dbType: 'oracle',
resultColumns: ['NAME', ORACLE_ROWID_LOCATOR_COLUMN],
allowOracleRowID: true,
})).toEqual({
strategy: 'oracle-rowid',
columns: ['ROWID'],
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
readOnly: false,
});
});
});
describe('resolveRowLocatorValues', () => {
it('extracts locator values from the original row', () => {
const locator = resolveEditRowLocator({
dbType: 'mysql',
resultColumns: ['EMAIL', 'NAME'],
indexes: [uniqueIndex('uk_email', 'EMAIL')],
});
expect(resolveRowLocatorValues(locator, { EMAIL: 'a@example.com', NAME: 'A' })).toEqual({
ok: true,
values: { EMAIL: 'a@example.com' },
});
});
it('rejects nullable unique locator values', () => {
const locator = resolveEditRowLocator({
dbType: 'mysql',
resultColumns: ['EMAIL', 'NAME'],
indexes: [uniqueIndex('uk_email', 'EMAIL')],
});
expect(resolveRowLocatorValues(locator, { EMAIL: null, NAME: 'A' })).toEqual({
ok: false,
error: '定位列 EMAIL 的值为空,无法安全提交修改。',
});
});
});
describe('filterHiddenLocatorColumns', () => {
it('removes hidden Oracle ROWID columns from displayed columns', () => {
const locator = resolveEditRowLocator({
dbType: 'oracle',
resultColumns: ['NAME', ORACLE_ROWID_LOCATOR_COLUMN],
allowOracleRowID: true,
});
expect(filterHiddenLocatorColumns(['NAME', ORACLE_ROWID_LOCATOR_COLUMN], locator)).toEqual(['NAME']);
});
});

View File

@@ -0,0 +1,133 @@
import type { IndexDefinition } from '../types';
import { resolveUniqueKeyGroupsFromIndexes } from '../components/dataGridCopyInsert';
import { isOracleLikeDialect } from './sqlDialect';
export const ORACLE_ROWID_LOCATOR_COLUMN = '__gonavi_oracle_rowid__';
export type RowLocatorStrategy = 'primary-key' | 'unique-key' | 'oracle-rowid' | 'none';
export type EditRowLocator = {
strategy: RowLocatorStrategy;
columns: string[];
valueColumns: string[];
hiddenColumns?: string[];
readOnly: boolean;
reason?: string;
};
export type ResolveEditRowLocatorParams = {
dbType: string;
resultColumns: string[];
primaryKeys?: string[];
indexes?: IndexDefinition[];
allowOracleRowID?: boolean;
};
export type ResolveRowLocatorValuesResult =
| { ok: true; values: Record<string, any> }
| { ok: false; error: string };
const normalizeColumnName = (value: string): string => String(value || '').trim();
const hasColumn = (columns: string[], target: string): boolean => {
const normalizedTarget = normalizeColumnName(target).toLowerCase();
return columns.some((column) => normalizeColumnName(column).toLowerCase() === normalizedTarget);
};
const findColumn = (columns: string[], target: string): string => {
const normalizedTarget = normalizeColumnName(target).toLowerCase();
return columns.find((column) => normalizeColumnName(column).toLowerCase() === normalizedTarget) || target;
};
const buildReadOnlyLocator = (reason: string): EditRowLocator => ({
strategy: 'none',
columns: [],
valueColumns: [],
readOnly: true,
reason,
});
export const resolveEditRowLocator = ({
dbType,
resultColumns,
primaryKeys = [],
indexes,
allowOracleRowID = false,
}: ResolveEditRowLocatorParams): EditRowLocator => {
const columns = (resultColumns || []).map(normalizeColumnName).filter(Boolean);
const primaryKeyColumns = (primaryKeys || []).map(normalizeColumnName).filter(Boolean);
if (primaryKeyColumns.length > 0) {
const missing = primaryKeyColumns.filter((column) => !hasColumn(columns, column));
if (missing.length === 0) {
return {
strategy: 'primary-key',
columns: primaryKeyColumns,
valueColumns: primaryKeyColumns.map((column) => findColumn(columns, column)),
readOnly: false,
};
}
return buildReadOnlyLocator(`结果集中缺少主键列 ${missing.join(', ')},无法安全提交修改。`);
}
const uniqueKeyGroups = resolveUniqueKeyGroupsFromIndexes(indexes);
const uniqueKeyGroup = uniqueKeyGroups.find((group) => group.length > 0 && group.every((column) => hasColumn(columns, column)));
if (uniqueKeyGroup) {
return {
strategy: 'unique-key',
columns: uniqueKeyGroup,
valueColumns: uniqueKeyGroup.map((column) => findColumn(columns, column)),
readOnly: false,
};
}
if (allowOracleRowID && isOracleLikeDialect(dbType) && hasColumn(columns, ORACLE_ROWID_LOCATOR_COLUMN)) {
const rowIDColumn = findColumn(columns, ORACLE_ROWID_LOCATOR_COLUMN);
return {
strategy: 'oracle-rowid',
columns: ['ROWID'],
valueColumns: [rowIDColumn],
hiddenColumns: [rowIDColumn],
readOnly: false,
};
}
if (allowOracleRowID && isOracleLikeDialect(dbType)) {
return buildReadOnlyLocator('未检测到主键或可用唯一索引,且结果中缺少 Oracle ROWID无法安全提交修改。');
}
return buildReadOnlyLocator('未检测到主键或可用唯一索引,无法安全提交修改。');
};
export const resolveRowLocatorValues = (
locator: EditRowLocator | undefined,
row: Record<string, any>,
): ResolveRowLocatorValuesResult => {
if (!locator || locator.readOnly || locator.strategy === 'none') {
return { ok: false, error: '当前结果没有可用的安全行定位方式,无法提交修改。' };
}
const values: Record<string, any> = {};
for (let index = 0; index < locator.columns.length; index++) {
const column = locator.columns[index];
const valueColumn = locator.valueColumns[index] || column;
const value = row?.[valueColumn];
if (value === null || value === undefined || value === '') {
return { ok: false, error: `定位列 ${column} 的值为空,无法安全提交修改。` };
}
values[column] = value;
}
return { ok: true, values };
};
export const filterHiddenLocatorColumns = (columns: string[], locator?: EditRowLocator): string[] => {
const hidden = new Set((locator?.hiddenColumns || []).map((column) => normalizeColumnName(column).toLowerCase()));
if (hidden.size === 0) return columns;
return (columns || []).filter((column) => !hidden.has(normalizeColumnName(column).toLowerCase()));
};
export const isHiddenLocatorColumn = (column: string, locator?: EditRowLocator): boolean => {
const normalized = normalizeColumnName(column).toLowerCase();
return (locator?.hiddenColumns || []).some((hidden) => normalizeColumnName(hidden).toLowerCase() === normalized);
};