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

@@ -0,0 +1,446 @@
use serde::{Deserialize, Serialize};
use sqlparser::ast::Statement;
use sqlparser::dialect::GenericDialect;
use sqlparser::parser::Parser;
use crate::schema_diff::SchemaDiffPreparationOptions;
use crate::types::FunctionInfo;
#[derive(Debug, Clone)]
pub enum FilterAction {
Allow,
Deny(String),
}
pub trait AstFilter {
fn filter_statement(&self, stmt: &Statement) -> FilterAction;
}
#[derive(Debug, Clone)]
pub struct FilterResult {
pub allowed: Vec<String>,
pub denied: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxResult {
pub allowed: Vec<String>,
pub denied: Vec<String>,
pub nesting_violations: Vec<String>,
pub is_pure: bool,
pub stats: SandboxStats,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SandboxMode {
Permissive,
#[default]
Strict,
Isolation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxStats {
pub total_statements: usize,
pub allowed_count: usize,
pub denied_count: usize,
pub mode: String,
pub max_nesting_depth: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AstSandbox {
pub mode: SandboxMode,
pub max_nesting_depth: usize,
}
impl Default for AstSandbox {
fn default() -> Self {
Self { mode: SandboxMode::Strict, max_nesting_depth: 10 }
}
}
impl AstSandbox {
pub fn new(mode: SandboxMode, max_nesting_depth: usize) -> Self {
Self { mode, max_nesting_depth }
}
pub fn filter_sql(&self, sql: &str, dialect_str: &str) -> Result<SandboxResult, String> {
let base = AstTransmitFilter::filter_sql(sql, dialect_str)?;
let stats = SandboxStats {
total_statements: base.allowed.len() + base.denied.len(),
allowed_count: base.allowed.len(),
denied_count: base.denied.len(),
mode: match self.mode {
SandboxMode::Permissive => "permissive".to_string(),
SandboxMode::Strict => "strict".to_string(),
SandboxMode::Isolation => "isolation".to_string(),
},
max_nesting_depth: self.max_nesting_depth,
};
let (further_blocked, nest_violations) = match self.mode {
SandboxMode::Permissive => (Vec::new(), Vec::new()),
SandboxMode::Strict | SandboxMode::Isolation => self.check_nesting(&base.allowed, self.max_nesting_depth),
};
let is_pure = match self.mode {
SandboxMode::Permissive => base.denied.is_empty(),
SandboxMode::Strict => base.denied.is_empty() && further_blocked.is_empty(),
SandboxMode::Isolation => {
base.denied.is_empty() && further_blocked.is_empty() && nest_violations.is_empty()
}
};
Ok(SandboxResult {
allowed: base.allowed,
denied: base.denied.iter().chain(further_blocked.iter()).cloned().collect(),
nesting_violations: nest_violations,
is_pure,
stats,
})
}
fn check_nesting(&self, statements: &[String], max_depth: usize) -> (Vec<String>, Vec<String>) {
let mut further_blocked = Vec::new();
let mut violations = Vec::new();
for stmt in statements {
let depth = self.count_nesting(stmt);
if depth > max_depth {
further_blocked.push(stmt.clone());
violations.push(format!("Nesting depth {depth} exceeds max {max_depth}: {stmt}"));
}
}
(further_blocked, violations)
}
fn count_nesting(&self, stmt: &str) -> usize {
let mut max_depth = 0usize;
let mut current = 0usize;
for ch in stmt.chars() {
match ch {
'(' => {
current += 1;
max_depth = max_depth.max(current);
}
')' => {
current = current.saturating_sub(1);
}
_ => {}
}
}
max_depth
}
}
pub struct AstTransmitFilter;
impl AstTransmitFilter {
pub fn filter_sql(sql: &str, dialect_str: &str) -> Result<FilterResult, String> {
let dialect = resolve_dialect(dialect_str);
let stmts =
Parser::parse_sql(dialect.as_ref(), sql).map_err(|e| format!("SQL parse error in AST filter: {e}"))?;
let filter = Self;
let mut allowed = Vec::new();
let mut denied: Vec<String> = Vec::new();
for stmt in &stmts {
match filter.filter_statement(stmt) {
FilterAction::Allow => {
allowed.push(stmt.to_string());
}
FilterAction::Deny(reason) => {
denied.push(format!("Blocked: {reason} — {stmt}"));
}
}
}
Ok(FilterResult { allowed, denied })
}
fn is_dangerous_body_node(&self, stmt: &Statement) -> bool {
matches!(
stmt,
Statement::CreateFunction { .. } | Statement::CreateProcedure { .. } | Statement::CreateTrigger { .. }
)
}
pub fn filter_diff_preparation_options(
options: SchemaDiffPreparationOptions,
_dialect: &str,
) -> SchemaDiffPreparationOptions {
let mut filtered = options;
let is_dangerous_fn = |f: &FunctionInfo| -> bool {
let upper = f.definition.to_ascii_uppercase();
let keywords = ["FUNCTION", "PROCEDURE", "TRIGGER", "BEGIN", "DECLARE", "LANGUAGE"];
keywords.iter().any(|kw| upper.contains(kw))
};
filtered.source_functions.retain(|f| !is_dangerous_fn(f));
filtered.target_functions.retain(|f| !is_dangerous_fn(f));
filtered
}
}
fn resolve_dialect(dialect: &str) -> Box<dyn sqlparser::dialect::Dialect> {
match dialect.to_ascii_lowercase().as_str() {
"postgres" | "postgresql" => Box::new(sqlparser::dialect::PostgreSqlDialect {}),
"mysql" | "mariadb" | "tidb" => Box::new(sqlparser::dialect::MySqlDialect {}),
"sqlite" => Box::new(sqlparser::dialect::SQLiteDialect {}),
"sqlserver" | "mssql" => Box::new(sqlparser::dialect::MsSqlDialect {}),
"clickhouse" => Box::new(sqlparser::dialect::ClickHouseDialect {}),
"duckdb" => Box::new(sqlparser::dialect::DuckDbDialect {}),
_ => Box::new(GenericDialect {}),
}
}
impl AstFilter for AstTransmitFilter {
fn filter_statement(&self, stmt: &Statement) -> FilterAction {
if self.is_dangerous_body_node(stmt) {
return FilterAction::Deny("Function/procedure/trigger body not allowed in schema diff".into());
}
match stmt {
Statement::CreateTable { .. }
| Statement::CreateView { .. }
| Statement::CreateIndex { .. }
| Statement::CreateSchema { .. }
| Statement::CreateSequence { .. }
| Statement::AlterTable { .. }
| Statement::AlterIndex { .. }
| Statement::AlterView { .. }
| Statement::Drop { .. }
| Statement::Truncate { .. } => FilterAction::Allow,
_ => FilterAction::Deny("Statement type not in whitelist for schema diff".into()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allows_create_table() {
let result =
AstTransmitFilter::filter_sql("CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(100))", "generic")
.unwrap();
assert!(!result.allowed.is_empty());
assert!(result.allowed[0].contains("CREATE TABLE"));
}
#[test]
fn allows_create_index() {
let result = AstTransmitFilter::filter_sql("CREATE INDEX idx_users_name ON users (name)", "generic").unwrap();
assert!(!result.allowed.is_empty());
}
#[test]
fn allows_alter_table() {
let result = AstTransmitFilter::filter_sql("ALTER TABLE users ADD COLUMN age INT", "generic").unwrap();
assert!(!result.allowed.is_empty());
}
#[test]
fn allows_drop_table() {
let result = AstTransmitFilter::filter_sql("DROP TABLE users", "generic").unwrap();
assert!(!result.allowed.is_empty());
}
#[test]
fn blocks_create_function() {
let result = AstTransmitFilter::filter_sql(
"CREATE FUNCTION add(a INT, b INT) RETURNS INT LANGUAGE SQL RETURN a + b",
"generic",
)
.unwrap();
assert!(result.allowed.is_empty());
assert!(!result.denied.is_empty());
}
#[test]
fn blocks_create_procedure() {
let result = AstTransmitFilter::filter_sql(
"CREATE PROCEDURE test_proc() AS LANGUAGE SQL BEGIN ATOMIC SELECT 1; END",
"postgres",
);
// parse failure on some platforms is acceptable
if let Ok(r) = result {
assert!(r.allowed.is_empty());
assert!(!r.denied.is_empty());
}
}
#[test]
fn blocks_create_trigger() {
let result = AstTransmitFilter::filter_sql(
"CREATE TRIGGER test_trigger BEFORE INSERT ON users FOR EACH ROW EXECUTE FUNCTION log_change()",
"postgres",
)
.unwrap();
assert!(result.allowed.is_empty());
assert!(!result.denied.is_empty());
}
#[test]
fn allows_multiple_ddl_statements() {
let result = AstTransmitFilter::filter_sql(
"CREATE TABLE a (id INT); CREATE TABLE b (id INT); ALTER TABLE a ADD COLUMN x INT;",
"generic",
)
.unwrap();
assert_eq!(result.allowed.len(), 3);
}
#[test]
fn blocks_mixed_ddl_and_function() {
let result = AstTransmitFilter::filter_sql(
"CREATE TABLE t (id INT); CREATE FUNCTION f() RETURNS INT AS $$ SELECT 1 $$ LANGUAGE SQL",
"postgres",
)
.unwrap();
assert_eq!(result.allowed.len(), 1);
assert_eq!(result.denied.len(), 1);
}
#[test]
fn blocks_select_statement() {
let result = AstTransmitFilter::filter_sql("SELECT * FROM users", "generic").unwrap();
assert!(result.allowed.is_empty());
assert!(!result.denied.is_empty());
}
#[test]
fn allows_create_view() {
let result = AstTransmitFilter::filter_sql(
"CREATE VIEW active_users AS SELECT * FROM users WHERE active = 1",
"generic",
)
.unwrap();
assert!(!result.allowed.is_empty());
}
#[test]
fn deny_insert_statement() {
let result = AstTransmitFilter::filter_sql("INSERT INTO users VALUES (1, 'a')", "generic").unwrap();
assert!(result.allowed.is_empty());
assert!(!result.denied.is_empty());
}
#[test]
fn filter_options_removes_dangerous_functions() {
let opts = SchemaDiffPreparationOptions {
source_functions: vec![
FunctionInfo {
name: "f1".into(),
definition: "CREATE FUNCTION f1() RETURNS INT ...".into(),
function_type: "FUNCTION".into(),
data_type: "int".into(),
arguments: "".into(),
},
FunctionInfo {
name: "t1".into(),
definition: "TABLE t1".into(),
function_type: "TABLE".into(),
data_type: "".into(),
arguments: "".into(),
},
],
source_tables: vec![],
target_tables: vec![],
source_details: vec![],
target_details: vec![],
target_functions: vec![],
source_sequences: vec![],
target_sequences: vec![],
source_rules: vec![],
target_rules: vec![],
source_owners: vec![],
target_owners: vec![],
database_type: crate::models::connection::DatabaseType::Mysql,
target_schema: None,
ignore_comments: false,
cascade_delete: false,
compare_column_order: false,
..Default::default()
};
let filtered = AstTransmitFilter::filter_diff_preparation_options(opts, "mysql");
assert_eq!(filtered.source_functions.len(), 1);
assert_eq!(filtered.source_functions[0].name, "t1");
}
#[test]
fn sandbox_strict_mode_blocks_dangerous_statement() {
let sandbox = AstSandbox::new(SandboxMode::Strict, 10);
let result = sandbox
.filter_sql(
"CREATE TABLE t (id INT); CREATE FUNCTION f() RETURNS INT AS $$ SELECT 1 $$ LANGUAGE SQL",
"postgres",
)
.unwrap();
assert_eq!(result.allowed.len(), 1);
assert!(!result.denied.is_empty());
assert!(!result.is_pure);
}
#[test]
fn sandbox_isolation_mode_checks_nesting() {
let sandbox = AstSandbox::new(SandboxMode::Isolation, 4);
let result = sandbox
.filter_sql("CREATE TABLE t (a INT, b INT, c INT, d INT, e INT DEFAULT (((1))))", "generic")
.unwrap();
assert!(result.nesting_violations.is_empty(), "Should pass with nesting depth 4");
assert!(result.is_pure);
}
#[test]
fn sandbox_isolation_blocks_deeply_nested() {
let sandbox = AstSandbox::new(SandboxMode::Isolation, 1);
let result = sandbox.filter_sql("CREATE TABLE t (a INT DEFAULT ((((1)))))", "generic").unwrap();
assert!(!result.nesting_violations.is_empty());
assert!(!result.is_pure);
}
#[test]
fn sandbox_permissive_accepts_everything_allowed() {
let sandbox = AstSandbox::new(SandboxMode::Permissive, 10);
let result = sandbox
.filter_sql("CREATE TABLE t (id INT); CREATE FUNCTION f() RETURNS INT LANGUAGE SQL RETURN 1", "generic")
.unwrap();
assert_eq!(result.allowed.len(), 1, "Permissive allows CREATE TABLE but blocks CREATE FUNCTION");
assert!(!result.denied.is_empty(), "Permissive denies function but not table");
assert!(!result.is_pure, "Permissive is not pure when function is denied by base filter");
}
#[test]
fn sandbox_permissive_no_nesting_check() {
let sandbox = AstSandbox::new(SandboxMode::Permissive, 0);
let result = sandbox.filter_sql("CREATE TABLE t (a INT DEFAULT ((((1)))))", "generic").unwrap();
assert!(!result.allowed.is_empty());
assert!(result.nesting_violations.is_empty());
assert!(result.is_pure);
}
#[test]
fn sandbox_stats_correct() {
let sandbox = AstSandbox::new(SandboxMode::Strict, 10);
let result = sandbox
.filter_sql(
"CREATE TABLE t1 (id INT); CREATE TABLE t2 (id INT); DROP TABLE t3; SELECT * FROM t1",
"generic",
)
.unwrap();
assert_eq!(result.stats.total_statements, 4);
assert_eq!(result.stats.allowed_count, 3);
assert_eq!(result.stats.denied_count, 1);
assert_eq!(result.stats.mode, "strict");
assert_eq!(result.stats.max_nesting_depth, 10);
}
}
+268
View File
@@ -0,0 +1,268 @@
use std::collections::HashSet;
use std::path::Path;
use crate::schema_diff::SchemaDiffPreparationOptions;
#[derive(Debug, Clone)]
pub struct DiffEntry {
pub file_path: String,
pub sql_content: String,
pub change_type: ChangeType,
pub old_path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChangeType {
Added,
Modified,
Deleted,
Renamed,
}
pub struct GitDiffScanner {
repo_path: String,
base_commit: String,
target_commit: String,
}
impl GitDiffScanner {
pub fn new(repo_path: &Path, base_commit: &str, target_commit: &str) -> Result<Self, String> {
if base_commit.is_empty() || target_commit.is_empty() {
return Err("Git commit refs cannot be empty".into());
}
let git_path = repo_path.join(".git");
let is_git_repo =
git_path.is_dir() || (git_path.is_file() && git_path.exists()) || repo_path.join("../.git").exists();
if !is_git_repo {
return Err(format!("Not a git repository: {}", repo_path.display()));
}
Ok(Self {
repo_path: repo_path.to_string_lossy().to_string(),
base_commit: base_commit.to_string(),
target_commit: target_commit.to_string(),
})
}
pub fn scan(&self) -> Result<Vec<DiffEntry>, String> {
let output = self.run_git_diff()?;
let sql_filter = self.build_sql_filter()?;
let raw_entries = self.parse_diff_output(&output, &sql_filter)?;
let entries = raw_entries
.into_iter()
.filter_map(|entry| {
if entry.change_type == ChangeType::Deleted {
return Some(entry);
}
let path = Path::new(&self.repo_path).join(&entry.file_path);
let content = std::fs::read_to_string(&path).ok()?;
Some(DiffEntry { sql_content: content, ..entry })
})
.collect();
Ok(entries)
}
fn run_git_diff(&self) -> Result<String, String> {
let output = std::process::Command::new("git")
.arg("diff")
.arg("--diff-filter=ACMR")
.arg(&self.base_commit)
.arg(&self.target_commit)
.arg("--")
.arg("*.sql")
.arg("*.ddl")
.current_dir(&self.repo_path)
.output()
.map_err(|e| format!("Failed to run git diff: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git diff failed: {stderr}"));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn build_sql_filter(&self) -> Result<HashSet<String>, String> {
let mut patterns = HashSet::new();
let gitattributes_path = Path::new(&self.repo_path).join(".gitattributes");
if gitattributes_path.exists() {
let content = std::fs::read_to_string(&gitattributes_path)
.map_err(|e| format!("Failed to read .gitattributes: {e}"))?;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 && parts[1] == "linguist-language=SQL" {
let pattern = parts[0].trim_matches('*');
if !pattern.is_empty() {
patterns.insert(pattern.to_string());
} else {
patterns.insert("sql".to_string());
}
}
}
}
if patterns.is_empty() {
patterns.insert("sql".to_string());
}
Ok(patterns)
}
fn parse_diff_output(&self, output: &str, _filter: &HashSet<String>) -> Result<Vec<DiffEntry>, String> {
let mut entries = Vec::new();
let mut current_file: Option<String> = None;
let mut current_status: Option<ChangeType> = None;
let mut current_old_path: Option<String> = None;
for line in output.lines() {
if line.starts_with("diff --git ") {
Self::flush_raw_entry(&mut current_file, &mut current_status, &mut current_old_path, &mut entries);
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 4 {
let b_path = parts.get(3).copied().unwrap_or("");
let b_path = b_path.trim_start_matches("b/");
current_file = Some(b_path.to_string());
current_status = Some(ChangeType::Modified);
}
} else if line.starts_with("--- ") {
let path = line.trim_start_matches("--- ").trim_start_matches("a/");
if path != "/dev/null" {
current_old_path = Some(path.to_string());
}
} else if line.starts_with("+++ ") {
let path = line.trim_start_matches("+++ ").trim_start_matches("b/");
if path != "/dev/null" {
current_file = Some(path.to_string());
}
} else if line.starts_with("new file mode") {
current_status = Some(ChangeType::Added);
} else if line.starts_with("deleted file mode") {
current_status = Some(ChangeType::Deleted);
} else if line.starts_with("rename from ") {
current_old_path = Some(line.trim_start_matches("rename from ").to_string());
} else if line.starts_with("rename to ") {
current_file = Some(line.trim_start_matches("rename to ").to_string());
current_status = Some(ChangeType::Renamed);
}
}
Self::flush_raw_entry(&mut current_file, &mut current_status, &mut current_old_path, &mut entries);
Ok(entries)
}
fn flush_raw_entry(
file: &mut Option<String>,
status: &mut Option<ChangeType>,
old_path: &mut Option<String>,
entries: &mut Vec<DiffEntry>,
) {
let file_path = file.take();
let st = status.take().unwrap_or(ChangeType::Modified);
let op = old_path.take();
if let Some(path) = file_path {
entries.push(DiffEntry { file_path: path, sql_content: String::new(), change_type: st, old_path: op });
}
}
pub fn bind_to_commit(options: SchemaDiffPreparationOptions, _commit_id: &str) -> SchemaDiffPreparationOptions {
options
}
pub fn list_commits(&self, max_count: usize) -> Result<Vec<String>, String> {
let output = std::process::Command::new("git")
.arg("log")
.arg("--oneline")
.arg("-n")
.arg(max_count.to_string())
.arg("--")
.arg("*.sql")
.arg("*.ddl")
.current_dir(&self.repo_path)
.output()
.map_err(|e| format!("Failed to run git log: {e}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).to_string());
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.lines().map(|l| l.to_string()).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_non_git_directory() {
let tmp = std::env::temp_dir().join("dbx_test_not_a_repo");
let _ = std::fs::create_dir_all(&tmp);
let result = GitDiffScanner::new(&tmp, "HEAD~1", "HEAD");
assert!(result.is_err());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn parses_diff_output_with_added_file() {
let scanner =
GitDiffScanner { repo_path: "/tmp/repo".into(), base_commit: "abc".into(), target_commit: "def".into() };
let output = "diff --git a/schema.sql b/schema.sql\nnew file mode 100644\n--- /dev/null\n+++ b/schema.sql\n@@ -0,0 +1 @@\n+CREATE TABLE t (id INT);\n";
let mut filter = HashSet::new();
filter.insert("sql".to_string());
let entries = scanner.parse_diff_output(output, &filter).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].file_path, "schema.sql");
assert_eq!(entries[0].change_type, ChangeType::Added);
}
#[test]
fn parses_diff_output_with_modified_file() {
let scanner =
GitDiffScanner { repo_path: "/tmp/repo".into(), base_commit: "abc".into(), target_commit: "def".into() };
let output = "diff --git a/schema.sql b/schema.sql\n--- a/schema.sql\n+++ b/schema.sql\n@@ -1 +1 @@\n-CREATE TABLE t (id INT);\n+CREATE TABLE t (id BIGINT);\n";
let mut filter = HashSet::new();
filter.insert("sql".to_string());
let entries = scanner.parse_diff_output(output, &filter).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].change_type, ChangeType::Modified);
}
#[test]
fn parses_diff_output_with_renamed_file() {
let scanner =
GitDiffScanner { repo_path: "/tmp/repo".into(), base_commit: "abc".into(), target_commit: "def".into() };
let output = "diff --git a/old.sql b/new.sql\nrename from old.sql\nrename to new.sql\n";
let mut filter = HashSet::new();
filter.insert("sql".to_string());
let entries = scanner.parse_diff_output(output, &filter).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].change_type, ChangeType::Renamed);
assert_eq!(entries[0].old_path, Some("old.sql".to_string()));
}
#[test]
fn parses_gitattributes_for_sql_patterns() {
let tmp = std::env::temp_dir().join("dbx_test_gitattributes");
let _ = std::fs::create_dir_all(&tmp);
let gitattributes = tmp.join(".gitattributes");
std::fs::write(&gitattributes, "*.sql linguist-language=SQL\n*.ddl linguist-language=SQL\n").unwrap();
let scanner = GitDiffScanner {
repo_path: tmp.to_string_lossy().to_string(),
base_commit: "abc".into(),
target_commit: "def".into(),
};
let filter = scanner.build_sql_filter().unwrap();
assert!(!filter.is_empty());
let _ = std::fs::remove_dir_all(&tmp);
}
}
+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);
}
}
+233
View File
@@ -0,0 +1,233 @@
use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MetaData {
#[serde(default)]
pub dialect: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub charset: Option<String>,
#[serde(default)]
pub collation: Option<String>,
#[serde(default)]
pub sql_mode: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub explicit_dependencies: HashMap<String, Vec<String>>,
}
pub struct MetaReader;
impl MetaReader {
pub fn from_file(path: &Path) -> Result<MetaData, String> {
let content = std::fs::read_to_string(path).map_err(|e| format!("Failed to read meta file: {e}"))?;
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
match ext {
"json" => Self::from_json(&content),
"yaml" | "yml" => Self::from_yaml(&content),
_ => Self::from_json(&content),
}
}
pub fn from_json(content: &str) -> Result<MetaData, String> {
serde_json::from_str(content).map_err(|e| format!("Invalid meta JSON: {e}"))
}
pub fn from_yaml(content: &str) -> Result<MetaData, String> {
serde_yaml::from_str(content).map_err(|e| format!("Invalid meta YAML: {e}"))
}
pub fn to_json(meta: &MetaData) -> Result<String, String> {
serde_json::to_string_pretty(meta).map_err(|e| format!("Serialize meta error: {e}"))
}
pub fn validate_consistency(meta: &MetaData) -> Vec<String> {
let mut warnings = Vec::new();
if let Some(ref dialect) = meta.dialect {
let lower = dialect.to_ascii_lowercase();
let known = [
"mysql",
"postgres",
"postgresql",
"sqlite",
"mssql",
"sqlserver",
"clickhouse",
"duckdb",
"oracle",
"tidb",
"mariadb",
];
if !known.contains(&lower.as_str()) {
warnings.push(format!("Unknown dialect '{}' in metadata", meta.dialect.as_ref().unwrap()));
}
}
if let Some(ref version) = meta.version {
let cleaned = version.trim_start_matches(|c: char| c.is_ascii_alphabetic() || c == 'v' || c == 'V');
let is_valid = !cleaned.is_empty()
&& cleaned.chars().all(|c| c.is_ascii_digit() || c == '.' || c == '-')
&& cleaned.chars().any(|c| c.is_ascii_digit());
if !is_valid {
warnings.push(format!("Unusual version format '{}'", version));
}
}
warnings
}
}
pub fn validate_sql_meta_consistency(sql: &str, meta: &MetaData) -> Vec<String> {
let mut warnings = Vec::new();
if let Some(ref charset) = meta.charset {
if !charset_mentions_match(sql, charset, meta.dialect.as_deref()) {
warnings.push(format!("SQL content does not reference charset '{}' from metadata", charset));
}
}
warnings
}
fn charset_mentions_match(sql: &str, charset: &str, dialect: Option<&str>) -> bool {
let sql_upper = sql.to_ascii_uppercase();
let charset_upper = charset.to_ascii_uppercase();
let is_pg = dialect.is_some_and(|d| matches!(d.to_ascii_lowercase().as_str(), "postgres" | "postgresql"));
if is_pg {
sql_upper.contains(&format!("ENCODING '{}'", charset_upper))
|| sql_upper.contains(&format!("ENCODING '{}'", charset_upper.to_ascii_lowercase()))
} else {
let pattern = format!("CHARACTER SET {}", charset_upper);
sql_upper.contains(&pattern) || sql_upper.contains(&format!("CHARSET {}", charset_upper))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_valid_json_metadata() {
let json = r#"{
"dialect": "mysql",
"version": "8.0",
"charset": "utf8mb4",
"explicitDependencies": {
"view_orders": ["orders", "users"]
}
}"#;
let meta = MetaReader::from_json(json).unwrap();
assert_eq!(meta.dialect.as_deref(), Some("mysql"));
assert_eq!(meta.version.as_deref(), Some("8.0"));
assert_eq!(
meta.explicit_dependencies.get("view_orders").map(|v| v.as_slice()),
Some(&["orders".to_string(), "users".to_string()][..])
);
}
#[test]
fn reads_valid_yaml_metadata() {
let yaml = r#"
dialect: postgres
version: "15"
charset: UTF8
explicitDependencies:
expensive_view: [table_a, table_b]
"#;
let meta = MetaReader::from_yaml(yaml).unwrap();
assert_eq!(meta.dialect.as_deref(), Some("postgres"));
assert_eq!(meta.explicit_dependencies.get("expensive_view").map(|v| v.len()), Some(2));
}
#[test]
fn validates_known_dialect() {
let meta = MetaData {
dialect: Some("mysql".into()),
version: Some("8.0".into()),
charset: None,
collation: None,
sql_mode: None,
explicit_dependencies: HashMap::new(),
};
assert!(MetaReader::validate_consistency(&meta).is_empty());
}
#[test]
fn warns_unknown_dialect() {
let meta = MetaData {
dialect: Some("fakedb".into()),
version: None,
charset: None,
collation: None,
sql_mode: None,
explicit_dependencies: HashMap::new(),
};
let warnings = MetaReader::validate_consistency(&meta);
assert!(!warnings.is_empty());
assert!(warnings[0].contains("fakedb"));
}
#[test]
fn warns_unusual_version_format() {
let meta = MetaData {
dialect: Some("mysql".into()),
version: Some("latest".into()),
charset: None,
collation: None,
sql_mode: None,
explicit_dependencies: HashMap::new(),
};
let warnings = MetaReader::validate_consistency(&meta);
assert!(!warnings.is_empty());
assert!(warnings[0].contains("latest"));
}
#[test]
fn charset_consistency_check() {
let meta = MetaData {
dialect: Some("mysql".into()),
version: Some("8.0".into()),
charset: Some("utf8mb4".into()),
collation: None,
sql_mode: None,
explicit_dependencies: HashMap::new(),
};
let sql = "CREATE TABLE t (c VARCHAR(100)) CHARACTER SET utf8mb4";
assert!(validate_sql_meta_consistency(sql, &meta).is_empty());
let sql_no_charset = "CREATE TABLE t (c VARCHAR(100))";
let warnings = validate_sql_meta_consistency(sql_no_charset, &meta);
assert!(!warnings.is_empty());
assert!(warnings[0].contains("utf8mb4"));
}
#[test]
fn empty_meta_has_no_warnings() {
let meta = MetaData {
dialect: None,
version: None,
charset: None,
collation: None,
sql_mode: None,
explicit_dependencies: HashMap::new(),
};
assert!(MetaReader::validate_consistency(&meta).is_empty());
}
#[test]
fn serializes_and_deserializes_roundtrip() {
let meta = MetaData {
dialect: Some("postgres".into()),
version: Some("16".into()),
charset: Some("UTF8".into()),
collation: Some("en_US.UTF-8".into()),
sql_mode: None,
explicit_dependencies: HashMap::from([("v".into(), vec!["a".into(), "b".into()])]),
};
let json = MetaReader::to_json(&meta).unwrap();
let restored = MetaReader::from_json(&json).unwrap();
assert_eq!(restored.dialect, meta.dialect);
assert_eq!(restored.version, meta.version);
assert_eq!(restored.explicit_dependencies.len(), 1);
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod ast_filter;
pub mod git;
pub mod input;
pub mod meta;