Skip to main content

nodedb_sql/planner/cte/
recursive_scan.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Entry point and collection-backed recursive-scan planning for
4//! `WITH RECURSIVE` CTEs.
5
6use sqlparser::ast::{self, Query, SetExpr};
7
8use crate::error::{Result, SqlError};
9use crate::functions::registry::FunctionRegistry;
10use crate::parser::normalize::normalize_ident;
11use crate::types::*;
12
13use super::recursive_value::plan_recursive_value;
14use super::validate::{count_select_cols, validate_self_ref_count};
15
16/// Default maximum recursion depth for WITH RECURSIVE queries.
17pub const DEFAULT_MAX_RECURSION_DEPTH: usize = 1000;
18
19/// Plan a WITH RECURSIVE query.
20///
21/// Dispatches to either `plan_recursive_scan` (collection-backed) or
22/// `plan_recursive_value` (pure expression / value-generating) based on
23/// whether the anchor arm references a real collection.
24pub fn plan_recursive_cte(
25    query: &Query,
26    catalog: &dyn SqlCatalog,
27    functions: &FunctionRegistry,
28    temporal: crate::TemporalScope,
29) -> Result<SqlPlan> {
30    let with = query.with.as_ref().ok_or_else(|| SqlError::Parse {
31        detail: "expected WITH clause".into(),
32    })?;
33
34    let cte = with.cte_tables.first().ok_or_else(|| SqlError::Parse {
35        detail: "empty WITH clause".into(),
36    })?;
37
38    let cte_name = normalize_ident(&cte.alias.name);
39    let declared_columns: Vec<String> = cte
40        .alias
41        .columns
42        .iter()
43        .map(|c| normalize_ident(&c.name))
44        .collect();
45
46    let cte_query = &cte.query;
47
48    // Validate set operator: only UNION / UNION ALL permitted.
49    let (left, right, set_quantifier) = match &*cte_query.body {
50        SetExpr::SetOperation {
51            op: ast::SetOperator::Union,
52            left,
53            right,
54            set_quantifier,
55        } => (left, right, set_quantifier),
56        SetExpr::SetOperation { op, .. } => {
57            return Err(SqlError::InvalidRecursiveSetOp {
58                op: format!("{op}"),
59            });
60        }
61        _ => {
62            return Err(SqlError::InvalidRecursiveSetOp {
63                op: "non-set-operation".into(),
64            });
65        }
66    };
67
68    // Validate self-reference count in the recursive arm.
69    validate_self_ref_count(right, &cte_name)?;
70
71    let distinct = !matches!(set_quantifier, ast::SetQuantifier::All);
72
73    // Try to detect whether this is a collection-backed or value-generating CTE
74    // by attempting to plan the anchor arm against the catalog.
75    match plan_cte_branch(left, catalog, functions, temporal) {
76        Ok(base) => {
77            let collection = extract_collection(&base);
78            if collection.is_empty() {
79                // Anchor planned but produced no collection → treat as value-gen.
80                plan_recursive_value(left, right, &cte_name, &declared_columns, distinct)
81            } else {
82                plan_recursive_scan_from_parts(
83                    &cte_name,
84                    &base,
85                    &RecursiveParts {
86                        left,
87                        right,
88                        declared_columns: &declared_columns,
89                        distinct,
90                    },
91                    catalog,
92                    functions,
93                    temporal,
94                )
95            }
96        }
97        Err(_) => {
98            // Anchor references CTE name or uses value expressions → value-gen.
99            plan_recursive_value(left, right, &cte_name, &declared_columns, distinct)
100        }
101    }
102}
103
104// ── Collection-backed recursive scan ─────────────────────────────────────────
105
106struct RecursiveParts<'a> {
107    left: &'a SetExpr,
108    right: &'a SetExpr,
109    declared_columns: &'a [String],
110    distinct: bool,
111}
112
113fn plan_recursive_scan_from_parts(
114    cte_name: &str,
115    base: &SqlPlan,
116    parts: &RecursiveParts<'_>,
117    catalog: &dyn SqlCatalog,
118    functions: &FunctionRegistry,
119    temporal: crate::TemporalScope,
120) -> Result<SqlPlan> {
121    let RecursiveParts {
122        left,
123        right,
124        declared_columns,
125        distinct,
126    } = parts;
127    let collection = extract_collection(base);
128
129    // Validate column count if columns were declared.
130    if !declared_columns.is_empty() {
131        let anchor_cols = count_select_cols(left);
132        if anchor_cols != 0 && anchor_cols != declared_columns.len() {
133            return Err(SqlError::RecursiveColumnMismatch {
134                cte_name: cte_name.to_owned(),
135                anchor_cols,
136                declared_cols: declared_columns.len(),
137            });
138        }
139    }
140
141    let (recursive_filters, join_link) = match plan_cte_branch(right, catalog, functions, temporal)
142    {
143        Ok(plan) => (extract_filters(&plan), None),
144        Err(_) => super::join_link::extract_recursive_info(right, cte_name)?,
145    };
146
147    // The anchor plan carries the CTE's resolved output columns; propagate
148    // them so the recursive scan self-describes its output schema.
149    let projection = match base {
150        SqlPlan::Scan { projection, .. } | SqlPlan::Join { projection, .. } => projection.clone(),
151        _ => Vec::new(),
152    };
153
154    Ok(SqlPlan::RecursiveScan {
155        collection,
156        base_filters: extract_filters(base),
157        recursive_filters,
158        join_link,
159        max_iterations: DEFAULT_MAX_RECURSION_DEPTH,
160        distinct: *distinct,
161        limit: 10000,
162        projection,
163    })
164}
165
166pub(super) fn plan_cte_branch(
167    expr: &SetExpr,
168    catalog: &dyn SqlCatalog,
169    functions: &FunctionRegistry,
170    temporal: crate::TemporalScope,
171) -> Result<SqlPlan> {
172    match expr {
173        SetExpr::Select(select) => {
174            let query = Query {
175                with: None,
176                body: Box::new(SetExpr::Select(select.clone())),
177                order_by: None,
178                limit_clause: None,
179                fetch: None,
180                locks: Vec::new(),
181                for_clause: None,
182                settings: None,
183                format_clause: None,
184                pipe_operators: Vec::new(),
185            };
186            crate::planner::select::plan_query(&query, catalog, functions, temporal)
187        }
188        _ => Err(SqlError::Unsupported {
189            detail: "CTE branch must be SELECT".into(),
190        }),
191    }
192}
193
194pub(super) fn extract_collection(plan: &SqlPlan) -> String {
195    match plan {
196        SqlPlan::Scan { collection, .. } => collection.clone(),
197        _ => String::new(),
198    }
199}
200
201pub(super) fn extract_filters(plan: &SqlPlan) -> Vec<Filter> {
202    match plan {
203        SqlPlan::Scan { filters, .. } => filters.clone(),
204        _ => Vec::new(),
205    }
206}