Skip to main content

cypherlite_query/executor/operators/
delete.rs

1// DeleteOp: node/edge deletion, ConstraintError if non-detach with edges
2
3use crate::executor::eval::eval;
4use crate::executor::{ExecutionError, Params, Record, ScalarFnLookup, TriggerLookup, Value};
5use crate::parser::ast::Expression;
6use cypherlite_core::LabelRegistry;
7use cypherlite_storage::StorageEngine;
8
9/// Delete nodes or edges identified by expressions.
10/// If detach is false and a node has edges, returns a ConstraintViolation error.
11/// If detach is true, uses engine.delete_node() which cascades edges.
12pub fn execute_delete(
13    source_records: Vec<Record>,
14    exprs: &[Expression],
15    detach: bool,
16    engine: &mut StorageEngine,
17    params: &Params,
18    scalar_fns: &dyn ScalarFnLookup,
19    trigger_fns: &dyn TriggerLookup,
20) -> Result<Vec<Record>, ExecutionError> {
21    // Collect all entity IDs to delete first, then delete.
22    // This avoids issues with deleting while iterating.
23    let mut node_ids_to_delete = Vec::new();
24    let mut edge_ids_to_delete = Vec::new();
25
26    for record in &source_records {
27        for expr in exprs {
28            let val = eval(expr, record, &*engine, params, scalar_fns)?;
29            match val {
30                Value::Node(nid) => {
31                    if !node_ids_to_delete.contains(&nid) {
32                        node_ids_to_delete.push(nid);
33                    }
34                }
35                Value::Edge(eid) => {
36                    if !edge_ids_to_delete.contains(&eid) {
37                        edge_ids_to_delete.push(eid);
38                    }
39                }
40                Value::Null => {
41                    // Deleting null is a no-op
42                }
43                _ => {
44                    return Err(ExecutionError {
45                        message: "DELETE requires a node or edge value".to_string(),
46                    });
47                }
48            }
49        }
50    }
51
52    // Delete edges first
53    for eid in &edge_ids_to_delete {
54        // Build trigger context for edge deletion
55        let edge_props = engine
56            .get_edge(*eid)
57            .map(|e| e.properties.clone())
58            .unwrap_or_default();
59        let rel_type_name = engine.get_edge(*eid).and_then(|e| {
60            engine
61                .catalog()
62                .rel_type_name(e.rel_type_id)
63                .map(|s| s.to_string())
64        });
65        let ctx = cypherlite_core::TriggerContext {
66            entity_type: cypherlite_core::EntityType::Edge,
67            entity_id: eid.0,
68            label_or_type: rel_type_name,
69            properties: edge_props
70                .iter()
71                .map(|(k, v)| {
72                    let name = engine
73                        .catalog()
74                        .prop_key_name(*k)
75                        .unwrap_or("?")
76                        .to_string();
77                    (name, v.clone())
78                })
79                .collect(),
80            operation: cypherlite_core::TriggerOperation::Delete,
81        };
82        trigger_fns.fire_before_delete(&ctx)?;
83
84        engine.delete_edge(*eid).map_err(|e| ExecutionError {
85            message: format!("failed to delete edge: {}", e),
86        })?;
87
88        trigger_fns.fire_after_delete(&ctx)?;
89    }
90
91    // Delete nodes
92    for nid in &node_ids_to_delete {
93        if !detach {
94            // Check if node has edges
95            let edges = engine.get_edges_for_node(*nid);
96            if !edges.is_empty() {
97                return Err(ExecutionError {
98                    message: format!(
99                        "cannot delete node {} because it still has {} relationship(s). Use DETACH DELETE",
100                        nid.0,
101                        edges.len()
102                    ),
103                });
104            }
105        }
106
107        // Build trigger context for node deletion
108        let node_props = engine
109            .get_node(*nid)
110            .map(|n| n.properties.clone())
111            .unwrap_or_default();
112        let label_name = engine
113            .get_node(*nid)
114            .and_then(|n| n.labels.first().copied())
115            .and_then(|lid| engine.catalog().label_name(lid).map(|s| s.to_string()));
116        let ctx = cypherlite_core::TriggerContext {
117            entity_type: cypherlite_core::EntityType::Node,
118            entity_id: nid.0,
119            label_or_type: label_name,
120            properties: node_props
121                .iter()
122                .map(|(k, v)| {
123                    let name = engine
124                        .catalog()
125                        .prop_key_name(*k)
126                        .unwrap_or("?")
127                        .to_string();
128                    (name, v.clone())
129                })
130                .collect(),
131            operation: cypherlite_core::TriggerOperation::Delete,
132        };
133        trigger_fns.fire_before_delete(&ctx)?;
134
135        engine.delete_node(*nid).map_err(|e| ExecutionError {
136            message: format!("failed to delete node: {}", e),
137        })?;
138
139        trigger_fns.fire_after_delete(&ctx)?;
140    }
141
142    // DELETE returns the source records (for chaining)
143    Ok(source_records)
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::executor::Record;
150    use cypherlite_core::{DatabaseConfig, LabelRegistry, SyncMode};
151    use tempfile::tempdir;
152
153    fn test_engine(dir: &std::path::Path) -> StorageEngine {
154        let config = DatabaseConfig {
155            path: dir.join("test.cyl"),
156            wal_sync_mode: SyncMode::Normal,
157            ..Default::default()
158        };
159        StorageEngine::open(config).expect("open")
160    }
161
162    // EXEC-T006: DeleteOp with relationships -> ConstraintError (without DETACH)
163    #[test]
164    fn test_delete_node_with_edges_no_detach_fails() {
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        let n1 = engine.create_node(vec![], vec![]);
170        let n2 = engine.create_node(vec![], vec![]);
171        engine
172            .create_edge(n1, n2, knows_type, vec![])
173            .expect("edge");
174
175        let mut record = Record::new();
176        record.insert("n".to_string(), Value::Node(n1));
177
178        let exprs = vec![Expression::Variable("n".to_string())];
179        let params = Params::new();
180
181        let result = execute_delete(vec![record], &exprs, false, &mut engine, &params, &(), &());
182        assert!(result.is_err());
183        let err = result.expect_err("should error");
184        assert!(err.message.contains("cannot delete node"));
185        assert!(err.message.contains("DETACH DELETE"));
186    }
187
188    #[test]
189    fn test_delete_node_with_detach_succeeds() {
190        let dir = tempdir().expect("tempdir");
191        let mut engine = test_engine(dir.path());
192
193        let knows_type = engine.get_or_create_rel_type("KNOWS");
194        let n1 = engine.create_node(vec![], vec![]);
195        let n2 = engine.create_node(vec![], vec![]);
196        engine
197            .create_edge(n1, n2, knows_type, vec![])
198            .expect("edge");
199
200        let mut record = Record::new();
201        record.insert("n".to_string(), Value::Node(n1));
202
203        let exprs = vec![Expression::Variable("n".to_string())];
204        let params = Params::new();
205
206        let result = execute_delete(vec![record], &exprs, true, &mut engine, &params, &(), &());
207        assert!(result.is_ok());
208        assert!(engine.get_node(n1).is_none());
209        assert_eq!(engine.edge_count(), 0);
210    }
211
212    #[test]
213    fn test_delete_isolated_node() {
214        let dir = tempdir().expect("tempdir");
215        let mut engine = test_engine(dir.path());
216
217        let n1 = engine.create_node(vec![], vec![]);
218
219        let mut record = Record::new();
220        record.insert("n".to_string(), Value::Node(n1));
221
222        let exprs = vec![Expression::Variable("n".to_string())];
223        let params = Params::new();
224
225        let result = execute_delete(vec![record], &exprs, false, &mut engine, &params, &(), &());
226        assert!(result.is_ok());
227        assert!(engine.get_node(n1).is_none());
228    }
229
230    #[test]
231    fn test_delete_null_is_noop() {
232        let dir = tempdir().expect("tempdir");
233        let mut engine = test_engine(dir.path());
234
235        let mut record = Record::new();
236        record.insert("n".to_string(), Value::Null);
237
238        let exprs = vec![Expression::Variable("n".to_string())];
239        let params = Params::new();
240
241        let result = execute_delete(vec![record], &exprs, false, &mut engine, &params, &(), &());
242        assert!(result.is_ok());
243    }
244}