Files
dbx/crates/dbx-core/src/sql_parser/meta.rs
T

234 lines
7.8 KiB
Rust

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);
}
}