mod test_helpers;
use test_helpers::TestDb;
use lora_database::PlanTreeNode;
fn contains_operator(node: &PlanTreeNode, name: &str) -> bool {
if node.operator == name {
return true;
}
node.children.iter().any(|c| contains_operator(c, name))
}
fn pluck_node_id(rows: &[serde_json::Value], path: &str) -> Vec<i64> {
rows.iter()
.map(|r| {
r["n"]["properties"][path]
.as_i64()
.unwrap_or_else(|| panic!("missing properties.{path} in row {r:?}"))
})
.collect()
}
#[test]
fn explain_rewrites_greater_than_to_range_scan() {
let db = TestDb::new();
db.run("CREATE INDEX age_idx FOR (n:Person) ON (n.age)");
let plan = db
.service
.explain("MATCH (n:Person) WHERE n.age > 30 RETURN n", None)
.unwrap();
assert!(
contains_operator(&plan.tree.root, "NodeByPropertyRangeScan"),
"expected NodeByPropertyRangeScan in plan, got {:?}",
plan.tree
);
}
#[test]
fn explain_combines_two_bounds_into_one_range_scan() {
let db = TestDb::new();
db.run("CREATE INDEX age_idx FOR (n:Person) ON (n.age)");
let plan = db
.service
.explain(
"MATCH (n:Person) WHERE n.age >= 30 AND n.age < 50 RETURN n",
None,
)
.unwrap();
assert!(contains_operator(
&plan.tree.root,
"NodeByPropertyRangeScan"
));
}
#[test]
fn range_scan_returns_correct_rows() {
let db = TestDb::new();
db.run("CREATE INDEX age_idx FOR (n:Person) ON (n.age)");
db.run("CREATE (:Person {id: 1, age: 20})");
db.run("CREATE (:Person {id: 2, age: 30})");
db.run("CREATE (:Person {id: 3, age: 40})");
db.run("CREATE (:Person {id: 4, age: 50})");
let rows = db.run("MATCH (n:Person) WHERE n.age >= 30 AND n.age < 50 RETURN n ORDER BY n.id");
let ids = pluck_node_id(&rows, "id");
assert_eq!(ids, vec![2, 3]);
}
#[test]
fn range_scan_preserves_already_bound_node_variable() {
let db = TestDb::new();
db.run("CREATE INDEX age_idx FOR (n:Person) ON (n.age)");
db.run("CREATE (:Person {id: 1, age: 20})");
db.run("CREATE (:Person {id: 2, age: 40})");
let rows = db.run(
"MATCH (n:Person {id: 1}) \
MATCH (n:Person) \
WHERE n.age > 30 \
RETURN n",
);
assert!(
rows.is_empty(),
"indexed scan must filter the existing binding instead of rebinding n"
);
}
#[test]
fn range_scan_inclusive_upper_bound() {
let db = TestDb::new();
db.run("CREATE INDEX age_idx FOR (n:Person) ON (n.age)");
db.run("CREATE (:Person {id: 1, age: 30})");
db.run("CREATE (:Person {id: 2, age: 40})");
let rows = db.run("MATCH (n:Person) WHERE n.age <= 40 RETURN n ORDER BY n.id");
let ids = pluck_node_id(&rows, "id");
assert_eq!(ids, vec![1, 2]);
}
#[test]
fn range_scan_falls_back_when_no_index() {
let db = TestDb::new();
db.run("CREATE (:Person {id: 1, age: 25})");
db.run("CREATE (:Person {id: 2, age: 35})");
let plan = db
.service
.explain("MATCH (n:Person) WHERE n.age > 30 RETURN n", None)
.unwrap();
assert!(!contains_operator(
&plan.tree.root,
"NodeByPropertyRangeScan"
));
let rows = db.run("MATCH (n:Person) WHERE n.age > 30 RETURN n ORDER BY n.id");
let ids = pluck_node_id(&rows, "id");
assert_eq!(ids, vec![2]);
}
#[test]
fn cached_plan_recompiles_after_index_catalog_changes() {
let db = TestDb::new();
db.run("CREATE (:Person {id: 1, age: 25})");
db.run("CREATE (:Person {id: 2, age: 35})");
let query = "MATCH (n:Person) WHERE n.age > 30 RETURN n";
let before = db.service.explain(query, None).unwrap();
assert!(!contains_operator(
&before.tree.root,
"NodeByPropertyRangeScan"
));
db.run("CREATE INDEX age_idx FOR (n:Person) ON (n.age)");
let after = db.service.explain(query, None).unwrap();
assert!(
contains_operator(&after.tree.root, "NodeByPropertyRangeScan"),
"expected recompile to pick up the new catalog index, got {:?}",
after.tree
);
}
#[test]
fn explain_rewrites_starts_with_to_text_scan() {
let db = TestDb::new();
db.run("CREATE TEXT INDEX name_idx FOR (n:Person) ON (n.name)");
let plan = db
.service
.explain(
"MATCH (n:Person) WHERE n.name STARTS WITH 'Alex' RETURN n",
None,
)
.unwrap();
assert!(
contains_operator(&plan.tree.root, "NodeByTextScan"),
"expected NodeByTextScan in plan, got {:?}",
plan.tree
);
}
#[test]
fn explain_rewrites_contains_to_text_scan() {
let db = TestDb::new();
db.run("CREATE TEXT INDEX name_idx FOR (n:Person) ON (n.name)");
let plan = db
.service
.explain(
"MATCH (n:Person) WHERE n.name CONTAINS 'lex' RETURN n",
None,
)
.unwrap();
assert!(contains_operator(&plan.tree.root, "NodeByTextScan"));
}
#[test]
fn explain_rewrites_ends_with_to_text_scan() {
let db = TestDb::new();
db.run("CREATE TEXT INDEX name_idx FOR (n:Person) ON (n.name)");
let plan = db
.service
.explain(
"MATCH (n:Person) WHERE n.name ENDS WITH 'ander' RETURN n",
None,
)
.unwrap();
assert!(contains_operator(&plan.tree.root, "NodeByTextScan"));
}
#[test]
fn text_scan_returns_correct_starts_with_rows() {
let db = TestDb::new();
db.run("CREATE TEXT INDEX name_idx FOR (n:Person) ON (n.name)");
db.run("CREATE (:Person {id: 1, name: 'Alexander'})");
db.run("CREATE (:Person {id: 2, name: 'Alexandra'})");
db.run("CREATE (:Person {id: 3, name: 'Bob'})");
let rows = db.run("MATCH (n:Person) WHERE n.name STARTS WITH 'Alex' RETURN n ORDER BY n.id");
let ids = pluck_node_id(&rows, "id");
assert_eq!(ids, vec![1, 2]);
}
#[test]
fn text_scan_preserves_already_bound_node_variable() {
let db = TestDb::new();
db.run("CREATE TEXT INDEX name_idx FOR (n:Person) ON (n.name)");
db.run("CREATE (:Person {id: 1, name: 'Alice'})");
db.run("CREATE (:Person {id: 2, name: 'Bob'})");
let rows = db.run(
"MATCH (n:Person {id: 1}) \
MATCH (n:Person) \
WHERE n.name STARTS WITH 'Bo' \
RETURN n",
);
assert!(
rows.is_empty(),
"indexed scan must filter the existing binding instead of rebinding n"
);
}
#[test]
fn text_scan_returns_correct_contains_rows() {
let db = TestDb::new();
db.run("CREATE TEXT INDEX name_idx FOR (n:Person) ON (n.name)");
db.run("CREATE (:Person {id: 1, name: 'Alexander'})");
db.run("CREATE (:Person {id: 2, name: 'Alexandra'})");
db.run("CREATE (:Person {id: 3, name: 'Bob'})");
let rows = db.run("MATCH (n:Person) WHERE n.name CONTAINS 'lex' RETURN n ORDER BY n.id");
let ids = pluck_node_id(&rows, "id");
assert_eq!(ids, vec![1, 2]);
}
#[test]
fn text_scan_returns_correct_ends_with_rows() {
let db = TestDb::new();
db.run("CREATE TEXT INDEX name_idx FOR (n:Person) ON (n.name)");
db.run("CREATE (:Person {id: 1, name: 'Alexander'})");
db.run("CREATE (:Person {id: 2, name: 'Alexandra'})");
let rows = db.run("MATCH (n:Person) WHERE n.name ENDS WITH 'ander' RETURN n ORDER BY n.id");
let ids = pluck_node_id(&rows, "id");
assert_eq!(ids, vec![1]);
}
#[test]
fn text_scan_falls_back_when_no_index() {
let db = TestDb::new();
db.run("CREATE (:Person {id: 1, name: 'Alice'})");
db.run("CREATE (:Person {id: 2, name: 'Bob'})");
let plan = db
.service
.explain(
"MATCH (n:Person) WHERE n.name STARTS WITH 'Al' RETURN n",
None,
)
.unwrap();
assert!(!contains_operator(&plan.tree.root, "NodeByTextScan"));
let rows = db.run("MATCH (n:Person) WHERE n.name STARTS WITH 'Al' RETURN n ORDER BY n.id");
let ids = pluck_node_id(&rows, "id");
assert_eq!(ids, vec![1]);
}
#[test]
fn text_index_tracks_node_label_add_and_remove() {
let db = TestDb::new();
db.run("CREATE TEXT INDEX name_idx FOR (n:Person) ON (n.name)");
db.run("CREATE (:Other {id: 1, name: 'Alice'})");
db.run("CREATE (:Person {id: 99, name: 'Zed'})");
db.run("MATCH (n:Other {id: 1}) SET n:Person");
let rows = db.run("MATCH (n:Person) WHERE n.name STARTS WITH 'Ali' RETURN n");
assert_eq!(pluck_node_id(&rows, "id"), vec![1]);
db.run("MATCH (n:Person {id: 1}) REMOVE n:Person");
let rows = db.run("MATCH (n:Person) WHERE n.name STARTS WITH 'Ali' RETURN n");
assert!(rows.is_empty());
}
#[test]
fn explain_rewrites_within_bbox_to_point_scan() {
let db = TestDb::new();
db.run("CREATE POINT INDEX loc_idx FOR (n:Place) ON (n.loc)");
let plan = db
.service
.explain(
"MATCH (n:Place) WHERE geo.within_bbox(n.loc, {x: 0, y: 0}::POINT, {x: 100, y: 100}::POINT) RETURN n",
None,
)
.unwrap();
assert!(
contains_operator(&plan.tree.root, "NodeByPointScan"),
"expected NodeByPointScan, got {:?}",
plan.tree
);
}
#[test]
fn explain_rewrites_distance_le_to_point_scan() {
let db = TestDb::new();
db.run("CREATE POINT INDEX loc_idx FOR (n:Place) ON (n.loc)");
let plan = db
.service
.explain(
"MATCH (n:Place) WHERE geo.distance(n.loc, {x: 0, y: 0}::POINT) <= 100 RETURN n",
None,
)
.unwrap();
assert!(contains_operator(&plan.tree.root, "NodeByPointScan"));
}
#[test]
fn point_scan_returns_within_bbox_correctly() {
let db = TestDb::new();
db.run("CREATE POINT INDEX loc_idx FOR (n:Place) ON (n.loc)");
db.run("CREATE (:Place {id: 1, loc: {x: 50, y: 50}::POINT})");
db.run("CREATE (:Place {id: 2, loc: {x: 150, y: 150}::POINT})");
db.run("CREATE (:Place {id: 3, loc: {x: 1000, y: 1000}::POINT})");
let rows = db.run(
"MATCH (n:Place) WHERE geo.within_bbox(n.loc, {x: 0, y: 0}::POINT, {x: 200, y: 200}::POINT) RETURN n ORDER BY n.id",
);
let ids = pluck_node_id(&rows, "id");
assert_eq!(ids, vec![1, 2]);
}
#[test]
fn point_scan_returns_within_distance_correctly() {
let db = TestDb::new();
db.run("CREATE POINT INDEX loc_idx FOR (n:Place) ON (n.loc)");
db.run("CREATE (:Place {id: 1, loc: {x: 0, y: 0}::POINT})");
db.run("CREATE (:Place {id: 2, loc: {x: 30, y: 40}::POINT})"); db.run("CREATE (:Place {id: 3, loc: {x: 300, y: 400}::POINT})");
let rows = db.run(
"MATCH (n:Place) WHERE geo.distance(n.loc, {x: 0, y: 0}::POINT) <= 60 RETURN n ORDER BY n.id",
);
let ids = pluck_node_id(&rows, "id");
assert_eq!(ids, vec![1, 2]);
}
#[test]
fn point_scan_falls_back_when_no_index() {
let db = TestDb::new();
db.run("CREATE (:Place {id: 1, loc: {x: 50, y: 50}::POINT})");
db.run("CREATE (:Place {id: 2, loc: {x: 1000, y: 1000}::POINT})");
let plan = db
.service
.explain(
"MATCH (n:Place) WHERE geo.within_bbox(n.loc, {x: 0, y: 0}::POINT, {x: 100, y: 100}::POINT) RETURN n",
None,
)
.unwrap();
assert!(!contains_operator(&plan.tree.root, "NodeByPointScan"));
let rows = db.run(
"MATCH (n:Place) WHERE geo.within_bbox(n.loc, {x: 0, y: 0}::POINT, {x: 100, y: 100}::POINT) RETURN n ORDER BY n.id",
);
let ids = pluck_node_id(&rows, "id");
assert_eq!(ids, vec![1]);
}
#[test]
fn point_scan_correctly_filters_after_property_update() {
let db = TestDb::new();
db.run("CREATE POINT INDEX loc_idx FOR (n:Place) ON (n.loc)");
db.run("CREATE (:Place {id: 1, loc: {x: 50, y: 50}::POINT})");
let inside = db.run(
"MATCH (n:Place) WHERE geo.within_bbox(n.loc, {x: 0, y: 0}::POINT, {x: 100, y: 100}::POINT) RETURN n",
);
assert_eq!(inside.len(), 1);
db.run("MATCH (n:Place {id: 1}) SET n.loc = {x: 1000, y: 1000}::POINT");
let still_inside = db.run(
"MATCH (n:Place) WHERE geo.within_bbox(n.loc, {x: 0, y: 0}::POINT, {x: 100, y: 100}::POINT) RETURN n",
);
assert!(
still_inside.is_empty(),
"spatial index must reflect the SET"
);
let now_outside = db.run(
"MATCH (n:Place) WHERE geo.within_bbox(n.loc, {x: 500, y: 500}::POINT, {x: 1500, y: 1500}::POINT) RETURN n",
);
assert_eq!(now_outside.len(), 1);
}
#[test]
fn text_scan_correctly_filters_after_property_update() {
let db = TestDb::new();
db.run("CREATE TEXT INDEX name_idx FOR (n:Person) ON (n.name)");
db.run("CREATE (:Person {id: 1, name: 'Alice'})");
let rows = db.run("MATCH (n:Person) WHERE n.name STARTS WITH 'Alic' RETURN n ORDER BY n.id");
assert_eq!(pluck_node_id(&rows, "id"), vec![1]);
db.run("MATCH (n:Person {id: 1}) SET n.name = 'Bob'");
let rows_after = db.run("MATCH (n:Person) WHERE n.name STARTS WITH 'Alic' RETURN n");
assert!(rows_after.is_empty(), "old trigrams must be evicted");
let rows_new = db.run("MATCH (n:Person) WHERE n.name STARTS WITH 'Bo' RETURN n ORDER BY n.id");
assert_eq!(pluck_node_id(&rows_new, "id"), vec![1]);
}
#[test]
fn cost_model_keeps_label_scan_for_starts_with_empty_string() {
let db = TestDb::new();
db.run("CREATE TEXT INDEX name_idx FOR (n:Person) ON (n.name)");
db.run("CREATE (:Person {id: 1, name: 'Alice'})");
let plan = db
.service
.explain(
"MATCH (n:Person) WHERE n.name STARTS WITH '' RETURN n",
None,
)
.unwrap();
assert!(
!contains_operator(&plan.tree.root, "NodeByTextScan"),
"STARTS WITH '' is tautological; expected label scan, got {:?}",
plan.tree
);
assert!(contains_operator(&plan.tree.root, "NodeByLabelScan"));
}
#[test]
fn cost_model_keeps_label_scan_for_world_bbox() {
let db = TestDb::new();
db.run("CREATE POINT INDEX loc_idx FOR (n:Place) ON (n.loc)");
db.run("CREATE (:Place {id: 1, loc: {x: 50, y: 50}::POINT})");
let plan = db
.service
.explain(
"MATCH (n:Place) \
WHERE geo.within_bbox(n.loc, \
{longitude: -180, latitude: -90}::POINT, \
{longitude: 180, latitude: 90}::POINT) \
RETURN n",
None,
)
.unwrap();
assert!(
!contains_operator(&plan.tree.root, "NodeByPointScan"),
"world bbox covers every point; expected label scan, got {:?}",
plan.tree
);
assert!(contains_operator(&plan.tree.root, "NodeByLabelScan"));
}
#[test]
fn cost_model_picks_cheaper_among_competing_candidates() {
let db = TestDb::new();
db.run("CREATE INDEX age_idx FOR (n:Person) ON (n.age)");
db.run("CREATE TEXT INDEX name_idx FOR (n:Person) ON (n.name)");
for i in 0..16 {
db.run(&format!(
"CREATE (:Person {{id: {i}, age: {age}, name: 'A{i}'}})",
age = 30 + i
));
}
let plan = db
.service
.explain(
"MATCH (n:Person) WHERE n.name STARTS WITH 'A' AND n.age > 30 RETURN n",
None,
)
.unwrap();
assert!(
contains_operator(&plan.tree.root, "NodeByTextScan"),
"expected the cheaper text scan to win over the range scan, got {:?}",
plan.tree
);
assert!(
!contains_operator(&plan.tree.root, "NodeByPropertyRangeScan"),
"range scan should not be picked when text is cheaper, got {:?}",
plan.tree
);
}
#[test]
fn cost_model_picks_property_scan_when_distinct_is_high() {
let db = TestDb::new();
db.run("CREATE INDEX id_idx FOR (n:Person) ON (n.id)");
for i in 0..100 {
db.run(&format!("CREATE (:Person {{id: {i}}})"));
}
let plan = db
.service
.explain("MATCH (n:Person) WHERE n.id = 7 RETURN n", None)
.unwrap();
assert!(
contains_operator(&plan.tree.root, "NodeByPropertyScan"),
"high-distinct equality should beat label scan, got {:?}",
plan.tree
);
}
fn pluck_rel_property(rows: &[serde_json::Value], var: &str, prop: &str) -> Vec<i64> {
rows.iter()
.map(|r| {
r[var]["properties"][prop]
.as_i64()
.unwrap_or_else(|| panic!("missing {var}.properties.{prop} in row {r:?}"))
})
.collect()
}
fn pluck_var_node_property(rows: &[serde_json::Value], var: &str, prop: &str) -> Vec<i64> {
rows.iter()
.map(|r| {
r[var]["properties"][prop]
.as_i64()
.unwrap_or_else(|| panic!("missing {var}.properties.{prop} in row {r:?}"))
})
.collect()
}
#[test]
fn explain_rewrites_rel_range_filter_to_rel_range_scan() {
let db = TestDb::new();
db.run("CREATE INDEX rel_since_idx FOR ()-[r:KNOWS]-() ON (r.since)");
let plan = db
.service
.explain("MATCH ()-[r:KNOWS]->() WHERE r.since > 2020 RETURN r", None)
.unwrap();
assert!(
contains_operator(&plan.tree.root, "RelByPropertyRangeScan"),
"expected RelByPropertyRangeScan in plan, got {:?}",
plan.tree
);
}
#[test]
fn rel_range_scan_returns_correct_rows_directed() {
let db = TestDb::new();
db.run("CREATE INDEX rel_since_idx FOR ()-[r:KNOWS]-() ON (r.since)");
db.run("CREATE (:Person {id: 1})");
db.run("CREATE (:Person {id: 2})");
db.run("CREATE (:Person {id: 3})");
db.run(
"MATCH (a:Person {id: 1}), (b:Person {id: 2}) \
CREATE (a)-[:KNOWS {since: 2018}]->(b)",
);
db.run(
"MATCH (a:Person {id: 2}), (b:Person {id: 3}) \
CREATE (a)-[:KNOWS {since: 2022}]->(b)",
);
db.run(
"MATCH (a:Person {id: 1}), (b:Person {id: 3}) \
CREATE (a)-[:KNOWS {since: 2024}]->(b)",
);
let rows = db.run("MATCH ()-[r:KNOWS]->() WHERE r.since > 2020 RETURN r ORDER BY r.since");
let years = pluck_rel_property(&rows, "r", "since");
assert_eq!(years, vec![2022, 2024]);
}
#[test]
fn rel_range_scan_respects_bound_source_from_upstream() {
let db = TestDb::new();
db.run("CREATE INDEX rel_since_idx FOR ()-[r:KNOWS]-() ON (r.since)");
db.run("CREATE (:Person {id: 1})");
db.run("CREATE (:Person {id: 2})");
db.run("CREATE (:Person {id: 3})");
db.run(
"MATCH (a:Person {id: 1}), (b:Person {id: 2}) \
CREATE (a)-[:KNOWS {since: 2018}]->(b)",
);
db.run(
"MATCH (a:Person {id: 2}), (b:Person {id: 3}) \
CREATE (a)-[:KNOWS {since: 2022}]->(b)",
);
db.run(
"MATCH (a:Person {id: 1}), (b:Person {id: 3}) \
CREATE (a)-[:KNOWS {since: 2024}]->(b)",
);
let rows = db.run(
"MATCH (a:Person {id: 1}) MATCH (a)-[r:KNOWS]->(b) \
WHERE r.since > 2020 RETURN b ORDER BY b.id",
);
assert_eq!(pluck_var_node_property(&rows, "b", "id"), vec![3]);
}
#[test]
fn rel_range_scan_undirected_emits_both_orientations() {
let db = TestDb::new();
db.run("CREATE INDEX rel_since_idx FOR ()-[r:KNOWS]-() ON (r.since)");
db.run("CREATE (:Person {id: 1})");
db.run("CREATE (:Person {id: 2})");
db.run(
"MATCH (a:Person {id: 1}), (b:Person {id: 2}) \
CREATE (a)-[:KNOWS {since: 2024}]->(b)",
);
let rows = db.run("MATCH (a)-[r:KNOWS]-(b) WHERE r.since > 2020 RETURN a, r, b");
assert_eq!(rows.len(), 2, "undirected scan must emit both orientations");
}
#[test]
fn rel_range_scan_falls_back_when_no_index() {
let db = TestDb::new();
db.run("CREATE (:P {id: 1})");
db.run("CREATE (:P {id: 2})");
db.run(
"MATCH (a:P {id: 1}), (b:P {id: 2}) \
CREATE (a)-[:LINKS {weight: 5}]->(b)",
);
db.run(
"MATCH (a:P {id: 2}), (b:P {id: 1}) \
CREATE (a)-[:LINKS {weight: 50}]->(b)",
);
let plan = db
.service
.explain("MATCH ()-[r:LINKS]->() WHERE r.weight > 10 RETURN r", None)
.unwrap();
assert!(!contains_operator(
&plan.tree.root,
"RelByPropertyRangeScan"
));
let rows = db.run("MATCH ()-[r:LINKS]->() WHERE r.weight > 10 RETURN r");
assert_eq!(rows.len(), 1);
}
#[test]
fn rel_range_scan_preserves_residual_predicates() {
let db = TestDb::new();
db.run("CREATE INDEX rel_weight_idx FOR ()-[r:LINKS]-() ON (r.weight)");
db.run("CREATE (:P {id: 1})");
db.run("CREATE (:P {id: 2})");
db.run(
"MATCH (a:P {id: 1}), (b:P {id: 2}) \
CREATE (a)-[:LINKS {weight: 20, keep: true}]->(b)",
);
db.run(
"MATCH (a:P {id: 2}), (b:P {id: 1}) \
CREATE (a)-[:LINKS {weight: 30, keep: false}]->(b)",
);
let plan = db
.service
.explain(
"MATCH ()-[r:LINKS]->() WHERE r.weight > 10 AND r.keep = true RETURN r",
None,
)
.unwrap();
assert!(
contains_operator(&plan.tree.root, "RelByPropertyRangeScan"),
"expected the index prefilter to remain in the plan, got {:?}",
plan.tree
);
let rows = db.run(
"MATCH ()-[r:LINKS]->() WHERE r.weight > 10 AND r.keep = true RETURN r ORDER BY r.weight",
);
assert_eq!(pluck_rel_property(&rows, "r", "weight"), vec![20]);
}
#[test]
fn rel_range_scan_preserves_cross_type_comparison_semantics() {
let db = TestDb::new();
db.run("CREATE INDEX rel_weight_idx FOR ()-[r:LINKS]-() ON (r.weight)");
db.run("CREATE (:P {id: 1})");
db.run("CREATE (:P {id: 2})");
db.run(
"MATCH (a:P {id: 1}), (b:P {id: 2}) \
CREATE (a)-[:LINKS {weight: 'heavy'}]->(b)",
);
let rows = db.run("MATCH ()-[r:LINKS]->() WHERE r.weight > 10 RETURN r");
assert!(
rows.is_empty(),
"range-index rewrite must match scan+filter semantics for non-comparable values"
);
}
#[test]
fn explain_rewrites_rel_text_filter_to_rel_text_scan() {
let db = TestDb::new();
db.run("CREATE TEXT INDEX rel_label_idx FOR ()-[r:KNOWS]-() ON (r.note)");
let plan = db
.service
.explain(
"MATCH ()-[r:KNOWS]->() WHERE r.note STARTS WITH 'Co' RETURN r",
None,
)
.unwrap();
assert!(
contains_operator(&plan.tree.root, "RelByTextScan"),
"expected RelByTextScan in plan, got {:?}",
plan.tree
);
}
#[test]
fn rel_text_scan_returns_correct_rows() {
let db = TestDb::new();
db.run("CREATE TEXT INDEX rel_note_idx FOR ()-[r:KNOWS]-() ON (r.note)");
db.run("CREATE (:P {id: 1})");
db.run("CREATE (:P {id: 2})");
db.run("CREATE (:P {id: 3})");
db.run(
"MATCH (a:P {id: 1}), (b:P {id: 2}) \
CREATE (a)-[:KNOWS {note: 'Colleague'}]->(b)",
);
db.run(
"MATCH (a:P {id: 2}), (b:P {id: 3}) \
CREATE (a)-[:KNOWS {note: 'Friend'}]->(b)",
);
db.run(
"MATCH (a:P {id: 1}), (b:P {id: 3}) \
CREATE (a)-[:KNOWS {note: 'Confidant'}]->(b)",
);
let rows =
db.run("MATCH ()-[r:KNOWS]->() WHERE r.note STARTS WITH 'Co' RETURN r ORDER BY r.note");
assert_eq!(rows.len(), 2);
}
#[test]
fn rel_text_scan_keeps_label_scan_for_empty_query() {
let db = TestDb::new();
db.run("CREATE TEXT INDEX rel_note_idx FOR ()-[r:KNOWS]-() ON (r.note)");
db.run("CREATE (:P {id: 1})");
db.run("CREATE (:P {id: 2})");
db.run(
"MATCH (a:P {id: 1}), (b:P {id: 2}) \
CREATE (a)-[:KNOWS {note: 'X'}]->(b)",
);
let plan = db
.service
.explain(
"MATCH ()-[r:KNOWS]->() WHERE r.note STARTS WITH '' RETURN r",
None,
)
.unwrap();
assert!(
!contains_operator(&plan.tree.root, "RelByTextScan"),
"tautological STARTS WITH '' must not trigger the index rewrite, got {:?}",
plan.tree
);
}
#[test]
fn explain_rewrites_rel_bbox_to_rel_point_scan() {
let db = TestDb::new();
db.run("CREATE POINT INDEX rel_loc_idx FOR ()-[r:DELIVERED]-() ON (r.loc)");
let plan = db
.service
.explain(
"MATCH ()-[r:DELIVERED]->() \
WHERE geo.within_bbox(r.loc, {x: 0, y: 0}::POINT, {x: 100, y: 100}::POINT) \
RETURN r",
None,
)
.unwrap();
assert!(
contains_operator(&plan.tree.root, "RelByPointScan"),
"expected RelByPointScan in plan, got {:?}",
plan.tree
);
}
#[test]
fn rel_point_scan_returns_within_bbox_correctly() {
let db = TestDb::new();
db.run("CREATE POINT INDEX rel_loc_idx FOR ()-[r:DELIVERED]-() ON (r.loc)");
db.run("CREATE (:P {id: 1})");
db.run("CREATE (:P {id: 2})");
db.run(
"MATCH (a:P {id: 1}), (b:P {id: 2}) \
CREATE (a)-[:DELIVERED {loc: {x: 50, y: 50}::POINT}]->(b)",
);
db.run(
"MATCH (a:P {id: 1}), (b:P {id: 2}) \
CREATE (a)-[:DELIVERED {loc: {x: 150, y: 150}::POINT}]->(b)",
);
let rows = db.run(
"MATCH ()-[r:DELIVERED]->() \
WHERE geo.within_bbox(r.loc, {x: 0, y: 0}::POINT, {x: 100, y: 100}::POINT) \
RETURN r",
);
assert_eq!(rows.len(), 1);
}
#[test]
fn rel_scan_skipped_when_src_has_label_constraint() {
let db = TestDb::new();
db.run("CREATE INDEX rel_since_idx FOR ()-[r:KNOWS]-() ON (r.since)");
let plan = db
.service
.explain(
"MATCH (a:Person)-[r:KNOWS]->() WHERE r.since > 2020 RETURN r",
None,
)
.unwrap();
assert!(
!contains_operator(&plan.tree.root, "RelByPropertyRangeScan"),
"rewrite must not fire when src has label constraint, got {:?}",
plan.tree
);
}
#[test]
fn rel_scan_with_residual_conjunct_keeps_filter_semantics() {
let db = TestDb::new();
db.run("CREATE TEXT INDEX rel_note_idx FOR ()-[r:KNOWS]-() ON (r.note)");
db.run("CREATE (:P {id: 1})");
db.run("CREATE (:P {id: 2})");
db.run(
"MATCH (a:P {id: 1}), (b:P {id: 2}) \
CREATE (a)-[:KNOWS {note: 'Colleague', weight: 1}]->(b)",
);
db.run(
"MATCH (a:P {id: 2}), (b:P {id: 1}) \
CREATE (a)-[:KNOWS {note: 'Colleague', weight: 10}]->(b)",
);
let plan = db
.service
.explain(
"MATCH ()-[r:KNOWS]->() \
WHERE r.note STARTS WITH 'Co' AND r.weight > 5 \
RETURN r",
None,
)
.unwrap();
assert!(
contains_operator(&plan.tree.root, "Filter"),
"residual weight predicate must keep a parent Filter, got {:?}",
plan.tree
);
assert!(
contains_operator(&plan.tree.root, "RelByTextScan")
|| contains_operator(&plan.tree.root, "RelByPropertyRangeScan"),
"one indexed conjunct should still prefilter below Filter, got {:?}",
plan.tree
);
let rows = db.run(
"MATCH ()-[r:KNOWS]->() \
WHERE r.note STARTS WITH 'Co' AND r.weight > 5 \
RETURN r",
);
assert_eq!(rows.len(), 1);
}
#[test]
fn rel_scan_with_duplicate_same_side_range_bounds_keeps_filter_semantics() {
let db = TestDb::new();
db.run("CREATE INDEX rel_weight_idx FOR ()-[r:LINKS]-() ON (r.weight)");
db.run("CREATE (:P {id: 1})");
db.run("CREATE (:P {id: 2})");
db.run(
"MATCH (a:P {id: 1}), (b:P {id: 2}) \
CREATE (a)-[:LINKS {weight: 20}]->(b)",
);
db.run(
"MATCH (a:P {id: 2}), (b:P {id: 1}) \
CREATE (a)-[:LINKS {weight: 40}]->(b)",
);
let query = "MATCH ()-[r:LINKS]->() \
WHERE r.weight > 10 AND r.weight > 30 \
RETURN r ORDER BY r.weight";
let plan = db.service.explain(query, None).unwrap();
assert!(
contains_operator(&plan.tree.root, "Filter"),
"duplicate lower bounds must keep a parent Filter, got {:?}",
plan.tree
);
assert!(
contains_operator(&plan.tree.root, "RelByPropertyRangeScan"),
"strongest indexed lower bound should still prefilter, got {:?}",
plan.tree
);
let rows = db.run(query);
assert_eq!(pluck_rel_property(&rows, "r", "weight"), vec![40]);
}
#[test]
fn rel_scan_skipped_when_src_may_be_prebound() {
let db = TestDb::new();
db.run("CREATE INDEX rel_since_idx FOR ()-[r:KNOWS]-() ON (r.since)");
let plan = db
.service
.explain(
"MATCH (a:Person {id: 1}) \
MATCH (a)-[r:KNOWS]->() \
WHERE r.since > 2020 \
RETURN r",
None,
)
.unwrap();
assert!(
!contains_operator(&plan.tree.root, "RelByPropertyRangeScan"),
"rewrite must not bypass an upstream source binding, got {:?}",
plan.tree
);
}