fix(mongodb): defer result copy statement generation
This commit is contained in:
parent
71575c32de
commit
6ace11b097
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue