Skip to main content

nodedb_sql/planner/select/entry/
query.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Top-level query entry: CTE handling and UNION dispatch. ORDER BY and
4//! search-trigger detection live in `order_by.rs`; LIMIT / OFFSET application
5//! lives in `limit.rs`.
6
7use sqlparser::ast::{Query, SetExpr};
8
9use crate::error::{Result, SqlError};
10use crate::functions::registry::FunctionRegistry;
11use crate::planner::select::cte_catalog::CteCatalog;
12use crate::planner::select::rekey_join_output;
13use crate::reserved::check_ast_identifier;
14use crate::resolver::derived::{infer_subquery_relation, rename_output_columns};
15use crate::temporal::TemporalScope;
16use crate::types::{CtePlan, SqlCatalog, SqlPlan};
17
18/// Plan a SELECT query that produces the statement's result rows.
19///
20/// Only this SELECT list may hold a Control-Plane-computed item (a sequence
21/// accessor over a relation): the Control Plane evaluates the statement's
22/// output rows and nothing deeper.
23pub fn plan_statement_query(
24    query: &Query,
25    catalog: &dyn SqlCatalog,
26    functions: &FunctionRegistry,
27    temporal: TemporalScope,
28) -> Result<SqlPlan> {
29    plan_query_at(query, catalog, functions, temporal, true)
30}
31
32/// Plan a nested SELECT query: a subquery, CTE body, UNION branch, derived
33/// table, INSERT source, or MERGE source. Its SELECT list refuses a
34/// sequence accessor over a relation.
35pub fn plan_query(
36    query: &Query,
37    catalog: &dyn SqlCatalog,
38    functions: &FunctionRegistry,
39    temporal: TemporalScope,
40) -> Result<SqlPlan> {
41    plan_query_at(query, catalog, functions, temporal, false)
42}
43
44/// Plan a SELECT query. `statement_output` says whether its rows are the
45/// statement's result; a WITH clause passes it to the outer query only.
46fn plan_query_at(
47    query: &Query,
48    catalog: &dyn SqlCatalog,
49    functions: &FunctionRegistry,
50    temporal: TemporalScope,
51    statement_output: bool,
52) -> Result<SqlPlan> {
53    // Handle CTEs (WITH clause).
54    if let Some(with) = &query.with
55        && with.recursive
56    {
57        return crate::planner::cte::plan_recursive_cte(query, catalog, functions, temporal);
58    }
59    // Non-recursive CTEs: plan each CTE subquery and the outer query.
60    if let Some(with) = &query.with
61        && !with.cte_tables.is_empty()
62    {
63        let inner_query = Query {
64            with: None,
65            body: query.body.clone(),
66            order_by: query.order_by.clone(),
67            limit_clause: query.limit_clause.clone(),
68            fetch: query.fetch.clone(),
69            locks: query.locks.clone(),
70            for_clause: query.for_clause.clone(),
71            settings: query.settings.clone(),
72            format_clause: query.format_clause.clone(),
73            pipe_operators: query.pipe_operators.clone(),
74        };
75
76        // Plan each CTE subquery and infer the relation it exposes. A
77        // definition reads the definitions before it, as in PostgreSQL:
78        // `WITH c AS (...), d AS (SELECT ... FROM c)`.
79        let mut definitions = Vec::new();
80        let mut relations = Vec::new();
81        for cte in &with.cte_tables {
82            let name = check_ast_identifier(&cte.alias.name)?;
83            let declared: Vec<String> = cte
84                .alias
85                .columns
86                .iter()
87                .map(|column| check_ast_identifier(&column.name))
88                .collect::<Result<_>>()?;
89            let earlier = CteCatalog {
90                inner: catalog,
91                relations: relations.clone(),
92            };
93            let cte_plan = plan_query(&cte.query, &earlier, functions, temporal)?;
94            let info = infer_subquery_relation(
95                &earlier,
96                &name,
97                &cte.query,
98                Some(&cte_plan),
99                functions,
100                temporal,
101            )?;
102            // The outer query reads the body by its output names.
103            definitions.push((name.clone(), rekey_join_output(cte_plan)));
104            relations.push((name, rename_output_columns(info, &declared)));
105        }
106
107        // Build CTE-aware catalog so the outer query can reference CTE names.
108        let cte_catalog = CteCatalog {
109            inner: catalog,
110            relations,
111        };
112        let outer = plan_query_at(
113            &inner_query,
114            &cte_catalog,
115            functions,
116            temporal,
117            statement_output,
118        )?;
119
120        return Ok(SqlPlan::Cte(CtePlan {
121            definitions,
122            outer: Box::new(outer),
123        }));
124    }
125
126    // Handle UNION.
127    match &*query.body {
128        SetExpr::Select(select) => super::search::plan_select_query(
129            query,
130            select,
131            catalog,
132            functions,
133            temporal,
134            statement_output,
135        ),
136        SetExpr::SetOperation {
137            op,
138            left,
139            right,
140            set_quantifier,
141        } => crate::planner::union::plan_set_operation(
142            op,
143            left,
144            right,
145            set_quantifier,
146            catalog,
147            functions,
148            temporal,
149        ),
150        _ => Err(SqlError::Unsupported {
151            detail: format!("query body type: {}", query.body),
152        }),
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::super::fixtures::plan_select_sql;
159    use crate::types::*;
160    #[test]
161    fn aggregate_subquery_join_filters_input_before_aggregation() {
162        let plan = plan_select_sql(
163            "SELECT AVG(price) FROM products WHERE category IN (SELECT DISTINCT category FROM products WHERE qty > 100)",
164        );
165
166        let SqlPlan::Aggregate { input, .. } = plan else {
167            panic!("expected aggregate plan");
168        };
169
170        let SqlPlan::Join {
171            left,
172            join_type,
173            on,
174            ..
175        } = *input
176        else {
177            panic!("expected semi-join below aggregate");
178        };
179
180        assert_eq!(join_type, JoinType::Semi);
181        assert_eq!(on, vec![("category".into(), "category".into())]);
182        assert!(matches!(*left, SqlPlan::Scan { .. }));
183    }
184
185    #[test]
186    fn scalar_subquery_defers_projection_until_after_join_filter() {
187        let plan = plan_select_sql(
188            "SELECT user_id FROM orders WHERE amount > (SELECT AVG(amount) FROM orders)",
189        );
190
191        let SqlPlan::Join {
192            left,
193            projection,
194            filters,
195            ..
196        } = plan
197        else {
198            panic!("expected join plan");
199        };
200
201        let SqlPlan::Scan {
202            projection: scan_projection,
203            ..
204        } = *left
205        else {
206            panic!("expected scan on join left");
207        };
208
209        assert!(scan_projection.is_empty(), "scan projected too early");
210        assert_eq!(projection.len(), 1);
211        match &projection[0] {
212            Projection::Column(name) => assert_eq!(name, "user_id"),
213            other => panic!("expected user_id projection, got {other:?}"),
214        }
215        assert!(
216            !filters.is_empty(),
217            "scalar comparison should stay post-join"
218        );
219    }
220
221    #[test]
222    fn chained_join_preserves_qualified_on_keys() {
223        let plan = plan_select_sql(
224            "SELECT d.name, t.tag, p.theme \
225             FROM docs d \
226             LEFT JOIN tags t ON d.id = t.doc_id \
227             INNER JOIN user_prefs p ON d.id = p.key",
228        );
229
230        let SqlPlan::Join { left, on, .. } = plan else {
231            panic!("expected outer join plan");
232        };
233        assert_eq!(on, vec![("d.id".into(), "p.key".into())]);
234
235        let SqlPlan::Join { on: inner_on, .. } = *left else {
236            panic!("expected nested left join");
237        };
238        assert_eq!(inner_on, vec![("d.id".into(), "t.doc_id".into())]);
239    }
240}