🐛 fix(mongodb): 修复编辑态字符串类型丢失

- 为 MongoDB 编辑态补充字段名驱动的保守类型推断
- 统一 DataGrid 基准数据为类型化值,覆盖 JSON、行编辑和单元格编辑
- 保持 pMid 等普通字符串字段不被误判为 ObjectId
- 补充 Mongo helper、DataViewer 主键定位与 DataGrid 提交回归测试
This commit is contained in:
Syngnat
2026-06-23 19:48:43 +08:00
parent adacf0b5c5
commit 07b3b908f9
5 changed files with 169 additions and 39 deletions

View File

@@ -159,7 +159,7 @@ import { useDataGridColumnResize } from './useDataGridColumnResize';
import { useDataGridPreviewPanel } from './useDataGridPreviewPanel';
import { buildTableExportTab } from '../utils/tableExportTab';
import { buildDataGridCssText } from './dataGridStyles';
import { formatMongoEditableValue, parseMongoEditedValue } from '../utils/mongodb';
import { formatMongoEditableValue, normalizeMongoDocumentForEditing, parseMongoEditedValue } from '../utils/mongodb';
// --- Error Boundary ---
import {
@@ -549,12 +549,12 @@ const DataGrid: React.FC<DataGridProps> = ({
const filteredExportSql = useMemo(() => String(exportSqlWithFilter || '').trim(), [exportSqlWithFilter]);
const hasFilteredExportSql = exportScope === 'table' && filteredExportSql.length > 0;
const mongoAwareEditableText = useCallback((value: any): string => (
isMongoDBConnection ? formatMongoEditableValue(value) : toEditableText(value)
const mongoAwareEditableText = useCallback((value: any, columnName?: string): string => (
isMongoDBConnection ? formatMongoEditableValue(value, columnName) : toEditableText(value)
), [isMongoDBConnection]);
const mongoAwareFormText = useCallback((value: any): string => (
isMongoDBConnection ? formatMongoEditableValue(value) : toFormText(value)
const mongoAwareFormText = useCallback((value: any, columnName?: string): string => (
isMongoDBConnection ? formatMongoEditableValue(value, columnName) : toFormText(value)
), [isMongoDBConnection]);
const normalizeMongoEditedCellValue = useCallback((columnName: string, value: any, currentValue?: any) => (
@@ -1493,9 +1493,15 @@ const DataGrid: React.FC<DataGridProps> = ({
updateCellSelection,
});
const baseData = useMemo(() => (
isMongoDBConnection
? data.map((row) => normalizeMongoDocumentForEditing(row))
: data
), [data, isMongoDBConnection]);
const displayData = useMemo(() => {
return [...data, ...addedRows];
}, [data, addedRows]);
return [...baseData, ...addedRows];
}, [baseData, addedRows]);
useEffect(() => { displayDataRef.current = displayData; }, [displayData]);
@@ -1609,7 +1615,7 @@ const DataGrid: React.FC<DataGridProps> = ({
}
if (deletedRowKeys.has(keyStr)) return;
// 查找原始行数据,对比是否真正有值变更
const originalRow = data.find(r => r?.[GONAVI_ROW_KEY] === rowKey);
const originalRow = baseData.find(r => r?.[GONAVI_ROW_KEY] === rowKey);
if (originalRow) {
const currentRow = modifiedRows[keyStr] ? { ...originalRow, ...modifiedRows[keyStr] } : originalRow;
const normalizedRow = normalizeMongoEditedRow(row, currentRow);
@@ -1650,7 +1656,7 @@ const DataGrid: React.FC<DataGridProps> = ({
});
setModifiedRows(prev => ({ ...prev, [keyStr]: normalizedRow }));
}
}, [addedRows, data, rowKeyStr, deletedRowKeys, effectiveEditLocator, modifiedRows, normalizeMongoEditedRow]);
}, [addedRows, baseData, rowKeyStr, deletedRowKeys, effectiveEditLocator, modifiedRows, normalizeMongoEditedRow]);
const handleDataPanelSave = useCallback(() => {
if (!focusedCellInfo) return;
@@ -1709,7 +1715,7 @@ const DataGrid: React.FC<DataGridProps> = ({
return;
}
const originalRow = data.find((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === keyStr);
const originalRow = baseData.find((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === keyStr);
if (!originalRow) {
void message.error(translateDataGrid('data_grid.message.undo_cell_original_missing'));
setCellContextMenu(prev => ({ ...prev, visible: false }));
@@ -1719,7 +1725,7 @@ const DataGrid: React.FC<DataGridProps> = ({
handleCellSave({ ...record, [dataIndex]: originalRow[dataIndex] });
setCellContextMenu(prev => ({ ...prev, visible: false }));
void message.success(translateDataGrid('data_grid.message.undo_cell_success'));
}, [addedRowKeySet, cellContextMenu.dataIndex, cellContextMenu.record, data, handleCellSave, modifiedColumns, rowKeyStr, translateDataGrid]);
}, [addedRowKeySet, baseData, cellContextMenu.dataIndex, cellContextMenu.record, handleCellSave, modifiedColumns, rowKeyStr, translateDataGrid]);
const handleCellEditorSave = useCallback(() => {
if (!cellEditorMeta) return;
@@ -1769,7 +1775,7 @@ const DataGrid: React.FC<DataGridProps> = ({
setCellFieldValue(form, fieldName, parseToDayjs(raw, pickerType));
} else {
const initialValue = isMongoDBConnection
? mongoAwareEditableText(raw)
? mongoAwareEditableText(raw, dataIndex)
: (typeof raw === 'string' ? normalizeDateTimeString(raw) : raw);
setCellFieldValue(form, fieldName, initialValue);
}
@@ -2042,7 +2048,7 @@ const DataGrid: React.FC<DataGridProps> = ({
}
const baseRow =
data.find(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) ||
baseData.find(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) ||
addedRows.find(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr) ||
displayRow;
@@ -2055,7 +2061,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const baseVal = (baseRow as any)?.[col];
const displayVal = (displayRow as any)?.[col];
baseRawMap[col] = baseVal;
displayMap[col] = mongoAwareFormText(displayVal);
displayMap[col] = mongoAwareFormText(displayVal, col);
// 日期时间类型: 将字符串值转为 dayjs 对象供 DatePicker 使用
const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()];
const rowPickerType = getTemporalPickerType(colMeta?.type, dbType, currentConnConfig);
@@ -2063,7 +2069,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const dVal = parseToDayjs(displayVal, rowPickerType);
formMap[col] = dVal;
} else {
formMap[col] = displayVal === null || displayVal === undefined ? undefined : mongoAwareFormText(displayVal);
formMap[col] = displayVal === null || displayVal === undefined ? undefined : mongoAwareFormText(displayVal, col);
}
if (baseVal === null || baseVal === undefined) nullCols.add(col);
});
@@ -2075,7 +2081,7 @@ const DataGrid: React.FC<DataGridProps> = ({
nullCols,
formValues: formMap,
});
}, [addedRows, canModifyData, columnMetaMap, columnMetaMapByLowerName, currentConnConfig, data, dbType, mergedDisplayData, mongoAwareFormText, openRowEditor, rowKeyStr, translateDataGrid, visibleColumnNames]);
}, [addedRows, baseData, canModifyData, columnMetaMap, columnMetaMapByLowerName, currentConnConfig, dbType, mergedDisplayData, mongoAwareFormText, openRowEditor, rowKeyStr, translateDataGrid, visibleColumnNames]);
const openCurrentViewRowEditor = useCallback(() => {
if (!canModifyData) return;
@@ -2139,7 +2145,7 @@ const DataGrid: React.FC<DataGridProps> = ({
});
const originalMap = new Map<string, any>();
data.forEach((r) => {
baseData.forEach((r) => {
const key = r?.[GONAVI_ROW_KEY];
if (key === undefined) return;
originalMap.set(rowKeyStr(key), r);
@@ -2212,7 +2218,7 @@ const DataGrid: React.FC<DataGridProps> = ({
closeJsonEditor();
void message.success(translateDataGrid('data_grid.message.json_applied'));
}, [canModifyData, jsonEditorValue, mergedDisplayData, addedRows, rowKeyStr, data, visibleColumnNames, effectiveEditLocator, closeJsonEditor, translateDataGrid]);
}, [canModifyData, jsonEditorValue, mergedDisplayData, addedRows, rowKeyStr, baseData, visibleColumnNames, effectiveEditLocator, closeJsonEditor, translateDataGrid]);
const openRowEditorFieldEditor = useCallback((dataIndex: string) => {
if (!dataIndex) return;
@@ -2700,7 +2706,7 @@ const DataGrid: React.FC<DataGridProps> = ({
addedRows,
modifiedRows,
deletedRowKeys,
data,
data: baseData,
editLocator: effectiveEditLocator,
visibleColumnNames,
rowKeyToString: rowKeyStr,
@@ -2747,7 +2753,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const rawErrorMessage = e?.message || String(e);
void message.error(translateDataGrid('data_grid.message.preview_sql_failed_detail', { detail: rawErrorMessage }));
}
}, [addedRows, modifiedRows, deletedRowKeys, data, effectiveEditLocator,
}, [addedRows, modifiedRows, deletedRowKeys, baseData, effectiveEditLocator,
visibleColumnNames, rowKeyStr, normalizeCommitCellValue, shouldCommitColumn,
connectionId, tableName, connections, rowLocatorMessages, translateDataGrid]);
@@ -2760,7 +2766,7 @@ const DataGrid: React.FC<DataGridProps> = ({
addedRows,
modifiedRows,
deletedRowKeys,
data,
data: baseData,
editLocator: effectiveEditLocator,
visibleColumnNames,
rowKeyToString: rowKeyStr,
@@ -2844,7 +2850,7 @@ const DataGrid: React.FC<DataGridProps> = ({
addedRows,
modifiedRows,
deletedRowKeys,
data,
baseData,
effectiveEditLocator,
visibleColumnNames,
rowKeyStr,

View File

@@ -18,7 +18,7 @@ interface OpenRowEditorParams {
}
interface UseDataGridModalEditorsParams {
toEditableText: (value: any) => string;
toEditableText: (value: any, columnName?: string) => string;
looksLikeJsonText: (text: string) => boolean;
}
@@ -100,7 +100,7 @@ export const useDataGridModalEditors = ({
) => {
if (!record || !dataIndex) return;
const raw = record?.[dataIndex];
const text = toEditableText(raw);
const text = toEditableText(raw, dataIndex);
const isJson = looksLikeJsonText(text);
const titleText = typeof title === 'string'
? title

View File

@@ -9,7 +9,7 @@ export interface DataGridFocusedCellInfo {
}
interface UseDataGridPreviewPanelParams {
toEditableText: (value: any) => string;
toEditableText: (value: any, columnName?: string) => string;
looksLikeJsonText: (text: string) => boolean;
normalizeDateTimeString: (value: string) => string;
}
@@ -44,8 +44,8 @@ export const useDataGridPreviewPanel = ({
const updateFocusedCell = React.useCallback((record: GridRecord, dataIndex: string) => {
if (!record || !dataIndex) return;
const raw = record?.[dataIndex];
let text = toEditableText(raw);
if (typeof raw === 'string') {
let text = toEditableText(raw, dataIndex);
if (typeof raw === 'string' && text === raw) {
text = normalizeDateTimeString(raw);
}
const isJson = looksLikeJsonText(text);

View File

@@ -5,6 +5,7 @@ import {
buildMongoFindCommand,
convertMongoShellToJsonCommand,
formatMongoEditableValue,
normalizeMongoDocumentForEditing,
parseMongoEditedValue,
} from './mongodb';
@@ -157,6 +158,12 @@ describe('Mongo edit value helpers', () => {
})).toBe('UUID("12345678-1234-4678-9234-567812345678")');
});
it('infers editable Mongo typed literals from common string field names', () => {
expect(formatMongoEditableValue('5a7fb5b93560e06a6e1e4950', 'merchantId')).toBe('ObjectId("5a7fb5b93560e06a6e1e4950")');
expect(formatMongoEditableValue('5ba279393560e029bb0b6359', 'pMid')).toBe('5ba279393560e029bb0b6359');
expect(formatMongoEditableValue('2018-06-24 07:42:51.8', 'updateTime')).toBe('ISODate("2018-06-24T07:42:51.800Z")');
});
it('parses typed Mongo edit text back to extended JSON wrappers', () => {
expect(parseMongoEditedValue('_id', '507f1f77bcf86cd799439011')).toEqual({ $oid: '507f1f77bcf86cd799439011' });
expect(parseMongoEditedValue('createdAt', '2024-06-23T00:00:00.000Z', { $date: { $numberLong: '1719100800000' } })).toEqual({
@@ -173,4 +180,28 @@ describe('Mongo edit value helpers', () => {
},
});
});
it('infers typed Mongo values from string edits when the field name is sufficient', () => {
expect(parseMongoEditedValue('merchantId', '5a7fb5b93560e06a6e1e4950')).toEqual({ $oid: '5a7fb5b93560e06a6e1e4950' });
expect(parseMongoEditedValue('updateTime', '2018-06-24 07:42:51.8')).toEqual({
$date: '2018-06-24T07:42:51.800Z',
});
expect(parseMongoEditedValue('pMid', '5ba279393560e029bb0b6359')).toBe('5ba279393560e029bb0b6359');
});
it('normalizes Mongo documents for JSON editing without promoting plain string ids blindly', () => {
expect(normalizeMongoDocumentForEditing({
_id: '5a8262f93560e05dd3465288',
merchantId: '5a7fb5b93560e06a6e1e4950',
pMid: '5ba279393560e029bb0b6359',
updateTime: '2018-06-24 07:42:51.8',
userId: '5a65611fadfce63b96bb2001',
})).toEqual({
_id: { $oid: '5a8262f93560e05dd3465288' },
merchantId: { $oid: '5a7fb5b93560e06a6e1e4950' },
pMid: '5ba279393560e029bb0b6359',
updateTime: { $date: '2018-06-24T07:42:51.800Z' },
userId: { $oid: '5a65611fadfce63b96bb2001' },
});
});
});

View File

@@ -85,6 +85,37 @@ const buildMongoBinaryUUID = (uuidText: string): { $binary: { base64: string; su
},
});
const isMongoObjectIdFieldName = (fieldName: string): boolean => {
const text = String(fieldName || '').trim();
if (!text) return false;
return text === '_id'
|| text === 'id'
|| text.endsWith('Id')
|| text.endsWith('ID')
|| text.endsWith('_id')
|| text.endsWith('-id');
};
const isMongoDateFieldName = (fieldName: string): boolean => {
const text = String(fieldName || '').trim();
if (!text) return false;
return text === 'date'
|| text === 'time'
|| text === 'timestamp'
|| text.endsWith('Date')
|| text.endsWith('Time')
|| text.endsWith('At')
|| text.endsWith('Timestamp')
|| text.endsWith('_date')
|| text.endsWith('_time')
|| text.endsWith('_at')
|| text.endsWith('_timestamp')
|| text.endsWith('-date')
|| text.endsWith('-time')
|| text.endsWith('-at')
|| text.endsWith('-timestamp');
};
const buildMongoDateLiteralText = (raw?: unknown): string => {
const millis = typeof raw === 'object' && raw && !Array.isArray(raw)
? parseMongoDateToMillis((raw as Record<string, unknown>)?.$numberLong ?? raw)
@@ -201,6 +232,17 @@ const parseMongoDateToMillis = (raw: unknown): number | null => {
if (Number.isFinite(n)) return Math.trunc(n);
}
const naiveMatch = text.match(
/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2}:\d{2})(\.\d{1,9})?)?$/
);
if (naiveMatch) {
const [, datePart, timePart = '00:00:00', fractionPart = ''] = naiveMatch;
const fractionDigits = fractionPart ? `${fractionPart.slice(1)}000`.slice(0, 3) : '000';
const utcText = `${datePart}T${timePart}.${fractionDigits}Z`;
const utcMillis = Date.parse(utcText);
if (!Number.isNaN(utcMillis)) return utcMillis;
}
const direct = new Date(text);
if (!Number.isNaN(direct.getTime())) return direct.getTime();
@@ -379,6 +421,56 @@ const parseMongoJSONValue = (raw: string): unknown => {
}
};
const relaxMongoDateValue = (raw: unknown): { $date: string } => ({
$date: buildMongoDateLiteralText(raw),
});
const normalizeMongoFieldValueForEditing = (fieldName: string, value: unknown): unknown => {
if (value === null || typeof value === 'undefined') return value;
const singleEntry = getSingleMongoOperatorEntry(value);
if (singleEntry) {
if (singleEntry[0] === '$date') {
return relaxMongoDateValue(singleEntry[1]);
}
return value;
}
if (Array.isArray(value)) {
return value.map((item) => normalizeMongoFieldValueForEditing(fieldName, item));
}
if (isPlainMongoObject(value)) {
const next: Record<string, unknown> = {};
Object.entries(value).forEach(([key, nestedValue]) => {
next[key] = normalizeMongoFieldValueForEditing(key, nestedValue);
});
return next;
}
if (typeof value !== 'string') return value;
const text = value.trim();
if (!text) return value;
if (isMongoObjectIdFieldName(fieldName) && HEX24_RE.test(text)) {
return { $oid: text.toLowerCase() };
}
if (isMongoDateFieldName(fieldName)) {
const millis = parseMongoDateToMillis(text);
if (millis !== null) {
return relaxMongoDateValue(millis);
}
}
return value;
};
export const normalizeMongoDocumentForEditing = <T>(value: T): T => (
normalizeMongoFieldValueForEditing('', value) as T
);
export const formatMongoValueForDisplay = (value: unknown): string => {
if (value === null) return 'NULL';
if (typeof value === 'undefined') return '';
@@ -420,20 +512,21 @@ export const formatMongoValueForDisplay = (value: unknown): string => {
return String(value);
};
export const formatMongoEditableValue = (value: unknown): string => {
if (value === null || typeof value === 'undefined') return '';
const singleEntry = getSingleMongoOperatorEntry(value);
export const formatMongoEditableValue = (value: unknown, columnName = ''): string => {
const normalizedValue = normalizeMongoFieldValueForEditing(columnName, value);
if (normalizedValue === null || typeof normalizedValue === 'undefined') return '';
const singleEntry = getSingleMongoOperatorEntry(normalizedValue);
if (singleEntry) {
return formatMongoValueForDisplay(value);
return formatMongoValueForDisplay(normalizedValue);
}
if (Array.isArray(value) || isPlainMongoObject(value)) {
if (Array.isArray(normalizedValue) || isPlainMongoObject(normalizedValue)) {
try {
return JSON.stringify(value, null, 2);
return JSON.stringify(normalizedValue, null, 2);
} catch {
return String(value);
return String(normalizedValue);
}
}
return String(value);
return String(normalizedValue);
};
export const parseMongoEditedValue = (
@@ -443,7 +536,9 @@ export const parseMongoEditedValue = (
): unknown => {
if (typeof rawValue !== 'string') return rawValue;
const currentKind = resolveMongoValueKind(currentValue);
const normalizedCurrentValue = normalizeMongoFieldValueForEditing(columnName, currentValue);
const inferredRawValue = normalizeMongoFieldValueForEditing(columnName, rawValue);
const currentKind = resolveMongoValueKind(normalizedCurrentValue);
const text = rawValue.trim();
const structuredLiteral = looksLikeMongoStructuredLiteral(rawValue);
const explicitLiteral = looksLikeExplicitMongoTypedLiteral(rawValue);
@@ -501,9 +596,7 @@ export const parseMongoEditedValue = (
case 'string':
case 'nullish':
default:
if (String(columnName || '').trim() === '_id' && HEX24_RE.test(text)) {
return { $oid: text.toLowerCase() };
}
if (inferredRawValue !== rawValue) return inferredRawValue;
return rawValue;
}
};