diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue index c52e77a61..b16b36d54 100644 --- a/apps/desktop/src/components/document/DocumentBrowser.vue +++ b/apps/desktop/src/components/document/DocumentBrowser.vue @@ -168,7 +168,7 @@ const gridResult = computed(() => { }), ); - return { columns, rows, affected_rows: 0, execution_time_ms: 0, truncated: false }; + return { columns, rows, mongo_documents: docs, affected_rows: 0, execution_time_ms: 0, truncated: false }; }); const documentFilterFieldOptions = computed(() => gridResult.value.columns); const documentStructuredFilterCount = computed(() => (appliedDocumentFilter.value ? 1 : 0)); @@ -421,6 +421,7 @@ const customSaveHandler = computed(() => ({ preview: previewDocumentChanges, supportsInsert: true, readonlyColumns: documentStoreProvider.value.kind === "elasticsearch" ? ["_routing"] : undefined, + targetLabel: props.collection, })); function stopDocumentLoadingTimer() { @@ -943,6 +944,7 @@ function resetTableSearchSplitWidth() { class="flex-1 min-h-0" :result="gridResult" context="results" + :database-type="props.databaseType" 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 000af2d20..65ad36d85 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -6504,6 +6504,7 @@ const { database: computed(() => props.executionDatabase ?? props.database), context: computed(() => props.context), sourceColumns: visibleSourceColumns, + mongoDocuments: computed(() => props.result.mongo_documents), columnTypes: visibleColumnTypes, whereInput: computed(() => currentWhereInput()), orderBy: computed(() => currentOrderBy()), diff --git a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts index 2dfe3fda9..e08b91649 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts @@ -55,6 +55,34 @@ function row(data: unknown[]) { }; } +function createMongoExportState(options: { columns: string[]; item: ReturnType & { sourceIndex: number }; mongoDocuments: unknown[] }) { + const state: UseDataGridExportOptions = { + columns: computed(() => options.columns), + displayItems: computed(() => [options.item]), + sql: computed(() => undefined), + tableMeta: computed(() => undefined), + copyInsertTargetLabel: computed(() => "documents"), + databaseType: computed(() => "mongodb"), + connectionId: computed(() => "connection-1"), + database: computed(() => "dbx"), + context: computed(() => "results"), + sourceColumns: computed(() => options.columns), + mongoDocuments: computed(() => options.mongoDocuments), + columnTypes: computed(() => undefined), + whereInput: computed(() => undefined), + orderBy: computed(() => undefined), + exportBatchSize: computed(() => 1000), + hasCellSelection: computed(() => false), + selectedCells: computed(() => ({ columns: [], rows: [] })), + 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), + }; + return useDataGridExport(state); +} + function createExportState(tableMeta: DataGridTableMeta, columns = tableMeta.columns?.map((column) => column.name) ?? ["id", "name"]) { const item = row(columns.map((column, index) => (column === "id" ? 1 : `value-${index}`))); const options: UseDataGridExportOptions = { @@ -166,4 +194,42 @@ describe("useDataGridExport prepared row statements", () => { expect(toast).toHaveBeenCalledWith("grid.copyFailed: update builder unavailable", 5000); expect(copyToClipboard).not.toHaveBeenCalled(); }); + + it("copies Mongo JSON from the original document using the sorted source index and visible columns", async () => { + const item = { ...row(["true", '{"role":"admin"}']), sourceIndex: 1 }; + const state = createMongoExportState({ + columns: ["booleanText", "profile"], + item, + mongoDocuments: [ + { booleanText: "wrong row", profile: { role: "viewer" } }, + { booleanText: "true", profile: { role: "admin" }, hidden: "not selected" }, + ], + }); + + await state.copyRow(); + + expect(copyToClipboard).toHaveBeenCalledWith(JSON.stringify({ booleanText: "true", profile: { role: "admin" } }, null, 2)); + }); + + it("preserves original Mongo string types in INSERT and applies explicit edits", async () => { + const item = { ...row(["123", "true", '{"kind":"literal"}', "2024-01-01 00:00:00", '{"role":"maintainer"}']), sourceIndex: 0 }; + item.isDirtyCol = [false, false, false, false, true]; + const state = createMongoExportState({ + columns: ["numericText", "booleanText", "jsonText", "dateText", "profile"], + item, + mongoDocuments: [ + { + numericText: "123", + booleanText: "true", + jsonText: '{"kind":"literal"}', + dateText: "2024-01-01 00:00:00", + profile: { role: "admin" }, + }, + ], + }); + + await state.copyRowAsInsert(); + + expect(copyToClipboard).toHaveBeenCalledWith('db.getCollection("documents").insert({"numericText":"123","booleanText":"true","jsonText":"{\\"kind\\":\\"literal\\"}","dateText":"2024-01-01 00:00:00","profile":{"role":"maintainer"}});'); + }); }); diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index d5bffb466..710542be7 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -12,7 +12,7 @@ import { formatSqlInsert } from "@/lib/export/exportFormats"; import { uuid } from "@/lib/common/utils"; import { useSettingsStore } from "@/stores/settingsStore"; import { expandNestedJsonStringsForCopy } from "@/lib/common/jsonCopyValue"; -import { buildMongoCopyInsertDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues"; +import { buildMongoCopyDocumentFromOriginal, buildMongoCopyInsertDocument, formatMongoShellLiteral, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues"; import type { DatabaseType, QueryResult } from "@/types/database"; import type { QueryResultExportRequest } from "@/lib/backend/api"; import { DBX_ROWID_COLUMN } from "@/lib/table/tableEditing"; @@ -40,6 +40,7 @@ export interface UseDataGridExportOptions { database: ComputedRef; context: ComputedRef<"results" | "table-data" | undefined>; sourceColumns: ComputedRef | undefined>; + mongoDocuments?: ComputedRef; columnTypes: ComputedRef | undefined>; whereInput: ComputedRef; orderBy: ComputedRef; @@ -233,7 +234,9 @@ export function useDataGridExport(options: UseDataGridExportOptions) { } function insertCopyKey(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode): string { - const rows = copyStatementRowsKey(insertEligibleRows()); + const eligibleRows = insertEligibleRows(); + const rows = copyStatementRowsKey(eligibleRows); + const originalMongoDocuments = eligibleRows.map((item) => (item.sourceIndex === undefined ? undefined : options.mongoDocuments?.value?.[item.sourceIndex])); return JSON.stringify({ databaseType: databaseType.value ?? null, schema: tableMeta.value?.schema ?? null, @@ -245,12 +248,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) { excludePrimaryKeys, insertMode, rows, + originalMongoDocuments, }); } - function copyStatementRowsKey(rows: RowItem[]): Array<{ id: number; data: CellValue[] }> { + function copyStatementRowsKey(rows: RowItem[]): Array<{ id: number; sourceIndex?: number; data: CellValue[]; isDirtyCol: boolean[] }> { // Prepared copy SQL depends on current cell values; edited rows keep the same id while their data changes. - return rows.map((item) => ({ id: item.id, data: item.data })); + return rows.map((item) => ({ id: item.id, sourceIndex: item.sourceIndex, data: item.data, isDirtyCol: item.isDirtyCol })); } function insertCopyCache(excludePrimaryKeys: boolean, insertMode: DataGridCopyInsertMode): CopyStatementCache { @@ -309,7 +313,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) { collection: copyInsertTargetLabel?.value || tableMeta.value?.tableName || "collection", columns: columns.value, sourceColumns: sourceColumns.value, - rows: rows.map((item) => item.data), + rows, + mongoDocuments: options.mongoDocuments?.value, excludePrimaryKeys, insertMode, }) @@ -516,6 +521,11 @@ export function useDataGridExport(options: UseDataGridExportOptions) { } function rowToJsonObject(item: RowItem): Record { + if (options.databaseType.value === "mongodb" && item.sourceIndex !== undefined) { + const original = options.mongoDocuments?.value?.[item.sourceIndex]; + const document = buildMongoCopyDocumentFromOriginal(original, item.data as MongoInputValue[], columns.value, item.isDirtyCol); + if (document) return document; + } const obj: Record = {}; columns.value.forEach((col, i) => { obj[col] = item.data[i]; @@ -526,7 +536,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) { async function copyRowsAsJson(items: RowItem[]) { if (items.length === 0) return; const value = items.length === 1 ? rowToJsonObject(items[0]) : items.map(rowToJsonObject); - const copyValue = options.databaseType.value === "mongodb" ? expandNestedJsonStringsForCopy(value) : value; + const hasOriginalMongoDocuments = options.databaseType.value === "mongodb" && items.every((item) => item.sourceIndex !== undefined && options.mongoDocuments?.value?.[item.sourceIndex] !== undefined); + const copyValue = options.databaseType.value === "mongodb" && !hasOriginalMongoDocuments ? expandNestedJsonStringsForCopy(value) : value; await copyText(JSON.stringify(copyValue, null, 2)); } @@ -1241,12 +1252,17 @@ function replaceControlCharacters(value: string, replacement: string): string { .join(""); } -function buildMongoCopyInsertStatement(options: { collection: string; columns: string[]; sourceColumns?: Array; rows: CellValue[][]; excludePrimaryKeys?: boolean; insertMode?: DataGridCopyInsertMode }): string | undefined { +function buildMongoCopyInsertStatement(options: { collection: string; columns: string[]; sourceColumns?: Array; rows: RowItem[]; mongoDocuments?: unknown[]; excludePrimaryKeys?: boolean; insertMode?: DataGridCopyInsertMode }): string | undefined { const saveColumns = effectiveColumns(options.sourceColumns, options.columns); const columnIndexes = saveColumns.map((column, index) => ({ column, index })).filter((item): item is { column: string; index: number } => !!item.column); if (columnIndexes.length === 0 || options.rows.length === 0) return undefined; const documentColumns = columnIndexes.map((item) => item.column); - const documents = options.rows.map((row) => buildMongoCopyInsertDocument(columnIndexes.map((item) => row[item.index]) as MongoInputValue[], documentColumns, { excludePrimaryKeys: options.excludePrimaryKeys })); + const documents = options.rows.map((item) => { + const row = columnIndexes.map(({ index }) => item.data[index]) as MongoInputValue[]; + const dirtyColumns = columnIndexes.map(({ index }) => item.isDirtyCol[index] ?? false); + const original = item.sourceIndex === undefined ? undefined : options.mongoDocuments?.[item.sourceIndex]; + return buildMongoCopyDocumentFromOriginal(original, row, documentColumns, dirtyColumns, { excludePrimaryKeys: options.excludePrimaryKeys }) ?? buildMongoCopyInsertDocument(row, documentColumns, { excludePrimaryKeys: options.excludePrimaryKeys }); + }); const collection = `db.getCollection(${JSON.stringify(options.collection)})`; if (documents.length === 1) return `${collection}.insert(${formatMongoShellLiteral(documents[0])});`; if (options.insertMode === "row-by-row") { diff --git a/apps/desktop/src/lib/mongo/mongoDocumentValues.ts b/apps/desktop/src/lib/mongo/mongoDocumentValues.ts index 7320b6eb8..2957e62f1 100644 --- a/apps/desktop/src/lib/mongo/mongoDocumentValues.ts +++ b/apps/desktop/src/lib/mongo/mongoDocumentValues.ts @@ -100,6 +100,26 @@ export function buildMongoCopyInsertDocument(row: MongoInputValue[], columns: st return doc; } +export function buildMongoCopyDocumentFromOriginal(original: unknown, row: MongoInputValue[], columns: string[], dirtyColumns: boolean[], options: { excludePrimaryKeys?: boolean } = {}): Record | null { + if (!original || typeof original !== "object" || Array.isArray(original)) return null; + + const source = original as Record; + const document: Record = {}; + for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) { + const column = columns[columnIndex]; + if (!column || (options.excludePrimaryKeys && column === "_id")) continue; + + // Display strings are ambiguous, so only explicitly edited cells may replace original BSON values. + if (dirtyColumns[columnIndex]) { + const value = row[columnIndex]; + if (value !== null) document[column] = parseMongoDocumentInputValue(value); + continue; + } + if (Object.prototype.hasOwnProperty.call(source, column)) document[column] = source[column]; + } + return document; +} + export function formatMongoShellLiteral(value: unknown): string { if (value === null || value === undefined) return "null"; if (typeof value === "number" || typeof value === "boolean") return String(value); diff --git a/packages/app-tests/documentBrowser.test.ts b/packages/app-tests/documentBrowser.test.ts index 16e491460..7e5b17f03 100644 --- a/packages/app-tests/documentBrowser.test.ts +++ b/packages/app-tests/documentBrowser.test.ts @@ -5,15 +5,25 @@ import { test } from "vitest"; function searchBarSlotSource(): string { const source = readFileSync(path.resolve("apps/desktop/src/components/document/DocumentBrowser.vue"), "utf8"); - const start = source.indexOf('