mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-30 15:19:59 +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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user