use core_api::{GraphDb, GraphError, IngestOptions, Predicate, ResultSet, RuleDef, Value};
use std::collections::BTreeMap;
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("graphdb-query-{}-{}", name, std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
fn tags(items: &[&str]) -> Value {
Value::List(items.iter().map(|s| Value::Str((*s).into())).collect())
}
fn overlap(name: &str, field: &str, edge_type: &str) -> RuleDef {
RuleDef {
name: name.into(),
src_label: "Org".into(),
dst_label: "Person".into(),
predicate: Predicate::Overlap {
field: field.into(),
min: 0.2,
},
edge_type: edge_type.into(),
weight_prop: Some("score".into()),
max_edges: None,
approximate: false,
via_label: None,
via_edge: None,
via_dir: None,
namespace: None,
}
}
fn open_fixture(name: &str) -> GraphDb<core_storage::fs::RealFs> {
let mut db = GraphDb::open(&tmp(name)).unwrap();
db.insert_node(
"Person",
"t1",
vec![
("id".into(), Value::Str("t1".into())),
("industries".into(), tags(&["a", "b", "c", "d"])),
("specialties".into(), tags(&["w", "x", "y", "z"])),
],
)
.unwrap();
db.insert_node("Person", "t2", vec![("id".into(), Value::Str("t2".into()))])
.unwrap();
for (key, industries, specialties) in [
("acme", &["a", "b", "c", "d"][..], &["w", "x", "y", "z"][..]),
("zeta", &["a", "b", "c"], &["w", "x", "y", "z"]),
("beta", &["a", "b", "c"], &["w", "x", "y"]),
("echo", &["a", "b"], &["w", "x"]),
("gamma", &["a"], &["w", "x", "y", "z"]),
("delta", &["a", "b", "c", "d"], &["w"]),
("foxtrot", &["a", "b", "c", "d"], &["q", "r"]),
] {
db.insert_node(
"Org",
key,
vec![
("industries".into(), tags(industries)),
("specialties".into(), tags(specialties)),
],
)
.unwrap();
}
db.create_rule(overlap("industry_align", "industries", "INDUSTRY"))
.unwrap();
db.create_rule(overlap("specialty_match", "specialties", "SPECIALTY"))
.unwrap();
db.insert_edge("KNOWS", "t1", "t2").unwrap();
db
}
fn tid(id: &str) -> BTreeMap<String, Value> {
let mut p = BTreeMap::new();
p.insert("tid".into(), Value::Str(id.into()));
p
}
fn f(x: f64) -> Value {
Value::Float(x)
}
fn s(x: &str) -> Value {
Value::Str(x.into())
}
fn rows_of(rs: &ResultSet) -> Vec<Vec<Option<Value>>> {
(0..rs.len()).map(|i| rs.row(i).to_vec()).collect()
}
#[test]
fn dogfood_shaped_scored_query_orders_by_rule_weights() {
let db = open_fixture("dogfood");
let rs = db
.query(
"\
MATCH (t:Person {id: $tid})
MATCH (c:Org)-[i:INDUSTRY]->(t)
MATCH (c)-[s:SPECIALTY]->(t)
WHERE i.score >= 0.5 AND s.score >= 0.5
RETURN c, i.score AS industry, s.score AS specialty
ORDER BY industry DESC, specialty DESC
LIMIT 10",
&tid("t1"),
)
.expect("dogfood-shaped query");
assert_eq!(
rs.columns(),
&[
"c".to_string(),
"industry".to_string(),
"specialty".to_string()
]
);
assert_eq!(
rows_of(&rs),
vec![
vec![Some(s("acme")), Some(f(1.0)), Some(f(1.0))],
vec![Some(s("zeta")), Some(f(0.75)), Some(f(1.0))],
vec![Some(s("beta")), Some(f(0.75)), Some(f(0.75))],
vec![Some(s("echo")), Some(f(0.5)), Some(f(0.5))],
]
);
}
#[test]
fn props_map_param_query_resolves_bound_node() {
let db = open_fixture("props-param");
let hit = db
.query("MATCH (t:Person {id: $tid}) RETURN t", &tid("t1"))
.expect("props + param");
assert_eq!(hit.columns(), &["t".to_string()]);
assert_eq!(rows_of(&hit), vec![vec![Some(s("t1"))]]);
let miss = db
.query("MATCH (t:Person {id: $tid}) RETURN t", &tid("nope"))
.expect("unknown param value is Ok(empty)");
assert!(miss.is_empty());
}
#[test]
fn undirected_query_finds_both_orientations() {
let db = open_fixture("undirected");
let rs = db
.query(
"MATCH (a:Person)-[r:KNOWS]-(b:Person) RETURN a, b",
&BTreeMap::new(),
)
.expect("undirected");
assert_eq!(
rows_of(&rs),
vec![
vec![Some(s("t1")), Some(s("t2"))],
vec![Some(s("t2")), Some(s("t1"))],
]
);
}
#[test]
fn syntax_error_is_query_error_with_detail() {
let db = open_fixture("syntax");
let err = db
.query("MATCH (n)", &BTreeMap::new())
.expect_err("missing RETURN is a query error");
match &err {
GraphError::QueryError { detail } => {
assert!(
detail.starts_with("parse:"),
"syntax errors must be prefixed parse:, got: {detail}"
);
let d = detail.to_ascii_lowercase();
assert!(
d.contains("token") || d.contains("return"),
"detail must name the failing token, got: {detail}"
);
}
other => panic!("expected QueryError, got {other:?}"),
}
let shown = err.to_string();
assert!(
shown.to_ascii_lowercase().contains("query"),
"Display must mention query, got: {shown}"
);
}
#[test]
fn cypher_neighbors_match_grouped_by_edge_type() {
let db = open_fixture("cross-check");
let t1 = db.node_ref("t1").expect("t1");
let grouped = t1.grouped_by_edge_type();
let mut traversal = grouped
.get("INDUSTRY")
.cloned()
.expect("INDUSTRY neighbors from traversal");
traversal.sort();
traversal.dedup();
let rs = db
.query(
"MATCH (t:Person {id: $tid})-[r:INDUSTRY]-(n) RETURN n",
&tid("t1"),
)
.expect("cypher neighbor fetch");
let mut cypher: Vec<String> = (0..rs.len())
.map(|i| match rs.get(i, "n") {
Some(Value::Str(k)) => k.clone(),
other => panic!("row {i} not a node key: {other:?}"),
})
.collect();
cypher.sort();
cypher.dedup();
assert_eq!(traversal, cypher);
}
#[test]
fn query_stage_prefixes_lex_plan_and_execute() {
let db = open_fixture("stage-prefix");
match db.query("@", &BTreeMap::new()) {
Err(GraphError::QueryError { detail }) => {
assert!(
detail.starts_with("lex:"),
"lex errors must be prefixed lex:, got: {detail}"
);
}
other => panic!("expected QueryError, got {other:?}"),
}
match db.query("MATCH (a) RETURN b", &BTreeMap::new()) {
Err(GraphError::QueryError { detail }) => {
assert!(
detail.starts_with("execute:"),
"planning errors now surface via execute:, got: {detail}"
);
assert!(
detail.contains("b") && (detail.contains("unbound") || detail.contains("Unbound")),
"error must name the unbound variable, got: {detail}"
);
}
other => panic!("expected QueryError, got {other:?}"),
}
match db.query("MATCH (t:Person {id: $tid}) RETURN t", &BTreeMap::new()) {
Err(GraphError::QueryError { detail }) => {
assert!(
detail.starts_with("execute:"),
"execute errors must be prefixed execute:, got: {detail}"
);
assert!(
detail.contains("tid"),
"missing-param execute error must name the parameter, got: {detail}"
);
}
other => panic!("expected QueryError, got {other:?}"),
}
}
#[test]
#[ignore]
fn harness_industry_alignment_timing() {
const N_TALENT: usize = 3_500;
const N_COMPANY: usize = 1_000;
const N_INDUSTRY: usize = 3;
const LIMIT: usize = 200;
let dir = tmp("ia-timing");
let mut db = GraphDb::open(&dir).expect("open");
let opts = IngestOptions {
key_field: "id".into(),
auto_fk: core_api::AutoFk::Off,
};
let talent_rows: Vec<BTreeMap<String, Value>> = (0..N_TALENT)
.map(|i| {
let mut row = BTreeMap::new();
row.insert("id".into(), Value::Str(format!("t{i:05}")));
row.insert(
"industry".into(),
Value::Str(format!("ind{}", i % N_INDUSTRY)),
);
row
})
.collect();
db.ingest("Talent", talent_rows, &opts)
.expect("talent ingest");
let company_rows: Vec<BTreeMap<String, Value>> = (0..N_COMPANY)
.map(|i| {
let mut row = BTreeMap::new();
row.insert("id".into(), Value::Str(format!("c{i:05}")));
row.insert(
"industry".into(),
Value::Str(format!("ind{}", i % N_INDUSTRY)),
);
row
})
.collect();
db.ingest("Company", company_rows, &opts)
.expect("company ingest");
let rule = RuleDef {
name: "INDUSTRY_ALIGNMENT".into(),
src_label: "Talent".into(),
dst_label: "Company".into(),
predicate: Predicate::FieldEqual {
field: "industry".into(),
},
edge_type: "INDUSTRY_ALIGNMENT".into(),
weight_prop: None,
max_edges: None,
approximate: false,
via_label: None,
via_edge: None,
via_dir: None,
namespace: None,
};
let t_rule = std::time::Instant::now();
db.create_rule(rule).expect("create IA rule");
let backfill_ms = t_rule.elapsed().as_millis();
println!("IA rule backfill: {backfill_ms} ms ({N_TALENT}T × {N_COMPANY}C)");
let params = BTreeMap::new();
let query = format!(
"MATCH (t:Talent)-[:INDUSTRY_ALIGNMENT]->(c:Company)\
<-[:INDUSTRY_ALIGNMENT]-(t2:Talent) RETURN t, c, t2 LIMIT {LIMIT}"
);
for _ in 0..3 {
let rs = db.query(&query, ¶ms).expect("warm-up query");
assert_eq!(rs.len(), LIMIT, "warm-up: expected {LIMIT} rows");
}
let mut times_us: Vec<u64> = Vec::new();
for _ in 0..20 {
let t0 = std::time::Instant::now();
let rs = db.query(&query, ¶ms).expect("timed query");
times_us.push(t0.elapsed().as_micros() as u64);
assert_eq!(rs.len(), LIMIT, "expected {LIMIT} rows");
}
times_us.sort();
let min_us = times_us[0];
let median_us = times_us[times_us.len() / 2];
let p95_us = times_us[(times_us.len() as f64 * 0.95) as usize];
println!(
"INDUSTRY_ALIGNMENT two-hop LIMIT {LIMIT} at {N_TALENT}T+{N_COMPANY}C:\n\
\tmin={min_us} µs median={median_us} µs p95={p95_us} µs"
);
assert_eq!(times_us.len(), 20);
assert!(min_us < 500_000, "query should complete in <500 ms");
}
#[test]
fn aggregate_wire_shape_and_semantics() {
let db = open_fixture("aggregate-wire");
let params = BTreeMap::new();
let rs = db
.query("MATCH (o:Org) RETURN COUNT(*)", ¶ms)
.expect("COUNT(*) must succeed");
assert_eq!(rs.columns(), &["COUNT(*)".to_string()]);
assert_eq!(rs.len(), 1, "aggregate always returns exactly one row");
assert_eq!(
rs.row(0),
&[Some(Value::Int(7))],
"7 Org nodes must be counted"
);
let rs_alias = db
.query("MATCH (o:Org) RETURN COUNT(*) AS n_orgs", ¶ms)
.expect("COUNT(*) AS n_orgs");
assert_eq!(rs_alias.columns(), &["n_orgs".to_string()]);
assert_eq!(rs_alias.row(0), &[Some(Value::Int(7))]);
let rs_edge = db
.query(
"MATCH (o:Org)-[:INDUSTRY]->(p:Person) RETURN COUNT(*)",
¶ms,
)
.expect("COUNT(*) on edge");
assert_eq!(rs_edge.columns(), &["COUNT(*)".to_string()]);
assert_eq!(rs_edge.len(), 1);
match rs_edge.row(0) {
[Some(Value::Int(n))] => assert!(*n >= 0, "edge count must be non-negative"),
other => panic!("expected [Some(Int)], got {other:?}"),
}
let rs_grouped = db
.query("MATCH (o:Org) RETURN o, COUNT(*) AS n", ¶ms)
.expect("grouped aggregation must now succeed");
assert_eq!(
rs_grouped.len(),
7,
"7 Org nodes must produce 7 groups; got {}",
rs_grouped.len()
);
assert_eq!(
rs_grouped.columns(),
&["o".to_string(), "n".to_string()],
"columns must be [o, n]"
);
}
fn diamond_db(name: &str) -> GraphDb<core_storage::fs::RealFs> {
let mut db = GraphDb::open(&tmp(name)).unwrap();
for n in ["a", "b", "c", "d"] {
db.insert_node("N", n, vec![("id".into(), Value::Str(n.into()))])
.unwrap();
}
db.insert_edge("T", "a", "b").unwrap();
db.insert_edge("T", "a", "c").unwrap();
db.insert_edge("T", "b", "d").unwrap();
db.insert_edge("T", "c", "d").unwrap();
db
}
fn chain_db(name: &str) -> GraphDb<core_storage::fs::RealFs> {
let mut db = GraphDb::open(&tmp(name)).unwrap();
for n in ["a", "b", "c", "d"] {
db.insert_node("N", n, vec![("id".into(), Value::Str(n.into()))])
.unwrap();
}
db.insert_edge("T", "a", "b").unwrap();
db.insert_edge("T", "b", "c").unwrap();
db.insert_edge("T", "c", "d").unwrap();
db
}
#[test]
fn var_expand_diamond_path_counts() {
let db = diamond_db("vp-diamond");
let empty = BTreeMap::new();
let rs = db
.query("MATCH (a:N {id: 'a'})-[r:T*1..1]->(b) RETURN b", &empty)
.unwrap_or_else(|e| panic!("var expand *1..1 failed: {e}"));
assert_eq!(rs.len(), 2, "*1..1 from a must yield 2 rows (b and c)");
let rs2 = db
.query(
"MATCH (a:N {id: 'a'})-[r:T*1..2]->(b) RETURN b, r.length",
&empty,
)
.unwrap_or_else(|e| panic!("var expand *1..2 failed: {e}"));
assert_eq!(
rs2.len(),
4,
"*1..2 from a must yield 4 rows (2 at depth 1, 2 at depth 2)"
);
let rs3 = db
.query("MATCH (a:N {id: 'a'})-[r:T*2..3]->(b) RETURN b", &empty)
.unwrap_or_else(|e| panic!("var expand *2..3 failed: {e}"));
assert_eq!(
rs3.len(),
2,
"*2..3 from a must yield 2 rows (d via b, d via c)"
);
}
#[test]
fn var_expand_diamond_no_id_prop() {
let db = diamond_db("vp-diamond-count");
let empty = BTreeMap::new();
let rs = db
.query("MATCH (a:N)-[r:T*1..2]->(b) RETURN COUNT(*)", &empty)
.unwrap_or_else(|e| panic!("diamond count *1..2: {e}"));
assert_eq!(rs.len(), 1);
assert_eq!(
rs.row(0)[0],
Some(Value::Int(6)),
"diamond *1..2 must yield 6 total paths"
);
}
#[test]
fn var_expand_cycle_terminates_and_edge_uniqueness_enforced() {
let mut db = GraphDb::open(&tmp("vp-cycle")).unwrap();
db.insert_node("N", "a", vec![]).unwrap();
db.insert_node("N", "b", vec![]).unwrap();
db.insert_edge("T", "a", "b").unwrap();
db.insert_edge("T", "b", "a").unwrap();
let p = BTreeMap::new();
let rs = db
.query("MATCH (a:N)-[r:T*1..10]->(b) RETURN b", &p)
.expect("cycle *1..10 must terminate");
assert!(
rs.len() <= 1_000_000,
"cycle must produce finite rows, got {}",
rs.len()
);
assert_eq!(
rs.len(),
4,
"2-cycle *1..10 must yield exactly 4 rows (2 per starting node, edge-uniqueness caps at depth 2)"
);
}
#[test]
fn shortest_path_reachable_at_depth_3() {
let db = chain_db("sp-chain");
let p = BTreeMap::new();
let rs = db
.query(
"MATCH (a:N {id: 'a'}) MATCH (d:N {id: 'd'}) \
MATCH shortestPath((a)-[r:T*..5]->(d)) \
RETURN r.length",
&p,
)
.unwrap_or_else(|e| panic!("shortestPath must succeed: {e}"));
assert_eq!(
rs.len(),
1,
"shortestPath must return exactly 1 row when reachable"
);
assert_eq!(
rs.row(0)[0],
Some(Value::Int(3)),
"shortest path a→b→c→d must have length 3"
);
}
#[test]
fn shortest_path_unreachable_returns_zero_rows() {
let db = chain_db("sp-chain-miss");
let p = BTreeMap::new();
let rs = db
.query(
"MATCH (d:N {id: 'd'}) MATCH (a:N {id: 'a'}) \
MATCH shortestPath((d)-[r:T*..5]->(a)) \
RETURN r.length",
&p,
)
.unwrap_or_else(|e| panic!("shortestPath unreachable must return Ok: {e}"));
assert_eq!(
rs.len(),
0,
"shortestPath with unreachable target must return 0 rows"
);
}
#[test]
fn shortest_path_max_hops_respected() {
let db = chain_db("sp-chain-hops");
let p = BTreeMap::new();
let rs = db
.query(
"MATCH (a:N {id: 'a'}) MATCH (d:N {id: 'd'}) \
MATCH shortestPath((a)-[r:T*..2]->(d)) \
RETURN r.length",
&p,
)
.unwrap_or_else(|e| panic!("shortestPath with tight hop cap: {e}"));
assert_eq!(
rs.len(),
0,
"shortestPath(a→d) with max 2 hops must return 0 rows (path requires 3)"
);
}
#[test]
fn var_expand_with_limit_takes_staged_path_and_returns_correct_rows() {
let db = diamond_db("vp-limit");
let p = BTreeMap::new();
let rs = db
.query("MATCH (a:N)-[r:T*1..2]->(b) RETURN b LIMIT 3", &p)
.unwrap_or_else(|e| panic!("var expand with LIMIT: {e}"));
assert_eq!(rs.len(), 3, "LIMIT 3 must return exactly 3 rows");
}
#[test]
fn var_expand_budget_exceeded_errors_cleanly() {
let mut db = GraphDb::open(&tmp("vp-budget")).unwrap();
for i in 0..10u32 {
db.insert_node("N", &format!("n{i}"), vec![]).unwrap();
}
for i in 0..10u32 {
for j in 0..10u32 {
if i != j {
db.insert_edge("T", &format!("n{i}"), &format!("n{j}"))
.unwrap();
}
}
}
let p = BTreeMap::new();
let result = db.query("MATCH (a:N)-[r:T*1..10]->(b) RETURN b", &p);
match result {
Err(GraphError::QueryError { ref detail }) => {
assert!(
detail.contains("intermediate result exceeds")
|| detail.contains("1000000")
|| detail.contains("1,000,000"),
"budget error must name the limit, got: {detail}"
);
}
Ok(_) => panic!("expected budget error on 10-node complete graph *1..10, got Ok"),
Err(e) => panic!("unexpected non-budget error: {e:?}"),
}
}
#[test]
fn var_expand_unbound_endpoint_is_plan_error() {
let db = diamond_db("vp-unbound");
let result = db.query(
"MATCH shortestPath((a)-[r:T*..5]->(b)) RETURN a",
&BTreeMap::new(),
);
match result {
Err(GraphError::QueryError { ref detail }) => {
assert!(
detail.contains("shortestPath") || detail.contains("bound"),
"unbound shortestPath must name the issue, got: {detail}"
);
}
Ok(_) => panic!("unbound shortestPath must be an error"),
Err(e) => panic!("unexpected error variant: {e:?}"),
}
}
#[test]
fn var_expand_cap_exceeded_in_parse_is_error() {
let db = diamond_db("vp-cap");
let result = db.query("MATCH (a:N)-[r:T*1..11]->(b) RETURN b", &BTreeMap::new());
match result {
Err(GraphError::QueryError { ref detail }) => {
assert!(
detail.contains("capped at 10 hops"),
"cap error must say '10 hops', got: {detail}"
);
}
Ok(_) => panic!("*1..11 must be rejected at parse time"),
Err(e) => panic!("unexpected error variant: {e:?}"),
}
}
#[test]
fn var_expand_zero_min_is_rejected() {
let db = diamond_db("vp-zero-min");
for q in &[
"MATCH (a:N)-[r:T*0]->(b) RETURN b",
"MATCH (a:N)-[r:T*0..3]->(b) RETURN b",
] {
let result = db.query(q, &BTreeMap::new());
match result {
Err(GraphError::QueryError { ref detail }) => {
assert!(
detail.contains("zero-length variable-length paths are not supported"),
"zero-min error must name the issue, got: {detail}"
);
}
Ok(_) => panic!("min=0 query must be rejected: {q}"),
Err(e) => panic!("unexpected error variant for {q}: {e:?}"),
}
}
}
#[test]
fn var_expand_frontier_budget_fires_before_output() {
let mut db = GraphDb::open(&tmp("vp-frontier-budget")).unwrap();
for i in 0..10u32 {
db.insert_node("N", &format!("n{i}"), vec![]).unwrap();
}
for i in 0..10u32 {
for j in 0..10u32 {
if i != j {
db.insert_edge("T", &format!("n{i}"), &format!("n{j}"))
.unwrap();
}
}
}
let result = db.query("MATCH (a:N)-[r:T*5..10]->(b) RETURN b", &BTreeMap::new());
match result {
Err(GraphError::QueryError { ref detail }) => {
assert!(
detail.contains("intermediate result exceeds")
|| detail.contains("1000000")
|| detail.contains("1,000,000"),
"frontier budget error must name the limit, got: {detail}"
);
}
Ok(_) => panic!("expected budget error on 10-node complete graph *5..10, got Ok"),
Err(e) => panic!("unexpected non-budget error: {e:?}"),
}
}
#[test]
fn var_expand_left_directed() {
let db = diamond_db("vp-left");
let rs = db
.query(
"MATCH (d:N {id: 'd'})<-[r:T*1..2]-(x) RETURN x",
&BTreeMap::new(),
)
.expect("left-directed *1..2 must succeed");
assert_eq!(
rs.len(),
4,
"left-directed from d: expected 4 rows, got {}",
rs.len()
);
}
#[test]
fn var_expand_undirected() {
let db = diamond_db("vp-undirected");
let rs = db
.query(
"MATCH (a:N {id: 'a'})-[r:T*1..2]-(x) RETURN x",
&BTreeMap::new(),
)
.expect("undirected *1..2 must succeed");
assert_eq!(
rs.len(),
4,
"undirected from a: expected 4 rows, got {}",
rs.len()
);
}
#[test]
fn shortest_path_min_gt_1_is_plan_error() {
let db = chain_db("sp-min-gt1");
let result = db.query(
"MATCH (a:N {id: 'a'}) MATCH (d:N {id: 'd'}) \
MATCH shortestPath((a)-[r:T*2..5]->(d)) RETURN r.length",
&BTreeMap::new(),
);
match result {
Err(GraphError::QueryError { ref detail }) => {
assert!(
detail.contains("shortestPath") && detail.contains("minimum"),
"error must name shortestPath and minimum, got: {detail}"
);
}
Ok(_) => panic!("shortestPath with min>1 must be rejected at planning time"),
Err(e) => panic!("unexpected error variant: {e:?}"),
}
}
#[test]
fn grouped_aggregate_counts_by_prop() {
let db = open_fixture("grouped-count");
let params = BTreeMap::new();
let rs = db
.query("MATCH (o:Org) RETURN o, COUNT(*) AS n", ¶ms)
.expect("grouped COUNT must succeed");
assert_eq!(rs.len(), 7, "7 Org nodes must produce 7 groups");
assert_eq!(
rs.columns(),
&["o".to_string(), "n".to_string()],
"columns must be [o, n]"
);
for i in 0..rs.len() {
assert_eq!(
rs.row(i)[1],
Some(Value::Int(1)),
"row {i}: each node appears in its own group, count must be 1"
);
}
}
#[test]
fn grouped_aggregate_order_by_count_limit() {
let db = open_fixture("grouped-limit");
let empty = BTreeMap::new();
let rs = db
.query(
"MATCH (o:Org)-[:INDUSTRY]->(p:Person) \
RETURN o, COUNT(*) AS n \
ORDER BY n DESC LIMIT 3",
&empty,
)
.expect("grouped COUNT ORDER BY LIMIT must succeed");
assert!(rs.len() <= 3, "LIMIT 3 must return at most 3 rows");
for i in 1..rs.len() {
let prev = rs.row(i - 1)[1].as_ref();
let curr = rs.row(i)[1].as_ref();
let ord = match (prev, curr) {
(Some(Value::Int(a)), Some(Value::Int(b))) => a.cmp(b),
_ => std::cmp::Ordering::Equal,
};
assert!(
ord != std::cmp::Ordering::Less,
"rows must be in descending order; row {i} has count > row {}",
i - 1
);
}
}
#[test]
fn aggregate_and_grouped_aggregate_survive_v4_reopen() {
let dir = tmp("agg-reopen-pin");
let build_db = |dir: &std::path::Path| {
let mut db = GraphDb::open(dir).unwrap();
for k in ["n1", "n2", "n3"] {
db.insert_node("N", k, vec![("cat".into(), Value::Str("A".into()))])
.unwrap();
}
for k in ["n4", "n5"] {
db.insert_node("N", k, vec![("cat".into(), Value::Str("B".into()))])
.unwrap();
}
db
};
let empty = BTreeMap::new();
let total_count_q = "MATCH (n:N) RETURN COUNT(*)";
let grouped_q = "MATCH (n:N) RETURN n.cat, COUNT(*) AS cnt ORDER BY n.cat";
let ref_db = build_db(&dir);
let ref_total = ref_db
.query(total_count_q, &empty)
.expect("reference COUNT(*) must succeed");
let ref_grouped = ref_db
.query(grouped_q, &empty)
.expect("reference grouped aggregate must succeed");
drop(ref_db); {
let mut db = GraphDb::open(&dir).unwrap();
db.snapshot().unwrap();
}
let db2 = GraphDb::open(&dir).unwrap();
let after_total = db2
.query(total_count_q, &empty)
.expect("post-reopen COUNT(*) must succeed");
let after_grouped = db2
.query(grouped_q, &empty)
.expect("post-reopen grouped aggregate must succeed");
assert_eq!(
ref_total.row(0),
after_total.row(0),
"COUNT(*) must match after V4 reopen: ref={:?} after={:?}",
ref_total.row(0),
after_total.row(0)
);
assert_eq!(
ref_grouped.len(),
after_grouped.len(),
"grouped aggregate row count must match after V4 reopen"
);
assert_eq!(
ref_grouped.columns(),
after_grouped.columns(),
"grouped aggregate columns must match after V4 reopen"
);
for i in 0..ref_grouped.len() {
assert_eq!(
ref_grouped.row(i),
after_grouped.row(i),
"grouped aggregate row {i} must match after V4 reopen: ref={:?} after={:?}",
ref_grouped.row(i),
after_grouped.row(i)
);
}
}
fn get_val(rs: &ResultSet, row: usize, col: &str) -> Option<Value> {
rs.get(row, col).cloned()
}
#[test]
fn optional_match_count_zero_for_edgeless() {
let dir = tmp("optional_count_zero");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("Person", "n1", vec![]);
batch.insert_node("Person", "n2", vec![]);
batch.insert_edge("KNOWS", "n1", "n2");
batch.insert_node("Person", "n3", vec![]); batch.commit().unwrap();
}
let rs = db
.query(
"MATCH (a:Person) OPTIONAL MATCH (a)-[:KNOWS]->(b) RETURN a, COUNT(b)",
&BTreeMap::new(),
)
.unwrap();
assert_eq!(rs.len(), 3, "must have 3 rows, one per node");
let counts: Vec<Option<Value>> = (0..rs.len()).map(|i| get_val(&rs, i, "COUNT(b)")).collect();
assert!(
counts.iter().any(|c| c.as_ref() == Some(&Value::Int(0))),
"edgeless node must return COUNT(b) = 0, got: {counts:?}"
);
}
#[test]
fn optional_match_with_where_inside_optional() {
let dir = tmp("optional_where");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node(
"OW",
"alice",
vec![("name".into(), Value::Str("Alice".into()))],
);
batch.insert_node("OW", "bob", vec![("name".into(), Value::Str("Bob".into()))]);
batch.insert_edge("FRIEND", "alice", "bob");
batch.commit().unwrap();
}
let rs = db
.query(
"MATCH (a:OW) WHERE a.name = 'Alice' \
OPTIONAL MATCH (a)-[:FRIEND]->(b) WHERE b.name = 'nonexistent' \
RETURN a, b",
&BTreeMap::new(),
)
.unwrap();
assert_eq!(rs.len(), 1, "one row expected (left-outer fallback)");
assert_eq!(
get_val(&rs, 0, "b"),
None,
"b must be null when WHERE inside optional fails"
);
}
#[test]
fn optional_match_chained() {
let dir = tmp("optional_chained");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("NdA", "a", vec![]);
batch.insert_node("NdB", "b", vec![]);
batch.insert_node("NdC", "c_node", vec![]);
batch.insert_edge("X", "a", "b");
batch.commit().unwrap();
}
let rs = db
.query(
"MATCH (a:NdA) \
OPTIONAL MATCH (a)-[:X]->(b) \
OPTIONAL MATCH (a)-[:Y]->(c) \
RETURN a, b, c",
&BTreeMap::new(),
)
.unwrap();
assert_eq!(rs.len(), 1);
assert_eq!(get_val(&rs, 0, "b"), Some(Value::Str("b".into())));
assert_eq!(get_val(&rs, 0, "c"), None);
}
#[test]
fn query_with_params_basic() {
let dir = tmp("params_basic");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
batch.insert_node("Person", "bob", vec![("age".into(), Value::Int(25))]);
batch.commit().unwrap();
}
let rs = db
.query_with_params(
"MATCH (n:Person) WHERE n.age = $age RETURN n",
&[("age", Value::Int(30))],
)
.unwrap();
assert_eq!(rs.len(), 1);
assert_eq!(get_val(&rs, 0, "n"), Some(Value::Str("alice".into())));
}
#[test]
fn query_with_params_unknown_param_error() {
let dir = tmp("params_unknown");
let db = GraphDb::open(&dir).unwrap();
let err = db.query_with_params(
"MATCH (n:Person) WHERE n.age = $missing RETURN n",
&[], );
assert!(err.is_err(), "unknown param must return Err");
let msg = format!("{:?}", err.unwrap_err());
assert!(
msg.contains("missing") || msg.contains("parameter"),
"error must mention the missing param: {msg}"
);
}
#[test]
fn set_with_param() {
let dir = tmp("set_param");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("SWP", "alice", vec![("age".into(), Value::Int(30))]);
batch.commit().unwrap();
}
let mut params = BTreeMap::new();
params.insert("newage".to_string(), Value::Int(99));
db.query_write(
"MATCH (n:SWP) WHERE n.age = 30 SET n.age = $newage",
¶ms,
)
.unwrap();
let rs = db
.query("MATCH (n:SWP) RETURN n.age", &BTreeMap::new())
.unwrap();
assert_eq!(rs.len(), 1);
assert_eq!(get_val(&rs, 0, "n.age"), Some(Value::Int(99)));
}
#[test]
fn params_injection_safe() {
let dir = tmp("params_injection");
let db = GraphDb::open(&dir).unwrap();
let rs = db
.query_with_params(
"MATCH (n:Person {id: $id}) RETURN n",
&[("id", Value::Str("' RETURN 1//".into()))],
)
.unwrap();
assert_eq!(
rs.len(),
0,
"injection payload must be treated as literal string"
);
}
#[test]
fn fn_tolower_happy() {
let dir = tmp("fn_tolower");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node(
"Tx",
"alice",
vec![("name".into(), Value::Str("Alice".into()))],
);
batch.commit().unwrap();
}
let rs = db
.query("MATCH (n:Tx) RETURN toLower(n.name)", &BTreeMap::new())
.unwrap();
assert_eq!(
get_val(&rs, 0, "toLower(n.name)"),
Some(Value::Str("alice".into()))
);
}
#[test]
fn fn_tolower_null_propagation() {
let dir = tmp("fn_tolower_null");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("Tx", "n1", vec![]); batch.commit().unwrap();
}
let rs = db
.query("MATCH (n:Tx) RETURN toLower(n.name)", &BTreeMap::new())
.unwrap();
assert_eq!(get_val(&rs, 0, "toLower(n.name)"), None);
}
#[test]
fn fn_toupper_happy() {
let dir = tmp("fn_toupper");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("Ty", "x", vec![("v".into(), Value::Str("hello".into()))]);
batch.commit().unwrap();
}
let rs = db
.query("MATCH (n:Ty) RETURN toUpper(n.v)", &BTreeMap::new())
.unwrap();
assert_eq!(
get_val(&rs, 0, "toUpper(n.v)"),
Some(Value::Str("HELLO".into()))
);
}
#[test]
fn fn_toupper_null_propagation() {
let dir = tmp("fn_toupper_null");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("Ty", "x", vec![]);
batch.commit().unwrap();
}
let rs = db
.query("MATCH (n:Ty) RETURN toUpper(n.v)", &BTreeMap::new())
.unwrap();
assert_eq!(get_val(&rs, 0, "toUpper(n.v)"), None);
}
#[test]
fn fn_size_string() {
let dir = tmp("fn_size_str");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("Ts", "x", vec![("s".into(), Value::Str("hello".into()))]);
batch.commit().unwrap();
}
let rs = db
.query("MATCH (n:Ts) RETURN size(n.s)", &BTreeMap::new())
.unwrap();
assert_eq!(get_val(&rs, 0, "size(n.s)"), Some(Value::Int(5)));
}
#[test]
fn fn_size_list() {
let dir = tmp("fn_size_list");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node(
"Tsl",
"x",
vec![(
"tags".into(),
Value::List(vec![
Value::Str("a".into()),
Value::Str("b".into()),
Value::Str("c".into()),
]),
)],
);
batch.commit().unwrap();
}
let rs = db
.query("MATCH (n:Tsl) RETURN size(n.tags)", &BTreeMap::new())
.unwrap();
assert_eq!(get_val(&rs, 0, "size(n.tags)"), Some(Value::Int(3)));
}
#[test]
fn fn_size_null_propagation() {
let dir = tmp("fn_size_null");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("Tsnull", "x", vec![]);
batch.commit().unwrap();
}
let rs = db
.query("MATCH (n:Tsnull) RETURN size(n.missing)", &BTreeMap::new())
.unwrap();
assert_eq!(get_val(&rs, 0, "size(n.missing)"), None);
}
#[test]
fn fn_coalesce_happy() {
let dir = tmp("fn_coalesce");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("Tc", "x", vec![("b".into(), Value::Int(42))]);
batch.commit().unwrap();
}
let rs = db
.query("MATCH (n:Tc) RETURN coalesce(n.a, n.b)", &BTreeMap::new())
.unwrap();
assert_eq!(get_val(&rs, 0, "coalesce(n.a, n.b)"), Some(Value::Int(42)));
}
#[test]
fn fn_coalesce_all_null() {
let dir = tmp("fn_coalesce_null");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("Tc", "x", vec![]);
batch.commit().unwrap();
}
let rs = db
.query("MATCH (n:Tc) RETURN coalesce(n.a, n.b)", &BTreeMap::new())
.unwrap();
assert_eq!(get_val(&rs, 0, "coalesce(n.a, n.b)"), None);
}
#[test]
fn fn_type_happy() {
let dir = tmp("fn_type");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("Pt", "ta", vec![]);
batch.insert_node("Pt", "tb", vec![]);
batch.insert_edge("KNOWS", "ta", "tb");
batch.commit().unwrap();
}
let rs = db
.query("MATCH (a:Pt)-[r]->(b:Pt) RETURN type(r)", &BTreeMap::new())
.unwrap();
assert_eq!(get_val(&rs, 0, "type(r)"), Some(Value::Str("KNOWS".into())));
}
#[test]
fn fn_type_null_propagation() {
let dir = tmp("fn_type_null");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("Ptn", "a", vec![]);
batch.commit().unwrap();
}
let rs = db
.query(
"MATCH (a:Ptn) OPTIONAL MATCH (a)-[r]->() RETURN type(r)",
&BTreeMap::new(),
)
.unwrap();
assert_eq!(get_val(&rs, 0, "type(r)"), None);
}
#[test]
fn fn_abs_happy() {
let dir = tmp("fn_abs");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("Tab", "x", vec![("v".into(), Value::Int(-7))]);
batch.commit().unwrap();
}
let rs = db
.query("MATCH (n:Tab) RETURN abs(n.v)", &BTreeMap::new())
.unwrap();
assert_eq!(get_val(&rs, 0, "abs(n.v)"), Some(Value::Int(7)));
}
#[test]
fn fn_abs_null_propagation() {
let dir = tmp("fn_abs_null");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("Tab", "x", vec![]);
batch.commit().unwrap();
}
let rs = db
.query("MATCH (n:Tab) RETURN abs(n.missing)", &BTreeMap::new())
.unwrap();
assert_eq!(get_val(&rs, 0, "abs(n.missing)"), None);
}
#[test]
fn fn_round_happy() {
let dir = tmp("fn_round");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("Tr", "x", vec![("v".into(), Value::Float(2.7))]);
batch.commit().unwrap();
}
let rs = db
.query("MATCH (n:Tr) RETURN round(n.v)", &BTreeMap::new())
.unwrap();
assert_eq!(get_val(&rs, 0, "round(n.v)"), Some(Value::Float(3.0)));
}
#[test]
fn fn_round_null_propagation() {
let dir = tmp("fn_round_null");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("Tr", "x", vec![]);
batch.commit().unwrap();
}
let rs = db
.query("MATCH (n:Tr) RETURN round(n.missing)", &BTreeMap::new())
.unwrap();
assert_eq!(get_val(&rs, 0, "round(n.missing)"), None);
}
#[test]
fn fn_abs_binarith_sub_arg() {
let dir = tmp("fn_abs_binarith");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("Ba", "x", vec![("age".into(), Value::Int(30))]);
batch.commit().unwrap();
}
let rs = db
.query("MATCH (n:Ba) RETURN abs(n.age - 27)", &BTreeMap::new())
.unwrap();
assert_eq!(get_val(&rs, 0, "abs(<arith>)"), Some(Value::Int(3)));
}
#[test]
fn fn_round_binarith_mul_arg() {
let dir = tmp("fn_round_binarith");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("Br", "x", vec![("score".into(), Value::Float(2.0))]);
batch.commit().unwrap();
}
let rs = db
.query("MATCH (n:Br) RETURN round(n.score * 1.5)", &BTreeMap::new())
.unwrap();
assert_eq!(get_val(&rs, 0, "round(<arith>)"), Some(Value::Float(3.0)));
}
#[test]
fn optional_match_null_property_access() {
let dir = tmp("optional_null_prop");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node(
"ONP",
"solo",
vec![("name".into(), Value::Str("Solo".into()))],
);
batch.commit().unwrap();
}
let rs = db
.query(
"MATCH (a:ONP) OPTIONAL MATCH (a)-[:KNOWS]->(b) RETURN a.name, b.name",
&BTreeMap::new(),
)
.unwrap();
assert_eq!(
rs.len(),
1,
"one outer row must survive OPTIONAL MATCH miss"
);
assert_eq!(
get_val(&rs, 0, "a.name"),
Some(Value::Str("Solo".into())),
"outer node property must be accessible"
);
assert_eq!(
get_val(&rs, 0, "b.name"),
None,
"null-binding property access must propagate null, not error"
);
}
#[test]
fn fn_unknown_function_error() {
let dir = tmp("fn_unknown");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("Tu", "x", vec![("v".into(), Value::Int(1))]);
batch.commit().unwrap();
}
let err = db.query("MATCH (n:Tu) RETURN unknownFn(n.v)", &BTreeMap::new());
assert!(err.is_err(), "unknown function must return Err");
let msg = format!("{:?}", err.unwrap_err());
assert!(
msg.contains("unknown function") || msg.contains("unknownFn"),
"error must name the unknown function: {msg}"
);
}
#[test]
fn unknown_function_lists_text_matches() {
let dir = tmp("fn_unknown_text_matches");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("N", "k", vec![]);
batch.commit().unwrap();
}
let err = db
.query("MATCH (n) RETURN nosuch(n)", &BTreeMap::new())
.unwrap_err();
let s = err.to_string();
assert!(s.contains("textMatches"), "{s}");
}
#[test]
fn limit_param_basic() {
let dir = tmp("limit_param");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
for i in 0..5u32 {
batch.insert_node("LP", &format!("n{i}"), vec![]);
}
batch.commit().unwrap();
}
let rs = db
.query_with_params(
"MATCH (n:LP) RETURN n LIMIT $cap",
&[("cap", Value::Int(2))],
)
.unwrap();
assert_eq!(rs.len(), 2, "LIMIT $cap=2 must return exactly 2 rows");
}
#[test]
fn skip_param_basic() {
let dir = tmp("skip_param");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
for i in 0..5u32 {
batch.insert_node("SP", &format!("n{i}"), vec![]);
}
batch.commit().unwrap();
}
let rs = db
.query_with_params(
"MATCH (n:SP) RETURN n SKIP $offset LIMIT 10",
&[("offset", Value::Int(3))],
)
.unwrap();
assert_eq!(
rs.len(),
2,
"SKIP $offset=3 with 5 nodes must return 2 rows"
);
}
#[test]
fn limit_param_negative_is_error() {
let dir = tmp("limit_param_neg");
let db = GraphDb::open(&dir).unwrap();
let err = db.query_with_params(
"MATCH (n:LPN) RETURN n LIMIT $cap",
&[("cap", Value::Int(-1))],
);
assert!(err.is_err(), "negative LIMIT param must return Err");
let msg = format!("{:?}", err.unwrap_err());
assert!(
msg.contains("non-negative") || msg.contains("cap"),
"error must mention the param or non-negative: {msg}"
);
}
#[test]
fn limit_param_unknown_is_error() {
let dir = tmp("limit_param_unknown");
let db = GraphDb::open(&dir).unwrap();
let err = db.query_with_params(
"MATCH (n:LPUK) RETURN n LIMIT $missing",
&[], );
assert!(err.is_err(), "missing LIMIT param must return Err");
let msg = format!("{:?}", err.unwrap_err());
assert!(
msg.contains("missing") || msg.contains("missing_param") || msg.contains("missing"),
"error must mention missing parameter: {msg}"
);
}
#[test]
fn test_limit_pushdown_large_graph_exact_count() {
let dir = tmp("limit_pushdown_pin");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
for i in 0..50u32 {
batch.insert_node("LP2", &format!("n{i}"), vec![]);
}
batch.commit().unwrap();
}
let rs = db
.query("MATCH (n:LP2) RETURN n LIMIT 5", &BTreeMap::new())
.unwrap();
assert_eq!(
rs.len(),
5,
"LIMIT 5 on 50-node graph must return exactly 5 rows (pull pushdown)"
);
}
#[test]
fn test_order_by_limit_correct_top_k() {
let dir = tmp("order_by_limit_topk");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
for i in 0..10i64 {
batch.insert_node(
"OBLTK",
&format!("n{i}"),
vec![("val".into(), Value::Int(i))],
);
}
batch.commit().unwrap();
}
let rs = db
.query(
"MATCH (n:OBLTK) RETURN n.val ORDER BY n.val DESC LIMIT 3",
&BTreeMap::new(),
)
.unwrap();
assert_eq!(
rs.len(),
3,
"ORDER BY n.val DESC LIMIT 3 must return exactly 3 rows"
);
assert_eq!(
get_val(&rs, 0, "n.val"),
Some(Value::Int(9)),
"row 0 must be val=9 (highest)"
);
assert_eq!(
get_val(&rs, 1, "n.val"),
Some(Value::Int(8)),
"row 1 must be val=8"
);
assert_eq!(
get_val(&rs, 2, "n.val"),
Some(Value::Int(7)),
"row 2 must be val=7"
);
}
#[test]
fn pipeline_group_aggregate_without_optional_match() {
let dir = tmp("pipeline_gagg");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("PGA", "a", vec![]);
batch.insert_node("PGA", "b", vec![]);
batch.insert_node("PGA", "c", vec![]);
batch.commit().unwrap();
}
let rs = db
.query("MATCH (a:PGA) WITH a RETURN COUNT(a)", &BTreeMap::new())
.unwrap();
assert_eq!(
rs.len(),
1,
"grouped aggregate with no keys must return exactly 1 row"
);
assert_eq!(
get_val(&rs, 0, "COUNT(a)"),
Some(Value::Int(3)),
"COUNT(a) over 3 nodes must be 3"
);
}
#[test]
fn params_preflight_catches_missing_param_in_return_funccall() {
let dir = tmp("params_preflight");
let db = GraphDb::open(&dir).unwrap();
let err = db.query(
"MATCH (n:PF) RETURN toLower($val)",
&BTreeMap::new(), );
assert!(
err.is_err(),
"missing $val in RETURN FuncCall must return Err"
);
let msg = format!("{:?}", err.unwrap_err());
assert!(
msg.contains("missing") || msg.contains("val"),
"error must mention the missing parameter: {msg}"
);
}
#[test]
fn optional_match_as_first_clause_is_parse_error() {
let dir = tmp("optional_first");
let db = GraphDb::open(&dir).unwrap();
let err = db.query("OPTIONAL MATCH (a:Person) RETURN a", &BTreeMap::new());
assert!(
err.is_err(),
"OPTIONAL MATCH without preceding MATCH must fail"
);
let msg = format!("{:?}", err.unwrap_err());
assert!(
msg.contains("MATCH") || msg.contains("parse") || msg.contains("expected"),
"error must indicate a parse issue: {msg}"
);
}
#[test]
fn fn_size_non_string_non_list_is_null() {
let dir = tmp("fn_size_int");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("Si", "x", vec![("v".into(), Value::Int(42))]);
batch.commit().unwrap();
}
let rs = db
.query("MATCH (n:Si) RETURN size(n.v)", &BTreeMap::new())
.unwrap();
assert_eq!(rs.len(), 1);
assert_eq!(
get_val(&rs, 0, "size(n.v)"),
None,
"size on Int must return null"
);
}
#[test]
fn fn_type_on_derived_edge() {
let dir = tmp("fn_type_derived");
let mut db = GraphDb::open(&dir).unwrap();
db.create_rule(RuleDef {
name: "link_rule".into(),
src_label: "TypeOrg".into(),
dst_label: "TypePerson".into(),
predicate: Predicate::Overlap {
field: "tags".into(),
min: 0.1,
},
edge_type: "LINKED_TO".into(),
weight_prop: None,
max_edges: None,
approximate: false,
via_label: None,
via_edge: None,
via_dir: None,
namespace: None,
})
.unwrap();
{
let mut batch = db.batch();
batch.insert_node(
"TypeOrg",
"org1",
vec![("tags".into(), Value::List(vec![Value::Str("rust".into())]))],
);
batch.insert_node(
"TypePerson",
"person1",
vec![("tags".into(), Value::List(vec![Value::Str("rust".into())]))],
);
batch.commit().unwrap();
}
let rs = db
.query(
"MATCH (a:TypeOrg)-[r]->(b:TypePerson) RETURN type(r)",
&BTreeMap::new(),
)
.unwrap();
assert_eq!(rs.len(), 1, "derived edge must appear in MATCH");
assert_eq!(
get_val(&rs, 0, "type(r)"),
Some(Value::Str("LINKED_TO".into())),
"type(r) must return the rule's edge_type for derived edges"
);
}
#[test]
fn skip_param_does_not_route_to_pull_path() {
let dir = tmp("skip_param_pull");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
for i in 0..20u32 {
batch.insert_node("SPP", &format!("n{i:02}"), vec![]);
}
batch.commit().unwrap();
}
let rs = db
.query_with_params(
"MATCH (n:SPP) RETURN n SKIP $offset LIMIT 3",
&[("offset", Value::Int(15))],
)
.unwrap();
assert_eq!(
rs.len(),
3,
"SKIP 15 LIMIT 3 over 20 nodes must return 3 rows"
);
}
#[test]
fn limit_param_wrong_type_is_named_error() {
let dir = tmp("limit_param_type");
let db = GraphDb::open(&dir).unwrap();
let err = db.query_with_params(
"MATCH (n:LPWT) RETURN n LIMIT $cap",
&[("cap", Value::Str("five".into()))],
);
assert!(err.is_err(), "string LIMIT param must return Err");
let msg = format!("{:?}", err.unwrap_err());
assert!(
msg.contains("integer") || msg.contains("cap"),
"error must mention integer type or param name: {msg}"
);
}
#[test]
fn optional_match_with_limit_param() {
let dir = tmp("opt_match_limit_param");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
for i in 0..4u32 {
batch.insert_node("OPL", &format!("n{i}"), vec![]);
}
batch.insert_edge("KNOWS", "n0", "n1");
batch.commit().unwrap();
}
let rs = db
.query_with_params(
"MATCH (a:OPL) OPTIONAL MATCH (a)-[:KNOWS]->(b) RETURN a LIMIT $cap",
&[("cap", Value::Int(2))],
)
.unwrap();
assert_eq!(
rs.len(),
2,
"LIMIT $cap=2 must cap result to 2 rows despite OPTIONAL MATCH"
);
}
#[test]
fn abs_binarith_param_arg_happy() {
let dir = tmp("abs_param_arg");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("PB", "x", vec![]);
batch.commit().unwrap();
}
let rs = db
.query_with_params("MATCH (n:PB) RETURN abs($x - 1)", &[("x", Value::Int(5))])
.unwrap();
assert_eq!(rs.len(), 1);
assert_eq!(
get_val(&rs, 0, "abs(<arith>)"),
Some(Value::Int(4)),
"abs($x - 1) with $x=5 must return 4"
);
}
#[test]
fn abs_binarith_param_arg_missing_is_error() {
let dir = tmp("abs_param_arg_missing");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("PBM", "x", vec![]);
batch.commit().unwrap();
}
let err = db.query_with_params(
"MATCH (n:PBM) RETURN abs($x - 1)",
&[], );
assert!(err.is_err(), "missing $x must return Err");
let msg = format!("{:?}", err.unwrap_err());
assert!(
msg.contains("x") || msg.contains("parameter"),
"error must name the missing parameter: {msg}"
);
}
#[test]
fn where_in_list_and_param() {
let dir = tmp("where_in_list");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node(
"Person",
"austin",
vec![
("id".into(), Value::Str("austin".into())),
("city".into(), Value::Str("Austin".into())),
],
);
batch.insert_node(
"Person",
"paris",
vec![
("id".into(), Value::Str("paris".into())),
("city".into(), Value::Str("Paris".into())),
],
);
batch.insert_node(
"Person",
"london",
vec![
("id".into(), Value::Str("london".into())),
("city".into(), Value::Str("London".into())),
],
);
batch.commit().unwrap();
}
let mut params = BTreeMap::new();
params.insert("c".into(), Value::Str("Paris".into()));
let rs = db
.query(
"MATCH (n:Person) WHERE n.city IN ['Austin', $c] RETURN n.city AS city ORDER BY city",
¶ms,
)
.unwrap();
assert_eq!(rs.len(), 2);
assert_eq!(get_val(&rs, 0, "city"), Some(Value::Str("Austin".into())));
assert_eq!(get_val(&rs, 1, "city"), Some(Value::Str("Paris".into())));
let mut list_params = BTreeMap::new();
list_params.insert(
"cities".into(),
Value::List(vec![
Value::Str("Austin".into()),
Value::Str("Paris".into()),
]),
);
let rs2 = db
.query(
"MATCH (n:Person) WHERE n.city IN $cities RETURN n.city AS city ORDER BY city",
&list_params,
)
.unwrap();
assert_eq!(rs2.len(), 2);
assert_eq!(get_val(&rs2, 0, "city"), Some(Value::Str("Austin".into())));
assert_eq!(get_val(&rs2, 1, "city"), Some(Value::Str("Paris".into())));
}
#[test]
fn return_distinct_cities() {
let dir = tmp("return_distinct");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node(
"Person",
"a1",
vec![
("id".into(), Value::Str("a1".into())),
("city".into(), Value::Str("Austin".into())),
],
);
batch.insert_node(
"Person",
"a2",
vec![
("id".into(), Value::Str("a2".into())),
("city".into(), Value::Str("Austin".into())),
],
);
batch.commit().unwrap();
}
let rs = db
.query(
"MATCH (n:Person) RETURN DISTINCT n.city AS city",
&BTreeMap::new(),
)
.unwrap();
assert_eq!(
rs.len(),
1,
"two Austin nodes must collapse to one DISTINCT row"
);
assert_eq!(get_val(&rs, 0, "city"), Some(Value::Str("Austin".into())));
}
#[test]
fn union_distinct_and_union_all() {
let dir = tmp("union");
let mut db = GraphDb::open(&dir).unwrap();
for (label, key, id) in [("A", "ax", "x"), ("A", "ay", "y"), ("B", "bx", "x")] {
db.insert_node(label, key, vec![("id".into(), Value::Str(id.into()))])
.unwrap();
}
let rs = db
.query(
"MATCH (n:A) RETURN n.id AS id UNION MATCH (m:B) RETURN m.id AS id",
&BTreeMap::new(),
)
.unwrap();
assert_eq!(rs.len(), 2, "UNION dedups the duplicate 'x'");
let rs = db
.query(
"MATCH (n:A) RETURN n.id AS id UNION ALL MATCH (m:B) RETURN m.id AS id",
&BTreeMap::new(),
)
.unwrap();
assert_eq!(rs.len(), 3, "UNION ALL keeps the duplicate 'x'");
let err = db.query(
"MATCH (n:A) RETURN n.id AS a UNION MATCH (m:B) RETURN m.id AS b",
&BTreeMap::new(),
);
assert!(err.is_err(), "UNION with mismatched columns must error");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn abs_float_binarith_null_propagation() {
let dir = tmp("abs_float_null");
let mut db = GraphDb::open(&dir).unwrap();
{
let mut batch = db.batch();
batch.insert_node("FBN", "x", vec![]);
batch.commit().unwrap();
}
let rs = db
.query(
"MATCH (n:FBN) RETURN abs(n.missing_float - 1.5)",
&BTreeMap::new(),
)
.unwrap();
assert_eq!(
rs.len(),
1,
"one row must be produced even when BinArith arg is null"
);
assert_eq!(
get_val(&rs, 0, "abs(<arith>)"),
None,
"abs(null - 1.5) must propagate null, not error"
);
}
#[test]
fn property_index_end_to_end_and_survives_snapshot() {
let dir = tmp("prop-index-e2e");
{
let mut db = GraphDb::open(&dir).unwrap();
db.enable_index("Person", "city").unwrap();
for (k, city) in [("a", "austin"), ("b", "boston"), ("c", "austin")] {
db.insert_node("Person", k, vec![("city".into(), Value::Str(city.into()))])
.unwrap();
}
let rs = db
.query(
"MATCH (n:Person {city: 'austin'}) RETURN n",
&BTreeMap::new(),
)
.unwrap();
assert_eq!(rs.len(), 2, "two austin nodes before snapshot");
db.snapshot().unwrap();
}
let mut db = GraphDb::open(&dir).unwrap();
assert!(
db.is_index_enabled("Person", "city"),
"index declaration must survive snapshot + reopen"
);
let rs = db
.query(
"MATCH (n:Person {city: 'austin'}) RETURN n",
&BTreeMap::new(),
)
.unwrap();
assert_eq!(rs.len(), 2, "indexed query correct after reopen");
db.insert_node(
"Person",
"d",
vec![("city".into(), Value::Str("austin".into()))],
)
.unwrap();
let rs = db
.query(
"MATCH (n:Person {city: 'austin'}) RETURN n",
&BTreeMap::new(),
)
.unwrap();
assert_eq!(rs.len(), 3, "new austin node reflected via index");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn property_index_matches_unindexed_scan() {
let indexed = {
let dir = tmp("prop-index-parity-on");
let mut db = GraphDb::open(&dir).unwrap();
db.enable_index("Person", "city").unwrap();
for (k, city) in [("a", "austin"), ("b", "boston"), ("c", "austin")] {
db.insert_node("Person", k, vec![("city".into(), Value::Str(city.into()))])
.unwrap();
}
let rs = db
.query(
"MATCH (n:Person {city: 'austin'}) RETURN n",
&BTreeMap::new(),
)
.unwrap();
let n = rs.len();
let _ = std::fs::remove_dir_all(&dir);
n
};
let unindexed = {
let dir = tmp("prop-index-parity-off");
let mut db = GraphDb::open(&dir).unwrap();
for (k, city) in [("a", "austin"), ("b", "boston"), ("c", "austin")] {
db.insert_node("Person", k, vec![("city".into(), Value::Str(city.into()))])
.unwrap();
}
let rs = db
.query(
"MATCH (n:Person {city: 'austin'}) RETURN n",
&BTreeMap::new(),
)
.unwrap();
let n = rs.len();
let _ = std::fs::remove_dir_all(&dir);
n
};
assert_eq!(
indexed, unindexed,
"indexed and unindexed results must match"
);
assert_eq!(indexed, 2);
}
#[test]
fn query_at_time_travel() {
let dir = tmp("query-at");
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node("N", "a", vec![]).unwrap(); db.insert_node("N", "b", vec![]).unwrap(); db.insert_node("N", "c", vec![]).unwrap();
let at0 = db
.query_at(0, "MATCH (n:N) RETURN n", &BTreeMap::new())
.unwrap();
assert_eq!(at0.len(), 1, "after commit 0 only 'a' exists");
let at2 = db
.query_at(2, "MATCH (n:N) RETURN n", &BTreeMap::new())
.unwrap();
assert_eq!(at2.len(), 3, "after commit 2 all three exist");
let now = db.query("MATCH (n:N) RETURN n", &BTreeMap::new()).unwrap();
assert_eq!(now.len(), 3);
assert!(
db.query_at(0, "CREATE (x:N {id: 'z'})", &BTreeMap::new())
.is_err(),
"query_at must reject writes"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn decay_scalar_halves_every_halflife() {
let dir = tmp("decay");
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node("N", "x", vec![]).unwrap();
let rs = db
.query(
"MATCH (n:N) RETURN decay(1.0, 10, 10) AS a, decay(0.8, 0, 5) AS b, decay(2.0, 20, 10) AS c, decay(n.missing, 1, 1) AS d LIMIT 1",
&Default::default(),
)
.unwrap();
let a = match get_val(&rs, 0, "a") {
Some(Value::Float(v)) => v,
other => panic!("expected Float for a, got {other:?}"),
};
let b = match get_val(&rs, 0, "b") {
Some(Value::Float(v)) => v,
other => panic!("expected Float for b, got {other:?}"),
};
let c = match get_val(&rs, 0, "c") {
Some(Value::Float(v)) => v,
other => panic!("expected Float for c, got {other:?}"),
};
assert!((a - 0.5).abs() < 1e-9);
assert!((b - 0.8).abs() < 1e-9);
assert!((c - 0.5).abs() < 1e-9);
assert_eq!(rs.get(0, "d"), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn decay_rejects_bad_arity_and_halflife() {
let dir = tmp("decay-err");
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node("N", "x", vec![]).unwrap();
assert!(db
.query(
"MATCH (n:N) RETURN decay(1.0, 1) LIMIT 1",
&Default::default()
)
.is_err());
assert!(db
.query(
"MATCH (n:N) RETURN decay(1.0, 1, 0) LIMIT 1",
&Default::default()
)
.is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn key_scalar_projects_the_node_key() {
let dir = tmp("key-scalar");
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node("N", "alice", vec![("age".into(), Value::Int(30))])
.unwrap();
db.insert_node("N", "bob", vec![("age".into(), Value::Int(41))])
.unwrap();
let rs = db
.query(
"MATCH (n:N) RETURN key(n) AS k ORDER BY k",
&Default::default(),
)
.unwrap();
assert_eq!(rs.len(), 2);
assert_eq!(get_val(&rs, 0, "k"), Some(Value::Str("alice".into())));
assert_eq!(get_val(&rs, 1, "k"), Some(Value::Str("bob".into())));
let rs = db
.query(
"MATCH (n:N) WHERE key(n) = 'alice' RETURN toUpper(key(n)) AS k",
&Default::default(),
)
.unwrap();
assert_eq!(rs.len(), 1);
assert_eq!(get_val(&rs, 0, "k"), Some(Value::Str("ALICE".into())));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn key_scalar_null_binding_from_optional_match() {
let dir = tmp("key-null");
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node("Kn", "alice", vec![]).unwrap();
let rs = db
.query(
"MATCH (a:Kn) OPTIONAL MATCH (a)-[:KNOWS]->(b) RETURN key(a) AS ka, key(b) AS kb",
&Default::default(),
)
.unwrap();
assert_eq!(rs.len(), 1);
assert_eq!(get_val(&rs, 0, "ka"), Some(Value::Str("alice".into())));
assert_eq!(get_val(&rs, 0, "kb"), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn key_scalar_in_write_statement_return() {
let dir = tmp("key-write");
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node("N", "alice", vec![("age".into(), Value::Int(30))])
.unwrap();
let rs = db
.query_write(
"MATCH (n:N) SET n.age = 31 RETURN key(n) AS k",
&Default::default(),
)
.unwrap();
assert_eq!(get_val(&rs, 0, "k"), Some(Value::Str("alice".into())));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn key_scalar_rejects_non_node_arguments() {
let dir = tmp("key-err");
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node("N", "alice", vec![("age".into(), Value::Int(30))])
.unwrap();
db.insert_node("N", "bob", vec![("age".into(), Value::Int(41))])
.unwrap();
db.insert_edge("KNOWS", "alice", "bob").unwrap();
assert!(db
.query("MATCH (n:N) RETURN key(n.age) AS k", &Default::default())
.is_err());
assert!(db
.query("MATCH (n:N) RETURN key(n, n) AS k", &Default::default())
.is_err());
assert!(db
.query(
"MATCH (a:N)-[r:KNOWS]->(b:N) RETURN key(r) AS k",
&Default::default()
)
.is_err());
assert!(db
.query_write(
"MATCH (a:N)-[r:KNOWS]->(b:N) SET a.age = 1 RETURN key(r) AS k",
&Default::default()
)
.is_err());
let _ = std::fs::remove_dir_all(&dir);
}