Skip to main content

palimpsest_sql/
normalize.rs

1// Copyright 2026 Thousand Birds Inc.
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Statement normalization (alias removal, catalog binding) used by
5//! the canonical-form / dedup paths.
6
7use 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
41/// Parses `sql` and returns a normalized statement (alias removal,
42/// catalog-driven type binding) suitable for stable canonical-form
43/// comparisons.
44///
45/// # Errors
46/// Surfaces parse, validation, and normalization errors.
47pub fn parse_and_normalize(sql: &str, catalog: &Catalog) -> Result<Statement, SqlError> {
48    let statement = parse_select(sql)?;
49    normalize_statement(&statement, catalog)
50}
51
52/// Normalizes an already-parsed `Statement` against `catalog`.
53///
54/// # Errors
55/// [`SqlError::UnsupportedStatement`] on non-`SELECT` input, or any
56/// catalog-validation error.
57pub 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
70/// Convenience wrapper: normalizes `statement` against `catalog`
71/// purely for the side-effect of catalog validation.
72///
73/// # Errors
74/// As [`normalize_statement`].
75pub 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}