diff --git a/apps/desktop/src/components/export/DatabaseExportDialog.vue b/apps/desktop/src/components/export/DatabaseExportDialog.vue index c9bcd863f4..a3e413f4ce 100644 --- a/apps/desktop/src/components/export/DatabaseExportDialog.vue +++ b/apps/desktop/src/components/export/DatabaseExportDialog.vue @@ -59,6 +59,7 @@ const filteredTables = computed(() => { return tables.value.filter((name) => name.toLowerCase().includes(q)); }); const tableError = ref(null); +const POSTGRES_ALL_SCHEMAS = "__DBX_ALL_SCHEMAS__"; // Options const includeStructure = ref(true); @@ -70,6 +71,7 @@ const omitAutoIncrement = ref(false); // `AUTO_INCREMENT` stripping is a MySQL-only DDL transform (backend gates on // db_type == mysql, which also covers MariaDB / TiDB / OceanBase-MySQL-mode). const isMysqlFamily = computed(() => store.getConfig(connectionId.value)?.db_type === "mysql"); +const isPostgresAllSchemas = computed(() => store.getConfig(connectionId.value)?.db_type === "postgres" && schema.value === POSTGRES_ALL_SCHEMAS); // Export state const isExporting = ref(false); @@ -115,6 +117,7 @@ const canExport = computed(() => { const hasContent = includeStructure.value || includeData.value || includeObjects.value; if (!connectionId.value || !hasContent || isExporting.value) return false; if (exportAllDatabases.value) return selectedDatabases.value.length > 0 && !loadingMeta.value; + if (isPostgresAllSchemas.value) return database.value && schemas.value.length > 0 && !loadingMeta.value; return ( database.value && schema.value && @@ -230,6 +233,13 @@ async function loadSchemas(preferredSchema = "") { } async function loadTables(preferredTable = "", preferredTables: string[] = []) { + if (schema.value === POSTGRES_ALL_SCHEMAS) { + tables.value = []; + selectedTables.value = []; + loadingTables.value = false; + tableError.value = null; + return; + } if (!connectionId.value || !database.value || !schema.value) return; loadingTables.value = true; tableError.value = null; @@ -371,9 +381,9 @@ async function startExport() { exportId: exportId.value, connectionId: connectionId.value, database: database.value, - schema: schema.value, + schema: isPostgresAllSchemas.value ? "" : schema.value, filePath, - selectedTables: includeStructure.value || includeData.value ? buildSelectedTablesPayload(tables.value, selectedTables.value) : undefined, + selectedTables: !isPostgresAllSchemas.value && (includeStructure.value || includeData.value) ? buildSelectedTablesPayload(tables.value, selectedTables.value) : undefined, includeStructure: includeStructure.value, includeData: includeData.value, includeObjects: includeObjects.value, @@ -737,6 +747,7 @@ watch(schema, (value) => { const preferredTables = pendingPrefillTables.value; pendingPrefillTable.value = ""; pendingPrefillTables.value = []; + if (value === POSTGRES_ALL_SCHEMAS) return; if (value) loadTables(preferredTable, preferredTables).catch((e) => toast(String(e), 5000)); }); @@ -846,12 +857,13 @@ watch( + {{ t("databaseExport.allSchemas") }} {{ s }} -
+
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index e4cbac10a4..6205b2d6e1 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -7708,6 +7708,7 @@ export default { noTables: "No tables found", export: "Export", exportAllDatabases: "Export All Databases", + allSchemas: "All schemas", exporting: "Exporting...", preparing: "Preparing export (reading tables and metadata)...", preparingObject: "Preparing: {object}", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 015f4b8ce4..87b8dbd9ee 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -7290,6 +7290,7 @@ export default withEnglishFallback({ exportSuccessWithErrors: "La exportación terminó con {count} objeto(s) fallidos: {firstError}… Consulta los comentarios -- ERROR en el archivo exportado.", exportAllSuccessWithErrors: "Se exportaron {count} bases de datos, pero {errorCount} objeto(s) fallaron: {firstError}… Consulta los comentarios -- ERROR en los archivos.", allDatabasesTask: "Todas las bases de datos ({count})", + allSchemas: "Todos los esquemas", exportError: "Error al exportar: {error}", exportCancelled: "Exportación cancelada", filterTables: "Buscar tablas...", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 15437d36c5..d6cc5a5f24 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -7292,6 +7292,7 @@ export default withEnglishFallback({ exportSuccessWithErrors: "Esportazione completata con {count} oggetti non riusciti: {firstError}… Dettagli nei commenti -- ERROR nel file esportato.", exportAllSuccessWithErrors: "Esportati {count} database, ma {errorCount} oggetti non riusciti: {firstError}… Dettagli nei commenti -- ERROR nei file.", allDatabasesTask: "Tutti i database ({count})", + allSchemas: "Tutti gli schemi", exportError: "Esportazione non riuscita: {error}", exportCancelled: "Esportazione annullata", runInBackground: "Esegui in background", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index c82a64b03c..fd765ff3e3 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -7344,6 +7344,7 @@ export default withEnglishFallback({ exportSuccessWithErrors: "エクスポートは完了しましたが、{count} 個のオブジェクトが失敗しました:{firstError}… 詳細はエクスポートファイル内の -- ERROR コメントを確認してください。", exportAllSuccessWithErrors: "{count} 個のデータベースをエクスポートしましたが、{errorCount} 個のオブジェクトが失敗しました:{firstError}… 詳細はファイル内の -- ERROR コメントを確認してください。", allDatabasesTask: "すべてのデータベース ({count})", + allSchemas: "すべてのスキーマ", exportError: "エクスポートに失敗しました: {error}", exportCancelled: "エクスポートがキャンセルされました", }, diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 8258704f72..1b4101e86c 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -7000,6 +7000,7 @@ export default withEnglishFallback({ exportSuccessWithErrors: "내보내기가 완료되었지만 {count}개 객체가 실패했습니다: {firstError}… 자세한 내용은 내보낸 파일의 -- ERROR 주석을 참조하세요.", exportAllSuccessWithErrors: "데이터베이스 {count}개를 내보냈지만 {errorCount}개 객체가 실패했습니다: {firstError}… 자세한 내용은 파일의 -- ERROR 주석을 참조하세요.", allDatabasesTask: "모든 데이터베이스 ({count})", + allSchemas: "모든 스키마", exportError: "내보내기 실패: {error}", exportCancelled: "내보내기 취소됨", runInBackground: "백그라운드에서 실행", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index a0f366649d..d03e83658a 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -7294,6 +7294,7 @@ export default withEnglishFallback({ exportSuccessWithErrors: "A exportação terminou com {count} objeto(s) com falha: {firstError}… Veja os comentários -- ERROR no arquivo exportado.", exportAllSuccessWithErrors: "{count} bancos de dados exportados, mas {errorCount} objeto(s) falharam: {firstError}… Veja os comentários -- ERROR nos arquivos.", allDatabasesTask: "Todos os bancos de dados ({count})", + allSchemas: "Todos os esquemas", exportError: "Falha na exportação: {error}", exportCancelled: "Exportação cancelada", }, diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 6da8987739..bc76c5a2dd 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -7616,6 +7616,7 @@ export default withEnglishFallback({ exportSuccessWithErrors: "Dışa aktarma tamamlandı ancak {count} nesne başarısız oldu: {firstError}… Ayrıntılar için dışa aktarılan dosyadaki -- ERROR yorumlarına bakın.", exportAllSuccessWithErrors: "{count} veritabanı dışa aktarıldı ancak {errorCount} nesne başarısız oldu: {firstError}… Ayrıntılar için dosyalardaki -- ERROR yorumlarına bakın.", allDatabasesTask: "Tüm veritabanları ({count})", + allSchemas: "Tüm şemalar", exportError: "Dışa aktarma başarısız: {error}", exportCancelled: "Dışa aktarma iptal edildi", runInBackground: "Arka planda çalıştır", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index be6eb88a97..cdd7ff9628 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -7689,6 +7689,7 @@ export default withEnglishFallback({ noTables: "未找到表", export: "导出", exportAllDatabases: "导出全部数据库", + allSchemas: "全部 Schema", exporting: "正在导出...", preparing: "正在准备导出(读取表与元数据)...", preparingObject: "正在准备: {object}", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 1d8f480f27..235a99a757 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -6607,6 +6607,7 @@ export default withEnglishFallback({ exportSuccessWithErrors: "匯出完成,但有 {count} 個物件失敗:{firstError}……詳情見匯出檔案中的 -- ERROR 註解。", exportAllSuccessWithErrors: "已匯出 {count} 個資料庫,但有 {errorCount} 個物件失敗:{firstError}……詳情見匯出檔案中的 -- ERROR 註解。", allDatabasesTask: "全部資料庫({count})", + allSchemas: "全部 schema", exportError: "匯出失敗: {error}", exportCancelled: "匯出已取消", runInBackground: "背景執行", diff --git a/apps/desktop/src/lib/export/databaseExport.ts b/apps/desktop/src/lib/export/databaseExport.ts index 2b6477d733..c503b8810b 100644 --- a/apps/desktop/src/lib/export/databaseExport.ts +++ b/apps/desktop/src/lib/export/databaseExport.ts @@ -166,6 +166,17 @@ export function buildAllDatabaseExportPlan(options: AllDatabaseExportPlanInput): displayName: schema, })); } + // PostgreSQL supports multiple schemas in one database. Keep the database + // as the export unit so the backend can produce one restore script for all + // schemas instead of one file per schema. + if (options.dbType === "postgres" && options.schemaAware) { + return options.databases.map((database) => ({ + database, + schema: "", + fileStem: database, + displayName: database, + })); + } return options.databases.flatMap((database) => { const schemas = options.schemaAware ? filterExportableSchemas(options.schemasByDatabase?.[database] ?? [], options.dbType).filter((schema) => schema.trim()) : [database]; const exportSchemas = schemas.length > 0 ? schemas : [database]; diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index 095d10583c..116a945f42 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -2586,10 +2586,21 @@ pub async fn export_database_sql_core( request: &DatabaseExportRequest, on_progress: impl Fn(ExportProgress) + Sync, ) -> Result<(), String> { + let db_type = state + .configs + .read() + .await + .get(&request.connection_id) + .map(|config| config.db_type) + .ok_or_else(|| format!("Connection config not found: {}", request.connection_id))?; // Keep the large export state machine on the heap. Besides making the // caller future small, this prevents the metadata-prefetch locals from // exhausting the bounded stack used by test and runtime worker threads. - let result = Box::pin(export_database_sql_core_inner(state, request, &on_progress)).await; + let result = if matches!(db_type, DatabaseType::Postgres) && request.schema.trim().is_empty() { + Box::pin(export_postgres_all_schemas_sql_core(state, request, &on_progress)).await + } else { + Box::pin(export_database_sql_core_inner(state, request, &on_progress)).await + }; let metadata_session_id = database_export_client_session_id(&request.export_id); if let Err(error) = state.close_metadata_session_pool(&request.connection_id, Some(&request.database), &metadata_session_id).await @@ -2611,6 +2622,185 @@ pub async fn export_database_sql_core( } } +/// Destination directory that must exist (and stay on the same filesystem) +/// before the export file may be written there, or `None` when the file path +/// has no parent (e.g. a bare file name or the filesystem root). Shared by +/// [`create_database_export_writer`] and the all-schemas export, which +/// validates the destination up front instead of only after exporting every +/// schema to temporary files. +fn export_destination_parent_dir(file_path: &str) -> Option<&std::path::Path> { + let parent = std::path::Path::new(file_path).parent()?; + (!parent.as_os_str().is_empty()).then_some(parent) +} + +async fn create_database_export_writer( + state: &Arc, + request: &DatabaseExportRequest, +) -> Result { + let mut expected_destination_identity = None; + if let Some(parent) = export_destination_parent_dir(&request.file_path) { + expected_destination_identity = ensure_export_destination_dir(state, parent).await?; + } + let file = std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to write file: {e}"))?; + // The directory check above and this `File::create` are separate + // operations: the mount can disappear and be replaced by something else + // at the same path in between. Re-check the identity of the handle we + // actually opened, not just the path, and refuse to keep a backup that + // landed on the wrong filesystem. See #6327. + let opened_destination_identity = export_destination_identity_for_file(&file); + if export_destination_identity_mismatch( + expected_destination_identity.as_ref(), + opened_destination_identity.as_ref(), + ) { + drop(file); + let _ = std::fs::remove_file(&request.file_path); + return Err(format!( + "Backup destination for {} changed while opening the output file -- the directory now \ + resolves to a different filesystem than the one just verified. If a removable or network \ + drive was disconnected and reconnected, retry the backup.", + request.file_path + )); + } + Ok(match request.output_compression { + DatabaseExportOutputCompression::None => DatabaseExportWriter::Plain(BufWriter::new(file)), + DatabaseExportOutputCompression::Gzip => { + DatabaseExportWriter::Gzip(Box::new(GzEncoder::new(BufWriter::new(file), Compression::default()))) + } + }) +} + +fn postgres_export_schema_names(schemas: Vec) -> Vec { + let mut seen = HashSet::new(); + schemas + .into_iter() + .map(|schema| schema.trim().to_string()) + .filter(|schema| !schema.is_empty() && schema != "information_schema" && !schema.starts_with("pg_")) + .filter(|schema| seen.insert(schema.clone())) + .collect() +} + +fn postgres_create_schema_sql(schema: &str) -> String { + format!("CREATE SCHEMA IF NOT EXISTS {};", quote_identifier(schema, &DatabaseType::Postgres)) +} + +async fn export_postgres_all_schemas_sql_core( + state: &Arc, + request: &DatabaseExportRequest, + on_progress: impl Fn(ExportProgress) + Sync, +) -> Result<(), String> { + let schemas = postgres_export_schema_names( + crate::schema::list_schemas_core(state, &request.connection_id, &request.database).await?, + ); + if schemas.is_empty() { + return Err(format!("No exportable schemas found in database '{}'.", request.database)); + } + + // Validate the destination before exporting every schema to temporary + // files: the writer below only runs after the loop, which for large + // databases can be hours away, and a missing or unwritable destination + // must fail fast instead. This is an early fail, not a replacement -- the + // writer still performs its own checks when it opens the output file. + if let Some(parent) = export_destination_parent_dir(&request.file_path) { + ensure_export_destination_dir(state, parent).await?; + } + + let temp_dir = + tempfile::tempdir().map_err(|error| format!("Failed to create temporary export directory: {error}"))?; + let result = async { + let mut schema_outputs = Vec::with_capacity(schemas.len()); + let mut rows_exported = 0_u64; + let mut error_count = 0_u64; + let mut error_summary = None; + + for (schema_index, schema_name) in schemas.iter().enumerate() { + let mut schema_request = request.clone(); + schema_request.schema = schema_name.clone(); + schema_request.file_path = temp_dir.path().join(format!("schema-{schema_index}.sql")).display().to_string(); + schema_request.output_compression = DatabaseExportOutputCompression::None; + + let terminal = Arc::new(std::sync::Mutex::new(None::)); + let terminal_for_callback = terminal.clone(); + let schema_name_for_callback = schema_name.clone(); + let completed_rows = rows_exported; + let child_progress = |mut progress: ExportProgress| { + if matches!(progress.status, ExportStatus::Done | ExportStatus::Cancelled) { + *terminal_for_callback.lock().expect("export terminal mutex poisoned") = Some(progress); + return; + } + progress.export_id = request.export_id.clone(); + progress.current_object = if progress.current_object.is_empty() { + schema_name_for_callback.clone() + } else { + format!("{schema_name_for_callback}: {}", progress.current_object) + }; + progress.rows_exported = completed_rows.saturating_add(progress.rows_exported); + on_progress(progress); + }; + + Box::pin(export_database_sql_core_inner(state, &schema_request, child_progress)).await?; + let terminal = terminal.lock().expect("export terminal mutex poisoned").clone(); + if terminal.as_ref().is_some_and(|progress| matches!(progress.status, ExportStatus::Cancelled)) { + return Err(EXPORT_CANCELLED_ERROR.to_string()); + } + if let Some(progress) = terminal { + rows_exported = rows_exported.saturating_add(progress.rows_exported); + error_count = error_count.saturating_add(progress.error_count); + // Aggregate lenient failure summaries across schemas; keeping + // only the first schema's summary would understate the errors + // behind an error_count that accumulates over all schemas. + if let Some(summary) = progress.error_summary { + error_summary = Some(match error_summary.take() { + Some(existing) => format!("{existing}; {summary}"), + None => summary, + }); + } + } + schema_outputs.push((schema_name.clone(), schema_request.file_path)); + } + + let mut file = create_database_export_writer(state, request).await?; + let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S"); + writeln!(file, "-- Database export: {}", request.database).map_err(|e| format!("Failed to write file: {e}"))?; + writeln!(file, "-- Date: {timestamp}").map_err(|e| format!("Failed to write file: {e}"))?; + writeln!(file, "-- Generated by DBX").map_err(|e| format!("Failed to write file: {e}"))?; + writeln!(file, "-- PostgreSQL schemas: {}", schemas.join(", ")) + .map_err(|e| format!("Failed to write file: {e}"))?; + writeln!(file).map_err(|e| format!("Failed to write file: {e}"))?; + for schema_name in &schemas { + writeln!(file, "{}", postgres_create_schema_sql(schema_name)) + .map_err(|e| format!("Failed to write file: {e}"))?; + } + writeln!(file).map_err(|e| format!("Failed to write file: {e}"))?; + + for (schema_name, path) in schema_outputs { + writeln!(file, "-- Schema export: {schema_name}").map_err(|e| format!("Failed to write file: {e}"))?; + let mut source = std::io::BufReader::new( + std::fs::File::open(path).map_err(|e| format!("Failed to read temporary schema export: {e}"))?, + ); + std::io::copy(&mut source, &mut file).map_err(|e| format!("Failed to combine schema export: {e}"))?; + writeln!(file).map_err(|e| format!("Failed to write file: {e}"))?; + } + file.finish()?; + on_progress(ExportProgress { + export_id: request.export_id.clone(), + current_object: request.database.clone(), + object_index: schemas.len(), + total_objects: schemas.len(), + rows_exported, + total_rows: None, + status: ExportStatus::Done, + error: None, + preparing: false, + error_count, + error_summary, + }); + Ok(()) + } + .await; + + result +} + async fn export_database_sql_core_inner( state: &Arc, request: &DatabaseExportRequest, @@ -2658,38 +2848,7 @@ async fn export_database_sql_core_inner( )) .await?; // 4. Create file - let mut expected_destination_identity = None; - if let Some(parent) = std::path::Path::new(&request.file_path).parent() { - if !parent.as_os_str().is_empty() { - expected_destination_identity = ensure_export_destination_dir(state, parent).await?; - } - } - let file = std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to write file: {e}"))?; - // The directory check above and this `File::create` are separate - // operations: the mount can disappear and be replaced by something else - // at the same path in between. Re-check the identity of the handle we - // actually opened, not just the path, and refuse to keep a backup that - // landed on the wrong filesystem. See #6327. - let opened_destination_identity = export_destination_identity_for_file(&file); - if export_destination_identity_mismatch( - expected_destination_identity.as_ref(), - opened_destination_identity.as_ref(), - ) { - drop(file); - let _ = std::fs::remove_file(&request.file_path); - return Err(format!( - "Backup destination for {} changed while opening the output file -- the directory now \ - resolves to a different filesystem than the one just verified. If a removable or network \ - drive was disconnected and reconnected, retry the backup.", - request.file_path - )); - } - let mut file = match request.output_compression { - DatabaseExportOutputCompression::None => DatabaseExportWriter::Plain(BufWriter::new(file)), - DatabaseExportOutputCompression::Gzip => { - DatabaseExportWriter::Gzip(Box::new(GzEncoder::new(BufWriter::new(file), Compression::default()))) - } - }; + let mut file = create_database_export_writer(state, request).await?; let create_database_preamble = if request.include_create_database && matches!(db_type, DatabaseType::Mysql) { Some(mysql_database_export_preamble_for_request(state, request, &client_session_id).await) @@ -3648,8 +3807,8 @@ mod tests { use super::{ await_export_operation, await_export_stream_operation, clear_export_cancelled, concurrent_metadata_prefetch_allowed, database_export_metadata_prefetch_concurrency, - emit_database_export_cancelled, set_export_cancelled, snapshot_batch_cancelled, ExportStatus, - EXPORT_CANCELLED_ERROR, + emit_database_export_cancelled, postgres_create_schema_sql, postgres_export_schema_names, set_export_cancelled, + snapshot_batch_cancelled, ExportStatus, EXPORT_CANCELLED_ERROR, }; use super::{ build_database_export_object_source_sql, build_database_sql_export, build_export_insert_statements, @@ -3948,6 +4107,26 @@ mod tests { assert!(!ddl.contains("VERSION")); } + #[test] + fn postgres_all_schema_export_filters_system_schemas_and_deduplicates() { + assert_eq!( + postgres_export_schema_names(vec![ + " public ".to_string(), + "pg_catalog".to_string(), + "information_schema".to_string(), + "private".to_string(), + "private".to_string(), + "".to_string(), + ]), + vec!["public".to_string(), "private".to_string()] + ); + } + + #[test] + fn postgres_all_schema_export_creates_quoted_schema_if_missing() { + assert_eq!(postgres_create_schema_sql("tenant\"data"), "CREATE SCHEMA IF NOT EXISTS \"tenant\"\"data\";"); + } + #[test] fn postgres_extension_member_filter_keeps_user_overload_with_same_name() { let mut members = PostgresExtensionMembers::default(); diff --git a/crates/dbx-core/tests/live_postgres_all_schema_export.rs b/crates/dbx-core/tests/live_postgres_all_schema_export.rs new file mode 100644 index 0000000000..a15f45d1c2 --- /dev/null +++ b/crates/dbx-core/tests/live_postgres_all_schema_export.rs @@ -0,0 +1,186 @@ +use dbx_core::connection::AppState; +use dbx_core::database_export::{export_database_sql_core, DatabaseExportRequest, ExportStatus}; +use dbx_core::models::connection::{ConnectionConfig, DatabaseType}; +use dbx_core::query::execute_sql_statement; +use dbx_core::sql::SqlFileRequest; +use dbx_core::sql_file_import::execute_sql_file_path; +use dbx_core::storage::Storage; +use std::sync::{Arc, Mutex}; +use tokio_util::sync::CancellationToken; + +fn live_postgres_config(id: &str, database: &str) -> ConnectionConfig { + let host = std::env::var("DBX_LIVE_POSTGRES_EXPORT_HOST").expect("DBX_LIVE_POSTGRES_EXPORT_HOST"); + let port = + std::env::var("DBX_LIVE_POSTGRES_EXPORT_PORT").ok().and_then(|value| value.parse::().ok()).unwrap_or(5432); + let username = std::env::var("DBX_LIVE_POSTGRES_EXPORT_USER").expect("DBX_LIVE_POSTGRES_EXPORT_USER"); + let password = std::env::var("DBX_LIVE_POSTGRES_EXPORT_PASSWORD").expect("DBX_LIVE_POSTGRES_EXPORT_PASSWORD"); + + serde_json::from_value(serde_json::json!({ + "id": id, + "name": id, + "db_type": DatabaseType::Postgres, + "host": host, + "port": port, + "username": username, + "password": password, + "database": database, + "ssl": false, + "connect_timeout_secs": 10, + "query_timeout_secs": 30, + "idle_timeout_secs": 60, + "keepalive_interval_secs": 0 + })) + .expect("live PostgreSQL export config should deserialize") +} + +#[tokio::test] +#[ignore = "requires a live PostgreSQL account with CREATE DATABASE privilege"] +async fn live_postgres_all_schema_export_restores_one_sql_file() { + let suffix = uuid::Uuid::new_v4().simple().to_string(); + let source_database = format!("dbx_issue8336_src_{}", &suffix[..12]); + let target_database = format!("dbx_issue8336_dst_{}", &suffix[..12]); + let expected_export_database = source_database.clone(); + let admin_connection_id = format!("postgres-all-schema-admin-{suffix}"); + let source_connection_id = format!("postgres-all-schema-source-{suffix}"); + let target_connection_id = format!("postgres-all-schema-target-{suffix}"); + let dir = tempfile::tempdir().expect("create export temp directory"); + let storage = Storage::open(&dir.path().join("storage.db")).await.expect("open temp storage"); + let state = Arc::new(AppState::new(storage)); + state + .configs + .write() + .await + .insert(admin_connection_id.clone(), live_postgres_config(&admin_connection_id, "postgres")); + for database in [&source_database, &target_database] { + Box::pin(execute_sql_statement( + &state, + &admin_connection_id, + "postgres", + &format!("CREATE DATABASE \"{database}\""), + None, + None, + )) + .await + .expect("create disposable PostgreSQL database"); + } + state + .configs + .write() + .await + .insert(source_connection_id.clone(), live_postgres_config(&source_connection_id, &source_database)); + state + .configs + .write() + .await + .insert(target_connection_id.clone(), live_postgres_config(&target_connection_id, &target_database)); + + for statement in [ + "CREATE SCHEMA inventory", + "CREATE SCHEMA reporting", + "CREATE TABLE public.accounts (id integer PRIMARY KEY, name text NOT NULL)", + "CREATE TABLE inventory.products (id integer PRIMARY KEY, sku text NOT NULL)", + "CREATE TABLE reporting.daily_totals (day date PRIMARY KEY, amount numeric(12,2) NOT NULL)", + "CREATE VIEW reporting.positive_totals AS SELECT day, amount FROM reporting.daily_totals WHERE amount > 0", + "INSERT INTO public.accounts VALUES (1, 'Alice'), (2, 'Bob')", + "INSERT INTO inventory.products VALUES (10, 'SKU-10')", + "INSERT INTO reporting.daily_totals VALUES ('2026-09-07', 42.50)", + ] { + Box::pin(execute_sql_statement(&state, &source_connection_id, &source_database, statement, None, None)) + .await + .expect("create source fixture"); + } + + let export_path = dir.path().join("postgres-all-schemas.sql"); + let progress = Arc::new(Mutex::new(Vec::new())); + let progress_sink = progress.clone(); + export_database_sql_core( + &state, + &DatabaseExportRequest { + export_id: format!("postgres-all-schema-export-{suffix}"), + connection_id: source_connection_id, + database: source_database.clone(), + schema: String::new(), + file_path: export_path.display().to_string(), + selected_tables: Vec::new(), + excluded_tables: Vec::new(), + include_structure: true, + include_data: true, + include_objects: true, + include_create_database: false, + drop_table_if_exists: false, + omit_auto_increment: false, + fail_on_error: true, + output_compression: Default::default(), + snapshot_session_id: None, + batch_size: 1000, + }, + move |event| progress_sink.lock().expect("progress mutex poisoned").push(event), + ) + .await + .expect("export every PostgreSQL schema"); + + let exported = std::fs::read_to_string(&export_path).expect("read combined export"); + for expected in [ + "CREATE SCHEMA IF NOT EXISTS \"public\";", + "CREATE SCHEMA IF NOT EXISTS \"inventory\";", + "CREATE SCHEMA IF NOT EXISTS \"reporting\";", + "CREATE TABLE \"public\".\"accounts\"", + "CREATE TABLE \"inventory\".\"products\"", + "CREATE TABLE \"reporting\".\"daily_totals\"", + "positive_totals", + ] { + assert!(exported.contains(expected), "missing {expected} in combined export:\n{exported}"); + } + assert_eq!(exported.matches("-- Schema export:").count(), 3, "unexpected schema sections:\n{exported}"); + assert!( + progress.lock().expect("progress mutex poisoned").iter().any(|event| { + matches!(event.status, ExportStatus::Done) && event.current_object == expected_export_database + }), + "combined export must emit a terminal Done event" + ); + + execute_sql_file_path( + &state, + &SqlFileRequest { + execution_id: format!("postgres-all-schema-import-{suffix}"), + connection_id: target_connection_id.clone(), + database: target_database.clone(), + file_path: export_path.display().to_string(), + continue_on_error: false, + }, + &export_path, + CancellationToken::new(), + std::time::Instant::now(), + |_| {}, + ) + .await + .expect("restore combined PostgreSQL export"); + + let restored = Box::pin(execute_sql_statement( + &state, + &target_connection_id, + &target_database, + "SELECT + (SELECT count(*) FROM public.accounts), + (SELECT count(*) FROM inventory.products), + (SELECT amount::text FROM reporting.positive_totals WHERE day = DATE '2026-09-07')", + None, + None, + )) + .await + .expect("query restored schemas"); + assert_eq!(restored.rows, vec![vec![serde_json::json!(2), serde_json::json!(1), serde_json::json!("42.50")]]); + + for database in [&source_database, &target_database] { + Box::pin(execute_sql_statement( + &state, + &admin_connection_id, + "postgres", + &format!("DROP DATABASE \"{database}\" WITH (FORCE)"), + None, + None, + )) + .await + .expect("drop disposable PostgreSQL database"); + } +} diff --git a/docs/screenshots/issue-8336-postgresql-all-schemas.png b/docs/screenshots/issue-8336-postgresql-all-schemas.png new file mode 100644 index 0000000000..f512c95230 Binary files /dev/null and b/docs/screenshots/issue-8336-postgresql-all-schemas.png differ diff --git a/packages/app-tests/databaseExportPlan.test.ts b/packages/app-tests/databaseExportPlan.test.ts index 288a9108a6..cf5ec6b33e 100644 --- a/packages/app-tests/databaseExportPlan.test.ts +++ b/packages/app-tests/databaseExportPlan.test.ts @@ -53,6 +53,23 @@ test("all-database export includes every schema for schema-aware databases", () ]); }); +test("all-database PostgreSQL export keeps all schemas in one database file", () => { + const plan = buildAllDatabaseExportPlan({ + databases: ["app", "analytics"], + schemaAware: true, + schemasByDatabase: { + app: ["public", "private"], + analytics: ["reporting"], + }, + dbType: "postgres", + }); + + assert.deepEqual(plan, [ + { database: "app", schema: "", fileStem: "app", displayName: "app" }, + { database: "analytics", schema: "", fileStem: "analytics", displayName: "analytics" }, + ]); +}); + test("all-database export uses the database as schema for non-schema-aware databases", () => { const plan = buildAllDatabaseExportPlan({ databases: ["app", "analytics"],