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