mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-23 04:55:21 +00:00
perf(export): 优化导出ui体验和性能
* docs(getting-started): set brew upgrade to use official homebrew cask * fix(mysql): make admin show handling dialect aware * fix(data-compare): create missing target tables during sync * feat(sql): suggest join conditions from foreign keys * fix(sidebar): use filtered order for range selection * fix(tabs): close connection tabs on disconnect * fix(sqlserver): execute cte queries without wrapping * fix(opengauss): open table structure editor * perf(export): 优化导出ui体验和性能 * perf(export): 优化导出ui体验和性能 * fix(export): normalize status casing and fix web route issues - Add missing table export web routes (POST /export/table, SSE progress, cancel) - Fix tauri table export Promise to wait for terminal events - Change cancel to return Ok(()) so it doesn't get overwritten as Error - Unify ExportStatus to PascalCase across all frontend components - Fix http.ts table export POST body wrapping - Fix duplicate pub mod in dbx-web routes * fix(export): return export file for browser download in web mode Write table export output to temp dir, register a download endpoint, and trigger browser download from the frontend when export completes. * fix(export): avoid table export progress races --------- Co-authored-by: Francesco Lucarelli <francesco06lucarelli@gmail.com> Co-authored-by: t8y2 <1156263951@qq.com> Co-authored-by: dqn <someonegdeng@qq.com>
This commit is contained in:
co-authored by
Francesco Lucarelli
t8y2
dqn
parent
bed72d2132
commit
bea3b45fd8
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, CheckCircle2, XCircle, AlertCircle, X } from "lucide-vue-next";
|
||||
|
||||
const { t } = useI18n();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
tableName: string;
|
||||
format: string;
|
||||
rowsExported: number;
|
||||
totalRows: number | null;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
disableCancel?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: [];
|
||||
"update:open": [value: boolean];
|
||||
}>();
|
||||
|
||||
const isActive = computed(() => props.status === "Running" || props.status === "Writing");
|
||||
const isFinished = computed(() => props.status === "Done" || props.status === "Error" || props.status === "Cancelled");
|
||||
const progressPercent = computed(() => {
|
||||
if (!props.totalRows || props.totalRows <= 0) return 0;
|
||||
return Math.min(100, Math.round((props.rowsExported / props.totalRows) * 100));
|
||||
});
|
||||
const rowsText = computed(() => {
|
||||
if (props.totalRows) {
|
||||
return t("exportProgress.rowsCount", {
|
||||
exported: props.rowsExported.toLocaleString(),
|
||||
total: props.totalRows.toLocaleString(),
|
||||
});
|
||||
}
|
||||
return t("exportProgress.rowsExported", { count: props.rowsExported.toLocaleString() });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:open="open"
|
||||
@update:open="
|
||||
(v: boolean) => {
|
||||
if (!isActive) emit('update:open', v);
|
||||
}
|
||||
"
|
||||
>
|
||||
<DialogContent class="sm:max-w-md" :class="{ 'pointer-events-none': isActive }" @interact-outside.prevent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ title }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="py-4 space-y-4">
|
||||
<!-- Table name and format info -->
|
||||
<div class="text-sm text-muted-foreground">{{ tableName }} (.{{ format }})</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
v-if="status === 'Running' || status === 'Writing'"
|
||||
class="h-full bg-primary rounded-full transition-all duration-300"
|
||||
:class="{ 'animate-pulse': !totalRows }"
|
||||
:style="{ width: totalRows ? `${progressPercent}%` : '50%' }"
|
||||
/>
|
||||
<div v-else-if="status === 'Done'" class="h-full bg-green-500 rounded-full" style="width: 100%" />
|
||||
</div>
|
||||
|
||||
<!-- Status message -->
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<template v-if="status === 'Running'">
|
||||
<Loader2 class="h-4 w-4 animate-spin text-primary" />
|
||||
<span>{{ t("exportProgress.fetching") }}</span>
|
||||
</template>
|
||||
<template v-else-if="status === 'Writing'">
|
||||
<Loader2 class="h-4 w-4 animate-spin text-primary" />
|
||||
<span>{{ t("exportProgress.writing") }}</span>
|
||||
</template>
|
||||
<template v-else-if="status === 'Done'">
|
||||
<CheckCircle2 class="h-4 w-4 text-green-500" />
|
||||
<span class="text-green-700 dark:text-green-400">{{ t("exportProgress.done") }}</span>
|
||||
</template>
|
||||
<template v-else-if="status === 'Error'">
|
||||
<XCircle class="h-4 w-4 text-destructive" />
|
||||
<span class="text-destructive">{{ errorMessage || t("exportProgress.error") }}</span>
|
||||
</template>
|
||||
<template v-else-if="status === 'Cancelled'">
|
||||
<AlertCircle class="h-4 w-4 text-yellow-500" />
|
||||
<span class="text-yellow-700 dark:text-yellow-400">{{ t("exportProgress.cancelled") }}</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Row count -->
|
||||
<div class="text-xs text-muted-foreground tabular-nums">
|
||||
{{ rowsText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<template v-if="isActive">
|
||||
<Button variant="outline" size="sm" :disabled="disableCancel" @click="emit('cancel')">
|
||||
<X class="h-3.5 w-3.5 mr-1" />
|
||||
{{ t("exportProgress.cancel") }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else-if="isFinished">
|
||||
<Button variant="outline" size="sm" @click="emit('update:open', false)">
|
||||
{{ t("exportProgress.close") }}
|
||||
</Button>
|
||||
</template>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -181,6 +181,7 @@ import {
|
||||
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useDataGridExport } from "@/composables/useDataGridExport";
|
||||
import ExportProgressDialog from "@/components/export/ExportProgressDialog.vue";
|
||||
import { DATA_GRID_ROW_NUM_WIDTH, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
|
||||
import { useDataGridSelection } from "@/composables/useDataGridSelection";
|
||||
import { useDataGridEditor } from "@/composables/useDataGridEditor";
|
||||
@@ -3432,6 +3433,18 @@ function setRowStatusFilter(value: string) {
|
||||
rowStatusFilter.value = value as RowStatusFilter;
|
||||
}
|
||||
|
||||
// --- Export progress dialog state ---
|
||||
const exportProgressDialog = ref(false);
|
||||
const exportProgressState = ref({
|
||||
title: "",
|
||||
tableName: "",
|
||||
format: "csv" as string,
|
||||
rowsExported: 0,
|
||||
totalRows: null as number | null,
|
||||
status: "",
|
||||
errorMessage: null as string | null,
|
||||
});
|
||||
|
||||
// --- Export composable ---
|
||||
const {
|
||||
copyText,
|
||||
@@ -3473,6 +3486,8 @@ const {
|
||||
selectedRowIds,
|
||||
hasRowSelection,
|
||||
fullExportResult: props.fullExportResult,
|
||||
exportProgressDialog,
|
||||
exportProgressState,
|
||||
});
|
||||
|
||||
const pageSizeMenuItems = computed(() =>
|
||||
@@ -7692,6 +7707,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
@confirm="confirmDeleteRow"
|
||||
/>
|
||||
<ImagePreviewDialog v-model:open="imagePreviewOpen" :src="imagePreviewSrc" :title="imagePreviewTitle" />
|
||||
<ExportProgressDialog v-model:open="exportProgressDialog" v-bind="exportProgressState" disable-cancel />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue";
|
||||
import ExportProgressDialog from "@/components/export/ExportProgressDialog.vue";
|
||||
import * as api from "@/lib/api";
|
||||
import type { ConnectionConfig, ObjectInfo, ObjectSourceKind } from "@/types/database";
|
||||
import { isSchemaAware } from "@/lib/databaseCapabilities";
|
||||
@@ -69,10 +70,10 @@ import {
|
||||
import { buildRenameObjectSql, supportsObjectRename } from "@/lib/objectRenameSql";
|
||||
import { buildViewDdl } from "@/lib/viewDdl";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import { generateDatabaseExportId } from "@/lib/databaseExport";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { formatSqlInsert } from "@/lib/exportFormats";
|
||||
import { fetchTableDataForExport } from "@/lib/tableDataExport";
|
||||
import { queryTimeoutSecsForConnection } from "@/lib/queryTimeout";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
@@ -155,6 +156,16 @@ const showBatchDropConfirm = ref(false);
|
||||
const batchDropPreviewSql = ref("");
|
||||
let loadId = 0;
|
||||
|
||||
// Export progress state
|
||||
const showExportProgress = ref(false);
|
||||
const exportProgressId = ref("");
|
||||
const exportProgressTableName = ref("");
|
||||
const exportProgressFormat = ref("");
|
||||
const exportProgressRows = ref(0);
|
||||
const exportProgressTotal = ref<number | null>(null);
|
||||
const exportProgressStatus = ref<string>("");
|
||||
const exportProgressError = ref<string | null>(null);
|
||||
|
||||
const needsSchema = computed(() => isSchemaAware(props.connection.db_type));
|
||||
const tableCount = computed(() => rows.value.filter((row) => row.type === "TABLE").length);
|
||||
const viewCount = computed(() => rows.value.filter((row) => row.type === "VIEW").length);
|
||||
@@ -772,7 +783,7 @@ async function exportStructure(row: ObjectBrowserRow) {
|
||||
}
|
||||
}
|
||||
|
||||
async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql") {
|
||||
async function exportDataLegacy(row: ObjectBrowserRow, format: "json" | "sql") {
|
||||
try {
|
||||
const schema = row.schema || selectedSchema.value;
|
||||
const queryColumns =
|
||||
@@ -781,54 +792,14 @@ async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql")
|
||||
(column) => column.name,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (format === "csv" && isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const outputPath = await save({
|
||||
defaultPath: `${row.name}.csv`,
|
||||
filters: [{ name: "CSV", extensions: ["csv"] }],
|
||||
});
|
||||
if (!outputPath) return;
|
||||
await api.exportTableDataCsv({
|
||||
filePath: outputPath as string,
|
||||
connectionId: props.connection.id,
|
||||
database: props.database,
|
||||
schema,
|
||||
tableName: row.name,
|
||||
columns: queryColumns,
|
||||
timeoutSecs: queryTimeoutSecsForConnection(props.connection),
|
||||
});
|
||||
toast(t("grid.exported"));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await fetchTableDataForExport({
|
||||
databaseType: props.connection.db_type,
|
||||
schema,
|
||||
tableName: row.name,
|
||||
columns: queryColumns,
|
||||
executePage: (sql) =>
|
||||
api.executeQuery(props.connection.id, props.database, sql, undefined, undefined, {
|
||||
timeoutSecs: queryTimeoutSecsForConnection(props.connection),
|
||||
}),
|
||||
executePage: (sql) => api.executeQuery(props.connection.id, props.database, sql),
|
||||
});
|
||||
|
||||
if (format === "csv") {
|
||||
let outputPath = `${row.name}.csv`;
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({
|
||||
defaultPath: outputPath,
|
||||
filters: [{ name: "CSV", extensions: ["csv"] }],
|
||||
});
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
}
|
||||
await api.exportQueryResultCsv(outputPath, result.columns, result.rows);
|
||||
toast(t("grid.exported"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (format === "json") {
|
||||
let outputPath = `${row.name}.json`;
|
||||
if (isTauriRuntime()) {
|
||||
@@ -859,40 +830,94 @@ async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql")
|
||||
}
|
||||
}
|
||||
|
||||
async function exportDataXlsx(row: ObjectBrowserRow) {
|
||||
try {
|
||||
const schema = row.schema || selectedSchema.value;
|
||||
const queryColumns =
|
||||
props.connection.db_type === "neo4j"
|
||||
? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name)).map(
|
||||
(column) => column.name,
|
||||
)
|
||||
: undefined;
|
||||
const result = await fetchTableDataForExport({
|
||||
databaseType: props.connection.db_type,
|
||||
schema,
|
||||
tableName: row.name,
|
||||
columns: queryColumns,
|
||||
executePage: (sql) =>
|
||||
api.executeQuery(props.connection.id, props.database, sql, undefined, undefined, {
|
||||
timeoutSecs: queryTimeoutSecsForConnection(props.connection),
|
||||
}),
|
||||
});
|
||||
async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql") {
|
||||
if (format === "csv") {
|
||||
await exportTableData(row, "csv");
|
||||
return;
|
||||
}
|
||||
await exportDataLegacy(row, format);
|
||||
}
|
||||
|
||||
let outputPath = `${row.name}.xlsx`;
|
||||
if (isTauriRuntime()) {
|
||||
async function exportDataXlsx(row: ObjectBrowserRow) {
|
||||
await exportTableData(row, "xlsx");
|
||||
}
|
||||
|
||||
async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx") {
|
||||
const schema = row.schema || selectedSchema.value;
|
||||
|
||||
// Save dialog first
|
||||
let filePath = "";
|
||||
const defaultName = `${row.name}.${format}`;
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
try {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const filter = format === "csv" ? { name: "CSV", extensions: ["csv"] } : { name: "Excel", extensions: ["xlsx"] };
|
||||
const path = await save({
|
||||
defaultPath: outputPath,
|
||||
filters: [{ name: "Excel", extensions: ["xlsx"] }],
|
||||
defaultPath: defaultName,
|
||||
filters: [filter],
|
||||
});
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
filePath = path as string;
|
||||
} catch (e: any) {
|
||||
toast(e?.message || String(e), 5000);
|
||||
return;
|
||||
}
|
||||
await api.exportQueryResultXlsx(outputPath, row.name, result.columns, result.rows);
|
||||
} else {
|
||||
const webExportId = generateDatabaseExportId();
|
||||
filePath = `__web_export_${webExportId}.${format}`;
|
||||
}
|
||||
|
||||
// Set up progress state and open dialog
|
||||
const exportId = generateDatabaseExportId();
|
||||
exportProgressId.value = exportId;
|
||||
exportProgressTableName.value = row.name;
|
||||
exportProgressFormat.value = format;
|
||||
exportProgressRows.value = 0;
|
||||
exportProgressTotal.value = null;
|
||||
exportProgressStatus.value = "Running";
|
||||
exportProgressError.value = null;
|
||||
showExportProgress.value = true;
|
||||
|
||||
// Get columns for neo4j only
|
||||
const queryColumns =
|
||||
props.connection.db_type === "neo4j"
|
||||
? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name)).map(
|
||||
(column) => column.name,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const request: api.TableExportRequest = {
|
||||
exportId,
|
||||
connectionId: props.connection.id,
|
||||
database: props.database,
|
||||
schema,
|
||||
tableName: row.name,
|
||||
filePath,
|
||||
format,
|
||||
columns: queryColumns,
|
||||
};
|
||||
|
||||
try {
|
||||
await api.startTableExport(request, (progress) => {
|
||||
exportProgressRows.value = progress.rowsExported;
|
||||
exportProgressTotal.value = progress.totalRows;
|
||||
exportProgressStatus.value = progress.status;
|
||||
exportProgressError.value = progress.errorMessage || null;
|
||||
});
|
||||
toast(t("grid.exported"));
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
if (exportProgressStatus.value !== "Cancelled") {
|
||||
exportProgressStatus.value = "Error";
|
||||
exportProgressError.value = e?.message || String(e);
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelExport() {
|
||||
if (exportProgressId.value) {
|
||||
await api.cancelTableExport(exportProgressId.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1683,6 +1708,18 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ExportProgressDialog
|
||||
v-model:open="showExportProgress"
|
||||
:title="t('grid.exporting')"
|
||||
:table-name="exportProgressTableName"
|
||||
:format="exportProgressFormat"
|
||||
:rows-exported="exportProgressRows"
|
||||
:total-rows="exportProgressTotal"
|
||||
:status="exportProgressStatus"
|
||||
:error-message="exportProgressError"
|
||||
@cancel="cancelExport"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -90,7 +90,7 @@ import {
|
||||
} from "@/lib/treeNodeClick";
|
||||
import { formatSqlInsert } from "@/lib/exportFormats";
|
||||
import { fetchTableDataForExport } from "@/lib/tableDataExport";
|
||||
import { queryTimeoutSecsForConnection } from "@/lib/queryTimeout";
|
||||
import { generateDatabaseExportId } from "@/lib/databaseExport";
|
||||
import {
|
||||
buildCreateDatabaseSql,
|
||||
buildDuckDbAttachDatabaseSql,
|
||||
@@ -127,6 +127,7 @@ import {
|
||||
} from "@/lib/sidebarTreeSelection";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue";
|
||||
import ExportProgressDialog from "@/components/export/ExportProgressDialog.vue";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { formatShortcut } from "@/lib/shortcutRegistry";
|
||||
@@ -161,6 +162,26 @@ type StructureCopyFormat = "tsv" | "markdown";
|
||||
type DuplicateStructureSource = TreeNode & { connectionId: string; database: string };
|
||||
const { getDatabaseOptions } = useDatabaseOptions();
|
||||
const showVisibleDatabasesDialog = ref(false);
|
||||
const exportProgressDialogOpen = ref(false);
|
||||
const exportProgress = ref<{
|
||||
title: string;
|
||||
tableName: string;
|
||||
format: string;
|
||||
rowsExported: number;
|
||||
totalRows: number | null;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
}>({
|
||||
title: "",
|
||||
tableName: "",
|
||||
format: "",
|
||||
rowsExported: 0,
|
||||
totalRows: null,
|
||||
status: "",
|
||||
errorMessage: null,
|
||||
});
|
||||
const exportCancelled = ref(false);
|
||||
const currentExportId = ref("");
|
||||
|
||||
const props = defineProps<{
|
||||
node: TreeNode;
|
||||
@@ -752,45 +773,38 @@ async function openData() {
|
||||
});
|
||||
queryStore.updateSql(tabId, sql);
|
||||
|
||||
queryStore.setTableMeta(tabId, {
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
columns: [],
|
||||
primaryKeys: [],
|
||||
});
|
||||
|
||||
console.info("[DBX][openData:get-columns:start]", {
|
||||
traceId,
|
||||
database: node.database,
|
||||
schema: querySchema,
|
||||
table: node.label,
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
const columnsPromise = api.getColumns(node.connectionId, node.database, querySchema, node.label);
|
||||
const loadTableMeta = async () => {
|
||||
try {
|
||||
console.info("[DBX][openData:get-columns:start]", {
|
||||
traceId,
|
||||
database: node.database,
|
||||
schema: querySchema,
|
||||
table: node.label,
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
const columns = await api.getColumns(node.connectionId, node.database, querySchema, node.label);
|
||||
console.info("[DBX][openData:get-columns:done]", {
|
||||
traceId,
|
||||
columnCount: columns.length,
|
||||
primaryKeys: columns.filter((column) => column.is_primary_key).map((column) => column.name),
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
const pks = editablePrimaryKeys(config.db_type, columns);
|
||||
queryStore.setTableMeta(tabId, {
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
columns,
|
||||
primaryKeys: pks,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[DBX][openData:get-columns:error]", { traceId, elapsed: elapsed(), error });
|
||||
}
|
||||
};
|
||||
|
||||
console.info("[DBX][openData:execute:start]", { traceId, tabId, elapsed: elapsed() });
|
||||
const dataPromise = queryStore.executeTabSql(tabId, sql);
|
||||
const [columnsResult, dataResult] = await Promise.allSettled([columnsPromise, dataPromise]);
|
||||
if (columnsResult.status === "fulfilled") {
|
||||
const columns = columnsResult.value;
|
||||
console.info("[DBX][openData:get-columns:done]", {
|
||||
traceId,
|
||||
columnCount: columns.length,
|
||||
primaryKeys: columns.filter((column) => column.is_primary_key).map((column) => column.name),
|
||||
elapsed: elapsed(),
|
||||
});
|
||||
const pks = editablePrimaryKeys(config.db_type, columns);
|
||||
queryStore.setTableMeta(tabId, {
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
columns,
|
||||
primaryKeys: pks,
|
||||
});
|
||||
} else {
|
||||
console.warn("[DBX][openData:get-columns:error]", { traceId, elapsed: elapsed(), error: columnsResult.reason });
|
||||
}
|
||||
if (dataResult.status === "rejected") throw dataResult.reason;
|
||||
await queryStore.executeTabSql(tabId, sql);
|
||||
console.info("[DBX][openData:execute:done]", { traceId, tabId, elapsed: elapsed() });
|
||||
void loadTableMeta();
|
||||
} catch (e: any) {
|
||||
console.error("[DBX][openData:error]", { traceId, elapsed: elapsed(), error: e });
|
||||
queryStore.setErrorResult(tabId, e);
|
||||
@@ -2042,7 +2056,7 @@ async function saveStructurePreview() {
|
||||
}
|
||||
}
|
||||
|
||||
async function exportData(format: "csv" | "json" | "sql") {
|
||||
async function exportDataLegacy(format: "csv" | "json" | "sql") {
|
||||
const node = props.node;
|
||||
if (!node.connectionId || !node.database) return;
|
||||
const connectionId = node.connectionId;
|
||||
@@ -2058,36 +2072,12 @@ async function exportData(format: "csv" | "json" | "sql") {
|
||||
(column) => column.name,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (format === "csv" && isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const outputPath = await save({
|
||||
defaultPath: `${node.label}.csv`,
|
||||
filters: [{ name: "CSV", extensions: ["csv"] }],
|
||||
});
|
||||
if (!outputPath) return;
|
||||
await api.exportTableDataCsv({
|
||||
filePath: outputPath as string,
|
||||
connectionId,
|
||||
database,
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
columns: queryColumns,
|
||||
timeoutSecs: queryTimeoutSecsForConnection(config),
|
||||
});
|
||||
toast(t("grid.exported"));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await fetchTableDataForExport({
|
||||
databaseType: config.db_type,
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
columns: queryColumns,
|
||||
executePage: (sql) =>
|
||||
api.executeQuery(connectionId, database, sql, undefined, undefined, {
|
||||
timeoutSecs: queryTimeoutSecsForConnection(config),
|
||||
}),
|
||||
executePage: (sql) => api.executeQuery(connectionId, database, sql),
|
||||
});
|
||||
|
||||
if (format === "csv") {
|
||||
@@ -2136,7 +2126,15 @@ async function exportData(format: "csv" | "json" | "sql") {
|
||||
}
|
||||
}
|
||||
|
||||
async function exportDataXlsx() {
|
||||
async function exportData(format: "csv" | "json" | "sql") {
|
||||
if (format !== "csv") {
|
||||
await exportDataLegacy(format);
|
||||
return;
|
||||
}
|
||||
await exportTableData("csv");
|
||||
}
|
||||
|
||||
async function exportTableData(format: "csv" | "xlsx") {
|
||||
const node = props.node;
|
||||
if (!node.connectionId || !node.database) return;
|
||||
const connectionId = node.connectionId;
|
||||
@@ -2146,40 +2144,93 @@ async function exportDataXlsx() {
|
||||
|
||||
try {
|
||||
await connectionStore.ensureConnected(connectionId);
|
||||
const queryColumns =
|
||||
config.db_type === "neo4j"
|
||||
? (await api.getColumns(connectionId, database, node.schema || database, node.label)).map(
|
||||
(column) => column.name,
|
||||
)
|
||||
: undefined;
|
||||
const result = await fetchTableDataForExport({
|
||||
databaseType: config.db_type,
|
||||
schema: node.schema,
|
||||
tableName: node.label,
|
||||
columns: queryColumns,
|
||||
executePage: (sql) =>
|
||||
api.executeQuery(connectionId, database, sql, undefined, undefined, {
|
||||
timeoutSecs: queryTimeoutSecsForConnection(config),
|
||||
}),
|
||||
});
|
||||
|
||||
let outputPath = `${node.label}.xlsx`;
|
||||
// Step 1: Open save dialog FIRST
|
||||
let outputPath = `${node.label}.${format}`;
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({
|
||||
defaultPath: outputPath,
|
||||
filters: [{ name: "Excel", extensions: ["xlsx"] }],
|
||||
filters: [{ name: format === "csv" ? "CSV" : "Excel", extensions: [format] }],
|
||||
});
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
}
|
||||
await api.exportQueryResultXlsx(outputPath, node.label, result.columns, result.rows);
|
||||
toast(t("grid.exported"));
|
||||
|
||||
// Step 2: Prepare progress state and open dialog
|
||||
const exportId = generateDatabaseExportId();
|
||||
currentExportId.value = exportId;
|
||||
exportCancelled.value = false;
|
||||
exportProgress.value = {
|
||||
title: t("exportProgress.title"),
|
||||
tableName: node.label,
|
||||
format,
|
||||
rowsExported: 0,
|
||||
totalRows: null,
|
||||
status: "Running",
|
||||
errorMessage: null,
|
||||
};
|
||||
exportProgressDialogOpen.value = true;
|
||||
|
||||
// Step 3: Get query columns for neo4j
|
||||
const queryColumns =
|
||||
config.db_type === "neo4j"
|
||||
? (await api.getColumns(connectionId, database, node.schema || database, node.label)).map((c) => c.name)
|
||||
: undefined;
|
||||
|
||||
// Step 4: Start streaming export
|
||||
const request: api.TableExportRequest = {
|
||||
exportId,
|
||||
connectionId,
|
||||
database,
|
||||
schema: node.schema || undefined,
|
||||
tableName: node.label,
|
||||
filePath: outputPath,
|
||||
format,
|
||||
columns: queryColumns,
|
||||
};
|
||||
|
||||
await api.startTableExport(request, (progress) => {
|
||||
exportProgress.value = {
|
||||
...exportProgress.value,
|
||||
rowsExported: progress.rowsExported,
|
||||
totalRows: progress.totalRows,
|
||||
status: progress.status,
|
||||
errorMessage: progress.errorMessage || null,
|
||||
};
|
||||
if (progress.status === "Done") {
|
||||
toast(t("grid.exported"));
|
||||
} else if (progress.status === "Error") {
|
||||
toast(t("grid.exportFailed", { message: progress.errorMessage || "" }), 5000);
|
||||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
if (!exportCancelled.value) {
|
||||
exportProgress.value = {
|
||||
...exportProgress.value,
|
||||
status: "Error",
|
||||
errorMessage: e?.message || String(e),
|
||||
};
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelExport() {
|
||||
exportCancelled.value = true;
|
||||
if (currentExportId.value) {
|
||||
try {
|
||||
await api.cancelTableExport(currentExportId.value);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function exportDataXlsx() {
|
||||
await exportTableData("xlsx");
|
||||
}
|
||||
|
||||
function editConnection() {
|
||||
if (props.node.connectionId) {
|
||||
connectionStore.startEditing(props.node.connectionId);
|
||||
@@ -3658,6 +3709,8 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
:confirm-label="t('contextMenu.dropSchema')"
|
||||
@confirm="confirmDropSchema"
|
||||
/>
|
||||
|
||||
<ExportProgressDialog v-model:open="exportProgressDialogOpen" v-bind="exportProgress" @cancel="cancelExport" />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -46,6 +46,16 @@ export interface UseDataGridExportOptions {
|
||||
selectedRowIds: Ref<Set<number>> | ComputedRef<Set<number>>;
|
||||
hasRowSelection: ComputedRef<boolean>;
|
||||
fullExportResult?: () => Promise<QueryResult | undefined>;
|
||||
exportProgressDialog?: Ref<boolean>;
|
||||
exportProgressState?: Ref<{
|
||||
title: string;
|
||||
tableName: string;
|
||||
format: string;
|
||||
rowsExported: number;
|
||||
totalRows: number | null;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface CopyStatementCache {
|
||||
@@ -93,6 +103,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
selectedRowIds,
|
||||
hasRowSelection,
|
||||
fullExportResult,
|
||||
exportProgressDialog,
|
||||
exportProgressState,
|
||||
} = options;
|
||||
|
||||
async function copyText(text: string) {
|
||||
@@ -441,8 +453,28 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
async function exportCsv(rowIds?: number[]) {
|
||||
await runExclusiveExport(async () => {
|
||||
try {
|
||||
const needsFullExport = !rowIds?.length && !!fullExportResult;
|
||||
if (needsFullExport && exportProgressDialog && exportProgressState) {
|
||||
exportProgressState.value = {
|
||||
title: t("exportProgress.title"),
|
||||
tableName: tableMeta.value?.tableName || "",
|
||||
format: "csv",
|
||||
rowsExported: 0,
|
||||
totalRows: null,
|
||||
status: "Running",
|
||||
errorMessage: null,
|
||||
};
|
||||
exportProgressDialog.value = true;
|
||||
}
|
||||
const result = await resultToExport(rowIds);
|
||||
const rows = result.rows.map((row) => row.map((c) => displayCellValue(c)));
|
||||
if (needsFullExport && exportProgressState) {
|
||||
exportProgressState.value = {
|
||||
...exportProgressState.value,
|
||||
status: "Writing",
|
||||
rowsExported: result.rows.length,
|
||||
};
|
||||
}
|
||||
let outputPath = "export.csv";
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
@@ -450,12 +482,30 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
defaultPath: outputPath,
|
||||
filters: [{ name: "CSV", extensions: ["csv"] }],
|
||||
});
|
||||
if (!path) return;
|
||||
if (!path) {
|
||||
if (exportProgressDialog) exportProgressDialog.value = false;
|
||||
return;
|
||||
}
|
||||
outputPath = path as string;
|
||||
}
|
||||
await api.exportQueryResultCsv(outputPath, result.columns, rows);
|
||||
if (needsFullExport && exportProgressState) {
|
||||
exportProgressState.value = {
|
||||
...exportProgressState.value,
|
||||
status: "Done",
|
||||
rowsExported: result.rows.length,
|
||||
totalRows: result.rows.length,
|
||||
};
|
||||
}
|
||||
toast(t("grid.exported"));
|
||||
} catch (e: any) {
|
||||
if (exportProgressState) {
|
||||
exportProgressState.value = {
|
||||
...exportProgressState.value,
|
||||
status: "Error",
|
||||
errorMessage: e?.message || String(e),
|
||||
};
|
||||
}
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
});
|
||||
@@ -518,15 +568,50 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
}
|
||||
const needsFullExport = !rowIds?.length && !!fullExportResult;
|
||||
if (needsFullExport && exportProgressDialog && exportProgressState) {
|
||||
exportProgressState.value = {
|
||||
title: t("exportProgress.title"),
|
||||
tableName: tableMeta.value?.tableName || "",
|
||||
format: "xlsx",
|
||||
rowsExported: 0,
|
||||
totalRows: null,
|
||||
status: "Running",
|
||||
errorMessage: null,
|
||||
};
|
||||
exportProgressDialog.value = true;
|
||||
}
|
||||
const result = await resultToExport(rowIds);
|
||||
if (needsFullExport && exportProgressState) {
|
||||
exportProgressState.value = {
|
||||
...exportProgressState.value,
|
||||
status: "Writing",
|
||||
rowsExported: result.rows.length,
|
||||
};
|
||||
}
|
||||
await api.exportQueryResultXlsx(
|
||||
outputPath,
|
||||
tableMeta.value?.tableName || "Export",
|
||||
result.columns,
|
||||
result.rows,
|
||||
);
|
||||
if (needsFullExport && exportProgressState) {
|
||||
exportProgressState.value = {
|
||||
...exportProgressState.value,
|
||||
status: "Done",
|
||||
rowsExported: result.rows.length,
|
||||
totalRows: result.rows.length,
|
||||
};
|
||||
}
|
||||
toast(t("grid.exported"));
|
||||
} catch (e: any) {
|
||||
if (exportProgressState) {
|
||||
exportProgressState.value = {
|
||||
...exportProgressState.value,
|
||||
status: "Error",
|
||||
errorMessage: e?.message || String(e),
|
||||
};
|
||||
}
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -654,6 +654,18 @@ export default {
|
||||
sortUnsupported: "This SQL does not support full-result sorting. Try again with a single SELECT query.",
|
||||
truncatedHint: "Results truncated to {count} rows. Use the footer pagination or adjust rows per page.",
|
||||
},
|
||||
exportProgress: {
|
||||
title: "Exporting Table Data",
|
||||
fetching: "Fetching data from database...",
|
||||
writing: "Writing to file...",
|
||||
done: "Export completed!",
|
||||
error: "Export failed",
|
||||
cancelled: "Export cancelled.",
|
||||
rowsCount: "{exported} / {total} rows",
|
||||
rowsExported: "{count} rows exported",
|
||||
cancel: "Cancel",
|
||||
close: "Close",
|
||||
},
|
||||
welcome: {
|
||||
title: "Database Workspace",
|
||||
subtitle: "Choose a connection on the left to browse schema, or open a query tab directly.",
|
||||
|
||||
@@ -587,6 +587,18 @@ export default {
|
||||
sortUnsupported: "Este SQL no admite ordenamiento del resultado completo. Intenta con una consulta SELECT simple.",
|
||||
truncatedHint: "Los resultados están limitados a 10.000 filas. Usa LIMIT/OFFSET en tu consulta para paginar.",
|
||||
},
|
||||
exportProgress: {
|
||||
title: "Exportando datos de la tabla",
|
||||
fetching: "Obteniendo datos de la base de datos...",
|
||||
writing: "Escribiendo en el archivo...",
|
||||
done: "¡Exportación completada!",
|
||||
error: "Error de exportación",
|
||||
cancelled: "Exportación cancelada.",
|
||||
rowsCount: "{exported} / {total} filas",
|
||||
rowsExported: "{count} filas exportadas",
|
||||
cancel: "Cancelar",
|
||||
close: "Cerrar",
|
||||
},
|
||||
welcome: {
|
||||
title: "Espacio de trabajo de base de datos",
|
||||
subtitle:
|
||||
|
||||
@@ -644,6 +644,18 @@ export default {
|
||||
sortUnsupported: "当前 SQL 不支持全量排序,请改为单条 SELECT 查询后再尝试。",
|
||||
truncatedHint: "结果已截断,仅显示前 {count} 行。可通过底部分页继续加载,或调整每页行数。",
|
||||
},
|
||||
exportProgress: {
|
||||
title: "导出表数据",
|
||||
fetching: "正在从数据库获取数据...",
|
||||
writing: "正在写入文件...",
|
||||
done: "导出完成!",
|
||||
error: "导出失败",
|
||||
cancelled: "导出已取消。",
|
||||
rowsCount: "已导出 {exported} / {total} 行",
|
||||
rowsExported: "已导出 {count} 行",
|
||||
cancel: "取消",
|
||||
close: "关闭",
|
||||
},
|
||||
welcome: {
|
||||
title: "数据库工作台",
|
||||
subtitle:
|
||||
|
||||
@@ -188,6 +188,8 @@ export const exportTableDataCsv = forward("exportTableDataCsv");
|
||||
export const exportQueryResultXlsx = forward("exportQueryResultXlsx");
|
||||
export const exportQueryResultJson = forward("exportQueryResultJson");
|
||||
export const exportQueryResultMarkdown = forward("exportQueryResultMarkdown");
|
||||
export const startTableExport = forward("startTableExport");
|
||||
export const cancelTableExport = forward("cancelTableExport");
|
||||
|
||||
// Redis
|
||||
export const redisListDatabases = forward("redisListDatabases");
|
||||
@@ -286,4 +288,7 @@ export type {
|
||||
TableImportProgress,
|
||||
DatabaseExportRequest,
|
||||
ExportProgress,
|
||||
TableExportProgress,
|
||||
TableExportStatus,
|
||||
TableExportRequest,
|
||||
} from "./tauri";
|
||||
|
||||
@@ -49,6 +49,8 @@ import type {
|
||||
TableImportProgress,
|
||||
DatabaseExportRequest,
|
||||
ExportProgress,
|
||||
TableExportRequest,
|
||||
TableExportProgress,
|
||||
TableCsvExportOptions,
|
||||
XlsxCellValue,
|
||||
QueryPaginationExecutionPlanOptions,
|
||||
@@ -1071,6 +1073,68 @@ export async function cancelDatabaseExport(exportId: string): Promise<void> {
|
||||
await post("/api/export/database/cancel", { exportId });
|
||||
}
|
||||
|
||||
// --- Table Export ---
|
||||
|
||||
export async function startTableExport(
|
||||
request: TableExportRequest,
|
||||
onProgress: (progress: TableExportProgress) => void,
|
||||
): Promise<TableExportProgress> {
|
||||
const { exportId } = request;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let started = false;
|
||||
let settled = false;
|
||||
const eventSource = new EventSource(`/api/export/table/progress/${exportId}`);
|
||||
|
||||
const finish = (callback: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
eventSource.close();
|
||||
callback();
|
||||
};
|
||||
|
||||
eventSource.onopen = () => {
|
||||
if (started) return;
|
||||
started = true;
|
||||
post("/api/export/table", { request }).catch((error) => {
|
||||
finish(() => reject(error));
|
||||
});
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const progress: TableExportProgress = JSON.parse(event.data);
|
||||
onProgress(progress);
|
||||
if (progress.status === "Done" || progress.status === "Error" || progress.status === "Cancelled") {
|
||||
if (progress.status === "Error") {
|
||||
finish(() => reject(new Error(progress.errorMessage || "Export failed")));
|
||||
} else if (progress.status === "Done") {
|
||||
// Trigger browser download
|
||||
downloadTableExportFile(exportId, request.format);
|
||||
finish(() => resolve(progress));
|
||||
} else {
|
||||
finish(() => resolve(progress));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
finish(() => reject(new Error("Export progress connection lost")));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function downloadTableExportFile(exportId: string, format: string): void {
|
||||
const ext = format === "csv" ? "csv" : "xlsx";
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/export/table/download/${exportId}`;
|
||||
a.download = `table_export_${exportId}.${ext}`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
export async function cancelTableExport(exportId: string): Promise<void> {
|
||||
return post("/api/export/table/cancel", { exportId });
|
||||
}
|
||||
|
||||
export async function exportQueryResultCsv(
|
||||
filePath: string,
|
||||
columns: string[],
|
||||
|
||||
@@ -1479,6 +1479,20 @@ export interface ExportProgress {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// --- Table Export ---
|
||||
export type TableExportStatus = "Running" | "Writing" | "Done" | "Error" | "Cancelled";
|
||||
|
||||
export interface TableExportRequest {
|
||||
exportId: string;
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
filePath: string;
|
||||
format: "csv" | "xlsx";
|
||||
columns?: string[];
|
||||
}
|
||||
|
||||
export interface TableCsvExportOptions {
|
||||
filePath: string;
|
||||
connectionId: string;
|
||||
@@ -1490,6 +1504,63 @@ export interface TableCsvExportOptions {
|
||||
timeoutSecs?: number;
|
||||
}
|
||||
|
||||
export interface TableExportProgress {
|
||||
exportId: string;
|
||||
tableName: string;
|
||||
rowsExported: number;
|
||||
totalRows: number | null;
|
||||
status: TableExportStatus;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export async function startTableExport(
|
||||
request: TableExportRequest,
|
||||
onProgress: (progress: TableExportProgress) => void,
|
||||
): Promise<TableExportProgress> {
|
||||
let unlisten: UnlistenFn | undefined;
|
||||
let settled = false;
|
||||
let resolveTerminal: (progress: TableExportProgress) => void = () => {};
|
||||
let rejectTerminal: (error: unknown) => void = () => {};
|
||||
|
||||
const terminalProgress = new Promise<TableExportProgress>((resolve, reject) => {
|
||||
resolveTerminal = resolve;
|
||||
rejectTerminal = reject;
|
||||
});
|
||||
|
||||
const finish = (callback: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
unlisten?.();
|
||||
callback();
|
||||
};
|
||||
|
||||
try {
|
||||
unlisten = await listen<TableExportProgress>("table-export-progress", (event) => {
|
||||
if (event.payload.exportId !== request.exportId) return;
|
||||
onProgress(event.payload);
|
||||
if (event.payload.status === "Done" || event.payload.status === "Error" || event.payload.status === "Cancelled") {
|
||||
if (event.payload.status === "Error") {
|
||||
finish(() => rejectTerminal(new Error(event.payload.errorMessage || "Export failed")));
|
||||
} else {
|
||||
finish(() => resolveTerminal(event.payload));
|
||||
}
|
||||
}
|
||||
});
|
||||
await invoke("start_table_export", { request });
|
||||
return await terminalProgress;
|
||||
} catch (error) {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
unlisten?.();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelTableExport(exportId: string): Promise<void> {
|
||||
return invoke("cancel_table_export", { exportId });
|
||||
}
|
||||
|
||||
export async function exportDatabaseSql(
|
||||
request: DatabaseExportRequest,
|
||||
onProgress: (progress: ExportProgress) => void,
|
||||
|
||||
@@ -27,11 +27,11 @@ pub struct TableCsvExportOptions {
|
||||
pub timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
fn escape_csv(value: &str) -> String {
|
||||
pub(crate) fn escape_csv(value: &str) -> String {
|
||||
format!("\"{}\"", value.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
fn value_to_csv_text(value: &Value) -> String {
|
||||
pub(crate) fn value_to_csv_text(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Null => String::new(),
|
||||
Value::Bool(v) => v.to_string(),
|
||||
|
||||
@@ -45,6 +45,7 @@ pub struct ExportProgress {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ExportStatus {
|
||||
Running,
|
||||
Writing,
|
||||
Done,
|
||||
Error,
|
||||
Cancelled,
|
||||
@@ -337,7 +338,7 @@ pub async fn export_database_sql_core(
|
||||
status: ExportStatus::Cancelled,
|
||||
error: None,
|
||||
});
|
||||
return Err("Export cancelled".to_string());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let table_name = &table_info.name;
|
||||
@@ -432,7 +433,7 @@ pub async fn export_database_sql_core(
|
||||
status: ExportStatus::Cancelled,
|
||||
error: None,
|
||||
});
|
||||
return Err("Export cancelled".to_string());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let sql = crate::transfer::pagination_sql(
|
||||
|
||||
@@ -36,6 +36,7 @@ pub mod sql_dialect;
|
||||
pub mod sql_editability;
|
||||
pub mod sql_file_import;
|
||||
pub mod storage;
|
||||
pub mod table_export;
|
||||
pub mod table_import;
|
||||
pub mod table_structure_sql;
|
||||
pub mod text_export;
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::io::Write;
|
||||
|
||||
use crate::connection::AppState;
|
||||
use crate::csv_export::{escape_csv, format_csv, value_to_csv_text};
|
||||
use crate::database_export::is_export_cancelled;
|
||||
pub use crate::database_export::ExportStatus;
|
||||
use crate::transfer::{count_sql, execute_on_pool, pagination_sql};
|
||||
use crate::xlsx_export::{build_xlsx_workbook, XlsxWorksheetData};
|
||||
|
||||
const DEFAULT_BATCH_SIZE: usize = 500;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableExportRequest {
|
||||
pub export_id: String,
|
||||
pub connection_id: String,
|
||||
pub database: String,
|
||||
pub schema: Option<String>,
|
||||
pub table_name: String,
|
||||
pub file_path: String,
|
||||
/// "csv" or "xlsx"
|
||||
pub format: String,
|
||||
#[serde(default)]
|
||||
pub columns: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableExportProgress {
|
||||
pub export_id: String,
|
||||
pub table_name: String,
|
||||
pub rows_exported: u64,
|
||||
pub total_rows: Option<u64>,
|
||||
pub status: ExportStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
/// Format rows as CSV text without a header row.
|
||||
/// Used for streaming subsequent pagination batches.
|
||||
fn format_csv_rows(rows: &[Vec<Value>]) -> String {
|
||||
rows.iter()
|
||||
.map(|row| row.iter().map(|cell| escape_csv(&value_to_csv_text(cell))).collect::<Vec<_>>().join(","))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
pub async fn export_table_data_core(
|
||||
state: &AppState,
|
||||
request: &TableExportRequest,
|
||||
on_progress: impl Fn(TableExportProgress),
|
||||
) -> Result<(), String> {
|
||||
// 1. Get database type
|
||||
let db_type = state
|
||||
.configs
|
||||
.read()
|
||||
.await
|
||||
.get(&request.connection_id)
|
||||
.map(|c| c.db_type)
|
||||
.ok_or_else(|| format!("Connection config not found: {}", request.connection_id))?;
|
||||
|
||||
// 2. Get pool
|
||||
let pool_key = state.get_or_create_pool(&request.connection_id, Some(&request.database)).await?;
|
||||
|
||||
// 3. Get columns
|
||||
let columns = crate::schema::get_columns_core(
|
||||
state,
|
||||
&request.connection_id,
|
||||
&request.database,
|
||||
request.schema.as_deref().unwrap_or(""),
|
||||
&request.table_name,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let col_names: Vec<String> = columns.iter().map(|c| c.name.clone()).collect();
|
||||
|
||||
// 4. Optionally filter to requested columns
|
||||
let col_names = if let Some(requested_cols) = &request.columns {
|
||||
if requested_cols.is_empty() {
|
||||
col_names
|
||||
} else {
|
||||
let filtered: Vec<String> = requested_cols.iter().filter(|c| col_names.contains(c)).cloned().collect();
|
||||
if filtered.is_empty() {
|
||||
return Err("None of the requested columns exist in the table".to_string());
|
||||
}
|
||||
filtered
|
||||
}
|
||||
} else {
|
||||
col_names
|
||||
};
|
||||
|
||||
if col_names.is_empty() {
|
||||
return Err("No columns found for table".to_string());
|
||||
}
|
||||
|
||||
// 5. Get total row count for progress estimation
|
||||
let count_query = count_sql(&request.table_name, request.schema.as_deref().unwrap_or(""), &db_type);
|
||||
let total_rows = match execute_on_pool(state, &pool_key, &count_query).await {
|
||||
Ok(result) => result.rows.first().and_then(|r| r.first()).and_then(|v| match v {
|
||||
Value::Number(n) => n.as_u64(),
|
||||
Value::String(s) => s.parse::<u64>().ok(),
|
||||
_ => None,
|
||||
}),
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
// 6. Emit initial Running progress
|
||||
on_progress(TableExportProgress {
|
||||
export_id: request.export_id.clone(),
|
||||
table_name: request.table_name.clone(),
|
||||
rows_exported: 0,
|
||||
total_rows,
|
||||
status: ExportStatus::Running,
|
||||
error_message: None,
|
||||
});
|
||||
|
||||
// 7. Create output file
|
||||
let mut file = std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to create file: {e}"))?;
|
||||
|
||||
let mut rows_exported: u64 = 0;
|
||||
let mut offset: u64 = 0;
|
||||
let batch_size = DEFAULT_BATCH_SIZE;
|
||||
|
||||
match request.format.to_lowercase().as_str() {
|
||||
"csv" => {
|
||||
// Write UTF-8 BOM
|
||||
file.write_all(b"\xEF\xBB\xBF").map_err(|e| format!("Failed to write BOM: {e}"))?;
|
||||
|
||||
let mut is_first_batch = true;
|
||||
|
||||
loop {
|
||||
// Check cancellation between batches
|
||||
if is_export_cancelled(&request.export_id).await {
|
||||
on_progress(TableExportProgress {
|
||||
export_id: request.export_id.clone(),
|
||||
table_name: request.table_name.clone(),
|
||||
rows_exported,
|
||||
total_rows,
|
||||
status: ExportStatus::Cancelled,
|
||||
error_message: Some("Export cancelled".to_string()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let sql = pagination_sql(
|
||||
&col_names,
|
||||
&request.table_name,
|
||||
request.schema.as_deref().unwrap_or(""),
|
||||
&db_type,
|
||||
offset,
|
||||
batch_size,
|
||||
);
|
||||
|
||||
let result = execute_on_pool(state, &pool_key, &sql).await?;
|
||||
let row_count = result.rows.len();
|
||||
if row_count == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
if is_first_batch {
|
||||
// First batch: write header + rows via format_csv
|
||||
let csv_content = format_csv(&col_names, &result.rows);
|
||||
file.write_all(csv_content.as_bytes()).map_err(|e| format!("Failed to write CSV: {e}"))?;
|
||||
is_first_batch = false;
|
||||
} else {
|
||||
// Subsequent batches: write rows only (prepend newline for separation)
|
||||
let rows_csv = format_csv_rows(&result.rows);
|
||||
if !rows_csv.is_empty() {
|
||||
write!(file, "\n{rows_csv}").map_err(|e| format!("Failed to write CSV rows: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
rows_exported += row_count as u64;
|
||||
offset += row_count as u64;
|
||||
|
||||
on_progress(TableExportProgress {
|
||||
export_id: request.export_id.clone(),
|
||||
table_name: request.table_name.clone(),
|
||||
rows_exported,
|
||||
total_rows,
|
||||
status: ExportStatus::Running,
|
||||
error_message: None,
|
||||
});
|
||||
|
||||
if row_count < batch_size {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
"xlsx" => {
|
||||
let mut all_rows: Vec<Vec<Value>> = Vec::new();
|
||||
|
||||
loop {
|
||||
// Check cancellation between batches
|
||||
if is_export_cancelled(&request.export_id).await {
|
||||
on_progress(TableExportProgress {
|
||||
export_id: request.export_id.clone(),
|
||||
table_name: request.table_name.clone(),
|
||||
rows_exported,
|
||||
total_rows,
|
||||
status: ExportStatus::Cancelled,
|
||||
error_message: Some("Export cancelled".to_string()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let sql = pagination_sql(
|
||||
&col_names,
|
||||
&request.table_name,
|
||||
request.schema.as_deref().unwrap_or(""),
|
||||
&db_type,
|
||||
offset,
|
||||
batch_size,
|
||||
);
|
||||
|
||||
let result = execute_on_pool(state, &pool_key, &sql).await?;
|
||||
let row_count = result.rows.len();
|
||||
if row_count == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
all_rows.extend(result.rows);
|
||||
rows_exported += row_count as u64;
|
||||
offset += row_count as u64;
|
||||
|
||||
on_progress(TableExportProgress {
|
||||
export_id: request.export_id.clone(),
|
||||
table_name: request.table_name.clone(),
|
||||
rows_exported,
|
||||
total_rows,
|
||||
status: ExportStatus::Running,
|
||||
error_message: None,
|
||||
});
|
||||
|
||||
if row_count < batch_size {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit Writing progress before building XLSX
|
||||
on_progress(TableExportProgress {
|
||||
export_id: request.export_id.clone(),
|
||||
table_name: request.table_name.clone(),
|
||||
rows_exported,
|
||||
total_rows,
|
||||
status: ExportStatus::Writing,
|
||||
error_message: None,
|
||||
});
|
||||
|
||||
// Build XLSX workbook from accumulated rows
|
||||
let workbook_data =
|
||||
XlsxWorksheetData { sheet_name: Some(request.table_name.clone()), columns: col_names, rows: all_rows };
|
||||
let xlsx_bytes = build_xlsx_workbook(&workbook_data)?;
|
||||
file.write_all(&xlsx_bytes).map_err(|e| format!("Failed to write XLSX file: {e}"))?;
|
||||
}
|
||||
other => {
|
||||
return Err(format!("Unsupported export format: {other}"));
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Emit Done progress
|
||||
on_progress(TableExportProgress {
|
||||
export_id: request.export_id.clone(),
|
||||
table_name: request.table_name.clone(),
|
||||
rows_exported,
|
||||
total_rows,
|
||||
status: ExportStatus::Done,
|
||||
error_message: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database_export::{clear_export_cancelled, set_export_cancelled};
|
||||
use serde_json::json;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helper: check that two CSV strings are equivalent by splitting lines
|
||||
// -----------------------------------------------------------------------
|
||||
fn csv_lines_equal(actual: &str, expected: &str) -> bool {
|
||||
let actual_lines: Vec<&str> = actual.lines().collect();
|
||||
let expected_lines: Vec<&str> = expected.lines().collect();
|
||||
actual_lines == expected_lines
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// format_csv_rows
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn formats_csv_rows_with_multiple_columns() {
|
||||
let rows = vec![vec![json!(1), json!("Alice")], vec![json!(2), json!("Bob \"Builder\"")]];
|
||||
let out = format_csv_rows(&rows);
|
||||
assert!(csv_lines_equal(&out, "\"1\",\"Alice\"\n\"2\",\"Bob \"\"Builder\"\"\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_csv_rows_with_null_values() {
|
||||
let rows = vec![vec![json!(1), Value::Null, json!("active")], vec![json!(2), json!("some notes"), Value::Null]];
|
||||
let out = format_csv_rows(&rows);
|
||||
assert!(csv_lines_equal(&out, "\"1\",\"\",\"active\"\n\"2\",\"some notes\",\"\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_csv_rows_with_boolean_and_number_values() {
|
||||
let rows = vec![vec![json!(true), json!(3.14)], vec![json!(false), json!(-42)]];
|
||||
let out = format_csv_rows(&rows);
|
||||
assert!(csv_lines_equal(&out, "\"true\",\"3.14\"\n\"false\",\"-42\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_csv_rows_returns_empty_string_for_empty_rows() {
|
||||
let rows: Vec<Vec<Value>> = vec![];
|
||||
let out = format_csv_rows(&rows);
|
||||
assert_eq!(out, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_csv_rows_single_row() {
|
||||
let rows = vec![vec![json!("just"), json!("one")]];
|
||||
let out = format_csv_rows(&rows);
|
||||
assert_eq!(out, "\"just\",\"one\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_csv_rows_escapes_embedded_commas_and_newlines() {
|
||||
let rows = vec![vec![json!("hello,world"), json!("line1\nline2")]];
|
||||
let out = format_csv_rows(&rows);
|
||||
assert!(out.contains("\"hello,world\""));
|
||||
assert!(out.contains("\"line1\nline2\""));
|
||||
// CSV escaping wraps in quotes, so commas inside are safe
|
||||
let fields: Vec<&str> = out.split(',').collect();
|
||||
assert_eq!(fields.len(), 2);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Cancellation flow
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cancellation_set_and_cleared_correctly() {
|
||||
let export_id = "test-cancel-1";
|
||||
|
||||
assert!(!poll_is_cancelled(export_id));
|
||||
block_on(set_export_cancelled(export_id));
|
||||
assert!(poll_is_cancelled(export_id));
|
||||
block_on(clear_export_cancelled(export_id));
|
||||
assert!(!poll_is_cancelled(export_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_is_id_scoped() {
|
||||
let id_a = "cancel-scope-a";
|
||||
let id_b = "cancel-scope-b";
|
||||
|
||||
block_on(set_export_cancelled(id_a));
|
||||
assert!(poll_is_cancelled(id_a));
|
||||
assert!(!poll_is_cancelled(id_b));
|
||||
block_on(clear_export_cancelled(id_a));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// XLSX workbook integration
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn builds_xlsx_workbook_with_table_export_data() {
|
||||
let data = XlsxWorksheetData {
|
||||
sheet_name: Some("employees".to_string()),
|
||||
columns: vec!["id".to_string(), "name".to_string(), "salary".to_string()],
|
||||
rows: vec![
|
||||
vec![json!(1), json!("Alice"), json!(75000.50)],
|
||||
vec![json!(2), json!("Bob"), json!(82000)],
|
||||
vec![json!(3), Value::Null, json!(0)],
|
||||
],
|
||||
};
|
||||
let workbook = build_xlsx_workbook(&data).expect("XLSX build should succeed");
|
||||
let text = String::from_utf8_lossy(&workbook);
|
||||
|
||||
assert_eq!(workbook[0], 0x50, "Should be a ZIP (PK) archive");
|
||||
assert_eq!(workbook[1], 0x4b);
|
||||
assert!(text.contains("[Content_Types].xml"));
|
||||
assert!(text.contains("xl/worksheets/sheet1.xml"));
|
||||
assert!(text.contains("name=\"employees\""));
|
||||
assert!(text.contains("<v>75000.5</v>"));
|
||||
assert!(text.contains("Alice"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CSV header + rows (format_csv) — basic integration check
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn format_csv_produces_header_and_rows() {
|
||||
let out = format_csv(
|
||||
&["col1".to_string(), "col2".to_string()],
|
||||
&[vec![json!("a"), json!("b")], vec![json!("c"), json!("d")]],
|
||||
);
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
assert_eq!(lines.len(), 3, "header + 2 data rows = 3 lines");
|
||||
assert_eq!(lines[0], "\"col1\",\"col2\"");
|
||||
assert_eq!(lines[1], "\"a\",\"b\"");
|
||||
assert_eq!(lines[2], "\"c\",\"d\"");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers for async cancellation in tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn poll_is_cancelled(export_id: &str) -> bool {
|
||||
block_on(is_export_cancelled(export_id))
|
||||
}
|
||||
|
||||
fn block_on<F: std::future::Future>(future: F) -> F::Output {
|
||||
tokio::runtime::Runtime::new().expect("create tokio runtime").block_on(future)
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ async fn main() {
|
||||
sse_channels: RwLock::new(HashMap::new()),
|
||||
sql_file_executions: RwLock::new(HashMap::new()),
|
||||
login_rate_limit: tokio::sync::Mutex::new(state::LoginRateLimit { fail_count: 0, locked_until: None }),
|
||||
export_files: RwLock::new(HashMap::new()),
|
||||
});
|
||||
|
||||
// CORS
|
||||
@@ -262,6 +263,11 @@ async fn main() {
|
||||
.route("/export/database", post(routes::database_export::start_database_export))
|
||||
.route("/export/database/progress/{exportId}", get(routes::database_export::database_export_progress))
|
||||
.route("/export/database/cancel", post(routes::database_export::cancel_database_export))
|
||||
// Table export
|
||||
.route("/export/table", post(routes::table_export::start_table_export))
|
||||
.route("/export/table/progress/{exportId}", get(routes::table_export::table_export_progress))
|
||||
.route("/export/table/download/{exportId}", get(routes::table_export::table_export_download))
|
||||
.route("/export/table/cancel", post(routes::table_export::cancel_table_export))
|
||||
// SQL file
|
||||
.route("/sql-file/preview", post(routes::sql_file::preview_sql_file))
|
||||
.route("/sql-file/execute", post(routes::sql_file::execute_sql_file))
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod schema;
|
||||
pub mod schema_cache;
|
||||
pub mod schema_diff;
|
||||
pub mod sql_file;
|
||||
pub mod table_export;
|
||||
pub mod table_import;
|
||||
pub mod text_export;
|
||||
pub mod transfer;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
use std::{
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::{Response, Sse};
|
||||
use axum::Json;
|
||||
use dbx_core::table_export::{self, ExportStatus, TableExportProgress, TableExportRequest};
|
||||
use futures::stream::Stream;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::state::WebState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StartExportRequest {
|
||||
pub request: TableExportRequest,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CancelExportRequest {
|
||||
pub export_id: String,
|
||||
}
|
||||
|
||||
pub async fn start_table_export(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<StartExportRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let mut req = body.request;
|
||||
let export_id = req.export_id.clone();
|
||||
|
||||
// Generate temp file path for web export output
|
||||
let tmp_dir = state.data_dir.join("tmp");
|
||||
std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError(e.to_string()))?;
|
||||
let ext = match req.format.as_str() {
|
||||
"csv" => "csv",
|
||||
"xlsx" => "xlsx",
|
||||
_ => return Err(AppError(format!("Unsupported export format: {}", req.format))),
|
||||
};
|
||||
let tmp_file = tmp_dir.join(format!("table_export_{export_id}.{ext}"));
|
||||
let file_path = tmp_file.to_string_lossy().to_string();
|
||||
req.file_path = file_path.clone();
|
||||
|
||||
// Store export file mapping for download
|
||||
state.export_files.write().await.insert(export_id.clone(), (file_path, req.format.clone()));
|
||||
|
||||
let tx = {
|
||||
let mut channels = state.sse_channels.write().await;
|
||||
channels.entry(export_id.clone()).or_insert_with(|| tokio::sync::broadcast::channel::<String>(256).0).clone()
|
||||
};
|
||||
|
||||
let app = state.app.clone();
|
||||
let state_clone = state.clone();
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let cancelled_progress = cancelled.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = table_export::export_table_data_core(&app, &req, |progress| {
|
||||
if matches!(progress.status, ExportStatus::Cancelled) {
|
||||
cancelled_progress.store(true, Ordering::SeqCst);
|
||||
}
|
||||
if let Ok(json) = serde_json::to_string(&progress) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
let _ = tokio::fs::remove_file(&req.file_path).await;
|
||||
state_clone.export_files.write().await.remove(&req.export_id);
|
||||
let progress = TableExportProgress {
|
||||
export_id: req.export_id.clone(),
|
||||
table_name: req.table_name.clone(),
|
||||
rows_exported: 0,
|
||||
total_rows: None,
|
||||
status: ExportStatus::Error,
|
||||
error_message: Some(e),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&progress) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
} else if cancelled.load(Ordering::SeqCst) {
|
||||
let _ = tokio::fs::remove_file(&req.file_path).await;
|
||||
state_clone.export_files.write().await.remove(&req.export_id);
|
||||
}
|
||||
|
||||
dbx_core::database_export::clear_export_cancelled(&req.export_id).await;
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
state_clone.remove_sse_channel(&req.export_id).await;
|
||||
});
|
||||
|
||||
Ok(Json(serde_json::json!({ "exportId": export_id })))
|
||||
}
|
||||
|
||||
pub async fn table_export_progress(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(export_id): Path<String>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>>, AppError> {
|
||||
let tx = {
|
||||
let mut channels = state.sse_channels.write().await;
|
||||
channels.entry(export_id).or_insert_with(|| tokio::sync::broadcast::channel::<String>(256).0).clone()
|
||||
};
|
||||
let rx = tx.subscribe();
|
||||
Ok(crate::sse::sse_from_channel(rx))
|
||||
}
|
||||
|
||||
pub async fn cancel_table_export(
|
||||
State(_state): State<Arc<WebState>>,
|
||||
Json(req): Json<CancelExportRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
dbx_core::database_export::set_export_cancelled(&req.export_id).await;
|
||||
Json(serde_json::json!({ "cancelled": true }))
|
||||
}
|
||||
|
||||
pub async fn table_export_download(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(export_id): Path<String>,
|
||||
) -> Result<Response, AppError> {
|
||||
let (file_path, format) = state
|
||||
.export_files
|
||||
.write()
|
||||
.await
|
||||
.remove(&export_id)
|
||||
.ok_or_else(|| AppError("Export file not found".to_string()))?;
|
||||
|
||||
let data = tokio::fs::read(&file_path).await.map_err(|e| AppError(e.to_string()))?;
|
||||
// Clean up temp file
|
||||
let _ = tokio::fs::remove_file(&file_path).await;
|
||||
|
||||
let (content_type, file_ext) = match format.as_str() {
|
||||
"csv" => ("text/csv; charset=utf-8", "csv"),
|
||||
"xlsx" => ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx"),
|
||||
_ => return Err(AppError(format!("Unknown format: {format}"))),
|
||||
};
|
||||
|
||||
let filename = format!("table_export_{export_id}.{file_ext}");
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CONTENT_DISPOSITION, format!("attachment; filename=\"{filename}\""))
|
||||
.body(Body::from(data))
|
||||
.map_err(|e| AppError(e.to_string()))?)
|
||||
}
|
||||
@@ -18,6 +18,8 @@ pub struct WebState {
|
||||
pub sse_channels: RwLock<HashMap<String, broadcast::Sender<String>>>,
|
||||
pub sql_file_executions: RwLock<HashMap<String, CancellationToken>>,
|
||||
pub login_rate_limit: Mutex<LoginRateLimit>,
|
||||
/// Table export temp files: export_id -> (file_path, format)
|
||||
pub export_files: RwLock<HashMap<String, (String, String)>>,
|
||||
}
|
||||
|
||||
impl WebState {
|
||||
|
||||
@@ -25,6 +25,7 @@ pub mod schema_cache;
|
||||
pub mod schema_diff;
|
||||
pub mod sql_file;
|
||||
pub mod system_fonts;
|
||||
pub mod table_export;
|
||||
pub mod table_import;
|
||||
pub mod text_export;
|
||||
pub mod transfer;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::commands::connection::AppState;
|
||||
|
||||
pub use dbx_core::table_export::{ExportStatus, TableExportProgress, TableExportRequest};
|
||||
|
||||
fn emit_progress(app: &AppHandle, progress: TableExportProgress) {
|
||||
let _ = app.emit("table-export-progress", progress);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_table_export(
|
||||
app: AppHandle,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
request: TableExportRequest,
|
||||
) -> Result<(), String> {
|
||||
let state = state.inner().clone();
|
||||
let export_id = request.export_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result =
|
||||
dbx_core::table_export::export_table_data_core(&state, &request, |progress| emit_progress(&app, progress))
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
emit_progress(
|
||||
&app,
|
||||
TableExportProgress {
|
||||
export_id: export_id.clone(),
|
||||
table_name: String::new(),
|
||||
rows_exported: 0,
|
||||
total_rows: None,
|
||||
status: ExportStatus::Error,
|
||||
error_message: Some(e),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
dbx_core::database_export::clear_export_cancelled(&export_id).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_table_export(export_id: String) -> Result<(), String> {
|
||||
dbx_core::database_export::set_export_cancelled(&export_id).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -499,6 +499,8 @@ pub fn run() {
|
||||
commands::transfer::cancel_transfer,
|
||||
commands::database_export::export_database_sql,
|
||||
commands::database_export::cancel_database_export,
|
||||
commands::table_export::start_table_export,
|
||||
commands::table_export::cancel_table_export,
|
||||
commands::csv_export::export_query_result_csv,
|
||||
commands::csv_export::export_table_data_csv,
|
||||
commands::xlsx_export::export_query_result_xlsx,
|
||||
|
||||
Reference in New Issue
Block a user