use ahash::AHashSet;
use scythe_core::analyzer::AnalyzedColumn;
#[derive(Debug, Clone)]
pub struct TypeOverride {
pub column: Option<String>,
pub db_type: Option<String>,
pub neutral_type: Option<String>,
}
impl TypeOverride {
pub fn matches(&self, column_match: &str, col_neutral_type: &str) -> bool {
if let Some(ref col) = self.column
&& col == column_match
{
return true;
}
if let Some(ref dt) = self.db_type {
return dt.eq_ignore_ascii_case(col_neutral_type);
}
false
}
}
pub fn column_references<'a>(columns: impl Iterator<Item = &'a AnalyzedColumn>) -> AHashSet<String> {
columns
.filter_map(|col| {
col.source_relation
.as_deref()
.map(|relation| format!("{relation}.{}", col.name))
})
.collect()
}
pub fn unmatched_column_overrides<'a>(
overrides: &'a [TypeOverride],
known_references: &AHashSet<String>,
) -> Vec<&'a TypeOverride> {
overrides
.iter()
.filter(|o| matches!(&o.column, Some(c) if !known_references.contains(c)))
.collect()
}
pub fn find_override<'a>(overrides: &'a [TypeOverride], column_match: &str, col_neutral_type: &str) -> Option<&'a str> {
overrides.iter().find_map(|o| {
if o.matches(column_match, col_neutral_type) {
o.neutral_type.as_deref()
} else {
None
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_column_override_matches() {
let o = TypeOverride {
column: Some("users.metadata".to_string()),
db_type: None,
neutral_type: Some("json".to_string()),
};
assert!(o.matches("users.metadata", "jsonb"));
assert!(!o.matches("posts.metadata", "jsonb"));
}
#[test]
fn test_db_type_override_matches() {
let o = TypeOverride {
column: None,
db_type: Some("ltree".to_string()),
neutral_type: Some("string".to_string()),
};
assert!(o.matches("", "ltree"));
assert!(o.matches("any.col", "LTREE"));
assert!(!o.matches("any.col", "text"));
}
#[test]
fn test_column_takes_priority_over_db_type() {
let o = TypeOverride {
column: Some("users.name".to_string()),
db_type: Some("text".to_string()),
neutral_type: Some("custom".to_string()),
};
assert!(o.matches("users.name", "int32"));
assert!(!o.matches("other.name", "int32"));
}
#[test]
fn test_column_miss_falls_through_to_db_type() {
let o = TypeOverride {
column: Some("users.name".to_string()),
db_type: Some("text".to_string()),
neutral_type: Some("custom".to_string()),
};
assert!(o.matches("other.name", "text"));
assert!(o.matches("other.name", "TEXT"));
}
#[test]
fn test_find_override_first_match_wins() {
let overrides = vec![
TypeOverride {
column: Some("users.metadata".to_string()),
db_type: None,
neutral_type: Some("json".to_string()),
},
TypeOverride {
column: None,
db_type: Some("jsonb".to_string()),
neutral_type: Some("string".to_string()),
},
];
assert_eq!(find_override(&overrides, "users.metadata", "jsonb"), Some("json"));
assert_eq!(find_override(&overrides, "posts.data", "jsonb"), Some("string"));
assert_eq!(find_override(&overrides, "posts.data", "text"), None);
}
#[test]
fn test_find_override_empty_list() {
assert_eq!(find_override(&[], "users.id", "int32"), None);
}
fn column(name: &str, relation: Option<&str>) -> AnalyzedColumn {
AnalyzedColumn {
name: name.to_string(),
neutral_type: "string".to_string(),
source_relation: relation.map(str::to_string),
..Default::default()
}
}
#[test]
fn test_column_references_skips_columns_with_no_source_relation() {
let columns = [column("id", Some("users")), column("total", None)];
let refs = column_references(columns.iter());
assert_eq!(refs.len(), 1);
assert!(refs.contains("users.id"));
}
#[test]
fn test_unmatched_column_overrides_reports_only_the_typo() {
let known = column_references([column("id", Some("users"))].iter());
let overrides = vec![
TypeOverride {
column: Some("users.id".to_string()),
db_type: None,
neutral_type: Some("string".to_string()),
},
TypeOverride {
column: Some("users.emial".to_string()),
db_type: None,
neutral_type: Some("string".to_string()),
},
TypeOverride {
column: None,
db_type: Some("jsonb".to_string()),
neutral_type: Some("string".to_string()),
},
];
let unmatched = unmatched_column_overrides(&overrides, &known);
assert_eq!(unmatched.len(), 1);
assert_eq!(unmatched[0].column.as_deref(), Some("users.emial"));
}
}