use super::*;
fn id_frame(ids: impl IntoIterator<Item = i64>) -> DataFrame {
let rows: Vec<Vec<Value>> = ids.into_iter().map(|i| vec![Value::Int64(i)]).collect();
DataFrame::from_cypher_rows(vec!["id".to_string()], rows).unwrap()
}
fn load(graph: &mut DirGraph, node_type: &str, ids: impl IntoIterator<Item = i64>) {
add_nodes(
graph,
id_frame(ids),
node_type.to_string(),
"id".to_string(),
Some("id".to_string()),
None,
)
.unwrap();
}
#[test]
fn appending_to_an_indexed_type_does_not_rebuild_the_index() {
let mut graph = DirGraph::new();
load(&mut graph, "Person", 0..50);
assert!(graph.id_indices.contains_key("Person"));
let planted = NodeIndex::new(0);
graph
.id_indices
.entry_or_default("Person".to_string())
.insert(Value::Int64(9_999), planted);
load(&mut graph, "Person", 50..60);
assert_eq!(
graph.id_indices.lookup("Person", &Value::Int64(9_999)),
Some(planted),
"the append rebuilt the whole id index instead of folding in its own rows"
);
}
#[test]
fn appended_ids_resolve_without_a_rebuild() {
let mut graph = DirGraph::new();
load(&mut graph, "Person", 0..20);
load(&mut graph, "Person", 20..30);
for id in 0..30i64 {
let idx = graph
.id_indices
.lookup("Person", &Value::Int64(id))
.unwrap_or_else(|| panic!("id {id} lost from the index"));
let stored = {
let _guard = graph.graph.begin_query();
graph
.graph
.node_view(idx)
.map(|view| view.id().into_owned())
.expect("index must point at a live node")
};
assert_eq!(stored, Value::Int64(id), "id {id} points at the wrong node");
}
assert_eq!(graph.id_indices.lookup("Person", &Value::Int64(30)), None);
assert_eq!(graph.type_indices.get("Person").map(|m| m.len()), Some(30));
}
#[test]
fn an_upserting_batch_leaves_the_index_length_alone() {
let mut graph = DirGraph::new();
load(&mut graph, "Person", 0..10);
let before = graph
.lookup_by_id_readonly("Person", &Value::Int64(5))
.unwrap();
load(&mut graph, "Person", (0..5).chain(10..15));
assert_eq!(
graph.id_indices.overlay_len("Person"),
Some(15),
"an upsert must not double-count the rows it updated"
);
assert_eq!(
graph.id_indices.lookup("Person", &Value::Int64(5)),
Some(before),
"an updated row must keep pointing at the node it updated"
);
assert_eq!(graph.graph.node_count(), 15);
}
#[test]
fn deleting_then_recreating_an_id_repoints_the_index() {
let mut graph = DirGraph::new();
load(&mut graph, "Person", 0..5);
let doomed = graph
.lookup_by_id_readonly("Person", &Value::Int64(2))
.unwrap();
let mut to_delete = HashSet::new();
to_delete.insert(doomed);
assert_eq!(detach_delete_nodes(&mut graph, &to_delete), (1, 0));
assert_eq!(graph.id_indices.lookup("Person", &Value::Int64(2)), None);
load(&mut graph, "Person", [2]);
let reborn = graph
.id_indices
.lookup("Person", &Value::Int64(2))
.expect("the recreated id must be indexed");
let stored = {
let _guard = graph.graph.begin_query();
graph
.graph
.node_view(reborn)
.map(|view| view.id().into_owned())
.expect("the index must point at a live node")
};
assert_eq!(stored, Value::Int64(2));
assert_eq!(graph.graph.node_count(), 5);
}
#[test]
fn a_within_batch_duplicate_id_collapses_the_way_a_rebuild_would() {
let mut graph = DirGraph::new();
load(&mut graph, "Person", [1, 2, 2, 3]);
assert_eq!(graph.type_indices.get("Person").map(|m| m.len()), Some(4));
let resolved = graph
.id_indices
.lookup("Person", &Value::Int64(2))
.expect("id 2 must resolve");
let expected = {
let _guard = graph.graph.begin_query();
let members = graph.type_indices.get("Person").unwrap().to_vec();
members
.into_iter()
.rfind(|idx| {
graph
.graph
.node_view(*idx)
.map(|view| view.id().into_owned() == Value::Int64(2))
.unwrap_or(false)
})
.expect("two members carry id 2")
};
assert_eq!(resolved, expected);
}
#[test]
fn the_first_append_leaves_a_complete_index() {
let mut graph = DirGraph::new();
load(&mut graph, "Person", 0..100);
assert!(graph.id_indices.contains_key("Person"));
assert_eq!(graph.id_indices.overlay_len("Person"), Some(100));
for id in 0..100i64 {
assert!(
graph
.id_indices
.lookup("Person", &Value::Int64(id))
.is_some(),
"id {id} missing from a supposedly complete index"
);
}
}
#[test]
fn appending_to_an_invalidated_index_rebuilds_it_whole() {
let mut graph = DirGraph::new();
load(&mut graph, "Person", 0..100);
graph.id_indices.remove("Person");
assert!(!graph.id_indices.contains_key("Person"));
load(&mut graph, "Person", 100..110);
assert_eq!(
graph.id_indices.overlay_len("Person"),
Some(110),
"the append folded into an absent entry instead of rebuilding"
);
for id in 0..110i64 {
assert!(
graph
.id_indices
.lookup("Person", &Value::Int64(id))
.is_some(),
"id {id} unreachable after an append onto an invalidated index"
);
}
}
#[test]
fn the_folded_index_equals_the_rebuilt_one() {
fn snapshot(graph: &DirGraph) -> (bool, Vec<(String, usize)>) {
let (_, index) = graph
.id_indices
.iter()
.into_iter()
.find(|(name, _)| name == "Person")
.expect("Person must be indexed");
let compact = matches!(index, crate::graph::schema::TypeIdIndex::Integer(_));
let mut entries: Vec<(String, usize)> = index
.iter()
.map(|(id, idx)| (format!("{id:?}"), idx.index()))
.collect();
entries.sort();
(compact, entries)
}
let mut graph = DirGraph::new();
load(&mut graph, "Person", 0..40);
load(&mut graph, "Person", 40..55);
load(&mut graph, "Person", (40..55).chain(55..65));
let folded = snapshot(&graph);
graph.id_indices.remove("Person");
graph.build_id_index("Person");
let rebuilt = snapshot(&graph);
assert_eq!(folded.0, rebuilt.0, "the fold changed the index variant");
assert_eq!(folded.1, rebuilt.1, "the fold and the rebuild disagree");
assert_eq!(folded.1.len(), 65);
}
#[test]
fn folded_user_indexes_equal_the_rebuilt_ones() {
fn buckets(graph: &DirGraph) -> Vec<(String, String, Vec<usize>)> {
let mut out = Vec::new();
for ((node_type, property), index) in &graph.property_indices {
for (value, members) in index.iter() {
out.push((
format!("prop:{node_type}.{property}"),
format!("{value:?}"),
members.iter().map(|idx| idx.index()).collect(),
));
}
}
for ((node_type, property), index) in &graph.range_indices {
for (value, members) in index.iter() {
out.push((
format!("range:{node_type}.{property}"),
format!("{value:?}"),
members.iter().map(|idx| idx.index()).collect(),
));
}
}
for ((node_type, properties), index) in &graph.composite_indices {
for (value, members) in index.iter() {
out.push((
format!("comp:{node_type}.{}", properties.join("+")),
format!("{value:?}"),
members.iter().map(|idx| idx.index()).collect(),
));
}
}
out.sort();
out
}
fn load_bucketed(graph: &mut DirGraph, ids: std::ops::Range<i64>) {
let rows: Vec<Vec<Value>> = ids
.map(|id| {
vec![
Value::Int64(id),
Value::Int64(id % 3),
Value::String(format!("g{}", id % 2)),
]
})
.collect();
let frame = DataFrame::from_cypher_rows(
vec!["id".to_string(), "bucket".to_string(), "group".to_string()],
rows,
)
.unwrap();
add_nodes(
graph,
frame,
"Person".to_string(),
"id".to_string(),
Some("id".to_string()),
None,
)
.unwrap();
}
let mut graph = DirGraph::new();
load_bucketed(&mut graph, 0..30);
graph.create_index("Person", "bucket");
graph.create_range_index("Person", "bucket");
graph.create_composite_index("Person", &["bucket", "group"]);
load_bucketed(&mut graph, 30..45);
let folded = buckets(&graph);
assert!(!folded.is_empty(), "the fixture must have indexed buckets");
graph.refresh_indexes_for_type("Person");
assert_eq!(
folded,
buckets(&graph),
"the appended rows landed differently from the rebuild"
);
let hits = graph
.lookup_by_index("Person", "bucket", &Value::Int64(0))
.expect("indexed lookup must resolve");
assert_eq!(hits.len(), 15);
}
#[test]
fn an_upserting_batch_keeps_the_user_index_correct() {
let frame = |rows: Vec<(i64, i64)>| {
DataFrame::from_cypher_rows(
vec!["id".to_string(), "bucket".to_string()],
rows.into_iter()
.map(|(id, bucket)| vec![Value::Int64(id), Value::Int64(bucket)])
.collect(),
)
.unwrap()
};
let load = |graph: &mut DirGraph, rows: Vec<(i64, i64)>| {
add_nodes(
graph,
frame(rows),
"Person".to_string(),
"id".to_string(),
Some("id".to_string()),
None,
)
.unwrap();
};
let mut graph = DirGraph::new();
load(&mut graph, vec![(1, 10), (2, 20)]);
graph.create_index("Person", "bucket");
let moved = graph
.lookup_by_id_readonly("Person", &Value::Int64(1))
.unwrap();
load(&mut graph, vec![(1, 30), (3, 10)]);
assert!(
graph
.lookup_by_index("Person", "bucket", &Value::Int64(10))
.unwrap_or_default()
.iter()
.all(|idx| *idx != moved),
"the vacated bucket still holds the moved node"
);
assert_eq!(
graph
.lookup_by_index("Person", "bucket", &Value::Int64(30))
.unwrap_or_default(),
vec![moved]
);
assert_eq!(
graph
.lookup_by_index("Person", "bucket", &Value::Int64(10))
.unwrap_or_default()
.len(),
1,
"the new row must be indexed"
);
}
#[test]
fn string_ids_survive_an_incremental_append() {
let mut graph = DirGraph::new();
let frame = |ids: &[&str]| {
let rows: Vec<Vec<Value>> = ids
.iter()
.map(|s| vec![Value::String((*s).to_string())])
.collect();
DataFrame::from_cypher_rows(vec!["id".to_string()], rows).unwrap()
};
for batch in [&["a", "b"][..], &["c"][..]] {
add_nodes(
&mut graph,
frame(batch),
"Doc".to_string(),
"id".to_string(),
Some("id".to_string()),
None,
)
.unwrap();
}
for id in ["a", "b", "c"] {
assert!(
graph
.id_indices
.lookup("Doc", &Value::String(id.to_string()))
.is_some(),
"string id {id} lost"
);
}
}
fn upsert_frame(rows: &[(i64, i64, &str)]) -> DataFrame {
DataFrame::from_cypher_rows(
vec!["id".to_string(), "bucket".to_string(), "code".to_string()],
rows.iter()
.map(|(id, bucket, code)| {
vec![
Value::Int64(*id),
Value::Int64(*bucket),
Value::String((*code).to_string()),
]
})
.collect(),
)
.unwrap()
}
fn upsert(graph: &mut DirGraph, rows: &[(i64, i64, &str)]) -> Result<NodeOperationReport, String> {
add_nodes(
graph,
upsert_frame(rows),
"Item".to_string(),
"id".to_string(),
Some("id".to_string()),
None,
)
}
fn index_buckets(graph: &DirGraph) -> Vec<(String, String, Vec<usize>)> {
let mut out = Vec::new();
for ((node_type, property), index) in &graph.property_indices {
for (value, members) in index.iter() {
out.push((
format!("prop:{node_type}.{property}"),
format!("{value:?}"),
members.iter().map(|idx| idx.index()).collect(),
));
}
}
for ((node_type, property), index) in &graph.range_indices {
for (value, members) in index.iter() {
out.push((
format!("range:{node_type}.{property}"),
format!("{value:?}"),
members.iter().map(|idx| idx.index()).collect(),
));
}
}
for ((node_type, properties), index) in &graph.composite_indices {
for (value, members) in index.iter() {
out.push((
format!("comp:{node_type}.{}", properties.join("+")),
format!("{value:?}"),
members.iter().map(|idx| idx.index()).collect(),
));
}
}
out.sort();
out
}
fn upsert_fixture(constrained: bool) -> DirGraph {
let mut graph = DirGraph::new();
let seed: Vec<(i64, i64, String)> = (0..200).map(|i| (i, i % 3, format!("c{i}"))).collect();
let seed_rows: Vec<(i64, i64, &str)> = seed
.iter()
.map(|(id, bucket, code)| (*id, *bucket, code.as_str()))
.collect();
upsert(&mut graph, &seed_rows).unwrap();
graph.create_index("Item", "bucket");
graph.create_range_index("Item", "bucket");
graph.create_composite_index("Item", &["bucket", "code"]);
if constrained {
graph.create_unique_constraint("Item", &["code"]).unwrap();
}
graph
}
#[test]
fn an_upsert_folds_the_user_index_instead_of_rebuilding_it() {
let mut graph = upsert_fixture(false);
let planted = NodeIndex::new(0);
graph
.property_indices
.get_mut(&("Item".to_string(), "bucket".to_string()))
.expect("the fixture declares the index")
.entry_or_default(&Value::Int64(9_999))
.push(planted);
upsert(&mut graph, &[(1, 7, "c1"), (99, 7, "c99")]).unwrap();
assert_eq!(
graph
.lookup_by_index("Item", "bucket", &Value::Int64(9_999))
.unwrap_or_default(),
vec![planted],
"the upsert rebuilt the whole index instead of folding its own rows"
);
}
#[test]
fn a_constrained_batch_folds_its_claims_instead_of_rebuilding_them() {
let mut graph = upsert_fixture(true);
let key = ("Item".to_string(), vec!["code".to_string()]);
let planted = NodeIndex::new(0);
graph
.unique_indices
.get_mut(&key)
.expect("the fixture declares the constraint")
.insert(
CompositeValue(vec![Value::String("sentinel".to_string())]),
planted,
);
upsert(&mut graph, &[(100, 1, "c100")]).unwrap();
assert_eq!(
graph
.unique_indices
.get(&key)
.and_then(|occupants| {
occupants.get(&CompositeValue(vec![Value::String("sentinel".to_string())]))
})
.copied(),
Some(planted),
"the constrained append re-derived occupancy instead of folding its claims"
);
}
#[test]
fn folded_upserts_hold_the_same_values_as_a_rebuild() {
let mut graph = upsert_fixture(true);
crate::graph::dir_graph::indexes::reset_type_index_rebuilds();
upsert(
&mut graph,
&[
(2, 7, "c2"), (3, 3 % 3, "x3"), (4, 8, "x4"), (5, 5 % 3, "c5"), (50, 1, "c50"), ],
)
.unwrap();
assert_eq!(
crate::graph::dir_graph::indexes::type_index_rebuilds(),
0,
"the batch rebuilt the type's indexes instead of folding, which would \
make every equality below vacuous"
);
let sorted = |mut buckets: Vec<(String, String, Vec<usize>)>| {
for entry in &mut buckets {
entry.2.sort();
}
buckets
};
let folded = sorted(index_buckets(&graph));
let folded_claims: Vec<_> = {
let mut claims: Vec<_> = graph
.unique_indices
.iter()
.flat_map(|(key, occupants)| {
occupants
.iter()
.map(move |(value, idx)| (key.clone(), format!("{value:?}"), idx.index()))
})
.collect();
claims.sort();
claims
};
graph.refresh_indexes_for_type("Item");
let rebuilt = sorted(index_buckets(&graph));
let rebuilt_claims: Vec<_> = {
let mut claims: Vec<_> = graph
.unique_indices
.iter()
.flat_map(|(key, occupants)| {
occupants
.iter()
.map(move |(value, idx)| (key.clone(), format!("{value:?}"), idx.index()))
})
.collect();
claims.sort();
claims
};
assert!(!folded.is_empty(), "the fixture must have indexed buckets");
assert_eq!(folded, rebuilt, "the fold and the rebuild disagree");
assert_eq!(
folded_claims, rebuilt_claims,
"folded unique occupancy disagrees with a re-derived one"
);
}
#[test]
fn an_upsert_that_moves_no_value_leaves_bucket_order_untouched() {
let mut graph = upsert_fixture(true);
let before = index_buckets(&graph);
upsert(&mut graph, &[(1, 1, "c1"), (4, 1, "c4"), (7, 1, "c7")]).unwrap();
assert_eq!(
before,
index_buckets(&graph),
"an upsert that changed no indexed value still moved nodes between \
(or within) buckets"
);
graph.refresh_indexes_for_type("Item");
assert_eq!(
before,
index_buckets(&graph),
"the untouched buckets differ from a rebuild's, order included"
);
}
#[test]
fn an_upsert_frees_the_unique_tuple_it_vacated() {
let mut graph = upsert_fixture(true);
upsert(&mut graph, &[(3, 0, "moved")]).unwrap();
upsert(&mut graph, &[(30, 0, "c3")]).expect("the vacated tuple must be free");
let holder = graph
.lookup_by_id_readonly("Item", &Value::Int64(30))
.expect("the new row exists");
assert_eq!(
graph
.unique_indices
.get(&("Item".to_string(), vec!["code".to_string()]))
.and_then(
|occupants| occupants.get(&CompositeValue(vec![Value::String("c3".to_string())]))
)
.copied(),
Some(holder),
"the freed tuple must be recorded against its new holder"
);
}
#[test]
fn a_folded_claim_still_refuses_the_next_duplicate() {
let mut graph = upsert_fixture(true);
upsert(&mut graph, &[(60, 1, "fresh")]).unwrap();
let err = upsert(&mut graph, &[(61, 1, "fresh")])
.expect_err("a second node may not take a claimed tuple");
assert!(
err.contains("fresh"),
"the violation must name the duplicated value: {err}"
);
}
#[test]
fn a_duplicate_within_one_batch_is_still_refused() {
let mut graph = upsert_fixture(true);
let err = upsert(&mut graph, &[(70, 1, "twice"), (71, 1, "twice")])
.expect_err("two rows of one batch may not claim the same tuple");
assert!(err.contains("twice"), "unexpected violation: {err}");
}
#[test]
fn a_mass_upsert_declines_to_the_rebuild() {
let mut graph = DirGraph::new();
let seed: Vec<(i64, i64, String)> = (0..400).map(|i| (i, i % 2, format!("c{i}"))).collect();
let rows: Vec<(i64, i64, &str)> = seed
.iter()
.map(|(id, bucket, code)| (*id, *bucket, code.as_str()))
.collect();
upsert(&mut graph, &rows).unwrap();
graph.create_index("Item", "bucket");
let ten: Vec<(i64, i64, &str)> = rows[..10]
.iter()
.map(|(id, _, code)| (*id, 9, *code))
.collect();
crate::graph::dir_graph::indexes::reset_type_index_rebuilds();
upsert(&mut graph, &ten).unwrap();
assert_eq!(
crate::graph::dir_graph::indexes::type_index_rebuilds(),
0,
"a ten-row upsert must fold"
);
let all: Vec<(i64, i64, &str)> = rows
.iter()
.map(|(id, bucket, code)| (*id, bucket + 100, *code))
.collect();
crate::graph::dir_graph::indexes::reset_type_index_rebuilds();
upsert(&mut graph, &all).unwrap();
assert_eq!(
crate::graph::dir_graph::indexes::type_index_rebuilds(),
1,
"a whole-type upsert must decline to the rebuild"
);
assert_eq!(
graph
.lookup_by_index("Item", "bucket", &Value::Int64(100))
.unwrap_or_default()
.len(),
200
);
assert!(graph
.lookup_by_index("Item", "bucket", &Value::Int64(9))
.unwrap_or_default()
.is_empty());
}
#[test]
fn a_captured_batch_that_moves_many_rows_still_declines() {
let mut graph = DirGraph::new();
let seed: Vec<(i64, i64, String)> = (0..1_000).map(|i| (i, i % 2, format!("c{i}"))).collect();
let rows: Vec<(i64, i64, &str)> = seed
.iter()
.map(|(id, bucket, code)| (*id, *bucket, code.as_str()))
.collect();
upsert(&mut graph, &rows).unwrap();
graph.create_index("Item", "bucket");
let moved: Vec<(i64, i64, &str)> = rows[..100]
.iter()
.map(|(id, _, code)| (*id, 42, *code))
.collect();
crate::graph::dir_graph::indexes::reset_type_index_rebuilds();
upsert(&mut graph, &moved).unwrap();
assert_eq!(
crate::graph::dir_graph::indexes::type_index_rebuilds(),
1,
"100 moves into two 500-member buckets must decline to the rebuild"
);
let few: Vec<(i64, i64, &str)> = rows[200..210]
.iter()
.map(|(id, _, code)| (*id, 43, *code))
.collect();
crate::graph::dir_graph::indexes::reset_type_index_rebuilds();
upsert(&mut graph, &few).unwrap();
assert_eq!(
crate::graph::dir_graph::indexes::type_index_rebuilds(),
0,
"ten moves must fold"
);
assert_eq!(
graph
.lookup_by_index("Item", "bucket", &Value::Int64(43))
.unwrap_or_default()
.len(),
10
);
assert_eq!(
graph
.lookup_by_index("Item", "bucket", &Value::Int64(42))
.unwrap_or_default()
.len(),
100
);
}
#[test]
fn a_bulk_load_into_a_constrained_type_still_rebuilds_occupancy() {
let mut graph = DirGraph::new();
let seed: Vec<(i64, i64, String)> = (0..50).map(|i| (i, i % 3, format!("c{i}"))).collect();
let rows: Vec<(i64, i64, &str)> = seed
.iter()
.map(|(id, bucket, code)| (*id, *bucket, code.as_str()))
.collect();
upsert(&mut graph, &rows).unwrap();
graph.create_unique_constraint("Item", &["code"]).unwrap();
let more: Vec<(i64, i64, String)> = (100..150).map(|i| (i, i % 3, format!("c{i}"))).collect();
let more_rows: Vec<(i64, i64, &str)> = more
.iter()
.map(|(id, bucket, code)| (*id, *bucket, code.as_str()))
.collect();
crate::graph::dir_graph::indexes::reset_type_index_rebuilds();
upsert(&mut graph, &more_rows).unwrap();
assert_eq!(
crate::graph::dir_graph::indexes::type_index_rebuilds(),
1,
"a load the size of the type must re-derive occupancy in one pass"
);
crate::graph::dir_graph::indexes::reset_type_index_rebuilds();
upsert(&mut graph, &[(300, 1, "c300"), (301, 1, "c301")]).unwrap();
assert_eq!(
crate::graph::dir_graph::indexes::type_index_rebuilds(),
0,
"two rows into a 100-member type must fold"
);
for code in ["c120", "c300"] {
let err = upsert(&mut graph, &[(900, 1, code)])
.expect_err("a claimed tuple must still be refused");
assert!(err.contains(code), "unexpected violation: {err}");
}
}