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

+301
View File
@@ -0,0 +1,301 @@
use std::path::{Path, PathBuf};
use crate::models::connection::ConnectionConfig;
use crate::schema_diff::SchemaDiffPreparationOptions;
use crate::sql_parser::meta::{validate_sql_meta_consistency, MetaData, MetaReader};
#[derive(Debug, Clone)]
#[allow(clippy::large_enum_variant)]
pub enum InputSource {
DdlFiles(Vec<PathBuf>),
DatabaseConnection(ConnectionConfig),
GitDiff { repo_path: PathBuf, base_commit: String, target_commit: String },
MetaData(MetaData),
}
#[derive(Debug, Clone)]
pub struct ResolvedInput {
pub source: InputSource,
pub meta: Option<MetaData>,
pub ddl_sql: Option<String>,
pub has_errors: bool,
pub warnings: Vec<String>,
}
pub struct InputResolver;
impl InputResolver {
pub fn discover_ddl_files(dir: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
if !dir.is_dir() {
return files;
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return files,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
files.extend(Self::discover_ddl_files(&path));
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if matches!(ext, "sql" | "ddl" | "txt") {
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !file_name.starts_with('.') {
files.push(path);
}
}
}
files
}
pub fn load_meta_for(sql_path: &Path) -> Option<MetaData> {
let dir = sql_path.parent()?;
let stem = sql_path.file_stem().and_then(|s| s.to_str())?;
for ext in &["meta.json", "meta.yaml", "meta.yml"] {
let meta_path = dir.join(format!("{}.{}", stem, ext));
if meta_path.exists() {
if let Ok(meta) = MetaReader::from_file(&meta_path) {
return Some(meta);
}
}
}
let meta_path = dir.join(".dbmeta.json");
if meta_path.exists() {
if let Ok(meta) = MetaReader::from_file(&meta_path) {
return Some(meta);
}
}
None
}
pub fn resolve(inputs: Vec<InputSource>) -> ResolvedInput {
let mut meta: Option<MetaData> = None;
let mut sql_parts: Vec<String> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let mut live_db: Option<ConnectionConfig> = None;
for input in inputs {
match input {
InputSource::DdlFiles(files) => {
for file in files {
if let Ok(content) = std::fs::read_to_string(&file) {
if let Some(file_meta) = Self::load_meta_for(&file) {
let mw = validate_sql_meta_consistency(&content, &file_meta);
warnings.extend(mw.into_iter().map(|w| format!("{}: {}", file.display(), w)));
meta = Self::merge_meta(meta.take(), Some(file_meta));
}
sql_parts.push(content);
} else {
warnings.push(format!("Cannot read DDL file: {}", file.display()));
}
}
}
InputSource::DatabaseConnection(config) => {
live_db = Some(config);
}
InputSource::GitDiff { repo_path, base_commit, target_commit } => {
match crate::sql_parser::git::GitDiffScanner::new(&repo_path, &base_commit, &target_commit) {
Ok(scanner) => match scanner.scan() {
Ok(entries) => {
for entry in entries {
sql_parts.push(entry.sql_content);
}
}
Err(e) => warnings.push(format!("Git diff scan error: {e}")),
},
Err(e) => warnings.push(format!("Git scanner init error: {e}")),
}
}
InputSource::MetaData(m) => {
meta = Self::merge_meta(meta.take(), Some(m));
}
}
}
if let Some(ref meta_data) = meta {
let mw = MetaReader::validate_consistency(meta_data);
warnings.extend(mw);
}
let source = if let Some(config) = live_db {
InputSource::DatabaseConnection(config)
} else {
InputSource::DdlFiles(Vec::new())
};
ResolvedInput {
source,
meta,
ddl_sql: if sql_parts.is_empty() { None } else { Some(sql_parts.join("\n\n")) },
has_errors: warnings.iter().any(|w| w.starts_with("Cannot read") || w.contains("error")),
warnings,
}
}
fn merge_meta(existing: Option<MetaData>, incoming: Option<MetaData>) -> Option<MetaData> {
match (existing, incoming) {
(None, incoming) => incoming,
(existing, None) => existing,
(Some(mut e), Some(i)) => {
e.dialect = e.dialect.or(i.dialect);
e.version = e.version.or(i.version);
e.charset = e.charset.or(i.charset);
e.collation = e.collation.or(i.collation);
e.sql_mode = e.sql_mode.or(i.sql_mode);
e.explicit_dependencies.extend(i.explicit_dependencies);
Some(e)
}
}
}
pub fn normalize_to_diff_options(resolved: ResolvedInput) -> (SchemaDiffPreparationOptions, Vec<String>) {
let mut options = SchemaDiffPreparationOptions {
source_tables: Vec::new(),
target_tables: Vec::new(),
source_details: Vec::new(),
target_details: Vec::new(),
source_functions: Vec::new(),
target_functions: Vec::new(),
source_sequences: Vec::new(),
target_sequences: Vec::new(),
source_rules: Vec::new(),
target_rules: Vec::new(),
source_owners: Vec::new(),
target_owners: Vec::new(),
database_type: crate::models::connection::DatabaseType::Mysql,
target_schema: None,
ignore_comments: false,
cascade_delete: false,
compare_column_order: false,
..Default::default()
};
if let Some(ref meta) = resolved.meta {
if let Some(ref dialect) = meta.dialect {
let db_type = dialect_to_database_type(dialect);
options.database_type = db_type;
}
}
(options, resolved.warnings)
}
}
fn dialect_to_database_type(dialect: &str) -> crate::models::connection::DatabaseType {
match dialect.to_ascii_lowercase().as_str() {
"mysql" | "mariadb" | "tidb" => crate::models::connection::DatabaseType::Mysql,
"postgres" | "postgresql" => crate::models::connection::DatabaseType::Postgres,
"sqlite" => crate::models::connection::DatabaseType::Sqlite,
"sqlserver" | "mssql" => crate::models::connection::DatabaseType::SqlServer,
"clickhouse" => crate::models::connection::DatabaseType::ClickHouse,
"duckdb" => crate::models::connection::DatabaseType::DuckDb,
"oracle" => crate::models::connection::DatabaseType::Oracle,
_ => crate::models::connection::DatabaseType::Mysql,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn discovers_sql_files_in_directory() {
let dir = std::env::temp_dir().join("dbx_test_ddl_discover");
let _ = std::fs::create_dir_all(&dir);
let mut f1 = std::fs::File::create(dir.join("schema.sql")).unwrap();
f1.write_all(b"CREATE TABLE t (id INT);").unwrap();
let mut f2 = std::fs::File::create(dir.join("data.ddl")).unwrap();
f2.write_all(b"INSERT INTO t VALUES (1);").unwrap();
let _hidden = std::fs::File::create(dir.join(".ignore.sql")).unwrap();
let files = InputResolver::discover_ddl_files(&dir);
let names: Vec<String> =
files.iter().filter_map(|f| f.file_name().and_then(|n| n.to_str()).map(|s| s.to_string())).collect();
assert!(names.contains(&"schema.sql".to_string()));
assert!(names.contains(&"data.ddl".to_string()));
assert!(!names.contains(&".ignore.sql".to_string()));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn loads_meta_file_for_sql() {
let dir = std::env::temp_dir().join("dbx_test_meta_load");
let _ = std::fs::create_dir_all(&dir);
let sql_path = dir.join("test.sql");
let meta_path = dir.join("test.meta.json");
std::fs::write(&sql_path, "CREATE TABLE t (id INT);").unwrap();
std::fs::write(&meta_path, r#"{"dialect": "postgres", "version": "15"}"#).unwrap();
let meta = InputResolver::load_meta_for(&sql_path);
assert!(meta.is_some());
assert_eq!(meta.unwrap().dialect.as_deref(), Some("postgres"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn normalize_uses_meta_dialect() {
let resolved = ResolvedInput {
source: InputSource::DdlFiles(Vec::new()),
meta: Some(MetaData {
dialect: Some("postgres".into()),
version: None,
charset: None,
collation: None,
sql_mode: None,
explicit_dependencies: std::collections::HashMap::new(),
}),
ddl_sql: Some("CREATE TABLE t (id INT);".into()),
has_errors: false,
warnings: Vec::new(),
};
let (options, _) = InputResolver::normalize_to_diff_options(resolved);
assert_eq!(options.database_type, crate::models::connection::DatabaseType::Postgres);
}
#[test]
fn resolve_adds_has_errors_on_failed_read() {
let resolved = InputResolver::resolve(vec![InputSource::DdlFiles(vec![PathBuf::from(
r"C:\non_existent_dir\nonexistent.sql",
)])]);
assert!(resolved.has_errors);
assert_eq!(resolved.warnings.len(), 1);
}
#[test]
fn resolve_merges_meta_from_multiple_sources() {
let meta1 = InputSource::MetaData(MetaData {
dialect: Some("mysql".into()),
version: Some("8.0".into()),
charset: None,
collation: None,
sql_mode: None,
explicit_dependencies: std::collections::HashMap::new(),
});
let meta2 = InputSource::MetaData(MetaData {
dialect: None,
version: None,
charset: Some("utf8mb4".into()),
collation: None,
sql_mode: None,
explicit_dependencies: std::collections::HashMap::new(),
});
let resolved = InputResolver::resolve(vec![meta1, meta2]);
assert_eq!(resolved.meta.as_ref().and_then(|m| m.dialect.as_deref()), Some("mysql"));
assert_eq!(resolved.meta.as_ref().and_then(|m| m.charset.as_deref()), Some("utf8mb4"));
}
#[test]
fn empty_ddl_dir_returns_no_files() {
let dir = std::env::temp_dir().join("dbx_test_empty_ddl");
let _ = std::fs::create_dir_all(&dir);
let files = InputResolver::discover_ddl_files(&dir);
assert!(files.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
}