mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-30 16:21:39 +00:00
feat: export entire database as SQL file
Add a new "Export Database" feature accessible from the sidebar right-click menu on database/schema nodes. Generates a .sql file containing CREATE TABLE DDL, batched INSERT statements, and view/procedure/function source code. Backend (database_export.rs): - Stream-writes SQL to file with configurable batch size - Supports all SQL database types via existing DDL/INSERT generators - Progress events with cancellation support - Graceful error handling (continues on per-table failures) Frontend (DatabaseExportDialog.vue): - Connection/database/schema selector - Checkboxes: structure, data, objects - Real-time progress with current table and row count - Cancel button Wired up through Tauri commands, Web SSE routes, and API layer.
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, 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";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import * as api from "@/lib/api";
|
||||
import type { ExportProgress } from "@/lib/api";
|
||||
import { isSchemaAware } from "@/lib/databaseCapabilities";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { Download, Square, CheckSquare, X } from "lucide-vue-next";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
const store = useConnectionStore();
|
||||
|
||||
const props = defineProps<{
|
||||
prefillConnectionId?: string;
|
||||
prefillDatabase?: string;
|
||||
prefillSchema?: string;
|
||||
}>();
|
||||
|
||||
// Connection / Database / Schema selectors
|
||||
const connectionId = ref("");
|
||||
const database = ref("");
|
||||
const databases = ref<string[]>([]);
|
||||
const schema = ref("");
|
||||
const schemas = ref<string[]>([]);
|
||||
const loadingMeta = ref(false);
|
||||
|
||||
// Options
|
||||
const includeStructure = ref(true);
|
||||
const includeData = ref(true);
|
||||
const includeObjects = ref(true);
|
||||
|
||||
// Export state
|
||||
const isExporting = ref(false);
|
||||
const exportProgress = ref<ExportProgress | null>(null);
|
||||
const exportId = ref("");
|
||||
const exportDone = ref(false);
|
||||
const exportError = ref<string | null>(null);
|
||||
const exportCancelled = ref(false);
|
||||
|
||||
const sqlConnections = computed(() =>
|
||||
store.connections.filter((c) => !["redis", "mongodb", "elasticsearch"].includes(c.db_type)),
|
||||
);
|
||||
|
||||
const canExport = computed(
|
||||
() =>
|
||||
connectionId.value &&
|
||||
database.value &&
|
||||
schema.value &&
|
||||
(includeStructure.value || includeData.value || includeObjects.value) &&
|
||||
!isExporting.value,
|
||||
);
|
||||
|
||||
function connectionIconType(connId: string) {
|
||||
const config = store.getConfig(connId);
|
||||
return config?.driver_profile || config?.db_type || "mysql";
|
||||
}
|
||||
|
||||
async function loadDatabases(connId: string) {
|
||||
if (!connId) return;
|
||||
loadingMeta.value = true;
|
||||
try {
|
||||
await store.ensureConnected(connId);
|
||||
const dbs = await api.listDatabases(connId);
|
||||
const names = dbs.map((d) => d.name);
|
||||
databases.value = names;
|
||||
database.value = names.length === 1 ? names[0] : "";
|
||||
schemas.value = [];
|
||||
schema.value = "";
|
||||
} catch {
|
||||
databases.value = [];
|
||||
} finally {
|
||||
loadingMeta.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSchemas(preferredSchema = "") {
|
||||
if (!connectionId.value || !database.value) return;
|
||||
const config = store.getConfig(connectionId.value);
|
||||
if (!isSchemaAware(config?.db_type)) {
|
||||
schemas.value = [];
|
||||
schema.value = database.value;
|
||||
return;
|
||||
}
|
||||
|
||||
const schemaList = await api.listSchemas(connectionId.value, database.value);
|
||||
const selected =
|
||||
preferredSchema && schemaList.includes(preferredSchema)
|
||||
? preferredSchema
|
||||
: schemaList.includes("public")
|
||||
? "public"
|
||||
: (schemaList[0] ?? "");
|
||||
schemas.value = schemaList;
|
||||
schema.value = selected;
|
||||
}
|
||||
|
||||
async function startExport() {
|
||||
if (!canExport.value) return;
|
||||
isExporting.value = true;
|
||||
exportDone.value = false;
|
||||
exportError.value = null;
|
||||
exportCancelled.value = false;
|
||||
exportProgress.value = null;
|
||||
|
||||
exportId.value = crypto.randomUUID();
|
||||
|
||||
let filePath = "";
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
try {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const safeName = (database.value || "database").replace(/[\\/:*?"<>|]+/g, "_").trim();
|
||||
const path = await save({
|
||||
defaultPath: `${safeName}.sql`,
|
||||
filters: [{ name: "SQL", extensions: ["sql"] }],
|
||||
});
|
||||
if (!path) {
|
||||
isExporting.value = false;
|
||||
return;
|
||||
}
|
||||
filePath = path;
|
||||
} catch (e: any) {
|
||||
isExporting.value = false;
|
||||
toast(e?.message || String(e), 5000);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Web mode: use a temp path; the server will handle the file
|
||||
filePath = `__web_export_${exportId.value}.sql`;
|
||||
}
|
||||
|
||||
const request: api.DatabaseExportRequest = {
|
||||
exportId: exportId.value,
|
||||
connectionId: connectionId.value,
|
||||
database: database.value,
|
||||
schema: schema.value,
|
||||
filePath,
|
||||
includeStructure: includeStructure.value,
|
||||
includeData: includeData.value,
|
||||
includeObjects: includeObjects.value,
|
||||
batchSize: 1000,
|
||||
};
|
||||
|
||||
try {
|
||||
await api.exportDatabaseSql(request, (progress) => {
|
||||
exportProgress.value = { ...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;
|
||||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
exportError.value = e?.message || String(e);
|
||||
isExporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelExport() {
|
||||
if (exportId.value) {
|
||||
await api.cancelDatabaseExport(exportId.value);
|
||||
}
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
connectionId.value = "";
|
||||
database.value = "";
|
||||
databases.value = [];
|
||||
schema.value = "";
|
||||
schemas.value = [];
|
||||
includeStructure.value = true;
|
||||
includeData.value = true;
|
||||
includeObjects.value = true;
|
||||
isExporting.value = false;
|
||||
exportProgress.value = null;
|
||||
exportDone.value = false;
|
||||
exportError.value = null;
|
||||
exportCancelled.value = false;
|
||||
exportId.value = "";
|
||||
}
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
const p = exportProgress.value;
|
||||
if (!p || p.totalObjects === 0) return 0;
|
||||
return Math.round((p.objectIndex / p.totalObjects) * 100);
|
||||
});
|
||||
|
||||
const skipConnectionWatch = ref(false);
|
||||
|
||||
watch(connectionId, (id) => {
|
||||
if (skipConnectionWatch.value) {
|
||||
skipConnectionWatch.value = false;
|
||||
return;
|
||||
}
|
||||
database.value = "";
|
||||
databases.value = [];
|
||||
schemas.value = [];
|
||||
schema.value = "";
|
||||
loadDatabases(id);
|
||||
});
|
||||
|
||||
watch(database, (db) => {
|
||||
schema.value = "";
|
||||
schemas.value = [];
|
||||
if (db) loadSchemas(props.prefillSchema).catch((e) => toast(String(e), 5000));
|
||||
});
|
||||
|
||||
watch(open, async (val) => {
|
||||
if (val) {
|
||||
resetState();
|
||||
if (props.prefillConnectionId) {
|
||||
skipConnectionWatch.value = true;
|
||||
connectionId.value = props.prefillConnectionId;
|
||||
await loadDatabases(props.prefillConnectionId);
|
||||
if (props.prefillDatabase) {
|
||||
database.value = props.prefillDatabase;
|
||||
await loadSchemas(props.prefillSchema);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="sm:max-w-[480px] max-h-[80vh] flex flex-col overflow-hidden" @interact-outside.prevent>
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Download class="w-4 h-4" />
|
||||
{{ t("databaseExport.title") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-auto space-y-4 py-2">
|
||||
<!-- Connection / Database / Schema Selection -->
|
||||
<div v-if="!isExporting && !exportDone && !exportError && !exportCancelled" class="space-y-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("transfer.sourceConnection") }}</Label>
|
||||
<Select :model-value="connectionId" @update:model-value="(v: any) => (connectionId = String(v))">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon v-if="connectionId" :db-type="connectionIconType(connectionId)" class="w-3.5 h-3.5" />
|
||||
<SelectValue :placeholder="t('diff.selectConnection')" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="c in sqlConnections" :key="c.id" :value="c.id">
|
||||
<div class="flex items-center gap-2">
|
||||
<DatabaseIcon :db-type="c.driver_profile || c.db_type" class="w-3.5 h-3.5" />
|
||||
{{ c.name }}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="databases.length" class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("transfer.sourceDatabase") }}</Label>
|
||||
<Select :model-value="database" @update:model-value="(v: any) => (database = String(v))">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue :placeholder="t('diff.selectDatabase')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="db in databases" :key="db" :value="db">{{ db }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div v-if="schemas.length" class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("diff.selectSchema") }}</Label>
|
||||
<Select :model-value="schema" @update:model-value="(v: any) => (schema = String(v))">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue :placeholder="t('diff.selectSchema')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="s in schemas" :key="s" :value="s">{{ s }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="space-y-2.5 pt-1">
|
||||
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{{ t("settings.options") }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 cursor-pointer text-xs" @click="includeStructure = !includeStructure">
|
||||
<CheckSquare v-if="includeStructure" class="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
|
||||
{{ t("databaseExport.includeStructure") }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 cursor-pointer text-xs" @click="includeData = !includeData">
|
||||
<CheckSquare v-if="includeData" class="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
|
||||
{{ t("databaseExport.includeData") }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 cursor-pointer text-xs" @click="includeObjects = !includeObjects">
|
||||
<CheckSquare v-if="includeObjects" class="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
|
||||
{{ t("databaseExport.includeObjects") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress View -->
|
||||
<div v-if="isExporting || exportDone || exportError || exportCancelled" class="py-3 space-y-3">
|
||||
<div v-if="exportProgress" class="space-y-2">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{
|
||||
t("databaseExport.currentTable", {
|
||||
table: exportProgress.currentObject,
|
||||
current: exportProgress.objectIndex,
|
||||
total: exportProgress.totalObjects,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="w-full bg-muted rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300"
|
||||
:class="exportError ? 'bg-destructive' : exportCancelled ? 'bg-yellow-500' : 'bg-primary'"
|
||||
:style="{ width: `${progressPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ t("databaseExport.rowsExported", { count: exportProgress.rowsExported.toLocaleString() }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status messages -->
|
||||
<div v-if="exportDone" class="text-xs text-green-600 font-medium">
|
||||
{{ t("databaseExport.exportSuccess") }}
|
||||
</div>
|
||||
<div v-else-if="exportError" class="text-xs text-destructive font-medium">
|
||||
{{ t("databaseExport.exportError", { error: exportError }) }}
|
||||
</div>
|
||||
<div v-else-if="exportCancelled" class="text-xs text-yellow-600 font-medium">
|
||||
{{ t("databaseExport.exportCancelled") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<template v-if="!isExporting && !exportDone && !exportError && !exportCancelled">
|
||||
<Button variant="outline" size="sm" @click="open = false">
|
||||
{{ t("transfer.cancel") }}
|
||||
</Button>
|
||||
<Button size="sm" :disabled="!canExport" @click="startExport">
|
||||
<Download class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ t("databaseExport.export") }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else-if="isExporting">
|
||||
<Button variant="destructive" size="sm" @click="cancelExport">
|
||||
<X class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ t("transfer.cancel") }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button size="sm" @click="open = false">
|
||||
{{ t("common.close") }}
|
||||
</Button>
|
||||
</template>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -18,6 +18,7 @@ const TableStructureEditorDialog = defineAsyncComponent(
|
||||
const FieldLineageDialog = defineAsyncComponent(() => import("@/components/lineage/FieldLineageDialog.vue"));
|
||||
const ConfigPassphraseDialog = defineAsyncComponent(() => import("@/components/config/ConfigPassphraseDialog.vue"));
|
||||
const DatabaseSearchDialog = defineAsyncComponent(() => import("@/components/search/DatabaseSearchDialog.vue"));
|
||||
const DatabaseExportDialog = defineAsyncComponent(() => import("@/components/export/DatabaseExportDialog.vue"));
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useDialogSources } from "@/composables/useDialogSources";
|
||||
|
||||
@@ -173,6 +174,12 @@ watch(
|
||||
:prefill-schema="dialogs.databaseSearchPrefillSchema.value"
|
||||
@open-target="emit('openDatabaseSearchTarget', $event)"
|
||||
/>
|
||||
<DatabaseExportDialog
|
||||
v-model:open="dialogs.showDatabaseExportDialog.value"
|
||||
:prefill-connection-id="dialogs.databaseExportPrefillConnectionId.value"
|
||||
:prefill-database="dialogs.databaseExportPrefillDatabase.value"
|
||||
:prefill-schema="dialogs.databaseExportPrefillSchema.value"
|
||||
/>
|
||||
<ConfigPassphraseDialog
|
||||
v-model:open="dialogs.showConfigPassphraseDialog.value"
|
||||
:mode="dialogs.configPassphraseMode.value"
|
||||
|
||||
@@ -57,18 +57,11 @@ import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
|
||||
import type { DatabaseType, QueryResult, TreeNode, TreeNodeType } from "@/types/database";
|
||||
import type { DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
|
||||
import * as api from "@/lib/api";
|
||||
import { uuid } from "@/lib/utils";
|
||||
import { resolveDefaultDatabase } from "@/lib/defaultDatabase";
|
||||
import { canTreeNodeShowExpander, treeItemPaddingLeft } from "@/lib/sidebarTreeItemLayout";
|
||||
import {
|
||||
DATABASE_EXPORT_PAGE_SIZE,
|
||||
DATABASE_EXPORT_ROW_LIMIT,
|
||||
buildDatabaseSqlExport,
|
||||
buildExportPageSql,
|
||||
type ExportedTableSql,
|
||||
} from "@/lib/databaseExport";
|
||||
import {
|
||||
buildTableSelectSql,
|
||||
qualifiedTableName as buildQualifiedTableName,
|
||||
@@ -125,8 +118,6 @@ const emit = defineEmits<{
|
||||
"search-toggle": [node: TreeNode];
|
||||
}>();
|
||||
|
||||
const isExportingDatabase = ref(false);
|
||||
|
||||
function currentDatabaseType(): DatabaseType | undefined {
|
||||
return props.node.connectionId ? connectionStore.getConfig(props.node.connectionId)?.db_type : undefined;
|
||||
}
|
||||
@@ -143,10 +134,6 @@ function qualifiedTableName(tableName: string, schema?: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
function safeFileName(name: string): string {
|
||||
return name.replace(/[\\/:*?"<>|]+/g, "_").trim() || "database";
|
||||
}
|
||||
|
||||
function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
|
||||
switch (node.type) {
|
||||
case "connection":
|
||||
@@ -835,78 +822,6 @@ function createTable() {
|
||||
};
|
||||
}
|
||||
|
||||
async function collectDatabaseExportTables(): Promise<Array<{ schema?: string; name: string; displayName: string }>> {
|
||||
const node = props.node;
|
||||
if (!node.connectionId || !node.database) return [];
|
||||
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
if (node.type === "schema" && node.schema) {
|
||||
const tables = await api.listTables(node.connectionId, node.database, node.schema);
|
||||
return tables.map((table) => ({
|
||||
schema: node.schema,
|
||||
name: table.name,
|
||||
displayName: `${node.schema}.${table.name}`,
|
||||
}));
|
||||
}
|
||||
|
||||
if (isSchemaAware(config?.db_type)) {
|
||||
const schemas = await api.listSchemas(node.connectionId, node.database);
|
||||
const groups = await Promise.all(
|
||||
schemas.map(async (schema) => {
|
||||
const tables = await api.listTables(node.connectionId!, node.database!, schema);
|
||||
return tables.map((table) => ({
|
||||
schema,
|
||||
name: table.name,
|
||||
displayName: `${schema}.${table.name}`,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
return groups.flat();
|
||||
}
|
||||
|
||||
const tables = await api.listTables(node.connectionId, node.database, node.database);
|
||||
return tables.map((table) => ({
|
||||
name: table.name,
|
||||
displayName: table.name,
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchExportTableRows(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
table: { schema?: string; name: string },
|
||||
databaseType?: DatabaseType,
|
||||
): Promise<{ columns: QueryResult["columns"]; rows: QueryResult["rows"]; truncated: boolean }> {
|
||||
const rows: QueryResult["rows"] = [];
|
||||
let columns: QueryResult["columns"] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (rows.length < DATABASE_EXPORT_ROW_LIMIT) {
|
||||
const remaining = DATABASE_EXPORT_ROW_LIMIT - rows.length;
|
||||
const limit =
|
||||
databaseType === "sqlserver" ? DATABASE_EXPORT_ROW_LIMIT : Math.min(DATABASE_EXPORT_PAGE_SIZE, remaining);
|
||||
const sql = buildExportPageSql({
|
||||
databaseType,
|
||||
schema: table.schema,
|
||||
tableName: table.name,
|
||||
limit,
|
||||
offset: databaseType === "sqlserver" ? undefined : offset,
|
||||
});
|
||||
const result = await api.executeQuery(connectionId, database, sql);
|
||||
if (columns.length === 0) columns = result.columns;
|
||||
rows.push(...result.rows);
|
||||
|
||||
if (result.rows.length < limit || databaseType === "sqlserver") break;
|
||||
offset += result.rows.length;
|
||||
}
|
||||
|
||||
return {
|
||||
columns,
|
||||
rows,
|
||||
truncated: rows.length >= DATABASE_EXPORT_ROW_LIMIT,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveFileContent(content: string, defaultFileName: string, filterName: string, filterExt: string) {
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
@@ -954,56 +869,6 @@ async function saveBinaryFileContent(
|
||||
}
|
||||
}
|
||||
|
||||
async function exportDatabase() {
|
||||
const node = props.node;
|
||||
if (!(node.type === "database" || node.type === "schema") || !node.connectionId || !node.database) return;
|
||||
|
||||
isExportingDatabase.value = true;
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
const tables = await collectDatabaseExportTables();
|
||||
const exportedTables: ExportedTableSql[] = [];
|
||||
|
||||
for (const table of tables) {
|
||||
const querySchema = table.schema || node.database;
|
||||
const qualifiedName = qualifiedTableName(table.name, table.schema);
|
||||
const ddl = await api.getTableDdl(node.connectionId, node.database, querySchema, table.name);
|
||||
const result = await fetchExportTableRows(node.connectionId, node.database, table, config?.db_type);
|
||||
exportedTables.push({
|
||||
displayName: table.displayName,
|
||||
qualifiedTableName: qualifiedName,
|
||||
ddl,
|
||||
columns: result.columns,
|
||||
rows: result.rows,
|
||||
truncated: result.truncated,
|
||||
});
|
||||
}
|
||||
|
||||
const scopeName = node.type === "schema" && node.schema ? `${node.database}.${node.schema}` : node.database;
|
||||
const content = buildDatabaseSqlExport({
|
||||
databaseName: scopeName,
|
||||
tables: exportedTables,
|
||||
quoteIdentifier: quoteIdent,
|
||||
rowLimitPerTable: DATABASE_EXPORT_ROW_LIMIT,
|
||||
});
|
||||
|
||||
await saveFileContent(content, `${safeFileName(scopeName)}.sql`, "SQL", "sql");
|
||||
toast(
|
||||
t("contextMenu.exportDatabaseSuccess", {
|
||||
count: exportedTables.length,
|
||||
limit: DATABASE_EXPORT_ROW_LIMIT,
|
||||
}),
|
||||
3000,
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.error("Export database failed:", e);
|
||||
toast(t("contextMenu.exportDatabaseFailed", { message: e?.message || String(e) }), 5000);
|
||||
} finally {
|
||||
isExportingDatabase.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportStructure() {
|
||||
const node = props.node;
|
||||
if (!node.connectionId || !node.database) return;
|
||||
@@ -1152,6 +1017,16 @@ function openDatabaseSearch() {
|
||||
};
|
||||
}
|
||||
|
||||
function openDatabaseExport() {
|
||||
const node = props.node;
|
||||
if (!node.connectionId || !node.database) return;
|
||||
connectionStore.databaseExportSource = {
|
||||
connectionId: node.connectionId,
|
||||
database: node.database,
|
||||
schema: node.type === "schema" ? node.schema : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function openTableImport() {
|
||||
const node = props.node;
|
||||
if (node.type !== "table" || !node.connectionId || !node.database) return;
|
||||
@@ -1706,9 +1581,8 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
|
||||
<ContextMenuItem @click="openDataCompare">
|
||||
<ArrowRightLeft class="w-4 h-4" /> {{ t("dataCompare.title") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem :disabled="isExportingDatabase" @click="exportDatabase">
|
||||
<Loader2 v-if="isExportingDatabase" class="w-4 h-4 animate-spin" />
|
||||
<Download v-else class="w-4 h-4" />
|
||||
<ContextMenuItem @click="openDatabaseExport">
|
||||
<Download class="w-4 h-4" />
|
||||
{{ t("contextMenu.exportDatabase") }}
|
||||
</ContextMenuItem>
|
||||
<template v-if="canDropDatabase || canDropSchema">
|
||||
|
||||
@@ -13,6 +13,7 @@ const showTableImportDialog = ref(false);
|
||||
const showStructureEditorDialog = ref(false);
|
||||
const showFieldLineageDialog = ref(false);
|
||||
const showDatabaseSearchDialog = ref(false);
|
||||
const showDatabaseExportDialog = ref(false);
|
||||
const showImportLayoutConfirm = ref(false);
|
||||
const pendingImportLayout = ref<SidebarLayout | null>(null);
|
||||
const showConfigPassphraseDialog = ref(false);
|
||||
@@ -51,6 +52,9 @@ const lineagePrefillColumn = ref("");
|
||||
const databaseSearchPrefillConnectionId = ref("");
|
||||
const databaseSearchPrefillDatabase = ref("");
|
||||
const databaseSearchPrefillSchema = ref("");
|
||||
const databaseExportPrefillConnectionId = ref("");
|
||||
const databaseExportPrefillDatabase = ref("");
|
||||
const databaseExportPrefillSchema = ref("");
|
||||
|
||||
let watchersRegistered = false;
|
||||
|
||||
@@ -183,6 +187,19 @@ export function useDialogSources() {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => connectionStore.databaseExportSource,
|
||||
(v) => {
|
||||
if (v) {
|
||||
databaseExportPrefillConnectionId.value = v.connectionId;
|
||||
databaseExportPrefillDatabase.value = v.database;
|
||||
databaseExportPrefillSchema.value = v.schema ?? "";
|
||||
showDatabaseExportDialog.value = true;
|
||||
connectionStore.databaseExportSource = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
} // end watchersRegistered
|
||||
|
||||
// Config export/import helpers
|
||||
@@ -258,6 +275,7 @@ export function useDialogSources() {
|
||||
showStructureEditorDialog,
|
||||
showFieldLineageDialog,
|
||||
showDatabaseSearchDialog,
|
||||
showDatabaseExportDialog,
|
||||
showImportLayoutConfirm,
|
||||
pendingImportLayout,
|
||||
showConfigPassphraseDialog,
|
||||
@@ -295,6 +313,9 @@ export function useDialogSources() {
|
||||
databaseSearchPrefillConnectionId,
|
||||
databaseSearchPrefillDatabase,
|
||||
databaseSearchPrefillSchema,
|
||||
databaseExportPrefillConnectionId,
|
||||
databaseExportPrefillDatabase,
|
||||
databaseExportPrefillSchema,
|
||||
onExportClick,
|
||||
onExportConfirm,
|
||||
onImportClick,
|
||||
|
||||
@@ -976,4 +976,17 @@ export default {
|
||||
openSource: "Open-source repository",
|
||||
officialDocs: "Official docs",
|
||||
},
|
||||
databaseExport: {
|
||||
title: "Export Database",
|
||||
includeStructure: "Table structure (DDL)",
|
||||
includeData: "Table data (INSERT)",
|
||||
includeObjects: "Views / Procedures / Functions",
|
||||
export: "Export",
|
||||
exporting: "Exporting...",
|
||||
currentTable: "Current: {table} ({current}/{total})",
|
||||
rowsExported: "{count} rows exported",
|
||||
exportSuccess: "Database exported successfully",
|
||||
exportError: "Export failed: {error}",
|
||||
exportCancelled: "Export cancelled",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -978,4 +978,17 @@ export default {
|
||||
openSource: "Repositorio de código abierto",
|
||||
officialDocs: "Documentación oficial",
|
||||
},
|
||||
databaseExport: {
|
||||
title: "Exportar base de datos",
|
||||
includeStructure: "Estructura de tablas (DDL)",
|
||||
includeData: "Datos de tablas (INSERT)",
|
||||
includeObjects: "Vistas / Procedimientos / Funciones",
|
||||
export: "Exportar",
|
||||
exporting: "Exportando...",
|
||||
currentTable: "Actual: {table} ({current}/{total})",
|
||||
rowsExported: "{count} filas exportadas",
|
||||
exportSuccess: "Base de datos exportada exitosamente",
|
||||
exportError: "Error al exportar: {error}",
|
||||
exportCancelled: "Exportación cancelada",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -961,4 +961,17 @@ export default {
|
||||
openSource: "开源仓库",
|
||||
officialDocs: "官方文档",
|
||||
},
|
||||
databaseExport: {
|
||||
title: "导出数据库",
|
||||
includeStructure: "表结构 (DDL)",
|
||||
includeData: "表数据 (INSERT)",
|
||||
includeObjects: "视图 / 存储过程 / 函数",
|
||||
export: "导出",
|
||||
exporting: "正在导出...",
|
||||
currentTable: "当前: {table} ({current}/{total})",
|
||||
rowsExported: "已导出 {count} 行",
|
||||
exportSuccess: "数据库导出成功",
|
||||
exportError: "导出失败: {error}",
|
||||
exportCancelled: "导出已取消",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -98,6 +98,10 @@ export const previewTableImportFile = forward("previewTableImportFile");
|
||||
export const importTableFile = forward("importTableFile");
|
||||
export const cancelTableImport = forward("cancelTableImport");
|
||||
|
||||
// Database Export
|
||||
export const exportDatabaseSql = forward("exportDatabaseSql");
|
||||
export const cancelDatabaseExport = forward("cancelDatabaseExport");
|
||||
|
||||
// Redis
|
||||
export const redisListDatabases = forward("redisListDatabases");
|
||||
export const redisScanKeys = forward("redisScanKeys");
|
||||
@@ -172,4 +176,6 @@ export type {
|
||||
TableImportRequest,
|
||||
TableImportSummary,
|
||||
TableImportProgress,
|
||||
DatabaseExportRequest,
|
||||
ExportProgress,
|
||||
} from "./tauri";
|
||||
|
||||
@@ -38,6 +38,8 @@ import type {
|
||||
TableImportRequest,
|
||||
TableImportSummary,
|
||||
TableImportProgress,
|
||||
DatabaseExportRequest,
|
||||
ExportProgress,
|
||||
} from "./tauri";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -497,6 +499,44 @@ export async function cancelTableImport(importId: string): Promise<boolean> {
|
||||
return post("/api/import/cancel", { importId });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database Export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function exportDatabaseSql(
|
||||
request: DatabaseExportRequest,
|
||||
onProgress: (progress: ExportProgress) => void,
|
||||
): Promise<void> {
|
||||
// 1. POST to start the export
|
||||
const res = await fetch("/api/export/database", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
|
||||
// 2. SSE to listen for progress
|
||||
return new Promise((resolve, reject) => {
|
||||
const es = new EventSource(`/api/export/database/progress/${request.exportId}`);
|
||||
es.onmessage = (e) => {
|
||||
const progress: ExportProgress = JSON.parse(e.data);
|
||||
onProgress(progress);
|
||||
if (progress.status === "Done" || progress.status === "Error" || progress.status === "Cancelled") {
|
||||
es.close();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
reject(new Error("Export SSE connection failed"));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelDatabaseExport(exportId: string): Promise<void> {
|
||||
await post("/api/export/database/cancel", { exportId });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Redis
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -744,3 +744,51 @@ export async function importTableFile(
|
||||
export async function cancelTableImport(importId: string): Promise<boolean> {
|
||||
return invoke("cancel_table_import", { importId });
|
||||
}
|
||||
|
||||
// --- Database Export ---
|
||||
export interface DatabaseExportRequest {
|
||||
exportId: string;
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema: string;
|
||||
filePath: string;
|
||||
includeStructure: boolean;
|
||||
includeData: boolean;
|
||||
includeObjects: boolean;
|
||||
batchSize: number;
|
||||
}
|
||||
|
||||
export interface ExportProgress {
|
||||
exportId: string;
|
||||
currentObject: string;
|
||||
objectIndex: number;
|
||||
totalObjects: number;
|
||||
rowsExported: number;
|
||||
totalRows: number | null;
|
||||
status: "Running" | "Done" | "Error" | "Cancelled";
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export async function exportDatabaseSql(
|
||||
request: DatabaseExportRequest,
|
||||
onProgress: (progress: ExportProgress) => void,
|
||||
): Promise<void> {
|
||||
const unlisten: UnlistenFn = await listen<ExportProgress>("database-export-progress", (event) => {
|
||||
if (event.payload.exportId === request.exportId) {
|
||||
onProgress(event.payload);
|
||||
if (event.payload.status === "Done" || event.payload.status === "Error" || event.payload.status === "Cancelled") {
|
||||
unlisten();
|
||||
}
|
||||
}
|
||||
});
|
||||
try {
|
||||
await invoke("export_database_sql", { request });
|
||||
} catch (e) {
|
||||
unlisten();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelDatabaseExport(exportId: string): Promise<void> {
|
||||
await invoke("cancel_database_export", { exportId });
|
||||
}
|
||||
|
||||
@@ -91,6 +91,11 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
database: string;
|
||||
schema?: string;
|
||||
} | null>(null);
|
||||
const databaseExportSource = ref<{
|
||||
connectionId: string;
|
||||
database: string;
|
||||
schema?: string;
|
||||
} | null>(null);
|
||||
const sidebarLayout = ref<SidebarLayout>(emptyLayout());
|
||||
let layoutPersistTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -1400,6 +1405,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
structureEditorSource,
|
||||
fieldLineageSource,
|
||||
databaseSearchSource,
|
||||
databaseExportSource,
|
||||
createConnectionGroup(name: string) {
|
||||
const result = createGroupOp(sidebarLayout.value, name);
|
||||
updateLayoutAndRebuild(result.layout);
|
||||
|
||||
Reference in New Issue
Block a user