use super::*;
#[cfg(test)]
mod multi_label_tests {
use super::*;
use crate::datatypes::Value;
use crate::graph::schema::NodeData;
use crate::graph::storage::GraphWrite;
fn add_node(graph: &mut DirGraph, id: &str, node_type: &str) -> NodeIndex {
let nd = NodeData::new(
Value::String(id.to_string()),
Value::String(id.to_string()),
node_type.to_string(),
HashMap::new(),
&mut graph.interner,
);
let idx = GraphWrite::add_node(&mut graph.graph, nd);
graph
.type_indices
.entry_or_default(node_type.to_string())
.push(idx);
idx
}
#[test]
fn add_node_label_idempotent_and_no_op_on_primary() {
let mut g = DirGraph::new();
let idx = add_node(&mut g, "n1", "Person");
let reviewer = g.interner.get_or_intern("Reviewer");
let person = g.interner.get_or_intern("Person");
assert!(g.add_node_label(idx, reviewer));
assert!(g.has_secondary_labels);
assert_eq!(g.secondary_label_index[&reviewer], vec![idx]);
assert!(!g.add_node_label(idx, reviewer));
assert_eq!(g.secondary_label_index[&reviewer], vec![idx]);
assert!(!g.add_node_label(idx, person));
let labels = g.node_labels(idx);
assert_eq!(labels.len(), 2);
assert_eq!(labels[0], person);
assert_eq!(labels[1], reviewer);
}
#[test]
fn remove_node_label_errors_on_primary() {
let mut g = DirGraph::new();
let idx = add_node(&mut g, "n1", "Person");
let person = g.interner.get_or_intern("Person");
let err = g.remove_node_label(idx, person).unwrap_err();
assert!(err.contains("primary label"));
}
#[test]
fn remove_node_label_clears_index_when_last_node_drops_it() {
let mut g = DirGraph::new();
let a = add_node(&mut g, "a", "Person");
let b = add_node(&mut g, "b", "Person");
let reviewer = g.interner.get_or_intern("Reviewer");
g.add_node_label(a, reviewer);
g.add_node_label(b, reviewer);
assert_eq!(g.secondary_label_index[&reviewer].len(), 2);
assert!(g.remove_node_label(a, reviewer).unwrap());
assert_eq!(g.secondary_label_index[&reviewer], vec![b]);
assert!(g.has_secondary_labels);
assert!(g.remove_node_label(b, reviewer).unwrap());
assert!(!g.secondary_label_index.contains_key(&reviewer));
assert!(!g.has_secondary_labels);
}
#[test]
fn rebuild_does_not_clobber_secondary_index() {
let mut g = DirGraph::new();
let idx = add_node(&mut g, "n1", "Person");
let reviewer = g.interner.get_or_intern("Reviewer");
g.add_node_label(idx, reviewer);
let before = g.secondary_label_index.clone();
let before_flag = g.has_secondary_labels;
g.rebuild_type_indices();
assert_eq!(g.secondary_label_index, before);
assert_eq!(g.has_secondary_labels, before_flag);
assert_eq!(
g.type_indices.get("Person").map(|s| s.iter().collect()),
Some(vec![idx])
);
}
#[test]
fn dir_graph_node_labels_returns_primary_plus_extras() {
let mut g = DirGraph::new();
let idx = add_node(&mut g, "n1", "Person");
let reviewer = g.interner.get_or_intern("Reviewer");
let person = g.interner.get_or_intern("Person");
g.add_node_label(idx, reviewer);
let labels = g.node_labels(idx);
assert_eq!(labels, vec![person, reviewer]);
}
#[test]
fn nodes_with_label_single_label_fast_path() {
let mut g = DirGraph::new();
let a = add_node(&mut g, "a", "Person");
let b = add_node(&mut g, "b", "Person");
add_node(&mut g, "w", "Widget");
assert!(!g.has_secondary_labels);
assert_eq!(g.nodes_with_label("Person"), vec![a, b]);
assert_eq!(g.nodes_with_label("Widget").len(), 1);
assert!(g.nodes_with_label("Absent").is_empty());
}
#[test]
fn nodes_with_label_unions_primary_and_secondary() {
let mut g = DirGraph::new();
let a = add_node(&mut g, "a", "Person"); let b = add_node(&mut g, "b", "Person"); let w = add_node(&mut g, "w", "Widget"); let vip = g.interner.get_or_intern("VIP");
g.add_node_label(a, vip);
g.add_node_label(w, vip);
let persons = g.nodes_with_label("Person");
assert_eq!(persons, vec![a, b]);
let mut vips = g.nodes_with_label("VIP");
vips.sort();
let mut expected = vec![a, w];
expected.sort();
assert_eq!(vips, expected);
}
#[test]
fn node_has_label_primary_secondary_and_absent() {
let mut g = DirGraph::new();
let a = add_node(&mut g, "a", "Person");
let person = g.interner.get_or_intern("Person");
let vip = g.interner.get_or_intern("VIP");
let ghost = g.interner.get_or_intern("Ghost");
g.add_node_label(a, vip);
assert!(g.node_has_label(a, person)); assert!(g.node_has_label(a, vip)); assert!(!g.node_has_label(a, ghost)); }
#[test]
fn detach_delete_evicts_secondary_label_index() {
use std::collections::HashSet;
let mut g = DirGraph::new();
let a = add_node(&mut g, "a", "Person");
let b = add_node(&mut g, "b", "Person");
let vip = g.interner.get_or_intern("VIP");
g.add_node_label(a, vip);
g.add_node_label(b, vip);
assert_eq!(g.secondary_label_index[&vip].len(), 2);
let to_del: HashSet<NodeIndex> = [a].into_iter().collect();
crate::graph::mutation::maintain::detach_delete_nodes(&mut g, &to_del);
assert_eq!(g.secondary_label_index.get(&vip).map(|v| v.len()), Some(1));
assert!(g.has_secondary_labels);
assert_eq!(g.nodes_with_label("VIP"), vec![b]);
}
}
#[cfg(test)]
mod bulk_index_freshness_tests {
use super::*;
use crate::datatypes::values::{DataFrame, Value};
use crate::graph::mutation::maintain::add_nodes;
fn people(rows: Vec<(&str, &str)>) -> DataFrame {
DataFrame::from_cypher_rows(
vec!["id".to_string(), "city".to_string()],
rows.into_iter()
.map(|(id, city)| {
vec![
Value::String(id.to_string()),
Value::String(city.to_string()),
]
})
.collect(),
)
.expect("dataframe")
}
#[test]
fn add_nodes_keeps_property_index_fresh() {
let mut g = DirGraph::new();
add_nodes(
&mut g,
people(vec![("p1", "Oslo")]),
"Person".to_string(),
"id".to_string(),
None,
None,
)
.expect("first load");
assert_eq!(g.create_index("Person", "city"), 1);
add_nodes(
&mut g,
people(vec![("p2", "Oslo"), ("p3", "Bergen")]),
"Person".to_string(),
"id".to_string(),
None,
None,
)
.expect("second load");
let oslo = g
.lookup_by_index("Person", "city", &Value::String("Oslo".to_string()))
.unwrap_or_default();
assert_eq!(oslo.len(), 2, "bulk load left the property index stale");
}
#[test]
fn add_nodes_keeps_range_and_composite_indexes_fresh() {
let mut g = DirGraph::new();
add_nodes(
&mut g,
people(vec![("p1", "Oslo")]),
"Person".to_string(),
"id".to_string(),
None,
None,
)
.expect("first load");
g.create_range_index("Person", "city");
g.create_composite_index("Person", &["city"]);
add_nodes(
&mut g,
people(vec![("p2", "Oslo")]),
"Person".to_string(),
"id".to_string(),
None,
None,
)
.expect("second load");
let oslo = Value::String("Oslo".to_string());
let ranged = g
.lookup_range(
"Person",
"city",
std::ops::Bound::Included(&oslo),
std::ops::Bound::Included(&oslo),
)
.unwrap_or_default();
assert_eq!(ranged.len(), 2, "bulk load left the range index stale");
let composite = g
.lookup_by_composite_index("Person", &["city".to_string()], &[oslo])
.unwrap_or_default();
assert_eq!(
composite.len(),
2,
"bulk load left the composite index stale"
);
}
}
#[cfg(test)]
mod constraint_snapshot_tests {
use super::*;
#[test]
fn populate_index_keys_snapshots_unique_constraints_sorted() {
let mut graph = DirGraph::new();
for (node_type, properties) in [
("Person", vec!["email"]),
("Order", vec!["ref"]),
("Person", vec!["city", "street"]),
("Person", vec!["ssn"]),
("Order", vec!["customer", "seq"]),
] {
graph
.create_unique_constraint(node_type, &properties)
.expect("empty graph cannot violate a constraint");
}
graph.populate_index_keys();
let expected: Vec<(String, Vec<String>)> = [
("Order", vec!["customer", "seq"]),
("Order", vec!["ref"]),
("Person", vec!["city", "street"]),
("Person", vec!["email"]),
("Person", vec!["ssn"]),
]
.into_iter()
.map(|(t, props)| {
(
t.to_string(),
props.into_iter().map(str::to_string).collect(),
)
})
.collect();
assert_eq!(
graph.unique_constraint_keys, expected,
"unique_constraint_keys must be persisted in a deterministic order"
);
}
}
#[cfg(test)]
mod overwrite_index_freshness_tests {
use super::*;
use crate::datatypes::values::{DataFrame, Value};
use crate::graph::mutation::maintain::{add_nodes, update_node_properties};
use crate::graph::storage::GraphWrite;
fn people(rows: Vec<(&str, &str)>) -> DataFrame {
DataFrame::from_cypher_rows(
vec!["id".to_string(), "city".to_string()],
rows.into_iter()
.map(|(id, city)| {
vec![
Value::String(id.to_string()),
Value::String(city.to_string()),
]
})
.collect(),
)
.expect("dataframe")
}
#[test]
fn update_node_properties_keeps_the_property_index_fresh() {
let mut g = DirGraph::new();
add_nodes(
&mut g,
people(vec![("p1", "Oslo")]),
"Person".to_string(),
"id".to_string(),
None,
None,
)
.expect("load");
assert_eq!(g.create_index("Person", "city"), 1);
let node = g
.type_indices
.get("Person")
.and_then(|nodes| nodes.iter().next())
.expect("the loaded Person node");
update_node_properties(
&mut g,
&[(Some(node), Value::String("Bergen".to_string()))],
"city",
)
.expect("update");
let stale = g
.lookup_by_index("Person", "city", &Value::String("Oslo".to_string()))
.unwrap_or_default();
assert!(
stale.is_empty(),
"the index still resolves the overwritten value 'Oslo' to {stale:?} — \
MATCH (n:Person {{city: 'Oslo'}}) would return a node whose city is 'Bergen'"
);
let fresh = g
.lookup_by_index("Person", "city", &Value::String("Bergen".to_string()))
.unwrap_or_default();
assert_eq!(fresh, vec![node], "the new value is not indexed");
}
#[test]
fn update_node_properties_keeps_range_and_composite_indexes_fresh() {
let mut g = DirGraph::new();
add_nodes(
&mut g,
people(vec![("p1", "Oslo")]),
"Person".to_string(),
"id".to_string(),
None,
None,
)
.expect("load");
g.create_range_index("Person", "city");
g.create_composite_index("Person", &["city"]);
let node = g
.type_indices
.get("Person")
.and_then(|nodes| nodes.iter().next())
.expect("the loaded Person node");
update_node_properties(
&mut g,
&[(Some(node), Value::String("Bergen".to_string()))],
"city",
)
.expect("update");
let oslo = Value::String("Oslo".to_string());
let ranged = g
.lookup_range(
"Person",
"city",
std::ops::Bound::Included(&oslo),
std::ops::Bound::Included(&oslo),
)
.unwrap_or_default();
assert!(
ranged.is_empty(),
"the range index still resolves the overwritten value: {ranged:?}"
);
let composite = g
.lookup_by_composite_index("Person", &["city".to_string()], &[oslo])
.unwrap_or_default();
assert!(
composite.is_empty(),
"the composite index still resolves the overwritten value: {composite:?}"
);
}
#[test]
fn update_node_properties_reuses_validation_without_changing_report_semantics() {
let mut g = DirGraph::new();
add_nodes(
&mut g,
people(vec![("p1", "Oslo"), ("p2", "Trondheim")]),
"Person".to_string(),
"id".to_string(),
None,
None,
)
.expect("load");
let loaded: Vec<NodeIndex> = g
.type_indices
.get("Person")
.expect("the loaded Person nodes")
.iter()
.collect();
let [node, dead] = loaded.as_slice() else {
panic!("expected exactly two loaded Person nodes");
};
let (node, dead) = (*node, *dead);
GraphWrite::remove_node(&mut g.graph, dead).expect("remove the second node");
let missing = NodeIndex::new(node.index() + 10_000);
let report = update_node_properties(
&mut g,
&[
(Some(node), Value::Int64(7)),
(Some(node), Value::Int64(7)),
(Some(dead), Value::Int64(7)),
(Some(missing), Value::Int64(7)),
(None, Value::Int64(7)),
],
"city",
)
.expect("valid rows still update when other rows are absent");
assert_eq!(
report.nodes_updated, 2,
"duplicate live rows remain updates"
);
assert_eq!(
report.nodes_skipped, 6,
"dead, missing, and absent rows retain validation + assembly skip accounting"
);
assert_eq!(report.errors.len(), 5);
for invalid in [dead, missing] {
assert!(report
.errors
.iter()
.any(|error| error == &format!("Node index {:?} not found in graph", invalid)));
assert!(report
.errors
.iter()
.any(|error| error == &format!("Node index {:?} is out of bounds", invalid)));
}
assert!(report
.errors
.iter()
.any(|error| error.contains("Type mismatch")));
assert_eq!(
g.get_node(node)
.and_then(|data| data.get_property("city"))
.map(|value| value.into_owned()),
Some(Value::Int64(7))
);
}
}