feat(core): split foundational capabilities into dedicated crates
This commit is contained in:
402 files changed
+4386
-3367
No files matched your search
@@ -0,0 +1,443 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlparser::ast::Statement;
|
||||
use sqlparser::dialect::GenericDialect;
|
||||
use sqlparser::parser::Parser;
|
||||
|
||||
use crate::schema_diff::SchemaDiffPreparationOptions;
|
||||
|
||||
#[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 { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// Pass-through for schema-diff preparation options.
|
||||
///
|
||||
/// Routine definitions are compared as metadata and must be preserved here.
|
||||
/// Dangerous statement bodies are still blocked by `filter_sql` /
|
||||
/// `is_dangerous_body_node` when SQL is whitelisted for transmit/sync.
|
||||
pub fn filter_diff_preparation_options(
|
||||
options: SchemaDiffPreparationOptions,
|
||||
_dialect: &str,
|
||||
) -> SchemaDiffPreparationOptions {
|
||||
options
|
||||
}
|
||||
}
|
||||
|
||||
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::*;
|
||||
use crate::types::FunctionInfo;
|
||||
|
||||
#[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_preserves_routine_definitions() {
|
||||
let opts = SchemaDiffPreparationOptions {
|
||||
source_functions: vec![
|
||||
FunctionInfo {
|
||||
name: "f1".into(),
|
||||
definition: "CREATE FUNCTION f1() RETURNS INT BEGIN RETURN 1; END".into(),
|
||||
function_type: "FUNCTION".into(),
|
||||
data_type: "int".into(),
|
||||
arguments: "".into(),
|
||||
},
|
||||
FunctionInfo {
|
||||
name: "p1".into(),
|
||||
definition: "CREATE PROCEDURE p1() BEGIN SELECT 1; END".into(),
|
||||
function_type: "PROCEDURE".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(), 2);
|
||||
assert_eq!(filtered.source_functions[0].name, "f1");
|
||||
assert_eq!(filtered.source_functions[1].name, "p1");
|
||||
assert!(filtered.source_functions[0].definition.contains("BEGIN"));
|
||||
assert!(filtered.source_functions[1].definition.contains("PROCEDURE"));
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod ast_filter;
|
||||
pub mod git;
|
||||
pub mod input;
|
||||
pub mod meta;
|
||||
Reference in new issue
Block a user