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::Collated {
207            expr,
208            ty,
209            collation,
210        } => ScalarExpr::Collated {
211            expr: Box::new(qualify_unqualified_columns(expr, qualifier)),
212            ty: ty.clone(),
213            collation: *collation,
214        },
215        ScalarExpr::Cast { expr, ty, implicit } => ScalarExpr::Cast {
216            implicit: *implicit,
217            expr: Box::new(qualify_unqualified_columns(expr, qualifier)),
218            ty: ty.clone(),
219        },
220        ScalarExpr::InSubquery {
221            expr,
222            subquery,
223            negated,
224        } => ScalarExpr::InSubquery {
225            expr: Box::new(qualify_unqualified_columns(expr, qualifier)),
226            subquery: *subquery,
227            negated: *negated,
228        },
229        ScalarExpr::ScalarSubquery(_) | ScalarExpr::Exists { .. } => expr.clone(),
230    }
231}
232
233/// True when the expression can never evaluate to SQL NULL for any
234/// row: registered search functions, IS NULL tests, and boolean
235/// combinations thereof. Anything referencing column comparisons may
236/// yield NULL, so set-complement `NOT` would be unsound for it.
237pub fn expr_is_null_free(expr: &ScalarExpr) -> bool {
238    match expr {
239        ScalarExpr::Func { name, .. } => crate::registry::is_registered(name),
240        ScalarExpr::IsNull { .. } => true,
241        ScalarExpr::Exists { .. } => true,
242        ScalarExpr::Literal(v) => !matches!(v, uqa_core::Value::Null),
243        ScalarExpr::And(parts) | ScalarExpr::Or(parts) => parts.iter().all(expr_is_null_free),
244        ScalarExpr::Not(inner) => expr_is_null_free(inner),
245        _ => false,
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::{expr_contains_function, expr_has_unqualified_column, expr_qualifiers, ScalarExpr};
252    use crate::ast::{FrameExclusion, FrameMode};
253    use crate::{ScalarFrameBound, ScalarWindowFrame, ScalarWindowSpec};
254
255    #[test]
256    fn expression_shape_uses_complete_scalar_traversal() {
257        let expression = ScalarExpr::WindowCall {
258            name: "sum".into(),
259            args: vec![ScalarExpr::QualifiedColumn {
260                qualifier: "orders".into(),
261                column: "amount".into(),
262            }],
263            spec: ScalarWindowSpec {
264                definition: None,
265                partition_by: Vec::new(),
266                order_by: Vec::new(),
267                frame: Some(ScalarWindowFrame {
268                    mode: FrameMode::Rows,
269                    start: ScalarFrameBound::Preceding(Box::new(ScalarExpr::Column(
270                        "frame_width".into(),
271                    ))),
272                    end: ScalarFrameBound::CurrentRow,
273                    between: true,
274                    exclusion: FrameExclusion::NoOthers,
275                }),
276            },
277            filter: None,
278            modifiers: crate::ast::WindowCallModifiers::default(),
279        };
280        assert!(expr_contains_function(&expression));
281        assert!(expr_has_unqualified_column(&expression));
282        assert_eq!(
283            expr_qualifiers(&expression),
284            std::collections::BTreeSet::from(["orders".into()])
285        );
286    }
287}