diff --git a/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue b/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue index 08f7886cb..6f9ab533f 100644 --- a/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue +++ b/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue @@ -13,7 +13,7 @@ import * as api from "@/lib/backend/api"; import { useScheduledDatabaseBackups } from "@/composables/useScheduledDatabaseBackups"; import { useToast } from "@/composables/useToast"; import { generateDatabaseExportId } from "@/lib/export/databaseExport"; -import { nextDatabaseBackupRunAt, supportsScheduledDatabaseBackup, type DatabaseBackupFile, type DatabaseBackupRun, type DatabaseBackupSchedule } from "@/lib/backup/scheduledDatabaseBackup"; +import { nextDatabaseBackupRunAt, normalizeDatabaseBackupTablePatterns, supportsScheduledDatabaseBackup, type DatabaseBackupFile, type DatabaseBackupRun, type DatabaseBackupSchedule } from "@/lib/backup/scheduledDatabaseBackup"; import { useConnectionStore } from "@/stores/connectionStore"; const { t, locale } = useI18n(); @@ -32,6 +32,7 @@ const saving = ref(false); const databaseOptions = ref([]); const allDatabases = ref(true); const selectedDatabases = ref([]); +const tablePatternsInput = ref(""); const expandedRunIds = reactive(new Set()); const sqlConnections = computed(() => connectionStore.connections.filter((connection) => supportsScheduledDatabaseBackup(connection.db_type))); @@ -54,6 +55,8 @@ function newScheduleDraft(connectionId = sqlConnections.value[0]?.id ?? ""): Dat enabled: true, connectionId, databases: [], + tableFilterMode: "all", + tablePatterns: [], destinationDirectory: "", frequency: "daily", intervalHours: 6, @@ -76,7 +79,8 @@ const draft = ref(newScheduleDraft()); const canSave = computed(() => { const hasContent = draft.value.includeStructure || draft.value.includeData || draft.value.includeObjects; const hasDatabaseScope = allDatabases.value || selectedDatabases.value.length > 0; - return !!draft.value.name.trim() && !!draft.value.connectionId && !!draft.value.destinationDirectory.trim() && hasContent && hasDatabaseScope && !saving.value && !loadingDatabases.value; + const hasTableScope = draft.value.tableFilterMode === "all" || normalizeDatabaseBackupTablePatterns(tablePatternsInput.value).length > 0; + return !!draft.value.name.trim() && !!draft.value.connectionId && !!draft.value.destinationDirectory.trim() && hasContent && hasDatabaseScope && hasTableScope && !saving.value && !loadingDatabases.value; }); const nextRunPreview = computed(() => nextDatabaseBackupRunAt(draft.value, new Date())); @@ -104,6 +108,12 @@ function databaseScopeLabel(schedule: DatabaseBackupSchedule): string { return t("databaseBackup.databaseCount", { count: schedule.databases.length }); } +function tableScopeLabel(schedule: DatabaseBackupSchedule): string { + if (schedule.tableFilterMode === "include") return t("databaseBackup.includedTablePatterns", { count: schedule.tablePatterns.length }); + if (schedule.tableFilterMode === "exclude") return t("databaseBackup.excludedTablePatterns", { count: schedule.tablePatterns.length }); + return ""; +} + function runStatusLabel(status: DatabaseBackupRun["status"]): string { return t(`databaseBackup.status.${status}`); } @@ -130,6 +140,9 @@ async function loadDatabases(connectionId: string, preserveSelection: boolean) { if (!preserveSelection) { selectedDatabases.value = []; allDatabases.value = true; + draft.value.tableFilterMode = "all"; + draft.value.tablePatterns = []; + tablePatternsInput.value = ""; } } catch (error: any) { toast(error?.message || String(error), 5000); @@ -143,15 +156,17 @@ async function openCreateSchedule() { draft.value = newScheduleDraft(); allDatabases.value = true; selectedDatabases.value = []; + tablePatternsInput.value = ""; scheduleDialogOpen.value = true; await loadDatabases(draft.value.connectionId, false); } async function openEditSchedule(schedule: DatabaseBackupSchedule) { editingScheduleId.value = schedule.id; - draft.value = { ...schedule, databases: [...schedule.databases] }; + draft.value = { ...schedule, databases: [...schedule.databases], tablePatterns: [...schedule.tablePatterns] }; allDatabases.value = schedule.databases.length === 0; selectedDatabases.value = [...schedule.databases]; + tablePatternsInput.value = schedule.tablePatterns.join(", "); scheduleDialogOpen.value = true; await loadDatabases(schedule.connectionId, true); } @@ -181,6 +196,7 @@ async function submitSchedule() { saveSchedule({ ...draft.value, databases: allDatabases.value ? [] : [...selectedDatabases.value], + tablePatterns: draft.value.tableFilterMode === "all" ? [] : normalizeDatabaseBackupTablePatterns(tablePatternsInput.value), }); scheduleDialogOpen.value = false; toast(t(editingScheduleId.value ? "databaseBackup.scheduleUpdated" : "databaseBackup.scheduleCreated"), 2500); @@ -286,6 +302,7 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
{{ frequencyLabel(schedule) }} {{ databaseScopeLabel(schedule) }} + {{ tableScopeLabel(schedule) }} {{ t("databaseBackup.nextRun", { time: formatDate(schedule.nextRunAt) }) }} {{ t("databaseBackup.keepRuns", { count: schedule.retentionCount }) }}
@@ -413,6 +430,27 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) { +
+
+
+ + +
+
+ + +
+
+

{{ t("databaseBackup.tablePatternsHint") }}

+
+
diff --git a/apps/desktop/src/composables/useScheduledDatabaseBackups.ts b/apps/desktop/src/composables/useScheduledDatabaseBackups.ts index 394b5fd60..1c379e026 100644 --- a/apps/desktop/src/composables/useScheduledDatabaseBackups.ts +++ b/apps/desktop/src/composables/useScheduledDatabaseBackups.ts @@ -11,10 +11,12 @@ import { databaseBackupFilePath, databaseBackupRunsToPrune, databaseBackupScheduleIsDue, + databaseBackupTableNamesAreCaseSensitive, nextDatabaseBackupRunAt, normalizeDatabaseBackupSchedule, readDatabaseBackupRuns, readDatabaseBackupSchedules, + resolveScheduledDatabaseBackupTableScope, resolveScheduledDatabaseBackupTargets, supportsScheduledDatabaseBackup, writeDatabaseBackupRuns, @@ -178,11 +180,22 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = { if (!connection || !supportsScheduledDatabaseBackup(connection.db_type)) throw new Error("The backup connection is unavailable or unsupported."); await connectionStore.ensureConnected(schedule.connectionId); const availableDatabases = (await api.listDatabases(schedule.connectionId)).map((database) => database.name); - const selectedDatabases = resolveScheduledDatabaseBackupTargets(schedule.databases, availableDatabases); + const selectedDatabases = resolveScheduledDatabaseBackupTargets(schedule.databases, availableDatabases, connection.db_type); if (selectedDatabases.length === 0) throw new Error("No databases are available for this backup schedule."); + let tableNamesCaseSensitive = true; + if (connection.db_type === "mysql" && schedule.tableFilterMode !== "all") { + try { + const result = await api.executeQuery(schedule.connectionId, "", "SHOW VARIABLES LIKE 'lower_case_table_names'", undefined, undefined, { maxRows: 1 }); + tableNamesCaseSensitive = databaseBackupTableNamesAreCaseSensitive(connection.db_type, result.rows[0]?.[1] ?? result.rows[0]?.[0]); + } catch (error) { + appendDebugLog("warn", "[DBX][database-backup:table-name-case-detection-error]", error); + } + } + let exportIndex = 0; - let totalExports = selectedDatabases.length; + let totalExports = 0; + let includedTableCount = 0; for (const database of selectedDatabases) { if (cancellationRequested.has(runId)) { finalStatus = "cancelled"; @@ -197,9 +210,21 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = { schemasByDatabase: { [database]: snapshot.schemas }, }); if (databasePlan.length === 0) throw new Error(`Database ${database} did not resolve to any schemas.`); - totalExports += databasePlan.length - 1; - + const scopedDatabasePlan: Array<(typeof databasePlan)[number] & { selectedTables?: string[]; excludedTables?: string[] }> = []; for (const item of databasePlan) { + if (schedule.tableFilterMode === "all") { + scopedDatabasePlan.push(item); + continue; + } + const availableTables = (await api.listTables(schedule.connectionId, item.database, item.schema)).map((table) => table.name); + const scope = resolveScheduledDatabaseBackupTableScope(schedule.tableFilterMode, schedule.tablePatterns, availableTables, item.database, item.schema, tableNamesCaseSensitive); + includedTableCount += scope.includedTables.length; + if (scope.includedTables.length === 0) continue; + scopedDatabasePlan.push({ ...item, selectedTables: scope.selectedTables, excludedTables: scope.excludedTables }); + } + totalExports += scopedDatabasePlan.length; + + for (const item of scopedDatabasePlan) { if (cancellationRequested.has(runId)) { finalStatus = "cancelled"; break; @@ -216,6 +241,8 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = { database: item.database, schema: item.schema, filePath, + selectedTables: item.selectedTables, + excludedTables: item.excludedTables, includeStructure: schedule.includeStructure, includeData: schedule.includeData, includeObjects: schedule.includeObjects, @@ -257,6 +284,9 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = { } if (finalStatus !== "success") break; } + if (schedule.tableFilterMode !== "all" && includedTableCount === 0) { + throw new Error(`No tables matched the configured ${schedule.tableFilterMode} backup rules.`); + } } catch (error: any) { finalStatus = cancellationRequested.has(runId) ? "cancelled" : "failed"; finalError = error?.message || String(error); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index e44c09844..31a084b69 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -4096,6 +4096,15 @@ export default { databases: "Databases", allDatabases: "All databases", databaseCount: "{count} databases", + tableScope: "Table scope", + allTables: "All tables and views", + includeTables: "Include matches only", + excludeTables: "Exclude matches", + tablePatterns: "Table name rules", + tablePatternsPlaceholder: "orders, audit_*, public.users", + tablePatternsHint: "Separate rules with commas. * and ? wildcards and schema.table are supported.", + includedTablePatterns: "{count} include rules", + excludedTablePatterns: "{count} exclude rules", frequency: "Frequency", frequencyHourly: "Every few hours", frequencyDaily: "Daily", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 5e3362651..2afb82f2f 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -4648,6 +4648,15 @@ export default withEnglishFallback({ databases: "Bases de datos", allDatabases: "Todas las bases de datos", databaseCount: "{count} bases de datos", + tableScope: "Ámbito de tablas", + allTables: "Todas las tablas y vistas", + includeTables: "Incluir solo coincidencias", + excludeTables: "Excluir coincidencias", + tablePatterns: "Reglas de nombres de tabla", + tablePatternsPlaceholder: "orders, audit_*, public.users", + tablePatternsHint: "Separe las reglas con comas. Se admiten los comodines * y ? y schema.table.", + includedTablePatterns: "{count} reglas de inclusión", + excludedTablePatterns: "{count} reglas de exclusión", frequency: "Frecuencia de ejecución", frequencyHourly: "Cada hora", frequencyDaily: "Diariamente", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index d719dd0f7..7961890c9 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -4646,6 +4646,15 @@ export default withEnglishFallback({ databases: "Database", allDatabases: "Tutti i database", databaseCount: "{count} database", + tableScope: "Ambito tabelle", + allTables: "Tutte le tabelle e viste", + includeTables: "Includi solo corrispondenze", + excludeTables: "Escludi corrispondenze", + tablePatterns: "Regole nomi tabella", + tablePatternsPlaceholder: "orders, audit_*, public.users", + tablePatternsHint: "Separa le regole con virgole. Sono supportati i caratteri jolly * e ? e schema.table.", + includedTablePatterns: "{count} regole di inclusione", + excludedTablePatterns: "{count} regole di esclusione", frequency: "Frequenza", frequencyHourly: "Ogni ora", frequencyDaily: "Ogni giorno", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index e75175851..d36c1aa4e 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -4647,6 +4647,15 @@ export default withEnglishFallback({ databases: "データベース", allDatabases: "すべてのデータベース", databaseCount: "{count} 個のデータベース", + tableScope: "テーブル範囲", + allTables: "すべてのテーブルとビュー", + includeTables: "一致項目のみ含める", + excludeTables: "一致項目を除外", + tablePatterns: "テーブル名ルール", + tablePatternsPlaceholder: "orders, audit_*, public.users", + tablePatternsHint: "ルールはカンマで区切ります。*、? ワイルドカードと schema.table を使用できます。", + includedTablePatterns: "包含ルール {count} 件", + excludedTablePatterns: "除外ルール {count} 件", frequency: "実行頻度", frequencyHourly: "毎時", frequencyDaily: "毎日", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index dc1766816..64a0b38e8 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -4648,6 +4648,15 @@ export default withEnglishFallback({ databases: "Bancos de dados", allDatabases: "Todos os bancos de dados", databaseCount: "{count} bancos de dados", + tableScope: "Escopo de tabelas", + allTables: "Todas as tabelas e views", + includeTables: "Incluir apenas correspondências", + excludeTables: "Excluir correspondências", + tablePatterns: "Regras de nomes de tabela", + tablePatternsPlaceholder: "orders, audit_*, public.users", + tablePatternsHint: "Separe as regras com vírgulas. Há suporte a curingas * e ? e schema.table.", + includedTablePatterns: "{count} regras de inclusão", + excludedTablePatterns: "{count} regras de exclusão", frequency: "Frequência", frequencyHourly: "A cada hora", frequencyDaily: "Diariamente", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index eb050f669..358c65ed1 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -4066,6 +4066,15 @@ export default withEnglishFallback({ databases: "数据库", allDatabases: "全部数据库", databaseCount: "{count} 个数据库", + tableScope: "表范围", + allTables: "全部表和视图", + includeTables: "仅包含匹配项", + excludeTables: "排除匹配项", + tablePatterns: "表名规则", + tablePatternsPlaceholder: "例如:orders, audit_*, public.users", + tablePatternsHint: "用逗号分隔规则,支持 *、? 通配符和 schema.table。", + includedTablePatterns: "包含 {count} 条表规则", + excludedTablePatterns: "排除 {count} 条表规则", frequency: "执行频率", frequencyHourly: "按小时", frequencyDaily: "每天", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 790c03255..617ce027a 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -4648,6 +4648,15 @@ export default withEnglishFallback({ databases: "資料庫", allDatabases: "全部資料庫", databaseCount: "{count} 個資料庫", + tableScope: "資料表範圍", + allTables: "全部資料表和檢視表", + includeTables: "僅包含符合項目", + excludeTables: "排除符合項目", + tablePatterns: "資料表名稱規則", + tablePatternsPlaceholder: "例如:orders, audit_*, public.users", + tablePatternsHint: "請用逗號分隔規則,支援 *、? 萬用字元和 schema.table。", + includedTablePatterns: "包含 {count} 條資料表規則", + excludedTablePatterns: "排除 {count} 條資料表規則", frequency: "執行頻率", frequencyHourly: "每小時", frequencyDaily: "每天", diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 1fb972058..18d70c7b6 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -2291,6 +2291,7 @@ export interface DatabaseExportRequest { schema: string; filePath: string; selectedTables?: string[]; + excludedTables?: string[]; includeStructure: boolean; includeData: boolean; includeObjects: boolean; diff --git a/apps/desktop/src/lib/backup/scheduledDatabaseBackup.ts b/apps/desktop/src/lib/backup/scheduledDatabaseBackup.ts index d704142a4..875ae71fa 100644 --- a/apps/desktop/src/lib/backup/scheduledDatabaseBackup.ts +++ b/apps/desktop/src/lib/backup/scheduledDatabaseBackup.ts @@ -1,4 +1,6 @@ import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage"; +import { isSystemDatabaseName } from "@/lib/database/visibleDatabases"; +import type { DatabaseType } from "@/types/database"; export const DATABASE_BACKUP_SCHEDULES_STORAGE_KEY = "dbx-database-backup-schedules"; export const DATABASE_BACKUP_RUNS_STORAGE_KEY = "dbx-database-backup-runs"; @@ -8,6 +10,7 @@ export const MAX_DATABASE_BACKUP_HISTORY = 200; export type DatabaseBackupFrequency = "hourly" | "daily" | "weekly"; export type DatabaseBackupRunStatus = "running" | "success" | "failed" | "cancelled"; export type DatabaseBackupRunTrigger = "manual" | "scheduled"; +export type DatabaseBackupTableFilterMode = "all" | "include" | "exclude"; const CONSISTENT_BACKUP_DATABASE_TYPES = new Set(["mysql", "postgres"]); @@ -15,9 +18,9 @@ export function supportsScheduledDatabaseBackup(databaseType: string | undefined return !!databaseType && CONSISTENT_BACKUP_DATABASE_TYPES.has(databaseType); } -export function resolveScheduledDatabaseBackupTargets(configuredDatabases: readonly string[], availableDatabases: readonly string[]): string[] { +export function resolveScheduledDatabaseBackupTargets(configuredDatabases: readonly string[], availableDatabases: readonly string[], databaseType?: DatabaseType): string[] { const available = [...new Set(availableDatabases.map((database) => database.trim()).filter(Boolean))]; - if (configuredDatabases.length === 0) return available; + if (configuredDatabases.length === 0) return available.filter((database) => !isSystemDatabaseName(databaseType, database)); const missing = configuredDatabases.filter((database) => !available.includes(database)); if (missing.length > 0) { @@ -26,12 +29,64 @@ export function resolveScheduledDatabaseBackupTargets(configuredDatabases: reado return [...configuredDatabases]; } +export interface DatabaseBackupTableScope { + includedTables: string[]; + selectedTables?: string[]; + excludedTables?: string[]; +} + +export function normalizeDatabaseBackupTablePatterns(value: unknown): string[] { + const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(/[,;\n]+/) : []; + return [...new Set(values.map((pattern) => stringValue(pattern).trim()).filter(Boolean))]; +} + +function tablePatternRegex(pattern: string, caseSensitive: boolean): RegExp { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, "."); + return new RegExp(`^${escaped}$`, caseSensitive ? "u" : "iu"); +} + +export function databaseBackupTableMatchesPattern(table: string, patterns: readonly string[], database = "", schema = "", caseSensitive = true): boolean { + const candidates = [table]; + if (schema) candidates.push(`${schema}.${table}`); + if (database && schema && database !== schema) candidates.push(`${database}.${schema}.${table}`); + return patterns.some((pattern) => { + const matcher = tablePatternRegex(pattern, caseSensitive); + return candidates.some((candidate) => matcher.test(candidate)); + }); +} + +export function databaseBackupTableNamesAreCaseSensitive(databaseType: DatabaseType | undefined, mysqlLowerCaseTableNames: unknown): boolean { + if (databaseType !== "mysql") return true; + const value = typeof mysqlLowerCaseTableNames === "number" ? mysqlLowerCaseTableNames : typeof mysqlLowerCaseTableNames === "string" ? Number(mysqlLowerCaseTableNames.trim()) : Number.NaN; + return value !== 1 && value !== 2; +} + +export function resolveScheduledDatabaseBackupTableScope(mode: DatabaseBackupTableFilterMode, patterns: readonly string[], availableTables: readonly string[], database = "", schema = "", caseSensitive = true): DatabaseBackupTableScope { + const available = [...new Set(availableTables.map((table) => table.trim()).filter(Boolean))]; + if (mode === "all") return { includedTables: available }; + + const normalizedPatterns = normalizeDatabaseBackupTablePatterns(patterns); + const matching = available.filter((table) => databaseBackupTableMatchesPattern(table, normalizedPatterns, database, schema, caseSensitive)); + if (mode === "include") return { includedTables: matching, selectedTables: matching }; + + const matchingSet = new Set(matching); + return { + includedTables: available.filter((table) => !matchingSet.has(table)), + excludedTables: matching, + }; +} + export interface DatabaseBackupSchedule { id: string; name: string; enabled: boolean; connectionId: string; databases: string[]; + tableFilterMode: DatabaseBackupTableFilterMode; + tablePatterns: string[]; destinationDirectory: string; frequency: DatabaseBackupFrequency; intervalHours: number; @@ -119,6 +174,8 @@ export function normalizeDatabaseBackupSchedule(value: unknown, now = new Date() enabled: booleanValue(input.enabled, true), connectionId, databases: Array.isArray(input.databases) ? [...new Set(input.databases.map((database) => stringValue(database).trim()).filter(Boolean))] : [], + tableFilterMode: input.tableFilterMode === "include" || input.tableFilterMode === "exclude" ? input.tableFilterMode : "all", + tablePatterns: normalizeDatabaseBackupTablePatterns(input.tablePatterns), destinationDirectory, frequency, intervalHours: boundedInteger(input.intervalHours, 6, 1, 168), diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index cf9008899..e00398227 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -31,6 +31,8 @@ pub struct DatabaseExportRequest { pub file_path: String, #[serde(default)] pub selected_tables: Vec, + #[serde(default)] + pub excluded_tables: Vec, pub include_structure: bool, pub include_data: bool, pub include_objects: bool, @@ -891,6 +893,7 @@ async fn list_postgres_export_sequences( pool_key: &str, schema: &str, selected_tables: &[String], + excluded_tables: &[String], include_objects: bool, fail_on_error: bool, ) -> Result, String> { @@ -932,6 +935,7 @@ async fn list_postgres_export_sequences( .map_err(|e| e.to_string())?; let selected: HashSet<&str> = selected_tables.iter().map(String::as_str).collect(); + let excluded: HashSet<&str> = excluded_tables.iter().map(String::as_str).collect(); let mut sequences = rows .iter() .map(|row| PostgresExportSequence { @@ -948,8 +952,9 @@ async fn list_postgres_export_sequences( owner_column: row.get::<_, Option>(9), }) .filter(|sequence| { - selected.is_empty() - || sequence.owner_table.as_deref().map(|owner_table| selected.contains(owner_table)).unwrap_or(false) + sequence.owner_table.as_deref().is_none_or(|owner_table| !excluded.contains(owner_table)) + && (selected.is_empty() + || sequence.owner_table.as_deref().is_some_and(|owner_table| selected.contains(owner_table))) }) .filter(|sequence| sequence.owner_table.is_some() || (include_objects && selected.is_empty())) .collect::>(); @@ -1139,7 +1144,7 @@ pub async fn export_database_sql_core( None, ) .await?; - let all_tables = filter_selected_table_infos(all_tables, &request.selected_tables); + let all_tables = filter_export_table_infos(all_tables, &request.selected_tables, &request.excluded_tables); // 4. Create file let mut file = std::fs::File::create(&request.file_path).map_err(|e| format!("Failed to write file: {e}"))?; @@ -1165,6 +1170,7 @@ pub async fn export_database_sql_core( &pool_key, &request.schema, &request.selected_tables, + &request.excluded_tables, request.include_objects, request.fail_on_error, ) @@ -1783,15 +1789,18 @@ pub async fn export_database_sql_core( Ok(()) } -fn filter_selected_table_infos( +fn filter_export_table_infos( tables: Vec, selected_tables: &[String], + excluded_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() + let excluded: HashSet<&str> = excluded_tables.iter().map(String::as_str).collect(); + tables + .into_iter() + .filter(|table| selected.is_empty() || selected.contains(table.name.as_str())) + .filter(|table| !excluded.contains(table.name.as_str())) + .collect() } fn drop_table_if_exists_sql(table_name: &str, schema: &str, db_type: &DatabaseType) -> String { @@ -1802,11 +1811,11 @@ fn drop_table_if_exists_sql(table_name: &str, schema: &str, db_type: &DatabaseTy mod tests { use super::concurrent_metadata_prefetch_allowed; use super::{ - build_database_sql_export, build_export_insert_statements, drop_table_if_exists_sql, - filter_selected_table_infos, format_export_sql_literal, generate_postgres_sequence_create_ddl, - generate_postgres_sequence_owner_ddl, generate_postgres_sequence_setval_sql, normalize_export_table_ddl, - record_export_error, BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions, ExportedTableSql, - PostgresExportSequence, DATABASE_EXPORT_INSERT_BATCH_SIZE, DATABASE_EXPORT_ROW_LIMIT, + build_database_sql_export, build_export_insert_statements, drop_table_if_exists_sql, filter_export_table_infos, + format_export_sql_literal, generate_postgres_sequence_create_ddl, generate_postgres_sequence_owner_ddl, + generate_postgres_sequence_setval_sql, normalize_export_table_ddl, record_export_error, + BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions, ExportedTableSql, PostgresExportSequence, + DATABASE_EXPORT_INSERT_BATCH_SIZE, DATABASE_EXPORT_ROW_LIMIT, }; use crate::models::connection::DatabaseType; use crate::types::TableInfo; @@ -1848,7 +1857,7 @@ mod tests { 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()]); + let filtered = filter_export_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"]); } @@ -1857,11 +1866,30 @@ mod tests { 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(), &[]); + let filtered = filter_export_table_infos(tables.clone(), &[], &[]); assert_eq!(filtered.iter().map(|table| table.name.as_str()).collect::>(), vec!["users", "orders"]); } + #[test] + fn excludes_export_tables_by_name() { + let tables = vec![table("users", "TABLE"), table("audit_log", "TABLE"), table("active_users", "VIEW")]; + + let filtered = filter_export_table_infos(tables, &[], &["audit_log".to_string(), "active_users".to_string()]); + + assert_eq!(filtered.iter().map(|table| table.name.as_str()).collect::>(), vec!["users"]); + } + + #[test] + fn exclusions_take_precedence_over_selected_tables() { + let tables = vec![table("users", "TABLE"), table("orders", "TABLE")]; + + let filtered = + filter_export_table_infos(tables, &["users".to_string(), "orders".to_string()], &["orders".to_string()]); + + assert_eq!(filtered.iter().map(|table| table.name.as_str()).collect::>(), vec!["users"]); + } + #[test] fn builds_drop_table_if_exists_with_qualified_mysql_name() { let sql = drop_table_if_exists_sql("users", "app", &DatabaseType::Mysql); diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index 135e2fb2c..a73ae7822 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -2918,14 +2918,26 @@ fn postgres_transaction_begin_sql(consistent_snapshot: bool) -> &'static str { } } -fn mysql_transaction_begin_sql(consistent_snapshot: bool) -> &'static str { +fn mysql_transaction_begin_sql_candidates(consistent_snapshot: bool) -> &'static [&'static str] { if consistent_snapshot { - "START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY" + &[ + "START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY", + "START TRANSACTION WITH CONSISTENT SNAPSHOT", + "START TRANSACTION", + ] } else { - "START TRANSACTION" + &["START TRANSACTION"] } } +fn mysql_transaction_isolation_sql(consistent_snapshot: bool) -> Option<&'static str> { + consistent_snapshot.then_some("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") +} + +fn mysql_error_is_syntax_error(error: &mysql_async::Error) -> bool { + matches!(error, mysql_async::Error::Server(server_error) if server_error.code == 1064) +} + async fn begin_transaction_session( state: &AppState, connection_id: &str, @@ -2968,8 +2980,25 @@ async fn begin_transaction_session( } TxnPoolHandle::Mysql(mysql_pool) => { let mut conn = mysql_pool.get_conn().await.map_err(|e| format!("Failed to get MySQL connection: {e}"))?; - let begin_sql = mysql_transaction_begin_sql(consistent_snapshot); - conn.query_drop(begin_sql).await.map_err(|e| format!("START TRANSACTION failed: {e}"))?; + if let Some(isolation_sql) = mysql_transaction_isolation_sql(consistent_snapshot) { + conn.query_drop(isolation_sql).await.map_err(|e| format!("SET TRANSACTION failed: {e}"))?; + } + let mut syntax_errors = Vec::new(); + for begin_sql in mysql_transaction_begin_sql_candidates(consistent_snapshot) { + match conn.query_drop(*begin_sql).await { + Ok(()) => { + syntax_errors.clear(); + break; + } + Err(error) if mysql_error_is_syntax_error(&error) => { + syntax_errors.push(format!("{begin_sql}: {error}")); + } + Err(error) => return Err(format!("START TRANSACTION failed: {error}")), + } + } + if !syntax_errors.is_empty() { + return Err(format!("START TRANSACTION failed for all compatible forms: {}", syntax_errors.join("; "))); + } TxnConnection::Mysql(conn) } }; @@ -4956,8 +4985,35 @@ mod tests { } #[test] - fn database_backup_transactions_request_consistent_read_only_snapshots() { + fn database_backup_transactions_request_consistent_snapshots() { assert_eq!(postgres_transaction_begin_sql(true), "BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY"); - assert_eq!(mysql_transaction_begin_sql(true), "START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY"); + assert_eq!(mysql_transaction_isolation_sql(true), Some("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")); + assert_eq!( + mysql_transaction_begin_sql_candidates(true), + [ + "START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY", + "START TRANSACTION WITH CONSISTENT SNAPSHOT", + "START TRANSACTION", + ] + ); + assert_eq!(mysql_transaction_isolation_sql(false), None); + assert_eq!(mysql_transaction_begin_sql_candidates(false), ["START TRANSACTION"]); + } + + #[test] + fn mysql_backup_transaction_only_falls_back_for_syntax_errors() { + let syntax_error = mysql_async::Error::Server(mysql_async::ServerError { + code: 1064, + message: "unsupported transaction characteristic".to_string(), + state: "42000".to_string(), + }); + let permission_error = mysql_async::Error::Server(mysql_async::ServerError { + code: 1044, + message: "access denied".to_string(), + state: "42000".to_string(), + }); + + assert!(mysql_error_is_syntax_error(&syntax_error)); + assert!(!mysql_error_is_syntax_error(&permission_error)); } } diff --git a/crates/dbx-core/tests/database_export_prefetch.rs b/crates/dbx-core/tests/database_export_prefetch.rs index c2d79f8d8..d982ca97a 100644 --- a/crates/dbx-core/tests/database_export_prefetch.rs +++ b/crates/dbx-core/tests/database_export_prefetch.rs @@ -172,6 +172,7 @@ async fn database_export_writes_structure_and_data_for_all_tables() { schema: "public".to_string(), file_path: file_path.to_string_lossy().to_string(), selected_tables: Vec::new(), + excluded_tables: Vec::new(), include_structure: true, include_data: true, include_objects: false, diff --git a/packages/app-tests/scheduledDatabaseBackup.test.ts b/packages/app-tests/scheduledDatabaseBackup.test.ts index 07c25e2a8..6c93e3985 100644 --- a/packages/app-tests/scheduledDatabaseBackup.test.ts +++ b/packages/app-tests/scheduledDatabaseBackup.test.ts @@ -1,6 +1,19 @@ import assert from "node:assert/strict"; import { test } from "vitest"; -import { databaseBackupFilePath, databaseBackupRunsToPrune, databaseBackupScheduleIsDue, nextDatabaseBackupRunAt, normalizeDatabaseBackupSchedule, resolveScheduledDatabaseBackupTargets, supportsScheduledDatabaseBackup, type DatabaseBackupRun, type DatabaseBackupSchedule } from "../../apps/desktop/src/lib/backup/scheduledDatabaseBackup.ts"; +import { + databaseBackupFilePath, + databaseBackupRunsToPrune, + databaseBackupScheduleIsDue, + databaseBackupTableNamesAreCaseSensitive, + nextDatabaseBackupRunAt, + normalizeDatabaseBackupSchedule, + normalizeDatabaseBackupTablePatterns, + resolveScheduledDatabaseBackupTableScope, + resolveScheduledDatabaseBackupTargets, + supportsScheduledDatabaseBackup, + type DatabaseBackupRun, + type DatabaseBackupSchedule, +} from "../../apps/desktop/src/lib/backup/scheduledDatabaseBackup.ts"; function schedule(overrides: Partial = {}): DatabaseBackupSchedule { return { @@ -9,6 +22,8 @@ function schedule(overrides: Partial = {}): DatabaseBack enabled: true, connectionId: "connection-1", databases: [], + tableFilterMode: "all", + tablePatterns: [], destinationDirectory: "C:\\backups", frequency: "daily", intervalHours: 6, @@ -78,6 +93,21 @@ test("normalization deduplicates databases and bounds schedule values", () => { assert.equal(normalized.timeOfDay, "02:00"); }); +test("normalization migrates old schedules and deduplicates table patterns", () => { + const legacy = { ...schedule(), tableFilterMode: undefined, tablePatterns: undefined }; + const normalizedLegacy = normalizeDatabaseBackupSchedule(legacy); + const normalizedFiltered = normalizeDatabaseBackupSchedule({ + ...schedule(), + tableFilterMode: "exclude", + tablePatterns: [" audit_* ", "audit_*", "public.events"], + }); + + assert.equal(normalizedLegacy?.tableFilterMode, "all"); + assert.deepEqual(normalizedLegacy?.tablePatterns, []); + assert.equal(normalizedFiltered?.tableFilterMode, "exclude"); + assert.deepEqual(normalizedFiltered?.tablePatterns, ["audit_*", "public.events"]); +}); + test("due check respects enabled state and next run", () => { const now = new Date("2026-07-16T12:00:00.000Z"); assert.equal(databaseBackupScheduleIsDue(schedule({ nextRunAt: "2026-07-16T11:59:00.000Z" }), now), true); @@ -103,6 +133,18 @@ test("all-database backups use the complete database list", () => { assert.deepEqual(resolveScheduledDatabaseBackupTargets([], ["visible", "hidden"]), ["visible", "hidden"]); }); +test("all-database MySQL backups exclude system databases", () => { + assert.deepEqual(resolveScheduledDatabaseBackupTargets([], ["information_schema", "app", "mysql", "analytics", "performance_schema", "sys"], "mysql"), ["app", "analytics"]); +}); + +test("all-database PostgreSQL backups exclude template databases", () => { + assert.deepEqual(resolveScheduledDatabaseBackupTargets([], ["template0", "postgres", "template1", "app"], "postgres"), ["postgres", "app"]); +}); + +test("explicit system database targets remain available", () => { + assert.deepEqual(resolveScheduledDatabaseBackupTargets(["mysql"], ["mysql", "app"], "mysql"), ["mysql"]); +}); + test("explicit backup databases fail when any configured target is missing", () => { assert.throws(() => resolveScheduledDatabaseBackupTargets(["app", "renamed"], ["app"]), /renamed/); }); @@ -114,6 +156,44 @@ test("scheduled backups are limited to databases with consistent snapshot suppor assert.equal(supportsScheduledDatabaseBackup("sqlserver"), false); }); +test("table pattern input supports exact, wildcard, and qualified rules", () => { + assert.deepEqual(normalizeDatabaseBackupTablePatterns(" orders, audit_*; public.events\norders "), ["orders", "audit_*", "public.events"]); + + const included = resolveScheduledDatabaseBackupTableScope("include", ["orders", "audit_*", "public.events"], ["orders", "audit_log", "events", "users"], "app", "public"); + assert.deepEqual(included, { + includedTables: ["orders", "audit_log", "events"], + selectedTables: ["orders", "audit_log", "events"], + }); +}); + +test("exclude table rules preserve all non-matching tables", () => { + const excluded = resolveScheduledDatabaseBackupTableScope("exclude", ["audit_*", "private.*"], ["users", "audit_log", "sessions"], "app", "public"); + assert.deepEqual(excluded, { + includedTables: ["users", "sessions"], + excludedTables: ["audit_log"], + }); +}); + +test("MySQL table rules respect lower_case_table_names", () => { + assert.equal(databaseBackupTableNamesAreCaseSensitive("mysql", 0), true); + assert.equal(databaseBackupTableNamesAreCaseSensitive("mysql", "1"), false); + assert.equal(databaseBackupTableNamesAreCaseSensitive("mysql", 2), false); + assert.equal(databaseBackupTableNamesAreCaseSensitive("postgres", 1), true); + assert.equal(databaseBackupTableNamesAreCaseSensitive("mysql", undefined), true); + + const caseSensitive = resolveScheduledDatabaseBackupTableScope("exclude", ["orders"], ["orders", "Orders"], "app", "app", true); + assert.deepEqual(caseSensitive, { + includedTables: ["Orders"], + excludedTables: ["orders"], + }); + + const caseInsensitive = resolveScheduledDatabaseBackupTableScope("exclude", ["orders"], ["orders", "Orders"], "app", "app", false); + assert.deepEqual(caseInsensitive, { + includedTables: [], + excludedTables: ["orders", "Orders"], + }); +}); + test("retention never selects failed backup runs", () => { const failed = { ...run("failed", "2026-07-16T04:00:00.000Z"), status: "failed" as const }; const successful = [run("new", "2026-07-16T03:00:00.000Z"), run("old", "2026-07-16T01:00:00.000Z")];