Skip to main content

cypherlite_query/executor/operators/
unwind.rs

1// UnwindOp: flatten a list expression into individual rows
2
3use crate::executor::eval::eval;
4use crate::executor::{ExecutionError, Params, Record, ScalarFnLookup, Value};
5use crate::parser::ast::Expression;
6use cypherlite_storage::StorageEngine;
7
8/// Execute UNWIND: for each source record, evaluate the expression.
9/// If the result is a List, emit one row per element with the variable bound.
10/// If the result is Null, emit zero rows (skip the source record).
11/// If the result is not a List and not Null, return an error.
12pub fn execute_unwind(
13    source_records: Vec<Record>,
14    expr: &Expression,
15    variable: &str,
16    engine: &StorageEngine,
17    params: &Params,
18    scalar_fns: &dyn ScalarFnLookup,
19) -> Result<Vec<Record>, ExecutionError> {
20    let mut results = Vec::new();
21
22    for record in &source_records {
23        let value = eval(expr, record, engine, params, scalar_fns)?;
24        match value {
25            Value::List(elements) => {
26                for element in elements {
27                    let mut new_record = record.clone();
28                    new_record.insert(variable.to_string(), element);
29                    results.push(new_record);
30                }
31            }
32            Value::Null => {
33                // UNWIND NULL produces zero rows -- skip this source record.
34            }
35            _ => {
36                return Err(ExecutionError {
37                    message: format!("UNWIND expected a list or null, got {:?}", value),
38                });
39            }
40        }
41    }
42
43    Ok(results)
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49    use crate::executor::Value;
50    use crate::parser::ast::*;
51    use cypherlite_core::{DatabaseConfig, SyncMode};
52    use cypherlite_storage::StorageEngine;
53    use tempfile::tempdir;
54
55    fn test_engine(dir: &std::path::Path) -> StorageEngine {
56        let config = DatabaseConfig {
57            path: dir.join("test.cyl"),
58            wal_sync_mode: SyncMode::Normal,
59            ..Default::default()
60        };
61        StorageEngine::open(config).expect("open")
62    }
63
64    // TASK-071: Basic UNWIND list produces one row per element
65    #[test]
66    fn test_unwind_list_produces_rows() {
67        let dir = tempdir().expect("tempdir");
68        let engine = test_engine(dir.path());
69
70        let mut record = Record::new();
71        record.insert("data".to_string(), Value::Int64(42));
72
73        let expr = Expression::ListLiteral(vec![
74            Expression::Literal(Literal::Integer(1)),
75            Expression::Literal(Literal::Integer(2)),
76            Expression::Literal(Literal::Integer(3)),
77        ]);
78
79        let params = Params::new();
80        let result = execute_unwind(vec![record], &expr, "x", &engine, &params, &());
81        let records = result.expect("should succeed");
82
83        assert_eq!(records.len(), 3);
84        assert_eq!(records[0].get("x"), Some(&Value::Int64(1)));
85        assert_eq!(records[1].get("x"), Some(&Value::Int64(2)));
86        assert_eq!(records[2].get("x"), Some(&Value::Int64(3)));
87        // Source columns preserved
88        assert_eq!(records[0].get("data"), Some(&Value::Int64(42)));
89        assert_eq!(records[1].get("data"), Some(&Value::Int64(42)));
90        assert_eq!(records[2].get("data"), Some(&Value::Int64(42)));
91    }
92
93    // TASK-071: UNWIND with multiple source records
94    #[test]
95    fn test_unwind_multiple_source_records() {
96        let dir = tempdir().expect("tempdir");
97        let engine = test_engine(dir.path());
98
99        let mut r1 = Record::new();
100        r1.insert("name".to_string(), Value::String("Alice".into()));
101
102        let mut r2 = Record::new();
103        r2.insert("name".to_string(), Value::String("Bob".into()));
104
105        let expr = Expression::ListLiteral(vec![
106            Expression::Literal(Literal::Integer(1)),
107            Expression::Literal(Literal::Integer(2)),
108        ]);
109
110        let params = Params::new();
111        let result = execute_unwind(vec![r1, r2], &expr, "x", &engine, &params, &());
112        let records = result.expect("should succeed");
113
114        // 2 source records x 2 list elements = 4 output records
115        assert_eq!(records.len(), 4);
116        assert_eq!(records[0].get("name"), Some(&Value::String("Alice".into())));
117        assert_eq!(records[0].get("x"), Some(&Value::Int64(1)));
118        assert_eq!(records[1].get("name"), Some(&Value::String("Alice".into())));
119        assert_eq!(records[1].get("x"), Some(&Value::Int64(2)));
120        assert_eq!(records[2].get("name"), Some(&Value::String("Bob".into())));
121        assert_eq!(records[2].get("x"), Some(&Value::Int64(1)));
122        assert_eq!(records[3].get("name"), Some(&Value::String("Bob".into())));
123        assert_eq!(records[3].get("x"), Some(&Value::Int64(2)));
124    }
125
126    // TASK-072: UNWIND empty list -> produces zero rows
127    #[test]
128    fn test_unwind_empty_list_produces_zero_rows() {
129        let dir = tempdir().expect("tempdir");
130        let engine = test_engine(dir.path());
131
132        let mut record = Record::new();
133        record.insert("data".to_string(), Value::Int64(42));
134
135        let expr = Expression::ListLiteral(vec![]);
136
137        let params = Params::new();
138        let result = execute_unwind(vec![record], &expr, "x", &engine, &params, &());
139        let records = result.expect("should succeed");
140
141        assert!(records.is_empty());
142    }
143
144    // TASK-072: UNWIND NULL -> produces zero rows
145    #[test]
146    fn test_unwind_null_produces_zero_rows() {
147        let dir = tempdir().expect("tempdir");
148        let engine = test_engine(dir.path());
149
150        let mut record = Record::new();
151        record.insert("data".to_string(), Value::Int64(42));
152
153        let expr = Expression::Literal(Literal::Null);
154
155        let params = Params::new();
156        let result = execute_unwind(vec![record], &expr, "x", &engine, &params, &());
157        let records = result.expect("should succeed");
158
159        assert!(records.is_empty());
160    }
161
162    // TASK-072: UNWIND non-list value -> ExecutionError
163    #[test]
164    fn test_unwind_non_list_returns_error() {
165        let dir = tempdir().expect("tempdir");
166        let engine = test_engine(dir.path());
167
168        let record = Record::new();
169        let expr = Expression::Literal(Literal::Integer(42));
170
171        let params = Params::new();
172        let result = execute_unwind(vec![record], &expr, "x", &engine, &params, &());
173
174        assert!(result.is_err());
175        let err = result.unwrap_err();
176        assert!(
177            err.message.contains("expected a list or null"),
178            "expected list error, got: {}",
179            err.message
180        );
181    }
182
183    // TASK-072: UNWIND non-list string -> ExecutionError
184    #[test]
185    fn test_unwind_string_returns_error() {
186        let dir = tempdir().expect("tempdir");
187        let engine = test_engine(dir.path());
188
189        let record = Record::new();
190        let expr = Expression::Literal(Literal::String("not a list".into()));
191
192        let params = Params::new();
193        let result = execute_unwind(vec![record], &expr, "x", &engine, &params, &());
194
195        assert!(result.is_err());
196    }
197
198    // TASK-071: UNWIND with empty source records
199    #[test]
200    fn test_unwind_empty_source() {
201        let dir = tempdir().expect("tempdir");
202        let engine = test_engine(dir.path());
203
204        let expr = Expression::ListLiteral(vec![Expression::Literal(Literal::Integer(1))]);
205
206        let params = Params::new();
207        let result = execute_unwind(vec![], &expr, "x", &engine, &params, &());
208        let records = result.expect("should succeed");
209
210        assert!(records.is_empty());
211    }
212
213    // TASK-071: UNWIND variable referencing a list in record
214    #[test]
215    fn test_unwind_variable_reference() {
216        let dir = tempdir().expect("tempdir");
217        let engine = test_engine(dir.path());
218
219        let mut record = Record::new();
220        record.insert(
221            "items".to_string(),
222            Value::List(vec![Value::String("a".into()), Value::String("b".into())]),
223        );
224
225        let expr = Expression::Variable("items".to_string());
226
227        let params = Params::new();
228        let result = execute_unwind(vec![record], &expr, "x", &engine, &params, &());
229        let records = result.expect("should succeed");
230
231        assert_eq!(records.len(), 2);
232        assert_eq!(records[0].get("x"), Some(&Value::String("a".into())));
233        assert_eq!(records[1].get("x"), Some(&Value::String("b".into())));
234    }
235}