diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 7a676223b..7ff191742 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -4988,9 +4988,7 @@ const { copyRow, copyRowAsInsert, copyRowAsInsertWithoutPrimaryKeys, - prefetchRowAsInsertStatement, canCopyRowAsInsert, - prefetchRowAsUpdateStatement, copyRowAsUpdate, canCopyRowAsInsertWithoutPrimaryKeys, canCopyRowAsUpdate, @@ -5001,10 +4999,7 @@ const { copySelectionJson, copySelectionSqlInList, copySelectionAsInsert, - prefetchSelectionAsInsertStatement, - canCopyPreparedSelectionInsert, canCopySelectionAsInsert, - selectionInsertRowCount, copySelectedRowsTsv, copySelectedRowsTsvWithHeaders, copyColumnNames, @@ -5284,7 +5279,6 @@ function onTransposeCellContext(rowIndex: number, actualColIdx: number, event: M selectTransposeCell(rowIndex, actualColIdx, event); const item = displayItemAt(rowIndex); contextCell.value = item ? { rowId: item.id, rowIndex, col: actualColIdx } : null; - void prefetchCopyStatements(); } watch([selectedRange, showCellDetail, isEditingDetail], () => { @@ -6310,7 +6304,6 @@ function selectTransposeRecord(rowIndex: number, event?: MouseEvent) { selectRow(rowIndex); } contextCell.value = { rowId: item.id, rowIndex, col: -1 }; - void prefetchCopyStatements(); } gridRef.value?.focus({ preventScroll: true }); } @@ -6393,7 +6386,6 @@ function onHeaderContext(col: string, columnIndex: number) { } contextHeaderColumn.value = col; contextHeaderColumnIndex.value = columnIndex; - void prefetchCopyStatements(); } async function copyHeaderColumn() { if (!contextHeaderColumn.value) return; @@ -6459,14 +6451,12 @@ function onCellContext(rowId: number, rowIndex: number, colIdx: number, visibleC contextHeaderColumnIndex.value = null; contextCell.value = { rowId, rowIndex, col: colIdx }; if (hasRowSelection.value && isRowSelected(rowId)) { - void prefetchCopyStatements(); return; } clearRowSelection(); if (!cellIsSelected(rowIndex, visibleColIdx)) { selectSingleCell(rowIndex, visibleColIdx); } - void prefetchCopyStatements(); } function onCellEditTextareaInput(event: Event) { @@ -6602,29 +6592,6 @@ function onRowContext(rowId: number, rowIndex: number) { selectedRowIds.value = new Set([rowId]); selection.lastClickedRowIndex.value = rowIndex; } - void prefetchCopyStatements(); -} - -async function prefetchCopyStatements() { - if (canCopySelectionAsInsert.value) { - await prefetchSelectionAsInsertStatement(); - if (selectionInsertRowCount.value > 1 && canCopyPreparedSelectionInsert()) { - await prefetchSelectionAsInsertStatement("row-by-row"); - } - } - await prefetchRowAsInsertStatement(false); - if (isMultiRow.value) { - await prefetchRowAsInsertStatement(false, "row-by-row"); - } - if (canCopyRowAsInsertWithoutPrimaryKeys.value) { - await prefetchRowAsInsertStatement(true); - if (isMultiRow.value) { - await prefetchRowAsInsertStatement(true, "row-by-row"); - } - } - if (canCopyRowAsUpdate.value) { - await prefetchRowAsUpdateStatement(); - } } const sqlOneLiner = computed(() => props.sql?.replace(/\s+/g, " ").trim() || ""); @@ -7309,10 +7276,10 @@ function selectionSubmenu(): ContextMenuItem { const insertItems: ContextMenuItem[] = selectedCells.value.rows.length > 1 ? [ - { label: t("grid.copySelectionInsertMerged"), action: () => copySelectionAsInsert("merged"), disabled: () => !canCopySelectionAsInsert.value || !canCopyPreparedSelectionInsert("merged") }, - { label: t("grid.copySelectionInsertRowByRow"), action: () => copySelectionAsInsert("row-by-row"), disabled: () => !canCopySelectionAsInsert.value || !canCopyPreparedSelectionInsert("row-by-row") }, + { label: t("grid.copySelectionInsertMerged"), action: () => void copySelectionAsInsert("merged"), disabled: () => !canCopySelectionAsInsert.value }, + { label: t("grid.copySelectionInsertRowByRow"), action: () => void copySelectionAsInsert("row-by-row"), disabled: () => !canCopySelectionAsInsert.value }, ] - : [{ label: t("grid.copySelectionInsert"), action: () => copySelectionAsInsert(), disabled: () => !canCopySelectionAsInsert.value || !canCopyPreparedSelectionInsert() }]; + : [{ label: t("grid.copySelectionInsert"), action: () => void copySelectionAsInsert(), disabled: () => !canCopySelectionAsInsert.value }]; return { label: t("grid.selection"), icon: SquareDashed, diff --git a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts index 7ebe32160..b37932901 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridExport.spec.ts @@ -266,14 +266,12 @@ describe("useDataGridExport prepared row statements", () => { vi.mocked(buildDataGridCopyInsertStatement).mockReturnValueOnce(pending.promise); const state = useDataGridExport(options); - const prefetch = state.prefetchSelectionAsInsertStatement("merged"); + const copy = state.copySelectionAsInsert("merged"); await vi.waitFor(() => expect(buildDataGridCopyInsertStatement).toHaveBeenCalledTimes(1)); - expect(state.copySelectionAsInsert("merged")).toBe(false); expect(copyToClipboard).not.toHaveBeenCalled(); pending.resolve("INSERT INTO users (name, note) VALUES ('Ada', 'math'), ('Grace', 'compiler');"); - await prefetch; + await copy; expect(state.canCopyPreparedSelectionInsert("merged")).toBe(true); - expect(state.copySelectionAsInsert("merged")).toBe(true); expect(buildDataGridCopyInsertStatement).toHaveBeenCalledWith( expect.objectContaining({ @@ -353,11 +351,47 @@ describe("useDataGridExport prepared row statements", () => { }, }); - await state.prefetchSelectionAsInsertStatement(); - state.copySelectionAsInsert(); + await state.copySelectionAsInsert(); expect(copyToClipboard).toHaveBeenCalledWith(`db.getCollection("documents").insert({ "booleanText": "true" });`); }); + + it("does not traverse Mongo documents while checking copy availability", async () => { + let documentReads = 0; + const originalDocument = Object.defineProperty({}, "payload", { + enumerable: true, + get() { + documentReads++; + return "large-value"; + }, + }); + const item = { ...row(["large-value"]), sourceIndex: 0 }; + const state = createMongoExportState({ columns: ["payload"], item, mongoDocuments: [originalDocument] }); + + expect(state.canCopyRowAsInsert.value).toBe(true); + expect(documentReads).toBe(0); + + const copy = state.copyRowAsInsert(); + expect(documentReads).toBe(0); + expect(copyToClipboard).not.toHaveBeenCalled(); + + await copy; + expect(documentReads).toBeGreaterThan(0); + expect(copyToClipboard).toHaveBeenCalledWith(expect.stringContaining('"payload": "large-value"')); + }); + + it("preserves oversized Mongo documents without running the formatter", async () => { + const payload = "x".repeat(1_100_000); + const item = { ...row([payload]), sourceIndex: 0 }; + const state = createMongoExportState({ columns: ["payload"], item, mongoDocuments: [{ payload }] }); + + await state.copyRowAsInsert(); + + const copied = vi.mocked(copyToClipboard).mock.calls[0]?.[0] ?? ""; + expect(copied).toHaveLength(payload.length + 'db.getCollection("documents").insert({"payload":""});'.length); + expect(copied.startsWith('db.getCollection("documents").insert({"payload":"')).toBe(true); + expect(copied.endsWith('"});')).toBe(true); + }); }); diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 1bab9ecb4..e3400b4c1 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -453,17 +453,23 @@ export function useDataGridExport(options: UseDataGridExportOptions) { }); return; } - const key = insertCopyKey(excludePrimaryKeys, insertMode); - const current = insertCopyCache(excludePrimaryKeys, insertMode); - if (current.ready && current.key === key) return current.text; - if (current.loading && current.key === key && current.promise) return current.promise; - const data: CopyInsertData = { columns: columns.value, sourceColumns: sourceColumns.value, columnTypes: columnTypes.value?.map((type) => type ?? undefined), rows, }; + if (databaseType.value === "mongodb") { + // Mongo documents can approach the 16 MiB BSON limit. Yield before the + // synchronous shell serialization so menu close/rendering is never held up. + await yieldToMainThread(); + return buildCopyInsertStatement(data, excludePrimaryKeys, insertMode); + } + + const key = insertCopyKey(excludePrimaryKeys, insertMode); + const current = insertCopyCache(excludePrimaryKeys, insertMode); + if (current.ready && current.key === key) return current.text; + if (current.loading && current.key === key && current.promise) return current.promise; const promise = Promise.resolve().then(async () => { const statement = await buildCopyInsertStatement(data, excludePrimaryKeys, insertMode); const latest = insertCopyCache(excludePrimaryKeys, insertMode); @@ -537,6 +543,12 @@ export function useDataGridExport(options: UseDataGridExportOptions) { }); return; } + if (databaseType.value === "mongodb") { + // Keep context-menu work bounded; serialize the selected Mongo fields only + // after the user explicitly invokes the copy command. + await yieldToMainThread(); + return buildCopyInsertStatement(data, false, insertMode); + } const key = selectionInsertCopyKey(insertMode); const current = selectionInsertCopyCache(insertMode); if (current.ready && current.key === key) return current.text; @@ -584,10 +596,16 @@ export function useDataGridExport(options: UseDataGridExportOptions) { return cache.ready && cache.key === selectionInsertCopyKey(insertMode); } - function copySelectionAsInsert(insertMode: DataGridCopyInsertMode = "merged"): boolean { - if (!canCopyPreparedSelectionInsert(insertMode)) return false; - void copyText(selectionInsertCopyCache(insertMode).text); - return true; + async function copySelectionAsInsert(insertMode: DataGridCopyInsertMode = "merged"): Promise { + try { + const statement = await prepareSelectionAsInsertStatement(insertMode); + if (!statement) return false; + await copyText(statement); + return true; + } catch (error: any) { + toast(t("grid.copyFailed", { message: error?.message || String(error) }), 5000); + return false; + } } async function prefetchRowAsUpdateStatement() { @@ -1564,6 +1582,10 @@ function formatMongoCopyInsertStatement(statement: string | undefined): string | } } +function yieldToMainThread(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + function compactLocalTimestamp(date = new Date()): string { const yy = String(date.getFullYear() % 100).padStart(2, "0"); const month = String(date.getMonth() + 1).padStart(2, "0"); diff --git a/apps/desktop/src/lib/__tests__/mongo/mongoFormatter.spec.ts b/apps/desktop/src/lib/__tests__/mongo/mongoFormatter.spec.ts new file mode 100644 index 000000000..e747e81c0 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/mongo/mongoFormatter.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { formatMongoShellText, MAX_MONGO_FORMAT_CHARS } from "@/lib/mongo/mongoFormatter"; + +describe("mongoFormatter", () => { + it("formats documents with many short fields without repeated whole-output rewrites", () => { + const fields = Array.from({ length: 20_000 }, (_, index) => `"field${index}":${index}`).join(","); + const query = `db.items.insert({${fields}});`; + + const formatted = formatMongoShellText(query); + + expect(formatted).toContain('"field0": 0'); + expect(formatted).toContain('"field19999": 19999'); + expect(formatted.length).toBeGreaterThan(query.length); + }); + + it("rejects input beyond the formatter safety limit", () => { + expect(() => formatMongoShellText("x".repeat(MAX_MONGO_FORMAT_CHARS + 1))).toThrow("MongoDB query is too large to format safely."); + }); +}); diff --git a/apps/desktop/src/lib/mongo/mongoFormatter.ts b/apps/desktop/src/lib/mongo/mongoFormatter.ts index 31a5605dd..d49291a9b 100644 --- a/apps/desktop/src/lib/mongo/mongoFormatter.ts +++ b/apps/desktop/src/lib/mongo/mongoFormatter.ts @@ -3,7 +3,9 @@ import { DEFAULT_SQL_FORMATTER_SETTINGS, type SqlFormatterSettings } from "@/lib export const MAX_MONGO_FORMAT_CHARS = 1_000_000; interface FormatState { - out: string; + out: string[]; + lastChar: string; + lastNonWhitespace: string; indentLevel: number; atLineStart: boolean; pendingSpace: boolean; @@ -21,7 +23,7 @@ export function formatMongoShellText(text: string, settings: Partial 0) { + const lastIndex = state.out.length - 1; + const chunk = state.out[lastIndex] ?? ""; + const trimmed = chunk.replace(/[ \t]+$/g, ""); + if (trimmed) { + state.out[lastIndex] = trimmed; + state.lastChar = trimmed[trimmed.length - 1] ?? ""; + return; + } + state.out.pop(); + } + state.lastChar = ""; + state.lastNonWhitespace = ""; } function cleanupFormattedMongoText(text: string): string {