1use 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
23pub 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 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 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
109fn 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}