🐛 fix(mongodb): 修复 DataGrid 编辑后 BSON 类型丢失

- 为 MongoDB 结果展示、单元格编辑和行编辑接入类型感知格式化与解析
- 支持 ObjectId、日期、Int32、Int64、Double、Decimal128、UUID 等常见类型保真
- 统一 v1/v2 驱动查询结果的 Extended JSON 输出与 ApplyChanges BSON 恢复
- 补充前端提交链路与后端类型转换回归测试
This commit is contained in:
Syngnat
2026-06-23 12:14:27 +08:00
parent bc63311003
commit 8da8cc7f91
9 changed files with 899 additions and 102 deletions

View File

@@ -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: [],

View File

@@ -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<DataGridProps> = ({
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<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)
), [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<DataGridProps> = ({
openBatchEditModal,
closeBatchEditModal,
} = useDataGridModalEditors({
toEditableText,
toEditableText: mongoAwareEditableText,
looksLikeJsonText,
});
const [virtualEditingCell, setVirtualEditingCell] = useState<VirtualEditingCellState | null>(null);
@@ -699,7 +728,7 @@ const DataGrid: React.FC<DataGridProps> = ({
updateFocusedCell,
handleDataPanelFormatJson,
} = useDataGridPreviewPanel({
toEditableText,
toEditableText: mongoAwareEditableText,
looksLikeJsonText,
normalizeDateTimeString,
});
@@ -954,6 +983,9 @@ const DataGrid: React.FC<DataGridProps> = ({
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<DataGridProps> = ({
return value;
},
[columnMetaMap, columnMetaMapByLowerName, dbType]
[columnMetaMap, columnMetaMapByLowerName, dbType, isMongoDBConnection]
);
const openTableByName = useCallback((nextTableName: string) => {
@@ -1567,19 +1599,23 @@ const DataGrid: React.FC<DataGridProps> = ({
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<string, any> = {};
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<DataGridProps> = ({
}
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<DataGridProps> = ({
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<DataGridProps> = ({
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<DataGridProps> = ({
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<DataGridProps> = ({
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<DataGridProps> = ({
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<DataGridProps> = ({
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<DataGridProps> = ({
const convertedValues: Record<string, any> = {};
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<DataGridProps> = ({
return;
}
const baseRawMap = rowEditorBaseRawRef.current || {};
const patch: Record<string, any> = {};
visibleColumnNames.forEach((col) => {
if (!isWritableResultColumn(col, effectiveEditLocator)) return;
@@ -2222,6 +2261,8 @@ const DataGrid: React.FC<DataGridProps> = ({
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<DataGridProps> = ({
});
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;

View File

@@ -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;

View File

@@ -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',
},
});
});
});

View File

@@ -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<string, unknown> => (
!!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<string, unknown>)?.$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
};
}
};

View File

@@ -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 {

View File

@@ -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])
}
}

View File

@@ -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 {

View File

@@ -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])
}
}