From cf78eebbc93c9860899bd64ab6e2fe34e6393fbf Mon Sep 17 00:00:00 2001 From: onenewcode Date: Tue, 29 Sep 2026 12:22:42 +0800 Subject: [PATCH] fix(mongodb): display array structures as shell-style bson literals --- .../components/document/DocumentBrowser.vue | 4 +- apps/desktop/src/components/grid/DataGrid.vue | 38 ++++--- .../__tests__/mongoDocumentValues.spec.ts | 41 ++++++- .../src/lib/mongo/mongoDocumentValues.ts | 103 +++++++++++++++++- .../src/lib/mongo/mongoShellCommand.ts | 26 ++++- apps/desktop/src/stores/queryStore.ts | 4 +- .../app-tests/mongoDocumentValues.test.ts | 24 ++++ packages/app-tests/mongoShellCommand.test.ts | 17 ++- 8 files changed, 223 insertions(+), 34 deletions(-) diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue index 56730cfc4f..5f66557c5c 100644 --- a/apps/desktop/src/components/document/DocumentBrowser.vue +++ b/apps/desktop/src/components/document/DocumentBrowser.vue @@ -716,7 +716,9 @@ async function exportAllDocumentStoreDocuments(onProgress?: (info: { rowsExporte } } - const result = mongoDocumentsToQueryResult(exportedDocuments, performance.now() - exportStartedAt, totalRows ?? exportedDocuments.length, exportedCopyDocuments, totalRows !== null); + // Exports consume source text (CSV/SQL), not the live grid's BSON-faithful + // cell encoding, so every document store keeps plain JSON cells here. + const result = mongoDocumentsToQueryResult(exportedDocuments, performance.now() - exportStartedAt, totalRows ?? exportedDocuments.length, exportedCopyDocuments, totalRows !== null, { documentGridValues: false }); if (result.columns.length === 0) result.columns = gridResult.value.columns; result.column_types = kind === "mongodb" ? mongoDocumentGridColumnTypes(exportedDocuments, result.columns) : kind === "elasticsearch" ? elasticsearchGridColumnTypesFor(result.columns) : kind === "solr" ? solrGridColumnTypesFor(result.columns) : undefined; result.affected_rows = exportedDocuments.length; diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 6311327ff6..aee63a9281 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -108,7 +108,7 @@ import type { BuildSingleColumnAlterSqlOptions } from "@/lib/table/tableStructur import { buildTableSelectSql, qualifyTableReferencesInSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql"; import { uuid } from "@/lib/common/utils"; import { generateCellValues, type CellValueGenerationKind } from "@/lib/dataGrid/cellValueGeneration"; -import { MONGO_DOCUMENT_GRID_NULL, mongoDocumentGridClipboardText, mongoDocumentGridDisplayText, mongoDocumentGridEditorText, mongoDocumentGridExternalValue, mongoDocumentGridInputValue } from "@/lib/mongo/mongoDocumentValues"; +import { MONGO_DOCUMENT_GRID_NULL, mongoDocumentGridClipboardText, mongoDocumentGridDisplayText, mongoDocumentGridEditorText, mongoDocumentGridExternalValue, mongoDocumentGridInputValue, mongoDocumentRelaxedExtendedJson } from "@/lib/mongo/mongoDocumentValues"; import { compactHeaderColumnType, formatMetadataColumnTypeLabel, isNumericColumnType, resolveDataGridTypeVisualKind, resolveHeaderColumnType, resolveResultColumnType } from "@/lib/dataGrid/dataGridColumnType"; import { dataGridCellTextClass, dataGridTypeVisualClass } from "@/lib/dataGrid/dataGridCellTextVisual"; import { DATA_GRID_TYPE_COLOR_KEYS, resolveActiveDataGridTypeColors } from "@/lib/dataGrid/dataGridTypeColorScheme"; @@ -745,6 +745,10 @@ const columnTypeMap = computed(() => { }); const resolvedConnectionConfig = computed(() => connectionStore.getConfig(props.connectionId ?? "")); const resolvedDatabaseType = computed(() => props.databaseType ?? effectiveDatabaseTypeForConnection(resolvedConnectionConfig.value)); +// The collection grid and Mongo query-result grids share the document-grid value +// encoding (BSON null sentinel + JSON-prefixed containers), so every display, +// editor and clipboard path must decode it whenever MongoDB values are on screen. +const usesMongoDocumentGridValues = computed(() => props.mongoCollectionGrid === true || resolvedDatabaseType.value === "mongodb"); const isResultsContext = computed(() => props.context === "results"); const canShowWhereSearch = computed(() => !!props.onExecuteSql && !isResultsContext.value && resolvedDatabaseType.value !== "victoriametrics"); const canUseWhereSearch = computed(() => !!props.tableMeta && canShowWhereSearch.value); @@ -2210,7 +2214,7 @@ const columnAligns = computed<("left" | "right")[]>(() => { function gridCellTextColorClass(item: RowItem, actualColIdx: number, visibleColIdx: number): string { const value = item.data[actualColIdx]; - const isGridNull = value === null || (props.mongoCollectionGrid === true && value === MONGO_DOCUMENT_GRID_NULL); + const isGridNull = value === null || (usesMongoDocumentGridValues.value && value === MONGO_DOCUMENT_GRID_NULL); if (isGridNull) return "text-muted-foreground italic"; if (!colorizeDataGridCellTypes.value) return "text-foreground"; const checkbox = booleanCellsUseCheckbox.value && isBooleanGridCell(item, actualColIdx) && value !== null; @@ -2233,7 +2237,7 @@ function transposeCellTextColorClass(recordIndex: number, actualColIdx: number): const item = displayItems.value[recordIndex]; if (!item) return "text-foreground"; const value = item.data[actualColIdx]; - const isGridNull = value === null || (props.mongoCollectionGrid === true && value === MONGO_DOCUMENT_GRID_NULL); + const isGridNull = value === null || (usesMongoDocumentGridValues.value && value === MONGO_DOCUMENT_GRID_NULL); if (isGridNull) return "text-muted-foreground italic"; if (!colorizeDataGridCellTypes.value) return "text-foreground"; return dataGridCellTextClass({ @@ -4019,7 +4023,7 @@ const editor = useDataGridEditor({ includeDatabaseNameInSaveSql: computed(() => settingsStore.editorSettings.generateSqlIncludeDatabaseName), initialEditColumn: firstVisibleColumnIndex, cellEditorText: cellEditorTextForValue, - normalizeEditorInput: (value) => (props.mongoCollectionGrid ? mongoDocumentGridInputValue(value) : value), + normalizeEditorInput: (value) => (usesMongoDocumentGridValues.value ? mongoDocumentGridInputValue(value) : value), getRowItem, pageSize, currentPage, @@ -4303,7 +4307,7 @@ function cellEditContentNeedsExpandedEditor(options: { displayText: string; edit } function cellEditorTextForValue(value: CellValue | undefined, columnIndex: number): string { - if (props.mongoCollectionGrid) { + if (usesMongoDocumentGridValues.value) { const documentGridText = mongoDocumentGridEditorText(value); if (documentGridText !== undefined) return documentGridText; } @@ -4374,7 +4378,7 @@ function isIoTDBTimestampColumn(columnIndex: number): boolean { } function inlineCellEditorText(value: CellValue, columnIndex: number): string { - if (props.mongoCollectionGrid) { + if (usesMongoDocumentGridValues.value) { const documentGridText = mongoDocumentGridEditorText(value); if (documentGridText !== undefined) return documentGridText; } @@ -5981,11 +5985,11 @@ const contextCellDetail = computed(() => { // The MongoDB collection grid stores an internal sentinel for explicit BSON // null; detail panes must render display text instead of leaking that marker. function gridDetailRawValue(value: CellValue): string { - return props.mongoCollectionGrid ? (mongoDocumentGridDisplayText(value) ?? displayCellValue(value)) : displayCellValue(value); + return usesMongoDocumentGridValues.value ? (mongoDocumentGridDisplayText(value) ?? displayCellValue(value)) : displayCellValue(value); } function gridDetailIsNullValue(value: CellValue): boolean { - return value === null || (props.mongoCollectionGrid === true && value === MONGO_DOCUMENT_GRID_NULL); + return value === null || (usesMongoDocumentGridValues.value && value === MONGO_DOCUMENT_GRID_NULL); } function cellDetailFor(rowIndex: number, columnIndex: number): DataGridCellDetail | null { @@ -6030,7 +6034,9 @@ const mongoJsonPreviewFullText = computed(() => { const document = activeMongoJsonDocument.value; if (document === undefined) return ""; try { - return JSON.stringify(document, null, 2) ?? ""; + // Relaxed Extended JSON (`{"$date": "…"}`) like Compass's JSON view, instead + // of the browser form's escaped `ISODate("…")` strings. + return JSON.stringify(mongoDocumentRelaxedExtendedJson(document), null, 2) ?? ""; } catch { return ""; } @@ -6324,7 +6330,7 @@ const detailEdit = useDataGridCellDetailEdit({ resultRows: computed(() => props.result.rows), getColumnInfo: (columnIndex) => tableColumnForGridColumn(columnIndex) ?? resultColumnInfoForGridColumn(columnIndex), cellEditorText: cellEditorTextForValue, - normalizeEditorInput: (value) => (props.mongoCollectionGrid ? mongoDocumentGridInputValue(value) : value), + normalizeEditorInput: (value) => (usesMongoDocumentGridValues.value ? mongoDocumentGridInputValue(value) : value), nullValue: () => (props.mongoCollectionGrid ? MONGO_DOCUMENT_GRID_NULL : null), getRowItem, hydrateLargeValueCell, @@ -6656,7 +6662,7 @@ function primitiveCellFormatKey(value: CellValue, columnIndex?: number): string function formatCell(value: CellValue, columnIndex?: number, originalBytes?: number, limitDisplay = true): string { const formatter = columnIndex === undefined ? undefined : resolvedColumnFormatters.value[columnIndex]; - if (props.mongoCollectionGrid) { + if (usesMongoDocumentGridValues.value) { const documentGridText = mongoDocumentGridDisplayText(value, formatter); if (documentGridText !== undefined) return documentGridText; } @@ -7603,7 +7609,7 @@ function drawCanvasGrid() { searchMatchKeys: searchMatchSet.value, currentSearchMatch: currentSearchMatch.value, formatCell: (value, columnIndex, row) => formatCellCached(visibleLargeValuePreviewValue(row, columnIndex, value), columnIndex, largeValueOriginalBytes(row, columnIndex)), - isNullValue: (value) => value === null || (props.mongoCollectionGrid === true && value === MONGO_DOCUMENT_GRID_NULL), + isNullValue: (value) => value === null || (usesMongoDocumentGridValues.value && value === MONGO_DOCUMENT_GRID_NULL), newRowCellPlaceholder, isRowActive, rowCellsUseSelectionVisual, @@ -7935,8 +7941,8 @@ const { columnComments: visibleColumnComments, allColumnComments, displayValue: formatCellCached, - cellClipboardText: (value) => (props.mongoCollectionGrid ? mongoDocumentGridClipboardText(value) : undefined), - externalCellValue: (value) => (props.mongoCollectionGrid ? mongoDocumentGridExternalValue(value) : value), + cellClipboardText: (value) => (usesMongoDocumentGridValues.value ? mongoDocumentGridClipboardText(value) : undefined), + externalCellValue: (value) => (usesMongoDocumentGridValues.value ? mongoDocumentGridExternalValue(value) : value), mongoDocuments: computed(() => props.result.mongo_copy_documents ?? props.result.mongo_documents), spatialColumns: computed(() => props.result.spatial_columns), spatialValues: computed(() => props.result.spatial_values), @@ -9588,7 +9594,7 @@ async function onGridKeydown(event: KeyboardEvent) { } function detailClipboardText(detail: DataGridCellDetail): string { - if (props.mongoCollectionGrid) { + if (usesMongoDocumentGridValues.value) { const documentGridText = mongoDocumentGridClipboardText(detail.value); if (documentGridText !== undefined) return documentGridText; } @@ -9603,7 +9609,7 @@ function detailClipboardText(detail: DataGridCellDetail): string { // grid's BSON null marker is restored to a real null instead of leaking the // internal sentinel into clipboard JSON/TSV. function gridDetailExternalValue(value: CellValue): CellValue { - return props.mongoCollectionGrid ? mongoDocumentGridExternalValue(value) : value; + return usesMongoDocumentGridValues.value ? mongoDocumentGridExternalValue(value) : value; } async function copyDetailValue() { diff --git a/apps/desktop/src/lib/mongo/__tests__/mongoDocumentValues.spec.ts b/apps/desktop/src/lib/mongo/__tests__/mongoDocumentValues.spec.ts index 3ae3a46edb..8639aa6f75 100644 --- a/apps/desktop/src/lib/mongo/__tests__/mongoDocumentValues.spec.ts +++ b/apps/desktop/src/lib/mongo/__tests__/mongoDocumentValues.spec.ts @@ -24,7 +24,7 @@ describe("MongoDB document date display", () => { it("formats nested dates without changing non-date BSON values", () => { const value = mongoDocumentGridValue({ event: { at: { $date: "2026-09-24T02:00:00.000Z" }, sequence: { $numberLong: "9007199254740993" } }, tags: ["a"] }); - expect(mongoDocumentGridDisplayText(value, dateFormatter("Asia/Shanghai"))).toBe('{"event":{"at":"2026-09-24 10:00:00","sequence":{"$numberLong":"9007199254740993"}},"tags":["a"]}'); + expect(mongoDocumentGridDisplayText(value, dateFormatter("Asia/Shanghai"))).toBe('{"event": {"at": "2026-09-24 10:00:00", "sequence": NumberLong("9007199254740993")}, "tags": ["a"]}'); }); it("uses local time when no timezone override is configured", () => { @@ -32,9 +32,9 @@ describe("MongoDB document date display", () => { expect(mongoDocumentGridDisplayText(value, dateFormatter(undefined))).toMatch(/^2026-09-2[34] \d{2}:00:00$/); }); - it("falls back to the original Extended JSON for an invalid timezone", () => { + it("falls back to the shell-style display for an invalid timezone", () => { const value = mongoDocumentGridValue({ nested: { $date: "2026-09-24T02:00:00.000Z" } }); - expect(mongoDocumentGridDisplayText(value, dateFormatter("Invalid/Timezone"))).toBe('{"nested":{"$date":"2026-09-24T02:00:00.000Z"}}'); + expect(mongoDocumentGridDisplayText(value, dateFormatter("Invalid/Timezone"))).toBe('{"nested": ISODate("2026-09-24T02:00:00.000Z")}'); }); it("keeps edit, copy, and external values independent from display formatting", () => { @@ -52,3 +52,38 @@ describe("MongoDB document date display", () => { expect(mongoDocumentGridDisplayText(value, dateFormatter("Asia/Shanghai"))).toBeUndefined(); }); }); + +describe("MongoDB array structure display", () => { + it("renders arrays with shell-style BSON literals instead of the internal JSON encoding", () => { + const value = mongoDocumentGridValue({ + ids: [{ $oid: "6743e4bfa3f6f84bc3fff6c8" }], + history: [{ at: 'ISODate("2026-06-10T13:59:31.287Z")', by: { $oid: "507f1f77bcf86cd799439011" } }], + counts: [{ $numberLong: "2326645729978441729" }, 2], + }); + expect(mongoDocumentGridDisplayText(value)).toBe('{"ids": [ObjectId("6743e4bfa3f6f84bc3fff6c8")], "history": [{"at": ISODate("2026-06-10T13:59:31.287Z"), "by": ObjectId("507f1f77bcf86cd799439011")}], "counts": [NumberLong("2326645729978441729"), 2]}'); + }); + + it("keeps the structure separators readable for scalar arrays and nested containers", () => { + expect(mongoDocumentGridDisplayText(mongoDocumentGridValue(["a", "b"]))).toBe('["a", "b"]'); + expect(mongoDocumentGridDisplayText(mongoDocumentGridValue({ tags: [], matrix: [[1, 2], [3]] }))).toBe('{"tags": [], "matrix": [[1, 2], [3]]}'); + }); + + it("still treats JSON-shaped BSON strings as data, not as structure", () => { + expect(mongoDocumentGridDisplayText(mongoDocumentGridValue({ note: "[1,2]" }))).toBe('{"note": "[1,2]"}'); + }); + + it("keeps edit, copy, and external JSON untouched by the shell-style display", () => { + const original = { items: [{ at: { $date: "2026-06-10T13:59:31.287Z" } }] }; + const value = mongoDocumentGridValue(original) as string; + const rawJson = JSON.stringify(original); + expect(mongoDocumentGridDisplayText(value)).toBe('{"items": [{"at": ISODate("2026-06-10T13:59:31.287Z")}]}'); + expect(mongoDocumentGridEditorText(value)).toBe(rawJson); + expect(mongoDocumentGridClipboardText(value)).toBe(rawJson); + expect(mongoDocumentGridExternalValue(value)).toBe(rawJson); + }); + + it("renders out-of-range canonical dates as the raw wrapper instead of throwing", () => { + const value = mongoDocumentGridValue({ d: { $date: { $numberLong: "9223372036854775807" } } }); + expect(mongoDocumentGridDisplayText(value)).toBe('{"d": {"$date": NumberLong("9223372036854775807")}}'); + }); +}); diff --git a/apps/desktop/src/lib/mongo/mongoDocumentValues.ts b/apps/desktop/src/lib/mongo/mongoDocumentValues.ts index 63a9efd43c..d157ff0bea 100644 --- a/apps/desktop/src/lib/mongo/mongoDocumentValues.ts +++ b/apps/desktop/src/lib/mongo/mongoDocumentValues.ts @@ -147,6 +147,93 @@ export function mongoDocumentDisplayValue(value: unknown): unknown { return value; } +/** + * Strings that are already shell-style BSON display literals. The driver ships + * dates as `ISODate("…")` text and the grid renders other typed scalars the same + * way, so inside arrays and subdocuments they must stay verbatim instead of + * being re-quoted (which produced the escaped `ISODate(\"…\")` mess). + */ +const MONGO_SHELL_DISPLAY_LITERAL_PATTERN = /^(?:ISODate|new Date|ObjectId|NumberLong|NumberInt|NumberDouble|NumberDecimal)\(\s*(["']).+\1\s*\)$/; + +/** + * Renders a browser/extended-JSON BSON value the way mongosh prints it, so an + * array cell reads `[ObjectId("…"), ISODate("…")]` instead of the internal + * `[{"$oid":"…"},"ISODate(\\\"…\\\")"]` encoding. Display-only: edit, copy and + * save paths keep the original JSON representation untouched. + */ +export function mongoDocumentDisplayText(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (typeof value === "string") return MONGO_SHELL_DISPLAY_LITERAL_PATTERN.test(value) ? value : JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map((item) => mongoDocumentDisplayText(item)).join(", ")}]`; + if (typeof value === "object") { + const object = value as Record; + const wrapper = mongoShellWrapperDisplayText(object); + if (wrapper !== undefined) return wrapper; + return `{${Object.entries(object) + .map(([key, item]) => `${JSON.stringify(key)}: ${mongoDocumentDisplayText(item)}`) + .join(", ")}}`; + } + return JSON.stringify(String(value)); +} + +function mongoShellWrapperDisplayText(object: Record): string | undefined { + const keys = Object.keys(object); + if (keys.length !== 1) return undefined; + const inner = object[keys[0] ?? ""]; + switch (keys[0]) { + case "$oid": + return typeof inner === "string" ? `ObjectId(${JSON.stringify(inner)})` : undefined; + case "$numberLong": + return typeof inner === "string" ? `NumberLong(${JSON.stringify(inner)})` : undefined; + case "$numberInt": + return typeof inner === "string" ? `NumberInt(${JSON.stringify(inner)})` : undefined; + case "$numberDouble": + return typeof inner === "string" ? `NumberDouble(${JSON.stringify(inner)})` : undefined; + case "$numberDecimal": + return typeof inner === "string" ? `NumberDecimal(${JSON.stringify(inner)})` : undefined; + case "$date": + return mongoShellDateWrapperDisplayText(inner); + case "$minKey": + return "MinKey()"; + case "$maxKey": + return "MaxKey()"; + default: + return undefined; + } +} + +function mongoShellDateWrapperDisplayText(inner: unknown): string | undefined { + if (typeof inner === "string") return `ISODate(${JSON.stringify(inner)})`; + if (inner && typeof inner === "object" && !Array.isArray(inner)) { + const long = (inner as Record).$numberLong; + if (typeof long === "string" && /^-?\d+$/.test(long)) { + const millis = Number(long); + // Dates beyond the JS Date range cannot round-trip through toISOString(); + // fall back to the raw wrapper instead of throwing past the grid formatter. + if (Number.isFinite(millis) && Math.abs(millis) <= 8.64e15) { + return `ISODate(${JSON.stringify(new Date(millis).toISOString())})`; + } + } + } + return undefined; +} + +/** + * Relaxed Extended JSON for read-only document previews: the browser form keeps + * dates as `ISODate("…")` strings, and the preview must show them as + * `{"$date": "…"}` like MongoDB Compass instead of escaped shell text. + */ +export function mongoDocumentRelaxedExtendedJson(value: unknown): unknown { + const date = mongoShellDateToExtendedJson(value); + if (date !== value) return date; + if (Array.isArray(value)) return value.map((item) => mongoDocumentRelaxedExtendedJson(item)); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value as Record).map(([key, item]) => [key, mongoDocumentRelaxedExtendedJson(item)])); + } + return value; +} + /** * Maps BSON values into the flat collection-grid representation. The grid * needs an internal sentinel for an explicit BSON null because an empty cell @@ -195,14 +282,19 @@ export function mongoDocumentGridDisplayText(value: unknown, formatter?: ColumnF } function formatMongoDocumentGridJson(json: string, formatter: ColumnFormatterConfig | undefined): string { - if (formatter?.kind !== "datetime" || !validMongoDisplayTimeZone(formatter.timezone)) return json; + let value: unknown; try { - const transformed = formatMongoDocumentDates(JSON.parse(json), formatter); - if (!transformed.changed) return json; - return typeof transformed.value === "string" ? transformed.value : JSON.stringify(transformed.value); + value = JSON.parse(json); } catch { return json; } + if (formatter?.kind === "datetime" && validMongoDisplayTimeZone(formatter.timezone)) { + const transformed = formatMongoDocumentDates(value, formatter); + if (transformed.changed) return typeof transformed.value === "string" ? transformed.value : mongoDocumentDisplayText(transformed.value); + } + // Shell-style structure text keeps BSON types inside arrays/subdocuments + // readable (`[ObjectId("…"), ISODate("…")]`) instead of raw JSON wrappers. + return mongoDocumentDisplayText(value); } function validMongoDisplayTimeZone(timeZone: string | undefined): boolean { @@ -454,7 +546,8 @@ export function mongoDocumentIdForGrid(value: unknown): MongoInputValue { return JSON.stringify(value); } -function isMongoExtendedJsonId(value: unknown): value is Record { +/** A single typed scalar wrapper (`{$oid}` / `{$numberLong}`) the grid shows compactly. */ +export function isMongoExtendedJsonId(value: unknown): value is Record { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const object = value as Record; const keys = Object.keys(object); diff --git a/apps/desktop/src/lib/mongo/mongoShellCommand.ts b/apps/desktop/src/lib/mongo/mongoShellCommand.ts index 46c2ac3ad0..142239074a 100644 --- a/apps/desktop/src/lib/mongo/mongoShellCommand.ts +++ b/apps/desktop/src/lib/mongo/mongoShellCommand.ts @@ -1,5 +1,5 @@ import type { QueryResult } from "@/types/database"; -import { mongoDocumentIdForGrid } from "@/lib/mongo/mongoDocumentValues"; +import { isMongoExtendedJsonId, mongoDocumentGridValue, mongoDocumentIdForGrid } from "@/lib/mongo/mongoDocumentValues"; import { chainedMethodCallPattern, describeMongoCommandParseFailure as describeMongoCommandParseFailureBasic, @@ -1136,7 +1136,17 @@ export function evaluateMongoAggregateSafety(command: MongoAggregateCommand, opt return { allowed: true }; } -export function mongoDocumentsToQueryResult(documents: unknown[], executionTimeMs: number, total: number, copyDocuments?: unknown[], totalIsExact = true): QueryResult { +export interface MongoDocumentsToQueryResultOptions { + /** + * Encode arrays/subdocuments with the collection grid's BSON-faithful values so + * display, editing and clipboard share one representation. Export paths that + * need source text pass `false` and keep plain JSON cells. + */ + documentGridValues?: boolean; +} + +export function mongoDocumentsToQueryResult(documents: unknown[], executionTimeMs: number, total: number, copyDocuments?: unknown[], totalIsExact = true, options: MongoDocumentsToQueryResultOptions = {}): QueryResult { + const documentGridValues = options.documentGridValues !== false; const columns: string[] = []; for (const doc of documents) { @@ -1150,8 +1160,8 @@ export function mongoDocumentsToQueryResult(documents: unknown[], executionTimeM } const rows = documents.map((doc) => { - if (isRecord(doc)) return columns.map((column) => toCellValue(doc[column])); - return columns.map((column) => (column === "value" ? toCellValue(doc) : null)); + if (isRecord(doc)) return columns.map((column) => toCellValue(doc[column], documentGridValues)); + return columns.map((column) => (column === "value" ? toCellValue(doc, documentGridValues) : null)); }); return { @@ -2030,8 +2040,12 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } -function toCellValue(value: unknown): string | number | boolean | null { +function toCellValue(value: unknown, documentGridValues = false): string | number | boolean | null { if (value === undefined || value === null) return null; if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value; - return mongoDocumentIdForGrid(value); + // Typed scalar ids keep their compact grid display; arrays and subdocuments take the + // document-grid encoding so display, editing and clipboard share the collection grid's + // representation instead of dumping the internal JSON encoding into the cell. + if (isMongoExtendedJsonId(value)) return mongoDocumentIdForGrid(value); + return documentGridValues ? (mongoDocumentGridValue(value) as string) : mongoDocumentIdForGrid(value); } diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 5e89db5348..53a21a67e4 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -9118,7 +9118,9 @@ export const useQueryStore = defineStore("query", () => { if (pageDocuments.length === 0 || pageDocuments.length < plan.requestLimit || reachedLogicalLimit || reachedExactTotal) break; } - const result = mongoDocumentsToQueryResult(documents, performance.now() - exportStartedAt, totalRows ?? documents.length, copyDocuments, totalRows !== null); + // Full exports return source rows (CSV/SQL text), not the live grid's + // BSON-faithful cell encoding. + const result = mongoDocumentsToQueryResult(documents, performance.now() - exportStartedAt, totalRows ?? documents.length, copyDocuments, totalRows !== null, { documentGridValues: false }); if (result.columns.length === 0) { result.columns = tab.result.columns; result.column_types = tab.result.column_types; diff --git a/packages/app-tests/mongoDocumentValues.test.ts b/packages/app-tests/mongoDocumentValues.test.ts index 585efbca32..c2caa6b435 100644 --- a/packages/app-tests/mongoDocumentValues.test.ts +++ b/packages/app-tests/mongoDocumentValues.test.ts @@ -10,6 +10,7 @@ import { formatMongoShellLiteral, MONGO_DOCUMENT_GRID_NULL, mongoDocumentDisplayValue, + mongoDocumentDisplayText, mongoDocumentGridDisplayText, mongoDocumentGridClipboardText, mongoDocumentGridEditorText, @@ -18,6 +19,7 @@ import { mongoDocumentGridValue, mongoDocumentGridColumnTypes, mongoDocumentIdForGrid, + mongoDocumentRelaxedExtendedJson, parseMongoDocumentInputValue, serializeMongoDocumentId, } from "../../apps/desktop/src/lib/mongo/mongoDocumentValues.ts"; @@ -344,6 +346,28 @@ test("keeps normal Mongo values readable and unsafe Int64 editable", () => { assert.deepEqual(parseMongoDocumentInputValue('NumberLong("9007199254740993")'), { $numberLong: "9007199254740993" }); }); +test("renders Mongo structures as shell-style literals like mongosh", () => { + assert.equal(mongoDocumentDisplayText({ $oid: "6743e4bfa3f6f84bc3fff6c8" }), 'ObjectId("6743e4bfa3f6f84bc3fff6c8")'); + assert.equal(mongoDocumentDisplayText({ $numberLong: "2326645729978441729" }), 'NumberLong("2326645729978441729")'); + assert.equal(mongoDocumentDisplayText({ $numberInt: "42" }), 'NumberInt("42")'); + assert.equal(mongoDocumentDisplayText({ $date: "2026-06-10T13:59:31.287Z" }), 'ISODate("2026-06-10T13:59:31.287Z")'); + assert.equal(mongoDocumentDisplayText({ $date: { $numberLong: "1781099971287" } }), 'ISODate("2026-06-10T13:59:31.287Z")'); + assert.equal(mongoDocumentDisplayText({ $minKey: 1 }), "MinKey()"); + assert.equal(mongoDocumentDisplayText(['ISODate("2026-06-10T13:59:31.287Z")', { $oid: "507f1f77bcf86cd799439011" }, 2]), '[ISODate("2026-06-10T13:59:31.287Z"), ObjectId("507f1f77bcf86cd799439011"), 2]'); + assert.equal(mongoDocumentDisplayText({ note: "[1,2]", nested: [[1], []] }), '{"note": "[1,2]", "nested": [[1], []]}'); + assert.equal(mongoDocumentDisplayText({ $binary: { base64: "AQI=", subType: "00" } }), '{"$binary": {"base64": "AQI=", "subType": "00"}}'); +}); + +test("converts browser-form dates to relaxed Extended JSON for document previews", () => { + assert.deepEqual(mongoDocumentRelaxedExtendedJson({ at: 'ISODate("2026-06-10T13:59:31.287Z")', keep: "ISODate-ish text" }), { + at: { $date: "2026-06-10T13:59:31.287Z" }, + keep: "ISODate-ish text", + }); + assert.deepEqual(mongoDocumentRelaxedExtendedJson({ items: [{ by: { $oid: "507f1f77bcf86cd799439011" }, at: 'new Date("2026-06-10T13:59:31.287Z")' }] }), { + items: [{ by: { $oid: "507f1f77bcf86cd799439011" }, at: { $date: "2026-06-10T13:59:31.287Z" } }], + }); +}); + test("builds edits for Int32, Double, Date, and unsafe Int64", () => { const changes = new Map([ [1, 42], diff --git a/packages/app-tests/mongoShellCommand.test.ts b/packages/app-tests/mongoShellCommand.test.ts index b87847b387..481d0891f5 100644 --- a/packages/app-tests/mongoShellCommand.test.ts +++ b/packages/app-tests/mongoShellCommand.test.ts @@ -38,7 +38,7 @@ import { splitMongoCommandRanges, } from "../../apps/desktop/src/lib/mongo/mongoShellCommand.ts"; import type { MongoWriteCommand } from "../../apps/desktop/src/lib/mongo/mongoShellCommand.ts"; -import { buildMongoUpdateDocument as buildMongoDocumentUpdate, formatMongoShellLiteral as formatMongoDocumentShellLiteral } from "../../apps/desktop/src/lib/mongo/mongoDocumentValues.ts"; +import { buildMongoUpdateDocument as buildMongoDocumentUpdate, formatMongoShellLiteral as formatMongoDocumentShellLiteral, mongoDocumentGridDisplayText, mongoDocumentGridEditorText, mongoDocumentGridValue } from "../../apps/desktop/src/lib/mongo/mongoDocumentValues.ts"; import { normalizeJsonArgument } from "../mongo-shell/src/json.ts"; test("parseMongoFindCommand parses db collection find with an empty JSON filter", () => { @@ -1751,7 +1751,7 @@ test("mongoDocumentsToQueryResult turns mongo documents into grid rows", () => { assert.deepEqual(result.columns, ["_id", "name", "profile", "active"]); assert.deepEqual(result.rows, [ - ["1", "Ada", '{"role":"admin"}', null], + ["1", "Ada", mongoDocumentGridValue({ role: "admin" }), null], ["2", "Lin", null, true], ]); assert.deepEqual(result.mongo_documents, [ @@ -1763,6 +1763,19 @@ test("mongoDocumentsToQueryResult turns mongo documents into grid rows", () => { assert.equal(result.truncated, true); }); +test("mongoDocumentsToQueryResult displays array structures with shell-style BSON literals", () => { + const document = { + _id: "1", + history: [{ at: 'ISODate("2026-06-10T13:59:31.287Z")', by: { $oid: "507f1f77bcf86cd799439011" } }], + }; + const result = mongoDocumentsToQueryResult([document], 5, 1); + const historyCell = result.rows[0]?.[1]; + + assert.equal(typeof historyCell, "string"); + assert.equal(mongoDocumentGridDisplayText(historyCell), '[{"at": ISODate("2026-06-10T13:59:31.287Z"), "by": ObjectId("507f1f77bcf86cd799439011")}]'); + assert.equal(mongoDocumentGridEditorText(historyCell), JSON.stringify(document.history)); +}); + 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" } }];