feat(schema-diff): preserve dialect-specific DDL semantics
This commit is contained in:
1 parent
820b8cd26f
commit
002a2c85c0
146 files changed
+36532
-1375
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
Reference in new issue
Block a user