From 048f629d21f0f90575aba52350c75a9eed2097d7 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 15 May 2026 18:54:35 +0800 Subject: [PATCH] feat(export): add table selection to database export --- crates/dbx-core/src/database_export.rs | 44 ++++++- .../export/DatabaseExportDialog.vue | 119 +++++++++++++++++- src/components/layout/AppDialogs.vue | 1 + src/components/sidebar/TreeItem.vue | 7 +- src/composables/useDialogSources.ts | 3 + src/i18n/locales/en.ts | 7 ++ src/i18n/locales/es.ts | 7 ++ src/i18n/locales/zh-CN.ts | 7 ++ src/lib/databaseExportSelection.ts | 8 ++ src/lib/tauri.ts | 1 + src/stores/connectionStore.ts | 1 + tests/databaseExportSelection.test.ts | 15 +++ 12 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 src/lib/databaseExportSelection.ts create mode 100644 tests/databaseExportSelection.test.ts diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index c82c353fdf..dd28c76bf4 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -16,6 +16,8 @@ pub struct DatabaseExportRequest { pub database: String, pub schema: String, pub file_path: String, + #[serde(default)] + pub selected_tables: Vec, 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 = Vec::new(); let mut functions: Vec = 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, + selected_tables: &[String], +) -> Vec { + 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!["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!["users", "orders"]); + } +} diff --git a/src/components/export/DatabaseExportDialog.vue b/src/components/export/DatabaseExportDialog.vue index 9e13eee13f..2f1b2d76bc 100644 --- a/src/components/export/DatabaseExportDialog.vue +++ b/src/components/export/DatabaseExportDialog.vue @@ -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([]); const schema = ref(""); const schemas = ref([]); const loadingMeta = ref(false); +const tables = ref([]); +const selectedTables = ref([]); +const loadingTables = ref(false); +const tableError = ref(null); // Options const includeStructure = ref(true); @@ -45,6 +51,7 @@ const exportId = ref(""); const exportDone = ref(false); const exportError = ref(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) => { - +
@@ -274,7 +345,7 @@ watch(open, async (val) => { - + {{ db }} @@ -286,12 +357,54 @@ watch(open, async (val) => { - + {{ s }}
+
+
+ +
+ {{ t("databaseExport.selectedTables", { selected: selectedTables.length, total: tables.length }) }} +
+
+ +
+ {{ t("databaseExport.loadingTables") }} +
+
+ {{ t("databaseExport.tableLoadError", { error: tableError }) }} +
+
+
+ + +
+
+ +
+
+
+ {{ t("databaseExport.noTables") }} +
+
+
diff --git a/src/components/layout/AppDialogs.vue b/src/components/layout/AppDialogs.vue index f90137a19c..0a1fd1057d 100644 --- a/src/components/layout/AppDialogs.vue +++ b/src/components/layout/AppDialogs.vue @@ -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" /> dragState.active && dragState.draggedId === pr XLSX + + + {{ t("contextMenu.exportDatabase") }} + {{ t("contextMenu.exportStructure") }} diff --git a/src/composables/useDialogSources.ts b/src/composables/useDialogSources.ts index 30301cb0ef..608adb29e9 100644 --- a/src/composables/useDialogSources.ts +++ b/src/composables/useDialogSources.ts @@ -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, diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c4a4f40406..ff7bf3a714 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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})", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index bf99c04625..4893977993 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -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})", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index c32ad2eb34..2c1a8d9586 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -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})", diff --git a/src/lib/databaseExportSelection.ts b/src/lib/databaseExportSelection.ts new file mode 100644 index 0000000000..5e7b9e19d5 --- /dev/null +++ b/src/lib/databaseExportSelection.ts @@ -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)); +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index ad37e0f9d8..7893dba2e6 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -776,6 +776,7 @@ export interface DatabaseExportRequest { database: string; schema: string; filePath: string; + selectedTables?: string[]; includeStructure: boolean; includeData: boolean; includeObjects: boolean; diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts index 738225f889..ae38c94209 100644 --- a/src/stores/connectionStore.ts +++ b/src/stores/connectionStore.ts @@ -108,6 +108,7 @@ export const useConnectionStore = defineStore("connection", () => { connectionId: string; database: string; schema?: string; + tableName?: string; } | null>(null); const sidebarLayout = ref(emptyLayout()); let layoutPersistTimer: ReturnType | null = null; diff --git a/tests/databaseExportSelection.test.ts b/tests/databaseExportSelection.test.ts new file mode 100644 index 0000000000..b546b50186 --- /dev/null +++ b/tests/databaseExportSelection.test.ts @@ -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"], []), []); +});