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

+8
View File
@@ -1,11 +1,19 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use std::fmt;
#[derive(Debug)]
pub struct AppError {
pub message: String,
pub status: StatusCode,
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)
}
}
impl AppError {
pub fn internal(msg: impl Into<String>) -> Self {
AppError { message: msg.into(), status: StatusCode::INTERNAL_SERVER_ERROR }
+26
View File
@@ -17,6 +17,8 @@ use axum::middleware;
use axum::routing::{delete, get, post};
use axum::Router;
use dbx_core::connection::AppState;
use dbx_core::sql_dialect::dialect_loader::{register_core_dialects, DialectPluginLoader, DialectRegistry};
use dbx_core::sql_dialect::hot_reload::DialectHotReload;
use dbx_core::storage::Storage;
use state::WebState;
use tokio::sync::RwLock;
@@ -202,6 +204,28 @@ async fn main() {
let db_path = data_dir.join("dbx.db");
let storage = Storage::open(&db_path).await.expect("Failed to open storage");
storage.migrate_from_json(&data_dir).await.expect("Failed to migrate JSON data");
// Initialize core dialect registry and load external plugin dialects
register_core_dialects();
let registry = DialectRegistry::global();
let plugin_dirs = vec![data_dir.join("plugins").join("dialects")];
let load_result = DialectPluginLoader::scan_and_load(registry, &plugin_dirs);
log::info!(
"Dialect plugins loaded: {} success, {} errors, {} skipped",
load_result.loaded.len(),
load_result.errors.len(),
load_result.skipped.len()
);
// Start dialect YAML hot-reload watcher
let watch_dirs = plugin_dirs.clone();
tokio::spawn(async move {
if let Err(e) = DialectHotReload::run_forever(watch_dirs, DialectRegistry::global()).await {
log::error!("Dialect hot-reload watcher exited: {e}");
}
});
log::info!("Dialect hot-reload watcher started");
Arc::new(AppState::new_with_plugin_and_agent_dir_and_app_version(
storage,
data_dir.join("plugins"),
@@ -348,6 +372,7 @@ async fn main() {
.route("/schema/extensions", get(routes::schema::list_extensions))
.route("/schema/available-extensions", get(routes::schema::list_available_extensions))
.route("/schema/ddl", get(routes::schema::get_ddl))
.route("/dialect/data-types", get(routes::dialect::list_data_types))
.route("/schema-diff/prepare", post(routes::schema_diff::prepare_schema_diff))
.route("/schema-diff/generate-sync-sql", post(routes::schema_diff::generate_schema_sync_sql))
.route(
@@ -370,6 +395,7 @@ async fn main() {
.route("/query/execute-batch", post(routes::query::execute_batch))
.route("/query/execute-script", post(routes::query::execute_script))
.route("/query/execute-in-transaction", post(routes::query::execute_in_transaction))
.route("/query/execute-script-2pc", post(routes::query::execute_script_with_2pc))
.route("/query/analyze-sql-references", post(routes::query::analyze_sql_references))
.route("/query/find-statement-at-cursor", post(routes::query::find_statement_at_cursor))
.route("/query/prepare-pagination-plan", post(routes::query::prepare_query_pagination_execution_plan))
+2 -19
View File
@@ -396,7 +396,7 @@ mod tests {
test_connection_with_info, ConnectRequest, DisconnectRequest, McpAddConnectionRequest,
McpRemoveConnectionRequest, SaveConnectionDatabaseInfoRequest, SaveConnectionsRequest,
};
use crate::state::{LoginRateLimit, WebState};
use crate::state::WebState;
use axum::extract::State;
use axum::Json;
use dbx_core::connection::{AppState, PoolKind};
@@ -405,13 +405,11 @@ mod tests {
TransportLayerConfig,
};
use dbx_core::storage::{McpGlobalPolicy, Storage};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
#[cfg(feature = "mq-admin")]
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[cfg(feature = "mq-admin")]
use tokio::net::TcpListener;
use tokio::sync::{Mutex, RwLock};
fn sqlite_config(id: &str, path: &str) -> ConnectionConfig {
ConnectionConfig {
@@ -487,22 +485,7 @@ mod tests {
std::fs::create_dir_all(&dir).unwrap();
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
let app = Arc::new(AppState::new_with_plugin_dir(storage, dir.join("plugins")));
let state = Arc::new(WebState {
app,
data_dir: dir.clone(),
public_base_path: "/".to_string(),
password_disabled: false,
password_hash: RwLock::new(None),
sessions: RwLock::new(HashSet::new()),
sse_channels: RwLock::new(HashMap::new()),
transfer_progress_channels: RwLock::new(HashMap::new()),
table_import_channels: RwLock::new(HashMap::new()),
sql_file_executions: RwLock::new(HashMap::new()),
nacos_imports: RwLock::new(HashMap::new()),
login_rate_limit: Mutex::new(LoginRateLimit { fail_count: 0, locked_until: None }),
export_files: RwLock::new(HashMap::new()),
ssh_prompts: Arc::new(crate::ssh_prompt::SshPromptHub::new()),
});
let state = Arc::new(WebState::for_tests(app, dir.clone()));
(state, dir)
}
+25
View File
@@ -0,0 +1,25 @@
use axum::extract::Query;
use axum::Json;
use serde::Deserialize;
#[derive(Deserialize)]
pub struct DialectDataTypesQuery {
dialect_name: String,
}
pub async fn list_data_types(Query(query): Query<DialectDataTypesQuery>) -> Json<Vec<String>> {
Json(dbx_core::sql_dialect::dialect_types::list_dialect_type_names(&query.dialect_name))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn list_data_types_matches_tauri_command_behavior() {
dbx_core::sql_dialect::dialect_loader::register_core_dialects();
let Json(types) =
list_data_types(Query(DialectDataTypesQuery { dialect_name: "PostgreSQL".to_string() })).await;
assert!(!types.is_empty(), "Expected non-empty types, got {types:?}");
}
}
+1
View File
@@ -5,6 +5,7 @@ pub mod cloud_sync;
pub mod connection;
pub mod data_compare;
pub mod database_export;
pub mod dialect;
pub mod document_store;
pub mod etcd;
pub mod hbase;
+2 -19
View File
@@ -797,14 +797,12 @@ async fn ensure_find_one_write_policy(
#[cfg(test)]
mod tests {
use super::ensure_find_one_write_policy;
use crate::state::{LoginRateLimit, WebState};
use crate::state::WebState;
use axum::http::{HeaderMap, HeaderValue};
use dbx_core::connection::AppState;
use dbx_core::models::connection::ConnectionConfig;
use dbx_core::storage::{McpGlobalPolicy, Storage};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
fn mongo_config(is_production: bool) -> ConnectionConfig {
serde_json::from_value(serde_json::json!({
@@ -826,22 +824,7 @@ mod tests {
std::fs::create_dir_all(&dir).unwrap();
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
let app = Arc::new(AppState::new_with_plugin_dir(storage, dir.join("plugins")));
let state = Arc::new(WebState {
app,
data_dir: dir.clone(),
public_base_path: "/".to_string(),
password_disabled: false,
password_hash: RwLock::new(None),
sessions: RwLock::new(HashSet::new()),
sse_channels: RwLock::new(HashMap::new()),
transfer_progress_channels: RwLock::new(HashMap::new()),
table_import_channels: RwLock::new(HashMap::new()),
sql_file_executions: RwLock::new(HashMap::new()),
nacos_imports: RwLock::new(HashMap::new()),
login_rate_limit: Mutex::new(LoginRateLimit { fail_count: 0, locked_until: None }),
export_files: RwLock::new(HashMap::new()),
ssh_prompts: Arc::new(crate::ssh_prompt::SshPromptHub::new()),
});
let state = Arc::new(WebState::for_tests(app, dir.clone()));
(state, dir)
}
+100
View File
@@ -514,6 +514,23 @@ pub async fn execute_in_transaction(
Ok(Json(result))
}
pub async fn execute_script_with_2pc(
State(state): State<Arc<WebState>>,
Json(req): Json<ExecuteBatchRequest>,
) -> Result<Json<dbx_core::query::SchemaDiffDeployResult>, AppError> {
tracing::debug!(connection_id = %req.connection_id, "execute_script_with_2pc");
// Single-connection real transaction (not per-statement auto-commit 2PC).
let result = dbx_core::query::execute_schema_diff_deploy(
&state.app,
&req.connection_id,
&req.database,
&req.statements,
req.schema.as_deref(),
)
.await;
Ok(Json(result))
}
pub async fn analyze_sql_references(
Json(req): Json<AnalyzeSqlReferencesRequest>,
) -> Result<Json<dbx_core::sql_analysis::SqlReferenceAnalysis>, AppError> {
@@ -883,3 +900,86 @@ pub async fn build_database_sql_export(
}
dbx_core::database_export::build_database_sql_export(options).map(Json).map_err(AppError::from)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::WebState;
use axum::extract::State as AxumState;
use dbx_core::connection::AppState;
use dbx_core::storage::Storage;
async fn test_web_state() -> (Arc<WebState>, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("dbx-web-query-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
let app = Arc::new(AppState::new_with_plugin_dir(storage, dir.join("plugins")));
let state = Arc::new(WebState::for_tests(app, dir.clone()));
(state, dir)
}
#[tokio::test]
async fn execute_script_with_2pc_returns_structured_result() {
let (state, _dir) = test_web_state().await;
let req = ExecuteBatchRequest {
connection_id: "conn-1".to_string(),
database: "testdb".to_string(),
statements: vec!["SELECT 1".to_string()],
schema: None,
timeout_secs: None,
};
let result = execute_script_with_2pc(AxumState(state), Json(req))
.await
.expect("execute_script_with_2pc should return Ok(Json(...))");
let log = result.0;
assert!(!log.transaction_id.is_empty());
assert!(!log.participants.is_empty());
assert_eq!(log.status, "rolled_back");
assert!(log.error.as_ref().is_some_and(|e| !e.is_empty()));
assert_eq!(log.statement_count, 1);
assert_eq!(log.executed_count, 0);
}
#[tokio::test]
async fn execute_script_with_2pc_empty_statements_succeeds() {
let (state, _dir) = test_web_state().await;
let req = ExecuteBatchRequest {
connection_id: "conn-empty".to_string(),
database: "testdb".to_string(),
statements: vec![],
schema: None,
timeout_secs: None,
};
let result = execute_script_with_2pc(AxumState(state), Json(req)).await.expect("empty deploy should succeed");
let log = result.0;
assert_eq!(log.status, "committed");
assert_eq!(log.statement_count, 0);
assert_eq!(log.executed_count, 0);
assert!(log.error.is_none());
assert_eq!(log.participants.len(), 1);
}
#[tokio::test]
async fn execute_script_with_2pc_propagates_structured_failure_fields() {
let (state, _dir) = test_web_state().await;
let req = ExecuteBatchRequest {
connection_id: "missing-conn".to_string(),
database: "testdb".to_string(),
statements: vec!["CREATE TABLE t1 (id INT)".to_string(), "CREATE TABLE t2 (id INT)".to_string()],
schema: None,
timeout_secs: None,
};
let result = execute_script_with_2pc(AxumState(state), Json(req))
.await
.expect("deploy endpoint should return structured JSON even on failure");
let log = result.0;
assert!(log.status == "rolled_back" || log.status == "mixed", "status={}", log.status);
assert_eq!(log.statement_count, 2);
assert!(log.error.as_ref().is_some_and(|e| !e.is_empty()));
// Missing connection cannot have applied statements.
assert_eq!(log.executed_count, 0);
}
}
+4
View File
@@ -12,6 +12,8 @@ pub struct GenerateSchemaSyncSqlRequest {
pub database_type: dbx_core::models::connection::DatabaseType,
pub target_schema: Option<String>,
pub cascade_delete: Option<bool>,
pub source_dialect: Option<String>,
pub field_mappings: Option<Vec<dbx_core::schema_diff::FieldMapping>>,
}
pub async fn prepare_schema_diff(
@@ -30,5 +32,7 @@ pub async fn generate_schema_sync_sql(Json(req): Json<GenerateSchemaSyncSqlReque
req.database_type,
req.target_schema.as_deref(),
req.cascade_delete.unwrap_or(false),
req.source_dialect.as_deref().and_then(dbx_core::sql_dialect::descriptor::DialectKind::from_label),
req.field_mappings.as_deref().unwrap_or(&[]),
))
}
+21
View File
@@ -42,4 +42,25 @@ impl WebState {
pub async fn remove_sse_channel(&self, id: &str) {
self.sse_channels.write().await.remove(id);
}
/// Test helper: full field set so new WebState fields don't break scattered test fixtures.
#[cfg(test)]
pub fn for_tests(app: Arc<AppState>, data_dir: PathBuf) -> Self {
Self {
app,
data_dir,
public_base_path: "/".to_string(),
password_disabled: false,
password_hash: RwLock::new(None),
sessions: RwLock::new(HashSet::new()),
sse_channels: RwLock::new(HashMap::new()),
transfer_progress_channels: RwLock::new(HashMap::new()),
table_import_channels: RwLock::new(HashMap::new()),
sql_file_executions: RwLock::new(HashMap::new()),
nacos_imports: RwLock::new(HashMap::new()),
login_rate_limit: Mutex::new(LoginRateLimit { fail_count: 0, locked_until: None }),
export_files: RwLock::new(HashMap::new()),
ssh_prompts: Arc::new(crate::ssh_prompt::SshPromptHub::new()),
}
}
}