use super::*;
#[test]
fn rollback_removes_a_column_a_failed_set_introduced() {
let fresh = crate::graph::schema::InternedKey::from_str("fresh");
let mut graph = wide_columnar();
let columns_before = graph.column_store("Item").expect("master").column_count();
assert!(
graph
.column_store("Item")
.expect("master")
.slot(fresh)
.is_none(),
"precondition: the fixture must not already have the property"
);
let before = fingerprint(&mut graph);
expect_failure(
&mut graph,
&format!("MATCH (n:Item {{id: 1}}) SET n.fresh = 7 {FAILS_AFTER_A_COLUMNAR_WRITE}"),
None,
);
let store = graph.column_store("Item").expect("master");
assert_eq!(
store.column_count(),
columns_before,
"a failed SET left the column it appended behind; the schema-growth \
undo entry is missing or truncating to the wrong width"
);
assert!(
store.slot(fresh).is_none(),
"a failed SET left the property in the type schema, so the next write \
resolves a slot that no longer has a column"
);
assert_eq!(item_prop(&graph, 1, "fresh"), None);
assert_eq!(fingerprint(&mut graph), before);
}
#[test]
fn a_committed_set_of_a_new_property_grows_the_store() {
let fresh = crate::graph::schema::InternedKey::from_str("fresh");
let mut graph = wide_columnar();
let columns_before = graph.column_store("Item").expect("master").column_count();
run(&mut graph, "MATCH (n:Item {id: 1}) SET n.fresh = 7");
let store = graph.column_store("Item").expect("master");
assert_eq!(
store.column_count(),
columns_before + 1,
"a SET of an unknown property must append exactly one column"
);
assert!(store.slot(fresh).is_some());
assert_eq!(item_prop(&graph, 1, "fresh"), Some(Value::Int64(7)));
}
#[test]
fn rollback_restores_a_cell_written_twice_in_one_statement() {
let mut graph = wide_columnar();
let before = fingerprint(&mut graph);
assert_eq!(
item_prop(&graph, 1, "qty"),
Some(Value::Int64(1)),
"precondition: the fixture seeds qty = id"
);
expect_failure(
&mut graph,
&format!(
"MATCH (n:Item {{id: 1}}) SET n.qty = 111 SET n.qty = 222 \
{FAILS_AFTER_A_COLUMNAR_WRITE}"
),
None,
);
assert_eq!(
item_prop(&graph, 1, "qty"),
Some(Value::Int64(1)),
"two writes to one cell must roll back to the pre-statement value; \
reading 111 means only the last capture survived, 222 means none did"
);
assert_eq!(fingerprint(&mut graph), before);
}
#[test]
fn rollback_restores_a_cell_to_absent() {
let mut graph = wide_columnar();
run(&mut graph, "MATCH (n:Item {id: 5}) SET n.extra = 'kept'");
assert_eq!(
item_prop(&graph, 1, "extra"),
None,
"precondition: the column exists but row 1's cell is empty"
);
let before = fingerprint(&mut graph);
expect_failure(
&mut graph,
&format!("MATCH (n:Item {{id: 1}}) SET n.extra = 'doomed' {FAILS_AFTER_A_COLUMNAR_WRITE}"),
None,
);
assert_eq!(
item_prop(&graph, 1, "extra"),
None,
"a cell that was absent must read absent after the rollback"
);
assert_eq!(
item_prop(&graph, 5, "extra"),
Some(Value::String("kept".into())),
"and the committed value on the other row must be untouched"
);
assert_eq!(fingerprint(&mut graph), before);
}
#[test]
fn mapped_columnar_cells_roll_back() {
let mut graph = wide_columnar_mapped();
run(&mut graph, "MATCH (n:Item {id: 5}) SET n.extra = 'kept'");
let before = fingerprint(&mut graph);
let columns_before = graph.column_store("Item").expect("master").column_count();
expect_failure(
&mut graph,
&format!(
"MATCH (n:Item {{id: 1}}) SET n.qty = 111 SET n.qty = 222, \
n.extra = 'doomed', n.fresh = 7 {FAILS_AFTER_A_COLUMNAR_WRITE}"
),
None,
);
assert_eq!(item_prop(&graph, 1, "qty"), Some(Value::Int64(1)));
assert_eq!(item_prop(&graph, 1, "extra"), None);
assert_eq!(item_prop(&graph, 1, "fresh"), None);
assert_eq!(
graph.column_store("Item").expect("master").column_count(),
columns_before
);
assert_eq!(fingerprint(&mut graph), before);
}
#[test]
fn a_read_statement_clones_no_column_store() {
use crate::graph::storage::column_store::{column_store_clones, reset_column_store_clones};
let mut graph = wide_columnar();
assert!(
graph.column_store_count() > 0,
"the fixture must own master column stores, or the control is vacuous"
);
reset_column_store_clones();
run(&mut graph, "MATCH (n:Item) WHERE n.qty > 1 RETURN n.qty");
let cloned = column_store_clones();
assert_eq!(
cloned, 0,
"a read statement cloned {cloned} store(s); the counter must be reading \
the columnar write path and nothing else"
);
}
#[test]
fn indexed_graph_rolls_back_without_copying_the_graph() {
use crate::graph::storage::backend::{backend_clone_nodes, reset_backend_clone_count};
let mut graph = seeded_indexed();
reset_backend_clone_count();
assert_rolls_back(
&mut graph,
"MATCH (n:Item) SET n.name = 'touched', n.bad = duration({months: 2147483648})",
None,
);
assert_eq!(
backend_clone_nodes(),
0,
"an indexed graph must take the journal path, not the clone checkpoint"
);
}
#[test]
fn a_committed_set_moves_every_row_between_index_buckets() {
let mut graph = seeded_indexed();
assert_eq!(
index_bucket(&graph, "qty", Value::Int64(10)).len(),
1,
"precondition: Item 1 sits in the qty=10 bucket"
);
run(&mut graph, "MATCH (n:Item) SET n.qty = 77");
assert!(
index_bucket(&graph, "qty", Value::Int64(10)).is_empty(),
"the vacated bucket must be empty"
);
assert_eq!(
index_bucket(&graph, "qty", Value::Int64(77)).len(),
3,
"all three Items must have joined the new bucket"
);
assert_eq!(
range_bucket(&graph, "qty", Value::Int64(77)).len(),
3,
"the range index is maintained by separate code and must move too"
);
assert_eq!(
graph
.lookup_by_composite_index(
"Item",
&["name".to_string(), "qty".to_string()],
&[Value::String("a".to_string()), Value::Int64(77)],
)
.map(|members| members.len()),
Some(1),
"and so must the composite index"
);
}
#[test]
fn rollback_restores_index_bucket_order_not_just_membership() {
let mut graph = seeded_indexed();
run(&mut graph, "MATCH (n:Item {id: 3}) SET n.qty = 10");
let bucket_before = index_bucket(&graph, "qty", Value::Int64(10));
assert_eq!(
bucket_before.len(),
2,
"the fixture needs a bucket with two members to have an order at all"
);
let before = fingerprint(&mut graph);
let error = expect_failure(
&mut graph,
"MATCH (n:Item {id: 1}) SET n.qty = 999 \
WITH n MATCH (m:Item {id: 2}) SET m.bad = duration({months: 2147483648})",
None,
);
let after = fingerprint(&mut graph);
assert_eq!(before, after, "statement must roll back.\nerror: {error}");
assert_eq!(
index_bucket(&graph, "qty", Value::Int64(10)),
bucket_before,
"the evicted member must come back at its original position"
);
}
#[test]
fn a_rolled_back_statement_restores_the_parked_range_index_under_a_fork() {
let mut graph = seeded_indexed();
run(&mut graph, "MATCH (n:Item {id: 3}) SET n.qty = 10");
let bucket_before = range_bucket(&graph, "qty", Value::Int64(10));
assert_eq!(
bucket_before.len(),
2,
"the fixture needs a range bucket with two members to have an order at all"
);
let reader = graph.clone();
let reader_before = range_bucket(&reader, "qty", Value::Int64(10));
let before = fingerprint(&mut graph);
let error = expect_failure(
&mut graph,
"MATCH (n:Item {id: 1}) SET n.qty = 999 \
WITH n MATCH (m:Item {id: 2}) SET m.bad = duration({months: 2147483648})",
None,
);
let after = fingerprint(&mut graph);
assert_eq!(before, after, "statement must roll back.\nerror: {error}");
assert_eq!(
range_bucket(&graph, "qty", Value::Int64(10)),
bucket_before,
"the evicted member must come back at its original position in the range bucket"
);
assert!(
range_bucket(&graph, "qty", Value::Int64(999)).is_empty(),
"the failed statement's new range bucket must be gone"
);
assert_eq!(
range_bucket(&reader, "qty", Value::Int64(10)),
reader_before,
"the reader must not have seen the write or its reversal"
);
let scanned = graph
.lookup_range(
"Item",
"qty",
std::ops::Bound::Unbounded,
std::ops::Bound::Unbounded,
)
.expect("the range index survives the rollback");
assert_eq!(
scanned.len(),
3,
"every seeded Item must still be reachable through the range index"
);
}
fn range_bucket(graph: &DirGraph, property: &str, value: Value) -> Vec<usize> {
graph
.range_indices
.get(&("Item".to_string(), property.to_string()))
.and_then(|btree| btree.get(&value))
.map(|members| members.iter().map(|idx| idx.index()).collect())
.unwrap_or_default()
}
fn index_bucket(graph: &DirGraph, property: &str, value: Value) -> Vec<usize> {
graph
.property_indices
.get(&("Item".to_string(), property.to_string()))
.and_then(|value_map| value_map.get(&value))
.map(|members| members.iter().map(|idx| idx.index()).collect())
.unwrap_or_default()
}