From 07b3b908f96d92860980324134f3daf100f24452 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 23 Jun 2026 19:48:43 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(mongodb):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=BC=96=E8=BE=91=E6=80=81=E5=AD=97=E7=AC=A6=E4=B8=B2?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为 MongoDB 编辑态补充字段名驱动的保守类型推断 - 统一 DataGrid 基准数据为类型化值,覆盖 JSON、行编辑和单元格编辑 - 保持 pMid 等普通字符串字段不被误判为 ObjectId - 补充 Mongo helper、DataViewer 主键定位与 DataGrid 提交回归测试 --- frontend/src/components/DataGrid.tsx | 50 ++++---- .../src/components/useDataGridModalEditors.ts | 4 +- .../src/components/useDataGridPreviewPanel.ts | 6 +- frontend/src/utils/mongodb.test.ts | 31 +++++ frontend/src/utils/mongodb.ts | 117 ++++++++++++++++-- 5 files changed, 169 insertions(+), 39 deletions(-) diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 4d0b373a..594b285e 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -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 = ({ 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 = ({ 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 = ({ } 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 = ({ }); 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 = ({ 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 = ({ 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 = ({ 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 = ({ } 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 = ({ 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 = ({ 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 = ({ 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 = ({ }); const originalMap = new Map(); - 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 = ({ 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 = ({ addedRows, modifiedRows, deletedRowKeys, - data, + data: baseData, editLocator: effectiveEditLocator, visibleColumnNames, rowKeyToString: rowKeyStr, @@ -2747,7 +2753,7 @@ const DataGrid: React.FC = ({ 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 = ({ addedRows, modifiedRows, deletedRowKeys, - data, + data: baseData, editLocator: effectiveEditLocator, visibleColumnNames, rowKeyToString: rowKeyStr, @@ -2844,7 +2850,7 @@ const DataGrid: React.FC = ({ addedRows, modifiedRows, deletedRowKeys, - data, + baseData, effectiveEditLocator, visibleColumnNames, rowKeyStr, diff --git a/frontend/src/components/useDataGridModalEditors.ts b/frontend/src/components/useDataGridModalEditors.ts index 375cfa73..73d46969 100644 --- a/frontend/src/components/useDataGridModalEditors.ts +++ b/frontend/src/components/useDataGridModalEditors.ts @@ -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 diff --git a/frontend/src/components/useDataGridPreviewPanel.ts b/frontend/src/components/useDataGridPreviewPanel.ts index 59ae07e6..02104086 100644 --- a/frontend/src/components/useDataGridPreviewPanel.ts +++ b/frontend/src/components/useDataGridPreviewPanel.ts @@ -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); diff --git a/frontend/src/utils/mongodb.test.ts b/frontend/src/utils/mongodb.test.ts index 25d9a3d6..6fefa432 100644 --- a/frontend/src/utils/mongodb.test.ts +++ b/frontend/src/utils/mongodb.test.ts @@ -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' }, + }); + }); }); diff --git a/frontend/src/utils/mongodb.ts b/frontend/src/utils/mongodb.ts index db30bf6c..eef62900 100644 --- a/frontend/src/utils/mongodb.ts +++ b/frontend/src/utils/mongodb.ts @@ -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)?.$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 = {}; + 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 = (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; } };