feat(postgres): export all schemas in one SQL file
This commit is contained in:
15 files changed
+452
-38
No files matched your search
@@ -59,6 +59,7 @@ const filteredTables = computed(() => {
|
|||||||
return tables.value.filter((name) => name.toLowerCase().includes(q));
|
return tables.value.filter((name) => name.toLowerCase().includes(q));
|
||||||
});
|
});
|
||||||
const tableError = ref<string | null>(null);
|
const tableError = ref<string | null>(null);
|
||||||
|
const POSTGRES_ALL_SCHEMAS = "__DBX_ALL_SCHEMAS__";
|
||||||
|
|
||||||
// Options
|
// Options
|
||||||
const includeStructure = ref(true);
|
const includeStructure = ref(true);
|
||||||
@@ -70,6 +71,7 @@ const omitAutoIncrement = ref(false);
|
|||||||
// `AUTO_INCREMENT` stripping is a MySQL-only DDL transform (backend gates on
|
// `AUTO_INCREMENT` stripping is a MySQL-only DDL transform (backend gates on
|
||||||
// db_type == mysql, which also covers MariaDB / TiDB / OceanBase-MySQL-mode).
|
// db_type == mysql, which also covers MariaDB / TiDB / OceanBase-MySQL-mode).
|
||||||
const isMysqlFamily = computed(() => store.getConfig(connectionId.value)?.db_type === "mysql");
|
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
|
// Export state
|
||||||
const isExporting = ref(false);
|
const isExporting = ref(false);
|
||||||
@@ -115,6 +117,7 @@ const canExport = computed(() => {
|
|||||||
const hasContent = includeStructure.value || includeData.value || includeObjects.value;
|
const hasContent = includeStructure.value || includeData.value || includeObjects.value;
|
||||||
if (!connectionId.value || !hasContent || isExporting.value) return false;
|
if (!connectionId.value || !hasContent || isExporting.value) return false;
|
||||||
if (exportAllDatabases.value) return selectedDatabases.value.length > 0 && !loadingMeta.value;
|
if (exportAllDatabases.value) return selectedDatabases.value.length > 0 && !loadingMeta.value;
|
||||||
|
if (isPostgresAllSchemas.value) return database.value && schemas.value.length > 0 && !loadingMeta.value;
|
||||||
return (
|
return (
|
||||||
database.value &&
|
database.value &&
|
||||||
schema.value &&
|
schema.value &&
|
||||||
@@ -230,6 +233,13 @@ async function loadSchemas(preferredSchema = "") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadTables(preferredTable = "", preferredTables: string[] = []) {
|
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;
|
if (!connectionId.value || !database.value || !schema.value) return;
|
||||||
loadingTables.value = true;
|
loadingTables.value = true;
|
||||||
tableError.value = null;
|
tableError.value = null;
|
||||||
@@ -371,9 +381,9 @@ async function startExport() {
|
|||||||
exportId: exportId.value,
|
exportId: exportId.value,
|
||||||
connectionId: connectionId.value,
|
connectionId: connectionId.value,
|
||||||
database: database.value,
|
database: database.value,
|
||||||
schema: schema.value,
|
schema: isPostgresAllSchemas.value ? "" : schema.value,
|
||||||
filePath,
|
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,
|
includeStructure: includeStructure.value,
|
||||||
includeData: includeData.value,
|
includeData: includeData.value,
|
||||||
includeObjects: includeObjects.value,
|
includeObjects: includeObjects.value,
|
||||||
@@ -737,6 +747,7 @@ watch(schema, (value) => {
|
|||||||
const preferredTables = pendingPrefillTables.value;
|
const preferredTables = pendingPrefillTables.value;
|
||||||
pendingPrefillTable.value = "";
|
pendingPrefillTable.value = "";
|
||||||
pendingPrefillTables.value = [];
|
pendingPrefillTables.value = [];
|
||||||
|
if (value === POSTGRES_ALL_SCHEMAS) return;
|
||||||
if (value) loadTables(preferredTable, preferredTables).catch((e) => toast(String(e), 5000));
|
if (value) loadTables(preferredTable, preferredTables).catch((e) => toast(String(e), 5000));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -846,12 +857,13 @@ watch(
|
|||||||
<SelectValue :placeholder="t('diff.selectSchema')" />
|
<SelectValue :placeholder="t('diff.selectSchema')" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent position="popper" align="start">
|
<SelectContent position="popper" align="start">
|
||||||
|
<SelectItem v-if="store.getConfig(connectionId)?.db_type === 'postgres'" :value="POSTGRES_ALL_SCHEMAS">{{ t("databaseExport.allSchemas") }}</SelectItem>
|
||||||
<SelectItem v-for="s in schemas" :key="s" :value="s">{{ s }}</SelectItem>
|
<SelectItem v-for="s in schemas" :key="s" :value="s">{{ s }}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!exportAllDatabases && schema" class="space-y-2">
|
<div v-if="!exportAllDatabases && schema && !isPostgresAllSchemas" class="space-y-2">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<Label class="text-xs">{{ t("databaseExport.tableSelection") }}</Label>
|
<Label class="text-xs">{{ t("databaseExport.tableSelection") }}</Label>
|
||||||
<div v-if="tables.length" class="text-[11px] text-muted-foreground">
|
<div v-if="tables.length" class="text-[11px] text-muted-foreground">
|
||||||
|
|||||||
@@ -7708,6 +7708,7 @@ export default {
|
|||||||
noTables: "No tables found",
|
noTables: "No tables found",
|
||||||
export: "Export",
|
export: "Export",
|
||||||
exportAllDatabases: "Export All Databases",
|
exportAllDatabases: "Export All Databases",
|
||||||
|
allSchemas: "All schemas",
|
||||||
exporting: "Exporting...",
|
exporting: "Exporting...",
|
||||||
preparing: "Preparing export (reading tables and metadata)...",
|
preparing: "Preparing export (reading tables and metadata)...",
|
||||||
preparingObject: "Preparing: {object}",
|
preparingObject: "Preparing: {object}",
|
||||||
|
|||||||
@@ -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.",
|
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.",
|
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})",
|
allDatabasesTask: "Todas las bases de datos ({count})",
|
||||||
|
allSchemas: "Todos los esquemas",
|
||||||
exportError: "Error al exportar: {error}",
|
exportError: "Error al exportar: {error}",
|
||||||
exportCancelled: "Exportación cancelada",
|
exportCancelled: "Exportación cancelada",
|
||||||
filterTables: "Buscar tablas...",
|
filterTables: "Buscar tablas...",
|
||||||
|
|||||||
@@ -7292,6 +7292,7 @@ export default withEnglishFallback({
|
|||||||
exportSuccessWithErrors: "Esportazione completata con {count} oggetti non riusciti: {firstError}… Dettagli nei commenti -- ERROR nel file esportato.",
|
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.",
|
exportAllSuccessWithErrors: "Esportati {count} database, ma {errorCount} oggetti non riusciti: {firstError}… Dettagli nei commenti -- ERROR nei file.",
|
||||||
allDatabasesTask: "Tutti i database ({count})",
|
allDatabasesTask: "Tutti i database ({count})",
|
||||||
|
allSchemas: "Tutti gli schemi",
|
||||||
exportError: "Esportazione non riuscita: {error}",
|
exportError: "Esportazione non riuscita: {error}",
|
||||||
exportCancelled: "Esportazione annullata",
|
exportCancelled: "Esportazione annullata",
|
||||||
runInBackground: "Esegui in background",
|
runInBackground: "Esegui in background",
|
||||||
|
|||||||
@@ -7344,6 +7344,7 @@ export default withEnglishFallback({
|
|||||||
exportSuccessWithErrors: "エクスポートは完了しましたが、{count} 個のオブジェクトが失敗しました:{firstError}… 詳細はエクスポートファイル内の -- ERROR コメントを確認してください。",
|
exportSuccessWithErrors: "エクスポートは完了しましたが、{count} 個のオブジェクトが失敗しました:{firstError}… 詳細はエクスポートファイル内の -- ERROR コメントを確認してください。",
|
||||||
exportAllSuccessWithErrors: "{count} 個のデータベースをエクスポートしましたが、{errorCount} 個のオブジェクトが失敗しました:{firstError}… 詳細はファイル内の -- ERROR コメントを確認してください。",
|
exportAllSuccessWithErrors: "{count} 個のデータベースをエクスポートしましたが、{errorCount} 個のオブジェクトが失敗しました:{firstError}… 詳細はファイル内の -- ERROR コメントを確認してください。",
|
||||||
allDatabasesTask: "すべてのデータベース ({count})",
|
allDatabasesTask: "すべてのデータベース ({count})",
|
||||||
|
allSchemas: "すべてのスキーマ",
|
||||||
exportError: "エクスポートに失敗しました: {error}",
|
exportError: "エクスポートに失敗しました: {error}",
|
||||||
exportCancelled: "エクスポートがキャンセルされました",
|
exportCancelled: "エクスポートがキャンセルされました",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7000,6 +7000,7 @@ export default withEnglishFallback({
|
|||||||
exportSuccessWithErrors: "내보내기가 완료되었지만 {count}개 객체가 실패했습니다: {firstError}… 자세한 내용은 내보낸 파일의 -- ERROR 주석을 참조하세요.",
|
exportSuccessWithErrors: "내보내기가 완료되었지만 {count}개 객체가 실패했습니다: {firstError}… 자세한 내용은 내보낸 파일의 -- ERROR 주석을 참조하세요.",
|
||||||
exportAllSuccessWithErrors: "데이터베이스 {count}개를 내보냈지만 {errorCount}개 객체가 실패했습니다: {firstError}… 자세한 내용은 파일의 -- ERROR 주석을 참조하세요.",
|
exportAllSuccessWithErrors: "데이터베이스 {count}개를 내보냈지만 {errorCount}개 객체가 실패했습니다: {firstError}… 자세한 내용은 파일의 -- ERROR 주석을 참조하세요.",
|
||||||
allDatabasesTask: "모든 데이터베이스 ({count})",
|
allDatabasesTask: "모든 데이터베이스 ({count})",
|
||||||
|
allSchemas: "모든 스키마",
|
||||||
exportError: "내보내기 실패: {error}",
|
exportError: "내보내기 실패: {error}",
|
||||||
exportCancelled: "내보내기 취소됨",
|
exportCancelled: "내보내기 취소됨",
|
||||||
runInBackground: "백그라운드에서 실행",
|
runInBackground: "백그라운드에서 실행",
|
||||||
|
|||||||
@@ -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.",
|
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.",
|
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})",
|
allDatabasesTask: "Todos os bancos de dados ({count})",
|
||||||
|
allSchemas: "Todos os esquemas",
|
||||||
exportError: "Falha na exportação: {error}",
|
exportError: "Falha na exportação: {error}",
|
||||||
exportCancelled: "Exportação cancelada",
|
exportCancelled: "Exportação cancelada",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.",
|
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.",
|
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})",
|
allDatabasesTask: "Tüm veritabanları ({count})",
|
||||||
|
allSchemas: "Tüm şemalar",
|
||||||
exportError: "Dışa aktarma başarısız: {error}",
|
exportError: "Dışa aktarma başarısız: {error}",
|
||||||
exportCancelled: "Dışa aktarma iptal edildi",
|
exportCancelled: "Dışa aktarma iptal edildi",
|
||||||
runInBackground: "Arka planda çalıştır",
|
runInBackground: "Arka planda çalıştır",
|
||||||
|
|||||||
@@ -7689,6 +7689,7 @@ export default withEnglishFallback({
|
|||||||
noTables: "未找到表",
|
noTables: "未找到表",
|
||||||
export: "导出",
|
export: "导出",
|
||||||
exportAllDatabases: "导出全部数据库",
|
exportAllDatabases: "导出全部数据库",
|
||||||
|
allSchemas: "全部 Schema",
|
||||||
exporting: "正在导出...",
|
exporting: "正在导出...",
|
||||||
preparing: "正在准备导出(读取表与元数据)...",
|
preparing: "正在准备导出(读取表与元数据)...",
|
||||||
preparingObject: "正在准备: {object}",
|
preparingObject: "正在准备: {object}",
|
||||||
|
|||||||
@@ -6607,6 +6607,7 @@ export default withEnglishFallback({
|
|||||||
exportSuccessWithErrors: "匯出完成,但有 {count} 個物件失敗:{firstError}……詳情見匯出檔案中的 -- ERROR 註解。",
|
exportSuccessWithErrors: "匯出完成,但有 {count} 個物件失敗:{firstError}……詳情見匯出檔案中的 -- ERROR 註解。",
|
||||||
exportAllSuccessWithErrors: "已匯出 {count} 個資料庫,但有 {errorCount} 個物件失敗:{firstError}……詳情見匯出檔案中的 -- ERROR 註解。",
|
exportAllSuccessWithErrors: "已匯出 {count} 個資料庫,但有 {errorCount} 個物件失敗:{firstError}……詳情見匯出檔案中的 -- ERROR 註解。",
|
||||||
allDatabasesTask: "全部資料庫({count})",
|
allDatabasesTask: "全部資料庫({count})",
|
||||||
|
allSchemas: "全部 schema",
|
||||||
exportError: "匯出失敗: {error}",
|
exportError: "匯出失敗: {error}",
|
||||||
exportCancelled: "匯出已取消",
|
exportCancelled: "匯出已取消",
|
||||||
runInBackground: "背景執行",
|
runInBackground: "背景執行",
|
||||||
|
|||||||
@@ -166,6 +166,17 @@ export function buildAllDatabaseExportPlan(options: AllDatabaseExportPlanInput):
|
|||||||
displayName: schema,
|
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) => {
|
return options.databases.flatMap((database) => {
|
||||||
const schemas = options.schemaAware ? filterExportableSchemas(options.schemasByDatabase?.[database] ?? [], options.dbType).filter((schema) => schema.trim()) : [database];
|
const schemas = options.schemaAware ? filterExportableSchemas(options.schemasByDatabase?.[database] ?? [], options.dbType).filter((schema) => schema.trim()) : [database];
|
||||||
const exportSchemas = schemas.length > 0 ? schemas : [database];
|
const exportSchemas = schemas.length > 0 ? schemas : [database];
|
||||||
|
|||||||
@@ -2586,10 +2586,21 @@ pub async fn export_database_sql_core(
|
|||||||
request: &DatabaseExportRequest,
|
request: &DatabaseExportRequest,
|
||||||
on_progress: impl Fn(ExportProgress) + Sync,
|
on_progress: impl Fn(ExportProgress) + Sync,
|
||||||
) -> Result<(), String> {
|
) -> 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
|
// Keep the large export state machine on the heap. Besides making the
|
||||||
// caller future small, this prevents the metadata-prefetch locals from
|
// caller future small, this prevents the metadata-prefetch locals from
|
||||||
// exhausting the bounded stack used by test and runtime worker threads.
|
// 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);
|
let metadata_session_id = database_export_client_session_id(&request.export_id);
|
||||||
if let Err(error) =
|
if let Err(error) =
|
||||||
state.close_metadata_session_pool(&request.connection_id, Some(&request.database), &metadata_session_id).await
|
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<crate::connection::AppState>,
|
||||||
|
request: &DatabaseExportRequest,
|
||||||
|
) -> Result<DatabaseExportWriter, String> {
|
||||||
|
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<String>) -> Vec<String> {
|
||||||
|
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<crate::connection::AppState>,
|
||||||
|
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::<ExportProgress>));
|
||||||
|
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(
|
async fn export_database_sql_core_inner(
|
||||||
state: &Arc<crate::connection::AppState>,
|
state: &Arc<crate::connection::AppState>,
|
||||||
request: &DatabaseExportRequest,
|
request: &DatabaseExportRequest,
|
||||||
@@ -2658,38 +2848,7 @@ async fn export_database_sql_core_inner(
|
|||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
// 4. Create file
|
// 4. Create file
|
||||||
let mut expected_destination_identity = None;
|
let mut file = create_database_export_writer(state, request).await?;
|
||||||
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 create_database_preamble = if request.include_create_database && matches!(db_type, DatabaseType::Mysql) {
|
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)
|
Some(mysql_database_export_preamble_for_request(state, request, &client_session_id).await)
|
||||||
@@ -3648,8 +3807,8 @@ mod tests {
|
|||||||
use super::{
|
use super::{
|
||||||
await_export_operation, await_export_stream_operation, clear_export_cancelled,
|
await_export_operation, await_export_stream_operation, clear_export_cancelled,
|
||||||
concurrent_metadata_prefetch_allowed, database_export_metadata_prefetch_concurrency,
|
concurrent_metadata_prefetch_allowed, database_export_metadata_prefetch_concurrency,
|
||||||
emit_database_export_cancelled, set_export_cancelled, snapshot_batch_cancelled, ExportStatus,
|
emit_database_export_cancelled, postgres_create_schema_sql, postgres_export_schema_names, set_export_cancelled,
|
||||||
EXPORT_CANCELLED_ERROR,
|
snapshot_batch_cancelled, ExportStatus, EXPORT_CANCELLED_ERROR,
|
||||||
};
|
};
|
||||||
use super::{
|
use super::{
|
||||||
build_database_export_object_source_sql, build_database_sql_export, build_export_insert_statements,
|
build_database_export_object_source_sql, build_database_sql_export, build_export_insert_statements,
|
||||||
@@ -3948,6 +4107,26 @@ mod tests {
|
|||||||
assert!(!ddl.contains("VERSION"));
|
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]
|
#[test]
|
||||||
fn postgres_extension_member_filter_keeps_user_overload_with_same_name() {
|
fn postgres_extension_member_filter_keeps_user_overload_with_same_name() {
|
||||||
let mut members = PostgresExtensionMembers::default();
|
let mut members = PostgresExtensionMembers::default();
|
||||||
|
|||||||
@@ -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::<u16>().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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
+17
@@ -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", () => {
|
test("all-database export uses the database as schema for non-schema-aware databases", () => {
|
||||||
const plan = buildAllDatabaseExportPlan({
|
const plan = buildAllDatabaseExportPlan({
|
||||||
databases: ["app", "analytics"],
|
databases: ["app", "analytics"],
|
||||||
|
|||||||
Reference in new issue
Block a user