mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-09 07:59:33 +08:00
✨ 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:
@@ -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: [] });
|
||||
|
||||
@@ -159,6 +159,7 @@ describe('DataGrid layout', () => {
|
||||
columnNames={['id', 'name']}
|
||||
loading={false}
|
||||
tableName="users"
|
||||
pkColumns={['id']}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
199
frontend/src/components/DataViewer.primary-key.test.tsx
Normal file
199
frontend/src/components/DataViewer.primary-key.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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 未检测到主键或可用唯一索引,无法安全提交修改。');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
// 支持多行 SQL:SELECT [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}
|
||||
/>
|
||||
|
||||
44
frontend/src/utils/queryResultTable.test.ts
Normal file
44
frontend/src/utils/queryResultTable.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
64
frontend/src/utils/queryResultTable.ts
Normal file
64
frontend/src/utils/queryResultTable.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
146
frontend/src/utils/rowLocator.test.ts
Normal file
146
frontend/src/utils/rowLocator.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
133
frontend/src/utils/rowLocator.ts
Normal file
133
frontend/src/utils/rowLocator.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user