diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue index 6c21a0b4f..ac7a93171 100644 --- a/apps/desktop/src/components/document/DocumentBrowser.vue +++ b/apps/desktop/src/components/document/DocumentBrowser.vue @@ -84,6 +84,7 @@ type ViewMode = "document" | "table"; const documents = ref([]); const copyDocuments = ref([]); +const mongoCopyDocumentsAvailable = ref(false); const lastGridColumns = ref([]); const total = ref(undefined); const totalIsExact = ref(true); @@ -112,6 +113,7 @@ const sortInput = ref(""); const filterInputRef = ref(); const sortInputRef = ref(); const dataGridRef = ref>(); +const mongoUpdateTarget = computed(() => (props.databaseType === "mongodb" && mongoCopyDocumentsAvailable.value ? { collection: props.collection, idColumn: "_id" as const } : undefined)); const documentViewerRef = ref(); const documentSearchInputRef = ref(); const documentSearchOpen = ref(false); @@ -822,9 +824,11 @@ async function load() { } }) : result.documents.map(asRecord); - const nextCopyDocuments = result.extended_documents?.length === nextDocuments.length ? result.extended_documents.map(asRecord) : nextDocuments; + const hasTypePreservingCopyDocuments = result.extended_documents?.length === nextDocuments.length; + const nextCopyDocuments = hasTypePreservingCopyDocuments ? result.extended_documents!.map(asRecord) : nextDocuments; documents.value = nextDocuments; copyDocuments.value = nextCopyDocuments; + mongoCopyDocumentsAvailable.value = hasTypePreservingCopyDocuments; if (nextDocuments.length > 0) { const keySet = new Set(); keySet.add("_id"); @@ -1569,6 +1573,7 @@ defineExpose({ focusSearch }); :result="gridResult" context="results" :database-type="props.databaseType" + :mongo-update-target="mongoUpdateTarget" editable :custom-save-handler="customSaveHandler" :loading="loading" diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 96316fa65..970b11f77 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -177,7 +177,7 @@ import { } from "@/lib/dataGrid/dataGridContextMenu"; import { useToast } from "@/composables/useToast"; -import { useDataGridExport } from "@/composables/useDataGridExport"; +import { useDataGridExport, type MongoCopyUpdateTarget } from "@/composables/useDataGridExport"; import { eventTargetAllowsNativeClipboard, isPlainClipboardShortcut, readTextFromClipboard } from "@/lib/common/clipboard"; import { claimDataGridPaste, clearDataGridClipboardCopy, parseDataGridClipboard, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard"; import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize"; @@ -304,6 +304,7 @@ interface DataGridProps { allExportResults?: Array<{ sheetName: string; result: QueryResult; sql?: string }>; exportFileBaseName?: string; customSaveHandler?: import("@/composables/useDataGridEditor").CustomSaveHandler; + mongoUpdateTarget?: MongoCopyUpdateTarget; queryEditabilityReason?: QueryEditabilityReason; allowInsertRows?: boolean; allowDeleteRows?: boolean; @@ -5103,6 +5104,7 @@ const { exportSql: computed(() => props.exportSql), tableMeta: computed(() => (props.tableMeta ? { ...props.tableMeta } : undefined)), copyInsertTargetLabel: computed(() => props.tableMeta?.tableName ?? props.customSaveHandler?.targetLabel), + mongoUpdateTarget: computed(() => props.mongoUpdateTarget), databaseType: computed(() => props.databaseType), connectionId: computed(() => props.connectionId), database: computed(() => props.executionDatabase ?? props.database), diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 49bbc9072..6a13bf39c 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -77,7 +77,7 @@ import { codeMirrorSqlDialect, codeMirrorSqlDialectForConnection, effectiveDatab import { chartableColumnIndexes } from "@/lib/dataGrid/chartData"; import { elasticsearchJsonResponseForResult } from "@/lib/elasticsearch/elasticsearchJsonResponse"; import * as api from "@/lib/backend/api"; -import { applyMongoGridChangesToDocument, buildMongoUpdateDocument, formatMongoShellLiteral, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues"; +import { applyMongoGridChangesToDocument, applyMongoGridChangesToDocumentBaseline, buildMongoUpdateDocument, formatMongoShellLiteral, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues"; import type { SqlExecutionOverride } from "@/lib/sql/sqlExecutionTarget"; import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort"; import { DATA_GRID_COMPACT_TOPBAR_WIDTH, type DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar"; @@ -440,16 +440,22 @@ const mongoQueryResultSaveHandler = computed(() = // Replace the raw array only after every backend update succeeds, keeping // the grid and JSON preview atomic when a multi-row save partially fails. - const replacements = new Map(); + if (tab.resultLocalSortOriginalMongoDocuments) { + tab.resultLocalSortOriginalMongoDocuments = applyMongoGridChangesToDocumentBaseline(tab.resultLocalSortOriginalMongoDocuments, documents, dirtyRows, columns); + } tab.result!.mongo_documents = documents.map((document, rowIdx) => { const changes = dirtyRows.get(rowIdx); - if (!changes) return document; - const updated = applyMongoGridChangesToDocument(document, changes, columns); - replacements.set(document, updated); - return updated; + return changes ? applyMongoGridChangesToDocument(document, changes, columns) : document; }); - if (tab.resultLocalSortOriginalMongoDocuments) { - tab.resultLocalSortOriginalMongoDocuments = tab.resultLocalSortOriginalMongoDocuments.map((document) => replacements.get(document) ?? document); + const copyDocuments = tab.result!.mongo_copy_documents; + if (copyDocuments) { + if (tab.resultLocalSortOriginalMongoCopyDocuments) { + tab.resultLocalSortOriginalMongoCopyDocuments = applyMongoGridChangesToDocumentBaseline(tab.resultLocalSortOriginalMongoCopyDocuments, copyDocuments, dirtyRows, columns); + } + tab.result!.mongo_copy_documents = copyDocuments.map((document, rowIdx) => { + const changes = dirtyRows.get(rowIdx); + return changes ? applyMongoGridChangesToDocument(document, changes, columns) : document; + }); } }; @@ -1168,6 +1174,7 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand :editable="!!activeTab.queryAnalysis || !!mongoQueryResultSaveHandler" :source-columns="activeTab.querySourceColumns" :custom-save-handler="mongoQueryResultSaveHandler" + :mongo-update-target="mongoQueryResultSaveHandler && activeTab.result.mongo_copy_documents?.length === activeTab.result.rows.length ? activeTab.mongoEditTarget : undefined" :query-editability-reason="activeTab.queryEditabilityReason" :allow-insert-rows="activeTab.queryAnalysis?.allowInsert !== false && activeTab.queryAnalysis?.allowInsertDelete !== false" :allow-delete-rows="activeTab.queryAnalysis?.allowInsertDelete !== false" diff --git a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts index b37932901..8f7503cba 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts @@ -56,13 +56,24 @@ function row(data: unknown[]) { }; } -function createMongoExportState(options: { columns: string[]; item: ReturnType & { sourceIndex: number }; mongoDocuments: unknown[]; selectedCellMatrix?: CellSelectionMatrix }) { +function createMongoExportState(options: { + columns: string[]; + item: ReturnType & { sourceIndex: number }; + items?: Array & { sourceIndex: number }>; + mongoDocuments: unknown[]; + selectedCellMatrix?: CellSelectionMatrix; + selectedRowIds?: Set; + mongoUpdateTarget?: false; +}) { + const items = options.items ?? [options.item]; + const selectedRowIds = options.selectedRowIds ?? new Set(); const state: UseDataGridExportOptions = { columns: computed(() => options.columns), - displayItems: computed(() => [options.item]), + displayItems: computed(() => items), sql: computed(() => undefined), tableMeta: computed(() => undefined), copyInsertTargetLabel: computed(() => "documents"), + mongoUpdateTarget: computed(() => (options.mongoUpdateTarget === false ? undefined : { collection: "documents", idColumn: "_id" })), databaseType: computed(() => "mongodb"), connectionId: computed(() => "connection-1"), database: computed(() => "dbx"), @@ -78,9 +89,9 @@ function createMongoExportState(options: { columns: string[]; item: ReturnType options.selectedCellMatrix ?? null), selectedRange: computed(() => null), contextCell: ref({ rowId: options.item.id, rowIndex: 0, col: -1 }), - getRowItem: (rowId) => (rowId === options.item.id ? options.item : undefined), - selectedRowIds: ref(new Set()), - hasRowSelection: computed(() => false), + getRowItem: (rowId) => items.find((item) => item.id === rowId), + selectedRowIds: ref(selectedRowIds), + hasRowSelection: computed(() => selectedRowIds.size > 0), }; return useDataGridExport(state); } @@ -337,6 +348,84 @@ describe("useDataGridExport prepared row statements", () => { });`); }); + it("copies a Mongo row as updateOne while preserving BSON types and missing fields", async () => { + const item = { + ...row(["507f1f77bcf86cd799439011", "123", 'NumberLong("9007199254740993")', null, null, '{"role":"maintainer"}', "12.34", "AQI="]), + sourceIndex: 0, + }; + item.isDirtyCol = [false, false, false, false, false, true, false, false]; + const state = createMongoExportState({ + columns: ["_id", "numericText", "counter", "nullable", "missing", "profile", "decimal", "payload"], + item, + mongoDocuments: [ + { + _id: { $oid: "507f1f77bcf86cd799439011" }, + numericText: "123", + counter: { $numberLong: "9007199254740993" }, + nullable: null, + profile: { role: "admin" }, + decimal: { $numberDecimal: "12.34" }, + payload: { $binary: { base64: "AQI=", subType: "00" } }, + }, + ], + }); + + expect(state.canCopyRowAsUpdate.value).toBe(true); + await state.copyRowAsUpdate(); + + expect(buildDataGridCopyUpdateStatements).not.toHaveBeenCalled(); + const copied = vi.mocked(copyToClipboard).mock.calls[0]?.[0] ?? ""; + expect(copied).toContain('db.getCollection("documents")'); + expect(copied).toContain(".updateOne("); + expect(copied).toContain('"_id": ObjectId("507f1f77bcf86cd799439011")'); + expect(copied).toContain('"numericText": "123"'); + expect(copied).toContain('"counter": NumberLong("9007199254740993")'); + expect(copied).toContain('"nullable": null'); + expect(copied).toContain('"profile":'); + expect(copied).toContain('"role": "maintainer"'); + expect(copied).toContain('"decimal": EJSON.deserialize('); + expect(copied).toContain('"$numberDecimal": "12.34"'); + expect(copied).toContain('"payload": EJSON.deserialize('); + expect(copied).toContain('"$binary":'); + expect(copied).toContain('"$unset":'); + expect(copied).toContain('"missing": ""'); + }); + + it("does not expose Mongo UPDATE copy without an explicit data-list target", async () => { + const item = { ...row(["507f1f77bcf86cd799439011", "Alice"]), sourceIndex: 0 }; + const state = createMongoExportState({ + columns: ["_id", "name"], + item, + mongoDocuments: [{ _id: { $oid: "507f1f77bcf86cd799439011" }, name: "Alice" }], + mongoUpdateTarget: false, + }); + + expect(state.canCopyRowAsUpdate.value).toBe(false); + await state.copyRowAsUpdate(); + expect(copyToClipboard).not.toHaveBeenCalled(); + }); + + it("copies selected Mongo rows as separate updates using each sorted source document", async () => { + const first = { ...row(["second-id", "Second"]), id: 1, sourceIndex: 1 }; + const second = { ...row(["first-id", "First"]), id: 2, sourceIndex: 0 }; + const state = createMongoExportState({ + columns: ["_id", "name"], + item: first, + items: [first, second], + mongoDocuments: [ + { _id: "first-id", name: "First" }, + { _id: "second-id", name: "Second" }, + ], + selectedRowIds: new Set([1, 2]), + }); + + await state.copyRowAsUpdate(); + + const copied = vi.mocked(copyToClipboard).mock.calls[0]?.[0] ?? ""; + expect(copied.match(/\.updateOne\(/g)).toHaveLength(2); + expect(copied.indexOf('"_id": "second-id"')).toBeLessThan(copied.indexOf('"_id": "first-id"')); + }); + it("preserves original Mongo types while limiting INSERT to the selected fields", async () => { const item = { ...row(["123", "true", '{"kind":"literal"}']), sourceIndex: 0 }; const state = createMongoExportState({ diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index d60e73939..dbaac9a3a 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -13,7 +13,7 @@ import { formatSqlInsert, formatTsv } from "@/lib/export/exportFormats"; import { uuid } from "@/lib/common/utils"; import { useSettingsStore } from "@/stores/settingsStore"; import { expandNestedJsonStringsForCopy } from "@/lib/common/jsonCopyValue"; -import { buildMongoCopyDocumentFromOriginal, buildMongoCopyInsertDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues"; +import { buildMongoCopyDocumentFromOriginal, buildMongoCopyInsertDocument, buildMongoCopyUpdateDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues"; import { formatMongoShellText } from "@/lib/mongo/mongoFormatter"; import type { DatabaseType, QueryResult } from "@/types/database"; import type { QueryResultExportRequest } from "@/lib/backend/api"; @@ -51,6 +51,11 @@ interface RowItem { status: string; } +export interface MongoCopyUpdateTarget { + collection: string; + idColumn: "_id"; +} + export interface UseDataGridExportOptions { columns: ComputedRef; displayItems: ComputedRef; @@ -58,6 +63,7 @@ export interface UseDataGridExportOptions { exportSql?: ComputedRef; tableMeta: ComputedRef; copyInsertTargetLabel?: ComputedRef; + mongoUpdateTarget?: ComputedRef; databaseType: ComputedRef; connectionId: ComputedRef; database: ComputedRef; @@ -181,6 +187,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { exportSql: resultExportSql, tableMeta, copyInsertTargetLabel, + mongoUpdateTarget, sourceColumns, databaseType, connectionId, @@ -328,6 +335,28 @@ export function useDataGridExport(options: UseDataGridExportOptions) { }); } + function buildMongoCopyUpdateStatements(rows: RowItem[], target: MongoCopyUpdateTarget): string[] { + const documents = options.mongoDocuments?.value; + if (!documents) return []; + + const copyColumns = effectiveColumns(sourceColumns.value, columns.value).map((column) => column ?? ""); + const statements: string[] = []; + for (const item of rows) { + if (item.sourceIndex === undefined) continue; + const originalDocument = documents[item.sourceIndex]; + if (!originalDocument || typeof originalDocument !== "object" || Array.isArray(originalDocument)) continue; + + const source = originalDocument as Record; + if (!Object.prototype.hasOwnProperty.call(source, target.idColumn)) continue; + const update = buildMongoCopyUpdateDocument(item.data as MongoInputValue[], copyColumns, item.isDirtyCol, originalDocument, target.idColumn); + if (!update) continue; + + const statement = `db.getCollection(${JSON.stringify(target.collection)}).updateOne({${JSON.stringify(target.idColumn)}:${formatMongoShellLiteral(source[target.idColumn])}},${formatMongoShellLiteral(update)});`; + statements.push(formatMongoCopyStatement(statement) ?? statement); + } + return statements; + } + function insertCopyKey(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode): string { return copyInsertKey( { @@ -426,7 +455,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { async function buildCopyInsertStatement(data: CopyInsertData, excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode): Promise { if (databaseType.value === "mongodb") { - return formatMongoCopyInsertStatement( + return formatMongoCopyStatement( buildMongoCopyInsertStatement({ collection: copyInsertTargetLabel?.value || tableMeta.value?.tableName || "collection", columns: data.columns, @@ -633,8 +662,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) { } async function prepareRowAsUpdateStatement(): Promise { - const currentTableMeta = tableMeta.value; - if (!currentTableMeta?.primaryKeys.length) { + const rows = updateEligibleRows(); + if (!rows.length) { setUpdateCopyCache({ key: "", text: "", @@ -643,8 +672,17 @@ export function useDataGridExport(options: UseDataGridExportOptions) { }); return; } - const rows = updateEligibleRows(); - if (!rows.length) { + + if (databaseType.value === "mongodb") { + const target = mongoUpdateTarget?.value; + if (!target) return; + await yieldToMainThread(); + const text = buildMongoCopyUpdateStatements(rows, target).join("\n"); + return text || undefined; + } + + const currentTableMeta = tableMeta.value; + if (!currentTableMeta?.primaryKeys.length) { setUpdateCopyCache({ key: "", text: "", @@ -831,11 +869,17 @@ export function useDataGridExport(options: UseDataGridExportOptions) { } const canCopyRowAsUpdate = computed(() => { - if (!tableMeta.value?.primaryKeys.length) return false; const rows = updateEligibleRows(); if (!rows.length) return false; - if (databaseType.value === "neo4j" || databaseType.value === "tdengine") return false; const saveColumns = effectiveColumns(sourceColumns.value, columns.value); + if (databaseType.value === "mongodb") { + const target = mongoUpdateTarget?.value; + if (!target || !options.mongoDocuments?.value || rows.some((item) => item.sourceIndex === undefined)) return false; + if (findColumnIndex(saveColumns, target.idColumn) === -1) return false; + return saveColumns.some((column) => column && normalizeColumnName(column) !== normalizeColumnName(target.idColumn)); + } + if (!tableMeta.value?.primaryKeys.length) return false; + if (databaseType.value === "neo4j" || databaseType.value === "tdengine") return false; const primaryKeys = tableMeta.value.primaryKeys; if (primaryKeys.some((primaryKey) => findColumnIndex(saveColumns, primaryKey) === -1)) return false; const primaryKeySet = new Set(primaryKeys.map(normalizeColumnName)); @@ -1589,7 +1633,7 @@ function buildMongoCopyInsertStatement(options: { collection: string; columns: s return `${collection}.insertMany(${formatMongoShellLiteral(documents)});`; } -function formatMongoCopyInsertStatement(statement: string | undefined): string | undefined { +function formatMongoCopyStatement(statement: string | undefined): string | undefined { if (!statement) return undefined; try { return formatMongoShellText(statement); diff --git a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts index 5b10b76fd..93ae5a3f7 100644 --- a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts +++ b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts @@ -133,6 +133,7 @@ export function useSidebarDataOpenRuntime() { tab.resultSortMode = undefined; tab.resultLocalSortOriginalRows = undefined; tab.resultLocalSortOriginalMongoDocuments = undefined; + tab.resultLocalSortOriginalMongoCopyDocuments = undefined; tab.resultSortedSql = undefined; tab.resultPageSql = undefined; tab.resultPageLimit = undefined; diff --git a/apps/desktop/src/lib/mongo/mongoDocumentValues.ts b/apps/desktop/src/lib/mongo/mongoDocumentValues.ts index 2b6289414..a7704c8e5 100644 --- a/apps/desktop/src/lib/mongo/mongoDocumentValues.ts +++ b/apps/desktop/src/lib/mongo/mongoDocumentValues.ts @@ -7,6 +7,7 @@ const MONGO_INTEGER_PATTERN = /^-?\d+$/; const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); const MIN_BSON_INT64 = -9223372036854775808n; const MAX_BSON_INT64 = 9223372036854775807n; +const MONGO_EXTENDED_JSON_VALUE_KEYS = new Set(["$binary", "$code", "$date", "$dbPointer", "$maxKey", "$minKey", "$numberDecimal", "$numberDouble", "$numberInt", "$numberLong", "$oid", "$regularExpression", "$symbol", "$timestamp", "$undefined", "$uuid"]); export function mongoShellDateToExtendedJson(value: unknown): unknown { if (typeof value !== "string") return value; @@ -86,6 +87,39 @@ export function buildMongoUpdateDocument(changes: Map, return doc; } +export function buildMongoCopyUpdateDocument(row: MongoInputValue[], columns: string[], dirtyColumns: boolean[], originalDocument?: unknown, idColumn = "_id"): Record | null { + if (!originalDocument || typeof originalDocument !== "object" || Array.isArray(originalDocument)) return null; + + const source = originalDocument as Record; + const setFields: Record = {}; + const unsetFields: Record = {}; + for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) { + const column = columns[columnIndex]; + if (!column || column === idColumn) continue; + + const value = row[columnIndex] ?? null; + if (dirtyColumns[columnIndex]) { + if (value === null) { + unsetFields[column] = ""; + } else { + setFields[column] = parseMongoExistingFieldInputValue(value, source[column]); + } + continue; + } + + if (Object.prototype.hasOwnProperty.call(source, column)) { + setFields[column] = source[column]; + } else { + unsetFields[column] = ""; + } + } + + const update: Record = {}; + if (Object.keys(setFields).length > 0) update.$set = setFields; + if (Object.keys(unsetFields).length > 0) update.$unset = unsetFields; + return Object.keys(update).length > 0 ? update : null; +} + export function applyMongoGridChangesToDocument(document: unknown, changes: Map, columns: string[]): unknown { if (!document || typeof document !== "object" || Array.isArray(document)) return document; @@ -102,6 +136,26 @@ export function applyMongoGridChangesToDocument(document: unknown, changes: Map< return updated; } +function mongoDocumentIdentityKey(document: unknown): string | undefined { + if (!document || typeof document !== "object" || Array.isArray(document)) return undefined; + const object = document as Record; + if (!Object.prototype.hasOwnProperty.call(object, "_id")) return undefined; + return JSON.stringify(object._id); +} + +export function applyMongoGridChangesToDocumentBaseline(baselineDocuments: unknown[], currentDocuments: unknown[], dirtyRows: Map>, columns: string[]): unknown[] { + const changesByDocumentId = new Map>(); + for (const [rowIndex, changes] of dirtyRows) { + const identityKey = mongoDocumentIdentityKey(currentDocuments[rowIndex]); + if (identityKey !== undefined) changesByDocumentId.set(identityKey, changes); + } + return baselineDocuments.map((document) => { + const identityKey = mongoDocumentIdentityKey(document); + const changes = identityKey === undefined ? undefined : changesByDocumentId.get(identityKey); + return changes ? applyMongoGridChangesToDocument(document, changes, columns) : document; + }); +} + export function buildMongoInsertDocument(row: MongoInputValue[], columns: string[]): Record { const doc: Record = {}; for (let ci = 0; ci < columns.length; ci++) { @@ -167,6 +221,9 @@ export function formatMongoShellLiteral(value: unknown): string { if (keys.length === 1 && typeof object.$numberLong === "string") { return `NumberLong(${JSON.stringify(object.$numberLong)})`; } + if ((keys.length === 1 && MONGO_EXTENDED_JSON_VALUE_KEYS.has(keys[0] ?? "")) || (keys.length === 2 && keys.includes("$code") && keys.includes("$scope"))) { + return `EJSON.deserialize(${JSON.stringify(object)})`; + } return `{${keys.map((key) => `${JSON.stringify(key)}:${formatMongoShellLiteral(object[key])}`).join(",")}}`; } return JSON.stringify(String(value)); diff --git a/apps/desktop/src/lib/mongo/mongoShellCommand.ts b/apps/desktop/src/lib/mongo/mongoShellCommand.ts index 9b1c6235a..a5768f1a3 100644 --- a/apps/desktop/src/lib/mongo/mongoShellCommand.ts +++ b/apps/desktop/src/lib/mongo/mongoShellCommand.ts @@ -645,7 +645,7 @@ export function evaluateMongoAggregateSafety(command: MongoAggregateCommand, opt return { allowed: true }; } -export function mongoDocumentsToQueryResult(documents: unknown[], executionTimeMs: number, total: number): QueryResult { +export function mongoDocumentsToQueryResult(documents: unknown[], executionTimeMs: number, total: number, copyDocuments?: unknown[]): QueryResult { const columns: string[] = []; for (const doc of documents) { @@ -667,6 +667,7 @@ export function mongoDocumentsToQueryResult(documents: unknown[], executionTimeM columns, rows, mongo_documents: documents, + ...(copyDocuments?.length === documents.length ? { mongo_copy_documents: copyDocuments } : {}), affected_rows: total, execution_time_ms: Math.max(0, Math.round(executionTimeMs)), truncated: total > documents.length, diff --git a/apps/desktop/src/lib/tabs/tabResultCache.ts b/apps/desktop/src/lib/tabs/tabResultCache.ts index 180edaeb3..caf98f7d4 100644 --- a/apps/desktop/src/lib/tabs/tabResultCache.ts +++ b/apps/desktop/src/lib/tabs/tabResultCache.ts @@ -28,6 +28,7 @@ export interface TabResultSnapshot { */ resultLocalSortOriginalRows?: QueryResult["rows"]; resultLocalSortOriginalMongoDocuments?: QueryResult["mongo_documents"]; + resultLocalSortOriginalMongoCopyDocuments?: QueryResult["mongo_copy_documents"]; resultRuns?: QueryTab["resultRuns"]; activeResultRunId?: string; queryAnalysis?: QueryTab["queryAnalysis"]; @@ -51,6 +52,7 @@ interface ColumnarQueryResult { columnValues: CellValue[][]; rowCount: number; mongo_documents?: unknown[]; + mongo_copy_documents?: unknown[]; affected_rows: number; execution_time_ms: number; truncated?: boolean; @@ -324,6 +326,7 @@ function stripSessionIds(result: QueryResult | undefined): QueryResult | undefin column_types: result.column_types ? [...result.column_types] : undefined, rows: result.rows.map((row) => [...row]), mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined, + mongo_copy_documents: result.mongo_copy_documents ? clonePlain(result.mongo_copy_documents) : undefined, affected_rows: result.affected_rows, execution_time_ms: result.execution_time_ms, truncated: result.truncated, @@ -347,6 +350,7 @@ function stripResultRunSessionIds(resultRuns: QueryTab["resultRuns"]): QueryTab[ results: stripResultSessionIds(run.results), resultLocalSortOriginalRows: run.resultLocalSortOriginalRows?.map((row) => [...row]), resultLocalSortOriginalMongoDocuments: run.resultLocalSortOriginalMongoDocuments ? clonePlain(run.resultLocalSortOriginalMongoDocuments) : undefined, + resultLocalSortOriginalMongoCopyDocuments: run.resultLocalSortOriginalMongoCopyDocuments ? clonePlain(run.resultLocalSortOriginalMongoCopyDocuments) : undefined, resultSessionId: undefined, })); } @@ -362,6 +366,7 @@ function toColumnarResult(result: QueryResult | undefined): ColumnarQueryResult columnValues, rowCount: result.rows.length, mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined, + mongo_copy_documents: result.mongo_copy_documents ? clonePlain(result.mongo_copy_documents) : undefined, affected_rows: result.affected_rows, execution_time_ms: result.execution_time_ms, truncated: result.truncated, @@ -383,6 +388,7 @@ function fromColumnarResult(result: ColumnarQueryResult | undefined): QueryResul column_types: result.column_types ? [...result.column_types] : undefined, rows, mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined, + mongo_copy_documents: result.mongo_copy_documents ? clonePlain(result.mongo_copy_documents) : undefined, affected_rows: result.affected_rows, execution_time_ms: result.execution_time_ms, truncated: result.truncated, @@ -702,6 +708,7 @@ export function buildTabResultSnapshot(tab: QueryTab): TabResultSnapshot | undef resultEditorFingerprint: tab.resultEditorFingerprint, resultLocalSortOriginalRows: tab.resultLocalSortOriginalRows?.map((row) => [...row]), resultLocalSortOriginalMongoDocuments: tab.resultLocalSortOriginalMongoDocuments ? clonePlain(tab.resultLocalSortOriginalMongoDocuments) : undefined, + resultLocalSortOriginalMongoCopyDocuments: tab.resultLocalSortOriginalMongoCopyDocuments ? clonePlain(tab.resultLocalSortOriginalMongoCopyDocuments) : undefined, resultRuns: stripResultRunSessionIds(tab.resultRuns), activeResultRunId: tab.activeResultRunId, queryAnalysis: tab.queryAnalysis ? clonePlain(tab.queryAnalysis) : undefined, diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 1055afead..d6ab51aff 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -123,6 +123,7 @@ function droppedTableObjectSchemaCandidates(target: DroppedTableObjectTarget): S function markQueryResultRowsRaw(result: QueryResult): QueryResult { markRaw(result.rows); if (result.mongo_documents) markRaw(result.mongo_documents); + if (result.mongo_copy_documents) markRaw(result.mongo_copy_documents); return result; } @@ -147,8 +148,9 @@ function appendQueryResultSegment(previous: QueryResult, segment: QueryResult, m const remainingRows = Math.max(0, maxRows - previous.rows.length); const appendedRowCount = Math.min(remainingRows, segment.rows.length); const appendParallelValues = (existing: T[] | undefined, next: T[] | undefined): T[] | undefined => { - if (!existing && !next) return undefined; - return [...(existing ?? []), ...(next ?? []).slice(0, appendedRowCount)]; + if (!existing || !next) return undefined; + if (existing.length !== previous.rows.length || next.length !== segment.rows.length) return undefined; + return [...existing, ...next.slice(0, appendedRowCount)]; }; // Keep prior row objects intact so source-index based dirty/new/deleted state // remains valid, while bounding the in-memory result by the configured cap. @@ -169,6 +171,7 @@ function markQueryResultRunsRowsRaw(resultRuns: NonNullable { tab.resultEditorFingerprint = undefined; tab.resultLocalSortOriginalRows = undefined; tab.resultLocalSortOriginalMongoDocuments = undefined; + tab.resultLocalSortOriginalMongoCopyDocuments = undefined; tab.resultSortMode = undefined; tab.resultSessionId = undefined; tab.resultAccessedAt = undefined; @@ -624,6 +628,7 @@ export const useQueryStore = defineStore("query", () => { run.results = undefined; run.resultLocalSortOriginalRows = undefined; run.resultLocalSortOriginalMongoDocuments = undefined; + run.resultLocalSortOriginalMongoCopyDocuments = undefined; run.resultSessionId = undefined; run.resultEstimatedBytes = undefined; run.queryAnalysis = undefined; @@ -650,6 +655,7 @@ export const useQueryStore = defineStore("query", () => { tab.resultSortMode = run.resultSortMode; tab.resultLocalSortOriginalRows = run.resultLocalSortOriginalRows; tab.resultLocalSortOriginalMongoDocuments = run.resultLocalSortOriginalMongoDocuments; + tab.resultLocalSortOriginalMongoCopyDocuments = run.resultLocalSortOriginalMongoCopyDocuments; tab.orderByInput = run.orderByInput; tab.resultPageSql = run.resultPageSql; tab.resultPageLimit = run.resultPageLimit; @@ -802,6 +808,7 @@ export const useQueryStore = defineStore("query", () => { resultSortMode: tab.resultSortMode, resultLocalSortOriginalRows: tab.resultLocalSortOriginalRows, resultLocalSortOriginalMongoDocuments: tab.resultLocalSortOriginalMongoDocuments, + resultLocalSortOriginalMongoCopyDocuments: tab.resultLocalSortOriginalMongoCopyDocuments, orderByInput: tab.orderByInput, resultPageSql: tab.resultPageSql, resultPageLimit: tab.resultPageLimit, @@ -855,6 +862,7 @@ export const useQueryStore = defineStore("query", () => { resultSortMode: tab.resultSortMode, resultLocalSortOriginalRows: tab.resultLocalSortOriginalRows, resultLocalSortOriginalMongoDocuments: tab.resultLocalSortOriginalMongoDocuments, + resultLocalSortOriginalMongoCopyDocuments: tab.resultLocalSortOriginalMongoCopyDocuments, orderByInput: tab.orderByInput, resultPageSql: tab.resultPageSql, resultPageLimit: tab.resultPageLimit, @@ -904,6 +912,7 @@ export const useQueryStore = defineStore("query", () => { if (!tab.resultLocalSortOriginalRows) { tab.resultLocalSortOriginalRows = tab.result.rows.slice(); tab.resultLocalSortOriginalMongoDocuments = tab.result.mongo_documents?.slice(); + tab.resultLocalSortOriginalMongoCopyDocuments = tab.result.mongo_copy_documents?.slice(); } const originalRows = tab.resultLocalSortOriginalRows; @@ -911,7 +920,9 @@ export const useQueryStore = defineStore("query", () => { const rows = rowIndexes.map((index) => originalRows[index]!); const originalMongoDocuments = tab.resultLocalSortOriginalMongoDocuments; const mongo_documents = originalMongoDocuments ? rowIndexes.map((index) => originalMongoDocuments[index]) : undefined; - assignDisplayedResult(tab, { ...tab.result, rows, mongo_documents }); + const originalMongoCopyDocuments = tab.resultLocalSortOriginalMongoCopyDocuments; + const mongo_copy_documents = originalMongoCopyDocuments ? rowIndexes.map((index) => originalMongoCopyDocuments[index]) : undefined; + assignDisplayedResult(tab, { ...tab.result, rows, mongo_documents, mongo_copy_documents }); tab.resultSortColumn = direction ? column : undefined; tab.resultSortColumnIndex = direction ? columnIndex : undefined; @@ -921,6 +932,7 @@ export const useQueryStore = defineStore("query", () => { if (!direction) { tab.resultLocalSortOriginalRows = undefined; tab.resultLocalSortOriginalMongoDocuments = undefined; + tab.resultLocalSortOriginalMongoCopyDocuments = undefined; } // 本地排序只是重排既有行/文档,字节规模不变,可复用估算值 @@ -1824,6 +1836,7 @@ export const useQueryStore = defineStore("query", () => { resultSortMode: undefined, resultLocalSortOriginalRows: undefined, resultLocalSortOriginalMongoDocuments: undefined, + resultLocalSortOriginalMongoCopyDocuments: undefined, orderByInput: undefined, resultPageSql: undefined, resultPageLimit: undefined, @@ -2929,6 +2942,7 @@ export const useQueryStore = defineStore("query", () => { tab.lastExecutedSql = sql; tab.resultLocalSortOriginalRows = undefined; tab.resultLocalSortOriginalMongoDocuments = undefined; + tab.resultLocalSortOriginalMongoCopyDocuments = undefined; const updateActiveResultRun = !!tab.activeResultRunId && options?.preserveResultDuringExecution === true; if (!updateActiveResultRun) { tab.activeResultRunId = undefined; @@ -3095,9 +3109,9 @@ export const useQueryStore = defineStore("query", () => { case "find": { queryExecutionLog("info", "mongo-find:start", { traceId, collection: mongoCommand.collection, database: currentDatabase }); const result = await api.mongoFindDocuments(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.skip, mongoCommand.limit, mongoCommand.filter, mongoCommand.projection, mongoCommand.sort, executionId); - const queryResult = markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total))); + const queryResult = markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents))); allResults.push(queryResult); - mongoEditTarget = mongoCommands.length === 1 && queryResult.columns.includes("_id") ? { collection: mongoCommand.collection, idColumn: "_id" } : undefined; + mongoEditTarget = mongoCommands.length === 1 && !mongoCommand.projection && queryResult.columns.includes("_id") ? { collection: mongoCommand.collection, idColumn: "_id" } : undefined; queryExecutionLog("info", "mongo-find:done", { traceId, collection: mongoCommand.collection, @@ -3111,9 +3125,9 @@ export const useQueryStore = defineStore("query", () => { case "findOne": { queryExecutionLog("info", "mongo-find-one:start", { traceId, collection: mongoCommand.collection, database: currentDatabase }); const result = await api.mongoFindOne(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.projection, mongoCommand.options, executionId); - const queryResult = markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total))); + const queryResult = markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents))); allResults.push(queryResult); - mongoEditTarget = mongoCommands.length === 1 && queryResult.columns.includes("_id") ? { collection: mongoCommand.collection, idColumn: "_id" } : undefined; + mongoEditTarget = mongoCommands.length === 1 && !mongoCommand.projection && queryResult.columns.includes("_id") ? { collection: mongoCommand.collection, idColumn: "_id" } : undefined; queryExecutionLog("info", "mongo-find-one:done", { traceId, collection: mongoCommand.collection, @@ -3158,7 +3172,7 @@ export const useQueryStore = defineStore("query", () => { queryExecutionLog("info", "mongo-aggregate:start", { traceId, collection: mongoCommand.collection, database: currentDatabase }); const aggregateMaxRows = normalizeResultPageSize(pageLimit ?? options?.pagination?.limit ?? settingsStore.editorSettings.pageSize); const result = await api.mongoAggregateDocuments(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.pipeline, aggregateMaxRows, mongoCommand.options, executionId); - allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total)))); + allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents)))); mongoEditTarget = undefined; queryExecutionLog("info", "mongo-aggregate:done", { traceId, @@ -3237,7 +3251,7 @@ export const useQueryStore = defineStore("query", () => { : mongoCommand.kind === "findOneAndReplace" ? await api.mongoFindOneAndReplace(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.replacement, mongoCommand.options) : await api.mongoFindOneAndDelete(tab.connectionId, currentDatabase, mongoCommand.collection, mongoCommand.filter, mongoCommand.options); - allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total)))); + allResults.push(markQueryResultRowsRaw(annotateMongoResult(mongoDocumentsToQueryResult(result.documents, performance.now() - commandStartedAt, result.total, result.extended_documents)))); mongoEditTarget = undefined; queryExecutionLog("info", "mongo-find-and-modify:done", { traceId, @@ -4056,6 +4070,7 @@ export const useQueryStore = defineStore("query", () => { tab.result = tab.results[index]; tab.resultLocalSortOriginalRows = undefined; tab.resultLocalSortOriginalMongoDocuments = undefined; + tab.resultLocalSortOriginalMongoCopyDocuments = undefined; tab.resultSortColumn = undefined; tab.resultSortColumnIndex = undefined; tab.resultSortDirection = undefined; @@ -4178,6 +4193,7 @@ export const useQueryStore = defineStore("query", () => { tab.result = snapshot.result ? markQueryResultRowsRaw(snapshot.result) : results?.[activeIndex] ? markQueryResultRowsRaw(results[activeIndex]) : undefined; tab.resultLocalSortOriginalRows = snapshot.resultLocalSortOriginalRows ? markRaw(snapshot.resultLocalSortOriginalRows) : undefined; tab.resultLocalSortOriginalMongoDocuments = snapshot.resultLocalSortOriginalMongoDocuments ? markRaw(snapshot.resultLocalSortOriginalMongoDocuments) : undefined; + tab.resultLocalSortOriginalMongoCopyDocuments = snapshot.resultLocalSortOriginalMongoCopyDocuments ? markRaw(snapshot.resultLocalSortOriginalMongoCopyDocuments) : undefined; // 快照编解码会重建负载,落盘前的各 run 估算值不再对应恢复后的对象, // 置空让 projectResultRun 按需重算 tab.resultRuns = snapshot.resultRuns ? markQueryResultRunsRowsRaw(snapshot.resultRuns).map((run) => ({ ...run, resultEstimatedBytes: undefined })) : tab.resultRuns; diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index b7aa1630f..970baa6f4 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -565,6 +565,7 @@ export interface QueryResultRun { resultSortMode?: "database" | "local"; resultLocalSortOriginalRows?: QueryResult["rows"]; resultLocalSortOriginalMongoDocuments?: QueryResult["mongo_documents"]; + resultLocalSortOriginalMongoCopyDocuments?: QueryResult["mongo_copy_documents"]; orderByInput?: string; resultPageSql?: string; resultPageLimit?: number; @@ -796,6 +797,7 @@ export interface QueryTab { resultSortMode?: "database" | "local"; resultLocalSortOriginalRows?: QueryResult["rows"]; resultLocalSortOriginalMongoDocuments?: QueryResult["mongo_documents"]; + resultLocalSortOriginalMongoCopyDocuments?: QueryResult["mongo_copy_documents"]; orderByInput?: string; resultPageSql?: string; resultPageLimit?: number; diff --git a/crates/dbx-core/src/db/mongo_driver.rs b/crates/dbx-core/src/db/mongo_driver.rs index 9bfc31594..c1d18e93b 100644 --- a/crates/dbx-core/src/db/mongo_driver.rs +++ b/crates/dbx-core/src/db/mongo_driver.rs @@ -667,7 +667,7 @@ pub async fn find_documents( while cursor.advance().await.map_err(|e| e.to_string())? { let doc = cursor.deserialize_current().map_err(|e| e.to_string())?; documents.push(bson_to_json(&Bson::Document(doc.clone()))); - extended_documents.push(Bson::Document(doc).into_relaxed_extjson()); + extended_documents.push(Bson::Document(doc).into_canonical_extjson()); } Ok(MongoDocumentResult { @@ -807,13 +807,16 @@ pub async fn find_documents_extended_json( let mut cursor = find.await.map_err(|e| e.to_string())?; let mut documents = Vec::new(); + let mut extended_documents = Vec::new(); while cursor.advance().await.map_err(|e| e.to_string())? { let doc = cursor.deserialize_current().map_err(|e| e.to_string())?; - documents.push(bson_to_browser_json(&Bson::Document(doc))); + let (document, extended_document) = document_json_views(doc); + documents.push(document); + extended_documents.push(extended_document); } Ok(MongoDocumentResult { - extended_documents: Some(documents.clone()), + extended_documents: Some(extended_documents), documents, raw_documents: None, total, @@ -1346,7 +1349,7 @@ fn single_document_result(document: Option) -> MongoDocumentResult { Some(document) => MongoDocumentResult { documents: vec![bson_to_json(&Bson::Document(document.clone()))], raw_documents: None, - extended_documents: Some(vec![Bson::Document(document).into_relaxed_extjson()]), + extended_documents: Some(vec![Bson::Document(document).into_canonical_extjson()]), total: 1, total_is_exact: true, }, @@ -1584,6 +1587,14 @@ fn bson_to_browser_json(bson: &Bson) -> serde_json::Value { } } +fn document_json_views(document: Document) -> (serde_json::Value, serde_json::Value) { + let bson = Bson::Document(document); + let browser = bson_to_browser_json(&bson); + // Derive copy JSON from the original BSON so every BSON type keeps its canonical wrapper. + let extended = bson.into_canonical_extjson(); + (browser, extended) +} + /// Convert a `serde_json::Value` (JSON object) to a BSON `Document`, /// handling MongoDB extended JSON conventions such as `{"$oid":"..."}`. pub fn json_object_to_document(value: &serde_json::Value) -> Result { @@ -2271,10 +2282,40 @@ mod tests { assert_eq!(result.documents[0]["lastUpdatedDate"], serde_json::json!("ISODate(\"2025-05-06T08:35:32Z\")")); let extended = result.extended_documents.expect("extended documents"); - assert_eq!(extended[0]["lastUpdatedDate"], serde_json::json!({ "$date": "2025-05-06T08:35:32Z" })); + assert_eq!(extended[0]["lastUpdatedDate"], serde_json::json!({ "$date": { "$numberLong": "1746520532000" } })); assert_eq!(extended[0]["dateText"], serde_json::json!("ISODate(\"2025-05-06T08:35:32Z\")")); } + #[test] + fn document_json_views_keep_browser_display_and_canonical_bson_types() { + let date = DateTime::parse_rfc3339_str("2026-06-10T13:59:31.287Z").unwrap(); + let (browser, extended) = document_json_views(doc! { + "date": date, + "int32": Bson::Int32(42), + "int64": Bson::Int64(42), + "unsafeInt64": Bson::Int64(2_326_645_729_978_441_729), + }); + + assert_eq!( + browser, + serde_json::json!({ + "date": "ISODate(\"2026-06-10T13:59:31.287Z\")", + "int32": 42, + "int64": 42, + "unsafeInt64": { "$numberLong": "2326645729978441729" }, + }) + ); + assert_eq!( + extended, + serde_json::json!({ + "date": { "$date": { "$numberLong": "1781099971287" } }, + "int32": { "$numberInt": "42" }, + "int64": { "$numberLong": "42" }, + "unsafeInt64": { "$numberLong": "2326645729978441729" }, + }) + ); + } + #[test] fn bson_to_json_preserves_unsafe_int64_for_js() { let value = bson_to_json(&Bson::Int64(2_326_645_729_978_441_729)); diff --git a/packages/app-tests/documentBrowser.test.ts b/packages/app-tests/documentBrowser.test.ts index 181529226..79f092c9d 100644 --- a/packages/app-tests/documentBrowser.test.ts +++ b/packages/app-tests/documentBrowser.test.ts @@ -28,6 +28,7 @@ test("mongo document table passes copy context to the data grid", () => { assert.match(source, /const customSaveHandler = computed\(\(\) => \(\{[\s\S]*?targetLabel: props\.collection,[\s\S]*?\}\)\);/); assert.match(source, /mongo_copy_documents: copyDocuments\.value/); assert.match(source, /result\.extended_documents\?\.length === nextDocuments\.length/); + assert.match(source, /props\.databaseType === "mongodb" && mongoCopyDocumentsAvailable\.value/); }); test("document edit mode toggles whole JSON editing for insert and save", () => { diff --git a/packages/app-tests/mongoDocumentValues.test.ts b/packages/app-tests/mongoDocumentValues.test.ts index 474e0b25d..b9b7127dc 100644 --- a/packages/app-tests/mongoDocumentValues.test.ts +++ b/packages/app-tests/mongoDocumentValues.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "vitest"; import { applyMongoGridChangesToDocument, + applyMongoGridChangesToDocumentBaseline, buildMongoCopyDocumentFromOriginal, buildMongoCopyInsertDocument, buildMongoInsertDocument, @@ -175,6 +176,20 @@ test("applies Mongo grid edits without converting existing JSON strings", () => }); }); +test("applies sorted Mongo grid edits to a cloned BSON baseline by document id", () => { + const currentDocuments = [ + { _id: { $oid: "507f1f77bcf86cd799439012" }, name: "Linus", counter: { $numberLong: "2" } }, + { _id: { $oid: "507f1f77bcf86cd799439011" }, name: "Ada", counter: { $numberLong: "1" } }, + ]; + const baselineDocuments = [structuredClone(currentDocuments[1]), structuredClone(currentDocuments[0])]; + const dirtyRows = new Map([[0, new Map([[1, "Grace"]])]]); + + assert.deepEqual(applyMongoGridChangesToDocumentBaseline(baselineDocuments, currentDocuments, dirtyRows, ["_id", "name", "counter"]), [ + { _id: { $oid: "507f1f77bcf86cd799439011" }, name: "Ada", counter: { $numberLong: "1" } }, + { _id: { $oid: "507f1f77bcf86cd799439012" }, name: "Grace", counter: { $numberLong: "2" } }, + ]); +}); + test("builds Mongo inserts with parsed date values", () => { assert.deepEqual(buildMongoInsertDocument(["ignored", 'new Date("2026-06-10T13:59:31.287Z")'], ["_id", "createdAt"]), { createdAt: { $date: "2026-06-10T13:59:31.287Z" }, @@ -261,6 +276,19 @@ test("formats extended JSON int64 values as Mongo shell NumberLong literals", () assert.equal(formatMongoShellLiteral({ snowflake: { $numberLong: "9007199254740993" } }), '{"snowflake":NumberLong("9007199254740993")}'); }); +test("formats other extended JSON values through EJSON.deserialize", () => { + assert.equal( + formatMongoShellLiteral({ + decimal: { $numberDecimal: "12.34" }, + payload: { $binary: { base64: "AQI=", subType: "00" } }, + timestamp: { $timestamp: { t: 42, i: 7 } }, + pattern: { $regularExpression: { pattern: "^dbx", options: "i" } }, + canonicalDate: { $date: { $numberLong: "1721779200000" } }, + }), + '{"decimal":EJSON.deserialize({"$numberDecimal":"12.34"}),"payload":EJSON.deserialize({"$binary":{"base64":"AQI=","subType":"00"}}),"timestamp":EJSON.deserialize({"$timestamp":{"t":42,"i":7}}),"pattern":EJSON.deserialize({"$regularExpression":{"pattern":"^dbx","options":"i"}}),"canonicalDate":EJSON.deserialize({"$date":{"$numberLong":"1721779200000"}})}', + ); +}); + test("keeps normal Mongo values readable and unsafe Int64 editable", () => { assert.equal(mongoDocumentDisplayValue(42), 42); assert.equal(mongoDocumentDisplayValue(3.5), 3.5); diff --git a/packages/app-tests/mongoShellCommand.test.ts b/packages/app-tests/mongoShellCommand.test.ts index 63500a98c..4444f9590 100644 --- a/packages/app-tests/mongoShellCommand.test.ts +++ b/packages/app-tests/mongoShellCommand.test.ts @@ -344,6 +344,16 @@ test("parseMongoWriteCommand accepts unquoted insert and update commands", () => }); }); +test("parseMongoWriteCommand unwraps EJSON.deserialize values", () => { + assert.deepEqual(parseMongoWriteCommand('db.products.updateOne({_id: ObjectId("507f1f77bcf86cd799439011")}, {$set: {price: EJSON.deserialize({"$numberDecimal":"12.34"}), payload: EJSON.deserialize({"$binary":{"base64":"AQI=","subType":"00"}})}})'), { + kind: "update", + collection: "products", + filter: '{"_id": {"$oid":"507f1f77bcf86cd799439011"}}', + update: '{"$set": {"price": {"$numberDecimal":"12.34"}, "payload": {"$binary":{"base64":"AQI=","subType":"00"}}}}', + many: false, + }); +}); + test("parseMongoWriteCommand accepts legacy insert commands", () => { assert.deepEqual( parseMongoWriteCommand(`db.getCollection("accounting_reconciliations").insert({ @@ -936,6 +946,17 @@ test("mongoDocumentsToQueryResult turns mongo documents into grid rows", () => { assert.equal(result.truncated, true); }); +test("mongoDocumentsToQueryResult keeps aligned extended documents for copying", () => { + const documents = [{ _id: { $oid: "6743e4bfa3f6f84bc3fff6c8" }, createdAt: 'ISODate("2026-07-24T00:00:00Z")' }]; + const copyDocuments = [{ _id: { $oid: "6743e4bfa3f6f84bc3fff6c8" }, createdAt: { $date: "2026-07-24T00:00:00Z" } }]; + + const result = mongoDocumentsToQueryResult(documents, 5, 1, copyDocuments); + + assert.deepEqual(result.mongo_documents, documents); + assert.deepEqual(result.mongo_copy_documents, copyDocuments); + assert.equal(mongoDocumentsToQueryResult(documents, 5, 1, []).mongo_copy_documents, undefined); +}); + test("mongoDocumentsToQueryResult displays ids without losing raw type metadata", () => { const documents = [ { _id: { $oid: "6743e4bfa3f6f84bc3fff6c8" }, name: "object id" }, diff --git a/packages/app-tests/queryResultArchive.test.ts b/packages/app-tests/queryResultArchive.test.ts index dec9c87c7..0c1e9c022 100644 --- a/packages/app-tests/queryResultArchive.test.ts +++ b/packages/app-tests/queryResultArchive.test.ts @@ -38,6 +38,10 @@ test("query result archives round-trip query tab metadata and result runs", asyn { _id: "1", profile: { role: "admin" } }, { _id: "2", profile: { role: "maintainer" } }, ], + mongo_copy_documents: [ + { _id: { $oid: "507f1f77bcf86cd799439011" }, createdAt: { $date: "2026-07-24T00:00:00Z" } }, + { _id: { $oid: "507f1f77bcf86cd799439012" }, counter: { $numberLong: "9007199254740993" } }, + ], affected_rows: 0, execution_time_ms: 3, session_id: "live-session", @@ -66,6 +70,7 @@ test("query result archives round-trip query tab metadata and result runs", asyn }, resultLocalSortOriginalRows: [[1, "pending"]], resultLocalSortOriginalMongoDocuments: [{ _id: "1", status: "pending" }], + resultLocalSortOriginalMongoCopyDocuments: [{ _id: { $oid: "507f1f77bcf86cd799439011" }, status: "pending" }], }); const snapshot = buildTabResultSnapshot(tab); assert.ok(snapshot); @@ -80,7 +85,10 @@ test("query result archives round-trip query tab metadata and result runs", asyn assert.equal(decoded?.tab.schema, "public"); assert.equal(decoded?.tab.sql, "select * from revenue"); assert.equal(decoded?.snapshot.activeResultRunId, "run-2"); - assert.deepEqual(decoded?.snapshot.resultRuns?.map((run) => run.sequence), [1, 2]); + assert.deepEqual( + decoded?.snapshot.resultRuns?.map((run) => run.sequence), + [1, 2], + ); assert.deepEqual(decoded?.snapshot.resultRuns?.[0]?.result?.rows, [ [1, "Ada"], [2, "Linus"], @@ -89,9 +97,14 @@ test("query result archives round-trip query tab metadata and result runs", asyn { _id: "1", profile: { role: "admin" } }, { _id: "2", profile: { role: "maintainer" } }, ]); + assert.deepEqual(decoded?.snapshot.resultRuns?.[0]?.result?.mongo_copy_documents, [ + { _id: { $oid: "507f1f77bcf86cd799439011" }, createdAt: { $date: "2026-07-24T00:00:00Z" } }, + { _id: { $oid: "507f1f77bcf86cd799439012" }, counter: { $numberLong: "9007199254740993" } }, + ]); assert.equal(decoded?.snapshot.resultRuns?.[0]?.result?.session_id, undefined); assert.deepEqual(decoded?.snapshot.resultLocalSortOriginalRows, [[1, "pending"]]); assert.deepEqual(decoded?.snapshot.resultLocalSortOriginalMongoDocuments, [{ _id: "1", status: "pending" }]); + assert.deepEqual(decoded?.snapshot.resultLocalSortOriginalMongoCopyDocuments, [{ _id: { $oid: "507f1f77bcf86cd799439011" }, status: "pending" }]); }); test("query result archives reject invalid files", async () => { diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 923b5c09e..d5602d953 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -786,6 +786,7 @@ test("sortTabResultLocally sorts current rows and restores original order", () = { id: 1, name: "Ada", nested: { level: 1 } }, { id: 3, name: "Linus", nested: { level: 3 } }, ], + mongo_copy_documents: [{ copyId: 2 }, { copyId: 1 }, { copyId: 3 }], affected_rows: 0, execution_time_ms: 1, }; @@ -801,12 +802,24 @@ test("sortTabResultLocally sorts current rows and restores original order", () = tab.result?.mongo_documents?.map((document) => (document as { id: number }).id), [1, 2, 3], ); + assert.deepEqual( + tab.result?.mongo_copy_documents?.map((document) => (document as { copyId: number }).copyId), + [1, 2, 3], + ); + assert.deepEqual( + tab.resultLocalSortOriginalMongoCopyDocuments?.map((document) => (document as { copyId: number }).copyId), + [2, 1, 3], + ); assert.equal(tab.resultSortColumn, "name"); assert.equal(tab.resultSortColumnIndex, 1); assert.equal(tab.resultSortDirection, "asc"); assert.equal(tab.resultSortMode, "local"); assert.equal(tab.resultSortedSql, undefined); + // Cache/archive decoding rebuilds row objects, so sorting must rely on the + // persisted BSON copy baseline rather than row reference identity. + tab.result!.rows = tab.result!.rows.map((resultRow) => [...resultRow]); + tab.resultLocalSortOriginalRows = tab.resultLocalSortOriginalRows?.map((resultRow) => [...resultRow]); store.sortTabResultLocally(tabId, "name", 1, "desc"); assert.deepEqual(tab.result?.rows, [ @@ -818,6 +831,10 @@ test("sortTabResultLocally sorts current rows and restores original order", () = tab.result?.mongo_documents?.map((document) => (document as { id: number }).id), [3, 2, 1], ); + assert.deepEqual( + tab.result?.mongo_copy_documents?.map((document) => (document as { copyId: number }).copyId), + [3, 2, 1], + ); store.sortTabResultLocally(tabId, "name", 1, null); @@ -830,8 +847,13 @@ test("sortTabResultLocally sorts current rows and restores original order", () = tab.result?.mongo_documents?.map((document) => (document as { id: number }).id), [2, 1, 3], ); + assert.deepEqual( + tab.result?.mongo_copy_documents?.map((document) => (document as { copyId: number }).copyId), + [2, 1, 3], + ); assert.equal(tab.resultSortColumn, undefined); assert.equal(tab.resultSortMode, undefined); + assert.equal(tab.resultLocalSortOriginalMongoCopyDocuments, undefined); }); test("selecting a result run restores its displayed result without changing SQL draft", async () => { @@ -2596,11 +2618,28 @@ test("append pagination preserves existing rows and respects the memory cap", as const tab = store.tabs.find((item) => item.id === tabId); assert.ok(tab); const firstRow = [1] as (string | number | boolean | null)[]; - tab.result = { columns: ["id"], rows: [firstRow], affected_rows: 0, execution_time_ms: 3 }; + tab.result = { + columns: ["id"], + rows: [firstRow], + mongo_documents: [{ id: 1 }], + mongo_copy_documents: [{ id: { $numberLong: "1" } }], + affected_rows: 0, + execution_time_ms: 3, + }; globalThis.fetch = withConnectionHealthMock(async (input) => { if (String(input) === "/api/query/execute-multi") { - return Response.json([{ columns: ["id"], rows: [[2], [3]], affected_rows: 0, execution_time_ms: 4, has_more: true }]); + return Response.json([ + { + columns: ["id"], + rows: [[2], [3]], + mongo_documents: [{ id: 2 }, { id: 3 }], + mongo_copy_documents: [{ id: { $numberLong: "2" } }, { id: { $numberLong: "3" } }], + affected_rows: 0, + execution_time_ms: 4, + has_more: true, + }, + ]); } return new Response("unexpected request", { status: 500 }); }); @@ -2614,6 +2653,8 @@ test("append pagination preserves existing rows and respects the memory cap", as }); assert.deepEqual(tab.result?.rows, [[1], [2]]); + assert.deepEqual(tab.result?.mongo_documents, [{ id: 1 }, { id: 2 }]); + assert.deepEqual(tab.result?.mongo_copy_documents, [{ id: { $numberLong: "1" } }, { id: { $numberLong: "2" } }]); assert.equal(toRaw(tab.result?.rows[0]), firstRow); assert.equal(tab.result?.execution_time_ms, 7); assert.equal(tab.result?.has_more, false); @@ -2625,6 +2666,57 @@ test("append pagination preserves existing rows and respects the memory cap", as } }); +test("append pagination drops incomplete Mongo copy metadata instead of misaligning rows", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + + connectionStore.addEphemeralConnection(conn("conn-append-old-cache")); + const tabId = store.createTab("conn-append-old-cache", "db", "users", "data", "public"); + const tab = store.tabs.find((item) => item.id === tabId); + assert.ok(tab); + tab.result = { + columns: ["id"], + rows: [[1]], + mongo_documents: [{ id: 1 }], + affected_rows: 0, + execution_time_ms: 3, + }; + + globalThis.fetch = withConnectionHealthMock(async (input) => { + if (String(input) === "/api/query/execute-multi") { + return Response.json([ + { + columns: ["id"], + rows: [[2]], + mongo_documents: [{ id: 2 }], + mongo_copy_documents: [{ id: { $numberLong: "2" } }], + affected_rows: 0, + execution_time_ms: 4, + }, + ]); + } + return new Response("unexpected request", { status: 500 }); + }); + + try { + await store.executeTabSql(tabId, 'SELECT * FROM "users" LIMIT 1 OFFSET 1;', { + pagination: { limit: 1, offset: 1 }, + appendResult: { maxRows: 2 }, + preserveResultDuringExecution: true, + }); + + assert.deepEqual(tab.result?.rows, [[1], [2]]); + assert.deepEqual(tab.result?.mongo_documents, [{ id: 1 }, { id: 2 }]); + assert.equal(tab.result?.mongo_copy_documents, undefined); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + test("failed append pagination preserves the visible result", async () => { const restoreStorage = installMemoryStorage(); setActivePinia(createPinia()); @@ -3238,6 +3330,47 @@ test("mongo multi-find results use database and collection source labels", async } }); +test("mongo projected find results do not enable copy as UPDATE", async () => { + const restoreStorage = installMemoryStorage(); + setActivePinia(createPinia()); + const connectionStore = useConnectionStore(); + const store = useQueryStore(); + const originalFetch = globalThis.fetch; + + connectionStore.addEphemeralConnection({ + ...conn("mongo-projection-1"), + db_type: "mongodb", + port: 27017, + }); + + globalThis.fetch = withConnectionHealthMock(async (input, init) => { + if (String(input) === "/api/document-store/find-documents") { + const body = JSON.parse(String(init?.body ?? "{}")); + assert.equal(body.projection, '{"_id":1,"profile.name":1}'); + return new Response( + JSON.stringify({ + documents: [{ _id: "user-1", profile: { name: "Ada" } }], + extended_documents: [{ _id: "user-1", profile: { name: "Ada" } }], + total: 1, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response("unexpected request", { status: 500 }); + }); + + try { + const tabId = store.createTab("mongo-projection-1", "accounting", "Query", "query", ""); + await store.executeTabSql(tabId, 'db.users.find({}, { _id: 1, "profile.name": 1 })'); + + const tab = store.tabs.find((item) => item.id === tabId); + assert.equal(tab?.mongoEditTarget, undefined); + } finally { + globalThis.fetch = originalFetch; + restoreStorage(); + } +}); + test("replacing one paginated SQL result preserves the grouped refresh SQL", async () => { const restoreStorage = installMemoryStorage(); setActivePinia(createPinia()); diff --git a/packages/app-tests/tabResultCache.test.ts b/packages/app-tests/tabResultCache.test.ts index 2d289bfec..b734933ac 100644 --- a/packages/app-tests/tabResultCache.test.ts +++ b/packages/app-tests/tabResultCache.test.ts @@ -22,6 +22,7 @@ test("result snapshots strip live session handles and clone result rows", () => columns: ["id"], rows: [[1]], mongo_documents: [{ _id: "1", profile: { role: "admin" } }], + mongo_copy_documents: [{ _id: { $oid: "507f1f77bcf86cd799439011" }, createdAt: { $date: "2026-07-24T00:00:00Z" } }], affected_rows: 0, execution_time_ms: 1, session_id: "live-session", @@ -40,6 +41,7 @@ test("result snapshots strip live session handles and clone result rows", () => activeResultIndex: 0, resultLocalSortOriginalRows: [[2]], resultLocalSortOriginalMongoDocuments: [{ _id: "2", profile: { role: "maintainer" } }], + resultLocalSortOriginalMongoCopyDocuments: [{ _id: { $oid: "507f1f77bcf86cd799439012" }, counter: { $numberLong: "9007199254740993" } }], }); const snapshot = buildTabResultSnapshot(tab); @@ -50,11 +52,15 @@ test("result snapshots strip live session handles and clone result rows", () => assert.equal(snapshot?.results?.[0]?.session_id, undefined); assert.deepEqual(snapshot?.result?.rows, [[1]]); assert.deepEqual(snapshot?.result?.mongo_documents, [{ _id: "1", profile: { role: "admin" } }]); + assert.deepEqual(snapshot?.result?.mongo_copy_documents, [{ _id: { $oid: "507f1f77bcf86cd799439011" }, createdAt: { $date: "2026-07-24T00:00:00Z" } }]); assert.deepEqual(snapshot?.resultLocalSortOriginalRows, [[2]]); assert.deepEqual(snapshot?.resultLocalSortOriginalMongoDocuments, [{ _id: "2", profile: { role: "maintainer" } }]); + assert.deepEqual(snapshot?.resultLocalSortOriginalMongoCopyDocuments, [{ _id: { $oid: "507f1f77bcf86cd799439012" }, counter: { $numberLong: "9007199254740993" } }]); tab.result!.rows[0]![0] = 2; + (tab.result!.mongo_copy_documents![0] as { createdAt: { $date: string } }).createdAt.$date = "changed"; tab.resultLocalSortOriginalRows![0]![0] = 3; assert.deepEqual(snapshot?.result?.rows, [[1]]); + assert.deepEqual(snapshot?.result?.mongo_copy_documents, [{ _id: { $oid: "507f1f77bcf86cd799439011" }, createdAt: { $date: "2026-07-24T00:00:00Z" } }]); assert.deepEqual(snapshot?.resultLocalSortOriginalRows, [[2]]); }); @@ -70,6 +76,7 @@ test("result snapshots strip session handles from result runs", () => { result: { columns: ["id"], rows: [[1]], + mongo_copy_documents: [{ _id: { $oid: "507f1f77bcf86cd799439011" } }], affected_rows: 0, execution_time_ms: 1, session_id: "live-run-session", @@ -78,6 +85,7 @@ test("result snapshots strip session handles from result runs", () => { }, resultLocalSortOriginalRows: [[2]], resultLocalSortOriginalMongoDocuments: [{ _id: "2", role: "maintainer" }], + resultLocalSortOriginalMongoCopyDocuments: [{ _id: { $oid: "507f1f77bcf86cd799439012" } }], }, ], }); @@ -88,8 +96,10 @@ test("result snapshots strip session handles from result runs", () => { assert.equal(snapshot?.resultRuns?.[0]?.result?.sourceLabel, "users"); assert.equal(snapshot?.resultRuns?.[0]?.result?.sourceStatement, "select * from users"); assert.deepEqual(snapshot?.resultRuns?.[0]?.result?.rows, [[1]]); + assert.deepEqual(snapshot?.resultRuns?.[0]?.result?.mongo_copy_documents, [{ _id: { $oid: "507f1f77bcf86cd799439011" } }]); assert.deepEqual(snapshot?.resultRuns?.[0]?.resultLocalSortOriginalRows, [[2]]); assert.deepEqual(snapshot?.resultRuns?.[0]?.resultLocalSortOriginalMongoDocuments, [{ _id: "2", role: "maintainer" }]); + assert.deepEqual(snapshot?.resultRuns?.[0]?.resultLocalSortOriginalMongoCopyDocuments, [{ _id: { $oid: "507f1f77bcf86cd799439012" } }]); }); test("result snapshots encode as binary columnar payloads and decode back to rows", () => { @@ -105,6 +115,10 @@ test("result snapshots encode as binary columnar payloads and decode back to row { _id: "1", name: "Ada", tags: ["admin"] }, { _id: "2", name: "Linus", tags: ["maintainer"] }, ], + mongo_copy_documents: [ + { _id: { $oid: "507f1f77bcf86cd799439011" }, createdAt: { $date: "2026-07-24T00:00:00Z" } }, + { _id: { $oid: "507f1f77bcf86cd799439012" }, counter: { $numberLong: "9007199254740993" } }, + ], affected_rows: 0, execution_time_ms: 3, session_id: "live-session", @@ -120,6 +134,10 @@ test("result snapshots encode as binary columnar payloads and decode back to row { _id: "2", name: "Linus", tags: ["maintainer"] }, { _id: "1", name: "Ada", tags: ["admin"] }, ], + resultLocalSortOriginalMongoCopyDocuments: [ + { _id: { $oid: "507f1f77bcf86cd799439012" }, counter: { $numberLong: "9007199254740993" } }, + { _id: { $oid: "507f1f77bcf86cd799439011" }, createdAt: { $date: "2026-07-24T00:00:00Z" } }, + ], }), ); assert.ok(snapshot); @@ -137,6 +155,10 @@ test("result snapshots encode as binary columnar payloads and decode back to row { _id: "1", name: "Ada", tags: ["admin"] }, { _id: "2", name: "Linus", tags: ["maintainer"] }, ]); + assert.deepEqual(decoded?.result?.mongo_copy_documents, [ + { _id: { $oid: "507f1f77bcf86cd799439011" }, createdAt: { $date: "2026-07-24T00:00:00Z" } }, + { _id: { $oid: "507f1f77bcf86cd799439012" }, counter: { $numberLong: "9007199254740993" } }, + ]); assert.deepEqual(decoded?.resultLocalSortOriginalRows, [ [2, "Linus", false], [1, "Ada", true], @@ -145,6 +167,10 @@ test("result snapshots encode as binary columnar payloads and decode back to row { _id: "2", name: "Linus", tags: ["maintainer"] }, { _id: "1", name: "Ada", tags: ["admin"] }, ]); + assert.deepEqual(decoded?.resultLocalSortOriginalMongoCopyDocuments, [ + { _id: { $oid: "507f1f77bcf86cd799439012" }, counter: { $numberLong: "9007199254740993" } }, + { _id: { $oid: "507f1f77bcf86cd799439011" }, createdAt: { $date: "2026-07-24T00:00:00Z" } }, + ]); assert.equal(decoded?.result?.session_id, undefined); assert.equal(decoded?.result?.has_more, true); assert.equal(decoded?.result?.sourceLabel, "public.users"); diff --git a/packages/mongo-shell/src/json.ts b/packages/mongo-shell/src/json.ts index cf977cd03..87a634ade 100644 --- a/packages/mongo-shell/src/json.ts +++ b/packages/mongo-shell/src/json.ts @@ -9,9 +9,10 @@ export function normalizeJsonArgument(value: string): string | null { if (!trimmed) return "{}"; const withoutComments = stripMongoJsonComments(trimmed).trim(); if (!withoutComments) return "{}"; + const withoutEjsonDeserialize = replaceMongoEjsonDeserialize(withoutComments); // Rewrite mongo shell constructors that are not valid JSON into extended JSON // (mongo_driver::json_value_to_bson): ObjectId / NumberLong / ISODate / new Date. - const withExtendedJson = replaceMongoShellConstructors(withoutComments); + const withExtendedJson = replaceMongoShellConstructors(withoutEjsonDeserialize); const preprocessed = quoteUnquotedObjectKeys(convertSingleQuotedStrings(withExtendedJson)); try { JSON.parse(preprocessed); @@ -286,6 +287,48 @@ function shouldQuoteObjectKey(source: string, index: number): boolean { return source[after] === ":"; } +function replaceMongoEjsonDeserialize(source: string): string { + const callPattern = /^EJSON\s*\.\s*deserialize\s*\(/; + let result = ""; + let index = 0; + while (index < source.length) { + const quote = source[index]; + if (quote === '"' || quote === "'") { + const start = index++; + while (index < source.length) { + if (source[index] === "\\") index += 2; + else if (source[index] === quote) { + index++; + break; + } else index++; + } + result += source.slice(start, index); + continue; + } + + const match = source.slice(index).match(callPattern); + if (!match) { + result += source[index++]!; + continue; + } + const openIndex = index + match[0].lastIndexOf("("); + const closeIndex = findMatchingParen(source, openIndex); + if (closeIndex < 0) { + result += source[index++]!; + continue; + } + const args = splitTopLevel(source.slice(openIndex + 1, closeIndex)); + if (args.length !== 1 || !args[0]?.trim()) { + result += source.slice(index, closeIndex + 1); + index = closeIndex + 1; + continue; + } + result += args[0].trim(); + index = closeIndex + 1; + } + return result; +} + function replaceMongoShellConstructors(source: string): string { const constructor = /^(ObjectId|NumberLong|ISODate)\s*\(\s*["']([^"']+)["']\s*\)|^(ObjectId|NumberLong)\s*\(\s*(-?\d+)\s*\)|^(?:new\s+Date)\s*\(\s*["']([^"']+)["']\s*\)/; let result = "";