1use std::collections::{HashMap, HashSet};
8
9use crate::LogicalPlan;
10
11#[derive(Debug, Clone, PartialEq, thiserror::Error)]
15pub enum ValidationError {
16 #[error("table not found: {0}")]
18 TableNotFound(String),
19 #[error("column not found: {table}.{column}")]
22 ColumnNotFound {
23 table: String,
25 column: String,
27 },
28 #[error("ambiguous column: {0} (found in multiple tables)")]
30 AmbiguousColumn(String),
31}
32
33pub struct SchemaValidator {
53 schema: HashMap<String, HashSet<String>>,
55}
56
57impl SchemaValidator {
58 pub fn new() -> Self {
60 Self {
61 schema: HashMap::new(),
62 }
63 }
64
65 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 pub fn validate(&self, plan: &LogicalPlan) -> Result<(), ValidationError> {
82 self.validate_scans(plan)?;
83 self.validate_projections(plan, &[])
84 }
85
86 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 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 fn validate_projections(
158 &self,
159 plan: &LogicalPlan,
160 accessible_tables: &[&str],
161 ) -> Result<(), ValidationError> {
162 match plan {
163 LogicalPlan::Project { input, columns } => {
164 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 let raw = col_expr
171 .split_whitespace()
172 .next()
173 .unwrap_or(col_expr.as_str());
174 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 } else {
188 let col_key = raw.trim_matches('"').to_ascii_lowercase();
190 if col_key == "*" || col_key.contains('(') {
191 continue;
192 }
193 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 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#[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 #[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 #[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 #[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 #[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 #[test]
318 fn test_validate_full_pipeline_unknown() {
319 let v = SchemaValidator::new(); 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 #[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 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 #[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 #[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 #[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 #[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}