feat(mongodb): 支持文档可视化编辑与删除

- 前端表格预览使用 _id 构建 MongoDB 行定位,并隐藏 typed ObjectID locator

- 后端 ApplyChanges 支持 MongoDB 更新、单删和批量删除,区分 ObjectID 与字符串 _id

- 补充 DataViewer、DataGrid 与双版本 Mongo driver 回归测试

Refs #458
This commit is contained in:
Syngnat
2026-05-13 21:48:14 +08:00
parent 2ad2f26b2b
commit 01eb2c25e0
10 changed files with 547 additions and 52 deletions

View File

@@ -328,6 +328,63 @@ describe('DataGrid commit change set', () => {
});
});
it('uses MongoDB _id as the locator and keeps _id out of update values', () => {
const result = buildDataGridCommitChangeSet({
addedRows: [{
[GONAVI_ROW_KEY]: 'new-1',
_id: '507f1f77bcf86cd799439013',
__gonavi_mongodb_id_locator__: { $oid: '507f1f77bcf86cd799439013' },
name: 'insert-name',
}],
modifiedRows: {
'row-1': {
[GONAVI_ROW_KEY]: 'row-1',
_id: '507f1f77bcf86cd799439999',
__gonavi_mongodb_id_locator__: '507f1f77bcf86cd799439999',
name: 'new-name',
},
},
deletedRowKeys: new Set(['row-2']),
data: [
{
[GONAVI_ROW_KEY]: 'row-1',
_id: '507f1f77bcf86cd799439011',
__gonavi_mongodb_id_locator__: { $oid: '507f1f77bcf86cd799439011' },
name: 'old-name',
},
{
[GONAVI_ROW_KEY]: 'row-2',
_id: '507f1f77bcf86cd799439012',
__gonavi_mongodb_id_locator__: '507f1f77bcf86cd799439012',
name: 'to-delete',
},
],
editLocator: {
strategy: 'primary-key',
columns: ['_id'],
valueColumns: ['__gonavi_mongodb_id_locator__'],
hiddenColumns: ['__gonavi_mongodb_id_locator__'],
writableColumns: {
name: 'name',
},
readOnly: false,
},
visibleColumnNames: ['_id', 'name'],
rowKeyToString,
normalizeCommitCellValue: normalizeValue,
shouldCommitColumn: commitColumnGuard,
});
expect(result).toEqual({
ok: true,
changes: {
inserts: [{ name: 'insert-name' }],
updates: [{ keys: { _id: { $oid: '507f1f77bcf86cd799439011' } }, values: { name: 'new-name' } }],
deletes: [{ _id: '507f1f77bcf86cd799439012' }],
},
});
});
it('fails closed when no safe locator is available', () => {
const result = buildDataGridCommitChangeSet({
addedRows: [],

View File

@@ -1453,6 +1453,11 @@ const DataGrid: React.FC<DataGridProps> = ({
const [dataPanelIsJson, setDataPanelIsJson] = useState(false);
const dataPanelDirtyRef = useRef(false);
const dataPanelOriginalRef = useRef('');
const focusedCellWritable = useMemo(() => (
canModifyData &&
!!focusedCellInfo &&
isWritableResultColumn(focusedCellInfo.dataIndex, effectiveEditLocator)
), [canModifyData, focusedCellInfo, effectiveEditLocator]);
const [rowEditorOpen, setRowEditorOpen] = useState(false);
const [rowEditorRowKey, setRowEditorRowKey] = useState<string>('');
const rowEditorBaseRawRef = useRef<Record<string, any>>({});
@@ -3020,6 +3025,15 @@ const DataGrid: React.FC<DataGridProps> = ({
return;
}
const writablePatchValues = Object.fromEntries(
Object.entries(copiedCellPatch.values)
.filter(([colName]) => isWritableResultColumn(colName, effectiveEditLocator))
);
if (Object.keys(writablePatchValues).length === 0) {
void message.info('没有可粘贴的可编辑字段');
return;
}
const targetKeySet = new Set<string>();
const selectedKeys = selectedRowKeysRef.current;
if (selectedKeys.length > 0) {
@@ -3060,7 +3074,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const addedRow = addedRowMap.get(targetRowKey);
const baseRow = baseRowMap.get(targetRowKey);
Object.entries(copiedCellPatch.values).forEach(([colName, nextValue]) => {
Object.entries(writablePatchValues).forEach(([colName, nextValue]) => {
let currentValue: any;
if (addedRow) {
@@ -3112,10 +3126,14 @@ const DataGrid: React.FC<DataGridProps> = ({
void message.success(`已粘贴到 ${patchesByRow.size} 行,共 ${updatedCellCount} 个单元格`);
setCellContextMenu(prev => ({ ...prev, visible: false }));
}, [copiedCellPatch, addedRows, modifiedRows, rowKeyStr]);
}, [copiedCellPatch, addedRows, modifiedRows, rowKeyStr, effectiveEditLocator]);
// 批量填充到选中行
const handleBatchFillToSelected = useCallback((sourceRecord: Item, dataIndex: string) => {
if (!isWritableResultColumn(dataIndex, effectiveEditLocator)) {
void message.info('当前字段不可编辑');
return;
}
const sourceValue = sourceRecord[dataIndex];
const selKeys = selectedRowKeysRef.current;
@@ -3170,7 +3188,7 @@ const DataGrid: React.FC<DataGridProps> = ({
void message.success(`已填充 ${updatedCount}`);
setCellContextMenu(prev => ({ ...prev, visible: false }));
}, [addedRows, rowKeyStr]);
}, [addedRows, rowKeyStr, effectiveEditLocator]);
const displayData = useMemo(() => {
return [...data, ...addedRows];
@@ -3431,6 +3449,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const changedFields: Record<string, any> = {};
for (const col of Object.keys(row)) {
if (col === GONAVI_ROW_KEY) continue;
if (!isWritableResultColumn(col, effectiveEditLocator)) continue;
if (!isCellValueEqualForDiff(originalRow[col], row[col])) {
changedFields[col] = row[col];
}
@@ -3464,10 +3483,14 @@ const DataGrid: React.FC<DataGridProps> = ({
});
setModifiedRows(prev => ({ ...prev, [keyStr]: row }));
}
}, [addedRows, data, rowKeyStr]);
}, [addedRows, data, rowKeyStr, deletedRowKeys, effectiveEditLocator]);
const handleDataPanelSave = useCallback(() => {
if (!focusedCellInfo) return;
if (!focusedCellWritable) {
void message.info('当前字段不可编辑');
return;
}
// 与 updateFocusedCell 设置的原始值比较,避免幽灵变更
if (dataPanelValue === dataPanelOriginalRef.current) {
dataPanelDirtyRef.current = false;
@@ -3479,16 +3502,26 @@ const DataGrid: React.FC<DataGridProps> = ({
dataPanelOriginalRef.current = dataPanelValue;
dataPanelDirtyRef.current = false;
void message.success('已保存');
}, [focusedCellInfo, dataPanelValue, handleCellSave]);
}, [focusedCellInfo, focusedCellWritable, dataPanelValue, handleCellSave]);
const handleCellSetNull = useCallback(() => {
if (!cellContextMenu.record) return;
if (!isWritableResultColumn(cellContextMenu.dataIndex, effectiveEditLocator)) {
void message.info('当前字段不可编辑');
setCellContextMenu(prev => ({ ...prev, visible: false }));
return;
}
handleCellSave({ ...cellContextMenu.record, [cellContextMenu.dataIndex]: null });
setCellContextMenu(prev => ({ ...prev, visible: false }));
}, [cellContextMenu, handleCellSave]);
}, [cellContextMenu, handleCellSave, effectiveEditLocator]);
const handleCellEditorSave = useCallback(() => {
if (!cellEditorMeta) return;
if (!isWritableResultColumn(cellEditorMeta.dataIndex, effectiveEditLocator)) {
void message.info('当前字段不可编辑');
closeCellEditor();
return;
}
const apply = cellEditorApplyRef.current;
if (apply) {
apply(cellEditorValue);
@@ -3498,7 +3531,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const nextRow: any = { ...cellEditorMeta.record, [cellEditorMeta.dataIndex]: cellEditorValue };
handleCellSave(nextRow);
closeCellEditor();
}, [cellEditorMeta, cellEditorValue, handleCellSave, closeCellEditor]);
}, [cellEditorMeta, cellEditorValue, handleCellSave, closeCellEditor, effectiveEditLocator]);
const handleFormatJsonInEditor = useCallback(() => {
if (!cellEditorIsJson) return;
@@ -3666,7 +3699,7 @@ const DataGrid: React.FC<DataGridProps> = ({
rowEditorForm.setFieldsValue(formMap);
setRowEditorRowKey(keyStr);
setRowEditorOpen(true);
}, [canModifyData, mergedDisplayData, data, addedRows, displayColumnNames, rowEditorForm, rowKeyStr, columnMetaMap, columnMetaMapByLowerName]);
}, [canModifyData, mergedDisplayData, data, addedRows, visibleColumnNames, rowEditorForm, rowKeyStr, columnMetaMap, columnMetaMapByLowerName]);
const openCurrentViewRowEditor = useCallback(() => {
if (!canModifyData) return;
@@ -3789,15 +3822,16 @@ const DataGrid: React.FC<DataGridProps> = ({
}
const keyStr = rowKeyStr(rowKey);
const normalizedNext: Record<string, any> = {};
let hasAnyVisibleChange = false;
let hasAnyWritableChange = false;
visibleColumnNames.forEach((col) => {
if (!isWritableResultColumn(col, effectiveEditLocator)) return;
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;
if (!isJsonViewValueEqual(currentVal, editedVal)) hasAnyWritableChange = true;
normalizedNext[col] = coerceJsonEditorValueForStorage(currentVal, editedVal);
});
if (!hasAnyVisibleChange) {
if (!hasAnyWritableChange) {
continue;
}
@@ -3810,6 +3844,7 @@ const DataGrid: React.FC<DataGridProps> = ({
if (!originalRow) continue;
const patch: Record<string, any> = {};
visibleColumnNames.forEach((col) => {
if (!isWritableResultColumn(col, effectiveEditLocator)) return;
const prevVal = (originalRow as any)?.[col];
const nextVal = normalizedNext[col];
if (!isCellValueEqualForDiff(prevVal, nextVal)) patch[col] = nextVal;
@@ -3836,10 +3871,14 @@ const DataGrid: React.FC<DataGridProps> = ({
setJsonEditorOpen(false);
void message.success("JSON 修改已应用到当前结果集,可继续“提交事务”");
}, [canModifyData, jsonEditorValue, mergedDisplayData, addedRows, rowKeyStr, data, displayColumnNames]);
}, [canModifyData, jsonEditorValue, mergedDisplayData, addedRows, rowKeyStr, data, visibleColumnNames, effectiveEditLocator]);
const openRowEditorFieldEditor = useCallback((dataIndex: string) => {
if (!dataIndex) return;
if (!isWritableResultColumn(dataIndex, effectiveEditLocator)) {
void message.info('当前字段不可编辑');
return;
}
const val = rowEditorForm.getFieldValue(dataIndex);
openCellEditor(
{ [dataIndex]: val ?? '' },
@@ -3847,7 +3886,7 @@ const DataGrid: React.FC<DataGridProps> = ({
dataIndex,
(nextVal) => rowEditorForm.setFieldsValue({ [dataIndex]: nextVal }),
);
}, [rowEditorForm, openCellEditor]);
}, [rowEditorForm, openCellEditor, effectiveEditLocator]);
const applyRowEditor = useCallback(() => {
const keyStr = rowEditorRowKey;
@@ -3859,6 +3898,7 @@ const DataGrid: React.FC<DataGridProps> = ({
// 日期时间类型: 将 dayjs 对象转回格式化字符串
const convertedValues: Record<string, any> = {};
Object.entries(values).forEach(([col, val]) => {
if (!isWritableResultColumn(col, effectiveEditLocator)) return;
if (val && dayjs.isDayjs(val)) {
const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()];
const rowPickerType = getTemporalPickerType(colMeta?.type);
@@ -3875,6 +3915,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const baseRawMap = rowEditorBaseRawRef.current || {};
const patch: Record<string, any> = {};
visibleColumnNames.forEach((col) => {
if (!isWritableResultColumn(col, effectiveEditLocator)) return;
let nextVal = values[col];
// 日期时间类型: 将 dayjs 对象转回格式化字符串
if (nextVal && dayjs.isDayjs(nextVal)) {
@@ -3894,7 +3935,7 @@ const DataGrid: React.FC<DataGridProps> = ({
});
closeRowEditor();
}, [rowEditorRowKey, rowEditorForm, addedRows, visibleColumnNames, rowKeyStr, closeRowEditor]);
}, [rowEditorRowKey, rowEditorForm, addedRows, visibleColumnNames, rowKeyStr, closeRowEditor, effectiveEditLocator, columnMetaMap, columnMetaMapByLowerName]);
const enableVirtual = viewMode === 'table';
@@ -4071,7 +4112,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const copiedRows = buildCopiedRowsForPaste({
rows: mergedDisplayData as Array<Record<string, any>>,
selectedRowKeys,
columnNames: displayOutputColumnNames,
columnNames: displayOutputColumnNames.filter((columnName) => isWritableResultColumn(columnName, effectiveEditLocator)),
rowKeyField: GONAVI_ROW_KEY,
rowKeyToString: rowKeyStr,
});
@@ -4082,7 +4123,7 @@ const DataGrid: React.FC<DataGridProps> = ({
setCopiedRowsForPaste(copiedRows);
void message.success(`已复制 ${copiedRows.length} 行,可粘贴为新增行`);
}, [selectedRowKeys, mergedDisplayData, displayOutputColumnNames, rowKeyStr]);
}, [selectedRowKeys, mergedDisplayData, displayOutputColumnNames, rowKeyStr, effectiveEditLocator]);
const handlePasteCopiedRowsAsNew = useCallback(() => {
if (copiedRowsForPaste.length === 0) {
@@ -4092,7 +4133,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const nextRows = buildPastedRowsFromCopiedRows({
rows: copiedRowsForPaste,
columnNames: displayOutputColumnNames,
columnNames: displayOutputColumnNames.filter((columnName) => isWritableResultColumn(columnName, effectiveEditLocator)),
rowKeyField: GONAVI_ROW_KEY,
createRowKey: (index) => {
pastedRowSequenceRef.current += 1;
@@ -4108,7 +4149,7 @@ const DataGrid: React.FC<DataGridProps> = ({
setAddedRows(prev => [...prev, ...nextRows]);
setSelectedRowKeys(nextRows.map(row => row[GONAVI_ROW_KEY]));
void message.success(`已粘贴 ${nextRows.length} 行为新增行,请检查后提交事务`);
}, [copiedRowsForPaste, displayOutputColumnNames]);
}, [copiedRowsForPaste, displayOutputColumnNames, effectiveEditLocator]);
const handleDeleteSelected = () => {
const addedKeysToRemove: string[] = [];
@@ -6151,6 +6192,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()];
const rowPickerType = getTemporalPickerType(colMeta?.type);
const isRowDateTimeField = !!rowPickerType && !(/^0{4}-0{2}-0{2}/.test(String(sample || '')));
const isWritable = isWritableResultColumn(col, effectiveEditLocator);
return (
<Form.Item key={col} label={col} style={{ marginBottom: 12 }}>
@@ -6163,6 +6205,7 @@ const DataGrid: React.FC<DataGridProps> = ({
format={TEMPORAL_FORMATS[rowPickerType]}
placeholder={placeholder}
needConfirm={false}
disabled={!isWritable}
/>
) : rowPickerType === 'datetime' ? (
<DatePicker
@@ -6171,6 +6214,7 @@ const DataGrid: React.FC<DataGridProps> = ({
format={TEMPORAL_FORMATS[rowPickerType]}
placeholder={placeholder}
needConfirm
disabled={!isWritable}
/>
) : (
<DatePicker
@@ -6179,6 +6223,7 @@ const DataGrid: React.FC<DataGridProps> = ({
picker={rowPickerType as any}
placeholder={placeholder}
needConfirm={false}
disabled={!isWritable}
/>
)
) : useArea ? (
@@ -6186,12 +6231,13 @@ const DataGrid: React.FC<DataGridProps> = ({
style={{ flex: 1 }}
autoSize={{ minRows: isJson ? 4 : 1, maxRows: 10 }}
placeholder={placeholder}
disabled={!isWritable}
/>
) : (
<Input style={{ flex: 1 }} placeholder={placeholder} />
<Input style={{ flex: 1 }} placeholder={placeholder} disabled={!isWritable} />
)}
</Form.Item>
<Button size="small" onClick={() => openRowEditorFieldEditor(col)} title="弹窗编辑">...</Button>
<Button size="small" onClick={() => openRowEditorFieldEditor(col)} title="弹窗编辑" disabled={!isWritable}>...</Button>
</div>
</Form.Item>
);
@@ -6488,7 +6534,7 @@ const DataGrid: React.FC<DataGridProps> = ({
{dataPanelIsJson && (
<Button size="small" onClick={handleDataPanelFormatJson}> JSON</Button>
)}
{canModifyData && focusedCellInfo && (
{focusedCellWritable && (
<Button size="small" type="primary" onClick={handleDataPanelSave}></Button>
)}
</div>
@@ -6512,7 +6558,7 @@ const DataGrid: React.FC<DataGridProps> = ({
fontSize: 13,
tabSize: 2,
automaticLayout: true,
readOnly: !canModifyData,
readOnly: !focusedCellWritable,
lineNumbers: 'off',
glyphMargin: false,
folding: false,

View File

@@ -154,6 +154,70 @@ describe('DataViewer safe editing locator', () => {
renderer.unmount();
});
it('enables MongoDB table preview editing through the _id locator', async () => {
storeState.connections[0].config.type = 'mongodb';
storeState.connections[0].config.database = 'app';
backendApp.DBQuery.mockResolvedValue({
success: true,
fields: ['_id', '__gonavi_mongodb_id_locator__', 'name', 'age'],
data: [{
_id: '507f1f77bcf86cd799439011',
__gonavi_mongodb_id_locator__: { $oid: '507f1f77bcf86cd799439011' },
name: 'old-name',
age: 18,
}],
});
const renderer = await renderAndReload(createTab({ id: 'tab-mongo', dbName: 'app', tableName: 'users', title: 'users' }));
expect(backendApp.DBGetColumns).not.toHaveBeenCalled();
expect(backendApp.DBGetIndexes).not.toHaveBeenCalled();
expect(dataGridState.latestProps?.pkColumns).toEqual(['_id']);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'primary-key',
columns: ['_id'],
valueColumns: ['__gonavi_mongodb_id_locator__'],
hiddenColumns: ['__gonavi_mongodb_id_locator__'],
writableColumns: {
name: 'name',
age: 'age',
},
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(messageApi.warning).not.toHaveBeenCalled();
const mongoFindCall = backendApp.DBQuery.mock.calls.find((call: any[]) => String(call[2] || '').includes('"find":"users"'));
expect(mongoFindCall).toBeTruthy();
expect(JSON.parse(String(mongoFindCall?.[2] || '{}'))).toMatchObject({
find: 'users',
sort: { _id: 1 },
__gonaviIncludeObjectIDLocator: true,
});
renderer.unmount();
});
it('keeps MongoDB results read-only when _id is missing', async () => {
storeState.connections[0].config.type = 'mongodb';
storeState.connections[0].config.database = 'app';
backendApp.DBQuery.mockResolvedValue({
success: true,
fields: ['name'],
data: [{ name: 'orphan-doc' }],
});
const renderer = await renderAndReload(createTab({ id: 'tab-mongo-no-id', dbName: 'app', tableName: 'users', title: 'users' }));
expect(dataGridState.latestProps?.pkColumns).toEqual([]);
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'none',
readOnly: true,
reason: 'MongoDB 结果集中缺少 _id无法安全提交修改。',
});
expect(dataGridState.latestProps?.readOnly).toBe(true);
expect(messageApi.warning).toHaveBeenCalledWith('集合 app.users 保持只读MongoDB 结果集中缺少 _id无法安全提交修改。');
renderer.unmount();
});
it('uses hidden Oracle ROWID when no primary or unique key is available', async () => {
backendApp.DBGetColumns.mockResolvedValue({
success: true,

View File

@@ -108,6 +108,41 @@ const getTableColumnNames = (columns: ColumnDefinition[] | undefined): string[]
.filter(Boolean)
);
const MONGODB_ID_COLUMN = '_id';
const MONGODB_ID_LOCATOR_COLUMN = '__gonavi_mongodb_id_locator__';
const buildMongoDataViewerEditLocator = (resultColumns: string[]): EditRowLocator => {
const columns = (resultColumns || [])
.map((column) => String(column || '').trim())
.filter(Boolean);
const idColumn = columns.find((column) => column.toLowerCase() === MONGODB_ID_COLUMN);
if (!idColumn) {
return buildDataViewerReadOnlyLocator('MongoDB 结果集中缺少 _id无法安全提交修改。');
}
const locatorValueColumn = columns.find((column) => column === MONGODB_ID_LOCATOR_COLUMN) || idColumn;
const writableColumns: Record<string, string> = {};
columns.forEach((column) => {
const normalized = String(column || '').trim();
if (
!normalized ||
normalized === GONAVI_ROW_KEY ||
normalized === MONGODB_ID_LOCATOR_COLUMN ||
normalized.toLowerCase() === MONGODB_ID_COLUMN
) return;
writableColumns[normalized] = normalized;
});
return {
strategy: 'primary-key',
columns: [MONGODB_ID_COLUMN],
valueColumns: [locatorValueColumn],
hiddenColumns: locatorValueColumn === MONGODB_ID_LOCATOR_COLUMN ? [MONGODB_ID_LOCATOR_COLUMN] : undefined,
writableColumns,
readOnly: false,
};
};
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;
@@ -506,6 +541,9 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
let pkColumnsForQuery = pkColumns;
let editLocatorForQuery = editLocator;
if (isMongoDB && !forceReadOnly && tableName) {
pkColumnsForQuery = [MONGODB_ID_COLUMN];
}
if (!isMongoDB && !forceReadOnly && tableName) {
const locatorKey = `${tab.connectionId}|${dbTypeLower}|${dbName}|${tableName}`;
if (pkKeyRef.current !== locatorKey || !editLocatorForQuery) {
@@ -602,13 +640,14 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
let clickHouseReverseHasMore = false;
let sql = '';
if (isMongoDB) {
const mongoSort = buildMongoSort(sortInfo, pkColumns);
const mongoSort = buildMongoSort(sortInfo, pkColumnsForQuery);
sql = buildMongoFindCommand({
collection: tableName,
filter: mongoFilter || {},
sort: mongoSort,
limit: size + 1,
skip: offset,
includeObjectIDLocator: true,
});
} else {
const baseSql = buildDataViewerBaseSelectSQL(dbType, tableName, whereSQL, editLocatorForQuery);
@@ -740,6 +779,16 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
fieldNames = Object.keys(resultData[0]);
}
if (fetchSeqRef.current !== seq) return;
if (isMongoDB && !forceReadOnly && tableName) {
const nextLocator = buildMongoDataViewerEditLocator(fieldNames);
pkColumnsForQuery = nextLocator.readOnly ? [] : [MONGODB_ID_COLUMN];
editLocatorForQuery = nextLocator;
setPkColumns(pkColumnsForQuery);
setEditLocator(nextLocator);
if (nextLocator.readOnly && resultData.length > 0) {
message.warning(`集合 ${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason || '当前结果没有可用的安全行定位方式,无法提交修改。'}`);
}
}
setColumnNames(fieldNames);
resultData.forEach((row: any, i: number) => {
if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = `row-${offset + i}`;

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from './mongodb';
import { applyMongoQueryAutoLimit, buildMongoFindCommand, convertMongoShellToJsonCommand } from './mongodb';
const parseCommand = (command: string | undefined) => JSON.parse(command || '{}');
@@ -120,3 +120,17 @@ describe('applyMongoQueryAutoLimit', () => {
expect(applyMongoQueryAutoLimit('db.users.find({})', 500).applied).toBe(false);
});
});
describe('buildMongoFindCommand', () => {
it('marks DataViewer Mongo find commands to include typed _id locator', () => {
expect(parseCommand(buildMongoFindCommand({
collection: 'users',
filter: {},
includeObjectIDLocator: true,
}))).toEqual({
find: 'users',
filter: {},
__gonaviIncludeObjectIDLocator: true,
});
});
});

View File

@@ -651,11 +651,15 @@ export const buildMongoFindCommand = (params: {
limit?: number;
skip?: number;
projection?: Record<string, unknown>;
includeObjectIDLocator?: boolean;
}): string => {
const command: Record<string, unknown> = {
find: String(params.collection || '').trim(),
filter: params.filter || {},
};
if (params.includeObjectIDLocator) {
command.__gonaviIncludeObjectIDLocator = true;
}
if (params.projection && Object.keys(params.projection).length > 0) {
command.projection = params.projection;
}

View File

@@ -40,6 +40,7 @@ func (d *mongoProxyDialer) DialContext(ctx context.Context, network, address str
}
const defaultMongoPort = 27017
const mongoObjectIDLocatorColumn = "__gonavi_mongodb_id_locator__"
func normalizeMongoAddress(host string, port int) string {
h := strings.TrimSpace(host)
@@ -922,6 +923,7 @@ func (m *MongoDB) execFind(ctx context.Context, cmd bson.D) ([]map[string]interf
var skip int64
var sortDoc interface{}
var projection interface{}
var includeObjectIDLocator bool
for _, elem := range cmd {
switch elem.Key {
@@ -937,6 +939,10 @@ func (m *MongoDB) execFind(ctx context.Context, cmd bson.D) ([]map[string]interf
sortDoc = elem.Value
case "projection":
projection = elem.Value
case "__gonaviIncludeObjectIDLocator":
if enabled, ok := elem.Value.(bool); ok {
includeObjectIDLocator = enabled
}
}
}
@@ -978,6 +984,10 @@ func (m *MongoDB) execFind(ctx context.Context, cmd bson.D) ([]map[string]interf
}
row := make(map[string]interface{})
for k, v := range doc {
if includeObjectIDLocator && k == "_id" {
row[mongoObjectIDLocatorColumn] = buildMongoObjectIDLocatorValue(v)
columnSet[mongoObjectIDLocatorColumn] = true
}
row[k] = convertBsonValue(v)
columnSet[k] = true
}
@@ -1006,6 +1016,13 @@ func (m *MongoDB) execFind(ctx context.Context, cmd bson.D) ([]map[string]interf
return data, columns, nil
}
func buildMongoObjectIDLocatorValue(v interface{}) interface{} {
if oid, ok := v.(bson.ObjectID); ok {
return bson.M{"$oid": oid.Hex()}
}
return convertBsonValue(v)
}
// execCount 使用原生 Collection.CountDocuments() 执行计数
func (m *MongoDB) execCount(ctx context.Context, cmd bson.D) ([]map[string]interface{}, []string, error) {
var collName string
@@ -1198,6 +1215,57 @@ func (m *MongoDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDef
return []connection.TriggerDefinition{}, nil
}
func copyMongoChangeDocument(row map[string]interface{}) bson.M {
doc := bson.M{}
for k, v := range row {
doc[k] = v
}
return doc
}
func buildMongoChangeFilter(row map[string]interface{}) bson.M {
filter := bson.M{}
for k, v := range row {
filter[k] = normalizeMongoChangeFilterValue(k, v)
}
return filter
}
func normalizeMongoChangeFilterValue(key string, value interface{}) interface{} {
if strings.TrimSpace(key) != "_id" {
return value
}
switch val := value.(type) {
case map[string]interface{}:
if raw, ok := val["$oid"]; ok {
if oid, parsed := parseMongoObjectIDHex(fmt.Sprintf("%v", raw)); parsed {
return oid
}
}
case bson.M:
if raw, ok := val["$oid"]; ok {
if oid, parsed := parseMongoObjectIDHex(fmt.Sprintf("%v", raw)); parsed {
return oid
}
}
}
return value
}
func parseMongoObjectIDHex(value string) (bson.ObjectID, bool) {
text := strings.TrimSpace(value)
var zero bson.ObjectID
if len(text) != 24 {
return zero, false
}
oid, err := bson.ObjectIDFromHex(text)
if err != nil {
return zero, false
}
return oid, true
}
// ApplyChanges implements batch changes for MongoDB
func (m *MongoDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if m.client == nil {
@@ -1211,23 +1279,21 @@ func (m *MongoDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
// Process deletes
for _, pk := range changes.Deletes {
filter := bson.M{}
for k, v := range pk {
filter[k] = v
}
filter := buildMongoChangeFilter(pk)
if len(filter) > 0 {
if _, err := collection.DeleteOne(ctx, filter); err != nil {
result, err := collection.DeleteOne(ctx, filter)
if err != nil {
return fmt.Errorf("删除失败:%v", err)
}
if result.DeletedCount == 0 {
return fmt.Errorf("删除失败:未匹配到文档")
}
}
}
// Process updates
for _, update := range changes.Updates {
filter := bson.M{}
for k, v := range update.Keys {
filter[k] = v
}
filter := buildMongoChangeFilter(update.Keys)
if len(filter) == 0 {
return fmt.Errorf("更新操作需要主键条件")
}
@@ -1237,17 +1303,18 @@ func (m *MongoDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
updateDoc["$set"].(bson.M)[k] = v
}
if _, err := collection.UpdateOne(ctx, filter, updateDoc); err != nil {
result, err := collection.UpdateOne(ctx, filter, updateDoc)
if err != nil {
return fmt.Errorf("更新失败:%v", err)
}
if result.MatchedCount == 0 {
return fmt.Errorf("更新失败:未匹配到文档")
}
}
// Process inserts
for _, row := range changes.Inserts {
doc := bson.M{}
for k, v := range row {
doc[k] = v
}
doc := copyMongoChangeDocument(row)
if len(doc) > 0 {
if _, err := collection.InsertOne(ctx, doc); err != nil {
return fmt.Errorf("插入失败:%v", err)

View File

@@ -7,6 +7,8 @@ import (
"testing"
"GoNavi-Wails/internal/connection"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestApplyMongoURI_ExplicitHostDoesNotAdoptURIHosts(t *testing.T) {
@@ -65,3 +67,64 @@ func TestMongoURI_MergesConnectionParamsIntoExistingURI(t *testing.T) {
t.Fatalf("uri 未合并已有 URI query 与额外参数:%s", uri)
}
}
func TestBuildMongoChangeFilter_ConvertsExplicitOIDToObjectID(t *testing.T) {
const oidHex = "507f1f77bcf86cd799439011"
filter := buildMongoChangeFilter(map[string]interface{}{
"_id": map[string]interface{}{"$oid": oidHex},
"name": oidHex,
})
gotID, ok := filter["_id"].(bson.ObjectID)
if !ok {
t.Fatalf("expected _id to be bson.ObjectID, got %T", filter["_id"])
}
if gotID.Hex() != oidHex {
t.Fatalf("unexpected ObjectID: got=%s want=%s", gotID.Hex(), oidHex)
}
if filter["name"] != oidHex {
t.Fatalf("non-_id 24 hex string should stay string, got %T %v", filter["name"], filter["name"])
}
}
func TestBuildMongoChangeFilter_LeavesIDHexStringUntouched(t *testing.T) {
const oidHex = "507f1f77bcf86cd799439011"
filter := buildMongoChangeFilter(map[string]interface{}{
"_id": oidHex,
})
if filter["_id"] != oidHex {
t.Fatalf("plain _id string should stay string, got %T %v", filter["_id"], filter["_id"])
}
}
func TestBuildMongoObjectIDLocatorValue_EncodesObjectID(t *testing.T) {
const oidHex = "507f1f77bcf86cd799439011"
oid, err := bson.ObjectIDFromHex(oidHex)
if err != nil {
t.Fatal(err)
}
locator := buildMongoObjectIDLocatorValue(oid)
locatorMap, ok := locator.(bson.M)
if !ok {
t.Fatalf("expected locator bson.M, got %T", locator)
}
if locatorMap["$oid"] != oidHex {
t.Fatalf("unexpected locator value: %v", locatorMap["$oid"])
}
}
func TestCopyMongoChangeDocument_LeavesInsertIDStringUntouched(t *testing.T) {
const oidHex = "507f1f77bcf86cd799439011"
doc := copyMongoChangeDocument(map[string]interface{}{
"_id": oidHex,
})
if doc["_id"] != oidHex {
t.Fatalf("insert _id string should stay string, got %T %v", doc["_id"], doc["_id"])
}
}

View File

@@ -41,6 +41,7 @@ func (d *mongoProxyDialer) DialContext(ctx context.Context, network, address str
}
const defaultMongoPort = 27017
const mongoObjectIDLocatorColumn = "__gonavi_mongodb_id_locator__"
func normalizeMongoAddress(host string, port int) string {
h := strings.TrimSpace(host)
@@ -925,6 +926,7 @@ func (m *MongoDBV1) execFind(ctx context.Context, cmd bson.D) ([]map[string]inte
var skip int64
var sortDoc interface{}
var projection interface{}
var includeObjectIDLocator bool
for _, elem := range cmd {
switch elem.Key {
@@ -940,6 +942,10 @@ func (m *MongoDBV1) execFind(ctx context.Context, cmd bson.D) ([]map[string]inte
sortDoc = elem.Value
case "projection":
projection = elem.Value
case "__gonaviIncludeObjectIDLocator":
if enabled, ok := elem.Value.(bool); ok {
includeObjectIDLocator = enabled
}
}
}
@@ -981,6 +987,10 @@ func (m *MongoDBV1) execFind(ctx context.Context, cmd bson.D) ([]map[string]inte
}
row := make(map[string]interface{})
for k, v := range doc {
if includeObjectIDLocator && k == "_id" {
row[mongoObjectIDLocatorColumn] = buildMongoObjectIDLocatorValue(v)
columnSet[mongoObjectIDLocatorColumn] = true
}
row[k] = convertBsonValue(v)
columnSet[k] = true
}
@@ -1009,6 +1019,13 @@ func (m *MongoDBV1) execFind(ctx context.Context, cmd bson.D) ([]map[string]inte
return data, columns, nil
}
func buildMongoObjectIDLocatorValue(v interface{}) interface{} {
if oid, ok := v.(primitive.ObjectID); ok {
return bson.M{"$oid": oid.Hex()}
}
return convertBsonValue(v)
}
// execCount 使用原生 Collection.CountDocuments() 执行计数
func (m *MongoDBV1) execCount(ctx context.Context, cmd bson.D) ([]map[string]interface{}, []string, error) {
var collName string
@@ -1201,6 +1218,57 @@ func (m *MongoDBV1) GetTriggers(dbName, tableName string) ([]connection.TriggerD
return []connection.TriggerDefinition{}, nil
}
func copyMongoChangeDocument(row map[string]interface{}) bson.M {
doc := bson.M{}
for k, v := range row {
doc[k] = v
}
return doc
}
func buildMongoChangeFilter(row map[string]interface{}) bson.M {
filter := bson.M{}
for k, v := range row {
filter[k] = normalizeMongoChangeFilterValue(k, v)
}
return filter
}
func normalizeMongoChangeFilterValue(key string, value interface{}) interface{} {
if strings.TrimSpace(key) != "_id" {
return value
}
switch val := value.(type) {
case map[string]interface{}:
if raw, ok := val["$oid"]; ok {
if oid, parsed := parseMongoObjectIDHex(fmt.Sprintf("%v", raw)); parsed {
return oid
}
}
case bson.M:
if raw, ok := val["$oid"]; ok {
if oid, parsed := parseMongoObjectIDHex(fmt.Sprintf("%v", raw)); parsed {
return oid
}
}
}
return value
}
func parseMongoObjectIDHex(value string) (primitive.ObjectID, bool) {
text := strings.TrimSpace(value)
var zero primitive.ObjectID
if len(text) != 24 {
return zero, false
}
oid, err := primitive.ObjectIDFromHex(text)
if err != nil {
return zero, false
}
return oid, true
}
// ApplyChanges implements batch changes for MongoDB
func (m *MongoDBV1) ApplyChanges(tableName string, changes connection.ChangeSet) error {
if m.client == nil {
@@ -1214,23 +1282,21 @@ func (m *MongoDBV1) ApplyChanges(tableName string, changes connection.ChangeSet)
// Process deletes
for _, pk := range changes.Deletes {
filter := bson.M{}
for k, v := range pk {
filter[k] = v
}
filter := buildMongoChangeFilter(pk)
if len(filter) > 0 {
if _, err := collection.DeleteOne(ctx, filter); err != nil {
result, err := collection.DeleteOne(ctx, filter)
if err != nil {
return fmt.Errorf("删除失败:%v", err)
}
if result.DeletedCount == 0 {
return fmt.Errorf("删除失败:未匹配到文档")
}
}
}
// Process updates
for _, update := range changes.Updates {
filter := bson.M{}
for k, v := range update.Keys {
filter[k] = v
}
filter := buildMongoChangeFilter(update.Keys)
if len(filter) == 0 {
return fmt.Errorf("更新操作需要主键条件")
}
@@ -1240,17 +1306,18 @@ func (m *MongoDBV1) ApplyChanges(tableName string, changes connection.ChangeSet)
updateDoc["$set"].(bson.M)[k] = v
}
if _, err := collection.UpdateOne(ctx, filter, updateDoc); err != nil {
result, err := collection.UpdateOne(ctx, filter, updateDoc)
if err != nil {
return fmt.Errorf("更新失败:%v", err)
}
if result.MatchedCount == 0 {
return fmt.Errorf("更新失败:未匹配到文档")
}
}
// Process inserts
for _, row := range changes.Inserts {
doc := bson.M{}
for k, v := range row {
doc[k] = v
}
doc := copyMongoChangeDocument(row)
if len(doc) > 0 {
if _, err := collection.InsertOne(ctx, doc); err != nil {
return fmt.Errorf("插入失败:%v", err)

View File

@@ -6,6 +6,9 @@ import (
"testing"
"GoNavi-Wails/internal/connection"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
)
func TestApplyMongoURIV1_ExplicitHostDoesNotAdoptURIHosts(t *testing.T) {
@@ -23,3 +26,64 @@ func TestApplyMongoURIV1_ExplicitHostDoesNotAdoptURIHosts(t *testing.T) {
t.Fatalf("expected hosts to remain empty when explicit host exists, got %v", got.Hosts)
}
}
func TestBuildMongoChangeFilterV1_ConvertsExplicitOIDToObjectID(t *testing.T) {
const oidHex = "507f1f77bcf86cd799439011"
filter := buildMongoChangeFilter(map[string]interface{}{
"_id": map[string]interface{}{"$oid": oidHex},
"name": oidHex,
})
gotID, ok := filter["_id"].(primitive.ObjectID)
if !ok {
t.Fatalf("expected _id to be primitive.ObjectID, got %T", filter["_id"])
}
if gotID.Hex() != oidHex {
t.Fatalf("unexpected ObjectID: got=%s want=%s", gotID.Hex(), oidHex)
}
if filter["name"] != oidHex {
t.Fatalf("non-_id 24 hex string should stay string, got %T %v", filter["name"], filter["name"])
}
}
func TestBuildMongoChangeFilterV1_LeavesIDHexStringUntouched(t *testing.T) {
const oidHex = "507f1f77bcf86cd799439011"
filter := buildMongoChangeFilter(map[string]interface{}{
"_id": oidHex,
})
if filter["_id"] != oidHex {
t.Fatalf("plain _id string should stay string, got %T %v", filter["_id"], filter["_id"])
}
}
func TestBuildMongoObjectIDLocatorValueV1_EncodesObjectID(t *testing.T) {
const oidHex = "507f1f77bcf86cd799439011"
oid, err := primitive.ObjectIDFromHex(oidHex)
if err != nil {
t.Fatal(err)
}
locator := buildMongoObjectIDLocatorValue(oid)
locatorMap, ok := locator.(bson.M)
if !ok {
t.Fatalf("expected locator bson.M, got %T", locator)
}
if locatorMap["$oid"] != oidHex {
t.Fatalf("unexpected locator value: %v", locatorMap["$oid"])
}
}
func TestCopyMongoChangeDocumentV1_LeavesInsertIDStringUntouched(t *testing.T) {
const oidHex = "507f1f77bcf86cd799439011"
doc := copyMongoChangeDocument(map[string]interface{}{
"_id": oidHex,
})
if doc["_id"] != oidHex {
t.Fatalf("insert _id string should stay string, got %T %v", doc["_id"], doc["_id"])
}
}