mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 03:46:22 +00:00
feat(export): add table selection to database export
This commit is contained in:
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -776,6 +776,7 @@ export interface DatabaseExportRequest {
|
||||
database: string;
|
||||
schema: string;
|
||||
filePath: string;
|
||||
selectedTables?: string[];
|
||||
includeStructure: boolean;
|
||||
includeData: boolean;
|
||||
includeObjects: boolean;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"], []), []);
|
||||
});
|
||||
Reference in New Issue
Block a user