Skip to main content

cypherlite_query/executor/operators/
set_props.rs

1// SetPropsOp: property mutation for SET/REMOVE clauses
2
3use crate::executor::eval::eval;
4use crate::executor::operators::create::{
5    is_system_property, is_temporal_edge_property, SYSTEM_PROP_UPDATED_AT,
6};
7use crate::executor::{ExecutionError, Params, Record, ScalarFnLookup, TriggerLookup, Value};
8use crate::parser::ast::*;
9use cypherlite_core::{LabelRegistry, PropertyValue};
10use cypherlite_storage::StorageEngine;
11
12/// Set properties on nodes/edges.
13/// For each SetItem::Property { target, value }, evaluate target to get the entity,
14/// and value to get the new property value.
15pub fn execute_set(
16    source_records: Vec<Record>,
17    items: &[SetItem],
18    engine: &mut StorageEngine,
19    params: &Params,
20    scalar_fns: &dyn ScalarFnLookup,
21    trigger_fns: &dyn TriggerLookup,
22) -> Result<Vec<Record>, ExecutionError> {
23    for record in &source_records {
24        for item in items {
25            match item {
26                SetItem::Property { target, value } => {
27                    apply_set_property(
28                        target,
29                        value,
30                        record,
31                        engine,
32                        params,
33                        scalar_fns,
34                        trigger_fns,
35                    )?;
36                }
37            }
38        }
39    }
40
41    Ok(source_records)
42}
43
44/// Get the current query timestamp from params.
45fn get_query_timestamp(params: &Params) -> i64 {
46    match params.get("__query_start_ms__") {
47        Some(Value::Int64(ms)) => *ms,
48        _ => std::time::SystemTime::now()
49            .duration_since(std::time::UNIX_EPOCH)
50            .map(|d| d.as_millis() as i64)
51            .unwrap_or(0),
52    }
53}
54
55/// Update _updated_at on a node's property list.
56fn inject_updated_at(
57    props: &mut Vec<(u32, PropertyValue)>,
58    engine: &mut StorageEngine,
59    params: &Params,
60) {
61    let now = get_query_timestamp(params);
62    let updated_key = engine.get_or_create_prop_key(SYSTEM_PROP_UPDATED_AT);
63    let mut found = false;
64    for (k, v) in props.iter_mut() {
65        if *k == updated_key {
66            *v = PropertyValue::DateTime(now);
67            found = true;
68            break;
69        }
70    }
71    if !found {
72        props.push((updated_key, PropertyValue::DateTime(now)));
73    }
74}
75
76/// Apply a single SET property operation.
77fn apply_set_property(
78    target: &Expression,
79    value_expr: &Expression,
80    record: &Record,
81    engine: &mut StorageEngine,
82    params: &Params,
83    scalar_fns: &dyn ScalarFnLookup,
84    trigger_fns: &dyn TriggerLookup,
85) -> Result<(), ExecutionError> {
86    // target should be Property(Variable(name), prop_name)
87    match target {
88        Expression::Property(var_expr, prop_name) => {
89            // V-003: Block user writes to system properties
90            // BB-T4: _valid_from/_valid_to are temporal but user-settable
91            if is_system_property(prop_name) {
92                return Err(ExecutionError {
93                    message: format!("System property is read-only: {}", prop_name),
94                });
95            }
96
97            let entity = eval(var_expr, record, &*engine, params, scalar_fns)?;
98            let new_value = eval(value_expr, record, &*engine, params, scalar_fns)?;
99            let pv = PropertyValue::try_from(new_value).map_err(|e| ExecutionError {
100                message: format!("invalid property value: {}", e),
101            })?;
102
103            let temporal_enabled = engine.config().temporal_tracking_enabled;
104
105            match entity {
106                Value::Node(nid) => {
107                    // Block temporal edge properties on nodes
108                    if is_temporal_edge_property(prop_name) {
109                        return Err(ExecutionError {
110                            message: format!(
111                                "Property '{}' is only valid on edges, not nodes",
112                                prop_name
113                            ),
114                        });
115                    }
116
117                    let prop_key_id = engine.get_or_create_prop_key(prop_name);
118                    // Get current node properties and label info before mutable borrow
119                    let node = engine.get_node(nid).ok_or_else(|| ExecutionError {
120                        message: format!("node {} not found", nid.0),
121                    })?;
122                    let mut props = node.properties.clone();
123                    let label_name =
124                        node.labels.first().copied().and_then(|lid| {
125                            engine.catalog().label_name(lid).map(|s| s.to_string())
126                        });
127                    // Release the immutable borrow on engine (node is no longer needed)
128                    let _ = node;
129
130                    // Update or add the property
131                    let mut found = false;
132                    for (k, v) in &mut props {
133                        if *k == prop_key_id {
134                            *v = pv.clone();
135                            found = true;
136                            break;
137                        }
138                    }
139                    if !found {
140                        props.push((prop_key_id, pv));
141                    }
142
143                    // V-002: Update _updated_at timestamp
144                    if temporal_enabled {
145                        inject_updated_at(&mut props, engine, params);
146                    }
147
148                    // Fire before_update trigger
149                    let ctx = cypherlite_core::TriggerContext {
150                        entity_type: cypherlite_core::EntityType::Node,
151                        entity_id: nid.0,
152                        label_or_type: label_name,
153                        properties: props
154                            .iter()
155                            .map(|(k, v)| {
156                                let name = engine
157                                    .catalog()
158                                    .prop_key_name(*k)
159                                    .unwrap_or("?")
160                                    .to_string();
161                                (name, v.clone())
162                            })
163                            .collect(),
164                        operation: cypherlite_core::TriggerOperation::Update,
165                    };
166                    trigger_fns.fire_before_update(&ctx)?;
167
168                    engine.update_node(nid, props).map_err(|e| ExecutionError {
169                        message: format!("failed to update node: {}", e),
170                    })?;
171
172                    trigger_fns.fire_after_update(&ctx)?;
173                }
174                Value::Edge(eid) => {
175                    let prop_key_id = engine.get_or_create_prop_key(prop_name);
176                    // Get current edge properties and rel_type info before mutable borrow
177                    let edge = engine.get_edge(eid).ok_or_else(|| ExecutionError {
178                        message: format!("edge {} not found", eid.0),
179                    })?;
180                    let mut props = edge.properties.clone();
181                    let rel_type_name = engine
182                        .catalog()
183                        .rel_type_name(edge.rel_type_id)
184                        .map(|s| s.to_string());
185                    // Release the immutable borrow on engine
186                    let _ = edge;
187
188                    // Update or add the property
189                    let mut found = false;
190                    for (k, v) in &mut props {
191                        if *k == prop_key_id {
192                            *v = pv.clone();
193                            found = true;
194                            break;
195                        }
196                    }
197                    if !found {
198                        props.push((prop_key_id, pv));
199                    }
200
201                    // BB-T5: Update _updated_at timestamp on edge SET
202                    if temporal_enabled {
203                        inject_updated_at(&mut props, engine, params);
204                    }
205
206                    // Fire before_update trigger
207                    let ctx = cypherlite_core::TriggerContext {
208                        entity_type: cypherlite_core::EntityType::Edge,
209                        entity_id: eid.0,
210                        label_or_type: rel_type_name,
211                        properties: props
212                            .iter()
213                            .map(|(k, v)| {
214                                let name = engine
215                                    .catalog()
216                                    .prop_key_name(*k)
217                                    .unwrap_or("?")
218                                    .to_string();
219                                (name, v.clone())
220                            })
221                            .collect(),
222                        operation: cypherlite_core::TriggerOperation::Update,
223                    };
224                    trigger_fns.fire_before_update(&ctx)?;
225
226                    engine.update_edge(eid, props).map_err(|e| ExecutionError {
227                        message: format!("failed to update edge: {}", e),
228                    })?;
229
230                    trigger_fns.fire_after_update(&ctx)?;
231                }
232                Value::Null => {
233                    // SET on null is a no-op (Cypher behavior)
234                }
235                _ => {
236                    return Err(ExecutionError {
237                        message: "SET target must be a node or edge property".to_string(),
238                    });
239                }
240            }
241        }
242        _ => {
243            return Err(ExecutionError {
244                message: "SET target must be a property access expression".to_string(),
245            });
246        }
247    }
248
249    Ok(())
250}
251
252/// Execute REMOVE operations (remove properties or labels).
253pub fn execute_remove(
254    source_records: Vec<Record>,
255    items: &[RemoveItem],
256    engine: &mut StorageEngine,
257    params: &Params,
258    scalar_fns: &dyn ScalarFnLookup,
259    trigger_fns: &dyn TriggerLookup,
260) -> Result<Vec<Record>, ExecutionError> {
261    for record in &source_records {
262        for item in items {
263            match item {
264                RemoveItem::Property(prop_expr) => {
265                    apply_remove_property(
266                        prop_expr,
267                        record,
268                        engine,
269                        params,
270                        scalar_fns,
271                        trigger_fns,
272                    )?;
273                }
274                RemoveItem::Label { variable, label } => {
275                    apply_remove_label(variable, label, record, engine)?;
276                }
277            }
278        }
279    }
280
281    Ok(source_records)
282}
283
284/// Remove a property from a node/edge.
285fn apply_remove_property(
286    prop_expr: &Expression,
287    record: &Record,
288    engine: &mut StorageEngine,
289    params: &Params,
290    scalar_fns: &dyn ScalarFnLookup,
291    trigger_fns: &dyn TriggerLookup,
292) -> Result<(), ExecutionError> {
293    match prop_expr {
294        Expression::Property(var_expr, prop_name) => {
295            // V-003: Block removal of system properties
296            if is_system_property(prop_name) {
297                return Err(ExecutionError {
298                    message: format!("System property is read-only: {}", prop_name),
299                });
300            }
301
302            let entity = eval(var_expr, record, &*engine, params, scalar_fns)?;
303            let temporal_enabled = engine.config().temporal_tracking_enabled;
304
305            match entity {
306                Value::Node(nid) => {
307                    let prop_key_id = match engine.catalog().prop_key_id(prop_name) {
308                        Some(id) => id,
309                        None => return Ok(()), // Property key doesn't exist, nothing to remove
310                    };
311
312                    let node = engine.get_node(nid).ok_or_else(|| ExecutionError {
313                        message: format!("node {} not found", nid.0),
314                    })?;
315                    let label_name =
316                        node.labels.first().copied().and_then(|lid| {
317                            engine.catalog().label_name(lid).map(|s| s.to_string())
318                        });
319                    let mut props: Vec<_> = node
320                        .properties
321                        .iter()
322                        .filter(|(k, _)| *k != prop_key_id)
323                        .cloned()
324                        .collect();
325
326                    // V-002: Update _updated_at on REMOVE
327                    if temporal_enabled {
328                        inject_updated_at(&mut props, engine, params);
329                    }
330
331                    let ctx = cypherlite_core::TriggerContext {
332                        entity_type: cypherlite_core::EntityType::Node,
333                        entity_id: nid.0,
334                        label_or_type: label_name,
335                        properties: props
336                            .iter()
337                            .map(|(k, v)| {
338                                let name = engine
339                                    .catalog()
340                                    .prop_key_name(*k)
341                                    .unwrap_or("?")
342                                    .to_string();
343                                (name, v.clone())
344                            })
345                            .collect(),
346                        operation: cypherlite_core::TriggerOperation::Update,
347                    };
348                    trigger_fns.fire_before_update(&ctx)?;
349
350                    engine.update_node(nid, props).map_err(|e| ExecutionError {
351                        message: format!("failed to update node: {}", e),
352                    })?;
353
354                    trigger_fns.fire_after_update(&ctx)?;
355                }
356                Value::Edge(eid) => {
357                    let prop_key_id = match engine.catalog().prop_key_id(prop_name) {
358                        Some(id) => id,
359                        None => return Ok(()),
360                    };
361
362                    let edge = engine.get_edge(eid).ok_or_else(|| ExecutionError {
363                        message: format!("edge {} not found", eid.0),
364                    })?;
365                    let rel_type_name = engine
366                        .catalog()
367                        .rel_type_name(edge.rel_type_id)
368                        .map(|s| s.to_string());
369                    let mut props: Vec<_> = edge
370                        .properties
371                        .iter()
372                        .filter(|(k, _)| *k != prop_key_id)
373                        .cloned()
374                        .collect();
375
376                    if temporal_enabled {
377                        inject_updated_at(&mut props, engine, params);
378                    }
379
380                    let ctx = cypherlite_core::TriggerContext {
381                        entity_type: cypherlite_core::EntityType::Edge,
382                        entity_id: eid.0,
383                        label_or_type: rel_type_name,
384                        properties: props
385                            .iter()
386                            .map(|(k, v)| {
387                                let name = engine
388                                    .catalog()
389                                    .prop_key_name(*k)
390                                    .unwrap_or("?")
391                                    .to_string();
392                                (name, v.clone())
393                            })
394                            .collect(),
395                        operation: cypherlite_core::TriggerOperation::Update,
396                    };
397                    trigger_fns.fire_before_update(&ctx)?;
398
399                    engine.update_edge(eid, props).map_err(|e| ExecutionError {
400                        message: format!("failed to update edge: {}", e),
401                    })?;
402
403                    trigger_fns.fire_after_update(&ctx)?;
404                }
405                Value::Null => {} // no-op
406                _ => {
407                    return Err(ExecutionError {
408                        message: "REMOVE target must be a node or edge property".to_string(),
409                    });
410                }
411            }
412        }
413        _ => {
414            return Err(ExecutionError {
415                message: "REMOVE property must be a property access expression".to_string(),
416            });
417        }
418    }
419
420    Ok(())
421}
422
423/// Remove a label from a node.
424fn apply_remove_label(
425    variable: &str,
426    label: &str,
427    record: &Record,
428    engine: &mut StorageEngine,
429) -> Result<(), ExecutionError> {
430    let entity = record.get(variable).cloned().unwrap_or(Value::Null);
431
432    match entity {
433        Value::Node(nid) => {
434            let label_id = match engine.catalog().label_id(label) {
435                Some(id) => id,
436                None => return Ok(()), // Label doesn't exist, nothing to remove
437            };
438
439            let node = engine.get_node(nid).ok_or_else(|| ExecutionError {
440                message: format!("node {} not found", nid.0),
441            })?;
442
443            // We can't directly modify labels through the current API.
444            // The node record has labels as a separate field.
445            // For now, we'll use update_node with existing properties.
446            // Labels modification would need a dedicated API.
447            // This is a limitation we note but cannot fully implement
448            // without extending the StorageEngine API.
449            let _ = label_id;
450            let _ = node;
451
452            // Note: StorageEngine doesn't expose a label modification API.
453            // This would need update_node_labels() or similar.
454            Ok(())
455        }
456        Value::Null => Ok(()),
457        _ => Err(ExecutionError {
458            message: "REMOVE label target must be a node".to_string(),
459        }),
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use crate::executor::Record;
467    use cypherlite_core::{DatabaseConfig, SyncMode};
468    use tempfile::tempdir;
469
470    fn test_engine(dir: &std::path::Path) -> StorageEngine {
471        let config = DatabaseConfig {
472            path: dir.join("test.cyl"),
473            wal_sync_mode: SyncMode::Normal,
474            ..Default::default()
475        };
476        StorageEngine::open(config).expect("open")
477    }
478
479    #[test]
480    fn test_set_property_on_node() {
481        let dir = tempdir().expect("tempdir");
482        let mut engine = test_engine(dir.path());
483
484        let name_key = engine.get_or_create_prop_key("name");
485        let nid = engine.create_node(
486            vec![],
487            vec![(name_key, PropertyValue::String("Alice".into()))],
488        );
489
490        let mut record = Record::new();
491        record.insert("n".to_string(), Value::Node(nid));
492
493        let items = vec![SetItem::Property {
494            target: Expression::Property(
495                Box::new(Expression::Variable("n".to_string())),
496                "name".to_string(),
497            ),
498            value: Expression::Literal(Literal::String("Bob".into())),
499        }];
500
501        let params = Params::new();
502        let result = execute_set(vec![record], &items, &mut engine, &params, &(), &());
503        assert!(result.is_ok());
504
505        // Verify property was updated
506        let node = engine.get_node(nid).expect("node exists");
507        let name_val = node
508            .properties
509            .iter()
510            .find(|(k, _)| *k == name_key)
511            .map(|(_, v)| v);
512        assert_eq!(name_val, Some(&PropertyValue::String("Bob".into())));
513    }
514
515    #[test]
516    fn test_set_new_property() {
517        let dir = tempdir().expect("tempdir");
518        let mut engine = test_engine(dir.path());
519
520        let nid = engine.create_node(vec![], vec![]);
521
522        let mut record = Record::new();
523        record.insert("n".to_string(), Value::Node(nid));
524
525        let items = vec![SetItem::Property {
526            target: Expression::Property(
527                Box::new(Expression::Variable("n".to_string())),
528                "age".to_string(),
529            ),
530            value: Expression::Literal(Literal::Integer(30)),
531        }];
532
533        let params = Params::new();
534        let result = execute_set(vec![record], &items, &mut engine, &params, &(), &());
535        assert!(result.is_ok());
536
537        let age_key = engine.catalog().prop_key_id("age").expect("age key");
538        let node = engine.get_node(nid).expect("node exists");
539        let age_val = node
540            .properties
541            .iter()
542            .find(|(k, _)| *k == age_key)
543            .map(|(_, v)| v);
544        assert_eq!(age_val, Some(&PropertyValue::Int64(30)));
545    }
546
547    #[test]
548    fn test_set_on_null_is_noop() {
549        let dir = tempdir().expect("tempdir");
550        let mut engine = test_engine(dir.path());
551
552        let mut record = Record::new();
553        record.insert("n".to_string(), Value::Null);
554
555        let items = vec![SetItem::Property {
556            target: Expression::Property(
557                Box::new(Expression::Variable("n".to_string())),
558                "name".to_string(),
559            ),
560            value: Expression::Literal(Literal::String("test".into())),
561        }];
562
563        let params = Params::new();
564        let result = execute_set(vec![record], &items, &mut engine, &params, &(), &());
565        assert!(result.is_ok());
566    }
567
568    #[test]
569    fn test_remove_property() {
570        let dir = tempdir().expect("tempdir");
571        let mut engine = test_engine(dir.path());
572
573        let name_key = engine.get_or_create_prop_key("name");
574        let age_key = engine.get_or_create_prop_key("age");
575        let nid = engine.create_node(
576            vec![],
577            vec![
578                (name_key, PropertyValue::String("Alice".into())),
579                (age_key, PropertyValue::Int64(30)),
580            ],
581        );
582
583        let mut record = Record::new();
584        record.insert("n".to_string(), Value::Node(nid));
585
586        let items = vec![RemoveItem::Property(Expression::Property(
587            Box::new(Expression::Variable("n".to_string())),
588            "age".to_string(),
589        ))];
590
591        let params = Params::new();
592        let result = execute_remove(vec![record], &items, &mut engine, &params, &(), &());
593        assert!(result.is_ok());
594
595        let node = engine.get_node(nid).expect("node exists");
596        // After REMOVE, we have: name + _updated_at (temporal tracking is on by default)
597        assert_eq!(node.properties.len(), 2);
598        assert!(node.properties.iter().any(|(k, _)| *k == name_key));
599        let updated_key = engine.catalog().prop_key_id("_updated_at").expect("key");
600        assert!(node.properties.iter().any(|(k, _)| *k == updated_key));
601    }
602
603    // BB-T5: SET property on edge
604    #[test]
605    fn test_set_property_on_edge() {
606        let dir = tempdir().expect("tempdir");
607        let mut engine = test_engine(dir.path());
608
609        let weight_key = engine.get_or_create_prop_key("weight");
610        let n1 = engine.create_node(vec![], vec![]);
611        let n2 = engine.create_node(vec![], vec![]);
612        let eid = engine
613            .create_edge(n1, n2, 1, vec![(weight_key, PropertyValue::Float64(1.0))])
614            .expect("edge");
615
616        let mut record = Record::new();
617        record.insert("r".to_string(), Value::Edge(eid));
618
619        let items = vec![SetItem::Property {
620            target: Expression::Property(
621                Box::new(Expression::Variable("r".to_string())),
622                "weight".to_string(),
623            ),
624            value: Expression::Literal(Literal::Float(2.5)),
625        }];
626
627        let params = Params::new();
628        let result = execute_set(vec![record], &items, &mut engine, &params, &(), &());
629        assert!(result.is_ok());
630
631        let edge = engine.get_edge(eid).expect("edge exists");
632        let weight_val = edge
633            .properties
634            .iter()
635            .find(|(k, _)| *k == weight_key)
636            .map(|(_, v)| v);
637        assert_eq!(weight_val, Some(&PropertyValue::Float64(2.5)));
638    }
639
640    // BB-T5: SET on edge updates _updated_at
641    #[test]
642    fn test_set_on_edge_updates_updated_at() {
643        let dir = tempdir().expect("tempdir");
644        let mut engine = test_engine(dir.path());
645
646        let n1 = engine.create_node(vec![], vec![]);
647        let n2 = engine.create_node(vec![], vec![]);
648        let eid = engine.create_edge(n1, n2, 1, vec![]).expect("edge");
649
650        let mut record = Record::new();
651        record.insert("r".to_string(), Value::Edge(eid));
652
653        let items = vec![SetItem::Property {
654            target: Expression::Property(
655                Box::new(Expression::Variable("r".to_string())),
656                "weight".to_string(),
657            ),
658            value: Expression::Literal(Literal::Float(1.0)),
659        }];
660
661        let mut params = Params::new();
662        params.insert("__query_start_ms__".to_string(), Value::Int64(9_999_999));
663        let result = execute_set(vec![record], &items, &mut engine, &params, &(), &());
664        assert!(result.is_ok());
665
666        let edge = engine.get_edge(eid).expect("edge exists");
667        let updated_key = engine
668            .catalog()
669            .prop_key_id("_updated_at")
670            .expect("updated key");
671        let updated_val = edge
672            .properties
673            .iter()
674            .find(|(k, _)| *k == updated_key)
675            .map(|(_, v)| v);
676        assert_eq!(updated_val, Some(&PropertyValue::DateTime(9_999_999)));
677    }
678
679    // BB-T4: SET _valid_from on edge is allowed
680    #[test]
681    fn test_set_valid_from_on_edge_allowed() {
682        let dir = tempdir().expect("tempdir");
683        let mut engine = test_engine(dir.path());
684
685        let n1 = engine.create_node(vec![], vec![]);
686        let n2 = engine.create_node(vec![], vec![]);
687        let eid = engine.create_edge(n1, n2, 1, vec![]).expect("edge");
688
689        let mut record = Record::new();
690        record.insert("r".to_string(), Value::Edge(eid));
691
692        let items = vec![SetItem::Property {
693            target: Expression::Property(
694                Box::new(Expression::Variable("r".to_string())),
695                "_valid_from".to_string(),
696            ),
697            value: Expression::Literal(Literal::Integer(1_700_000_000_000)),
698        }];
699
700        let params = Params::new();
701        let result = execute_set(vec![record], &items, &mut engine, &params, &(), &());
702        assert!(result.is_ok());
703
704        let edge = engine.get_edge(eid).expect("edge exists");
705        let vf_key = engine
706            .catalog()
707            .prop_key_id("_valid_from")
708            .expect("valid_from key");
709        let vf_val = edge
710            .properties
711            .iter()
712            .find(|(k, _)| *k == vf_key)
713            .map(|(_, v)| v);
714        assert_eq!(vf_val, Some(&PropertyValue::Int64(1_700_000_000_000)));
715    }
716
717    // BB-T4: SET _valid_to on edge is allowed
718    #[test]
719    fn test_set_valid_to_on_edge_allowed() {
720        let dir = tempdir().expect("tempdir");
721        let mut engine = test_engine(dir.path());
722
723        let n1 = engine.create_node(vec![], vec![]);
724        let n2 = engine.create_node(vec![], vec![]);
725        let eid = engine.create_edge(n1, n2, 1, vec![]).expect("edge");
726
727        let mut record = Record::new();
728        record.insert("r".to_string(), Value::Edge(eid));
729
730        let items = vec![SetItem::Property {
731            target: Expression::Property(
732                Box::new(Expression::Variable("r".to_string())),
733                "_valid_to".to_string(),
734            ),
735            value: Expression::Literal(Literal::Integer(1_800_000_000_000)),
736        }];
737
738        let params = Params::new();
739        let result = execute_set(vec![record], &items, &mut engine, &params, &(), &());
740        assert!(result.is_ok());
741    }
742
743    // BB-T4: SET _valid_from on NODE is blocked
744    #[test]
745    fn test_set_valid_from_on_node_blocked() {
746        let dir = tempdir().expect("tempdir");
747        let mut engine = test_engine(dir.path());
748
749        let nid = engine.create_node(vec![], vec![]);
750
751        let mut record = Record::new();
752        record.insert("n".to_string(), Value::Node(nid));
753
754        let items = vec![SetItem::Property {
755            target: Expression::Property(
756                Box::new(Expression::Variable("n".to_string())),
757                "_valid_from".to_string(),
758            ),
759            value: Expression::Literal(Literal::Integer(1_700_000_000_000)),
760        }];
761
762        let params = Params::new();
763        let result = execute_set(vec![record], &items, &mut engine, &params, &(), &());
764        assert!(result.is_err());
765    }
766
767    // BB-T5: SET _created_at on edge is blocked (system property)
768    #[test]
769    fn test_set_created_at_on_edge_blocked() {
770        let dir = tempdir().expect("tempdir");
771        let mut engine = test_engine(dir.path());
772
773        let n1 = engine.create_node(vec![], vec![]);
774        let n2 = engine.create_node(vec![], vec![]);
775        let eid = engine.create_edge(n1, n2, 1, vec![]).expect("edge");
776
777        let mut record = Record::new();
778        record.insert("r".to_string(), Value::Edge(eid));
779
780        let items = vec![SetItem::Property {
781            target: Expression::Property(
782                Box::new(Expression::Variable("r".to_string())),
783                "_created_at".to_string(),
784            ),
785            value: Expression::Literal(Literal::Integer(999)),
786        }];
787
788        let params = Params::new();
789        let result = execute_set(vec![record], &items, &mut engine, &params, &(), &());
790        assert!(result.is_err());
791    }
792
793    // BB: REMOVE property on edge works
794    #[test]
795    fn test_remove_property_on_edge() {
796        let dir = tempdir().expect("tempdir");
797        let mut engine = test_engine(dir.path());
798
799        let weight_key = engine.get_or_create_prop_key("weight");
800        let color_key = engine.get_or_create_prop_key("color");
801        let n1 = engine.create_node(vec![], vec![]);
802        let n2 = engine.create_node(vec![], vec![]);
803        let eid = engine
804            .create_edge(
805                n1,
806                n2,
807                1,
808                vec![
809                    (weight_key, PropertyValue::Float64(1.0)),
810                    (color_key, PropertyValue::String("red".into())),
811                ],
812            )
813            .expect("edge");
814
815        let mut record = Record::new();
816        record.insert("r".to_string(), Value::Edge(eid));
817
818        let items = vec![RemoveItem::Property(Expression::Property(
819            Box::new(Expression::Variable("r".to_string())),
820            "weight".to_string(),
821        ))];
822
823        let params = Params::new();
824        let result = execute_remove(vec![record], &items, &mut engine, &params, &(), &());
825        assert!(result.is_ok());
826
827        let edge = engine.get_edge(eid).expect("edge exists");
828        // weight removed, color remains, _updated_at added
829        assert!(edge.properties.iter().any(|(k, _)| *k == color_key));
830        assert!(!edge.properties.iter().any(|(k, _)| *k == weight_key));
831    }
832}