Skip to main content

akar_processor/physical/write_ops/
recursiveextend.rs

1use crate::physical::common::store_value_in_vector;
2use crate::physical::scan_filter::PhysicalScan;
3use crate::physical::types::{OperatorResult, PhysicalOperatorExec};
4use crate::physical::write_ops::evaluate_expression_for_row;
5use akar_common::error::ProcessorError;
6use akar_common::types::{PhysicalTypeID, Value};
7use akar_common::vector::{DataChunk, ValueVector};
8use akar_storage::table::TableCatalog;
9use std::sync::Arc;
10
11// ==================== RecursiveExtend ====================
12
13/// Physical operator for variable-length path matching (BFS traversal).
14///
15/// For each source node, performs BFS up to `upper_bound` depth and emits
16/// result rows for all nodes reachable at depths between `lower_bound` and
17/// `upper_bound`.
18///
19/// Uses GDS-style path tracking to record actual paths (node IDs + edge IDs)
20/// and enforces path semantics (WALK/TRAIL/ACYCLIC).
21///
22/// Produces a DataChunk with columns:
23///   (src_offset, dst_offset, length, path_node_ids, path_edge_ids[, cost])
24///
25/// When `weight_property` is `Some`, uses Dijkstra's algorithm for weighted
26/// shortest path traversal (port of C++ `WeightedSPPathsFunction`).
27/// The `cost` column is appended to the output.
28pub struct PhysicalRecursiveExtend {
29    pub source_table_id: u64,
30    pub rel_table_ids: Vec<u64>,
31    pub lower_bound: u64,
32    pub upper_bound: u64,
33    pub direction: akar_common::enums::ExtendDirection,
34    pub semantic: akar_common::enums::PathSemantic,
35    pub table_catalog: Option<Arc<TableCatalog>>,
36    /// Optional edge weight property name for weighted shortest path.
37    /// When set, Dijkstra traversal is used instead of BFS.
38    pub weight_property: Option<String>,
39    /// Optional name for the cost output column.
40    pub cost_output_name: Option<String>,
41}
42
43impl PhysicalOperatorExec for PhysicalRecursiveExtend {
44    fn operator_type(&self) -> &str {
45        "recursive_extend"
46    }
47
48    fn execute(&self, input: Vec<DataChunk>) -> OperatorResult {
49        use akar_common::enums::ExtendDirection;
50        use akar_common::enums::PathSemantic;
51        use akar_common::types::Value;
52        use akar_common::vector::ValueVector;
53        use std::collections::{HashMap, VecDeque};
54
55        let catalog = self
56            .table_catalog
57            .as_ref()
58            .ok_or_else(|| "No table catalog available for RecursiveExtend".to_string())?;
59
60        // Build adjacency with edge IDs: neighbor_offset -> (neighbor_offset, edge_id)
61        let mut fwd_adj: HashMap<u64, Vec<(u64, u64)>> = HashMap::new();
62        let mut rev_adj: HashMap<u64, Vec<(u64, u64)>> = HashMap::new();
63        // Edge weight lookup: edge_id -> weight (for weighted shortest path)
64        let mut edge_weights: HashMap<u64, f64> = HashMap::new();
65        // Whether we're doing weighted shortest path
66        let is_weighted = self.weight_property.is_some();
67        // Resolve weight column index for each rel table
68        let mut weight_col_idx: HashMap<u64, Option<usize>> = HashMap::new();
69
70        for &rel_table_id in &self.rel_table_ids {
71            if let Some(rel_table) = catalog.get_rel_table(rel_table_id) {
72                // Resolve weight column index
73                if let Some(ref wp) = self.weight_property {
74                    let idx = rel_table.columns.iter().position(|c| c.name == *wp);
75                    weight_col_idx.insert(rel_table_id, idx);
76                }
77
78                for (&src, neighbors) in rel_table.fwd_adj.iter() {
79                    fwd_adj
80                        .entry(src)
81                        .or_default()
82                        .extend(neighbors.iter().map(|(dst, edge_idx)| (*dst, *edge_idx as u64)));
83                    // Pre-compute edge weights
84                    if is_weighted && let Some(col_idx) = weight_col_idx.get(&rel_table_id).and_then(|&c| c) {
85                        for &(_dst, edge_idx) in neighbors {
86                            if let Some(weight_val) =
87                                rel_table.properties.get(col_idx).and_then(|col| col.get(edge_idx))
88                            {
89                                let w = match weight_val {
90                                    Value::Int64(i) => *i as f64,
91                                    Value::Double(d) => *d,
92                                    Value::Float(f) => *f as f64,
93                                    Value::Int32(i) => *i as f64,
94                                    _ => 1.0, // default weight for unrecognized types
95                                };
96                                edge_weights.insert(edge_idx as u64, w);
97                            }
98                        }
99                    }
100                }
101                for (&dst, neighbors) in rel_table.rev_adj.iter() {
102                    rev_adj
103                        .entry(dst)
104                        .or_default()
105                        .extend(neighbors.iter().map(|(src, edge_idx)| (*src, *edge_idx as u64)));
106                }
107            }
108        }
109
110        // Collect source node offsets from input
111        let source_offsets: Vec<i64> = if input.is_empty() || input[0].fields.is_empty() {
112            let mut all: Vec<i64> = fwd_adj
113                .keys()
114                .chain(rev_adj.keys())
115                .copied()
116                .map(|k| k as i64)
117                .collect();
118            all.sort();
119            all.dedup();
120            all
121        } else {
122            let field = &input[0].fields[0];
123            let num_rows = input[0].size;
124            let mut offsets = Vec::with_capacity(num_rows);
125            for i in 0..num_rows {
126                if !field.is_null(i) {
127                    let offset = if let Some(Value::Int64(val)) = input[0].get_value(0, i) {
128                        val
129                    } else {
130                        0
131                    };
132                    offsets.push(offset);
133                }
134            }
135            offsets
136        };
137
138        if source_offsets.is_empty() {
139            return Ok(vec![DataChunk::new(vec![], vec![])]);
140        }
141
142        // Result columns
143        let mut result_src: Vec<i64> = Vec::new();
144        let mut result_dst: Vec<i64> = Vec::new();
145        let mut result_len: Vec<i64> = Vec::new();
146        let mut result_cost: Vec<f64> = Vec::new(); // only used for weighted
147        // Path tracking: for each result, store the sequence of (node_id, edge_id) pairs
148        let mut result_path_nodes: Vec<Vec<i64>> = Vec::new();
149        let mut result_path_edges: Vec<Vec<i64>> = Vec::new();
150
151        for &src in &source_offsets {
152            let src_u = src as u64;
153
154            if is_weighted {
155                // === Weighted Shortest Path: Dijkstra ===
156                use std::cmp::Reverse;
157                use std::collections::BinaryHeap;
158
159                // Use i64 for priority queue (cost * PRECISION) since f64 doesn't implement Ord.
160                // PRECISION = 1000 captures 3 decimal places.
161                const COST_PRECISION: i64 = 1000;
162
163                // Helper to convert f64 cost to i64 for the pq
164                let cost_to_i64 = |c: f64| -> i64 { (c * COST_PRECISION as f64).round() as i64 };
165
166                // Parent map: child -> (parent, edge_id, depth, cumulative_cost)
167                let mut parents: HashMap<u64, (u64, u64, u64, f64)> = HashMap::new();
168                let mut pq: BinaryHeap<Reverse<(i64, u64)>> = BinaryHeap::new();
169
170                pq.push(Reverse((cost_to_i64(0.0), src_u)));
171                parents.insert(src_u, (u64::MAX, u64::MAX, 0, 0.0));
172
173                while let Some(Reverse((cur_cost_i64, node))) = pq.pop() {
174                    let cur_cost = cur_cost_i64 as f64 / COST_PRECISION as f64;
175                    let cur_depth = parents.get(&node).map(|&(_, _, d, _)| d).unwrap_or(0);
176
177                    // If we already found a better path to this node, skip
178                    if let Some(&(_, _, _, best_cost)) = parents.get(&node)
179                        && cur_cost > best_cost + 1e-9
180                    {
181                        continue;
182                    }
183
184                    if cur_depth >= self.upper_bound {
185                        continue;
186                    }
187
188                    // Get neighbors
189                    let neighbors: Vec<(u64, u64)> = match self.direction {
190                        ExtendDirection::Fwd => fwd_adj.get(&node).cloned().unwrap_or_default(),
191                        ExtendDirection::Bwd => rev_adj.get(&node).cloned().unwrap_or_default(),
192                        ExtendDirection::Both => {
193                            let mut nbrs = fwd_adj.get(&node).cloned().unwrap_or_default();
194                            if let Some(bwd) = rev_adj.get(&node) {
195                                nbrs.extend(bwd.iter().copied());
196                            }
197                            nbrs
198                        }
199                    };
200
201                    for (nbr, edge_id) in neighbors {
202                        let edge_w = edge_weights.get(&edge_id).copied().unwrap_or(1.0);
203                        let new_cost = cur_cost + edge_w;
204                        let new_depth = cur_depth + 1;
205
206                        let should_visit = match parents.get(&nbr) {
207                            Some(&(_, _, _, existing_cost)) => new_cost < existing_cost - 1e-9,
208                            None => true,
209                        };
210
211                        if should_visit {
212                            parents.insert(nbr, (node, edge_id, new_depth, new_cost));
213                            pq.push(Reverse((cost_to_i64(new_cost), nbr)));
214                        }
215                    }
216                }
217
218                // Emit results
219                for (&node, &(_parent, _eid, depth, cost)) in &parents {
220                    if depth < self.lower_bound || depth > self.upper_bound {
221                        continue;
222                    }
223                    if depth == 0 && self.lower_bound > 0 {
224                        continue;
225                    }
226
227                    result_src.push(src);
228                    result_dst.push(node as i64);
229                    result_len.push(depth as i64);
230                    result_cost.push(cost);
231
232                    // Reconstruct path
233                    let mut cur = node;
234                    let mut temp_nodes = vec![node as i64];
235                    let mut temp_edges = Vec::new();
236
237                    while cur != src_u {
238                        if let Some(&(parent, eid, _, _)) = parents.get(&cur) {
239                            if parent == u64::MAX {
240                                break;
241                            }
242                            temp_edges.push(eid as i64);
243                            temp_nodes.push(parent as i64);
244                            cur = parent;
245                        } else {
246                            break;
247                        }
248                    }
249
250                    temp_nodes.reverse();
251                    temp_edges.reverse();
252                    let mut path_nodes = vec![src];
253                    path_nodes.extend(temp_nodes);
254                    result_path_nodes.push(path_nodes);
255                    result_path_edges.push(temp_edges);
256                }
257            } else {
258                // === Unweighted: BFS ===
259                let mut queue = VecDeque::new();
260                // Parent map: child -> (parent, edge_id, depth)
261                let mut parents: HashMap<u64, (u64, u64, u64)> = HashMap::new();
262                queue.push_back((src_u, 0u64));
263                parents.insert(src_u, (u64::MAX, u64::MAX, 0));
264
265                let semantic = self.semantic;
266
267                while let Some((node, depth)) = queue.pop_front() {
268                    if depth >= self.upper_bound {
269                        continue;
270                    }
271
272                    let neighbors: Vec<(u64, u64)> = match self.direction {
273                        ExtendDirection::Fwd => fwd_adj.get(&node).cloned().unwrap_or_default(),
274                        ExtendDirection::Bwd => rev_adj.get(&node).cloned().unwrap_or_default(),
275                        ExtendDirection::Both => {
276                            let mut nbrs = fwd_adj.get(&node).cloned().unwrap_or_default();
277                            if let Some(bwd) = rev_adj.get(&node) {
278                                nbrs.extend(bwd.iter().copied());
279                            }
280                            nbrs
281                        }
282                    };
283
284                    'neighbors: for (nbr, edge_id) in neighbors {
285                        if parents.contains_key(&nbr) {
286                            match semantic {
287                                PathSemantic::Walk | PathSemantic::Acyclic => continue 'neighbors,
288                                PathSemantic::Trail => {
289                                    let mut cur = node;
290                                    while let Some(&(p, eid, _)) = parents.get(&cur) {
291                                        if eid == edge_id {
292                                            continue 'neighbors;
293                                        }
294                                        if p == u64::MAX {
295                                            break;
296                                        }
297                                        cur = p;
298                                    }
299                                }
300                            }
301                        }
302
303                        let new_depth = depth + 1;
304                        parents.insert(nbr, (node, edge_id, new_depth));
305                        queue.push_back((nbr, new_depth));
306                    }
307                }
308
309                // Emit results for nodes at valid depths
310                for (&node, &(_parent_node, _edge_id, depth)) in &parents {
311                    if depth < self.lower_bound || depth > self.upper_bound {
312                        continue;
313                    }
314                    if depth == 0 && self.lower_bound > 0 {
315                        continue;
316                    }
317
318                    result_src.push(src);
319                    result_dst.push(node as i64);
320                    result_len.push(depth as i64);
321
322                    // Reconstruct path
323                    let mut cur = node;
324                    let mut temp_nodes = vec![node as i64];
325                    let mut temp_edges = Vec::new();
326
327                    while cur != src_u {
328                        if let Some(&(parent, eid, _)) = parents.get(&cur) {
329                            if parent == u64::MAX {
330                                break;
331                            }
332                            temp_edges.push(eid as i64);
333                            temp_nodes.push(parent as i64);
334                            cur = parent;
335                        } else {
336                            break;
337                        }
338                    }
339
340                    temp_nodes.reverse();
341                    temp_edges.reverse();
342                    let mut path_nodes = vec![src];
343                    path_nodes.extend(temp_nodes);
344                    result_path_nodes.push(path_nodes);
345                    result_path_edges.push(temp_edges);
346                }
347            }
348        }
349
350        // Build output DataChunk
351        let num_results = result_src.len();
352        if num_results == 0 {
353            return Ok(vec![DataChunk::new(vec![], vec![])]);
354        }
355
356        // Column 0-2: primitive Int64 vectors
357        let mut src_v = ValueVector::new(akar_common::types::PhysicalTypeID::Int64, num_results);
358        let mut dst_v = ValueVector::new(akar_common::types::PhysicalTypeID::Int64, num_results);
359        let mut len_v = ValueVector::new(akar_common::types::PhysicalTypeID::Int64, num_results);
360
361        for i in 0..num_results {
362            let offset = i * 8;
363            src_v.data_mut()[offset..offset + 8].copy_from_slice(&result_src[i].to_le_bytes());
364            src_v.set_null(i, false);
365            dst_v.data_mut()[offset..offset + 8].copy_from_slice(&result_dst[i].to_le_bytes());
366            dst_v.set_null(i, false);
367            len_v.data_mut()[offset..offset + 8].copy_from_slice(&result_len[i].to_le_bytes());
368            len_v.set_null(i, false);
369        }
370        src_v.resize(num_results);
371        dst_v.resize(num_results);
372        len_v.resize(num_results);
373
374        // Column 3-4: create Value vectors for path lists, then convert to columns
375        // Use Vec<Option<Value>> to store per-row path data
376        let mut path_nodes_col: Vec<Value> = Vec::with_capacity(num_results);
377        let mut path_edges_col: Vec<Value> = Vec::with_capacity(num_results);
378
379        for i in 0..num_results {
380            // Path nodes as List(Int64)
381            let node_vals: Vec<Value> = result_path_nodes[i].iter().map(|&n| Value::Int64(n)).collect();
382            path_nodes_col.push(Value::List(node_vals));
383            // Path edges as List(Int64)
384            let edge_vals: Vec<Value> = result_path_edges[i].iter().map(|&e| Value::Int64(e)).collect();
385            path_edges_col.push(Value::List(edge_vals));
386        }
387
388        // Store List values in ValueVector via set_value
389        let mut path_nodes_v = ValueVector::new(akar_common::types::PhysicalTypeID::List, num_results);
390        let mut path_edges_v = ValueVector::new(akar_common::types::PhysicalTypeID::List, num_results);
391
392        for (i, val) in path_nodes_col.iter().enumerate() {
393            path_nodes_v.set_value(i, val)?;
394        }
395        for (i, val) in path_edges_col.iter().enumerate() {
396            path_edges_v.set_value(i, val)?;
397        }
398
399        // When weighted, include cost column
400        let has_cost = is_weighted;
401
402        if has_cost {
403            let mut cost_v = ValueVector::new(akar_common::types::PhysicalTypeID::Double, num_results);
404            for (i, cost) in result_cost.iter().enumerate().take(num_results) {
405                let offset = i * 8;
406                cost_v.data_mut()[offset..offset + 8].copy_from_slice(&cost.to_le_bytes());
407                cost_v.set_null(i, false);
408            }
409            cost_v.resize(num_results);
410
411            Ok(vec![DataChunk {
412                fields: vec![
413                    akar_common::arrow_vector::ArrowVector::from_legacy(&src_v).array,
414                    akar_common::arrow_vector::ArrowVector::from_legacy(&dst_v).array,
415                    akar_common::arrow_vector::ArrowVector::from_legacy(&len_v).array,
416                    akar_common::arrow_vector::ArrowVector::from_legacy(&path_nodes_v).array,
417                    akar_common::arrow_vector::ArrowVector::from_legacy(&path_edges_v).array,
418                    akar_common::arrow_vector::ArrowVector::from_legacy(&cost_v).array,
419                ],
420                field_types: vec![
421                    src_v.physical_type(),
422                    dst_v.physical_type(),
423                    len_v.physical_type(),
424                    path_nodes_v.physical_type(),
425                    path_edges_v.physical_type(),
426                    cost_v.physical_type(),
427                ],
428                size: num_results,
429                field_names: vec![],
430                sel_vector: None,
431            }])
432        } else {
433            Ok(vec![DataChunk {
434                fields: vec![
435                    akar_common::arrow_vector::ArrowVector::from_legacy(&src_v).array,
436                    akar_common::arrow_vector::ArrowVector::from_legacy(&dst_v).array,
437                    akar_common::arrow_vector::ArrowVector::from_legacy(&len_v).array,
438                    akar_common::arrow_vector::ArrowVector::from_legacy(&path_nodes_v).array,
439                    akar_common::arrow_vector::ArrowVector::from_legacy(&path_edges_v).array,
440                ],
441                field_types: vec![
442                    src_v.physical_type(),
443                    dst_v.physical_type(),
444                    len_v.physical_type(),
445                    path_nodes_v.physical_type(),
446                    path_edges_v.physical_type(),
447                ],
448                size: num_results,
449                field_names: vec![],
450                sel_vector: None,
451            }])
452        }
453    }
454}
455
456pub struct PhysicalCreateNode {
457    pub table_name: String,
458    pub table_id: u64,
459    pub out_var_name: String,
460    pub properties: Vec<(String, akar_parser::ast::Expression)>,
461    pub table_catalog: Arc<TableCatalog>,
462}
463
464impl PhysicalCreateNode {
465    pub fn execute(&self, input: Vec<DataChunk>) -> Result<Vec<DataChunk>, ProcessorError> {
466        if input.is_empty() {
467            return Ok(input);
468        }
469
470        let mut table = self
471            .table_catalog
472            .get_node_table_by_name_mut(&self.table_name)
473            .ok_or_else(|| format!("Node table {} not found", self.table_name))?;
474
475        // For each input chunk, we create nodes and attach the new node IDs
476        let mut output = Vec::with_capacity(input.len());
477
478        for mut chunk in input {
479            let mut node_ids = ValueVector::new(akar_common::types::PhysicalTypeID::Int64, chunk.size);
480
481            for i in 0..chunk.size {
482                let mut values = vec![akar_common::types::Value::Null; table.columns.len()];
483                for (prop_name, prop_expr) in &self.properties {
484                    if let Some(col_idx) = table.columns.iter().position(|c| c.name == *prop_name) {
485                        values[col_idx] = evaluate_expression_for_row(prop_expr, &chunk, i);
486                    }
487                }
488
489                let row_offset = table.insert_row(values)?;
490                node_ids.data_mut()[i * 8..(i + 1) * 8].copy_from_slice(&(row_offset as i64).to_le_bytes());
491                node_ids.set_null(i, false);
492            }
493            node_ids.resize(chunk.size);
494
495            chunk
496                .fields
497                .push(akar_common::arrow_vector::ArrowVector::from_legacy(&node_ids).array);
498            chunk.field_types.push(akar_common::types::PhysicalTypeID::List);
499            chunk.field_names.push(self.out_var_name.clone());
500            output.push(chunk);
501        }
502
503        Ok(output)
504    }
505}
506
507pub struct PhysicalCreateRel {
508    pub table_name: String,
509    pub table_id: u64,
510    pub src_node_name: String,
511    pub dst_node_name: String,
512    pub properties: Vec<(String, akar_parser::ast::Expression)>,
513    pub table_catalog: Arc<TableCatalog>,
514}
515
516impl PhysicalCreateRel {
517    pub fn execute(&self, input: Vec<DataChunk>) -> Result<Vec<DataChunk>, ProcessorError> {
518        if input.is_empty() {
519            return Ok(input);
520        }
521
522        let mut table = self
523            .table_catalog
524            .get_rel_table_by_name_mut(&self.table_name)
525            .ok_or_else(|| format!("Rel table {} not found", self.table_name))?;
526
527        let mut output = Vec::with_capacity(input.len());
528
529        for chunk in input {
530            let src_name_id = format!("{}.{}", self.src_node_name, "_id");
531            let src_name_pk = format!("{}.{}", self.src_node_name, "id");
532            let src_idx = chunk
533                .field_names
534                .iter()
535                .position(|name| name == &src_name_id)
536                .or_else(|| chunk.field_names.iter().position(|name| name == &self.src_node_name))
537                .or_else(|| chunk.field_names.iter().position(|name| name == &src_name_pk))
538                .ok_or_else(|| format!("Source node variable {} not found", self.src_node_name))?;
539
540            let dst_name_id = format!("{}.{}", self.dst_node_name, "_id");
541            let dst_name_pk = format!("{}.{}", self.dst_node_name, "id");
542            let dst_idx = chunk
543                .field_names
544                .iter()
545                .position(|name| name == &dst_name_id)
546                .or_else(|| chunk.field_names.iter().position(|name| name == &self.dst_node_name))
547                .or_else(|| chunk.field_names.iter().position(|name| name == &dst_name_pk))
548                .ok_or_else(|| format!("Destination node variable {} not found", self.dst_node_name))?;
549
550            let src_vec = &chunk.fields[src_idx];
551            let dst_vec = &chunk.fields[dst_idx];
552
553            let mut inserted = 0;
554            for i in 0..chunk.size {
555                if src_vec.is_null(i) || dst_vec.is_null(i) {
556                    continue; // Skip creating relationships involving NULL nodes
557                }
558
559                let mut src_bytes = [0u8; 8];
560                src_bytes.copy_from_slice(&src_vec.to_data().buffers()[0].as_slice()[i * 8..(i + 1) * 8]);
561                let src_id = i64::from_le_bytes(src_bytes) as u64;
562
563                let mut dst_bytes = [0u8; 8];
564                dst_bytes.copy_from_slice(&dst_vec.to_data().buffers()[0].as_slice()[i * 8..(i + 1) * 8]);
565                let dst_id = i64::from_le_bytes(dst_bytes) as u64;
566
567                let mut values = vec![akar_common::types::Value::Null; table.columns.len()];
568                for (prop_name, prop_expr) in &self.properties {
569                    if let Some(col_idx) = table.columns.iter().position(|c| c.name == *prop_name) {
570                        values[col_idx] = evaluate_expression_for_row(prop_expr, &chunk, i);
571                    }
572                }
573
574                table.insert_rel(src_id, dst_id, values)?;
575                inserted += 1;
576            }
577            tracing::debug!(
578                "PhysicalCreateRel inserted {} relationships from chunk of size {}",
579                inserted,
580                chunk.size
581            );
582
583            output.push(chunk);
584        }
585
586        Ok(output)
587    }
588}
589
590/// Physical operator for extending from a source node through a relationship.
591///
592/// Takes input chunks from the source node scan, and for each source row,
593/// looks up adjacency list entries in the relationship table, producing
594/// output rows that include the source fields, relationship properties,
595/// and destination node properties.
596///
597/// Ported from C++ `ScanRelTable` (the physical extend operator).
598pub struct PhysicalExtend {
599    /// Name of the relationship table.
600    pub rel_table_name: String,
601    /// ID of the relationship table.
602    pub rel_table_id: u64,
603    /// Variable name of the bound (source) node.
604    pub bound_node_var: String,
605    /// Direction of the extend.
606    pub direction: akar_parser::ast::EdgeDirection,
607    /// Variable name of the destination node.
608    pub dst_node_var: String,
609    /// Table name of the destination node.
610    pub dst_table_name: String,
611    /// Table ID of the destination node.
612    pub dst_table_id: u64,
613    /// Table catalog for data access.
614    pub table_catalog: Arc<TableCatalog>,
615}
616
617impl PhysicalExtend {
618    pub fn execute(&self, input: Vec<DataChunk>) -> Result<Vec<DataChunk>, ProcessorError> {
619        if input.is_empty() || input.iter().all(|c| c.size == 0) {
620            return Ok(input);
621        }
622
623        // Collect rel table data upfront (owned)
624        let (fwd_adj, rev_adj, rel_props, rel_cols) = {
625            let rel_table = self
626                .table_catalog
627                .get_rel_table_by_name(&self.rel_table_name)
628                .ok_or_else(|| format!("Rel table {} not found", self.rel_table_name))?;
629            let fwd = rel_table.fwd_adj.clone();
630            let rev = rel_table.rev_adj.clone();
631            let props = rel_table.properties.clone();
632            let cols = rel_table.columns.clone();
633            (fwd, rev, props, cols)
634        };
635
636        // Collect dest node table data upfront (owned)
637        let (dest_data, dest_cols, dest_pk_col) = {
638            let dest_table = self
639                .table_catalog
640                .get_node_table_by_name(&self.dst_table_name)
641                .ok_or_else(|| format!("Node table {} not found", self.dst_table_name))?;
642            let data = dest_table.to_column_major_data();
643            let cols = dest_table.columns.clone();
644            let pk = dest_table.primary_key_column;
645            (data, cols, pk)
646        };
647
648        // Build PK → row offset map for destination lookups
649        let pk_to_row: std::collections::HashMap<u64, usize> = if dest_pk_col < dest_data.len() {
650            dest_data[dest_pk_col]
651                .iter()
652                .enumerate()
653                .filter_map(|(row, val)| {
654                    if let Value::Int64(id) = val {
655                        Some((*id as u64, row))
656                    } else {
657                        None
658                    }
659                })
660                .collect()
661        } else {
662            std::collections::HashMap::new()
663        };
664
665        let mut output = Vec::with_capacity(input.len());
666
667        for chunk in input {
668            // Find the bound node column in the chunk
669            let bound_name_id = format!("{}.{}", self.bound_node_var, "_id");
670            let bound_name_pk = format!("{}.{}", self.bound_node_var, "id");
671            let bound_idx = chunk
672                .field_names
673                .iter()
674                .position(|name| name == &bound_name_id)
675                .or_else(|| chunk.field_names.iter().position(|name| name == &self.bound_node_var))
676                .or_else(|| chunk.field_names.iter().position(|name| name == &bound_name_pk))
677                .ok_or_else(|| {
678                    format!(
679                        "Bound node variable {} not found in Extend input. Available fields: {:?}",
680                        self.bound_node_var, chunk.field_names
681                    )
682                })?;
683
684            // Calculate total output rows and build row mapping
685            let mut total_rows = 0;
686            let mut row_mappings: Vec<(usize, u64, usize)> = Vec::new(); // (input_row, dst_offset, edge_idx)
687
688            for i in 0..chunk.size {
689                if chunk.fields[bound_idx].is_null(i) {
690                    continue;
691                }
692                let src_id = if let Some(akar_common::types::Value::Int64(val)) = chunk.get_value(bound_idx, i) {
693                    val as u64
694                } else {
695                    continue;
696                };
697
698                let edges: Vec<(u64, usize)> = match self.direction {
699                    akar_parser::ast::EdgeDirection::LeftToRight => fwd_adj.get(&src_id).cloned().unwrap_or_default(),
700                    akar_parser::ast::EdgeDirection::RightToLeft => rev_adj.get(&src_id).cloned().unwrap_or_default(),
701                    akar_parser::ast::EdgeDirection::Both => {
702                        let mut all = fwd_adj.get(&src_id).cloned().unwrap_or_default();
703                        if let Some(rev) = rev_adj.get(&src_id) {
704                            all.extend(rev.iter().cloned());
705                        }
706                        all
707                    }
708                };
709
710                for &(dst_offset, edge_idx) in &edges {
711                    if !pk_to_row.contains_key(&dst_offset)
712                        && dst_offset as usize >= dest_data.first().map(|c| c.len()).unwrap_or(0)
713                    {
714                        continue;
715                    }
716                    total_rows += 1;
717                    row_mappings.push((i, dst_offset, edge_idx));
718                }
719            }
720
721            if total_rows == 0 {
722                output.push(DataChunk::new(vec![], vec![]));
723                continue;
724            }
725
726            // Build output:
727            // Column layout: [input_fields | rel_properties | dest_node_fields]
728            let num_input_fields = chunk.fields.len();
729            let num_rel_cols = rel_cols.len();
730            let num_dest_cols = dest_cols.len();
731            let num_out_cols = num_input_fields + num_rel_cols + num_dest_cols;
732
733            // Build column-major data
734            let mut out_data: Vec<Vec<Value>> = vec![Vec::with_capacity(total_rows); num_out_cols];
735
736            for &(input_row, dst_offset, edge_idx) in &row_mappings {
737                // Copy input fields
738                for col in 0..num_input_fields {
739                    let val = chunk.get_value(col, input_row).unwrap_or(Value::Null);
740                    out_data[col].push(val);
741                }
742                // Copy rel properties
743                for col in 0..num_rel_cols {
744                    let val = rel_props
745                        .get(col)
746                        .and_then(|c| c.get(edge_idx))
747                        .cloned()
748                        .unwrap_or(Value::Null);
749                    out_data[num_input_fields + col].push(val);
750                }
751                // Copy dest node properties
752                let dest_row = pk_to_row.get(&dst_offset).copied();
753                for col in 0..num_dest_cols {
754                    let val = dest_row
755                        .and_then(|r| dest_data.get(col).and_then(|c| c.get(r)))
756                        .cloned()
757                        .unwrap_or_else(|| {
758                            dest_data
759                                .get(col)
760                                .and_then(|c| c.get(dst_offset as usize))
761                                .cloned()
762                                .unwrap_or(Value::Null)
763                        });
764                    out_data[num_input_fields + num_rel_cols + col].push(val);
765                }
766            }
767
768            // Convert column-major data to ValueVectors
769            let mut fields = Vec::with_capacity(num_out_cols);
770            let mut field_names = Vec::with_capacity(num_out_cols);
771
772            // Input field names (already prefixed)
773            for col in 0..num_input_fields {
774                let phys_type = chunk.field_types[col];
775                let mut v = ValueVector::new(phys_type, total_rows);
776                v.resize(total_rows);
777                for row in 0..total_rows {
778                    store_value_in_vector(&mut v, row, &out_data[col][row])?;
779                }
780                fields.push(v);
781                if col < chunk.field_names.len() {
782                    field_names.push(chunk.field_names[col].clone());
783                } else {
784                    field_names.push(format!("field_{}", col));
785                }
786            }
787
788            // Rel field names (prefixed with rel table name)
789            for col in 0..num_rel_cols {
790                let phys_type = if col < rel_cols.len() {
791                    PhysicalScan::logical_to_physical(&rel_cols[col].logical_type)
792                } else {
793                    PhysicalTypeID::Int64
794                };
795                let mut v = ValueVector::new(phys_type, total_rows);
796                v.resize(total_rows);
797                for row in 0..total_rows {
798                    store_value_in_vector(&mut v, row, &out_data[num_input_fields + col][row])?;
799                }
800                fields.push(v);
801                let rel_prefix = &self.rel_table_name;
802                let col_name = rel_cols.get(col).map(|c| c.name.as_str()).unwrap_or("");
803                field_names.push(format!("{}.{}", rel_prefix, col_name));
804            }
805
806            // Dest field names (prefixed with dest variable)
807            for col in 0..num_dest_cols {
808                let phys_type = if col < dest_cols.len() {
809                    PhysicalScan::logical_to_physical(&dest_cols[col].logical_type)
810                } else {
811                    PhysicalTypeID::Int64
812                };
813                let mut v = ValueVector::new(phys_type, total_rows);
814                v.resize(total_rows);
815                for row in 0..total_rows {
816                    store_value_in_vector(&mut v, row, &out_data[num_input_fields + num_rel_cols + col][row])?;
817                }
818                fields.push(v);
819                let prefix = &self.dst_node_var;
820                let col_name = dest_cols.get(col).map(|c| c.name.as_str()).unwrap_or("");
821                field_names.push(format!("{}.{}", prefix, col_name));
822            }
823
824            let arrow_fields = fields
825                .iter()
826                .map(|v| akar_common::arrow_vector::ArrowVector::from_legacy(v).array)
827                .collect::<Vec<_>>();
828            let arrow_field_types = fields.iter().map(|v| v.physical_type()).collect::<Vec<_>>();
829            output.push(DataChunk {
830                fields: arrow_fields,
831                field_types: arrow_field_types,
832                size: total_rows,
833                field_names,
834                sel_vector: None,
835            });
836        }
837
838        Ok(output)
839    }
840}