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::CompositeRow {
90            items,
91            binding,
92            bound_type,
93        } => ScalarExpr::CompositeRow {
94            items: items
95                .iter()
96                .map(|item| qualify_unqualified_columns(item, qualifier))
97                .collect(),
98            binding: binding.clone(),
99            bound_type: bound_type.clone(),
100        },
101        ScalarExpr::And(items) => ScalarExpr::And(
102            items
103                .iter()
104                .map(|item| qualify_unqualified_columns(item, qualifier))
105                .collect(),
106        ),
107        ScalarExpr::Or(items) => ScalarExpr::Or(
108            items
109                .iter()
110                .map(|item| qualify_unqualified_columns(item, qualifier))
111                .collect(),
112        ),
113        ScalarExpr::Binary { op, lhs, rhs } => ScalarExpr::Binary {
114            op: *op,
115            lhs: Box::new(qualify_unqualified_columns(lhs, qualifier)),
116            rhs: Box::new(qualify_unqualified_columns(rhs, qualifier)),
117        },
118        ScalarExpr::Not(inner) => {
119            ScalarExpr::Not(Box::new(qualify_unqualified_columns(inner, qualifier)))
120        }
121        ScalarExpr::UnaryMinus(inner) => {
122            ScalarExpr::UnaryMinus(Box::new(qualify_unqualified_columns(inner, qualifier)))
123        }
124        ScalarExpr::IsNull { expr, negated } => ScalarExpr::IsNull {
125            expr: Box::new(qualify_unqualified_columns(expr, qualifier)),
126            negated: *negated,
127        },
128        ScalarExpr::Between { expr, low, high } => ScalarExpr::Between {
129            expr: Box::new(qualify_unqualified_columns(expr, qualifier)),
130            low: Box::new(qualify_unqualified_columns(low, qualifier)),
131            high: Box::new(qualify_unqualified_columns(high, qualifier)),
132        },
133        ScalarExpr::InList {
134            expr,
135            list,
136            negated,
137        } => ScalarExpr::InList {
138            expr: Box::new(qualify_unqualified_columns(expr, qualifier)),
139            list: list
140                .iter()
141                .map(|item| qualify_unqualified_columns(item, qualifier))
142                .collect(),
143            negated: *negated,
144        },
145        ScalarExpr::Func {
146            order_syntax,
147            name,
148            binding,
149            args,
150            distinct,
151            order_by,
152            filter,
153        } => ScalarExpr::Func {
154            order_syntax: *order_syntax,
155            name: name.clone(),
156            binding: binding.clone(),
157            args: args
158                .iter()
159                .map(|arg| qualify_unqualified_columns(arg, qualifier))
160                .collect(),
161            distinct: *distinct,
162            order_by: order_by.clone(),
163            filter: filter
164                .as_ref()
165                .map(|filter| Box::new(qualify_unqualified_columns(filter, qualifier))),
166        },
167        ScalarExpr::WindowCall {
168            name,
169            args,
170            spec,
171            filter,
172            modifiers,
173        } => ScalarExpr::WindowCall {
174            modifiers: *modifiers,
175            name: name.clone(),
176            args: args
177                .iter()
178                .map(|arg| qualify_unqualified_columns(arg, qualifier))
179                .collect(),
180            spec: spec.clone(),
181            filter: filter
182                .as_ref()
183                .map(|filter| Box::new(qualify_unqualified_columns(filter, qualifier))),
184        },
185        ScalarExpr::Case {
186            base,
187            when,
188            else_branch,
189        } => ScalarExpr::Case {
190            base: base
191                .as_ref()
192                .map(|expr| Box::new(qualify_unqualified_columns(expr, qualifier))),
193            when: when
194                .iter()
195                .map(|(cond, result)| {
196                    (
197                        qualify_unqualified_columns(cond, qualifier),
198                        qualify_unqualified_columns(result, qualifier),
199                    )
200                })
201                .collect(),
202            else_branch: else_branch
203                .as_ref()
204                .map(|expr| Box::new(qualify_unqualified_columns(expr, qualifier))),
205        },
206        ScalarExpr::Cast { expr, ty, implicit } => ScalarExpr::Cast {
207            implicit: *implicit,
208            expr: Box::new(qualify_unqualified_columns(expr, qualifier)),
209            ty: ty.clone(),
210        },
211        ScalarExpr::InSubquery {
212            expr,
213            subquery,
214            negated,
215        } => ScalarExpr::InSubquery {
216            expr: Box::new(qualify_unqualified_columns(expr, qualifier)),
217            subquery: *subquery,
218            negated: *negated,
219        },
220        ScalarExpr::ScalarSubquery(_) | ScalarExpr::Exists { .. } => expr.clone(),
221    }
222}
223
224/// True when the expression can never evaluate to SQL NULL for any
225/// row: registered search functions, IS NULL tests, and boolean
226/// combinations thereof. Anything referencing column comparisons may
227/// yield NULL, so set-complement `NOT` would be unsound for it.
228pub fn expr_is_null_free(expr: &ScalarExpr) -> bool {
229    match expr {
230        ScalarExpr::Func { name, .. } => crate::registry::is_registered(name),
231        ScalarExpr::IsNull { .. } => true,
232        ScalarExpr::Exists { .. } => true,
233        ScalarExpr::Literal(v) => !matches!(v, uqa_core::Value::Null),
234        ScalarExpr::And(parts) | ScalarExpr::Or(parts) => parts.iter().all(expr_is_null_free),
235        ScalarExpr::Not(inner) => expr_is_null_free(inner),
236        _ => false,
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::{expr_contains_function, expr_has_unqualified_column, expr_qualifiers, ScalarExpr};
243    use crate::ast::{FrameExclusion, FrameMode};
244    use crate::{ScalarFrameBound, ScalarWindowFrame, ScalarWindowSpec};
245
246    #[test]
247    fn expression_shape_uses_complete_scalar_traversal() {
248        let expression = ScalarExpr::WindowCall {
249            name: "sum".into(),
250            args: vec![ScalarExpr::QualifiedColumn {
251                qualifier: "orders".into(),
252                column: "amount".into(),
253            }],
254            spec: ScalarWindowSpec {
255                definition: None,
256                partition_by: Vec::new(),
257                order_by: Vec::new(),
258                frame: Some(ScalarWindowFrame {
259                    mode: FrameMode::Rows,
260                    start: ScalarFrameBound::Preceding(Box::new(ScalarExpr::Column(
261                        "frame_width".into(),
262                    ))),
263                    end: ScalarFrameBound::CurrentRow,
264                    between: true,
265                    exclusion: FrameExclusion::NoOthers,
266                }),
267            },
268            filter: None,
269            modifiers: crate::ast::WindowCallModifiers::default(),
270        };
271        assert!(expr_contains_function(&expression));
272        assert!(expr_has_unqualified_column(&expression));
273        assert_eq!(
274            expr_qualifiers(&expression),
275            std::collections::BTreeSet::from(["orders".into()])
276        );
277    }
278}