1use crate::types::{ArrayAggPlan, ArrayElementwisePlan, ArrayProjectPlan, ArraySlicePlan};
7use sqlparser::ast;
8
9use nodedb_types::Value;
10
11use super::helpers::{
12 collect_args, expect_string_array, expect_string_literal, expect_u32, is_null_literal,
13 require_array_name, value_to_coord_literal,
14};
15use crate::error::{Result, SqlError};
16use crate::parser::object_literal::parse_object_literal_complete;
17use crate::temporal::TemporalScope;
18use crate::types::{SqlCatalog, SqlPlan};
19use crate::types_array::{ArrayBinaryOpAst, ArrayReducerAst, ArraySliceAst, NamedDimRange};
20
21pub fn try_plan_array_table_fn(
33 from: &[ast::TableWithJoins],
34 catalog: &dyn SqlCatalog,
35 temporal: TemporalScope,
36) -> Result<Option<SqlPlan>> {
37 if from.len() != 1 {
38 return Ok(None);
39 }
40 let twj = &from[0];
41 if !twj.joins.is_empty() {
42 return Ok(None);
43 }
44 let (name, args) = match &twj.relation {
45 ast::TableFactor::Table {
46 name,
47 args: Some(args),
48 alias,
49 ..
50 } => {
51 if let Some(alias) = alias {
52 crate::reserved::check_ast_identifier(&alias.name)?;
53 }
54 (name, args)
55 }
56 _ => return Ok(None),
57 };
58 let fn_name = crate::parser::normalize::normalize_object_name_checked(name)?;
59 let arg_exprs = collect_args(&args.args);
60 match fn_name.as_str() {
61 "array_slice" => Ok(Some(plan_slice(&arg_exprs, catalog, temporal)?)),
62 "array_project" => Ok(Some(plan_project(&arg_exprs, catalog)?)),
63 "array_agg" => Ok(Some(plan_agg(&arg_exprs, catalog, temporal)?)),
64 "array_elementwise" => Ok(Some(plan_elementwise(&arg_exprs, catalog)?)),
65 _ => Ok(None),
66 }
67}
68
69fn plan_slice(
70 args: &[ast::Expr],
71 catalog: &dyn SqlCatalog,
72 temporal: TemporalScope,
73) -> Result<SqlPlan> {
74 if args.len() < 2 || args.len() > 4 {
75 return Err(SqlError::Unsupported {
76 detail: format!(
77 "ARRAY_SLICE expects 2..=4 args (name, slice_obj, [attrs], [limit]); got {}",
78 args.len()
79 ),
80 });
81 }
82 let name = require_array_name(args, 0, "ARRAY_SLICE", catalog)?;
83 let view = catalog
84 .lookup_array(&name)
85 .ok_or_else(|| SqlError::Unsupported {
86 detail: format!("ARRAY_SLICE: array '{name}' not found"),
87 })?;
88
89 let slice_str = expect_string_literal(&args[1], "ARRAY_SLICE slice predicate")?;
94 let parsed =
95 parse_object_literal_complete(&slice_str).ok_or_else(|| SqlError::Unsupported {
96 detail: format!("ARRAY_SLICE: slice predicate must be an object literal: {slice_str}"),
97 })?;
98 let map = parsed.map_err(|detail| SqlError::Unsupported {
99 detail: format!("ARRAY_SLICE: slice parse: {detail}"),
100 })?;
101 let mut dim_ranges: Vec<NamedDimRange> = Vec::with_capacity(map.len());
102 for (dim, val) in map {
103 if !view.dims.iter().any(|d| d.name == dim) {
105 return Err(SqlError::Unsupported {
106 detail: format!("ARRAY_SLICE: array '{name}' has no dim '{dim}'"),
107 });
108 }
109 let arr = match val {
110 Value::Array(a) if a.len() == 2 => a,
111 _ => {
112 return Err(SqlError::Unsupported {
113 detail: format!(
114 "ARRAY_SLICE: dim '{dim}' range must be a 2-element array [lo, hi]"
115 ),
116 });
117 }
118 };
119 let lo = value_to_coord_literal(&arr[0], &dim)?;
120 let hi = value_to_coord_literal(&arr[1], &dim)?;
121 dim_ranges.push(NamedDimRange { dim, lo, hi });
122 }
123
124 let attr_projection = if args.len() >= 3 {
125 match &args[2] {
126 ast::Expr::Value(v) if matches!(v.value, ast::Value::SingleQuotedString(ref s) if s == "*") => {
127 Vec::new()
128 }
129 _ => expect_string_array(&args[2], "ARRAY_SLICE attr projection")?,
130 }
131 } else {
132 Vec::new()
133 };
134 for attr in &attr_projection {
136 if !view.attrs.iter().any(|a| &a.name == attr) {
137 return Err(SqlError::Unsupported {
138 detail: format!("ARRAY_SLICE: array '{name}' has no attr '{attr}'"),
139 });
140 }
141 }
142
143 let limit = if args.len() >= 4 {
144 expect_u32(&args[3], "ARRAY_SLICE limit")?
145 } else {
146 0
147 };
148
149 Ok(SqlPlan::ArraySlice(ArraySlicePlan {
150 name,
151 slice: ArraySliceAst { dim_ranges },
152 attr_projection,
153 limit,
154 temporal,
155 }))
156}
157
158fn plan_project(args: &[ast::Expr], catalog: &dyn SqlCatalog) -> Result<SqlPlan> {
159 if args.len() != 2 {
160 return Err(SqlError::Unsupported {
161 detail: format!(
162 "ARRAY_PROJECT expects 2 args (name, [attrs]); got {}",
163 args.len()
164 ),
165 });
166 }
167 let name = require_array_name(args, 0, "ARRAY_PROJECT", catalog)?;
168 let view = catalog
169 .lookup_array(&name)
170 .ok_or_else(|| SqlError::Unsupported {
171 detail: format!("ARRAY_PROJECT: array '{name}' not found"),
172 })?;
173 let attr_projection = expect_string_array(&args[1], "ARRAY_PROJECT attrs")?;
174 if attr_projection.is_empty() {
175 return Err(SqlError::Unsupported {
176 detail: "ARRAY_PROJECT: attr list must not be empty".into(),
177 });
178 }
179 for attr in &attr_projection {
180 if !view.attrs.iter().any(|a| &a.name == attr) {
181 return Err(SqlError::Unsupported {
182 detail: format!("ARRAY_PROJECT: array '{name}' has no attr '{attr}'"),
183 });
184 }
185 }
186 Ok(SqlPlan::ArrayProject(ArrayProjectPlan {
187 name,
188 attr_projection,
189 }))
190}
191
192fn plan_agg(
193 args: &[ast::Expr],
194 catalog: &dyn SqlCatalog,
195 temporal: TemporalScope,
196) -> Result<SqlPlan> {
197 if args.len() < 3 || args.len() > 4 {
198 return Err(SqlError::Unsupported {
199 detail: format!(
200 "ARRAY_AGG expects 3..=4 args (name, attr, reducer, [group_by_dim]); got {}",
201 args.len()
202 ),
203 });
204 }
205 let name = require_array_name(args, 0, "ARRAY_AGG", catalog)?;
206 let view = catalog
207 .lookup_array(&name)
208 .ok_or_else(|| SqlError::Unsupported {
209 detail: format!("ARRAY_AGG: array '{name}' not found"),
210 })?;
211
212 let attr = expect_string_literal(&args[1], "ARRAY_AGG attr")?;
213 if !view.attrs.iter().any(|a| a.name == attr) {
214 return Err(SqlError::Unsupported {
215 detail: format!("ARRAY_AGG: array '{name}' has no attr '{attr}'"),
216 });
217 }
218
219 let reducer_str = expect_string_literal(&args[2], "ARRAY_AGG reducer")?;
220 let reducer = ArrayReducerAst::parse(&reducer_str).ok_or_else(|| SqlError::Unsupported {
221 detail: format!("ARRAY_AGG: unknown reducer '{reducer_str}' (want sum/count/min/max/mean)"),
222 })?;
223
224 let group_by_dim = if args.len() == 4 && !is_null_literal(&args[3]) {
225 let dim = expect_string_literal(&args[3], "ARRAY_AGG group_by_dim")?;
226 if !view.dims.iter().any(|d| d.name == dim) {
227 return Err(SqlError::Unsupported {
228 detail: format!("ARRAY_AGG: array '{name}' has no dim '{dim}'"),
229 });
230 }
231 Some(dim)
232 } else {
233 None
234 };
235
236 Ok(SqlPlan::ArrayAgg(ArrayAggPlan {
237 name,
238 attr,
239 reducer,
240 group_by_dim,
241 temporal,
242 }))
243}
244
245fn plan_elementwise(args: &[ast::Expr], catalog: &dyn SqlCatalog) -> Result<SqlPlan> {
246 if args.len() != 4 {
247 return Err(SqlError::Unsupported {
248 detail: format!(
249 "ARRAY_ELEMENTWISE expects 4 args (left, right, op, attr); got {}",
250 args.len()
251 ),
252 });
253 }
254 let left = require_array_name(args, 0, "ARRAY_ELEMENTWISE", catalog)?;
255 let right = require_array_name(args, 1, "ARRAY_ELEMENTWISE", catalog)?;
256 let lview = catalog
257 .lookup_array(&left)
258 .ok_or_else(|| SqlError::Unsupported {
259 detail: format!("ARRAY_ELEMENTWISE: array '{left}' not found"),
260 })?;
261 let rview = catalog
262 .lookup_array(&right)
263 .ok_or_else(|| SqlError::Unsupported {
264 detail: format!("ARRAY_ELEMENTWISE: array '{right}' not found"),
265 })?;
266 if lview.dims.len() != rview.dims.len() || lview.attrs.len() != rview.attrs.len() {
267 return Err(SqlError::Unsupported {
268 detail: format!(
269 "ARRAY_ELEMENTWISE: arrays '{left}' and '{right}' must share schema shape"
270 ),
271 });
272 }
273 let op_str = expect_string_literal(&args[2], "ARRAY_ELEMENTWISE op")?;
274 let op = ArrayBinaryOpAst::parse(&op_str).ok_or_else(|| SqlError::Unsupported {
275 detail: format!("ARRAY_ELEMENTWISE: unknown op '{op_str}' (want add/sub/mul/div)"),
276 })?;
277 let attr = expect_string_literal(&args[3], "ARRAY_ELEMENTWISE attr")?;
278 if !lview.attrs.iter().any(|a| a.name == attr) {
279 return Err(SqlError::Unsupported {
280 detail: format!("ARRAY_ELEMENTWISE: array '{left}' has no attr '{attr}'"),
281 });
282 }
283 Ok(SqlPlan::ArrayElementwise(ArrayElementwisePlan {
284 left,
285 right,
286 op,
287 attr,
288 }))
289}
290
291#[cfg(test)]
292mod tests {
293 use crate::catalog::{ArrayCatalogView, SqlCatalogError};
294 use crate::error::Result;
295 use crate::functions::registry::FunctionRegistry;
296 use crate::parser::statement::parse_sql;
297 use crate::types::{ArrayAggPlan, ArrayProjectPlan, ArraySlicePlan};
298 use crate::types::{CollectionInfo, SqlCatalog, SqlPlan};
299 use crate::types_array::{
300 ArrayAttrAst, ArrayAttrType, ArrayDimAst, ArrayDimType, ArrayDomainBound, ArrayReducerAst,
301 };
302
303 struct StubCatalog {
304 view: Option<ArrayCatalogView>,
305 right_view: Option<ArrayCatalogView>,
306 }
307 impl SqlCatalog for StubCatalog {
308 fn get_collection(
309 &self,
310 _: nodedb_types::DatabaseId,
311 _name: &str,
312 ) -> std::result::Result<Option<CollectionInfo>, SqlCatalogError> {
313 Ok(None)
314 }
315 fn lookup_array(&self, name: &str) -> Option<ArrayCatalogView> {
316 if name == "g" || name == "left" {
317 self.view.clone()
318 } else if name == "right" {
319 self.right_view.clone()
320 } else {
321 None
322 }
323 }
324 }
325
326 fn view() -> ArrayCatalogView {
327 ArrayCatalogView {
328 name: "g".into(),
329 dims: vec![
330 ArrayDimAst {
331 name: "chrom".into(),
332 dtype: ArrayDimType::Int64,
333 lo: ArrayDomainBound::Int64(1),
334 hi: ArrayDomainBound::Int64(23),
335 },
336 ArrayDimAst {
337 name: "pos".into(),
338 dtype: ArrayDimType::Int64,
339 lo: ArrayDomainBound::Int64(0),
340 hi: ArrayDomainBound::Int64(1_000_000),
341 },
342 ],
343 attrs: vec![
344 ArrayAttrAst {
345 name: "variant".into(),
346 dtype: ArrayAttrType::String,
347 nullable: true,
348 },
349 ArrayAttrAst {
350 name: "qual".into(),
351 dtype: ArrayAttrType::Float64,
352 nullable: true,
353 },
354 ],
355 tile_extents: vec![1, 1_000_000],
356 }
357 }
358
359 fn cat() -> StubCatalog {
360 StubCatalog {
361 view: Some(view()),
362 right_view: Some(view()),
363 }
364 }
365
366 fn plan_one(sql: &str) -> Result<SqlPlan> {
367 let stmts = parse_sql(sql)?;
368 let q = match &stmts[0] {
369 sqlparser::ast::Statement::Query(q) => q,
370 _ => panic!("not a query"),
371 };
372 crate::planner::select::plan_query(
373 q,
374 &cat(),
375 &FunctionRegistry::new(),
376 crate::TemporalScope::default(),
377 )
378 }
379
380 #[test]
381 fn slice_happy() {
382 let p = plan_one(
383 "SELECT * FROM ARRAY_SLICE('g', '{chrom: [1,1], pos: [0, 100]}', ['qual'], 50)",
384 )
385 .unwrap();
386 match p {
387 SqlPlan::ArraySlice(ArraySlicePlan {
388 name,
389 slice,
390 attr_projection,
391 limit,
392 ..
393 }) => {
394 assert_eq!(name, "g");
395 assert_eq!(slice.dim_ranges.len(), 2);
396 assert_eq!(attr_projection, vec!["qual".to_string()]);
397 assert_eq!(limit, 50);
398 }
399 other => panic!("expected ArraySlice, got {other:?}"),
400 }
401 }
402
403 #[test]
404 fn slice_unknown_dim_rejected() {
405 let err = plan_one("SELECT * FROM ARRAY_SLICE('g', '{nope: [1, 2]}')")
406 .err()
407 .unwrap();
408 assert!(format!("{err}").contains("no dim"));
409 }
410
411 #[test]
412 fn project_happy() {
413 let p = plan_one("SELECT * FROM ARRAY_PROJECT('g', ['qual', 'variant'])").unwrap();
414 match p {
415 SqlPlan::ArrayProject(ArrayProjectPlan {
416 name,
417 attr_projection,
418 }) => {
419 assert_eq!(name, "g");
420 assert_eq!(attr_projection, vec!["qual".to_string(), "variant".into()]);
421 }
422 other => panic!("expected ArrayProject, got {other:?}"),
423 }
424 }
425
426 #[test]
427 fn project_empty_rejected() {
428 assert!(plan_one("SELECT * FROM ARRAY_PROJECT('g', ARRAY[])").is_err());
429 }
430
431 #[test]
432 fn agg_scalar() {
433 let p = plan_one("SELECT * FROM ARRAY_AGG('g', 'qual', 'sum')").unwrap();
434 match p {
435 SqlPlan::ArrayAgg(ArrayAggPlan {
436 name,
437 attr,
438 reducer,
439 group_by_dim,
440 ..
441 }) => {
442 assert_eq!(name, "g");
443 assert_eq!(attr, "qual");
444 assert_eq!(reducer, ArrayReducerAst::Sum);
445 assert!(group_by_dim.is_none());
446 }
447 other => panic!("expected ArrayAgg, got {other:?}"),
448 }
449 }
450
451 #[test]
452 fn agg_grouped() {
453 let p = plan_one("SELECT * FROM ARRAY_AGG('g', 'qual', 'mean', 'chrom')").unwrap();
454 match p {
455 SqlPlan::ArrayAgg(ArrayAggPlan {
456 reducer,
457 group_by_dim,
458 ..
459 }) => {
460 assert_eq!(reducer, ArrayReducerAst::Mean);
461 assert_eq!(group_by_dim, Some("chrom".into()));
462 }
463 other => panic!("expected ArrayAgg, got {other:?}"),
464 }
465 }
466
467 #[test]
468 fn agg_unknown_reducer_rejected() {
469 assert!(plan_one("SELECT * FROM ARRAY_AGG('g', 'qual', 'bogus')").is_err());
470 }
471
472 #[test]
473 fn elementwise_happy() {
474 let p =
475 plan_one("SELECT * FROM ARRAY_ELEMENTWISE('left', 'right', 'add', 'qual')").unwrap();
476 assert!(matches!(p, SqlPlan::ArrayElementwise { .. }));
477 }
478
479 #[test]
480 fn elementwise_unknown_op_rejected() {
481 assert!(
482 plan_one("SELECT * FROM ARRAY_ELEMENTWISE('left', 'right', 'wat', 'qual')").is_err()
483 );
484 }
485}