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
@@ -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 }
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(&[]),
|
||||
))
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user