feat(schema-diff): preserve dialect-specific DDL semantics

This commit is contained in:
dbin0123 authored and GitHub committed 2026-07-29 03:47:19 +08:00
1 parent 820b8cd26f
commit 002a2c85c0
146 files changed
+36532 -1375

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
use dbx_core::config::{TraceEntry, TraceRingBuffer};
// ---- Trace commands ----
#[tauri::command]
#[allow(dead_code)]
pub fn trace_export_command(entries: Vec<TraceEntry>, capacity: Option<usize>) -> Result<String, String> {
let cap = capacity.unwrap_or(1000);
let mut buf = TraceRingBuffer::new(cap);
for entry in entries {
buf.push(entry);
}
buf.export_json()
}
#[tauri::command]
#[allow(dead_code)]
pub fn trace_stats_command(entries: Vec<TraceEntry>) -> dbx_core::config::TraceStats {
let mut buf = TraceRingBuffer::new(entries.len().max(1));
for entry in entries {
buf.push(entry);
}
buf.stats()
}
+134
View File
@@ -0,0 +1,134 @@
use std::path::PathBuf;
use dbx_core::sql_dialect::{
descriptor::DialectKind, dialect_check, dialect_check_all, dialect_yaml::DialectYaml, DialectInfo,
};
#[tauri::command]
#[allow(dead_code)]
pub fn dialect_check_command(kind: String) -> Result<DialectInfo, String> {
let db_type = match kind.to_ascii_lowercase().as_str() {
"mysql" => DialectKind::Mysql,
"postgres" | "postgresql" => DialectKind::Postgres,
"sqlite" => DialectKind::Sqlite,
"duckdb" => DialectKind::DuckDb,
"sqlserver" | "mssql" => DialectKind::SqlServer,
"oracle" => DialectKind::Oracle,
"h2" => DialectKind::H2,
"clickhouse" => DialectKind::ClickHouse,
"manticore" | "manticoresearch" => DialectKind::ManticoreSearch,
"informix" => DialectKind::Informix,
"questdb" => DialectKind::Questdb,
_ => return Err(format!("Unknown dialect: {kind}")),
};
Ok(dialect_check(db_type))
}
#[tauri::command]
#[allow(dead_code)]
pub fn dialect_check_all_command() -> Vec<DialectInfo> {
dialect_check_all()
}
#[derive(serde::Serialize)]
#[allow(dead_code)]
pub struct DialectExportResult {
pub files_written: Vec<String>,
pub errors: Vec<String>,
}
/// Export all hardcoded dialect descriptors to YAML files.
/// Writes to `plugins/dialects/<label>.yaml` for each core dialect.
#[tauri::command]
#[allow(dead_code)]
pub fn dialect_export_command(target_dir: Option<String>) -> DialectExportResult {
let dir = target_dir.filter(|d| !d.is_empty()).unwrap_or_else(|| "plugins/dialects".to_string());
let dir_path = PathBuf::from(&dir);
if let Err(e) = std::fs::create_dir_all(&dir_path) {
return DialectExportResult {
files_written: vec![],
errors: vec![format!("Cannot create directory {dir}: {e}")],
};
}
let kinds = [
DialectKind::Mysql,
DialectKind::Postgres,
DialectKind::Sqlite,
DialectKind::DuckDb,
DialectKind::SqlServer,
DialectKind::Oracle,
DialectKind::H2,
DialectKind::ClickHouse,
DialectKind::ManticoreSearch,
DialectKind::Informix,
DialectKind::Questdb,
];
let mut files_written = Vec::new();
let mut errors = Vec::new();
for kind in kinds {
let yaml = DialectYaml::from_descriptor(kind);
let file_name = format!("{}.yaml", kind.label());
let file_path = dir_path.join(&file_name);
match yaml.to_yaml_string() {
Ok(content) => match std::fs::write(&file_path, &content) {
Ok(()) => files_written.push(file_path.display().to_string()),
Err(e) => errors.push(format!("Failed to write {}: {e}", file_path.display())),
},
Err(e) => errors.push(format!("Failed to serialize {}: {e}", kind.label())),
}
}
DialectExportResult { files_written, errors }
}
/// Initialize a new dialect YAML descriptor with minimal required fields.
/// This creates a skeleton YAML file at `plugins/dialects/<label>.yaml`.
#[tauri::command]
#[allow(dead_code)]
pub fn dialect_init_command(
name: String,
_display_name: Option<String>,
quote_char: Option<String>,
max_length: Option<u32>,
target_dir: Option<String>,
) -> Result<String, String> {
if name.trim().is_empty() {
return Err("Dialect name is required".to_string());
}
let dir = target_dir.filter(|d| !d.is_empty()).unwrap_or_else(|| "plugins/dialects".to_string());
let dir_path = PathBuf::from(&dir);
std::fs::create_dir_all(&dir_path).map_err(|e| format!("Cannot create directory: {e}"))?;
let label = name.to_ascii_lowercase().replace(' ', "_");
let file_name = format!("dialect_{label}.yaml");
let file_path = dir_path.join(&file_name);
let quote = quote_char.unwrap_or_else(|| "\"".to_string());
let max_len = max_length.unwrap_or(128);
let content = format!(
r#"dialect:
name: "{name}"
display_name: "{name}"
versions: ""
identifier_rules:
quote_char: "{quote}"
max_length: {max_len}
"#,
);
std::fs::write(&file_path, &content).map_err(|e| format!("Failed to write {file_name}: {e}"))?;
Ok(format!("Created dialect descriptor at {}", file_path.display()))
}
#[tauri::command]
pub async fn list_dialect_data_types(dialect_name: String) -> Vec<String> {
dbx_core::sql_dialect::dialect_types::list_dialect_type_names(&dialect_name)
}
+2
View File
@@ -3,6 +3,7 @@ pub mod ai;
pub mod ai_multi_config;
pub mod app_settings;
pub mod cloud_sync;
pub mod config_cmd;
pub mod connection;
#[allow(dead_code, unused_imports)]
mod connection_secrets;
@@ -10,6 +11,7 @@ pub mod csv_export;
pub mod data_compare;
pub mod database_export;
pub mod deep_link;
pub mod dialect_cmd;
pub mod document_cmd;
pub mod etcd_cmd;
pub mod external_db;
+110
View File
@@ -241,6 +241,33 @@ pub async fn execute_in_transaction(
.await
}
/// Schema Diff deploy entrypoint (legacy command name kept for API compatibility).
///
/// Executes as one single-connection transaction via [`execute_schema_diff_deploy`].
/// On failure, status is `rolled_back` when DDL/DML atomicity is guaranteed for the
/// target, otherwise `mixed` with a best-effort `executed_count` (e.g. MySQL/Oracle DDL).
pub async fn execute_script_with_2pc_core(
app: Arc<AppState>,
connection_id: String,
database: String,
statements: Vec<String>,
schema: Option<String>,
) -> dbx_core::query::SchemaDiffDeployResult {
dbx_core::query::execute_schema_diff_deploy(&app, &connection_id, &database, &statements, schema.as_deref()).await
}
#[tauri::command]
pub async fn execute_script_with_2pc(
state: State<'_, Arc<AppState>>,
connection_id: String,
database: String,
statements: Vec<String>,
schema: Option<String>,
) -> Result<dbx_core::query::SchemaDiffDeployResult, String> {
let app: Arc<AppState> = (*state).clone();
Ok(execute_script_with_2pc_core(app, connection_id, database, statements, schema).await)
}
#[tauri::command]
pub async fn begin_manual_transaction(
state: State<'_, Arc<AppState>>,
@@ -676,3 +703,86 @@ pub async fn get_explain_info(
pub fn build_create_user_sql(username: String, password: String, tablespace: String) -> Result<String, String> {
Ok(dbx_core::db_admin_sql::build_create_user_sql(&username, &password, &tablespace))
}
#[cfg(test)]
mod tests {
use super::*;
use dbx_core::storage::Storage;
use std::sync::Arc;
async fn test_app_state() -> Arc<AppState> {
let dir = std::env::temp_dir().join(format!("dbx-query-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
Arc::new(AppState::new_with_plugin_dir(storage, dir.join("plugins")))
}
#[tokio::test]
async fn execute_script_with_2pc_returns_structured_result() {
let state = test_app_state().await;
let result = execute_script_with_2pc_core(
state,
"conn-1".to_string(),
"testdb".to_string(),
vec!["SELECT 1".to_string()],
None,
)
.await;
assert!(!result.transaction_id.is_empty());
assert!(!result.participants.is_empty());
// No real connection: rolled_back with error, not silent auto-commit success.
assert_eq!(result.status, "rolled_back");
assert!(result.error.as_ref().is_some_and(|e| !e.is_empty()));
assert_eq!(result.statement_count, 1);
assert_eq!(result.executed_count, 0);
}
#[tokio::test]
async fn execute_script_with_2pc_empty_statements_succeeds() {
let state = test_app_state().await;
let result =
execute_script_with_2pc_core(state, "conn-empty".to_string(), "testdb".to_string(), vec![], None).await;
assert_eq!(result.status, "committed");
assert_eq!(result.statement_count, 0);
assert_eq!(result.executed_count, 0);
assert!(result.error.is_none());
assert_eq!(result.participants.len(), 1);
}
#[tokio::test]
async fn execute_script_with_2pc_comment_only_is_empty_success() {
let state = test_app_state().await;
let result = execute_script_with_2pc_core(
state,
"conn-comment".to_string(),
"testdb".to_string(),
vec!["-- WARNING: incomplete\n-- manual only".to_string()],
None,
)
.await;
assert_eq!(result.status, "committed");
assert_eq!(result.statement_count, 0);
assert!(result.error.is_none());
}
#[tokio::test]
async fn execute_script_with_2pc_propagates_structured_failure_fields() {
let state = test_app_state().await;
let result = execute_script_with_2pc_core(
state,
"missing-conn".to_string(),
"testdb".to_string(),
vec!["CREATE TABLE t1 (id INT)".to_string(), "CREATE TABLE t2 (id INT)".to_string()],
None,
)
.await;
assert!(result.status == "rolled_back" || result.status == "mixed", "status={}", result.status);
assert_eq!(result.statement_count, 2);
assert!(result.error.as_ref().is_some_and(|e| !e.is_empty()));
assert_eq!(result.executed_count, 0);
}
}
+4
View File
@@ -16,6 +16,8 @@ pub fn generate_schema_sync_sql(
database_type: dbx_core::models::connection::DatabaseType,
target_schema: Option<String>,
cascade_delete: Option<bool>,
source_dialect: Option<dbx_core::sql_dialect::descriptor::DialectKind>,
field_mappings: Option<Vec<dbx_core::schema_diff::FieldMapping>>,
) -> Result<String, String> {
Ok(dbx_core::schema_diff::generate_schema_sync_sql(
&diffs,
@@ -26,5 +28,7 @@ pub fn generate_schema_sync_sql(
database_type,
target_schema.as_deref(),
cascade_delete.unwrap_or(false),
source_dialect,
&field_mappings.unwrap_or_default(),
))
}