Skip to main content

cypherlite_query/executor/operators/
optional_expand.rs

1// OptionalExpandOp: edge traversal with left join semantics (OPTIONAL MATCH)
2//
3// For each source record, try to expand (find matching edges/nodes).
4// If expansion finds N results: emit N records (same as regular expand).
5// If expansion finds 0 results: emit 1 record with NULL for rel_var and target_var.
6
7use crate::executor::{Record, Value};
8use crate::parser::ast::RelDirection;
9use cypherlite_core::NodeId;
10use cypherlite_storage::StorageEngine;
11
12/// Execute an optional expand (left join). For each source record, find matching
13/// edges. If matches exist, emit expanded records. If no match, emit one record
14/// with NULL-padded new variables.
15pub fn execute_optional_expand(
16    source_records: Vec<Record>,
17    src_var: &str,
18    rel_var: Option<&str>,
19    target_var: &str,
20    rel_type_id: Option<u32>,
21    direction: &RelDirection,
22    engine: &StorageEngine,
23) -> Vec<Record> {
24    let mut results = Vec::new();
25
26    for record in source_records {
27        let src_node_id = match record.get(src_var) {
28            Some(Value::Node(nid)) => *nid,
29            _ => {
30                // Source variable is not a node (or missing) -- emit NULL-padded record.
31                let mut null_record = record;
32                if let Some(rv) = rel_var {
33                    null_record.insert(rv.to_string(), Value::Null);
34                }
35                null_record.insert(target_var.to_string(), Value::Null);
36                results.push(null_record);
37                continue;
38            }
39        };
40
41        let edges = engine.get_edges_for_node(src_node_id);
42        let mut matched = false;
43
44        for edge in edges {
45            // Filter by relationship type if specified
46            if let Some(tid) = rel_type_id {
47                if edge.rel_type_id != tid {
48                    continue;
49                }
50            }
51
52            // Direction filtering and determine target node
53            let target_node_id: Option<NodeId> = match direction {
54                RelDirection::Outgoing => {
55                    if edge.start_node == src_node_id {
56                        Some(edge.end_node)
57                    } else {
58                        None
59                    }
60                }
61                RelDirection::Incoming => {
62                    if edge.end_node == src_node_id {
63                        Some(edge.start_node)
64                    } else {
65                        None
66                    }
67                }
68                RelDirection::Undirected => {
69                    if edge.start_node == src_node_id {
70                        Some(edge.end_node)
71                    } else if edge.end_node == src_node_id {
72                        Some(edge.start_node)
73                    } else {
74                        None
75                    }
76                }
77            };
78
79            if let Some(target_id) = target_node_id {
80                matched = true;
81                let mut new_record = record.clone();
82                if let Some(rv) = rel_var {
83                    new_record.insert(rv.to_string(), Value::Edge(edge.edge_id));
84                }
85                new_record.insert(target_var.to_string(), Value::Node(target_id));
86                results.push(new_record);
87            }
88        }
89
90        // Left join semantics: if no match found, emit one NULL-padded record.
91        if !matched {
92            let mut null_record = record;
93            if let Some(rv) = rel_var {
94                null_record.insert(rv.to_string(), Value::Null);
95            }
96            null_record.insert(target_var.to_string(), Value::Null);
97            results.push(null_record);
98        }
99    }
100
101    results
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::executor::Record;
108    use cypherlite_core::{DatabaseConfig, LabelRegistry, SyncMode};
109    use cypherlite_storage::StorageEngine;
110    use tempfile::tempdir;
111
112    fn test_engine(dir: &std::path::Path) -> StorageEngine {
113        let config = DatabaseConfig {
114            path: dir.join("test.cyl"),
115            wal_sync_mode: SyncMode::Normal,
116            ..Default::default()
117        };
118        StorageEngine::open(config).expect("open")
119    }
120
121    // TASK-076: OptionalExpand with matching edges (same as regular expand)
122    #[test]
123    fn test_optional_expand_with_matches() {
124        let dir = tempdir().expect("tempdir");
125        let mut engine = test_engine(dir.path());
126
127        let knows_type = engine.get_or_create_rel_type("KNOWS");
128        let n1 = engine.create_node(vec![], vec![]);
129        let n2 = engine.create_node(vec![], vec![]);
130        let n3 = engine.create_node(vec![], vec![]);
131
132        engine
133            .create_edge(n1, n2, knows_type, vec![])
134            .expect("edge");
135        engine
136            .create_edge(n1, n3, knows_type, vec![])
137            .expect("edge");
138
139        let mut source = Record::new();
140        source.insert("a".to_string(), Value::Node(n1));
141
142        let results = execute_optional_expand(
143            vec![source],
144            "a",
145            Some("r"),
146            "b",
147            Some(knows_type),
148            &RelDirection::Outgoing,
149            &engine,
150        );
151
152        // Should find 2 matches (same as regular expand)
153        assert_eq!(results.len(), 2);
154        for r in &results {
155            assert!(r.contains_key("a"));
156            assert!(r.contains_key("r"));
157            assert!(r.contains_key("b"));
158            assert_ne!(r.get("b"), Some(&Value::Null));
159        }
160    }
161
162    // TASK-076: OptionalExpand with NO matching edges (left join: NULL padding)
163    #[test]
164    fn test_optional_expand_no_matches_produces_null() {
165        let dir = tempdir().expect("tempdir");
166        let mut engine = test_engine(dir.path());
167
168        let knows_type = engine.get_or_create_rel_type("KNOWS");
169        // Node with no outgoing KNOWS edges
170        let n1 = engine.create_node(vec![], vec![]);
171
172        let mut source = Record::new();
173        source.insert("a".to_string(), Value::Node(n1));
174
175        let results = execute_optional_expand(
176            vec![source],
177            "a",
178            Some("r"),
179            "b",
180            Some(knows_type),
181            &RelDirection::Outgoing,
182            &engine,
183        );
184
185        // Should produce exactly 1 record with NULL for 'b' and 'r'
186        assert_eq!(results.len(), 1);
187        assert_eq!(results[0].get("a"), Some(&Value::Node(n1)));
188        assert_eq!(results[0].get("b"), Some(&Value::Null));
189        assert_eq!(results[0].get("r"), Some(&Value::Null));
190    }
191
192    // TASK-076: OptionalExpand with mixed matches (some source nodes match, some don't)
193    #[test]
194    fn test_optional_expand_mixed_matches() {
195        let dir = tempdir().expect("tempdir");
196        let mut engine = test_engine(dir.path());
197
198        let knows_type = engine.get_or_create_rel_type("KNOWS");
199        let n1 = engine.create_node(vec![], vec![]);
200        let n2 = engine.create_node(vec![], vec![]);
201        let n3 = engine.create_node(vec![], vec![]);
202
203        // n1 knows n3, but n2 knows nobody
204        engine
205            .create_edge(n1, n3, knows_type, vec![])
206            .expect("edge");
207
208        let mut source1 = Record::new();
209        source1.insert("a".to_string(), Value::Node(n1));
210        let mut source2 = Record::new();
211        source2.insert("a".to_string(), Value::Node(n2));
212
213        let results = execute_optional_expand(
214            vec![source1, source2],
215            "a",
216            None,
217            "b",
218            Some(knows_type),
219            &RelDirection::Outgoing,
220            &engine,
221        );
222
223        // n1 -> 1 match, n2 -> 1 null-padded = 2 records total
224        assert_eq!(results.len(), 2);
225
226        // First record: n1 matched n3
227        assert_eq!(results[0].get("a"), Some(&Value::Node(n1)));
228        assert_eq!(results[0].get("b"), Some(&Value::Node(n3)));
229
230        // Second record: n2 has no match, b is Null
231        assert_eq!(results[1].get("a"), Some(&Value::Node(n2)));
232        assert_eq!(results[1].get("b"), Some(&Value::Null));
233    }
234
235    // TASK-076: OptionalExpand without rel_var
236    #[test]
237    fn test_optional_expand_no_rel_var() {
238        let dir = tempdir().expect("tempdir");
239        let mut engine = test_engine(dir.path());
240
241        let knows_type = engine.get_or_create_rel_type("KNOWS");
242        let n1 = engine.create_node(vec![], vec![]);
243
244        let mut source = Record::new();
245        source.insert("a".to_string(), Value::Node(n1));
246
247        let results = execute_optional_expand(
248            vec![source],
249            "a",
250            None, // no rel_var
251            "b",
252            Some(knows_type),
253            &RelDirection::Outgoing,
254            &engine,
255        );
256
257        assert_eq!(results.len(), 1);
258        assert_eq!(results[0].get("b"), Some(&Value::Null));
259        // No 'r' key should be present
260        assert!(!results[0].contains_key("r"));
261    }
262}