Skip to main content

oxisql_parse/
validate.rs

1//! Schema-aware validation of [`LogicalPlan`] trees.
2//!
3//! [`SchemaValidator`] walks a plan produced by [`crate::plan_statement`] and
4//! checks that every table and column reference names a known entity in the
5//! caller-supplied schema catalogue.
6
7use std::collections::{HashMap, HashSet};
8
9use crate::LogicalPlan;
10
11// ── Error type ───────────────────────────────────────────────────────────────
12
13/// Errors returned by [`SchemaValidator::validate`].
14#[derive(Debug, Clone, PartialEq, thiserror::Error)]
15pub enum ValidationError {
16    /// A `Scan` or `InSubquery` references a table that does not exist.
17    #[error("table not found: {0}")]
18    TableNotFound(String),
19    /// A `Project` references a column that does not exist in any accessible
20    /// table.
21    #[error("column not found: {table}.{column}")]
22    ColumnNotFound {
23        /// The table the column was expected in.
24        table: String,
25        /// The missing column name.
26        column: String,
27    },
28    /// A bare column name matches more than one table in scope.
29    #[error("ambiguous column: {0} (found in multiple tables)")]
30    AmbiguousColumn(String),
31}
32
33// ── Validator ────────────────────────────────────────────────────────────────
34
35/// Validates a [`LogicalPlan`] against a schema catalogue.
36///
37/// Build the validator with [`SchemaValidator::new`], register tables via
38/// [`SchemaValidator::add_table`], then call [`SchemaValidator::validate`].
39///
40/// # Example
41///
42/// ```rust
43/// use oxisql_parse::{parse_one, plan_statement, SchemaValidator};
44///
45/// let mut v = SchemaValidator::new();
46/// v.add_table("users", &["id", "name", "age"]);
47///
48/// let stmt = parse_one("SELECT * FROM users").unwrap();
49/// let plan = plan_statement(&stmt).unwrap();
50/// assert!(v.validate(&plan).is_ok());
51/// ```
52pub struct SchemaValidator {
53    /// Maps table name (lower-cased) to set of column names (lower-cased).
54    schema: HashMap<String, HashSet<String>>,
55}
56
57impl SchemaValidator {
58    /// Create a new, empty `SchemaValidator`.
59    pub fn new() -> Self {
60        Self {
61            schema: HashMap::new(),
62        }
63    }
64
65    /// Register a table with its column list.
66    ///
67    /// Both `name` and every element of `columns` are stored in lower-case so
68    /// comparisons are case-insensitive.
69    pub fn add_table(&mut self, name: &str, columns: &[&str]) {
70        let cols: HashSet<String> = columns.iter().map(|c| c.to_ascii_lowercase()).collect();
71        self.schema.insert(name.to_ascii_lowercase(), cols);
72    }
73
74    /// Validate `plan` against the registered schema.
75    ///
76    /// Returns `Ok(())` when all referenced tables and columns are known.
77    ///
78    /// # Errors
79    ///
80    /// Returns the first [`ValidationError`] encountered during the tree walk.
81    pub fn validate(&self, plan: &LogicalPlan) -> Result<(), ValidationError> {
82        self.validate_scans(plan)?;
83        self.validate_projections(plan, &[])
84    }
85
86    /// Recursively check that every `Scan` node references a known table.
87    fn validate_scans(&self, plan: &LogicalPlan) -> Result<(), ValidationError> {
88        match plan {
89            LogicalPlan::Scan { table, .. } => {
90                let key = table.to_ascii_lowercase();
91                if !self.schema.contains_key(&key) {
92                    return Err(ValidationError::TableNotFound(table.clone()));
93                }
94            }
95            LogicalPlan::Filter { input, .. }
96            | LogicalPlan::Project { input, .. }
97            | LogicalPlan::Sort { input, .. }
98            | LogicalPlan::Limit { input, .. }
99            | LogicalPlan::Aggregate { input, .. }
100            | LogicalPlan::Window { input, .. }
101            | LogicalPlan::Compute { input, .. } => {
102                self.validate_scans(input)?;
103            }
104            LogicalPlan::Join { left, right, .. } | LogicalPlan::SetOp { left, right, .. } => {
105                self.validate_scans(left)?;
106                self.validate_scans(right)?;
107            }
108            LogicalPlan::Cte { query, .. } => {
109                self.validate_scans(query)?;
110            }
111            LogicalPlan::Subquery { query, .. }
112            | LogicalPlan::Exists {
113                subquery: query, ..
114            }
115            | LogicalPlan::InSubquery {
116                subquery: query, ..
117            } => {
118                self.validate_scans(query)?;
119            }
120            LogicalPlan::Values { .. } | LogicalPlan::Empty | LogicalPlan::CteRef { .. } => {}
121        }
122        Ok(())
123    }
124
125    /// Collect all table names reachable from `plan` via `Scan` nodes.
126    fn collect_scan_tables<'a>(&self, plan: &'a LogicalPlan, out: &mut Vec<&'a str>) {
127        match plan {
128            LogicalPlan::Scan { table, .. } => out.push(table.as_str()),
129            LogicalPlan::Filter { input, .. }
130            | LogicalPlan::Project { input, .. }
131            | LogicalPlan::Sort { input, .. }
132            | LogicalPlan::Limit { input, .. }
133            | LogicalPlan::Aggregate { input, .. }
134            | LogicalPlan::Window { input, .. }
135            | LogicalPlan::Compute { input, .. } => {
136                self.collect_scan_tables(input, out);
137            }
138            LogicalPlan::Join { left, right, .. } | LogicalPlan::SetOp { left, right, .. } => {
139                self.collect_scan_tables(left, out);
140                self.collect_scan_tables(right, out);
141            }
142            LogicalPlan::Cte { query, .. } => self.collect_scan_tables(query, out),
143            LogicalPlan::Subquery { query, .. }
144            | LogicalPlan::Exists {
145                subquery: query, ..
146            }
147            | LogicalPlan::InSubquery {
148                subquery: query, ..
149            } => {
150                self.collect_scan_tables(query, out);
151            }
152            LogicalPlan::Values { .. } | LogicalPlan::Empty | LogicalPlan::CteRef { .. } => {}
153        }
154    }
155
156    /// Validate column references inside `Project` nodes.
157    ///
158    /// `accessible_tables` carries table names visible from ancestor nodes.
159    fn validate_projections(
160        &self,
161        plan: &LogicalPlan,
162        accessible_tables: &[&str],
163    ) -> Result<(), ValidationError> {
164        match plan {
165            LogicalPlan::Project { input, columns } => {
166                // Gather tables accessible to this project.
167                let mut tables: Vec<&str> = accessible_tables.to_vec();
168                self.collect_scan_tables(input, &mut tables);
169
170                for col_expr in columns {
171                    // Strip aliases (e.g. `"col AS c"` → check `"col"`).
172                    let raw = col_expr
173                        .split_whitespace()
174                        .next()
175                        .unwrap_or(col_expr.as_str());
176                    // Qualified: `table.column`
177                    if let Some((tbl, col)) = raw.split_once('.') {
178                        let tbl_key = tbl.trim_matches('"').to_ascii_lowercase();
179                        let col_key = col.trim_matches('"').to_ascii_lowercase();
180                        if let Some(known_cols) = self.schema.get(&tbl_key) {
181                            if !known_cols.contains(&col_key) {
182                                return Err(ValidationError::ColumnNotFound {
183                                    table: tbl.to_string(),
184                                    column: col.to_string(),
185                                });
186                            }
187                        }
188                        // If table not in schema we skip (scan validation covers it).
189                    } else {
190                        // Unqualified column — skip wildcards and function calls.
191                        let col_key = raw.trim_matches('"').to_ascii_lowercase();
192                        if col_key == "*" || col_key.contains('(') {
193                            continue;
194                        }
195                        // Count how many tables have this column.
196                        let matches: Vec<&str> = tables
197                            .iter()
198                            .filter(|&&t| {
199                                let tk = t.to_ascii_lowercase();
200                                self.schema
201                                    .get(&tk)
202                                    .map(|cols| cols.contains(&col_key))
203                                    .unwrap_or(false)
204                            })
205                            .copied()
206                            .collect();
207                        if matches.len() > 1 {
208                            return Err(ValidationError::AmbiguousColumn(raw.to_string()));
209                        }
210                        // If zero matches and tables are known, report missing.
211                        if matches.is_empty()
212                            && !tables.is_empty()
213                            && tables
214                                .iter()
215                                .all(|t| self.schema.contains_key(&t.to_ascii_lowercase()))
216                        {
217                            let first_table = tables.first().copied().unwrap_or("unknown");
218                            return Err(ValidationError::ColumnNotFound {
219                                table: first_table.to_string(),
220                                column: raw.to_string(),
221                            });
222                        }
223                    }
224                }
225                self.validate_projections(input, accessible_tables)
226            }
227            LogicalPlan::Filter { input, .. }
228            | LogicalPlan::Sort { input, .. }
229            | LogicalPlan::Limit { input, .. }
230            | LogicalPlan::Aggregate { input, .. }
231            | LogicalPlan::Window { input, .. }
232            | LogicalPlan::Compute { input, .. } => {
233                self.validate_projections(input, accessible_tables)
234            }
235            LogicalPlan::Join { left, right, .. } | LogicalPlan::SetOp { left, right, .. } => {
236                self.validate_projections(left, accessible_tables)?;
237                self.validate_projections(right, accessible_tables)
238            }
239            LogicalPlan::Cte { query, .. } => self.validate_projections(query, accessible_tables),
240            LogicalPlan::Subquery { query, .. }
241            | LogicalPlan::Exists {
242                subquery: query, ..
243            }
244            | LogicalPlan::InSubquery {
245                subquery: query, ..
246            } => self.validate_projections(query, accessible_tables),
247            LogicalPlan::Scan { .. }
248            | LogicalPlan::Values { .. }
249            | LogicalPlan::Empty
250            | LogicalPlan::CteRef { .. } => Ok(()),
251        }
252    }
253}
254
255impl Default for SchemaValidator {
256    fn default() -> Self {
257        Self::new()
258    }
259}
260
261// ── Tests ────────────────────────────────────────────────────────────────────
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::{parse_one, plan_statement};
267
268    fn make_scan(table: &str) -> LogicalPlan {
269        LogicalPlan::Scan {
270            table: table.to_string(),
271            alias: None,
272            limit: None,
273        }
274    }
275
276    /// Known table → Ok.
277    #[test]
278    fn test_validate_known_table() {
279        let mut v = SchemaValidator::new();
280        v.add_table("users", &["id", "name"]);
281        let plan = make_scan("users");
282        assert!(v.validate(&plan).is_ok());
283    }
284
285    /// Unknown table → TableNotFound.
286    #[test]
287    fn test_validate_unknown_table() {
288        let v = SchemaValidator::new();
289        let plan = make_scan("orders");
290        assert_eq!(
291            v.validate(&plan),
292            Err(ValidationError::TableNotFound("orders".to_string()))
293        );
294    }
295
296    /// Scan with Filter over a known table → Ok.
297    #[test]
298    fn test_validate_filter_known() {
299        let mut v = SchemaValidator::new();
300        v.add_table("users", &["id", "name", "age"]);
301        let plan = LogicalPlan::Filter {
302            input: Box::new(make_scan("users")),
303            predicate: "age > 18".to_string(),
304        };
305        assert!(v.validate(&plan).is_ok());
306    }
307
308    /// Parse → plan → validate: all tables known → Ok.
309    #[test]
310    fn test_validate_full_pipeline() {
311        let mut v = SchemaValidator::new();
312        v.add_table("users", &["id", "name", "age"]);
313
314        let stmt = parse_one("SELECT * FROM users WHERE age > 18").expect("parse");
315        let plan = plan_statement(&stmt).expect("plan");
316        assert!(v.validate(&plan).is_ok());
317    }
318
319    /// Parse → plan → validate: table not in schema → TableNotFound.
320    #[test]
321    fn test_validate_full_pipeline_unknown() {
322        let v = SchemaValidator::new(); // empty schema
323
324        let stmt = parse_one("SELECT * FROM orders").expect("parse");
325        let plan = plan_statement(&stmt).expect("plan");
326        assert_eq!(
327            v.validate(&plan),
328            Err(ValidationError::TableNotFound("orders".to_string()))
329        );
330    }
331
332    // ── Subquery plan tests ─────────────────────────────────────────────────
333
334    /// Scalar subquery in SELECT projection → Subquery node in plan.
335    #[test]
336    fn test_plan_subquery_in_select() {
337        let sql = "SELECT (SELECT COUNT(*) FROM orders \
338                   WHERE orders.user_id = users.id) AS cnt FROM users";
339        let stmt = parse_one(sql).expect("parse");
340        let plan = plan_statement(&stmt).expect("plan");
341        fn has_subquery(p: &LogicalPlan) -> bool {
342            match p {
343                LogicalPlan::Subquery { query, .. } => {
344                    // Count as found even if we recurse deeper.
345                    let _ = has_subquery(query);
346                    true
347                }
348                LogicalPlan::Project { input, .. }
349                | LogicalPlan::Filter { input, .. }
350                | LogicalPlan::Sort { input, .. }
351                | LogicalPlan::Limit { input, .. }
352                | LogicalPlan::Aggregate { input, .. }
353                | LogicalPlan::Window { input, .. } => has_subquery(input),
354                _ => false,
355            }
356        }
357        let found = matches!(&plan, LogicalPlan::Subquery { .. }) || has_subquery(&plan);
358        assert!(found, "expected a Subquery node in: {plan:?}");
359    }
360
361    /// EXISTS subquery → Exists { negated: false }.
362    #[test]
363    fn test_plan_exists() {
364        let sql = "SELECT * FROM users WHERE EXISTS \
365                   (SELECT 1 FROM orders WHERE orders.user_id = users.id)";
366        let stmt = parse_one(sql).expect("parse");
367        let plan = plan_statement(&stmt).expect("plan");
368        match &plan {
369            LogicalPlan::Exists { negated, .. } => {
370                assert!(!negated, "EXISTS should have negated=false");
371            }
372            other => panic!("expected Exists at root, got: {other:?}"),
373        }
374    }
375
376    /// NOT EXISTS subquery → Exists { negated: true }.
377    #[test]
378    fn test_plan_not_exists() {
379        let sql = "SELECT * FROM users WHERE NOT EXISTS \
380                   (SELECT 1 FROM orders WHERE orders.user_id = users.id)";
381        let stmt = parse_one(sql).expect("parse");
382        let plan = plan_statement(&stmt).expect("plan");
383        match &plan {
384            LogicalPlan::Exists { negated, .. } => {
385                assert!(negated, "NOT EXISTS should have negated=true");
386            }
387            other => panic!("expected Exists at root, got: {other:?}"),
388        }
389    }
390
391    /// IN (subquery) → InSubquery { negated: false }.
392    #[test]
393    fn test_plan_in_subquery() {
394        let sql = "SELECT * FROM users WHERE id IN (SELECT user_id FROM orders)";
395        let stmt = parse_one(sql).expect("parse");
396        let plan = plan_statement(&stmt).expect("plan");
397        match &plan {
398            LogicalPlan::InSubquery { negated, .. } => {
399                assert!(!negated, "IN should have negated=false");
400            }
401            other => panic!("expected InSubquery at root, got: {other:?}"),
402        }
403    }
404
405    /// explain() on a hand-built Subquery plan produces non-empty output.
406    #[test]
407    fn test_explain_subquery() {
408        let plan = LogicalPlan::Subquery {
409            query: Box::new(LogicalPlan::Scan {
410                table: "orders".to_string(),
411                alias: None,
412                limit: None,
413            }),
414            alias: Some("cnt".to_string()),
415        };
416        let text = crate::explain(&plan);
417        assert!(!text.is_empty(), "explain output should not be empty");
418        assert!(text.contains("Subquery"), "expected 'Subquery' in: {text}");
419    }
420}