perf(export): optimize SQL export performance

This commit is contained in:
miracle authored and GitHub committed 2026-08-05 14:57:06 +08:00
1 parent 7fd4aef060
commit 605d20dd4f
14 files changed
+665 -293

No files matched your search

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Dialog, DialogHeader, DialogTitle, DialogFooter, DialogContent } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
@@ -12,13 +12,13 @@ import * as api from "@/lib/backend/api";
import type { ExportProgress } from "@/lib/backend/api";
import { isSchemaAware } from "@/lib/database/databaseFeatureSupport";
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
import { buildAllDatabaseExportPlan, generateDatabaseExportId, runDatabaseExportUntilTerminal, type AllDatabaseExportPlanItem } from "@/lib/export/databaseExport";
import { buildAllDatabaseExportPlan, generateDatabaseExportId, runDatabaseExportUntilTerminal, runWithDatabaseBackupSnapshot, type AllDatabaseExportPlanItem } from "@/lib/export/databaseExport";
import { buildSelectedTablesPayload } from "@/lib/export/databaseExportSelection";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { useToast } from "@/composables/useToast";
import { Input } from "@/components/ui/input";
import { Download, Square, CheckSquare, Search, X, Loader2 } from "@lucide/vue";
import { useExportTracker } from "@/composables/useExportTracker";
import { formatDataTransferDuration, useExportTracker } from "@/composables/useExportTracker";
const { t } = useI18n();
const { toast } = useToast();
@@ -73,6 +73,9 @@ const exportId = ref("");
const exportDone = ref(false);
const exportError = ref<string | null>(null);
const exportCancelled = ref(false);
const exportStartedAt = ref<number | null>(null);
const exportFinishedAt = ref<number | null>(null);
const currentTime = ref(Date.now());
const pendingPrefillTable = ref("");
const pendingPrefillTables = ref<string[]>([]);
const exportAllDatabases = ref(false);
@@ -81,6 +84,25 @@ const batchDatabaseTotal = ref(0);
const batchRowsExported = ref(0);
const activeDatabaseExportId = ref("");
let elapsedTimer: ReturnType<typeof setInterval> | undefined;
onMounted(() => {
elapsedTimer = setInterval(() => {
currentTime.value = Date.now();
}, 1000);
});
onBeforeUnmount(() => {
if (elapsedTimer) clearInterval(elapsedTimer);
});
function finishExportTiming() {
exportFinishedAt.value ??= Date.now();
}
const exportElapsedText = computed(() => {
if (exportStartedAt.value === null) return "";
return formatDataTransferDuration((exportFinishedAt.value ?? currentTime.value) - exportStartedAt.value);
});
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"].includes(c.db_type)));
const canExport = computed(() => {
@@ -257,6 +279,8 @@ async function startExport() {
// Switch to the progress view only after the save dialog closes, and seed a
// preparing state so the dialog is never a blank panel while metadata loads.
isExporting.value = true;
exportStartedAt.value = Date.now();
exportFinishedAt.value = null;
exportDone.value = false;
exportError.value = null;
exportCancelled.value = false;
@@ -272,40 +296,54 @@ async function startExport() {
preparing: true,
};
const request: api.DatabaseExportRequest = {
exportId: exportId.value,
connectionId: connectionId.value,
database: database.value,
schema: schema.value,
filePath,
selectedTables: buildSelectedTablesPayload(tables.value, selectedTables.value),
includeStructure: includeStructure.value,
includeData: includeData.value,
includeObjects: includeObjects.value,
includeCreateDatabase: includeCreateDatabase.value,
dropTableIfExists: dropTableIfExists.value,
omitAutoIncrement: omitAutoIncrement.value,
batchSize: 1000,
};
addDatabaseExportTask(exportId.value, database.value || "database", filePath);
try {
await api.exportDatabaseSql(request, (progress) => {
exportProgress.value = { ...progress };
updateDatabaseExportTask(progress.exportId, progress);
if (progress.status === "Done") {
exportDone.value = true;
isExporting.value = false;
toast(t("databaseExport.exportSuccess"), 3000);
} else if (progress.status === "Error") {
exportError.value = progress.error;
isExporting.value = false;
} else if (progress.status === "Cancelled") {
exportCancelled.value = true;
isExporting.value = false;
}
});
const connectionType = store.getConfig(connectionId.value)?.db_type;
await runWithDatabaseBackupSnapshot(
{
connectionId: connectionId.value,
database: database.value,
enabled: includeData.value && (connectionType === "mysql" || connectionType === "postgres"),
},
async (snapshotSessionId) => {
const request: api.DatabaseExportRequest = {
exportId: exportId.value,
connectionId: connectionId.value,
database: database.value,
schema: schema.value,
filePath,
selectedTables: buildSelectedTablesPayload(tables.value, selectedTables.value),
includeStructure: includeStructure.value,
includeData: includeData.value,
includeObjects: includeObjects.value,
includeCreateDatabase: includeCreateDatabase.value,
dropTableIfExists: dropTableIfExists.value,
omitAutoIncrement: omitAutoIncrement.value,
snapshotSessionId,
batchSize: 1000,
};
await api.exportDatabaseSql(request, (progress) => {
exportProgress.value = { ...progress };
updateDatabaseExportTask(progress.exportId, progress);
if (progress.status === "Done") {
finishExportTiming();
exportDone.value = true;
isExporting.value = false;
toast(t("databaseExport.exportSuccess"), 3000);
} else if (progress.status === "Error") {
finishExportTiming();
exportError.value = progress.error;
isExporting.value = false;
} else if (progress.status === "Cancelled") {
finishExportTiming();
exportCancelled.value = true;
isExporting.value = false;
}
});
},
() => !exportError.value && !exportCancelled.value,
);
} catch (e: any) {
exportError.value = e?.message || String(e);
const lastProgress = exportProgress.value as api.ExportProgress | null;
@@ -320,6 +358,7 @@ async function startExport() {
error: exportError.value,
};
updateDatabaseExportTask(exportId.value, fallbackProgress);
finishExportTiming();
isExporting.value = false;
}
}
@@ -345,6 +384,8 @@ async function startAllDatabasesExport() {
}
isExporting.value = true;
exportStartedAt.value = Date.now();
exportFinishedAt.value = null;
exportDone.value = false;
exportError.value = null;
exportCancelled.value = false;
@@ -352,6 +393,7 @@ async function startAllDatabasesExport() {
batchRowsExported.value = 0;
const dbs = [...selectedDatabases.value];
const connectionType = store.getConfig(connectionId.value)?.db_type;
const batchId = generateDatabaseExportId();
exportId.value = batchId;
exportProgress.value = {
@@ -390,48 +432,62 @@ async function startAllDatabasesExport() {
const currentExportId = `${batchId}-${index + 1}`;
activeDatabaseExportId.value = currentExportId;
const filePath = isTauriRuntime() ? joinExportPath(directoryPath, `${sanitizeFileName(item.fileStem)}.sql`) : `__web_export_${currentExportId}.sql`;
const request: api.DatabaseExportRequest = {
exportId: currentExportId,
connectionId: connectionId.value,
database: item.database,
schema: item.schema,
filePath,
includeStructure: includeStructure.value,
includeData: includeData.value,
includeObjects: includeObjects.value,
includeCreateDatabase: includeCreateDatabase.value,
dropTableIfExists: dropTableIfExists.value,
omitAutoIncrement: omitAutoIncrement.value,
batchSize: 1000,
};
let currentDatabaseRowsExported = 0;
await runDatabaseExportUntilTerminal(request, (progress) => {
const nextRowsExported = Math.max(0, progress.rowsExported);
batchRowsExported.value += Math.max(0, nextRowsExported - currentDatabaseRowsExported);
currentDatabaseRowsExported = nextRowsExported;
exportProgress.value = {
...progress,
exportId: batchId,
currentObject: `${item.displayName}: ${progress.currentObject || item.displayName}`,
rowsExported: batchRowsExported.value,
};
updateDatabaseExportTask(batchId, {
...progress,
exportId: batchId,
currentObject: item.displayName,
objectIndex: index,
totalObjects: exportPlan.length,
rowsExported: batchRowsExported.value,
});
if (progress.status === "Error") {
exportError.value = progress.error;
isExporting.value = false;
} else if (progress.status === "Cancelled") {
exportCancelled.value = true;
isExporting.value = false;
}
});
await runWithDatabaseBackupSnapshot(
{
connectionId: connectionId.value,
database: item.database,
enabled: includeData.value && (connectionType === "mysql" || connectionType === "postgres"),
},
(snapshotSessionId) =>
runDatabaseExportUntilTerminal(
{
exportId: currentExportId,
connectionId: connectionId.value,
database: item.database,
schema: item.schema,
filePath,
includeStructure: includeStructure.value,
includeData: includeData.value,
includeObjects: includeObjects.value,
includeCreateDatabase: includeCreateDatabase.value,
dropTableIfExists: dropTableIfExists.value,
omitAutoIncrement: omitAutoIncrement.value,
snapshotSessionId,
batchSize: 1000,
},
(progress) => {
const nextRowsExported = Math.max(0, progress.rowsExported);
batchRowsExported.value += Math.max(0, nextRowsExported - currentDatabaseRowsExported);
currentDatabaseRowsExported = nextRowsExported;
exportProgress.value = {
...progress,
exportId: batchId,
currentObject: `${item.displayName}: ${progress.currentObject || item.displayName}`,
rowsExported: batchRowsExported.value,
};
updateDatabaseExportTask(batchId, {
...progress,
exportId: batchId,
currentObject: item.displayName,
objectIndex: index,
totalObjects: exportPlan.length,
rowsExported: batchRowsExported.value,
});
if (progress.status === "Error") {
finishExportTiming();
exportError.value = progress.error;
isExporting.value = false;
} else if (progress.status === "Cancelled") {
finishExportTiming();
exportCancelled.value = true;
isExporting.value = false;
}
},
),
(terminal) => terminal.status === "Done",
);
if (exportError.value || exportCancelled.value) break;
activeDatabaseExportId.value = "";
@@ -439,6 +495,7 @@ async function startAllDatabasesExport() {
if (!exportError.value && !exportCancelled.value) {
exportDone.value = true;
finishExportTiming();
isExporting.value = false;
const finalProgress: api.ExportProgress = {
exportId: batchId,
@@ -466,6 +523,7 @@ async function startAllDatabasesExport() {
status: "Error",
error: exportError.value,
});
finishExportTiming();
isExporting.value = false;
}
}
@@ -474,6 +532,7 @@ async function cancelExport() {
if (exportId.value) {
if (exportAllDatabases.value) {
exportCancelled.value = true;
finishExportTiming();
isExporting.value = false;
if (activeDatabaseExportId.value) {
await api.cancelDatabaseExport(activeDatabaseExportId.value);
@@ -509,6 +568,8 @@ function resetState() {
exportDone.value = false;
exportError.value = null;
exportCancelled.value = false;
exportStartedAt.value = null;
exportFinishedAt.value = null;
exportId.value = "";
batchDatabaseIndex.value = 0;
batchDatabaseTotal.value = 0;
@@ -802,6 +863,9 @@ watch(
<div v-if="exportProgress && !isPreparingExport" class="text-xs text-muted-foreground">
{{ exportAllDatabases ? t("databaseExport.allRowsExported", { count: exportProgress.rowsExported.toLocaleString() }) : t("databaseExport.rowsExported", { current: exportProgress.objectIndex, total: exportProgress.totalObjects, count: exportProgress.rowsExported.toLocaleString() }) }}
</div>
<div v-if="exportElapsedText" class="text-xs text-muted-foreground tabular-nums">
{{ t("exportProgress.elapsed", { duration: exportElapsedText }) }}
</div>
</div>
<!-- Status messages -->
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
@@ -8,11 +8,22 @@ import { useToast } from "@/composables/useToast";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import * as api from "@/lib/backend/api";
import { translateBackendError } from "@/i18n/backend-errors";
import { formatDataTransferDuration } from "@/composables/useExportTracker";
const { t } = useI18n();
const { toast } = useToast();
const open = defineModel<boolean>("open", { default: false });
const isRevealing = ref(false);
const currentTime = ref(Date.now());
let elapsedTimer: ReturnType<typeof setInterval> | undefined;
onMounted(() => {
elapsedTimer = setInterval(() => {
currentTime.value = Date.now();
}, 1000);
});
onBeforeUnmount(() => {
if (elapsedTimer) clearInterval(elapsedTimer);
});
const props = defineProps<{
title: string;
@@ -23,6 +34,8 @@ const props = defineProps<{
status: string;
errorMessage: string | null;
filePath?: string | null;
startedAt?: number;
finishedAt?: number;
disableCancel?: boolean;
canMinimize?: boolean;
}>();
@@ -50,6 +63,10 @@ const rowsText = computed(() => {
}
return t("exportProgress.rowsExported", { count: props.rowsExported.toLocaleString() });
});
const elapsedText = computed(() => {
if (props.startedAt === undefined) return "";
return formatDataTransferDuration((props.finishedAt ?? currentTime.value) - props.startedAt);
});
async function revealExportFile() {
if (!props.filePath || isRevealing.value) return;
@@ -120,6 +137,7 @@ async function revealExportFile() {
<!-- Row count -->
<div class="text-xs text-muted-foreground tabular-nums">
{{ rowsText }}
<span v-if="elapsedText" class="ml-2">{{ t("exportProgress.elapsed", { duration: elapsedText }) }}</span>
</div>
</div>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
@@ -12,8 +12,19 @@ const { tasks, activeCount, hasActive, clearFinished, cancelTask, removeTask } =
const open = ref(false);
const showAll = ref(false);
const expandedFailureTaskIds = ref<string[]>([]);
const currentTime = ref(Date.now());
const MAX_VISIBLE = 5;
let elapsedTimer: ReturnType<typeof setInterval> | undefined;
onMounted(() => {
elapsedTimer = setInterval(() => {
currentTime.value = Date.now();
}, 1000);
});
onBeforeUnmount(() => {
if (elapsedTimer) clearInterval(elapsedTimer);
});
const reversedTasks = computed(() => {
return [...tasks.value].reverse();
});
@@ -96,6 +107,12 @@ const rowsText = (task: ExportTask) => {
return `${task.rowsExported.toLocaleString()} ${t("exportProgress.rowsShort")}`;
};
const elapsedText = (task: ExportTask) => {
if (task.startedAt === undefined) return "";
const finishedAt = task.finishedAt ?? currentTime.value;
return t("exportProgress.elapsed", { duration: formatDataTransferDuration(finishedAt - task.startedAt) });
};
const statusIcon = (task: ExportTask) => {
if (isActive(task.status)) {
if (task.kind === "database-export") return DatabaseBackup;
@@ -192,6 +209,7 @@ function failureDetailCount(task: ExportTask) {
<div class="min-w-0 text-muted-foreground">
<span class="break-words tabular-nums">{{ rowsText(task) }}</span>
<span v-if="task.kind !== 'data-transfer' && task.startedAt !== undefined" class="ml-1 tabular-nums">{{ elapsedText(task) }}</span>
<span v-if="task.status === 'Error' && task.errorMessage" class="mt-1 block whitespace-normal break-words text-destructive" :title="translateBackendError(t, task.errorMessage)">
{{ translateBackendError(t, task.errorMessage) }}
</span>
@@ -66,8 +66,8 @@ async function mountPopover() {
await nextTick();
}
describe("ExportProgressPopover data transfer duration", () => {
it("shows frozen transfer elapsed time without adding it to other task kinds", async () => {
describe("ExportProgressPopover task duration", () => {
it("shows frozen transfer elapsed time and live duration for table tasks", async () => {
const tracker = useExportTracker();
now = 1_000;
const transfer = tracker.addDataTransferTask("transfer", "users", 1);
@@ -89,7 +89,7 @@ describe("ExportProgressPopover data transfer duration", () => {
await mountPopover();
expect(document.body.textContent).toContain("Elapsed: 1m 5s");
expect(document.body.textContent?.match(/Elapsed:/g)).toHaveLength(1);
expect(document.body.textContent?.match(/Elapsed:/g)).toHaveLength(2);
});
it("expands complete per-table transfer failure details", async () => {
@@ -5478,6 +5478,8 @@ const exportProgressState = ref({
status: "",
errorMessage: null as string | null,
filePath: null as string | null,
startedAt: undefined as number | undefined,
finishedAt: undefined as number | undefined,
});
const exportCancelHandler = ref<(() => Promise<void>) | null>(null);
const exportCanMinimize = ref(false);
@@ -83,7 +83,6 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { generateDatabaseExportId } from "@/lib/export/databaseExport";
import { copyToClipboard, eventTargetAllowsAppClipboardShortcut } from "@/lib/common/clipboard";
import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableClipboardMenuState, tableClipboardSourceContext, tableDataCopyColumnOptions, type PasteTableMode, type TableClipboardContext } from "@/lib/table/tableClipboard";
import { formatSqlInsert } from "@/lib/export/exportFormats";
import { buildSingleDdlExportFileContent } from "@/lib/export/ddlExport";
import { fetchTableDataForExport } from "@/lib/table/tableDataExport";
import { useConnectionStore } from "@/stores/connectionStore";
@@ -259,7 +258,7 @@ let stopColumnResize: (() => void) | null = null;
let preserveObjectFilterScrollOnce = false;
// Export via background tracker
const { addTask: addExportTask } = useExportTracker();
const { addTask: addExportTask, updateTableExportTask } = useExportTracker();
const needsSchema = computed(() => isSchemaAware(props.connection.db_type) && !connectionUsesDatabaseObjectTreeMode(props.connection));
const canDropTargetCascade = computed(() => dropTarget.value?.type === "TABLE" && supportsDropTableCascade(effectiveDatabaseType.value));
@@ -1749,11 +1748,10 @@ function tableDdlObjectType(type: ObjectBrowserRow["type"]): ObjectSourceKind |
return undefined;
}
async function exportDataLegacy(row: ObjectBrowserRow, format: "json" | "sql") {
async function exportDataLegacy(row: ObjectBrowserRow, format: "json") {
try {
const schema = row.schema || selectedSchema.value;
const tableColumns = format === "sql" ? await api.getColumns(props.connection.id, props.database, schema || props.database, row.name, props.catalog) : undefined;
const queryColumns = props.connection.db_type === "neo4j" ? (tableColumns ?? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name, props.catalog))).map((column) => column.name) : undefined;
const queryColumns = props.connection.db_type === "neo4j" ? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name, props.catalog)).map((column) => column.name) : undefined;
const result = await fetchTableDataForExport({
databaseType: effectiveDatabaseType.value,
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connection.id),
@@ -1776,35 +1774,15 @@ async function exportDataLegacy(row: ObjectBrowserRow, format: "json" | "sql") {
}
await api.exportQueryResultJson(outputPath, result.columns, result.rows);
toast(t("grid.exported"));
return;
}
const content = await formatSqlInsert({
databaseType: effectiveDatabaseType.value,
schema,
tableName: row.name,
columns: result.columns,
columnTypes: tableColumns ? columnTypesForResultColumns(result.columns, tableColumns) : undefined,
rows: result.rows,
});
await saveFileContent(content, `${row.name}.sql`, "SQL", "sql");
toast(t("grid.exported"));
} catch (e: any) {
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
}
function columnTypesForResultColumns(columns: string[], tableColumns: Array<{ name: string; data_type: string }>): Array<string | undefined> {
const typesByName = new Map(tableColumns.map((column) => [column.name.toLocaleLowerCase(), column.data_type]));
return columns.map((column) => typesByName.get(column.toLocaleLowerCase()));
}
async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql") {
if (format === "csv") {
await exportTableData(row, "csv");
return;
}
await exportDataLegacy(row, format);
if (format === "json") await exportDataLegacy(row, format);
else await exportTableData(row, format);
}
function showObjectBrowserXlsxHeaderDialog(hasComments: boolean): Promise<boolean | null> {
@@ -1850,7 +1828,7 @@ async function exportDataXlsx(row: ObjectBrowserRow) {
await exportTableData(row, "xlsx", columnInfos, useCommentHeader);
}
async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx", columnInfos?: ColumnInfo[], useCommentHeader = false) {
async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "sql", columnInfos?: ColumnInfo[], useCommentHeader = false) {
const schema = row.schema || selectedSchema.value;
// Save dialog first
@@ -1860,7 +1838,7 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx", co
if (isTauriRuntime()) {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const filter = format === "csv" ? { name: "CSV", extensions: ["csv"] } : { name: "Excel", extensions: ["xlsx"] };
const filter = format === "csv" ? { name: "CSV", extensions: ["csv"] } : format === "xlsx" ? { name: "Excel", extensions: ["xlsx"] } : { name: "SQL", extensions: ["sql"] };
const path = await save({
defaultPath: defaultName,
filters: [filter],
@@ -1906,14 +1884,12 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx", co
columns,
columnComments: format === "xlsx" ? columnComments : undefined,
batchSize: settingsStore.editorSettings.exportBatchSize,
skipCount: format === "sql",
rowLimit,
};
const terminalProgress = await api.startTableExport(request, (progress) => {
currentTask.rowsExported = progress.rowsExported;
currentTask.totalRows = progress.totalRows;
currentTask.status = progress.status;
currentTask.errorMessage = progress.errorMessage || null;
updateTableExportTask(currentTask.exportId, progress);
});
if (terminalProgress.status === "Done") {
toast(t("grid.exported"));
@@ -0,0 +1,142 @@
import { computed, ref } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useDataGridExport, type UseDataGridExportOptions } from "@/composables/useDataGridExport";
import { useExportTracker } from "@/composables/useExportTracker";
import * as api from "@/lib/backend/api";
vi.mock("@/lib/backend/tauriRuntime", () => ({
isTauriRuntime: () => true,
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({
save: vi.fn().mockResolvedValue("C:/exports/query-result.sql"),
}));
vi.mock("@/lib/backend/api", () => ({
startQueryResultExport: vi.fn(),
cancelQueryResultExport: vi.fn(),
}));
vi.mock("@/composables/useToast", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
vi.mock("@/composables/useDataGridExtractor", () => ({
useDataGridExtractor: () => ({
copyWithExtractor: vi.fn(),
previewWithExtractor: vi.fn(),
canCopyWithExtractor: vi.fn(),
}),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings: {
exportBatchSize: 1000,
globalDateTimeExportFormat: "",
numericColumnRightAlign: true,
},
}),
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock("@/i18n", () => ({
default: { install() {} },
}));
function createOptions(overrides: Partial<UseDataGridExportOptions> = {}): UseDataGridExportOptions {
const base: UseDataGridExportOptions = {
columns: computed(() => ["id", "name"]),
displayItems: computed(() => []),
sql: computed(() => "SELECT id, name FROM users"),
tableMeta: computed(() => ({
tableName: "users",
primaryKeys: [],
columns: [
{ name: "id", data_type: "int" },
{ name: "name", data_type: "text" },
],
})),
databaseType: computed(() => "postgres"),
connectionId: computed(() => "connection-1"),
database: computed(() => "dbx"),
context: computed(() => "results"),
sourceColumns: computed(() => ["id", "name"]),
columnTypes: computed(() => ["int4", "text"]),
allColumnTypes: computed(() => ["int4", "text"]),
whereInput: computed(() => undefined),
orderBy: computed(() => undefined),
exportBatchSize: computed(() => 1000),
hasCellSelection: computed(() => false),
selectedCells: computed(() => ({ columns: [], rows: [] })),
selectedCellMatrix: computed(() => null),
selectedRange: computed(() => null),
contextCell: ref(null),
contextSelectionIsSynthetic: ref(false),
getRowItem: () => undefined,
selectedRowIds: ref(new Set<number>()),
hasRowSelection: computed(() => false),
hasCompleteLocalResult: computed(() => false),
queryResultExportRequest: vi.fn(async (request) => ({
...request,
connectionId: "connection-1",
database: "dbx",
databaseType: "postgres",
queryBaseSql: "SELECT id, name FROM users",
sql: "SELECT id, name FROM users",
executionId: "execution-1",
})),
};
return { ...base, ...overrides };
}
describe("query result SQL export progress", () => {
beforeEach(() => {
vi.clearAllMocks();
const tracker = useExportTracker();
for (const task of tracker.tasks.value) tracker.removeTask(task.exportId);
vi.mocked(api.startQueryResultExport).mockImplementation(async (_request, onProgress) => {
onProgress({ exportId: "sql-export", tableName: "Query Result", rowsExported: 1, totalRows: 1, status: "Done", errorMessage: null });
return { exportId: "sql-export", tableName: "Query Result", rowsExported: 1, totalRows: 1, status: "Done", errorMessage: null };
});
});
afterEach(() => {
const tracker = useExportTracker();
for (const task of tracker.tasks.value) tracker.removeTask(task.exportId);
});
it("uses the same progress dialog and task lifecycle as CSV/XLSX", async () => {
const progressDialog = ref(false);
const progressState = ref({
title: "",
tableName: "",
format: "",
rowsExported: 0,
totalRows: null as number | null,
status: "",
errorMessage: null as string | null,
filePath: null as string | null,
startedAt: undefined as number | undefined,
finishedAt: undefined as number | undefined,
});
const cancelHandler = ref<(() => Promise<void>) | null>(null);
const canMinimize = ref(false);
const state = useDataGridExport(createOptions({ exportProgressDialog: progressDialog, exportProgressState: progressState, exportCancelHandler: cancelHandler, exportCanMinimize: canMinimize }));
await state.exportSql();
expect(progressDialog.value).toBe(true);
expect(canMinimize.value).toBe(false);
expect(progressState.value.format).toBe("sql");
expect(progressState.value.status).toBe("Done");
expect(progressState.value.rowsExported).toBe(1);
expect(progressState.value.startedAt).toEqual(expect.any(Number));
expect(progressState.value.finishedAt).toEqual(expect.any(Number));
expect(cancelHandler.value).toBeNull();
expect(api.startQueryResultExport).toHaveBeenCalledWith(expect.objectContaining({ format: "sql", exportTableName: "users", exportColumnTypes: ["int4", "text"] }), expect.any(Function));
});
});
@@ -130,6 +130,8 @@ export interface UseDataGridExportOptions {
status: string;
errorMessage: string | null;
filePath: string | null;
startedAt?: number;
finishedAt?: number;
}>;
exportCancelHandler?: Ref<(() => Promise<void>) | null>;
exportCanMinimize?: Ref<boolean>;
@@ -147,7 +149,6 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
const { toast } = useToast();
const tracker = useExportTracker();
const exportGuard: ActionActivationGuard = {};
const { addTask, updateTableExportTask, registerTaskCancelHandler, unregisterTaskCancelHandler, removeTask } = useExportTracker();
const {
columns,
@@ -621,6 +622,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
status: "Running",
errorMessage: null,
filePath: null,
startedAt: Date.now(),
finishedAt: undefined,
};
exportProgressDialog.value = true;
}
@@ -670,6 +673,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
status: "Done",
rowsExported: result.rows.length,
totalRows: result.rows.length,
finishedAt: Date.now(),
};
}
toast(t("grid.exported"));
@@ -858,6 +862,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
status: "Running",
errorMessage: null,
filePath: outputPath,
startedAt: Date.now(),
finishedAt: undefined,
};
exportProgressDialog.value = true;
}
@@ -892,6 +898,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
status: "Done",
rowsExported: result.rows.length,
totalRows: result.rows.length,
finishedAt: Date.now(),
};
}
toast(t("grid.exported"));
@@ -1028,6 +1035,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
status: "Running",
errorMessage: null,
filePath: outputPath,
startedAt: Date.now(),
finishedAt: undefined,
};
}
if (exportProgressDialog) exportProgressDialog.value = true;
@@ -1074,6 +1083,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
totalRows: progress.totalRows,
status: progress.status,
errorMessage: progress.errorMessage || null,
finishedAt: progress.status === "Done" || progress.status === "Error" || progress.status === "Cancelled" ? Date.now() : exportProgressState.value.finishedAt,
};
}
tracker.updateTableExportTask(exportId, progress);
@@ -1090,7 +1100,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
return true;
}
async function exportQueryResultViaBackend(format: "csv" | "xlsx" | "txt", rowIds?: number[], includeSqlSheet = false, useCommentHeader = false): Promise<boolean> {
async function exportQueryResultViaBackend(format: "csv" | "xlsx" | "txt" | "sql", rowIds?: number[], includeSqlSheet = false, useCommentHeader = false): Promise<boolean> {
if (rowIds !== undefined || context.value !== "results" || !queryResultExportRequest) {
return false;
}
@@ -1114,7 +1124,14 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
const exportId = uuid();
const baseRequest = await queryResultExportRequest({ exportId, filePath: outputPath, format, includeSqlSheet });
const baseRequest = await queryResultExportRequest({
exportId,
filePath: outputPath,
format,
includeSqlSheet,
exportTableName: format === "sql" ? tableMeta.value?.tableName : undefined,
exportColumnTypes: format === "sql" ? allColumnTypes.value?.map((type) => type ?? null) : undefined,
});
const columnComments = useCommentHeader ? buildColumnComments(columns.value) : undefined;
const request = baseRequest ? { ...baseRequest, dateTimeFormat: useSettingsStore().editorSettings.globalDateTimeExportFormat || undefined, numericColumnRightAlign: useSettingsStore().editorSettings.numericColumnRightAlign ?? true, columnComments } : undefined;
if (!request) throw new Error("Unable to build query result export request");
@@ -1129,6 +1146,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
status: "Running",
errorMessage: null,
filePath: outputPath,
startedAt: Date.now(),
finishedAt: undefined,
};
}
if (exportProgressDialog) exportProgressDialog.value = true;
@@ -1150,6 +1169,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
totalRows: adjustedTotal,
status: progress.status,
errorMessage: progress.errorMessage || null,
finishedAt: progress.status === "Done" || progress.status === "Error" || progress.status === "Cancelled" ? Date.now() : exportProgressState.value.finishedAt,
};
}
tracker.updateTableExportTask(exportId, progress);
@@ -1157,6 +1177,25 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
if (terminalProgress.status === "Done") {
toast(t("grid.exported"));
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (exportProgressState) {
exportProgressState.value = {
...exportProgressState.value,
status: "Error",
errorMessage,
finishedAt: Date.now(),
};
}
tracker.updateTableExportTask(exportId, {
exportId,
tableName: "Query Result",
rowsExported: exportProgressState?.value.rowsExported ?? 0,
totalRows: exportProgressState?.value.totalRows ?? null,
status: "Error",
errorMessage,
});
throw error;
} finally {
if (exportCancelHandler) exportCancelHandler.value = null;
tracker.unregisterTaskCancelHandler(exportId);
@@ -1166,71 +1205,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
async function exportQueryResultSqlViaBackend(rowIds?: number[]): Promise<boolean> {
// Guard: only for query-result context without complete local result, desktop only
if (rowIds !== undefined || context.value !== "results" || !queryResultExportRequest) return false;
if (databaseType.value === "mongodb") return false;
if (hasCompleteLocalResult?.value) return false;
if (!isTauriRuntime()) return false; // Web → local export fallback
// 1. Save dialog FIRST (immediate user feedback)
let outputPath = exportFileName("query-result", "sql");
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({
defaultPath: outputPath,
filters: [{ name: "SQL", extensions: ["sql"] }],
});
if (!path) return true;
outputPath = path as string;
// 2. Register background task FIRST so its exportId drives the request.
// addTask generates its own ID — capture it so progress callbacks match.
const task = addTask(tableMeta.value?.tableName || "Query Result", "sql", outputPath);
const taskExportId = task.exportId;
let request: api.QueryResultExportRequest;
try {
const built = await queryResultExportRequest({
exportId: taskExportId,
filePath: outputPath,
format: "sql",
exportTableName: tableMeta.value?.tableName,
exportColumnTypes: allColumnTypes.value?.map((t) => t ?? null) as Array<string | null | undefined> | undefined,
});
if (!built) {
// builder declined — clean up the task we just registered
removeTask(taskExportId);
return false;
}
request = built;
} catch {
removeTask(taskExportId);
return false;
}
registerTaskCancelHandler(taskExportId, () => api.cancelQueryResultExport(taskExportId, request.executionId));
try {
await api.startQueryResultExport(request, (progress) => {
updateTableExportTask(taskExportId, progress);
if (progress.status === "Done") {
toast(t("grid.exported"));
}
});
} catch (e) {
// 4. Startup rejection → mark task Error (fixes stuck-Running bug)
updateTableExportTask(taskExportId, {
exportId: taskExportId,
tableName: tableMeta.value?.tableName || "Query Result",
rowsExported: 0,
totalRows: null,
status: "Error" as const,
errorMessage: (e as Error)?.message || String(e),
});
throw e;
} finally {
unregisterTaskCancelHandler(taskExportId);
}
return true;
if (!isTauriRuntime()) return false;
return exportQueryResultViaBackend("sql", rowIds);
}
async function exportSql(rowIds?: number[]) {
@@ -175,6 +175,10 @@ function finishDataTransferTask(task: ExportTask) {
task.finishedAt ??= Date.now();
}
function finishExportTask(task: ExportTask) {
task.finishedAt ??= Date.now();
}
export function formatDataTransferDuration(elapsedMs: number): string {
const safeElapsedMs = Math.max(0, Number.isFinite(elapsedMs) ? Math.round(elapsedMs) : 0);
if (safeElapsedMs < 1000) return `${safeElapsedMs} ms`;
@@ -244,6 +248,7 @@ export function useExportTracker() {
totalRows: null,
status: "Running",
errorMessage: null,
startedAt: Date.now(),
});
taskMap.set(id, task);
return task;
@@ -262,6 +267,7 @@ export function useExportTracker() {
errorMessage: null,
objectIndex: 0,
totalObjects: 0,
startedAt: Date.now(),
});
taskMap.set(exportId, task);
return task;
@@ -380,6 +386,7 @@ export function useExportTracker() {
task.totalRows = progress.totalRows;
task.status = normalizeExportStatus(progress.status);
task.errorMessage = progress.errorMessage || null;
if (task.status === "Done" || task.status === "Error" || task.status === "Cancelled") finishExportTask(task);
}
function updateDatabaseExportTask(exportId: string, progress: api.ExportProgress & { overallPercent?: number }) {
@@ -398,6 +405,7 @@ export function useExportTracker() {
if (progress.overallPercent !== undefined) {
task.overallPercent = Math.max(0, Math.min(100, Math.round(progress.overallPercent)));
}
if (task.status === "Done" || task.status === "Error" || task.status === "Cancelled") finishExportTask(task);
}
function updateSqlFileTask(executionId: string, progress: api.SqlFileProgress) {
@@ -10,7 +10,6 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { copyToClipboard } from "@/lib/common/clipboard";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { joinExportedDdls } from "@/lib/export/ddlExport";
import { formatSqlInsert } from "@/lib/export/exportFormats";
import { translateBackendError } from "@/i18n/backend-errors";
import { sidebarStructureExportTargets } from "@/lib/sidebar/sidebarExportRuntime";
import { fetchTableDataForExport } from "@/lib/table/tableDataExport";
@@ -28,7 +27,7 @@ interface SidebarTreeExportRuntimeOptions {
export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOptions) {
const { t } = useI18n();
const { toast } = useToast();
const { addTask: addExportTask } = useExportTracker();
const { addTask: addExportTask, updateTableExportTask } = useExportTracker();
const { activeNode, connectionStore, settingsStore } = options;
async function saveFileContent(content: string, defaultFileName: string, filterName: string, filterExt: string) {
@@ -195,12 +194,7 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
}
}
function columnTypesForResultColumns(columns: string[], tableColumns: ColumnInfo[]): Array<string | undefined> {
const typesByName = new Map(tableColumns.map((column) => [column.name.toLocaleLowerCase(), column.data_type]));
return columns.map((column) => typesByName.get(column.toLocaleLowerCase()));
}
async function exportDataLegacy(format: "json" | "sql") {
async function exportDataLegacy(format: "json") {
const node = activeNode.value;
if (!node.connectionId || !node.database) return;
const connectionId = node.connectionId;
@@ -210,8 +204,7 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
try {
await connectionStore.ensureConnected(connectionId);
const tableColumns = format === "sql" ? await api.getColumns(connectionId, database, node.schema || database, node.label) : undefined;
const queryColumns = config.db_type === "neo4j" ? (tableColumns ?? (await api.getColumns(connectionId, database, node.schema || database, node.label))).map((column) => column.name) : undefined;
const queryColumns = config.db_type === "neo4j" ? (await api.getColumns(connectionId, database, node.schema || database, node.label)).map((column) => column.name) : undefined;
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
const result = await fetchTableDataForExport({
databaseType: effectiveDbType,
@@ -233,25 +226,13 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
}
await api.exportQueryResultJson(outputPath, result.columns, result.rows);
toast(t("grid.exported"));
return;
}
const content = await formatSqlInsert({
databaseType: effectiveDbType,
schema: node.schema,
tableName: node.label,
columns: result.columns,
columnTypes: tableColumns ? columnTypesForResultColumns(result.columns, tableColumns) : undefined,
rows: result.rows,
});
await saveFileContent(content, `${node.label}.sql`, "SQL", "sql");
toast(t("grid.exported"));
} catch (error: any) {
toast(t("grid.exportFailed", { message: translateBackendError(t, error) }), 5000);
}
}
async function exportTableData(format: "csv" | "xlsx") {
async function exportTableData(format: "csv" | "xlsx" | "sql") {
const node = activeNode.value;
if (!node.connectionId || !node.database) return;
const connectionId = node.connectionId;
@@ -261,20 +242,20 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
let task: ExportTask | null = null;
try {
await connectionStore.ensureConnected(connectionId);
// Choose the destination before registering a background task so cancellation creates no orphan tracker entry.
let outputPath = `${node.label}.${format}`;
if (isTauriRuntime()) {
const { save } = await import("@tauri-apps/plugin-dialog");
const filterName = format === "csv" ? "CSV" : format === "xlsx" ? "Excel" : "SQL";
const path = await save({
defaultPath: outputPath,
filters: [{ name: format === "csv" ? "CSV" : "Excel", extensions: [format] }],
filters: [{ name: filterName, extensions: [format] }],
});
if (!path) return;
outputPath = path as string;
}
await connectionStore.ensureConnected(connectionId);
task = addExportTask(node.label, format, outputPath);
const currentTask = task;
const queryColumns = config.db_type === "neo4j" ? (await api.getColumns(connectionId, database, node.schema || database, node.label)).map((column) => column.name) : undefined;
@@ -290,14 +271,12 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
format,
columns: queryColumns,
batchSize: settingsStore.editorSettings.exportBatchSize,
skipCount: format === "sql",
rowLimit,
};
await api.startTableExport(request, (progress) => {
currentTask.rowsExported = progress.rowsExported;
currentTask.totalRows = progress.totalRows;
currentTask.status = progress.status;
currentTask.errorMessage = progress.errorMessage || null;
updateTableExportTask(currentTask.exportId, progress);
if (progress.status === "Done") toast(t("grid.exported"));
else if (progress.status === "Error") toast(t("grid.exportFailed", { message: translateBackendError(t, progress.errorMessage || "") }), 5000);
});
@@ -311,8 +290,8 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
}
async function exportData(format: "csv" | "json" | "sql") {
if (format === "csv") await exportTableData("csv");
else await exportDataLegacy(format);
if (format === "json") await exportDataLegacy(format);
else await exportTableData(format);
}
async function exportDataXlsx() {
@@ -66,6 +66,12 @@ export interface AllDatabaseExportPlanItem {
displayName: string;
}
export interface DatabaseBackupSnapshotOptions {
connectionId: string;
database: string;
enabled: boolean;
}
export function buildInsertStatements(options: BuildExportInsertStatementsOptions): Promise<string[]> {
return api.buildExportInsertStatements(options);
}
@@ -100,6 +106,23 @@ export async function runDatabaseExportUntilTerminal(request: api.DatabaseExport
});
}
export async function runWithDatabaseBackupSnapshot<T>(options: DatabaseBackupSnapshotOptions, operation: (snapshotSessionId: string | undefined) => Promise<T>, shouldPropagateCleanupError: (result: T) => boolean = () => true): Promise<T> {
if (!options.enabled) return operation(undefined);
const snapshot = await api.beginDatabaseBackupSnapshot(options.connectionId, options.database);
let result: T | undefined;
let operationCompleted = false;
try {
result = await operation(snapshot.sessionId);
operationCompleted = true;
return result;
} finally {
await api.rollbackManualTransaction(snapshot.sessionId).catch((error) => {
if (operationCompleted && shouldPropagateCleanupError(result as T)) throw error;
});
}
}
export function buildAllDatabaseExportPlan(options: AllDatabaseExportPlanInput): AllDatabaseExportPlanItem[] {
return options.databases.flatMap((database) => {
const schemas = options.schemaAware ? (options.schemasByDatabase?.[database] ?? []).filter((schema) => schema.trim()) : [database];