fix: handle MySQL backup SQL import

This commit is contained in:
t8y2 2026-05-16 12:13:40 +08:00
parent e9621ef06a
commit f4c7c1d764
10 changed files with 371 additions and 10 deletions

View File

@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize};
use crate::models::connection::DatabaseType;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SqlFileRequest {
@ -31,6 +33,12 @@ pub enum SqlFileStatus {
Cancelled,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SqlFileStatementAction {
Execute(String),
Skip,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SqlFileProgress {
@ -247,6 +255,28 @@ pub fn statement_summary(statement: &str) -> String {
collapsed.chars().take(MAX_LEN).collect()
}
pub fn prepare_sql_file_statement(
statement: &str,
db_type: &DatabaseType,
driver_profile: Option<&str>,
) -> SqlFileStatementAction {
let statement = statement.trim();
let Some(body) = mysql_executable_comment_body(statement) else {
return SqlFileStatementAction::Execute(statement.to_string());
};
if !is_mysql_compatible_import_target(db_type, driver_profile) {
return SqlFileStatementAction::Skip;
}
let body = body.trim();
if body.is_empty() || is_mysql_key_toggle_statement(body) {
return SqlFileStatementAction::Skip;
}
SqlFileStatementAction::Execute(body.to_string())
}
pub fn starts_with_executable_sql_keyword(sql: &str, keywords: &[&str]) -> bool {
let Some(token) = first_executable_sql_token(sql) else {
return false;
@ -254,6 +284,81 @@ pub fn starts_with_executable_sql_keyword(sql: &str, keywords: &[&str]) -> bool
keywords.iter().any(|keyword| token.eq_ignore_ascii_case(keyword))
}
fn is_mysql_compatible_import_target(db_type: &DatabaseType, driver_profile: Option<&str>) -> bool {
matches!(db_type, DatabaseType::Mysql | DatabaseType::Doris | DatabaseType::StarRocks | DatabaseType::Goldendb)
|| driver_profile.map(|profile| profile.to_ascii_lowercase()).is_some_and(|profile| {
matches!(
profile.as_str(),
"mariadb" | "tidb" | "oceanbase" | "custom_mysql" | "doris" | "starrocks" | "selectdb" | "goldendb"
)
})
}
fn mysql_executable_comment_body(statement: &str) -> Option<&str> {
let bytes = statement.as_bytes();
let start = leading_mysql_executable_comment_start(statement)?;
let body_start = if bytes.get(start + 2) == Some(&b'!') { start + 3 } else { start + 4 };
let mut body_start = body_start;
while body_start < bytes.len() && (bytes[body_start].is_ascii_digit() || bytes[body_start].is_ascii_whitespace()) {
body_start += 1;
}
let close = find_block_comment_close(bytes, body_start)?;
if has_executable_sql(&statement[close + 2..]) {
return None;
}
Some(&statement[body_start..close])
}
fn leading_mysql_executable_comment_start(statement: &str) -> Option<usize> {
let bytes = statement.as_bytes();
let mut i = 0;
while i < bytes.len() {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i + 1 < bytes.len() && bytes[i] == b'-' && bytes[i + 1] == b'-' {
i += 2;
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
continue;
}
if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
if i + 2 < bytes.len() && (bytes[i + 2] == b'!' || (i + 3 < bytes.len() && &bytes[i + 2..i + 4] == b"M!")) {
return Some(i);
}
let close = find_block_comment_close(bytes, i + 2)?;
i = close + 2;
continue;
}
return None;
}
None
}
fn find_block_comment_close(bytes: &[u8], mut start: usize) -> Option<usize> {
while start + 1 < bytes.len() {
if bytes[start] == b'*' && bytes[start + 1] == b'/' {
return Some(start);
}
start += 1;
}
None
}
fn is_mysql_key_toggle_statement(statement: &str) -> bool {
let upper = statement.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_uppercase();
upper.starts_with("ALTER TABLE ") && (upper.ends_with(" ENABLE KEYS") || upper.ends_with(" DISABLE KEYS"))
}
fn first_executable_sql_token(sql: &str) -> Option<&str> {
let bytes = sql.as_bytes();
let mut i = 0;
@ -399,7 +504,12 @@ fn split_sql_script(sql: &str) -> Result<Vec<String>, String> {
#[cfg(test)]
mod tests {
use super::{split_sql_script, starts_with_executable_sql_keyword, SqlStatementSplitter};
use crate::models::connection::DatabaseType;
use super::{
prepare_sql_file_statement, split_sql_script, starts_with_executable_sql_keyword, SqlFileStatementAction,
SqlStatementSplitter,
};
#[test]
fn splits_semicolon_delimited_statements() {
@ -504,6 +614,42 @@ mod tests {
assert!(starts_with_executable_sql_keyword("/*M! SELECT 1 */", &["SELECT"]));
}
#[test]
fn prepares_mysql_executable_comments_for_mysql_compatible_imports() {
assert_eq!(
prepare_sql_file_statement(
"/*!40101 SET character_set_client = @saved_cs_client */",
&DatabaseType::Mysql,
None
),
SqlFileStatementAction::Execute("SET character_set_client = @saved_cs_client".to_string())
);
}
#[test]
fn skips_mysql_key_toggle_comments_for_mysql_compatible_imports() {
assert_eq!(
prepare_sql_file_statement(" /*!40000 ALTER TABLE `dd_admin` ENABLE KEYS */", &DatabaseType::Mysql, None),
SqlFileStatementAction::Skip
);
assert_eq!(
prepare_sql_file_statement("/*!40000 ALTER TABLE `dd_admin` DISABLE KEYS */", &DatabaseType::Mysql, None),
SqlFileStatementAction::Skip
);
}
#[test]
fn skips_mysql_executable_comments_for_non_mysql_imports() {
assert_eq!(
prepare_sql_file_statement(
"/*!40101 SET character_set_client = @saved_cs_client */",
&DatabaseType::Postgres,
None
),
SqlFileStatementAction::Skip
);
}
#[test]
fn split_batches_by_go() {
assert_eq!(super::split_sql_batches("SELECT 1\nGO\nSELECT 2"), vec!["SELECT 1", "SELECT 2"]);

View File

@ -46,7 +46,7 @@ DBX 会按流式方式读取 SQL 文件,并尽量正确识别语句边界,
- 字符串中的分号不会被当作语句结束
- 行注释和块注释中的分号不会被当作语句结束
- PostgreSQL 的 dollar-quoted 函数体会保持为同一条语句
- MySQL executable comments 会作为可执行语句处理
- MySQL executable comments 会在导入前按目标连接处理MySQL 兼容连接会展开可执行内容,非 MySQL 连接会跳过;`ENABLE/DISABLE KEYS` 这类恢复优化指令会跳过
## 错误处理

View File

@ -46,7 +46,7 @@ DBX reads SQL files as a stream and tries to detect statement boundaries correct
- Semicolons inside strings are not treated as statement endings
- Semicolons inside line and block comments are ignored
- PostgreSQL dollar-quoted function bodies are kept together
- MySQL executable comments are treated as executable statements
- MySQL executable comments are prepared for the target connection before import: MySQL-compatible connections unwrap executable content, non-MySQL connections skip it, and restore-only `ENABLE/DISABLE KEYS` directives are skipped
## Error Handling

View File

@ -10,9 +10,11 @@ use tokio_util::sync::CancellationToken;
use crate::commands::connection::AppState;
use crate::commands::query::execute_sql_statement;
use dbx_core::models::connection::DatabaseType;
pub use dbx_core::sql::{
statement_summary, SqlFilePreview, SqlFileProgress, SqlFileRequest, SqlFileStatus, SqlStatementSplitter,
prepare_sql_file_statement, statement_summary, SqlFilePreview, SqlFileProgress, SqlFileRequest,
SqlFileStatementAction, SqlFileStatus, SqlStatementSplitter,
};
static SQL_FILE_EXECUTIONS: std::sync::LazyLock<RwLock<HashMap<String, CancellationToken>>> =
@ -25,6 +27,12 @@ struct StatementErrorDecision {
result: Result<bool, String>,
}
#[derive(Debug, Clone)]
struct SqlFileImportTarget {
db_type: DatabaseType,
driver_profile: Option<String>,
}
#[cfg(test)]
#[derive(Debug, Clone, PartialEq, Eq)]
struct SqlFileSummary {
@ -118,6 +126,7 @@ async fn execute_sql_file_inner(
let mut reader = BufReader::new(file);
let mut splitter = SqlStatementSplitter::default();
let mut line = String::new();
let import_target = sql_file_import_target(state.inner().as_ref(), &request.connection_id).await;
loop {
if token.is_cancelled() {
@ -168,6 +177,7 @@ async fn execute_sql_file_inner(
started_at,
statement_index,
&statement,
import_target.as_ref(),
&mut success_count,
&mut failure_count,
&mut affected_rows,
@ -189,6 +199,7 @@ async fn execute_sql_file_inner(
started_at,
statement_index,
&statement,
import_target.as_ref(),
&mut success_count,
&mut failure_count,
&mut affected_rows,
@ -214,6 +225,13 @@ async fn execute_sql_file_inner(
Ok(())
}
async fn sql_file_import_target(state: &AppState, connection_id: &str) -> Option<SqlFileImportTarget> {
let configs = state.configs.read().await;
configs
.get(connection_id)
.map(|config| SqlFileImportTarget { db_type: config.db_type, driver_profile: config.driver_profile.clone() })
}
async fn execute_statement_with_progress(
app: &AppHandle,
state: &State<'_, Arc<AppState>>,
@ -222,13 +240,13 @@ async fn execute_statement_with_progress(
started_at: Instant,
statement_index: usize,
statement: &str,
import_target: Option<&SqlFileImportTarget>,
success_count: &mut usize,
failure_count: &mut usize,
affected_rows: &mut u64,
) -> Result<bool, String> {
let summary = statement_summary(statement);
if token.is_cancelled() {
let summary = statement_summary(statement);
emit_progress(
app,
&request.execution_id,
@ -244,6 +262,43 @@ async fn execute_statement_with_progress(
return Ok(true);
}
let statement_action = import_target
.map(|target| prepare_sql_file_statement(statement, &target.db_type, target.driver_profile.as_deref()))
.unwrap_or_else(|| SqlFileStatementAction::Execute(statement.to_string()));
let executable_statement = match statement_action {
SqlFileStatementAction::Execute(statement) => statement,
SqlFileStatementAction::Skip => {
let summary = statement_summary(statement);
emit_progress(
app,
&request.execution_id,
SqlFileStatus::Running,
statement_index,
*success_count,
*failure_count,
*affected_rows,
started_at,
&summary,
None,
);
*success_count += 1;
emit_progress(
app,
&request.execution_id,
SqlFileStatus::StatementDone,
statement_index,
*success_count,
*failure_count,
*affected_rows,
started_at,
&summary,
None,
);
return Ok(false);
}
};
let summary = statement_summary(&executable_statement);
emit_progress(
app,
&request.execution_id,
@ -261,7 +316,7 @@ async fn execute_statement_with_progress(
state.inner().as_ref(),
&request.connection_id,
&request.database,
statement,
&executable_statement,
None,
Some(token.clone()),
)

View File

@ -5,6 +5,7 @@ use axum::response::sse::{Event, Sse};
use axum::Json;
use dbx_core::query;
use dbx_core::sql;
use dbx_core::sql::SqlFileStatementAction;
use futures::stream::Stream;
use serde::Deserialize;
@ -160,13 +161,29 @@ pub async fn execute_sql_file(
}
let statements = sql::split_sql_statements(&file_content);
let import_target = {
let configs = app.configs.read().await;
configs.get(&req.connection_id).map(|config| (config.db_type, config.driver_profile.clone()))
};
let start = std::time::Instant::now();
let mut success_count = 0usize;
let mut failure_count = 0usize;
let mut total_affected: u64 = 0;
for (i, stmt) in statements.iter().enumerate() {
let summary = sql::statement_summary(stmt);
let statement_action = import_target
.as_ref()
.map(|(db_type, driver_profile)| {
sql::prepare_sql_file_statement(stmt, db_type, driver_profile.as_deref())
})
.unwrap_or_else(|| SqlFileStatementAction::Execute(stmt.to_string()));
let (stmt_to_execute, summary, should_execute) = match statement_action {
SqlFileStatementAction::Execute(statement) => {
let summary = sql::statement_summary(&statement);
(statement, summary, true)
}
SqlFileStatementAction::Skip => (String::new(), sql::statement_summary(stmt), false),
};
// Send running
let running = dbx_core::sql::SqlFileProgress {
@ -184,7 +201,28 @@ pub async fn execute_sql_file(
let _ = tx.send(json);
}
match query::execute_sql_statement(&app, &req.connection_id, &req.database, stmt, None, None).await {
if !should_execute {
success_count += 1;
let done = dbx_core::sql::SqlFileProgress {
execution_id: req.execution_id.clone(),
status: dbx_core::sql::SqlFileStatus::StatementDone,
statement_index: i,
success_count,
failure_count,
affected_rows: total_affected,
elapsed_ms: start.elapsed().as_millis(),
statement_summary: summary,
error: None,
};
if let Ok(json) = serde_json::to_string(&done) {
let _ = tx.send(json);
}
continue;
}
match query::execute_sql_statement(&app, &req.connection_id, &req.database, &stmt_to_execute, None, None)
.await
{
Ok(result) => {
success_count += 1;
total_affected += result.affected_rows;

View File

@ -88,6 +88,7 @@ import {
} from "@/lib/databaseCapabilities";
import { sidebarSelectionCopyAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/treeNodeClick";
import { formatCsv, formatJson, formatSqlInsert } from "@/lib/exportFormats";
import { buildCreateDatabaseSql, supportsCreateDatabaseCharset } from "@/lib/createDatabaseSql";
import { hexToRgba } from "@/lib/color";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import { isTauriRuntime } from "@/lib/tauriRuntime";
@ -604,6 +605,8 @@ const duplicateTableName = ref("");
const showCreateDatabaseDialog = ref(false);
const createDatabaseName = ref("");
const createDatabaseCharset = ref("utf8mb4");
const createDatabaseCollation = ref("utf8mb4_unicode_ci");
const showDropDatabaseConfirm = ref(false);
const showCreateSchemaDialog = ref(false);
const createSchemaName = ref("");
@ -682,6 +685,11 @@ const canCreateDatabase = computed(() => {
return props.node.type === "connection" && supportsDatabaseCreation(config?.db_type);
});
const canSetCreateDatabaseCharset = computed(() => {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return supportsCreateDatabaseCharset(config?.db_type, config?.driver_profile);
});
const canDropDatabase = computed(() => {
const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined;
return props.node.type === "database" && supportsDatabaseCreation(config?.db_type);
@ -786,6 +794,8 @@ function buildDropSchemaSql(): string {
function openCreateDatabaseDialog() {
createDatabaseName.value = "";
createDatabaseCharset.value = "utf8mb4";
createDatabaseCollation.value = "utf8mb4_unicode_ci";
showCreateDatabaseDialog.value = true;
}
@ -796,7 +806,14 @@ async function confirmCreateDatabase() {
showCreateDatabaseDialog.value = false;
try {
await connectionStore.ensureConnected(node.connectionId);
const sql = `CREATE DATABASE ${quoteIdent(name)};`;
const config = connectionStore.getConfig(node.connectionId);
const sql = buildCreateDatabaseSql({
databaseType: config?.db_type,
driverProfile: config?.driver_profile,
name,
charset: createDatabaseCharset.value,
collation: createDatabaseCollation.value,
});
await api.executeQuery(node.connectionId, "", sql);
toast(t("contextMenu.createDatabaseSuccess", { name }), 3000);
await connectionStore.loadDatabases(node.connectionId, { force: true });
@ -2037,6 +2054,26 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
:placeholder="t('contextMenu.createDatabaseNamePlaceholder')"
@keydown.enter.prevent="confirmCreateDatabase"
/>
<div v-if="canSetCreateDatabaseCharset" class="grid gap-2">
<div class="grid gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{ t("contextMenu.createDatabaseCharset") }}</label>
<Input
v-model="createDatabaseCharset"
:placeholder="t('contextMenu.createDatabaseCharsetPlaceholder')"
@keydown.enter.prevent="confirmCreateDatabase"
/>
</div>
<div class="grid gap-1.5">
<label class="text-xs font-medium text-muted-foreground">{{
t("contextMenu.createDatabaseCollation")
}}</label>
<Input
v-model="createDatabaseCollation"
:placeholder="t('contextMenu.createDatabaseCollationPlaceholder')"
@keydown.enter.prevent="confirmCreateDatabase"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showCreateDatabaseDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="!createDatabaseName.trim()" @click="confirmCreateDatabase">{{

View File

@ -660,6 +660,10 @@ export default {
createDatabaseSuccess: 'Database "{name}" created',
dropDatabaseSuccess: 'Database "{name}" dropped',
createDatabaseNamePlaceholder: "Database name",
createDatabaseCharset: "Character set",
createDatabaseCharsetPlaceholder: "e.g. utf8mb4",
createDatabaseCollation: "Collation",
createDatabaseCollationPlaceholder: "e.g. utf8mb4_unicode_ci",
createSchema: "Create Schema",
dropSchema: "Drop Schema",
confirmDropSchemaTitle: "Drop Schema",

View File

@ -643,6 +643,10 @@ export default {
createDatabaseSuccess: "数据库「{name}」已创建",
dropDatabaseSuccess: "数据库「{name}」已删除",
createDatabaseNamePlaceholder: "数据库名称",
createDatabaseCharset: "字符集",
createDatabaseCharsetPlaceholder: "例如 utf8mb4",
createDatabaseCollation: "排序规则",
createDatabaseCollationPlaceholder: "例如 utf8mb4_unicode_ci",
createSchema: "新建 Schema",
dropSchema: "删除 Schema",
confirmDropSchemaTitle: "删除 Schema",

View File

@ -0,0 +1,43 @@
import type { DatabaseType } from "@/types/database";
import { quoteTableIdentifier } from "./tableSelectSql";
const MYSQL_COMPATIBLE_PROFILES = new Set([
"mysql",
"mariadb",
"tidb",
"oceanbase",
"doris",
"starrocks",
"custom_mysql",
]);
const MYSQL_COMPATIBLE_TYPES = new Set<DatabaseType>(["mysql", "doris", "starrocks", "goldendb"]);
export interface CreateDatabaseSqlOptions {
databaseType?: DatabaseType;
driverProfile?: string | null;
name: string;
charset?: string;
collation?: string;
}
export function supportsCreateDatabaseCharset(databaseType?: DatabaseType, driverProfile?: string | null): boolean {
return (
MYSQL_COMPATIBLE_TYPES.has(databaseType as DatabaseType) ||
(!!driverProfile && MYSQL_COMPATIBLE_PROFILES.has(driverProfile))
);
}
export function buildCreateDatabaseSql(options: CreateDatabaseSqlOptions): string {
const name = quoteTableIdentifier(options.databaseType, options.name);
const charset = cleanSqlOption(options.charset);
const collation = cleanSqlOption(options.collation);
if (!supportsCreateDatabaseCharset(options.databaseType, options.driverProfile) || !charset) {
return `CREATE DATABASE ${name};`;
}
const collateClause = collation ? ` COLLATE ${collation}` : "";
return `CREATE DATABASE ${name} CHARACTER SET ${charset}${collateClause};`;
}
function cleanSqlOption(value: string | undefined): string {
return value?.trim().replace(/[;\s]+/g, "") ?? "";
}

View File

@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildCreateDatabaseSql, supportsCreateDatabaseCharset } from "../src/lib/createDatabaseSql.ts";
test("builds MySQL create database SQL with charset and collation", () => {
assert.equal(
buildCreateDatabaseSql({
databaseType: "mysql",
driverProfile: "mysql",
name: "app db",
charset: "utf8mb4",
collation: "utf8mb4_unicode_ci",
}),
"CREATE DATABASE `app db` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;",
);
});
test("omits charset clauses for non-MySQL database types", () => {
assert.equal(
buildCreateDatabaseSql({
databaseType: "postgres",
name: "analytics",
charset: "utf8mb4",
collation: "utf8mb4_unicode_ci",
}),
'CREATE DATABASE "analytics";',
);
});
test("recognizes MySQL-compatible driver profiles", () => {
assert.equal(supportsCreateDatabaseCharset("mysql", "oceanbase"), true);
assert.equal(supportsCreateDatabaseCharset("mysql", "doris"), true);
assert.equal(supportsCreateDatabaseCharset("postgres", undefined), false);
});