feat(backup): support table filters and MySQL compatibility

This commit is contained in:
CSXFanMeng 2026-07-19 10:32:33 +08:00 committed by GitHub
parent 09c3f7f44c
commit 7abac0959f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 386 additions and 32 deletions

View File

@ -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<string[]>([]);
const allDatabases = ref(true);
const selectedDatabases = ref<string[]>([]);
const tablePatternsInput = ref("");
const expandedRunIds = reactive(new Set<string>());
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<DatabaseBackupSchedule>(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) {
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{{ frequencyLabel(schedule) }}</span>
<span>{{ databaseScopeLabel(schedule) }}</span>
<span v-if="schedule.tableFilterMode !== 'all'">{{ tableScopeLabel(schedule) }}</span>
<span>{{ t("databaseBackup.nextRun", { time: formatDate(schedule.nextRunAt) }) }}</span>
<span>{{ t("databaseBackup.keepRuns", { count: schedule.retentionCount }) }}</span>
</div>
@ -413,6 +430,27 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
</div>
</div>
<div class="space-y-3">
<div class="grid gap-4 sm:grid-cols-[minmax(0,200px)_minmax(0,1fr)]">
<div class="space-y-2">
<Label>{{ t("databaseBackup.tableScope") }}</Label>
<Select v-model="draft.tableFilterMode">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ t("databaseBackup.allTables") }}</SelectItem>
<SelectItem value="include">{{ t("databaseBackup.includeTables") }}</SelectItem>
<SelectItem value="exclude">{{ t("databaseBackup.excludeTables") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="draft.tableFilterMode !== 'all'" class="space-y-2">
<Label>{{ t("databaseBackup.tablePatterns") }}</Label>
<Input v-model="tablePatternsInput" :placeholder="t('databaseBackup.tablePatternsPlaceholder')" />
</div>
</div>
<p v-if="draft.tableFilterMode !== 'all'" class="text-xs text-muted-foreground">{{ t("databaseBackup.tablePatternsHint") }}</p>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div class="space-y-2">
<Label>{{ t("databaseBackup.frequency") }}</Label>

View File

@ -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);

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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: "毎日",

View File

@ -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",

View File

@ -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: "每天",

View File

@ -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: "每天",

View File

@ -2291,6 +2291,7 @@ export interface DatabaseExportRequest {
schema: string;
filePath: string;
selectedTables?: string[];
excludedTables?: string[];
includeStructure: boolean;
includeData: boolean;
includeObjects: boolean;

View File

@ -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),

View File

@ -31,6 +31,8 @@ pub struct DatabaseExportRequest {
pub file_path: String,
#[serde(default)]
pub selected_tables: Vec<String>,
#[serde(default)]
pub excluded_tables: Vec<String>,
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<Vec<PostgresExportSequence>, 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<String>>(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::<Vec<_>>();
@ -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<crate::types::TableInfo>,
selected_tables: &[String],
excluded_tables: &[String],
) -> Vec<crate::types::TableInfo> {
if selected_tables.is_empty() {
return tables;
}
let selected: HashSet<&str> = selected_tables.iter().map(String::as_str).collect();
tables.into_iter().filter(|table| selected.contains(table.name.as_str())).collect()
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<_>>(), 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<_>>(), 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<_>>(), 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<_>>(), vec!["users"]);
}
#[test]
fn builds_drop_table_if_exists_with_qualified_mysql_name() {
let sql = drop_table_if_exists_sql("users", "app", &DatabaseType::Mysql);

View File

@ -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));
}
}

View File

@ -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,

View File

@ -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> = {}): DatabaseBackupSchedule {
return {
@ -9,6 +22,8 @@ function schedule(overrides: Partial<DatabaseBackupSchedule> = {}): 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")];