1use std::collections::BTreeMap;
8
9use sqlparser::ast::{
10 BinaryOperator, Expr, GroupByExpr, Ident, JoinConstraint, JoinOperator, ObjectName, Query,
11 Select, SelectItem, SetExpr, Statement, TableFactor, TableWithJoins, UnaryOperator, Value,
12 WildcardAdditionalOptions,
13};
14
15use crate::{
16 catalog::{Catalog, ColumnSchema, ColumnType},
17 parse_select, SqlError,
18};
19
20#[derive(Debug, Clone)]
21struct QueryShape {
22 columns: Vec<ColumnSchema>,
23 aliases: BTreeMap<String, Expr>,
24}
25
26#[derive(Debug, Clone)]
27struct RelationBinding {
28 columns: Vec<ColumnSchema>,
29}
30
31#[derive(Debug, Clone, Default)]
32struct Scope {
33 relations: BTreeMap<String, RelationBinding>,
34}
35
36#[derive(Debug, Clone, Default)]
37struct NormalizeContext {
38 ctes: BTreeMap<String, QueryShape>,
39}
40
41pub fn parse_and_normalize(sql: &str, catalog: &Catalog) -> Result<Statement, SqlError> {
48 let statement = parse_select(sql)?;
49 normalize_statement(&statement, catalog)
50}
51
52pub fn normalize_statement(
58 statement: &Statement,
59 catalog: &Catalog,
60) -> Result<Statement, SqlError> {
61 let Statement::Query(query) = statement else {
62 return Err(SqlError::UnsupportedStatement);
63 };
64
65 let mut query = query.clone();
66 normalize_query(&mut query, catalog, &NormalizeContext::default())?;
67 Ok(Statement::Query(query))
68}
69
70pub fn validate_statement_against_catalog(
76 statement: &Statement,
77 catalog: &Catalog,
78) -> Result<(), SqlError> {
79 normalize_statement(statement, catalog).map(|_| ())
80}
81
82fn normalize_query(
83 query: &mut Query,
84 catalog: &Catalog,
85 context: &NormalizeContext,
86) -> Result<QueryShape, SqlError> {
87 let mut local_context = context.clone();
88
89 if let Some(with) = &mut query.with {
90 for cte in &mut with.cte_tables {
91 let mut cte_query = (*cte.query).clone();
92 let mut shape = normalize_query(&mut cte_query, catalog, &local_context)?;
93 *cte.query = cte_query;
94
95 if !cte.alias.columns.is_empty() {
96 if cte.alias.columns.len() != shape.columns.len() {
97 return Err(SqlError::TypeMismatch(format!(
98 "CTE {} has {} column aliases for {} output columns",
99 cte.alias.name,
100 cte.alias.columns.len(),
101 shape.columns.len()
102 )));
103 }
104 for (column, alias) in shape.columns.iter_mut().zip(&cte.alias.columns) {
105 column.name.clone_from(&alias.value);
106 }
107 }
108
109 local_context
110 .ctes
111 .insert(cte.alias.name.value.clone(), shape);
112 }
113 }
114
115 let shape = normalize_set_expr(&mut query.body, catalog, &local_context)?;
116
117 if let Some(order_by) = &mut query.order_by {
118 for order in &mut order_by.exprs {
119 order.expr = normalize_output_expr(&order.expr, &shape)?;
120 }
121 }
122
123 Ok(shape)
124}
125
126fn normalize_set_expr(
127 expr: &mut SetExpr,
128 catalog: &Catalog,
129 context: &NormalizeContext,
130) -> Result<QueryShape, SqlError> {
131 match expr {
132 SetExpr::Select(select) => normalize_select(select, catalog, context),
133 SetExpr::Query(query) => normalize_query(query, catalog, context),
134 SetExpr::SetOperation { left, right, .. } => {
135 let left_shape = normalize_set_expr(left, catalog, context)?;
136 let right_shape = normalize_set_expr(right, catalog, context)?;
137 validate_set_shapes(&left_shape, &right_shape)?;
138 Ok(left_shape)
139 }
140 SetExpr::Values(_) => Err(SqlError::UnsupportedFeature("VALUES queries")),
141 SetExpr::Insert(_) => Err(SqlError::UnsupportedFeature("INSERT in query body")),
142 SetExpr::Update(_) => Err(SqlError::UnsupportedFeature("UPDATE in query body")),
143 SetExpr::Table(_) => Err(SqlError::UnsupportedFeature("TABLE queries")),
144 }
145}
146
147fn normalize_select(
148 select: &mut Select,
149 catalog: &Catalog,
150 context: &NormalizeContext,
151) -> Result<QueryShape, SqlError> {
152 let mut scope = Scope::default();
153
154 for table in &mut select.from {
155 bind_table_with_joins(table, catalog, context, &mut scope)?;
156 }
157
158 if let Some(selection) = &mut select.selection {
159 *selection = normalize_expr(selection, &scope, &BTreeMap::new())?;
160 let ty = infer_expr_type(selection, &scope)?;
161 if !matches!(ty, ColumnType::Bool | ColumnType::Unknown) {
162 return Err(SqlError::TypeMismatch(format!(
163 "WHERE expression must be boolean, got {ty:?}"
164 )));
165 }
166 }
167
168 let (projection, shape) = normalize_projection(&select.projection, &scope)?;
169 select.projection = projection;
170
171 if let GroupByExpr::Expressions(expressions, _) = &mut select.group_by {
172 for expression in expressions {
173 *expression = normalize_expr(expression, &scope, &shape.aliases)?;
174 }
175 }
176
177 Ok(shape)
178}
179
180fn bind_table_with_joins(
181 table: &mut TableWithJoins,
182 catalog: &Catalog,
183 context: &NormalizeContext,
184 scope: &mut Scope,
185) -> Result<(), SqlError> {
186 bind_table_factor(&mut table.relation, catalog, context, scope)?;
187
188 for join in &mut table.joins {
189 bind_table_factor(&mut join.relation, catalog, context, scope)?;
190 match &mut join.join_operator {
191 JoinOperator::Inner(JoinConstraint::On(predicate))
192 | JoinOperator::LeftOuter(JoinConstraint::On(predicate)) => {
193 *predicate = normalize_expr(predicate, scope, &BTreeMap::new())?;
194 validate_equi_join(predicate)?;
195 }
196 _ => {}
197 }
198 }
199
200 Ok(())
201}
202
203fn bind_table_factor(
204 table: &mut TableFactor,
205 catalog: &Catalog,
206 context: &NormalizeContext,
207 scope: &mut Scope,
208) -> Result<(), SqlError> {
209 match table {
210 TableFactor::Table { name, alias, .. } => {
211 let table_name = catalog_table_name(name);
212 let columns = if let Some(cte) = context.ctes.get(&table_name) {
213 cte.columns.clone()
214 } else {
215 catalog.require_table(&table_name)?.columns.clone()
216 };
217 let qualifier = alias
218 .as_ref()
219 .map_or_else(|| table_name.clone(), |alias| alias.name.value.clone());
220 insert_relation(scope, qualifier, columns)
221 }
222 TableFactor::Derived {
223 lateral: false,
224 subquery,
225 alias,
226 } => {
227 let shape = normalize_query(subquery, catalog, context)?;
228 let Some(alias) = alias else {
229 return Err(SqlError::UnsupportedFeature(
230 "derived tables without aliases",
231 ));
232 };
233 insert_relation(scope, alias.name.value.clone(), shape.columns)
234 }
235 TableFactor::Derived { lateral: true, .. } => {
236 Err(SqlError::UnsupportedFeature("LATERAL derived tables"))
237 }
238 _ => Err(SqlError::UnsupportedFeature(
239 "table functions or special table factors",
240 )),
241 }
242}
243
244fn insert_relation(
245 scope: &mut Scope,
246 qualifier: String,
247 columns: Vec<ColumnSchema>,
248) -> Result<(), SqlError> {
249 if scope
250 .relations
251 .insert(qualifier.clone(), RelationBinding { columns })
252 .is_some()
253 {
254 return Err(SqlError::AmbiguousColumn(qualifier));
255 }
256 Ok(())
257}
258
259fn normalize_projection(
260 projection: &[SelectItem],
261 scope: &Scope,
262) -> Result<(Vec<SelectItem>, QueryShape), SqlError> {
263 let mut normalized = Vec::new();
264 let mut columns = Vec::new();
265 let mut aliases = BTreeMap::new();
266
267 for item in projection {
268 match item {
269 SelectItem::Wildcard(options) if wildcard_options_empty(options) => {
270 for (qualifier, binding) in &scope.relations {
271 for column in &binding.columns {
272 let expr = qualified_column(qualifier, &column.name);
273 normalized.push(SelectItem::UnnamedExpr(expr));
274 columns.push(column.clone());
275 }
276 }
277 }
278 SelectItem::QualifiedWildcard(name, options) if wildcard_options_empty(options) => {
279 let qualifier = object_name(name);
280 let binding = scope
281 .relations
282 .get(&qualifier)
283 .ok_or_else(|| SqlError::UnknownTable(qualifier.clone()))?;
284 for column in &binding.columns {
285 let expr = qualified_column(&qualifier, &column.name);
286 normalized.push(SelectItem::UnnamedExpr(expr));
287 columns.push(column.clone());
288 }
289 }
290 SelectItem::UnnamedExpr(expr) => {
291 let expr = normalize_expr(expr, scope, &BTreeMap::new())?;
292 let ty = infer_expr_type(&expr, scope)?;
293 let name = output_name(&expr);
294 normalized.push(SelectItem::UnnamedExpr(expr));
295 columns.push(ColumnSchema::new(name, ty));
296 }
297 SelectItem::ExprWithAlias { expr, alias } => {
298 let expr = normalize_expr(expr, scope, &BTreeMap::new())?;
299 let ty = infer_expr_type(&expr, scope)?;
300 aliases.insert(alias.value.clone(), expr.clone());
301 normalized.push(SelectItem::ExprWithAlias {
302 expr,
303 alias: alias.clone(),
304 });
305 columns.push(ColumnSchema::new(alias.value.clone(), ty));
306 }
307 SelectItem::Wildcard(_) | SelectItem::QualifiedWildcard(_, _) => {
308 return Err(SqlError::UnsupportedFeature("wildcard options"));
309 }
310 }
311 }
312
313 Ok((normalized, QueryShape { columns, aliases }))
314}
315
316fn normalize_expr(
317 expr: &Expr,
318 scope: &Scope,
319 aliases: &BTreeMap<String, Expr>,
320) -> Result<Expr, SqlError> {
321 match expr {
322 Expr::Identifier(identifier) => {
323 if let Some(alias) = aliases.get(&identifier.value) {
324 return Ok(alias.clone());
325 }
326 resolve_column(scope, None, &identifier.value)
327 }
328 Expr::CompoundIdentifier(parts) => {
329 let [relation, column] = parts.as_slice() else {
330 return Err(SqlError::UnsupportedFeature(
331 "multi-part column references beyond relation.column",
332 ));
333 };
334 resolve_column(scope, Some(&relation.value), &column.value)
335 }
336 Expr::Between {
337 expr,
338 negated,
339 low,
340 high,
341 } => {
342 let value = normalize_expr(expr, scope, aliases)?;
343 let low = normalize_expr(low, scope, aliases)?;
344 let high = normalize_expr(high, scope, aliases)?;
345 let range = and(
346 binary(value.clone(), BinaryOperator::GtEq, low),
347 binary(value, BinaryOperator::LtEq, high),
348 );
349 Ok(if *negated { not(range) } else { range })
350 }
351 Expr::InList {
352 expr,
353 list,
354 negated,
355 } => {
356 if list.is_empty() {
357 return Err(SqlError::UnsupportedFeature("empty IN list"));
358 }
359 let value = normalize_expr(expr, scope, aliases)?;
360 let op = if *negated {
361 BinaryOperator::NotEq
362 } else {
363 BinaryOperator::Eq
364 };
365 let join = if *negated { and } else { or };
366 let mut parts = list
367 .iter()
368 .map(|item| normalize_expr(item, scope, aliases))
369 .map(|item| item.map(|item| binary(value.clone(), op.clone(), item)));
370 let first = parts.next().expect("empty list rejected above")?;
371 let normalized =
372 parts.try_fold(first, |left, right| right.map(|right| join(left, right)))?;
373 if *negated || list.len() == 1 {
374 Ok(normalized)
375 } else {
376 Ok(Expr::Nested(Box::new(normalized)))
377 }
378 }
379 Expr::IsNull(inner) => Ok(binary(
380 normalize_expr(inner, scope, aliases)?,
381 BinaryOperator::Eq,
382 Expr::Value(Value::Null),
383 )),
384 Expr::IsNotNull(inner) => Ok(binary(
385 normalize_expr(inner, scope, aliases)?,
386 BinaryOperator::NotEq,
387 Expr::Value(Value::Null),
388 )),
389 Expr::BinaryOp { left, op, right } => {
390 let left = normalize_expr(left, scope, aliases)?;
391 let right = normalize_expr(right, scope, aliases)?;
392 validate_binary_types(&left, op, &right, scope)?;
393 Ok(binary(left, op.clone(), right))
394 }
395 Expr::UnaryOp { op, expr } => {
396 let expr = normalize_expr(expr, scope, aliases)?;
397 if *op == UnaryOperator::Not {
398 let ty = infer_expr_type(&expr, scope)?;
399 if !matches!(ty, ColumnType::Bool | ColumnType::Unknown) {
400 return Err(SqlError::TypeMismatch(format!(
401 "NOT expects boolean input, got {ty:?}"
402 )));
403 }
404 }
405 Ok(Expr::UnaryOp {
406 op: *op,
407 expr: Box::new(expr),
408 })
409 }
410 Expr::Nested(inner) => normalize_expr(inner, scope, aliases),
411 _ => Ok(expr.clone()),
412 }
413}
414
415fn normalize_output_expr(expr: &Expr, shape: &QueryShape) -> Result<Expr, SqlError> {
416 match expr {
417 Expr::Identifier(identifier) => {
418 if let Some(alias) = shape.aliases.get(&identifier.value) {
419 return Ok(alias.clone());
420 }
421 if shape
422 .columns
423 .iter()
424 .any(|column| column.name == identifier.value)
425 {
426 return Ok(expr.clone());
427 }
428 Err(SqlError::UnknownColumn(identifier.value.clone()))
429 }
430 _ => Ok(expr.clone()),
431 }
432}
433
434fn resolve_column(scope: &Scope, qualifier: Option<&str>, column: &str) -> Result<Expr, SqlError> {
435 if let Some(qualifier) = qualifier {
436 let binding = scope
437 .relations
438 .get(qualifier)
439 .ok_or_else(|| SqlError::UnknownTable(qualifier.to_owned()))?;
440 if binding
441 .columns
442 .iter()
443 .any(|candidate| candidate.name == column)
444 {
445 return Ok(qualified_column(qualifier, column));
446 }
447 return Err(SqlError::UnknownColumn(format!("{qualifier}.{column}")));
448 }
449
450 let mut matches = scope.relations.iter().filter(|(_, binding)| {
451 binding
452 .columns
453 .iter()
454 .any(|candidate| candidate.name == column)
455 });
456 let Some((qualifier, _)) = matches.next() else {
457 return Err(SqlError::UnknownColumn(column.to_owned()));
458 };
459 if matches.next().is_some() {
460 return Err(SqlError::AmbiguousColumn(column.to_owned()));
461 }
462
463 Ok(qualified_column(qualifier, column))
464}
465
466fn infer_expr_type(expr: &Expr, scope: &Scope) -> Result<ColumnType, SqlError> {
467 match expr {
468 Expr::Value(Value::Boolean(_))
469 | Expr::UnaryOp {
470 op: UnaryOperator::Not,
471 ..
472 } => Ok(ColumnType::Bool),
473 Expr::Value(Value::Number(_, _)) => Ok(ColumnType::Int),
474 Expr::Value(
475 Value::SingleQuotedString(_)
476 | Value::EscapedStringLiteral(_)
477 | Value::UnicodeStringLiteral(_)
478 | Value::NationalStringLiteral(_)
479 | Value::DoubleQuotedString(_),
480 ) => Ok(ColumnType::Text),
481 Expr::Identifier(identifier) => column_type(scope, None, &identifier.value),
482 Expr::CompoundIdentifier(parts) => {
483 let [relation, column] = parts.as_slice() else {
484 return Ok(ColumnType::Unknown);
485 };
486 column_type(scope, Some(&relation.value), &column.value)
487 }
488 Expr::BinaryOp { left, op, right } => match op {
489 BinaryOperator::Eq
490 | BinaryOperator::NotEq
491 | BinaryOperator::Gt
492 | BinaryOperator::GtEq
493 | BinaryOperator::Lt
494 | BinaryOperator::LtEq
495 | BinaryOperator::And
496 | BinaryOperator::Or => Ok(ColumnType::Bool),
497 BinaryOperator::Plus
498 | BinaryOperator::Minus
499 | BinaryOperator::Multiply
500 | BinaryOperator::Divide
501 | BinaryOperator::Modulo => {
502 let left = infer_expr_type(left, scope)?;
503 let right = infer_expr_type(right, scope)?;
504 if left == ColumnType::Float || right == ColumnType::Float {
505 Ok(ColumnType::Float)
506 } else {
507 Ok(ColumnType::Int)
508 }
509 }
510 _ => Ok(ColumnType::Unknown),
511 },
512 Expr::Function(function) => {
513 let name = function.name.to_string().to_ascii_lowercase();
514 if matches!(name.as_str(), "count") {
515 Ok(ColumnType::Int)
516 } else {
517 Ok(ColumnType::Unknown)
518 }
519 }
520 _ => Ok(ColumnType::Unknown),
521 }
522}
523
524fn column_type(
525 scope: &Scope,
526 qualifier: Option<&str>,
527 column: &str,
528) -> Result<ColumnType, SqlError> {
529 if let Some(qualifier) = qualifier {
530 let binding = scope
531 .relations
532 .get(qualifier)
533 .ok_or_else(|| SqlError::UnknownTable(qualifier.to_owned()))?;
534 return binding
535 .columns
536 .iter()
537 .find(|candidate| candidate.name == column)
538 .map(|column| column.ty)
539 .ok_or_else(|| SqlError::UnknownColumn(format!("{qualifier}.{column}")));
540 }
541
542 let mut matches = scope.relations.values().filter_map(|binding| {
543 binding
544 .columns
545 .iter()
546 .find(|candidate| candidate.name == column)
547 .map(|column| column.ty)
548 });
549 let Some(ty) = matches.next() else {
550 return Err(SqlError::UnknownColumn(column.to_owned()));
551 };
552 if matches.next().is_some() {
553 return Err(SqlError::AmbiguousColumn(column.to_owned()));
554 }
555 Ok(ty)
556}
557
558fn validate_binary_types(
559 left: &Expr,
560 op: &BinaryOperator,
561 right: &Expr,
562 scope: &Scope,
563) -> Result<(), SqlError> {
564 let left_ty = infer_expr_type(left, scope)?;
565 let right_ty = infer_expr_type(right, scope)?;
566
567 match op {
568 BinaryOperator::And | BinaryOperator::Or => {
569 if matches!(left_ty, ColumnType::Bool | ColumnType::Unknown)
570 && matches!(right_ty, ColumnType::Bool | ColumnType::Unknown)
571 {
572 Ok(())
573 } else {
574 Err(SqlError::TypeMismatch(format!(
575 "{op:?} expects boolean inputs, got {left_ty:?} and {right_ty:?}"
576 )))
577 }
578 }
579 BinaryOperator::Plus
580 | BinaryOperator::Minus
581 | BinaryOperator::Multiply
582 | BinaryOperator::Divide
583 | BinaryOperator::Modulo => {
584 if left_ty.is_numeric() && right_ty.is_numeric() {
585 Ok(())
586 } else {
587 Err(SqlError::TypeMismatch(format!(
588 "{op:?} expects numeric inputs, got {left_ty:?} and {right_ty:?}"
589 )))
590 }
591 }
592 BinaryOperator::Eq
593 | BinaryOperator::NotEq
594 | BinaryOperator::Gt
595 | BinaryOperator::GtEq
596 | BinaryOperator::Lt
597 | BinaryOperator::LtEq => {
598 if left_ty.is_compatible_with(right_ty) {
599 Ok(())
600 } else {
601 Err(SqlError::TypeMismatch(format!(
602 "{op:?} compares incompatible inputs {left_ty:?} and {right_ty:?}"
603 )))
604 }
605 }
606 _ => Ok(()),
607 }
608}
609
610fn validate_equi_join(expr: &Expr) -> Result<(), SqlError> {
611 match expr {
612 Expr::BinaryOp {
613 left,
614 op: BinaryOperator::Eq,
615 right,
616 } if matches!(
617 left.as_ref(),
618 Expr::Identifier(_) | Expr::CompoundIdentifier(_)
619 ) && matches!(
620 right.as_ref(),
621 Expr::Identifier(_) | Expr::CompoundIdentifier(_)
622 ) =>
623 {
624 Ok(())
625 }
626 Expr::BinaryOp {
627 left,
628 op: BinaryOperator::And,
629 right,
630 } => {
631 validate_equi_join(left)?;
632 validate_equi_join(right)
633 }
634 _ => Err(SqlError::UnsupportedFeature("theta joins")),
635 }
636}
637
638fn validate_set_shapes(left: &QueryShape, right: &QueryShape) -> Result<(), SqlError> {
639 if left.columns.len() != right.columns.len() {
640 return Err(SqlError::TypeMismatch(format!(
641 "set operation column count mismatch: {} vs {}",
642 left.columns.len(),
643 right.columns.len()
644 )));
645 }
646 for (left, right) in left.columns.iter().zip(&right.columns) {
647 if !left.ty.is_compatible_with(right.ty) {
648 return Err(SqlError::TypeMismatch(format!(
649 "set operation column type mismatch: {} is {:?}, right side is {:?}",
650 left.name, left.ty, right.ty
651 )));
652 }
653 }
654 Ok(())
655}
656
657const fn wildcard_options_empty(options: &WildcardAdditionalOptions) -> bool {
658 options.opt_ilike.is_none()
659 && options.opt_exclude.is_none()
660 && options.opt_except.is_none()
661 && options.opt_replace.is_none()
662 && options.opt_rename.is_none()
663}
664
665fn output_name(expr: &Expr) -> String {
666 match expr {
667 Expr::CompoundIdentifier(parts) => parts
668 .last()
669 .map_or_else(|| expr.to_string(), |part| part.value.clone()),
670 Expr::Identifier(identifier) => identifier.value.clone(),
671 _ => expr.to_string(),
672 }
673}
674
675fn catalog_table_name(name: &ObjectName) -> String {
676 name.0
677 .last()
678 .map_or_else(|| name.to_string(), |part| part.value.clone())
679}
680
681fn object_name(name: &ObjectName) -> String {
682 name.0
683 .last()
684 .map_or_else(|| name.to_string(), |part| part.value.clone())
685}
686
687fn qualified_column(qualifier: &str, column: &str) -> Expr {
688 Expr::CompoundIdentifier(vec![Ident::new(qualifier), Ident::new(column)])
689}
690
691fn binary(left: Expr, op: BinaryOperator, right: Expr) -> Expr {
692 Expr::BinaryOp {
693 left: Box::new(left),
694 op,
695 right: Box::new(right),
696 }
697}
698
699fn and(left: Expr, right: Expr) -> Expr {
700 binary(left, BinaryOperator::And, right)
701}
702
703fn or(left: Expr, right: Expr) -> Expr {
704 binary(left, BinaryOperator::Or, right)
705}
706
707fn not(expr: Expr) -> Expr {
708 Expr::UnaryOp {
709 op: UnaryOperator::Not,
710 expr: Box::new(expr),
711 }
712}
713
714#[cfg(test)]
715mod tests {
716 use sqlparser::ast::Statement;
717
718 use super::parse_and_normalize;
719 use crate::Catalog;
720
721 #[test]
722 fn expands_wildcard_and_resolves_columns() {
723 let normalized = parse_and_normalize("SELECT * FROM posts", &Catalog::demo())
724 .expect("demo catalog contains posts");
725
726 let Statement::Query(query) = normalized else {
727 panic!("expected query");
728 };
729
730 assert_eq!(
731 query.to_string(),
732 "SELECT posts.id, posts.author_id, posts.created_at, posts.title, posts.published FROM posts"
733 );
734 }
735
736 #[test]
737 fn propagates_projection_aliases_to_order_by() {
738 let normalized = parse_and_normalize(
739 "SELECT created_at AS published_at FROM posts ORDER BY published_at LIMIT 10",
740 &Catalog::demo(),
741 )
742 .expect("alias should normalize");
743
744 assert_eq!(
745 normalized.to_string(),
746 "SELECT posts.created_at AS published_at FROM posts ORDER BY posts.created_at LIMIT 10"
747 );
748 }
749
750 #[test]
751 fn desugars_predicate_forms() {
752 let normalized = parse_and_normalize(
753 "SELECT id FROM posts WHERE author_id IN (1, 2) AND created_at IS NOT NULL",
754 &Catalog::demo(),
755 )
756 .expect("predicate should normalize");
757
758 assert!(normalized
759 .to_string()
760 .contains("posts.author_id = 1 OR posts.author_id = 2"));
761 assert!(normalized.to_string().contains("posts.created_at <> NULL"));
762 }
763
764 #[test]
765 fn rejects_unknown_columns() {
766 let err = parse_and_normalize("SELECT missing FROM posts", &Catalog::demo())
767 .expect_err("missing column should reject");
768
769 assert!(err.to_string().contains("unknown column"));
770 }
771
772 #[test]
773 fn rejects_type_mismatches() {
774 let err = parse_and_normalize("SELECT id FROM posts WHERE title = 1", &Catalog::demo())
775 .expect_err("text and integer comparison should reject");
776
777 assert!(err.to_string().contains("type mismatch"));
778 }
779}