From 8da8cc7f9146f7e750b31f11ec5e0d6d676a1b10 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 23 Jun 2026 12:14:27 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(mongodb):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20DataGrid=20=E7=BC=96=E8=BE=91=E5=90=8E=20BSON=20?= =?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 结果展示、单元格编辑和行编辑接入类型感知格式化与解析 - 支持 ObjectId、日期、Int32、Int64、Double、Decimal128、UUID 等常见类型保真 - 统一 v1/v2 驱动查询结果的 Extended JSON 输出与 ApplyChanges BSON 恢复 - 补充前端提交链路与后端类型转换回归测试 --- frontend/src/components/DataGrid.ddl.test.tsx | 42 +++ frontend/src/components/DataGrid.tsx | 75 +++- frontend/src/components/DataGridCore.tsx | 8 + frontend/src/utils/mongodb.test.ts | 42 ++- frontend/src/utils/mongodb.ts | 328 +++++++++++++++++- internal/db/mongodb_impl.go | 118 ++++--- internal/db/mongodb_impl_uri_test.go | 135 +++++++ internal/db/mongodb_impl_v1.go | 118 ++++--- internal/db/mongodb_impl_v1_uri_test.go | 135 +++++++ 9 files changed, 899 insertions(+), 102 deletions(-) diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx index 5f7f937..1d1961b 100644 --- a/frontend/src/components/DataGrid.ddl.test.tsx +++ b/frontend/src/components/DataGrid.ddl.test.tsx @@ -12,6 +12,7 @@ import DataGrid, { import DataGridToolbarFrame from './DataGridToolbarFrame'; import { V2CellContextMenuView, V2ColumnHeaderContextMenuView, V2TableGroupContextMenuView } from './V2TableContextMenu'; import { setCurrentLanguage, t } from '../i18n'; +import { parseMongoEditedValue } from '../utils/mongodb'; import { DUCKDB_ROWID_LOCATOR_COLUMN, ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator'; const storeState = vi.hoisted(() => ({ @@ -648,6 +649,47 @@ describe('DataGrid commit change set', () => { }); }); + it('keeps MongoDB explicit typed edit values in the final commit payload', () => { + const result = buildDataGridCommitChangeSet({ + addedRows: [{ + [GONAVI_ROW_KEY]: 'new-1', + _id: '507f1f77bcf86cd799439013', + age: '{"$numberLong":"12"}', + ratio: '1.5', + }], + modifiedRows: {}, + deletedRowKeys: new Set(), + data: [], + editLocator: { + strategy: 'primary-key', + columns: ['_id'], + valueColumns: ['_id'], + readOnly: false, + }, + visibleColumnNames: ['_id', 'age', 'ratio'], + rowKeyToString, + normalizeCommitCellValue: (columnName, value) => parseMongoEditedValue( + columnName, + value, + columnName === 'ratio' ? { $numberDouble: '0.5' } : undefined, + ), + shouldCommitColumn: commitColumnGuard, + }); + + expect(result).toEqual({ + ok: true, + changes: { + inserts: [{ + _id: { $oid: '507f1f77bcf86cd799439013' }, + age: { $numberLong: '12' }, + ratio: { $numberDouble: '1.5' }, + }], + updates: [], + deletes: [], + }, + }); + }); + it('fails closed when no safe locator is available', () => { const result = buildDataGridCommitChangeSet({ addedRows: [], diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 38a1f04..d78dbc9 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -158,6 +158,7 @@ import { useDataGridColumnResize } from './useDataGridColumnResize'; import { useDataGridPreviewPanel } from './useDataGridPreviewPanel'; import { buildTableExportTab } from '../utils/tableExportTab'; import { buildDataGridCssText } from './dataGridStyles'; +import { formatMongoEditableValue, parseMongoEditedValue } from '../utils/mongodb'; // --- Error Boundary --- import { @@ -533,6 +534,7 @@ const DataGrid: React.FC = ({ const supportsApproximateTableCount = dataSourceCaps.supportsApproximateTableCount; const supportsApproximateTotalPages = dataSourceCaps.supportsApproximateTotalPages; const dbType = dataSourceCaps.type; + const isMongoDBConnection = dbType === 'mongodb'; const isDuckDBConnection = dataSourceCaps.type === 'duckdb'; const supportsCopyInsert = dataSourceCaps.supportsCopyInsert; const supportsSqlQueryExport = dataSourceCaps.supportsSqlQueryExport; @@ -544,6 +546,33 @@ 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) + ), [isMongoDBConnection]); + + const mongoAwareFormText = useCallback((value: any): string => ( + isMongoDBConnection ? formatMongoEditableValue(value) : toFormText(value) + ), [isMongoDBConnection]); + + const normalizeMongoEditedCellValue = useCallback((columnName: string, value: any, currentValue?: any) => ( + isMongoDBConnection ? parseMongoEditedValue(columnName, value, currentValue) : value + ), [isMongoDBConnection]); + + const normalizeMongoEditedRow = useCallback((row: any, currentRow?: any) => { + if (!isMongoDBConnection || !row || typeof row !== 'object') return row; + let changed = false; + const nextRow: any = { ...row }; + Object.keys(row).forEach((columnName) => { + if (columnName === GONAVI_ROW_KEY) return; + const normalizedValue = normalizeMongoEditedCellValue(columnName, row[columnName], currentRow?.[columnName]); + if (normalizedValue !== row[columnName]) { + nextRow[columnName] = normalizedValue; + changed = true; + } + }); + return changed ? nextRow : row; + }, [isMongoDBConnection, normalizeMongoEditedCellValue]); + // --- 主题样式变量(仅在 darkMode / opacity / blur 变化时重算) --- const themeStyles = useMemo(() => { const _getBg = (darkHex: string) => { @@ -679,7 +708,7 @@ const DataGrid: React.FC = ({ openBatchEditModal, closeBatchEditModal, } = useDataGridModalEditors({ - toEditableText, + toEditableText: mongoAwareEditableText, looksLikeJsonText, }); const [virtualEditingCell, setVirtualEditingCell] = useState(null); @@ -699,7 +728,7 @@ const DataGrid: React.FC = ({ updateFocusedCell, handleDataPanelFormatJson, } = useDataGridPreviewPanel({ - toEditableText, + toEditableText: mongoAwareEditableText, looksLikeJsonText, normalizeDateTimeString, }); @@ -954,6 +983,9 @@ const DataGrid: React.FC = ({ const normalizeCommitCellValue = useCallback( (columnName: string, value: any, mode: 'insert' | 'update') => { if (value === undefined) return undefined; + if (isMongoDBConnection) { + return parseMongoEditedValue(columnName, value, undefined); + } const normalizedName = String(columnName || '').trim(); const meta = columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()]; const temporal = isTemporalColumnType(meta?.type, dbType); @@ -977,7 +1009,7 @@ const DataGrid: React.FC = ({ return value; }, - [columnMetaMap, columnMetaMapByLowerName, dbType] + [columnMetaMap, columnMetaMapByLowerName, dbType, isMongoDBConnection] ); const openTableByName = useCallback((nextTableName: string) => { @@ -1567,19 +1599,23 @@ const DataGrid: React.FC = ({ const keyStr = rowKeyStr(rowKey); const isAdded = addedRows.some(r => r?.[GONAVI_ROW_KEY] === rowKey); if (isAdded) { - setAddedRows(prev => prev.map(r => r?.[GONAVI_ROW_KEY] === rowKey ? { ...r, ...row } : r)); + const currentAddedRow = addedRows.find(r => r?.[GONAVI_ROW_KEY] === rowKey); + const normalizedRow = normalizeMongoEditedRow(row, currentAddedRow); + setAddedRows(prev => prev.map(r => r?.[GONAVI_ROW_KEY] === rowKey ? { ...r, ...normalizedRow } : r)); return; } if (deletedRowKeys.has(keyStr)) return; // 查找原始行数据,对比是否真正有值变更 const originalRow = data.find(r => r?.[GONAVI_ROW_KEY] === rowKey); if (originalRow) { + const currentRow = modifiedRows[keyStr] ? { ...originalRow, ...modifiedRows[keyStr] } : originalRow; + const normalizedRow = normalizeMongoEditedRow(row, currentRow); const changedFields: Record = {}; - for (const col of Object.keys(row)) { + for (const col of Object.keys(normalizedRow)) { if (col === GONAVI_ROW_KEY) continue; if (!isWritableResultColumn(col, effectiveEditLocator)) continue; - if (!isCellValueEqualForDiff(originalRow[col], row[col])) { - changedFields[col] = row[col]; + if (!isCellValueEqualForDiff(originalRow[col], normalizedRow[col])) { + changedFields[col] = normalizedRow[col]; } } if (Object.keys(changedFields).length === 0) { @@ -1609,9 +1645,9 @@ const DataGrid: React.FC = ({ } return { ...prev, [keyStr]: newCols }; }); - setModifiedRows(prev => ({ ...prev, [keyStr]: row })); + setModifiedRows(prev => ({ ...prev, [keyStr]: normalizedRow })); } - }, [addedRows, data, rowKeyStr, deletedRowKeys, effectiveEditLocator]); + }, [addedRows, data, rowKeyStr, deletedRowKeys, effectiveEditLocator, modifiedRows, normalizeMongoEditedRow]); const handleDataPanelSave = useCallback(() => { if (!focusedCellInfo) return; @@ -1729,7 +1765,9 @@ const DataGrid: React.FC = ({ if (isDateTimeField) { setCellFieldValue(form, fieldName, parseToDayjs(raw, pickerType)); } else { - const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw; + const initialValue = isMongoDBConnection + ? mongoAwareEditableText(raw) + : (typeof raw === 'string' ? normalizeDateTimeString(raw) : raw); setCellFieldValue(form, fieldName, initialValue); } setVirtualEditingCell({ @@ -1738,7 +1776,7 @@ const DataGrid: React.FC = ({ title, columnType, }); - }, [canModifyData, columnMetaMap, columnMetaMapByLowerName, currentConnConfig, dbType, form, openCellEditor, rowKeyStr]); + }, [canModifyData, columnMetaMap, columnMetaMapByLowerName, currentConnConfig, dbType, form, isMongoDBConnection, mongoAwareEditableText, openCellEditor, rowKeyStr]); const handleVirtualCellActivate = useCallback((record: Item, dataIndex: string, title: React.ReactNode) => { if (!canModifyData) return; @@ -2014,7 +2052,7 @@ const DataGrid: React.FC = ({ const baseVal = (baseRow as any)?.[col]; const displayVal = (displayRow as any)?.[col]; baseRawMap[col] = baseVal; - displayMap[col] = toFormText(displayVal); + displayMap[col] = mongoAwareFormText(displayVal); // 日期时间类型: 将字符串值转为 dayjs 对象供 DatePicker 使用 const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; const rowPickerType = getTemporalPickerType(colMeta?.type, dbType, currentConnConfig); @@ -2022,7 +2060,7 @@ const DataGrid: React.FC = ({ const dVal = parseToDayjs(displayVal, rowPickerType); formMap[col] = dVal; } else { - formMap[col] = displayVal === null || displayVal === undefined ? undefined : toFormText(displayVal); + formMap[col] = displayVal === null || displayVal === undefined ? undefined : mongoAwareFormText(displayVal); } if (baseVal === null || baseVal === undefined) nullCols.add(col); }); @@ -2034,7 +2072,7 @@ const DataGrid: React.FC = ({ nullCols, formValues: formMap, }); - }, [addedRows, canModifyData, columnMetaMap, columnMetaMapByLowerName, currentConnConfig, data, dbType, mergedDisplayData, openRowEditor, rowKeyStr, translateDataGrid, visibleColumnNames]); + }, [addedRows, canModifyData, columnMetaMap, columnMetaMapByLowerName, currentConnConfig, data, dbType, mergedDisplayData, mongoAwareFormText, openRowEditor, rowKeyStr, translateDataGrid, visibleColumnNames]); const openCurrentViewRowEditor = useCallback(() => { if (!canModifyData) return; @@ -2192,6 +2230,7 @@ const DataGrid: React.FC = ({ const keyStr = rowEditorRowKey; if (!keyStr) return; const values = rowEditorForm.getFieldsValue(true) || {}; + const baseRawMap = rowEditorBaseRawRef.current || {}; const isAdded = addedRows.some(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr); if (isAdded) { @@ -2199,12 +2238,13 @@ const DataGrid: React.FC = ({ const convertedValues: Record = {}; Object.entries(values).forEach(([col, val]) => { if (!isWritableResultColumn(col, effectiveEditLocator)) return; + const baseVal = baseRawMap[col]; if (val && dayjs.isDayjs(val)) { const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; const rowPickerType = getTemporalPickerType(colMeta?.type, dbType, currentConnConfig); convertedValues[col] = formatFromDayjs(val as dayjs.Dayjs, rowPickerType); } else { - convertedValues[col] = val; + convertedValues[col] = normalizeMongoEditedCellValue(col, val, baseVal); } }); setAddedRows(prev => prev.map(r => rowKeyStr(r?.[GONAVI_ROW_KEY]) === keyStr ? { ...r, ...convertedValues } : r)); @@ -2212,7 +2252,6 @@ const DataGrid: React.FC = ({ return; } - const baseRawMap = rowEditorBaseRawRef.current || {}; const patch: Record = {}; visibleColumnNames.forEach((col) => { if (!isWritableResultColumn(col, effectiveEditLocator)) return; @@ -2222,6 +2261,8 @@ const DataGrid: React.FC = ({ const colMeta = columnMetaMap[col] || columnMetaMapByLowerName[col.toLowerCase()]; const rowPickerType = getTemporalPickerType(colMeta?.type, dbType, currentConnConfig); nextVal = formatFromDayjs(nextVal as dayjs.Dayjs, rowPickerType); + } else { + nextVal = normalizeMongoEditedCellValue(col, nextVal, baseRawMap[col]); } const baseVal = baseRawMap[col]; if (!isCellValueEqualForDiff(baseVal, nextVal)) patch[col] = nextVal; @@ -2235,7 +2276,7 @@ const DataGrid: React.FC = ({ }); closeRowEditor(); - }, [addedRows, closeRowEditor, columnMetaMap, columnMetaMapByLowerName, currentConnConfig, dbType, effectiveEditLocator, rowEditorForm, rowEditorRowKey, rowKeyStr, visibleColumnNames]); + }, [addedRows, closeRowEditor, columnMetaMap, columnMetaMapByLowerName, currentConnConfig, dbType, effectiveEditLocator, normalizeMongoEditedCellValue, rowEditorForm, rowEditorRowKey, rowKeyStr, visibleColumnNames]); const enableVirtual = isTableSurfaceActive; diff --git a/frontend/src/components/DataGridCore.tsx b/frontend/src/components/DataGridCore.tsx index 047a3fa..b341b23 100644 --- a/frontend/src/components/DataGridCore.tsx +++ b/frontend/src/components/DataGridCore.tsx @@ -73,6 +73,7 @@ import { } from './dataGridClipboardExport'; import { applyNoAutoCapAttributesWithin, noAutoCapInputProps } from '../utils/inputAutoCap'; import { DEFAULT_SHORTCUT_OPTIONS, getShortcutPlatform, resolveShortcutDisplay } from '../utils/shortcuts'; +import { formatMongoValueForDisplay } from '../utils/mongodb'; import { TEMPORAL_FORMATS, formatFromDayjs, @@ -355,6 +356,10 @@ export const formatCellDisplayText = (val: any, columnType?: string, connectionC if (val === null) return 'NULL'; const bitText = normalizeBitHexDisplayText(val, columnType); if (bitText !== null) return bitText; + if (String(connectionConfig?.type || '').trim().toLowerCase() === 'mongodb') { + const mongoText = formatMongoValueForDisplay(val); + return mongoText.length > TABLE_CELL_PREVIEW_MAX_CHARS ? `${mongoText.slice(0, TABLE_CELL_PREVIEW_MAX_CHARS)}…` : mongoText; + } if (typeof val === 'object') { if (!Array.isArray(val) && !isPlainObject(val)) { return String(val); @@ -398,6 +403,9 @@ const formatClipboardCellText = (val: any, columnType?: string, connectionConfig if (val === null || val === undefined) return 'NULL'; const bitText = normalizeBitHexDisplayText(val, columnType); if (bitText !== null) return bitText; + if (String(connectionConfig?.type || '').trim().toLowerCase() === 'mongodb') { + return formatMongoValueForDisplay(val); + } if (typeof val === 'string') { const oceanBaseDateOnly = normalizeOceanBaseOracleDateDisplayText(val, columnType, connectionConfig); if (oceanBaseDateOnly !== null) return oceanBaseDateOnly; diff --git a/frontend/src/utils/mongodb.test.ts b/frontend/src/utils/mongodb.test.ts index 52dcbf7..25d9a3d 100644 --- a/frontend/src/utils/mongodb.test.ts +++ b/frontend/src/utils/mongodb.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { applyMongoQueryAutoLimit, buildMongoFindCommand, convertMongoShellToJsonCommand } from './mongodb'; +import { + applyMongoQueryAutoLimit, + buildMongoFindCommand, + convertMongoShellToJsonCommand, + formatMongoEditableValue, + parseMongoEditedValue, +} from './mongodb'; const parseCommand = (command: string | undefined) => JSON.parse(command || '{}'); @@ -134,3 +140,37 @@ describe('buildMongoFindCommand', () => { }); }); }); + +describe('Mongo edit value helpers', () => { + it('formats common extended JSON wrappers to editable literals', () => { + expect(formatMongoEditableValue({ $oid: '507f1f77bcf86cd799439011' })).toBe('ObjectId("507f1f77bcf86cd799439011")'); + expect(formatMongoEditableValue({ $date: { $numberLong: '1719100800000' } })).toBe('ISODate("2024-06-23T00:00:00.000Z")'); + expect(formatMongoEditableValue({ $numberInt: '7' })).toBe('NumberInt(7)'); + expect(formatMongoEditableValue({ $numberLong: '8' })).toBe('NumberLong("8")'); + expect(formatMongoEditableValue({ $numberDouble: '1.5' })).toBe('1.5'); + expect(formatMongoEditableValue({ $numberDecimal: '9.99' })).toBe('NumberDecimal("9.99")'); + expect(formatMongoEditableValue({ + $binary: { + base64: 'EjRWeBI0RniSNFZ4EjRWeA==', + subType: '04', + }, + })).toBe('UUID("12345678-1234-4678-9234-567812345678")'); + }); + + 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({ + $date: { $numberLong: '1719100800000' }, + }); + expect(parseMongoEditedValue('count32', '7', { $numberInt: '1' })).toEqual({ $numberInt: '7' }); + expect(parseMongoEditedValue('count64', '8', { $numberLong: '1' })).toEqual({ $numberLong: '8' }); + expect(parseMongoEditedValue('ratio', '1.5', { $numberDouble: '0.5' })).toEqual({ $numberDouble: '1.5' }); + expect(parseMongoEditedValue('price', '9.99', { $numberDecimal: '1.23' })).toEqual({ $numberDecimal: '9.99' }); + expect(parseMongoEditedValue('uid', 'UUID("12345678-1234-4678-9234-567812345678")')).toEqual({ + $binary: { + base64: 'EjRWeBI0RniSNFZ4EjRWeA==', + subType: '04', + }, + }); + }); +}); diff --git a/frontend/src/utils/mongodb.ts b/frontend/src/utils/mongodb.ts index 08bd14e..db30bf6 100644 --- a/frontend/src/utils/mongodb.ts +++ b/frontend/src/utils/mongodb.ts @@ -16,8 +16,168 @@ type ShellConvertResult = { }; const HEX24_RE = /^[0-9a-fA-F]{24}$/; +const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/; const INTEGER_RE = /^[+-]?\d+$/; const FLOAT_RE = /^[+-]?(?:\d+\.\d+|\d+\.|\.\d+)$/; +const SCIENTIFIC_RE = /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)[eE][+-]?\d+$/; + +const isPlainMongoObject = (value: unknown): value is Record => ( + !!value && typeof value === 'object' && !Array.isArray(value) +); + +const getSingleMongoOperatorEntry = (value: unknown): [string, unknown] | null => { + if (!isPlainMongoObject(value)) return null; + const entries = Object.entries(value); + if (entries.length !== 1) return null; + return entries[0] || null; +}; + +const byteArrayToBase64 = (bytes: Uint8Array): string => { + const BufferCtor = (globalThis as any)?.Buffer; + if (BufferCtor) { + return BufferCtor.from(bytes).toString('base64'); + } + let binary = ''; + bytes.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + return globalThis.btoa(binary); +}; + +const base64ToByteArray = (base64: string): Uint8Array => { + const BufferCtor = (globalThis as any)?.Buffer; + if (BufferCtor) { + return Uint8Array.from(BufferCtor.from(base64, 'base64')); + } + const binary = globalThis.atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +}; + +const uuidToBytes = (uuid: string): Uint8Array => { + const hex = String(uuid || '').trim().replace(/-/g, '').toLowerCase(); + const bytes = new Uint8Array(16); + for (let index = 0; index < 16; index += 1) { + bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +}; + +const bytesToUuid = (bytes: Uint8Array): string => { + const hex = Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0')).join(''); + if (hex.length !== 32) return ''; + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20, 32), + ].join('-'); +}; + +const buildMongoBinaryUUID = (uuidText: string): { $binary: { base64: string; subType: string } } => ({ + $binary: { + base64: byteArrayToBase64(uuidToBytes(uuidText)), + subType: '04', + }, +}); + +const buildMongoDateLiteralText = (raw?: unknown): string => { + const millis = typeof raw === 'object' && raw && !Array.isArray(raw) + ? parseMongoDateToMillis((raw as Record)?.$numberLong ?? raw) + : parseMongoDateToMillis(raw); + if (millis !== null) { + return new Date(millis).toISOString(); + } + return String(raw ?? ''); +}; + +const buildMongoBinaryLiteralText = (raw: unknown): string | null => { + if (!isPlainMongoObject(raw)) return null; + const binary = raw.$binary; + if (!isPlainMongoObject(binary)) return null; + const subType = String(binary.subType ?? '').trim().toLowerCase(); + const base64 = String(binary.base64 ?? '').trim(); + if (subType !== '04' || !base64) return null; + try { + const uuidText = bytesToUuid(base64ToByteArray(base64)); + return UUID_RE.test(uuidText) ? `UUID("${uuidText}")` : null; + } catch { + return null; + } +}; + +const looksLikeExplicitMongoTypedLiteral = (raw: string): boolean => ( + /^(?:ObjectId|ISODate|NumberInt|NumberLong|NumberDouble|NumberDecimal|UUID|MaxKey|MinKey)\s*\(/i.test(String(raw || '').trim()) +); + +const looksLikeMongoStructuredLiteral = (raw: string): boolean => { + const text = String(raw || '').trim(); + if (!text) return false; + const first = text[0]; + const last = text[text.length - 1]; + return (first === '{' && last === '}') || (first === '[' && last === ']'); +}; + +type MongoValueKind = + | 'nullish' + | 'string' + | 'boolean' + | 'number' + | 'object' + | 'array' + | 'objectId' + | 'date' + | 'int32' + | 'int64' + | 'double' + | 'decimal128' + | 'uuid' + | 'binary' + | 'maxKey' + | 'minKey'; + +const resolveMongoValueKind = (value: unknown): MongoValueKind => { + if (value === null || typeof value === 'undefined') return 'nullish'; + if (Array.isArray(value)) return 'array'; + if (typeof value === 'string') return 'string'; + if (typeof value === 'boolean') return 'boolean'; + if (typeof value === 'number') return 'number'; + const singleEntry = getSingleMongoOperatorEntry(value); + if (singleEntry) { + switch (singleEntry[0]) { + case '$oid': + return 'objectId'; + case '$date': + return 'date'; + case '$numberInt': + return 'int32'; + case '$numberLong': + return 'int64'; + case '$numberDouble': + return 'double'; + case '$numberDecimal': + return 'decimal128'; + case '$binary': { + const binary = singleEntry[1]; + if (isPlainMongoObject(binary) && String(binary.subType ?? '').trim().toLowerCase() === '04') { + return 'uuid'; + } + return 'binary'; + } + case '$maxKey': + return 'maxKey'; + case '$minKey': + return 'minKey'; + default: + break; + } + } + return typeof value === 'object' ? 'object' : 'string'; +}; const escapeRegex = (raw: string) => raw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -69,13 +229,31 @@ const parseBooleanLiteral = (raw: string): boolean | null => { return null; }; +const normalizeMongoDoubleLiteral = (raw: string): string | null => { + const text = String(raw || '').trim(); + if (!text) return null; + const lower = text.toLowerCase(); + if (lower === 'nan') return 'NaN'; + if (lower === 'infinity' || lower === '+infinity') return 'Infinity'; + if (lower === '-infinity') return '-Infinity'; + if (INTEGER_RE.test(text) || FLOAT_RE.test(text) || SCIENTIFIC_RE.test(text)) { + const parsed = Number(text); + return Number.isFinite(parsed) ? String(parsed) : null; + } + return null; +}; + const normalizeExtendedJSON = (raw: string): string => { let text = String(raw || ''); text = text.replace(/ObjectId\s*\(\s*["']([0-9a-fA-F]{24})["']\s*\)/g, (_m, oid: string) => JSON.stringify({ $oid: oid })); text = text.replace(/ISODate\s*\(\s*["']([^"']+)["']\s*\)/g, (_m, dateText: string) => JSON.stringify(buildMongoExtendedDate(dateText))); text = text.replace(/NumberLong\s*\(\s*["']?([+-]?\d+)["']?\s*\)/g, '{"$numberLong":"$1"}'); text = text.replace(/NumberInt\s*\(\s*["']?([+-]?\d+)["']?\s*\)/g, '{"$numberInt":"$1"}'); + text = text.replace(/NumberDouble\s*\(\s*["']?([^"')]+)["']?\s*\)/g, '{"$numberDouble":"$1"}'); text = text.replace(/NumberDecimal\s*\(\s*["']?([+-]?(?:\d+(?:\.\d+)?|\.\d+))["']?\s*\)/g, '{"$numberDecimal":"$1"}'); + text = text.replace(/UUID\s*\(\s*["']([0-9a-fA-F-]{36})["']\s*\)/g, (_m, uuidText: string) => JSON.stringify(buildMongoBinaryUUID(uuidText))); + text = text.replace(/MaxKey\s*\(\s*\)/g, '{"$maxKey":1}'); + text = text.replace(/MinKey\s*\(\s*\)/g, '{"$minKey":1}'); return text; }; @@ -130,21 +308,39 @@ const evalMongoLikeLiteral = (raw: string): unknown => { if (!INTEGER_RE.test(text)) throw new Error(`NumberLong invalid value: ${text}`); return { $numberLong: text }; }; + const NumberDouble = (value: unknown) => { + const normalized = normalizeMongoDoubleLiteral(String(value ?? '').trim()); + if (!normalized) throw new Error(`NumberDouble invalid value: ${String(value)}`); + return { $numberDouble: normalized }; + }; const NumberDecimal = (value: unknown) => { const text = String(value ?? '').trim(); if (!text) throw new Error('NumberDecimal invalid value'); return { $numberDecimal: text }; }; + const UUID = (value: unknown) => { + const text = String(value ?? '').trim().replace(/^['"]|['"]$/g, ''); + if (!UUID_RE.test(text)) { + throw new Error(`UUID invalid value: ${text}`); + } + return buildMongoBinaryUUID(text.toLowerCase()); + }; + const MaxKey = () => ({ $maxKey: 1 }); + const MinKey = () => ({ $minKey: 1 }); const parser = new Function( 'ObjectId', 'ISODate', 'NumberInt', 'NumberLong', + 'NumberDouble', 'NumberDecimal', + 'UUID', + 'MaxKey', + 'MinKey', '"use strict"; return (' + expression + ');', ); - const evaluated = parser(ObjectId, ISODate, NumberInt, NumberLong, NumberDecimal); + const evaluated = parser(ObjectId, ISODate, NumberInt, NumberLong, NumberDouble, NumberDecimal, UUID, MaxKey, MinKey); return normalizeEvaluatedMongoValue(evaluated); }; @@ -183,6 +379,135 @@ const parseMongoJSONValue = (raw: string): unknown => { } }; +export const formatMongoValueForDisplay = (value: unknown): string => { + if (value === null) return 'NULL'; + if (typeof value === 'undefined') return ''; + const singleEntry = getSingleMongoOperatorEntry(value); + if (singleEntry) { + switch (singleEntry[0]) { + case '$oid': + return `ObjectId("${String(singleEntry[1] ?? '')}")`; + case '$date': + return `ISODate("${buildMongoDateLiteralText(singleEntry[1])}")`; + case '$numberInt': + return `NumberInt(${String(singleEntry[1] ?? '')})`; + case '$numberLong': + return `NumberLong("${String(singleEntry[1] ?? '')}")`; + case '$numberDouble': + return String(singleEntry[1] ?? ''); + case '$numberDecimal': + return `NumberDecimal("${String(singleEntry[1] ?? '')}")`; + case '$binary': { + const binaryText = buildMongoBinaryLiteralText(value); + if (binaryText) return binaryText; + break; + } + case '$maxKey': + return 'MaxKey()'; + case '$minKey': + return 'MinKey()'; + default: + break; + } + } + if (Array.isArray(value) || isPlainMongoObject(value)) { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); +}; + +export const formatMongoEditableValue = (value: unknown): string => { + if (value === null || typeof value === 'undefined') return ''; + const singleEntry = getSingleMongoOperatorEntry(value); + if (singleEntry) { + return formatMongoValueForDisplay(value); + } + if (Array.isArray(value) || isPlainMongoObject(value)) { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } + return String(value); +}; + +export const parseMongoEditedValue = ( + columnName: string, + rawValue: unknown, + currentValue?: unknown, +): unknown => { + if (typeof rawValue !== 'string') return rawValue; + + const currentKind = resolveMongoValueKind(currentValue); + const text = rawValue.trim(); + const structuredLiteral = looksLikeMongoStructuredLiteral(rawValue); + const explicitLiteral = looksLikeExplicitMongoTypedLiteral(rawValue); + + if (structuredLiteral || explicitLiteral) { + return parseMongoJSONValue(rawValue); + } + + switch (currentKind) { + case 'objectId': + if (HEX24_RE.test(text)) return { $oid: text.toLowerCase() }; + return rawValue; + case 'date': + if (!text) return rawValue; + return buildMongoExtendedDate(text); + case 'int32': + if (INTEGER_RE.test(text)) return { $numberInt: String(Number.parseInt(text, 10)) }; + if (text.toLowerCase() === 'null') return null; + return rawValue; + case 'int64': + if (INTEGER_RE.test(text)) return { $numberLong: text }; + if (text.toLowerCase() === 'null') return null; + return rawValue; + case 'double': { + const normalized = normalizeMongoDoubleLiteral(text); + if (normalized !== null) return { $numberDouble: normalized }; + if (text.toLowerCase() === 'null') return null; + return rawValue; + } + case 'decimal128': + if (INTEGER_RE.test(text) || FLOAT_RE.test(text)) return { $numberDecimal: text }; + if (text.toLowerCase() === 'null') return null; + return rawValue; + case 'boolean': { + const boolValue = parseBooleanLiteral(text); + if (boolValue !== null) return boolValue; + if (text.toLowerCase() === 'null') return null; + return rawValue; + } + case 'number': + if (INTEGER_RE.test(text) || FLOAT_RE.test(text)) { + const parsed = Number(text); + return Number.isFinite(parsed) ? parsed : rawValue; + } + if (text.toLowerCase() === 'null') return null; + return rawValue; + case 'array': + case 'object': + case 'uuid': + case 'binary': + case 'maxKey': + case 'minKey': + if (text.toLowerCase() === 'null') return null; + return rawValue; + case 'string': + case 'nullish': + default: + if (String(columnName || '').trim() === '_id' && HEX24_RE.test(text)) { + return { $oid: text.toLowerCase() }; + } + return rawValue; + } +}; + const splitTopLevelComma = (raw: string): string[] => { const text = String(raw || ''); const result: string[] = []; @@ -1098,4 +1423,3 @@ export const convertMongoShellToJsonCommand = (raw: string): ShellConvertResult }; } }; - diff --git a/internal/db/mongodb_impl.go b/internal/db/mongodb_impl.go index e6e2e19..5d7f4c3 100644 --- a/internal/db/mongodb_impl.go +++ b/internal/db/mongodb_impl.go @@ -4,6 +4,7 @@ package db import ( "context" + "encoding/json" "fmt" "net" "net/url" @@ -1058,7 +1059,16 @@ func (m *MongoDB) execCount(ctx context.Context, cmd bson.D) ([]map[string]inter // convertBsonValue 将 BSON 特殊类型转换为前端可读的 JSON 友好值 func convertBsonValue(v interface{}) interface{} { switch val := v.(type) { + case map[string]interface{}: + result := make(map[string]interface{}, len(val)) + for k, v2 := range val { + result[k] = convertBsonValue(v2) + } + return result case bson.ObjectID: + if converted, ok := encodeMongoExtendedJSONFieldValue(val); ok { + return converted + } return val.Hex() case bson.M: result := make(map[string]interface{}, len(val)) @@ -1078,11 +1088,75 @@ func convertBsonValue(v interface{}) interface{} { result[i] = convertBsonValue(v2) } return result + case []interface{}: + result := make([]interface{}, len(val)) + for i, v2 := range val { + result[i] = convertBsonValue(v2) + } + return result default: + if !shouldEncodeMongoExtendedJSONFieldValue(v) { + return v + } + if converted, ok := encodeMongoExtendedJSONFieldValue(v); ok { + return converted + } return v } } +func shouldEncodeMongoExtendedJSONFieldValue(v interface{}) bool { + switch v.(type) { + case bson.DateTime, + bson.Decimal128, + bson.Binary, + bson.Regex, + bson.Timestamp, + bson.MaxKey, + bson.MinKey, + bson.Undefined, + int32, + int64, + []byte, + time.Time: + return true + default: + return false + } +} + +func encodeMongoExtendedJSONFieldValue(v interface{}) (interface{}, bool) { + payload, err := bson.MarshalExtJSON(bson.M{"v": v}, true, false) + if err != nil { + return nil, false + } + + var wrapped map[string]interface{} + if err := json.Unmarshal(payload, &wrapped); err != nil { + return nil, false + } + + converted, ok := wrapped["v"] + return converted, ok +} + +func decodeMongoExtendedJSONFieldValue(v interface{}) interface{} { + payload, err := json.Marshal(map[string]interface{}{"v": v}) + if err != nil { + return v + } + + var wrapped bson.M + if err := bson.UnmarshalExtJSON(payload, false, &wrapped); err != nil { + return v + } + + if converted, ok := wrapped["v"]; ok { + return converted + } + return v +} + func (m *MongoDB) Exec(query string) (int64, error) { _, _, err := m.Query(query) if err != nil { @@ -1220,7 +1294,7 @@ func (m *MongoDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDef func copyMongoChangeDocument(row map[string]interface{}) bson.M { doc := bson.M{} for k, v := range row { - doc[k] = v + doc[k] = decodeMongoExtendedJSONFieldValue(v) } return doc } @@ -1228,46 +1302,11 @@ func copyMongoChangeDocument(row map[string]interface{}) bson.M { func buildMongoChangeFilter(row map[string]interface{}) bson.M { filter := bson.M{} for k, v := range row { - filter[k] = normalizeMongoChangeFilterValue(k, v) + filter[k] = decodeMongoExtendedJSONFieldValue(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 { @@ -1300,10 +1339,7 @@ func (m *MongoDB) ApplyChanges(tableName string, changes connection.ChangeSet) e return fmt.Errorf("更新操作需要主键条件") } - updateDoc := bson.M{"$set": bson.M{}} - for k, v := range update.Values { - updateDoc["$set"].(bson.M)[k] = v - } + updateDoc := bson.M{"$set": copyMongoChangeDocument(update.Values)} result, err := collection.UpdateOne(ctx, filter, updateDoc) if err != nil { diff --git a/internal/db/mongodb_impl_uri_test.go b/internal/db/mongodb_impl_uri_test.go index d69292e..ed20d01 100644 --- a/internal/db/mongodb_impl_uri_test.go +++ b/internal/db/mongodb_impl_uri_test.go @@ -128,3 +128,138 @@ func TestCopyMongoChangeDocument_LeavesInsertIDStringUntouched(t *testing.T) { t.Fatalf("insert _id string should stay string, got %T %v", doc["_id"], doc["_id"]) } } + +func TestConvertBsonValue_EncodesMongoTypedValues(t *testing.T) { + const oidHex = "507f1f77bcf86cd799439011" + oid, err := bson.ObjectIDFromHex(oidHex) + if err != nil { + t.Fatal(err) + } + decimalValue, err := bson.ParseDecimal128("12.34") + if err != nil { + t.Fatal(err) + } + + converted, ok := convertBsonValue(bson.M{ + "_id": oid, + "createdAt": bson.DateTime(1719100800000), + "count32": int32(7), + "count64": int64(8), + "ratio": 1.5, + "price": decimalValue, + "uid": bson.Binary{ + Subtype: 0x04, + Data: []byte{0x12, 0x34, 0x56, 0x78, 0x12, 0x34, 0x56, 0x78, 0x12, 0x34, 0x56, 0x78, 0x12, 0x34, 0x56, 0x78}, + }, + "nested": bson.M{ + "innerId": oid, + }, + "items": bson.A{int32(1), int64(2)}, + }).(map[string]interface{}) + if !ok { + t.Fatalf("expected converted document map, got %T", converted) + } + + if converted["_id"].(map[string]interface{})["$oid"] != oidHex { + t.Fatalf("unexpected ObjectID wrapper: %#v", converted["_id"]) + } + if converted["createdAt"].(map[string]interface{})["$date"].(map[string]interface{})["$numberLong"] != "1719100800000" { + t.Fatalf("unexpected date wrapper: %#v", converted["createdAt"]) + } + if converted["count32"].(map[string]interface{})["$numberInt"] != "7" { + t.Fatalf("unexpected int32 wrapper: %#v", converted["count32"]) + } + if converted["count64"].(map[string]interface{})["$numberLong"] != "8" { + t.Fatalf("unexpected int64 wrapper: %#v", converted["count64"]) + } + if converted["ratio"] != 1.5 { + t.Fatalf("plain double should stay float64, got %T %#v", converted["ratio"], converted["ratio"]) + } + if converted["price"].(map[string]interface{})["$numberDecimal"] != "12.34" { + t.Fatalf("unexpected decimal wrapper: %#v", converted["price"]) + } + if converted["uid"].(map[string]interface{})["$binary"].(map[string]interface{})["base64"] != "EjRWeBI0VngSNFZ4EjRWeA==" { + t.Fatalf("unexpected binary wrapper: %#v", converted["uid"]) + } + + nestedDoc, ok := converted["nested"].(map[string]interface{}) + if !ok { + t.Fatalf("expected nested map, got %T", converted["nested"]) + } + if nestedDoc["innerId"].(map[string]interface{})["$oid"] != oidHex { + t.Fatalf("unexpected nested ObjectID wrapper: %#v", nestedDoc["innerId"]) + } + + items, ok := converted["items"].([]interface{}) + if !ok || len(items) != 2 { + t.Fatalf("unexpected items wrapper: %#v", converted["items"]) + } + if items[0].(map[string]interface{})["$numberInt"] != "1" || items[1].(map[string]interface{})["$numberLong"] != "2" { + t.Fatalf("unexpected numeric array wrappers: %#v", items) + } +} + +func TestCopyMongoChangeDocument_DecodesExtendedJSONWrappers(t *testing.T) { + doc := copyMongoChangeDocument(map[string]interface{}{ + "_id": map[string]interface{}{"$oid": "507f1f77bcf86cd799439011"}, + "createdAt": map[string]interface{}{"$date": map[string]interface{}{"$numberLong": "1719100800000"}}, + "count32": map[string]interface{}{"$numberInt": "7"}, + "count64": map[string]interface{}{"$numberLong": "8"}, + "ratio": map[string]interface{}{"$numberDouble": "1.5"}, + "price": map[string]interface{}{"$numberDecimal": "12.34"}, + "uid": map[string]interface{}{ + "$binary": map[string]interface{}{ + "base64": "EjRWeBI0VngSNFZ4EjRWeA==", + "subType": "04", + }, + }, + "nested": map[string]interface{}{ + "innerId": map[string]interface{}{"$oid": "507f1f77bcf86cd799439012"}, + }, + "items": []interface{}{ + map[string]interface{}{"$numberInt": "1"}, + map[string]interface{}{"$numberLong": "2"}, + }, + }) + + if _, ok := doc["_id"].(bson.ObjectID); !ok { + t.Fatalf("expected _id to decode to bson.ObjectID, got %T", doc["_id"]) + } + if got, ok := doc["createdAt"].(bson.DateTime); !ok || got != bson.DateTime(1719100800000) { + t.Fatalf("expected createdAt bson.DateTime, got %T %#v", doc["createdAt"], doc["createdAt"]) + } + if got, ok := doc["count32"].(int32); !ok || got != 7 { + t.Fatalf("expected count32 int32, got %T %#v", doc["count32"], doc["count32"]) + } + if got, ok := doc["count64"].(int64); !ok || got != 8 { + t.Fatalf("expected count64 int64, got %T %#v", doc["count64"], doc["count64"]) + } + if got, ok := doc["ratio"].(float64); !ok || got != 1.5 { + t.Fatalf("expected ratio float64, got %T %#v", doc["ratio"], doc["ratio"]) + } + if _, ok := doc["price"].(bson.Decimal128); !ok { + t.Fatalf("expected price bson.Decimal128, got %T", doc["price"]) + } + if binaryValue, ok := doc["uid"].(bson.Binary); !ok || binaryValue.Subtype != 0x04 || len(binaryValue.Data) != 16 { + t.Fatalf("expected uid bson.Binary UUID, got %T %#v", doc["uid"], doc["uid"]) + } + + nestedDoc, ok := doc["nested"].(bson.D) + if !ok || len(nestedDoc) != 1 || nestedDoc[0].Key != "innerId" { + t.Fatalf("expected nested bson.D, got %T %#v", doc["nested"], doc["nested"]) + } + if _, ok := nestedDoc[0].Value.(bson.ObjectID); !ok { + t.Fatalf("expected nested innerId ObjectID, got %T", nestedDoc[0].Value) + } + + items, ok := doc["items"].(bson.A) + if !ok || len(items) != 2 { + t.Fatalf("expected items bson.A, got %T %#v", doc["items"], doc["items"]) + } + if got, ok := items[0].(int32); !ok || got != 1 { + t.Fatalf("expected items[0] int32, got %T %#v", items[0], items[0]) + } + if got, ok := items[1].(int64); !ok || got != 2 { + t.Fatalf("expected items[1] int64, got %T %#v", items[1], items[1]) + } +} diff --git a/internal/db/mongodb_impl_v1.go b/internal/db/mongodb_impl_v1.go index ed49ed6..accf9b5 100644 --- a/internal/db/mongodb_impl_v1.go +++ b/internal/db/mongodb_impl_v1.go @@ -4,6 +4,7 @@ package db import ( "context" + "encoding/json" "fmt" "net" "net/url" @@ -1061,7 +1062,16 @@ func (m *MongoDBV1) execCount(ctx context.Context, cmd bson.D) ([]map[string]int // convertBsonValue 将 BSON 特殊类型转换为前端可读的 JSON 友好值 func convertBsonValue(v interface{}) interface{} { switch val := v.(type) { + case map[string]interface{}: + result := make(map[string]interface{}, len(val)) + for k, v2 := range val { + result[k] = convertBsonValue(v2) + } + return result case primitive.ObjectID: + if converted, ok := encodeMongoExtendedJSONFieldValue(val); ok { + return converted + } return val.Hex() case bson.M: result := make(map[string]interface{}, len(val)) @@ -1081,11 +1091,75 @@ func convertBsonValue(v interface{}) interface{} { result[i] = convertBsonValue(v2) } return result + case []interface{}: + result := make([]interface{}, len(val)) + for i, v2 := range val { + result[i] = convertBsonValue(v2) + } + return result default: + if !shouldEncodeMongoExtendedJSONFieldValue(v) { + return v + } + if converted, ok := encodeMongoExtendedJSONFieldValue(v); ok { + return converted + } return v } } +func shouldEncodeMongoExtendedJSONFieldValue(v interface{}) bool { + switch v.(type) { + case primitive.DateTime, + primitive.Decimal128, + primitive.Binary, + primitive.Regex, + primitive.Timestamp, + primitive.MaxKey, + primitive.MinKey, + primitive.Undefined, + int32, + int64, + []byte, + time.Time: + return true + default: + return false + } +} + +func encodeMongoExtendedJSONFieldValue(v interface{}) (interface{}, bool) { + payload, err := bson.MarshalExtJSON(bson.M{"v": v}, true, false) + if err != nil { + return nil, false + } + + var wrapped map[string]interface{} + if err := json.Unmarshal(payload, &wrapped); err != nil { + return nil, false + } + + converted, ok := wrapped["v"] + return converted, ok +} + +func decodeMongoExtendedJSONFieldValue(v interface{}) interface{} { + payload, err := json.Marshal(map[string]interface{}{"v": v}) + if err != nil { + return v + } + + var wrapped bson.M + if err := bson.UnmarshalExtJSON(payload, false, &wrapped); err != nil { + return v + } + + if converted, ok := wrapped["v"]; ok { + return converted + } + return v +} + func (m *MongoDBV1) Exec(query string) (int64, error) { _, _, err := m.Query(query) if err != nil { @@ -1223,7 +1297,7 @@ func (m *MongoDBV1) GetTriggers(dbName, tableName string) ([]connection.TriggerD func copyMongoChangeDocument(row map[string]interface{}) bson.M { doc := bson.M{} for k, v := range row { - doc[k] = v + doc[k] = decodeMongoExtendedJSONFieldValue(v) } return doc } @@ -1231,46 +1305,11 @@ func copyMongoChangeDocument(row map[string]interface{}) bson.M { func buildMongoChangeFilter(row map[string]interface{}) bson.M { filter := bson.M{} for k, v := range row { - filter[k] = normalizeMongoChangeFilterValue(k, v) + filter[k] = decodeMongoExtendedJSONFieldValue(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 { @@ -1303,10 +1342,7 @@ func (m *MongoDBV1) ApplyChanges(tableName string, changes connection.ChangeSet) return fmt.Errorf("更新操作需要主键条件") } - updateDoc := bson.M{"$set": bson.M{}} - for k, v := range update.Values { - updateDoc["$set"].(bson.M)[k] = v - } + updateDoc := bson.M{"$set": copyMongoChangeDocument(update.Values)} result, err := collection.UpdateOne(ctx, filter, updateDoc) if err != nil { diff --git a/internal/db/mongodb_impl_v1_uri_test.go b/internal/db/mongodb_impl_v1_uri_test.go index b90deac..3a95726 100644 --- a/internal/db/mongodb_impl_v1_uri_test.go +++ b/internal/db/mongodb_impl_v1_uri_test.go @@ -87,3 +87,138 @@ func TestCopyMongoChangeDocumentV1_LeavesInsertIDStringUntouched(t *testing.T) { t.Fatalf("insert _id string should stay string, got %T %v", doc["_id"], doc["_id"]) } } + +func TestConvertBsonValueV1_EncodesMongoTypedValues(t *testing.T) { + const oidHex = "507f1f77bcf86cd799439011" + oid, err := primitive.ObjectIDFromHex(oidHex) + if err != nil { + t.Fatal(err) + } + decimalValue, err := primitive.ParseDecimal128("12.34") + if err != nil { + t.Fatal(err) + } + + converted, ok := convertBsonValue(bson.M{ + "_id": oid, + "createdAt": primitive.DateTime(1719100800000), + "count32": int32(7), + "count64": int64(8), + "ratio": 1.5, + "price": decimalValue, + "uid": primitive.Binary{ + Subtype: 0x04, + Data: []byte{0x12, 0x34, 0x56, 0x78, 0x12, 0x34, 0x56, 0x78, 0x12, 0x34, 0x56, 0x78, 0x12, 0x34, 0x56, 0x78}, + }, + "nested": bson.M{ + "innerId": oid, + }, + "items": bson.A{int32(1), int64(2)}, + }).(map[string]interface{}) + if !ok { + t.Fatalf("expected converted document map, got %T", converted) + } + + if converted["_id"].(map[string]interface{})["$oid"] != oidHex { + t.Fatalf("unexpected ObjectID wrapper: %#v", converted["_id"]) + } + if converted["createdAt"].(map[string]interface{})["$date"].(map[string]interface{})["$numberLong"] != "1719100800000" { + t.Fatalf("unexpected date wrapper: %#v", converted["createdAt"]) + } + if converted["count32"].(map[string]interface{})["$numberInt"] != "7" { + t.Fatalf("unexpected int32 wrapper: %#v", converted["count32"]) + } + if converted["count64"].(map[string]interface{})["$numberLong"] != "8" { + t.Fatalf("unexpected int64 wrapper: %#v", converted["count64"]) + } + if converted["ratio"] != 1.5 { + t.Fatalf("plain double should stay float64, got %T %#v", converted["ratio"], converted["ratio"]) + } + if converted["price"].(map[string]interface{})["$numberDecimal"] != "12.34" { + t.Fatalf("unexpected decimal wrapper: %#v", converted["price"]) + } + if converted["uid"].(map[string]interface{})["$binary"].(map[string]interface{})["base64"] != "EjRWeBI0VngSNFZ4EjRWeA==" { + t.Fatalf("unexpected binary wrapper: %#v", converted["uid"]) + } + + nestedDoc, ok := converted["nested"].(map[string]interface{}) + if !ok { + t.Fatalf("expected nested map, got %T", converted["nested"]) + } + if nestedDoc["innerId"].(map[string]interface{})["$oid"] != oidHex { + t.Fatalf("unexpected nested ObjectID wrapper: %#v", nestedDoc["innerId"]) + } + + items, ok := converted["items"].([]interface{}) + if !ok || len(items) != 2 { + t.Fatalf("unexpected items wrapper: %#v", converted["items"]) + } + if items[0].(map[string]interface{})["$numberInt"] != "1" || items[1].(map[string]interface{})["$numberLong"] != "2" { + t.Fatalf("unexpected numeric array wrappers: %#v", items) + } +} + +func TestCopyMongoChangeDocumentV1_DecodesExtendedJSONWrappers(t *testing.T) { + doc := copyMongoChangeDocument(map[string]interface{}{ + "_id": map[string]interface{}{"$oid": "507f1f77bcf86cd799439011"}, + "createdAt": map[string]interface{}{"$date": map[string]interface{}{"$numberLong": "1719100800000"}}, + "count32": map[string]interface{}{"$numberInt": "7"}, + "count64": map[string]interface{}{"$numberLong": "8"}, + "ratio": map[string]interface{}{"$numberDouble": "1.5"}, + "price": map[string]interface{}{"$numberDecimal": "12.34"}, + "uid": map[string]interface{}{ + "$binary": map[string]interface{}{ + "base64": "EjRWeBI0VngSNFZ4EjRWeA==", + "subType": "04", + }, + }, + "nested": map[string]interface{}{ + "innerId": map[string]interface{}{"$oid": "507f1f77bcf86cd799439012"}, + }, + "items": []interface{}{ + map[string]interface{}{"$numberInt": "1"}, + map[string]interface{}{"$numberLong": "2"}, + }, + }) + + if _, ok := doc["_id"].(primitive.ObjectID); !ok { + t.Fatalf("expected _id to decode to primitive.ObjectID, got %T", doc["_id"]) + } + if got, ok := doc["createdAt"].(primitive.DateTime); !ok || got != primitive.DateTime(1719100800000) { + t.Fatalf("expected createdAt primitive.DateTime, got %T %#v", doc["createdAt"], doc["createdAt"]) + } + if got, ok := doc["count32"].(int32); !ok || got != 7 { + t.Fatalf("expected count32 int32, got %T %#v", doc["count32"], doc["count32"]) + } + if got, ok := doc["count64"].(int64); !ok || got != 8 { + t.Fatalf("expected count64 int64, got %T %#v", doc["count64"], doc["count64"]) + } + if got, ok := doc["ratio"].(float64); !ok || got != 1.5 { + t.Fatalf("expected ratio float64, got %T %#v", doc["ratio"], doc["ratio"]) + } + if _, ok := doc["price"].(primitive.Decimal128); !ok { + t.Fatalf("expected price primitive.Decimal128, got %T", doc["price"]) + } + if binaryValue, ok := doc["uid"].(primitive.Binary); !ok || binaryValue.Subtype != 0x04 || len(binaryValue.Data) != 16 { + t.Fatalf("expected uid primitive.Binary UUID, got %T %#v", doc["uid"], doc["uid"]) + } + + nestedDoc, ok := doc["nested"].(primitive.M) + if !ok { + t.Fatalf("expected nested primitive.M, got %T %#v", doc["nested"], doc["nested"]) + } + if _, ok := nestedDoc["innerId"].(primitive.ObjectID); !ok { + t.Fatalf("expected nested innerId ObjectID, got %T", nestedDoc["innerId"]) + } + + items, ok := doc["items"].(bson.A) + if !ok || len(items) != 2 { + t.Fatalf("expected items bson.A, got %T %#v", doc["items"], doc["items"]) + } + if got, ok := items[0].(int32); !ok || got != 1 { + t.Fatalf("expected items[0] int32, got %T %#v", items[0], items[0]) + } + if got, ok := items[1].(int64); !ok || got != 2 { + t.Fatalf("expected items[1] int64, got %T %#v", items[1], items[1]) + } +}