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                self.validate_scans(input)?;
102            }
103            LogicalPlan::Join { left, right, .. } | LogicalPlan::SetOp { left, right, .. } => {
104                self.validate_scans(left)?;
105                self.validate_scans(right)?;
106            }
107            LogicalPlan::Cte { query, .. } => {
108                self.validate_scans(query)?;
109            }
110            LogicalPlan::Subquery { query, .. }
111            | LogicalPlan::Exists {
112                subquery: query, ..
113            }
114            | LogicalPlan::InSubquery {
115                subquery: query, ..
116            } => {
117                self.validate_scans(query)?;
118            }
119            LogicalPlan::Values { .. } | LogicalPlan::Empty | LogicalPlan::CteRef { .. } => {}
120        }
121        Ok(())
122    }
123
124    /// Collect all table names reachable from `plan` via `Scan` nodes.
125    fn collect_scan_tables<'a>(&self, plan: &'a LogicalPlan, out: &mut Vec<&'a str>) {
126        match plan {
127            LogicalPlan::Scan { table, .. } => out.push(table.as_str()),
128            LogicalPlan::Filter { input, .. }
129            | LogicalPlan::Project { input, .. }
130            | LogicalPlan::Sort { input, .. }
131            | LogicalPlan::Limit { input, .. }
132            | LogicalPlan::Aggregate { input, .. }
133            | LogicalPlan::Window { input, .. } => {
134                self.collect_scan_tables(input, out);
135            }
136            LogicalPlan::Join { left, right, .. } | LogicalPlan::SetOp { left, right, .. } => {
137                self.collect_scan_tables(left, out);
138                self.collect_scan_tables(right, out);
139            }
140            LogicalPlan::Cte { query, .. } => self.collect_scan_tables(query, out),
141            LogicalPlan::Subquery { query, .. }
142            | LogicalPlan::Exists {
143                subquery: query, ..
144            }
145            | LogicalPlan::InSubquery {
146                subquery: query, ..
147            } => {
148                self.collect_scan_tables(query, out);
149            }
150            LogicalPlan::Values { .. } | LogicalPlan::Empty | LogicalPlan::CteRef { .. } => {}
151        }
152    }
153
154    /// Validate column references inside `Project` nodes.
155    ///
156    /// `accessible_tables` carries table names visible from ancestor nodes.
157    fn validate_projections(
158        &self,
159        plan: &LogicalPlan,
160        accessible_tables: &[&str],
161    ) -> Result<(), ValidationError> {
162        match plan {
163            LogicalPlan::Project { input, columns } => {
164                // Gather tables accessible to this project.
165                let mut tables: Vec<&str> = accessible_tables.to_vec();
166                self.collect_scan_tables(input, &mut tables);
167
168                for col_expr in columns {
169                    // Strip aliases (e.g. `"col AS c"` → check `"col"`).
170                    let raw = col_expr
171                        .split_whitespace()
172                        .next()
173                        .unwrap_or(col_expr.as_str());
174                    // Qualified: `table.column`
175                    if let Some((tbl, col)) = raw.split_once('.') {
176                        let tbl_key = tbl.trim_matches('"').to_ascii_lowercase();
177                        let col_key = col.trim_matches('"').to_ascii_lowercase();
178                        if let Some(known_cols) = self.schema.get(&tbl_key) {
179                            if !known_cols.contains(&col_key) {
180                                return Err(ValidationError::ColumnNotFound {
181                                    table: tbl.to_string(),
182                                    column: col.to_string(),
183                                });
184                            }
185                        }
186                        // If table not in schema we skip (scan validation covers it).
187                    } else {
188                        // Unqualified column — skip wildcards and function calls.
189                        let col_key = raw.trim_matches('"').to_ascii_lowercase();
190                        if col_key == "*" || col_key.contains('(') {
191                            continue;
192                        }
193                        // Count how many tables have this column.
194                        let matches: Vec<&str> = tables
195                            .iter()
196                            .filter(|&&t| {
197                                let tk = t.to_ascii_lowercase();
198                                self.schema
199                                    .get(&tk)
200                                    .map(|cols| cols.contains(&col_key))
201                                    .unwrap_or(false)
202                            })
203                            .copied()
204                            .collect();
205                        if matches.len() > 1 {
206                            return Err(ValidationError::AmbiguousColumn(raw.to_string()));
207                        }
208                        // If zero matches and tables are known, report missing.
209                        if matches.is_empty()
210                            && !tables.is_empty()
211                            && tables
212                                .iter()
213                                .all(|t| self.schema.contains_key(&t.to_ascii_lowercase()))
214                        {
215                            let first_table = tables.first().copied().unwrap_or("unknown");
216                            return Err(ValidationError::ColumnNotFound {
217                                table: first_table.to_string(),
218                                column: raw.to_string(),
219                            });
220                        }
221                    }
222                }
223                self.validate_projections(input, accessible_tables)
224            }
225            LogicalPlan::Filter { input, .. }
226            | LogicalPlan::Sort { input, .. }
227            | LogicalPlan::Limit { input, .. }
228            | LogicalPlan::Aggregate { input, .. }
229            | LogicalPlan::Window { input, .. } => {
230                self.validate_projections(input, accessible_tables)
231            }
232            LogicalPlan::Join { left, right, .. } | LogicalPlan::SetOp { left, right, .. } => {
233                self.validate_projections(left, accessible_tables)?;
234                self.validate_projections(right, accessible_tables)
235            }
236            LogicalPlan::Cte { query, .. } => self.validate_projections(query, accessible_tables),
237            LogicalPlan::Subquery { query, .. }
238            | LogicalPlan::Exists {
239                subquery: query, ..
240            }
241            | LogicalPlan::InSubquery {
242                subquery: query, ..
243            } => self.validate_projections(query, accessible_tables),
244            LogicalPlan::Scan { .. }
245            | LogicalPlan::Values { .. }
246            | LogicalPlan::Empty
247            | LogicalPlan::CteRef { .. } => Ok(()),
248        }
249    }
250}
251
252impl Default for SchemaValidator {
253    fn default() -> Self {
254        Self::new()
255    }
256}
257
258// ── Tests ────────────────────────────────────────────────────────────────────
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::{parse_one, plan_statement};
264
265    fn make_scan(table: &str) -> LogicalPlan {
266        LogicalPlan::Scan {
267            table: table.to_string(),
268            alias: None,
269            limit: None,
270        }
271    }
272
273    /// Known table → Ok.
274    #[test]
275    fn test_validate_known_table() {
276        let mut v = SchemaValidator::new();
277        v.add_table("users", &["id", "name"]);
278        let plan = make_scan("users");
279        assert!(v.validate(&plan).is_ok());
280    }
281
282    /// Unknown table → TableNotFound.
283    #[test]
284    fn test_validate_unknown_table() {
285        let v = SchemaValidator::new();
286        let plan = make_scan("orders");
287        assert_eq!(
288            v.validate(&plan),
289            Err(ValidationError::TableNotFound("orders".to_string()))
290        );
291    }
292
293    /// Scan with Filter over a known table → Ok.
294    #[test]
295    fn test_validate_filter_known() {
296        let mut v = SchemaValidator::new();
297        v.add_table("users", &["id", "name", "age"]);
298        let plan = LogicalPlan::Filter {
299            input: Box::new(make_scan("users")),
300            predicate: "age > 18".to_string(),
301        };
302        assert!(v.validate(&plan).is_ok());
303    }
304
305    /// Parse → plan → validate: all tables known → Ok.
306    #[test]
307    fn test_validate_full_pipeline() {
308        let mut v = SchemaValidator::new();
309        v.add_table("users", &["id", "name", "age"]);
310
311        let stmt = parse_one("SELECT * FROM users WHERE age > 18").expect("parse");
312        let plan = plan_statement(&stmt).expect("plan");
313        assert!(v.validate(&plan).is_ok());
314    }
315
316    /// Parse → plan → validate: table not in schema → TableNotFound.
317    #[test]
318    fn test_validate_full_pipeline_unknown() {
319        let v = SchemaValidator::new(); // empty schema
320
321        let stmt = parse_one("SELECT * FROM orders").expect("parse");
322        let plan = plan_statement(&stmt).expect("plan");
323        assert_eq!(
324            v.validate(&plan),
325            Err(ValidationError::TableNotFound("orders".to_string()))
326        );
327    }
328
329    // ── Subquery plan tests ─────────────────────────────────────────────────
330
331    /// Scalar subquery in SELECT projection → Subquery node in plan.
332    #[test]
333    fn test_plan_subquery_in_select() {
334        let sql = "SELECT (SELECT COUNT(*) FROM orders \
335                   WHERE orders.user_id = users.id) AS cnt FROM users";
336        let stmt = parse_one(sql).expect("parse");
337        let plan = plan_statement(&stmt).expect("plan");
338        fn has_subquery(p: &LogicalPlan) -> bool {
339            match p {
340                LogicalPlan::Subquery { query, .. } => {
341                    // Count as found even if we recurse deeper.
342                    let _ = has_subquery(query);
343                    true
344                }
345                LogicalPlan::Project { input, .. }
346                | LogicalPlan::Filter { input, .. }
347                | LogicalPlan::Sort { input, .. }
348                | LogicalPlan::Limit { input, .. }
349                | LogicalPlan::Aggregate { input, .. }
350                | LogicalPlan::Window { input, .. } => has_subquery(input),
351                _ => false,
352            }
353        }
354        let found = matches!(&plan, LogicalPlan::Subquery { .. }) || has_subquery(&plan);
355        assert!(found, "expected a Subquery node in: {plan:?}");
356    }
357
358    /// EXISTS subquery → Exists { negated: false }.
359    #[test]
360    fn test_plan_exists() {
361        let sql = "SELECT * FROM users WHERE EXISTS \
362                   (SELECT 1 FROM orders WHERE orders.user_id = users.id)";
363        let stmt = parse_one(sql).expect("parse");
364        let plan = plan_statement(&stmt).expect("plan");
365        match &plan {
366            LogicalPlan::Exists { negated, .. } => {
367                assert!(!negated, "EXISTS should have negated=false");
368            }
369            other => panic!("expected Exists at root, got: {other:?}"),
370        }
371    }
372
373    /// NOT EXISTS subquery → Exists { negated: true }.
374    #[test]
375    fn test_plan_not_exists() {
376        let sql = "SELECT * FROM users WHERE NOT EXISTS \
377                   (SELECT 1 FROM orders WHERE orders.user_id = users.id)";
378        let stmt = parse_one(sql).expect("parse");
379        let plan = plan_statement(&stmt).expect("plan");
380        match &plan {
381            LogicalPlan::Exists { negated, .. } => {
382                assert!(negated, "NOT EXISTS should have negated=true");
383            }
384            other => panic!("expected Exists at root, got: {other:?}"),
385        }
386    }
387
388    /// IN (subquery) → InSubquery { negated: false }.
389    #[test]
390    fn test_plan_in_subquery() {
391        let sql = "SELECT * FROM users WHERE id IN (SELECT user_id FROM orders)";
392        let stmt = parse_one(sql).expect("parse");
393        let plan = plan_statement(&stmt).expect("plan");
394        match &plan {
395            LogicalPlan::InSubquery { negated, .. } => {
396                assert!(!negated, "IN should have negated=false");
397            }
398            other => panic!("expected InSubquery at root, got: {other:?}"),
399        }
400    }
401
402    /// explain() on a hand-built Subquery plan produces non-empty output.
403    #[test]
404    fn test_explain_subquery() {
405        let plan = LogicalPlan::Subquery {
406            query: Box::new(LogicalPlan::Scan {
407                table: "orders".to_string(),
408                alias: None,
409                limit: None,
410            }),
411            alias: Some("cnt".to_string()),
412        };
413        let text = crate::explain(&plan);
414        assert!(!text.is_empty(), "explain output should not be empty");
415        assert!(text.contains("Subquery"), "expected 'Subquery' in: {text}");
416    }
417}