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 | 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 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 fn validate_projections(
160 &self,
161 plan: &LogicalPlan,
162 accessible_tables: &[&str],
163 ) -> Result<(), ValidationError> {
164 match plan {
165 LogicalPlan::Project { input, columns } => {
166 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 let raw = col_expr
173 .split_whitespace()
174 .next()
175 .unwrap_or(col_expr.as_str());
176 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 } else {
190 let col_key = raw.trim_matches('"').to_ascii_lowercase();
192 if col_key == "*" || col_key.contains('(') {
193 continue;
194 }
195 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 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#[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 #[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 #[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 #[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 #[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 #[test]
321 fn test_validate_full_pipeline_unknown() {
322 let v = SchemaValidator::new(); 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 #[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 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 #[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 #[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 #[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 #[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}