Skip to main content

uqa_sql/semantics/
expression_shape.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Expression traversal, qualifier analysis, and safe qualification.
8
9use super::collect_from_qualifiers;
10use crate::plan::SourcePlan;
11use crate::ScalarExpr;
12use std::collections::BTreeSet;
13
14pub fn expr_contains_function(expression: &ScalarExpr) -> bool {
15    let mut contains_function = false;
16    expression.visit(&mut |part| {
17        contains_function |= matches!(
18            part,
19            ScalarExpr::Func { .. } | ScalarExpr::WindowCall { .. }
20        );
21    });
22    contains_function
23}
24
25pub fn flatten_and_filter_parts(expr: &ScalarExpr) -> Vec<&ScalarExpr> {
26    match expr {
27        ScalarExpr::And(items) => items.iter().flat_map(flatten_and_filter_parts).collect(),
28        other => vec![other],
29    }
30}
31
32pub fn from_qualifier_set(from: &SourcePlan) -> BTreeSet<String> {
33    let mut qualifiers = Vec::new();
34    collect_from_qualifiers(from, &mut qualifiers);
35    qualifiers.into_iter().collect()
36}
37
38pub fn expr_qualifiers(expr: &ScalarExpr) -> BTreeSet<String> {
39    let mut qualifiers = BTreeSet::new();
40    collect_expr_qualifiers(expr, &mut qualifiers);
41    qualifiers
42}
43
44pub fn collect_expr_qualifiers(expr: &ScalarExpr, qualifiers: &mut BTreeSet<String>) {
45    expr.visit(&mut |part| match part {
46        ScalarExpr::QualifiedColumn { qualifier, .. } | ScalarExpr::QualifiedStar(qualifier) => {
47            qualifiers.insert(qualifier.clone());
48        }
49        _ => {}
50    });
51}
52
53pub fn expr_has_unqualified_column(expr: &ScalarExpr) -> bool {
54    let mut found = false;
55    expr.visit(&mut |part| {
56        found |= matches!(part, ScalarExpr::Column(_));
57    });
58    found
59}
60
61#[expect(
62    clippy::too_many_lines,
63    reason = "preserves SELECT schema and row identity"
64)]
65pub fn qualify_unqualified_columns(expr: &ScalarExpr, qualifier: &str) -> ScalarExpr {
66    match expr {
67        ScalarExpr::Column(column) => ScalarExpr::qualified_column(qualifier, column),
68        ScalarExpr::Default
69        | ScalarExpr::Position(_)
70        | ScalarExpr::InternalColumn(_)
71        | ScalarExpr::QualifiedColumn { .. }
72        | ScalarExpr::QualifiedStar(_)
73        | ScalarExpr::Literal(_)
74        | ScalarExpr::TypedLiteral { .. }
75        | ScalarExpr::Param(_)
76        | ScalarExpr::Star => expr.clone(),
77        ScalarExpr::Array(items) => ScalarExpr::Array(
78            items
79                .iter()
80                .map(|item| qualify_unqualified_columns(item, qualifier))
81                .collect(),
82        ),
83        ScalarExpr::Row(items) => ScalarExpr::Row(
84            items
85                .iter()
86                .map(|item| qualify_unqualified_columns(item, qualifier))
87                .collect(),
88        ),
89        ScalarExpr::And(items) => ScalarExpr::And(
90            items
91                .iter()
92                .map(|item| qualify_unqualified_columns(item, qualifier))
93                .collect(),
94        ),
95        ScalarExpr::Or(items) => ScalarExpr::Or(
96            items
97                .iter()
98                .map(|item| qualify_unqualified_columns(item, qualifier))
99                .collect(),
100        ),
101        ScalarExpr::Binary { op, lhs, rhs } => ScalarExpr::Binary {
102            op: *op,
103            lhs: Box::new(qualify_unqualified_columns(lhs, qualifier)),
104            rhs: Box::new(qualify_unqualified_columns(rhs, qualifier)),
105        },
106        ScalarExpr::Not(inner) => {
107            ScalarExpr::Not(Box::new(qualify_unqualified_columns(inner, qualifier)))
108        }
109        ScalarExpr::UnaryMinus(inner) => {
110            ScalarExpr::UnaryMinus(Box::new(qualify_unqualified_columns(inner, qualifier)))
111        }
112        ScalarExpr::IsNull { expr, negated } => ScalarExpr::IsNull {
113            expr: Box::new(qualify_unqualified_columns(expr, qualifier)),
114            negated: *negated,
115        },
116        ScalarExpr::Between { expr, low, high } => ScalarExpr::Between {
117            expr: Box::new(qualify_unqualified_columns(expr, qualifier)),
118            low: Box::new(qualify_unqualified_columns(low, qualifier)),
119            high: Box::new(qualify_unqualified_columns(high, qualifier)),
120        },
121        ScalarExpr::InList {
122            expr,
123            list,
124            negated,
125        } => ScalarExpr::InList {
126            expr: Box::new(qualify_unqualified_columns(expr, qualifier)),
127            list: list
128                .iter()
129                .map(|item| qualify_unqualified_columns(item, qualifier))
130                .collect(),
131            negated: *negated,
132        },
133        ScalarExpr::Func {
134            name,
135            binding,
136            args,
137            distinct,
138            order_by,
139            filter,
140        } => ScalarExpr::Func {
141            name: name.clone(),
142            binding: binding.clone(),
143            args: args
144                .iter()
145                .map(|arg| qualify_unqualified_columns(arg, qualifier))
146                .collect(),
147            distinct: *distinct,
148            order_by: order_by.clone(),
149            filter: filter
150                .as_ref()
151                .map(|filter| Box::new(qualify_unqualified_columns(filter, qualifier))),
152        },
153        ScalarExpr::WindowCall { name, args, spec } => ScalarExpr::WindowCall {
154            name: name.clone(),
155            args: args
156                .iter()
157                .map(|arg| qualify_unqualified_columns(arg, qualifier))
158                .collect(),
159            spec: spec.clone(),
160        },
161        ScalarExpr::Case {
162            base,
163            when,
164            else_branch,
165        } => ScalarExpr::Case {
166            base: base
167                .as_ref()
168                .map(|expr| Box::new(qualify_unqualified_columns(expr, qualifier))),
169            when: when
170                .iter()
171                .map(|(cond, result)| {
172                    (
173                        qualify_unqualified_columns(cond, qualifier),
174                        qualify_unqualified_columns(result, qualifier),
175                    )
176                })
177                .collect(),
178            else_branch: else_branch
179                .as_ref()
180                .map(|expr| Box::new(qualify_unqualified_columns(expr, qualifier))),
181        },
182        ScalarExpr::Cast { expr, ty } => ScalarExpr::Cast {
183            expr: Box::new(qualify_unqualified_columns(expr, qualifier)),
184            ty: ty.clone(),
185        },
186        ScalarExpr::InSubquery {
187            expr,
188            subquery,
189            negated,
190        } => ScalarExpr::InSubquery {
191            expr: Box::new(qualify_unqualified_columns(expr, qualifier)),
192            subquery: *subquery,
193            negated: *negated,
194        },
195        ScalarExpr::ScalarSubquery(_) | ScalarExpr::Exists { .. } => expr.clone(),
196    }
197}
198
199/// True when the expression can never evaluate to SQL NULL for any
200/// row: registered search functions, IS NULL tests, and boolean
201/// combinations thereof. Anything referencing column comparisons may
202/// yield NULL, so set-complement `NOT` would be unsound for it.
203pub fn expr_is_null_free(expr: &ScalarExpr) -> bool {
204    match expr {
205        ScalarExpr::Func { name, .. } => crate::registry::is_registered(name),
206        ScalarExpr::IsNull { .. } => true,
207        ScalarExpr::Exists { .. } => true,
208        ScalarExpr::Literal(v) => !matches!(v, uqa_core::Value::Null),
209        ScalarExpr::And(parts) | ScalarExpr::Or(parts) => parts.iter().all(expr_is_null_free),
210        ScalarExpr::Not(inner) => expr_is_null_free(inner),
211        _ => false,
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::{expr_contains_function, expr_has_unqualified_column, expr_qualifiers, ScalarExpr};
218    use crate::ast::FrameMode;
219    use crate::{ScalarFrameBound, ScalarWindowFrame, ScalarWindowSpec};
220
221    #[test]
222    fn expression_shape_uses_complete_scalar_traversal() {
223        let expression = ScalarExpr::WindowCall {
224            name: "sum".into(),
225            args: vec![ScalarExpr::QualifiedColumn {
226                qualifier: "orders".into(),
227                column: "amount".into(),
228            }],
229            spec: ScalarWindowSpec {
230                partition_by: Vec::new(),
231                order_by: Vec::new(),
232                frame: Some(ScalarWindowFrame {
233                    mode: FrameMode::Rows,
234                    start: ScalarFrameBound::Preceding(Box::new(ScalarExpr::Column(
235                        "frame_width".into(),
236                    ))),
237                    end: ScalarFrameBound::CurrentRow,
238                }),
239            },
240        };
241        assert!(expr_contains_function(&expression));
242        assert!(expr_has_unqualified_column(&expression));
243        assert_eq!(
244            expr_qualifiers(&expression),
245            std::collections::BTreeSet::from(["orders".into()])
246        );
247    }
248}