mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-02 13:12:53 +08:00
🐛 fix(mongodb): 修复 DataGrid 编辑后 BSON 类型丢失
- 为 MongoDB 结果展示、单元格编辑和行编辑接入类型感知格式化与解析 - 支持 ObjectId、日期、Int32、Int64、Double、Decimal128、UUID 等常见类型保真 - 统一 v1/v2 驱动查询结果的 Extended JSON 输出与 ApplyChanges BSON 恢复 - 补充前端提交链路与后端类型转换回归测试
This commit is contained in:
@@ -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: [],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user