feat(export): add table selection to database export

This commit is contained in:
t8y2
2026-05-15 18:56:23 +08:00
parent cb3157c5ae
commit 048f629d21
12 changed files with 215 additions and 5 deletions
+43 -1
View File
@@ -16,6 +16,8 @@ pub struct DatabaseExportRequest {
pub database: String,
pub schema: String,
pub file_path: String,
#[serde(default)]
pub selected_tables: Vec<String>,
pub include_structure: bool,
pub include_data: bool,
pub include_objects: bool,
@@ -76,6 +78,7 @@ pub async fn export_database_sql_core(
let all_tables =
crate::schema::list_tables_core(state, &request.connection_id, &request.database, &request.schema, None, None)
.await?;
let all_tables = filter_selected_table_infos(all_tables, &request.selected_tables);
// 4. Create file
let mut file = std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to write file: {e}"))?;
@@ -103,7 +106,7 @@ pub async fn export_database_sql_core(
let mut procedures: Vec<String> = Vec::new();
let mut functions: Vec<String> = Vec::new();
if request.include_objects {
if request.include_objects && request.selected_tables.is_empty() {
if let Ok(objects) =
crate::schema::list_objects_core(state, &request.connection_id, &request.database, &request.schema).await
{
@@ -433,3 +436,42 @@ pub async fn export_database_sql_core(
Ok(())
}
fn filter_selected_table_infos(
tables: Vec<crate::types::TableInfo>,
selected_tables: &[String],
) -> Vec<crate::types::TableInfo> {
if selected_tables.is_empty() {
return tables;
}
let selected: HashSet<&str> = selected_tables.iter().map(String::as_str).collect();
tables.into_iter().filter(|table| selected.contains(table.name.as_str())).collect()
}
#[cfg(test)]
mod tests {
use super::filter_selected_table_infos;
use crate::types::TableInfo;
fn table(name: &str, table_type: &str) -> TableInfo {
TableInfo { name: name.to_string(), table_type: table_type.to_string(), comment: None }
}
#[test]
fn filters_export_tables_by_selected_names() {
let tables = vec![table("users", "TABLE"), table("orders", "TABLE"), table("active_users", "VIEW")];
let filtered = filter_selected_table_infos(tables, &["active_users".to_string(), "users".to_string()]);
assert_eq!(filtered.iter().map(|table| table.name.as_str()).collect::<Vec<_>>(), vec!["users", "active_users"]);
}
#[test]
fn keeps_all_export_tables_when_selection_is_empty() {
let tables = vec![table("users", "TABLE"), table("orders", "TABLE")];
let filtered = filter_selected_table_infos(tables.clone(), &[]);
assert_eq!(filtered.iter().map(|table| table.name.as_str()).collect::<Vec<_>>(), vec!["users", "orders"]);
}
}
+116 -3
View File
@@ -10,6 +10,7 @@ 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 { buildSelectedTablesPayload } from "@/lib/databaseExportSelection";
import { isTauriRuntime } from "@/lib/tauriRuntime";
import { useToast } from "@/composables/useToast";
import { Download, Square, CheckSquare, X } from "lucide-vue-next";
@@ -23,6 +24,7 @@ const props = defineProps<{
prefillConnectionId?: string;
prefillDatabase?: string;
prefillSchema?: string;
prefillTable?: string;
}>();
// Connection / Database / Schema selectors
@@ -32,6 +34,10 @@ const databases = ref<string[]>([]);
const schema = ref("");
const schemas = ref<string[]>([]);
const loadingMeta = ref(false);
const tables = ref<string[]>([]);
const selectedTables = ref<string[]>([]);
const loadingTables = ref(false);
const tableError = ref<string | null>(null);
// Options
const includeStructure = ref(true);
@@ -45,6 +51,7 @@ const exportId = ref("");
const exportDone = ref(false);
const exportError = ref<string | null>(null);
const exportCancelled = ref(false);
const pendingPrefillTable = ref("");
const sqlConnections = computed(() =>
store.connections.filter((c) => !["redis", "mongodb", "elasticsearch"].includes(c.db_type)),
@@ -55,10 +62,15 @@ const canExport = computed(
connectionId.value &&
database.value &&
schema.value &&
!loadingTables.value &&
!tableError.value &&
(tables.value.length === 0 || selectedTables.value.length > 0) &&
(includeStructure.value || includeData.value || includeObjects.value) &&
!isExporting.value,
);
const selectedTableSet = computed(() => new Set(selectedTables.value));
function connectionIconType(connId: string) {
const config = store.getConfig(connId);
return config?.driver_profile || config?.db_type || "mysql";
@@ -75,6 +87,8 @@ async function loadDatabases(connId: string) {
database.value = names.length === 1 ? names[0] : "";
schemas.value = [];
schema.value = "";
tables.value = [];
selectedTables.value = [];
} catch {
databases.value = [];
} finally {
@@ -102,6 +116,42 @@ async function loadSchemas(preferredSchema = "") {
schema.value = selected;
}
async function loadTables(preferredTable = "") {
if (!connectionId.value || !database.value || !schema.value) return;
loadingTables.value = true;
tableError.value = null;
tables.value = [];
selectedTables.value = [];
try {
const tableInfos = await api.listTables(connectionId.value, database.value, schema.value);
const names = tableInfos.map((table) => table.name);
tables.value = names;
selectedTables.value = preferredTable && names.includes(preferredTable) ? [preferredTable] : [...names];
} catch (e: any) {
tableError.value = e?.message || String(e);
} finally {
loadingTables.value = false;
}
}
function toggleTable(table: string) {
const selected = new Set(selectedTables.value);
if (selected.has(table)) {
selected.delete(table);
} else {
selected.add(table);
}
selectedTables.value = tables.value.filter((name) => selected.has(name));
}
function selectAllTables() {
selectedTables.value = [...tables.value];
}
function clearSelectedTables() {
selectedTables.value = [];
}
async function startExport() {
if (!canExport.value) return;
isExporting.value = true;
@@ -143,6 +193,7 @@ async function startExport() {
database: database.value,
schema: schema.value,
filePath,
selectedTables: buildSelectedTablesPayload(tables.value, selectedTables.value),
includeStructure: includeStructure.value,
includeData: includeData.value,
includeObjects: includeObjects.value,
@@ -182,6 +233,10 @@ function resetState() {
databases.value = [];
schema.value = "";
schemas.value = [];
tables.value = [];
selectedTables.value = [];
tableError.value = null;
pendingPrefillTable.value = "";
includeStructure.value = true;
includeData.value = true;
includeObjects.value = true;
@@ -210,18 +265,34 @@ watch(connectionId, (id) => {
databases.value = [];
schemas.value = [];
schema.value = "";
tables.value = [];
selectedTables.value = [];
tableError.value = null;
loadDatabases(id);
});
watch(database, (db) => {
schema.value = "";
schemas.value = [];
tables.value = [];
selectedTables.value = [];
tableError.value = null;
if (db) loadSchemas(props.prefillSchema).catch((e) => toast(String(e), 5000));
});
watch(schema, (value) => {
tables.value = [];
selectedTables.value = [];
tableError.value = null;
const preferredTable = pendingPrefillTable.value;
pendingPrefillTable.value = "";
if (value) loadTables(preferredTable).catch((e) => toast(String(e), 5000));
});
watch(open, async (val) => {
if (val) {
resetState();
pendingPrefillTable.value = props.prefillTable ?? "";
if (props.prefillConnectionId) {
skipConnectionWatch.value = true;
connectionId.value = props.prefillConnectionId;
@@ -257,7 +328,7 @@ watch(open, async (val) => {
<SelectValue :placeholder="t('diff.selectConnection')" />
</div>
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" align="start">
<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" />
@@ -274,7 +345,7 @@ watch(open, async (val) => {
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('diff.selectDatabase')" />
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" align="start">
<SelectItem v-for="db in databases" :key="db" :value="db">{{ db }}</SelectItem>
</SelectContent>
</Select>
@@ -286,12 +357,54 @@ watch(open, async (val) => {
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('diff.selectSchema')" />
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" align="start">
<SelectItem v-for="s in schemas" :key="s" :value="s">{{ s }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="schema" class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label class="text-xs">{{ t("databaseExport.tableSelection") }}</Label>
<div v-if="tables.length" class="text-[11px] text-muted-foreground">
{{ t("databaseExport.selectedTables", { selected: selectedTables.length, total: tables.length }) }}
</div>
</div>
<div v-if="loadingTables" class="text-xs text-muted-foreground">
{{ t("databaseExport.loadingTables") }}
</div>
<div v-else-if="tableError" class="text-xs text-destructive">
{{ t("databaseExport.tableLoadError", { error: tableError }) }}
</div>
<div v-else-if="tables.length" class="space-y-2 rounded border border-border/60 p-2">
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" class="h-7 px-2 text-xs" @click="selectAllTables">
{{ t("databaseExport.selectAllTables") }}
</Button>
<Button variant="outline" size="sm" class="h-7 px-2 text-xs" @click="clearSelectedTables">
{{ t("databaseExport.clearTables") }}
</Button>
</div>
<div class="max-h-40 overflow-auto space-y-1 pr-1">
<button
v-for="table in tables"
:key="table"
type="button"
class="flex w-full min-w-0 items-center gap-2 rounded px-1.5 py-1 text-left text-xs hover:bg-muted"
@click="toggleTable(table)"
>
<CheckSquare v-if="selectedTableSet.has(table)" 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" />
<span class="truncate">{{ table }}</span>
</button>
</div>
</div>
<div v-else class="text-xs text-muted-foreground">
{{ t("databaseExport.noTables") }}
</div>
</div>
<!-- Options -->
<div class="space-y-2.5 pt-1">
<div class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
+1
View File
@@ -181,6 +181,7 @@ watch(
:prefill-connection-id="dialogs.databaseExportPrefillConnectionId.value"
:prefill-database="dialogs.databaseExportPrefillDatabase.value"
:prefill-schema="dialogs.databaseExportPrefillSchema.value"
:prefill-table="dialogs.databaseExportPrefillTable.value"
/>
<ConfigPassphraseDialog
v-model:open="dialogs.showConfigPassphraseDialog.value"
+6 -1
View File
@@ -1137,7 +1137,8 @@ function openDatabaseExport() {
connectionStore.databaseExportSource = {
connectionId: node.connectionId,
database: node.database,
schema: node.type === "schema" ? node.schema : undefined,
schema: node.type === "schema" || node.type === "table" || node.type === "view" ? node.schema : undefined,
tableName: node.type === "table" || node.type === "view" ? node.label : undefined,
};
}
@@ -1759,6 +1760,10 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
<ContextMenuItem @click="exportDataXlsx">XLSX</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem @click="openDatabaseExport">
<Download class="w-4 h-4" />
{{ t("contextMenu.exportDatabase") }}
</ContextMenuItem>
<ContextMenuItem @click="exportStructure">
<FileCode class="w-4 h-4" /> {{ t("contextMenu.exportStructure") }}
</ContextMenuItem>
+3
View File
@@ -55,6 +55,7 @@ const databaseSearchPrefillSchema = ref("");
const databaseExportPrefillConnectionId = ref("");
const databaseExportPrefillDatabase = ref("");
const databaseExportPrefillSchema = ref("");
const databaseExportPrefillTable = ref("");
let watchersRegistered = false;
@@ -195,6 +196,7 @@ export function useDialogSources() {
databaseExportPrefillConnectionId.value = v.connectionId;
databaseExportPrefillDatabase.value = v.database;
databaseExportPrefillSchema.value = v.schema ?? "";
databaseExportPrefillTable.value = v.tableName ?? "";
showDatabaseExportDialog.value = true;
connectionStore.databaseExportSource = null;
}
@@ -316,6 +318,7 @@ export function useDialogSources() {
databaseExportPrefillConnectionId,
databaseExportPrefillDatabase,
databaseExportPrefillSchema,
databaseExportPrefillTable,
onExportClick,
onExportConfirm,
onImportClick,
+7
View File
@@ -1076,6 +1076,13 @@ export default {
includeStructure: "Table structure (DDL)",
includeData: "Table data (INSERT)",
includeObjects: "Views / Procedures / Functions",
tableSelection: "Tables",
selectedTables: "{selected}/{total} selected",
selectAllTables: "Select all",
clearTables: "Clear",
loadingTables: "Loading tables...",
tableLoadError: "Failed to load tables: {error}",
noTables: "No tables found",
export: "Export",
exporting: "Exporting...",
currentTable: "Current: {table} ({current}/{total})",
+7
View File
@@ -1054,6 +1054,13 @@ export default {
includeStructure: "Estructura de tablas (DDL)",
includeData: "Datos de tablas (INSERT)",
includeObjects: "Vistas / Procedimientos / Funciones",
tableSelection: "Tablas",
selectedTables: "{selected}/{total} seleccionadas",
selectAllTables: "Seleccionar todo",
clearTables: "Limpiar",
loadingTables: "Cargando tablas...",
tableLoadError: "Error al cargar tablas: {error}",
noTables: "No se encontraron tablas",
export: "Exportar",
exporting: "Exportando...",
currentTable: "Actual: {table} ({current}/{total})",
+7
View File
@@ -1058,6 +1058,13 @@ export default {
includeStructure: "表结构 (DDL)",
includeData: "表数据 (INSERT)",
includeObjects: "视图 / 存储过程 / 函数",
tableSelection: "选择表",
selectedTables: "已选择 {selected}/{total}",
selectAllTables: "全选",
clearTables: "清空",
loadingTables: "正在加载表...",
tableLoadError: "加载表失败:{error}",
noTables: "未找到表",
export: "导出",
exporting: "正在导出...",
currentTable: "当前: {table} ({current}/{total})",
+8
View File
@@ -0,0 +1,8 @@
export function buildSelectedTablesPayload(allTables: string[], selectedTables: string[]): string[] | undefined {
if (allTables.length > 0 && selectedTables.length === allTables.length) {
return undefined;
}
const selected = new Set(selectedTables);
return allTables.filter((table) => selected.has(table));
}
+1
View File
@@ -776,6 +776,7 @@ export interface DatabaseExportRequest {
database: string;
schema: string;
filePath: string;
selectedTables?: string[];
includeStructure: boolean;
includeData: boolean;
includeObjects: boolean;
+1
View File
@@ -108,6 +108,7 @@ export const useConnectionStore = defineStore("connection", () => {
connectionId: string;
database: string;
schema?: string;
tableName?: string;
} | null>(null);
const sidebarLayout = ref<SidebarLayout>(emptyLayout());
let layoutPersistTimer: ReturnType<typeof setTimeout> | null = null;
+15
View File
@@ -0,0 +1,15 @@
import { strict as assert } from "node:assert";
import test from "node:test";
import { buildSelectedTablesPayload } from "../src/lib/databaseExportSelection.ts";
test("omits selected table payload when every table is selected", () => {
assert.equal(buildSelectedTablesPayload(["users", "orders"], ["users", "orders"]), undefined);
});
test("builds selected table payload for a subset in table order", () => {
assert.deepEqual(buildSelectedTablesPayload(["users", "orders", "logs"], ["logs", "users"]), ["users", "logs"]);
});
test("builds an empty payload when no table is selected", () => {
assert.deepEqual(buildSelectedTablesPayload(["users"], []), []);
});