qail_core/
validator.rs

1//! Schema validator and fuzzy matching suggestions.
2
3use std::collections::HashMap;
4use strsim::levenshtein;
5
6/// Validates query elements against known schema and provides suggestions.
7#[derive(Debug, Clone)]
8pub struct Validator {
9    tables: Vec<String>,
10    columns: HashMap<String, Vec<String>>,
11}
12
13impl Validator {
14    /// Create a new Validator with known tables and columns.
15    pub fn new() -> Self {
16        Self {
17            tables: Vec::new(),
18            columns: HashMap::new(),
19        }
20    }
21
22    /// Register a table and its columns.
23    pub fn add_table(&mut self, table: &str, cols: &[&str]) {
24        self.tables.push(table.to_string());
25        self.columns.insert(
26            table.to_string(),
27            cols.iter().map(|s| s.to_string()).collect(),
28        );
29    }
30
31    /// Check if a table exists. If not, returns suggested names.
32    pub fn validate_table(&self, table: &str) -> Result<(), String> {
33        if self.tables.contains(&table.to_string()) {
34            Ok(())
35        } else {
36            let suggestions = self.did_you_mean(table, &self.tables);
37            if let Some(sugg) = suggestions {
38                Err(format!("Table '{}' not found. Did you mean '{}'?", table, sugg))
39            } else {
40                Err(format!("Table '{}' not found.", table))
41            }
42        }
43    }
44
45    /// Check if a column exists in a table. If not, returns suggested names.
46    pub fn validate_column(&self, table: &str, column: &str) -> Result<(), String> {
47        // If table doesn't exist, we can't validate column
48        if !self.tables.contains(&table.to_string()) {
49            return Ok(());
50        }
51
52        if let Some(cols) = self.columns.get(table) {
53            // Check literal match
54            if cols.contains(&column.to_string()) || column == "*" {
55                return Ok(());
56            }
57
58            // Fuzzy match
59            let suggestions = self.did_you_mean(column, cols);
60            if let Some(sugg) = suggestions {
61                Err(format!(
62                    "Column '{}' not found in table '{}'. Did you mean '{}'?",
63                    column, table, sugg
64                ))
65            } else {
66                Err(format!("Column '{}' not found in table '{}'.", column, table))
67            }
68        } else {
69            Ok(())
70        }
71    }
72
73    /// Find the best match with Levenshtein distance <= 3.
74    fn did_you_mean(&self, input: &str, candidates: &[impl AsRef<str>]) -> Option<String> {
75        let mut best_match = None;
76        let mut min_dist = usize::MAX;
77
78        for cand in candidates {
79            let cand_str = cand.as_ref();
80            let dist = levenshtein(input, cand_str);
81
82            // Dynamic threshold based on length
83            let threshold = match input.len() {
84                0..=2 => 0,      // Precise match only
85                3..=5 => 2,      // Allow 2 char diff (e.g. usr -> users)
86                _ => 3,          // Allow 3 char diff
87            };
88
89            if dist <= threshold && dist < min_dist {
90                min_dist = dist;
91                best_match = Some(cand_str.to_string());
92            }
93        }
94
95        best_match
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn test_did_you_mean_table() {
105        let mut v = Validator::new();
106        v.add_table("users", &["id", "name"]);
107        v.add_table("orders", &["id", "total"]);
108
109        assert!(v.validate_table("users").is_ok());
110        
111        let err = v.validate_table("usr").unwrap_err();
112        assert!(err.contains("Did you mean 'users'?")); // distance 2
113
114        let err = v.validate_table("usrs").unwrap_err();
115        assert!(err.contains("Did you mean 'users'?")); // distance 1
116    }
117
118    #[test]
119    fn test_did_you_mean_column() {
120        let mut v = Validator::new();
121        v.add_table("users", &["email", "password"]);
122
123        assert!(v.validate_column("users", "email").is_ok());
124
125        let err = v.validate_column("users", "emial").unwrap_err();
126        assert!(err.contains("Did you mean 'email'?"));
127    }
128}