Skip to main content

nodedb_sql/planner/aggregate_cp_wrap/
wrap.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Wrapping a grouped plan so a Control-Plane-computed SELECT-list item keeps
4//! its position.
5//!
6//! An `Aggregate` plan emits group keys and aggregate values only. A SELECT
7//! item that calls a sequence accessor is neither, so the aggregate cannot
8//! carry it. The planner wraps the finished aggregate in a `Subquery` whose
9//! projection restates every output column in SELECT-list order and places a
10//! [`crate::types::Projection::CpComputed`] entry at the item's position. The wrap runs
11//! after ORDER BY and LIMIT are attached, so those stay on the aggregate.
12
13use crate::types::CtePlan;
14use sqlparser::ast;
15
16use super::projection::aggregate_cp_projection;
17use crate::error::{Result, SqlError};
18use crate::functions::registry::FunctionRegistry;
19use crate::planner::cp_projection::ast_sequence_accessor;
20use crate::resolver::columns::TableScope;
21use crate::types::SqlPlan;
22
23/// Wrap `plan` when `items` hold a sequence accessor the aggregate cannot
24/// carry. A plan with no such item, or one that is not grouped, returns
25/// unchanged: `convert_projection` marks the item on a row plan itself.
26///
27/// `grouped` says whether the SELECT aggregates (GROUP BY, or an aggregate
28/// call in its list). `scope` is the relation set the SELECT resolved
29/// against. A grouped SELECT that does not produce the statement's output
30/// rows (a subquery, CTE body, UNION branch, derived table) refuses the
31/// item: the aggregate planner never converts a bare non-key item, so
32/// nothing else reports it.
33pub fn wrap_aggregate_cp_items(
34    plan: SqlPlan,
35    items: &[ast::SelectItem],
36    grouped: bool,
37    functions: &FunctionRegistry,
38    scope: &TableScope,
39) -> Result<SqlPlan> {
40    if !grouped || !scope.is_row_scope() {
41        return Ok(plan);
42    }
43    let Some(accessor) = items.iter().find_map(item_accessor) else {
44        return Ok(plan);
45    };
46    if !scope.is_statement_output() {
47        return Err(SqlError::SequencePerRowUnsupported { name: accessor });
48    }
49    match plan {
50        SqlPlan::Cte(CtePlan { definitions, outer }) => Ok(SqlPlan::Cte(CtePlan {
51            definitions,
52            outer: Box::new(wrap_aggregate_cp_items(
53                *outer, items, grouped, functions, scope,
54            )?),
55        })),
56        SqlPlan::Aggregate { .. } => {
57            let projection = aggregate_cp_projection(&plan, items, functions, scope)?;
58            Ok(SqlPlan::Subquery {
59                input: Box::new(plan),
60                filters: Vec::new(),
61                projection,
62                window_functions: Vec::new(),
63                sort_keys: Vec::new(),
64                offset: 0,
65                distinct: false,
66                limit: None,
67            })
68        }
69        // An OFFSET the aggregate cannot hold already sits in a
70        // post-processing tail with no projection of its own. The tail
71        // projects after it offsets and sorts, so the restated columns land
72        // on it instead of a second wrapper.
73        SqlPlan::Subquery {
74            input,
75            filters,
76            projection,
77            window_functions,
78            sort_keys,
79            offset,
80            distinct,
81            limit,
82        } if matches!(*input, SqlPlan::Aggregate { .. }) && projection.is_empty() => {
83            let projection = aggregate_cp_projection(&input, items, functions, scope)?;
84            Ok(SqlPlan::Subquery {
85                input,
86                filters,
87                projection,
88                window_functions,
89                sort_keys,
90                offset,
91                distinct,
92                limit,
93            })
94        }
95        // A grouped query the engine rules planned as another shape (a
96        // timeseries bucket scan, a grouped query joined to a scalar
97        // subquery) names its output columns by rules this wrap does not
98        // restate. Refusing beats dropping the item from the result.
99        other => Err(SqlError::Unsupported {
100            detail: format!(
101                "a sequence accessor in the SELECT list of a grouped query planned as {}; \
102                 call it in a FROM-less SELECT instead",
103                other.variant_name()
104            ),
105        }),
106    }
107}
108
109/// The sequence accessor a SELECT item calls, when it calls one.
110fn item_accessor(item: &ast::SelectItem) -> Option<String> {
111    match item {
112        ast::SelectItem::UnnamedExpr(expr) | ast::SelectItem::ExprWithAlias { expr, .. } => {
113            ast_sequence_accessor(expr)
114        }
115        ast::SelectItem::ExprWithAliases { .. }
116        | ast::SelectItem::Wildcard(_)
117        | ast::SelectItem::QualifiedWildcard(..) => None,
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use crate::types::{
124        CollectionInfo, EngineType, PlanCacheEligibility, Projection, SqlCatalog, SqlCatalogError,
125        SqlExpr, SqlPlan,
126    };
127    use crate::{SqlError, plan_sql};
128
129    struct Catalog;
130
131    impl SqlCatalog for Catalog {
132        fn get_collection(
133            &self,
134            _: nodedb_types::DatabaseId,
135            name: &str,
136        ) -> std::result::Result<Option<CollectionInfo>, SqlCatalogError> {
137            if name != "t" {
138                return Ok(None);
139            }
140            Ok(Some(CollectionInfo {
141                name: "t".into(),
142                engine: EngineType::DocumentSchemaless,
143                columns: Vec::new(),
144                primary_key: Some("id".into()),
145                has_auto_tier: false,
146                indexes: Vec::new(),
147                bitemporal: false,
148                primary: nodedb_types::PrimaryEngine::Document,
149                vector_primary: None,
150                partition_strategy: nodedb_types::PartitionStrategy::CollectionHomed,
151                open_schema: CollectionInfo::open_schema_for(EngineType::DocumentSchemaless),
152            }))
153        }
154    }
155
156    fn plan(sql: &str) -> SqlPlan {
157        plan_sql(sql, &Catalog)
158            .unwrap_or_else(|e| panic!("{sql}: {e}"))
159            .remove(0)
160    }
161
162    fn plan_err(sql: &str) -> SqlError {
163        match plan_sql(sql, &Catalog) {
164            Ok(plans) => panic!("{sql} must not plan, got {plans:?}"),
165            Err(e) => e,
166        }
167    }
168
169    fn is_nextval(expr: &SqlExpr) -> bool {
170        matches!(expr, SqlExpr::Function { name, .. } if name == "nextval")
171    }
172
173    #[test]
174    fn a_scan_marks_the_accessor_item_as_control_plane_computed() {
175        let plan = plan("SELECT id, nextval('s') FROM t");
176        let SqlPlan::Scan { projection, .. } = &plan else {
177            panic!("expected Scan, got {plan:?}");
178        };
179        assert_eq!(projection.len(), 2);
180        assert!(matches!(&projection[0], Projection::Column(name) if name == "id"));
181        match &projection[1] {
182            Projection::CpComputed { expr, alias } => {
183                assert_eq!(alias, "nextval('s')");
184                assert!(is_nextval(expr));
185            }
186            other => panic!("expected CpComputed, got {other:?}"),
187        }
188        assert_eq!(
189            plan.cache_eligibility(),
190            PlanCacheEligibility::DataDependent
191        );
192    }
193
194    #[test]
195    fn a_wider_aliased_expression_keeps_its_alias() {
196        let plan = plan("SELECT nextval('s') * 2 AS n FROM t");
197        let SqlPlan::Scan { projection, .. } = &plan else {
198            panic!("expected Scan, got {plan:?}");
199        };
200        match &projection[0] {
201            Projection::CpComputed { expr, alias } => {
202                assert_eq!(alias, "n");
203                assert!(matches!(expr, SqlExpr::BinaryOp { .. }));
204            }
205            other => panic!("expected CpComputed, got {other:?}"),
206        }
207    }
208
209    #[test]
210    fn every_other_row_clause_refuses_the_accessor() {
211        for sql in [
212            "SELECT id FROM t WHERE nextval('s') > 0",
213            "SELECT id FROM t ORDER BY nextval('s')",
214            "SELECT SUM(nextval('s')) FROM t",
215            "SELECT grp, COUNT(*) + nextval('s') FROM t GROUP BY grp",
216            "SELECT grp, COUNT(*) FROM t GROUP BY grp HAVING COUNT(*) > nextval('s')",
217            "SELECT n FROM (SELECT id, nextval('s') AS n FROM t) d",
218            "SELECT n FROM (SELECT grp, COUNT(*), nextval('s') AS n FROM t GROUP BY grp) d",
219            "SELECT id FROM t UNION ALL SELECT nextval('s') FROM t",
220            "WITH c AS (SELECT nextval('s') AS n FROM t) SELECT n FROM c",
221            "INSERT INTO t (id, n) SELECT id, nextval('s') FROM t",
222        ] {
223            let err = plan_err(sql);
224            assert!(
225                matches!(err, SqlError::SequencePerRowUnsupported { .. }),
226                "{sql}: expected SequencePerRowUnsupported, got {err:?}"
227            );
228        }
229    }
230
231    #[test]
232    fn a_grouped_query_wraps_the_aggregate_in_select_list_order() {
233        let plan = plan("SELECT grp, COUNT(*), nextval('s') FROM t GROUP BY grp");
234        let SqlPlan::Subquery {
235            input,
236            projection,
237            filters,
238            sort_keys,
239            limit,
240            ..
241        } = &plan
242        else {
243            panic!("expected Subquery, got {plan:?}");
244        };
245        assert!(matches!(**input, SqlPlan::Aggregate { .. }));
246        assert!(filters.is_empty());
247        assert!(sort_keys.is_empty());
248        assert_eq!(*limit, None);
249        assert_eq!(projection.len(), 3);
250        assert!(matches!(&projection[0], Projection::Column(name) if name == "grp"));
251        assert!(matches!(&projection[1], Projection::Column(name) if name == "count(*)"));
252        assert!(matches!(&projection[2], Projection::CpComputed { expr, .. } if is_nextval(expr)));
253        assert_eq!(
254            plan.cache_eligibility(),
255            PlanCacheEligibility::DataDependent
256        );
257    }
258
259    #[test]
260    fn order_by_and_limit_stay_on_the_wrapped_aggregate() {
261        let plan = plan(
262            "SELECT t.grp, nextval('s') + t.grp AS n, COUNT(*) AS c FROM t \
263             GROUP BY t.grp ORDER BY c DESC LIMIT 5",
264        );
265        let SqlPlan::Subquery {
266            input, projection, ..
267        } = &plan
268        else {
269            panic!("expected Subquery, got {plan:?}");
270        };
271        let SqlPlan::Aggregate {
272            sort_keys, limit, ..
273        } = &**input
274        else {
275            panic!("expected Aggregate, got {input:?}");
276        };
277        assert_eq!(sort_keys.len(), 1);
278        assert_eq!(*limit, 5);
279        assert!(matches!(&projection[0], Projection::Column(name) if name == "grp"));
280        match &projection[1] {
281            Projection::CpComputed { expr, alias } => {
282                assert_eq!(alias, "n");
283                let SqlExpr::BinaryOp { right, .. } = expr else {
284                    panic!("expected a binary expression, got {expr:?}");
285                };
286                assert!(
287                    matches!(&**right, SqlExpr::Column { table: None, name } if name == "grp"),
288                    "the key reference must lose its qualifier, got {right:?}"
289                );
290            }
291            other => panic!("expected CpComputed, got {other:?}"),
292        }
293        assert!(matches!(&projection[2], Projection::Column(name) if name == "c"));
294    }
295
296    #[test]
297    fn an_offset_tail_carries_the_restated_projection_itself() {
298        let plan = plan("SELECT grp, nextval('s'), COUNT(*) FROM t GROUP BY grp OFFSET 2");
299        let SqlPlan::Subquery {
300            input,
301            projection,
302            offset,
303            ..
304        } = &plan
305        else {
306            panic!("expected Subquery, got {plan:?}");
307        };
308        assert!(matches!(**input, SqlPlan::Aggregate { .. }));
309        assert_eq!(*offset, 2);
310        assert_eq!(projection.len(), 3);
311        assert!(matches!(&projection[1], Projection::CpComputed { .. }));
312    }
313
314    #[test]
315    fn a_grouped_accessor_item_may_reference_group_keys_only() {
316        let err = plan_err("SELECT grp, COUNT(*), nextval('s') + other FROM t GROUP BY grp");
317        assert!(
318            matches!(err, SqlError::Unsupported { ref detail } if detail.contains("'other'")),
319            "got {err:?}"
320        );
321    }
322
323    #[test]
324    fn a_from_less_select_still_folds_the_accessor_at_plan_time() {
325        let plan = plan("SELECT 1 AS one");
326        assert!(matches!(plan, SqlPlan::ConstantResult { .. }));
327        let err = plan_err("SELECT nextval('s')");
328        assert!(
329            !matches!(err, SqlError::SequencePerRowUnsupported { .. }),
330            "a FROM-less accessor reaches the catalog, not the per-row gate: {err:?}"
331        );
332    }
333}