fix(mongodb): defer result copy statement generation

This commit is contained in:
t8y2 2026-07-21 17:05:20 +08:00
parent 71575c32de
commit 6ace11b097
5 changed files with 125 additions and 67 deletions

View File

@ -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,

View File

@ -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);
});
});

View File

@ -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<boolean> {
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<void> {
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");

View File

@ -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.");
});
});

View File

@ -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<SqlFormatte
}
const indentUnit = settings.useTabs ? "\t" : " ".repeat(settings.tabWidth ?? DEFAULT_SQL_FORMATTER_SETTINGS.tabWidth);
const state: FormatState = { out: "", indentLevel: 0, atLineStart: true, pendingSpace: false, chainIndent: false, pendingChainCall: false, stack: [] };
const state: FormatState = { out: [], lastChar: "", lastNonWhitespace: "", indentLevel: 0, atLineStart: true, pendingSpace: false, chainIndent: false, pendingChainCall: false, stack: [] };
for (let index = 0; index < text.length; index++) {
const char = text[index] ?? "";
@ -111,34 +113,34 @@ export function formatMongoShellText(text: string, settings: Partial<SqlFormatte
appendToken(state, char, indentUnit);
}
return cleanupFormattedMongoText(state.out);
return cleanupFormattedMongoText(state.out.join(""));
}
function appendToken(state: FormatState, token: string, indentUnit: string) {
if (state.atLineStart) {
state.out += indentUnit.repeat(Math.max(0, state.indentLevel + (state.chainIndent ? 1 : 0)));
appendOutput(state, indentUnit.repeat(Math.max(0, state.indentLevel + (state.chainIndent ? 1 : 0))));
state.atLineStart = false;
} else if (state.pendingSpace && shouldInsertPendingSpace(state.out, token)) {
state.out += " ";
} else if (state.pendingSpace && shouldInsertPendingSpace(state.lastNonWhitespace, token)) {
appendOutput(state, " ");
}
state.out += token;
appendOutput(state, token);
state.pendingSpace = false;
state.chainIndent = false;
}
function appendRaw(state: FormatState, token: string, indentUnit: string) {
if (state.atLineStart) {
state.out += indentUnit.repeat(Math.max(0, state.indentLevel + (state.chainIndent ? 1 : 0)));
appendOutput(state, indentUnit.repeat(Math.max(0, state.indentLevel + (state.chainIndent ? 1 : 0))));
state.atLineStart = false;
}
state.out += token;
appendOutput(state, token);
state.pendingSpace = false;
state.chainIndent = false;
}
function newline(state: FormatState, indentUnit: string, indentDelta = 0) {
trimTrailingSpaces(state);
if (!state.out.endsWith("\n")) state.out += "\n";
if (state.lastChar !== "\n") appendOutput(state, "\n");
state.indentLevel = Math.max(0, state.indentLevel + indentDelta);
state.atLineStart = true;
state.pendingSpace = false;
@ -146,8 +148,7 @@ function newline(state: FormatState, indentUnit: string, indentDelta = 0) {
void indentUnit;
}
function shouldInsertPendingSpace(output: string, token: string): boolean {
const previous = lastNonWhitespace(output);
function shouldInsertPendingSpace(previous: string, token: string): boolean {
if (!previous) return false;
if ([".", "(", "[", "{"].includes(previous)) return false;
if ([".", ")", "]", "}", ",", ":"].includes(token)) return false;
@ -258,13 +259,28 @@ function findPreviousNonWhitespace(text: string, start: number): number | null {
return null;
}
function lastNonWhitespace(text: string): string | null {
const index = findPreviousNonWhitespace(text, text.length - 1);
return index == null ? null : (text[index] ?? null);
function appendOutput(state: FormatState, text: string) {
if (!text) return;
state.out.push(text);
state.lastChar = text[text.length - 1] ?? state.lastChar;
const lastNonWhitespaceIndex = findPreviousNonWhitespace(text, text.length - 1);
if (lastNonWhitespaceIndex !== null) state.lastNonWhitespace = text[lastNonWhitespaceIndex] ?? state.lastNonWhitespace;
}
function trimTrailingSpaces(state: FormatState) {
state.out = state.out.replace(/[ \t]+$/g, "");
while (state.out.length > 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 {