mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-08 22:12:29 +08:00
✨ feat(mongodb): 支持文档可视化编辑与删除
- 前端表格预览使用 _id 构建 MongoDB 行定位,并隐藏 typed ObjectID locator - 后端 ApplyChanges 支持 MongoDB 更新、单删和批量删除,区分 ObjectID 与字符串 _id - 补充 DataViewer、DataGrid 与双版本 Mongo driver 回归测试 Refs #458
This commit is contained in:
@@ -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: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user