perf(export): optimize SQL export performance
This commit is contained in:
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"));
|
||||
|
||||
+142
@@ -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];
|
||||
|
||||
Reference in new issue
Block a user