use std::collections::HashSet;
use marsdb_graph::{GraphStore, Txn};
use crate::ast::{
CompareOp, Expr, Literal, NodePattern, Pattern, PropAccess, RelDirection, ReturnExpr,
};
use crate::error::QueryError;
use crate::executor::literal_to_value;
use crate::ir::{ExpandDirection, LogicalPlan};
struct VarNamer {
next: usize,
}
impl VarNamer {
fn new() -> Self {
Self { next: 0 }
}
fn name(&mut self, given: &Option<String>) -> String {
match given {
Some(v) => v.clone(),
None => {
let n = format!("__anon{}", self.next);
self.next += 1;
n
}
}
}
}
pub fn build_match_plan(
pattern: &Pattern,
where_clause: &Option<Expr>,
carried_vars: &HashSet<String>,
) -> Result<LogicalPlan, QueryError> {
let mut namer = VarNamer::new();
let start_var = namer.name(&pattern.start.var);
let mut plan = if carried_vars.contains(&start_var) {
wrap_labels_and_props(
LogicalPlan::Seed {
var: start_var.clone(),
},
&start_var,
&pattern.start,
0,
)?
} else {
scan_for(&start_var, &pattern.start)?
};
let mut from_var = start_var.clone();
let mut prior_rel_vars: Vec<String> = Vec::new();
let mut prior_edge_sets: Vec<String> = Vec::new();
let mut pattern_bound_vars: HashSet<String> = HashSet::new();
pattern_bound_vars.insert(start_var);
let mut pattern_rel_var_names: HashSet<String> = HashSet::new();
for (rel, node) in &pattern.hops {
let is_repeat = node
.var
.as_ref()
.is_some_and(|v| carried_vars.contains(v) || pattern_bound_vars.contains(v));
let to_var = if is_repeat {
namer.name(&None)
} else {
namer.name(&node.var)
};
if !is_repeat {
if let Some(v) = &node.var {
pattern_bound_vars.insert(v.clone());
}
}
let direction = match rel.direction {
RelDirection::Right => ExpandDirection::Out,
RelDirection::Left => ExpandDirection::In,
RelDirection::Either => ExpandDirection::Either,
};
if let Some(v) = &rel.var {
if pattern_rel_var_names.contains(v) {
return Err(QueryError::Semantic(format!(
"'{v}' is used for two different relationships in the same pattern — a relationship \
variable can't be reused within one MATCH pattern"
)));
}
if !carried_vars.contains(v) {
pattern_rel_var_names.insert(v.clone());
}
}
let rel_is_repeat = rel.var.as_ref().is_some_and(|v| carried_vars.contains(v));
let rel_filter_var = if rel.hop_range.is_none() {
Some(if rel_is_repeat {
namer.name(&None)
} else {
namer.name(&rel.var)
})
} else {
None
};
plan = match rel.hop_range {
None => LogicalPlan::Expand {
input: Box::new(plan),
from_var: from_var.clone(),
to_var: to_var.clone(),
rel_var: rel_filter_var.clone(),
rel_labels: rel.rel_types.clone(),
direction,
},
Some((min_hops, max_hops)) if rel_is_repeat && !rel.capture_path_segment => {
LogicalPlan::MatchRelList {
input: Box::new(plan),
from_var: from_var.clone(),
to_var: to_var.clone(),
rel_list_var: rel
.var
.clone()
.expect("rel_is_repeat implies rel.var is Some"),
rel_labels: rel.rel_types.clone(),
direction,
min_hops,
max_hops,
}
}
Some((min_hops, max_hops)) => {
let exclude_edge_var = namer.name(&None);
let plan = LogicalPlan::VarExpand {
input: Box::new(plan),
from_var: from_var.clone(),
to_var: to_var.clone(),
rel_labels: rel.rel_types.clone(),
direction,
min_hops,
max_hops,
exclude_edge_vars: prior_rel_vars.clone(),
exclude_edge_sets: prior_edge_sets.clone(),
exclude_edge_var: exclude_edge_var.clone(),
path_segment_var: rel.capture_path_segment.then(|| {
rel.var
.clone()
.expect("name_pattern_for_path always sets rel.var alongside capture_path_segment")
}),
rel_list_var: if rel.capture_path_segment {
rel.rel_list_var.clone()
} else {
rel.var.clone()
},
rel_props: rel.props.clone(),
};
prior_edge_sets.push(exclude_edge_var);
plan
}
};
plan = wrap_labels_and_props(plan, &to_var, node, 0)?;
if let Some(rel_var) = &rel_filter_var {
for (key, expr) in &rel.props {
plan = LogicalPlan::Filter {
input: Box::new(plan),
predicate: pattern_prop_predicate(rel_var, key, expr),
};
}
for prior in &prior_rel_vars {
plan = LogicalPlan::Filter {
input: Box::new(plan),
predicate: Expr::Not(Box::new(Expr::VarEq(rel_var.clone(), prior.clone()))),
};
}
for prior_set in &prior_edge_sets {
plan = LogicalPlan::Filter {
input: Box::new(plan),
predicate: Expr::EdgeNotInSet {
edge_var: rel_var.clone(),
edge_set_var: prior_set.clone(),
},
};
}
if rel_is_repeat {
let original = rel
.var
.clone()
.expect("rel_is_repeat implies rel.var is Some");
plan = LogicalPlan::Filter {
input: Box::new(plan),
predicate: Expr::VarEq(rel_var.clone(), original),
};
}
if rel.hop_range.is_none() {
prior_rel_vars.push(rel_var.clone());
}
}
if is_repeat {
let original = node
.var
.clone()
.expect("is_repeat implies node.var is Some");
plan = LogicalPlan::Filter {
input: Box::new(plan),
predicate: Expr::VarEq(to_var.clone(), original),
};
}
from_var = to_var;
}
if let Some(expr) = where_clause {
plan = LogicalPlan::Filter {
input: Box::new(plan),
predicate: expr.clone(),
};
}
Ok(plan)
}
fn scan_for(var: &str, node: &NodePattern) -> Result<LogicalPlan, QueryError> {
let base = match node.labels.first() {
Some(label) => LogicalPlan::NodeByLabelScan {
var: var.to_string(),
label: label.clone(),
},
None => LogicalPlan::AllNodesScan {
var: var.to_string(),
},
};
wrap_labels_and_props(base, var, node, 1)
}
fn wrap_labels_and_props(
plan: LogicalPlan,
var: &str,
node: &NodePattern,
skip: usize,
) -> Result<LogicalPlan, QueryError> {
let mut plan = plan;
for label in node.labels.iter().skip(skip) {
plan = LogicalPlan::Filter {
input: Box::new(plan),
predicate: Expr::HasLabel(var.to_string(), label.clone()),
};
}
for (key, expr) in &node.props {
plan = LogicalPlan::Filter {
input: Box::new(plan),
predicate: pattern_prop_predicate(var, key, expr),
};
}
Ok(plan)
}
fn push_conjuncts(expr: Expr, out: &mut Vec<Expr>) {
match expr {
Expr::And(l, r) => {
push_conjuncts(*l, out);
push_conjuncts(*r, out);
}
other => out.push(other),
}
}
fn rebuild_and(mut exprs: Vec<Expr>) -> Option<Expr> {
let first = exprs.pop()?;
Some(
exprs
.into_iter()
.fold(first, |acc, e| Expr::And(Box::new(e), Box::new(acc))),
)
}
fn pattern_prop_predicate(var: &str, key: &str, expr: &ReturnExpr) -> Expr {
let access = PropAccess {
var: var.to_string(),
prop: key.to_string(),
};
match expr {
ReturnExpr::Lit(lit) => Expr::Compare(access, CompareOp::Eq, lit.clone()),
other => Expr::GeneralCompare(ReturnExpr::Prop(access), CompareOp::Eq, other.clone()),
}
}
pub fn pattern_all_vars(pattern: &Pattern) -> HashSet<String> {
let mut vars = HashSet::new();
if let Some(v) = &pattern.start.var {
vars.insert(v.clone());
}
for (rel, node) in &pattern.hops {
if let Some(v) = &rel.var {
vars.insert(v.clone());
}
if let Some(v) = &rel.rel_list_var {
vars.insert(v.clone());
}
if let Some(v) = &node.var {
vars.insert(v.clone());
}
}
vars
}
pub fn pattern_new_vars(pattern: &Pattern, carried_vars: &HashSet<String>) -> HashSet<String> {
pattern_all_vars(pattern)
.into_iter()
.filter(|v| !carried_vars.contains(v))
.collect()
}
pub fn apply_index_seeks(plan: LogicalPlan, txn: Txn) -> Result<LogicalPlan, QueryError> {
Ok(match plan {
LogicalPlan::Filter { .. } => {
let mut node = plan;
let mut candidates = Vec::new();
let base = loop {
match node {
LogicalPlan::Filter { input, predicate } => {
push_conjuncts(predicate, &mut candidates);
node = *input;
}
other => break other,
}
};
let base = apply_index_seeks(base, txn)?;
if let LogicalPlan::NodeByLabelScan { var, label } = &base {
let mut chosen: Option<(usize, u64)> = None;
for (i, c) in candidates.iter().enumerate() {
let Expr::Compare(pa, CompareOp::Eq, lit) = c else {
continue;
};
if pa.var != *var || matches!(lit, Literal::Param(_)) {
continue;
}
if GraphStore::index_def_in_txn(txn, label, &pa.prop)?.is_some() {
let value = literal_to_value(lit);
let count =
GraphStore::index_match_count_in_txn(txn, label, &pa.prop, &value)?;
if chosen.is_none_or(|(_, best)| count < best) {
chosen = Some((i, count));
}
}
}
if let Some((i, _)) = chosen {
let Expr::Compare(pa, _, lit) = candidates.remove(i) else {
unreachable!("chosen index always points at a Compare, checked above")
};
let seek = LogicalPlan::IndexSeek {
var: var.clone(),
label: label.clone(),
prop: pa.prop,
value: literal_to_value(&lit),
};
return Ok(match rebuild_and(candidates) {
Some(predicate) => LogicalPlan::Filter {
input: Box::new(seek),
predicate,
},
None => seek,
});
}
}
match rebuild_and(candidates) {
Some(predicate) => LogicalPlan::Filter {
input: Box::new(base),
predicate,
},
None => base,
}
}
LogicalPlan::Expand {
input,
from_var,
to_var,
rel_var,
rel_labels,
direction,
} => LogicalPlan::Expand {
input: Box::new(apply_index_seeks(*input, txn)?),
from_var,
to_var,
rel_var,
rel_labels,
direction,
},
LogicalPlan::VarExpand {
input,
from_var,
to_var,
rel_labels,
direction,
min_hops,
max_hops,
exclude_edge_vars,
exclude_edge_sets,
exclude_edge_var,
path_segment_var,
rel_list_var,
rel_props,
} => LogicalPlan::VarExpand {
input: Box::new(apply_index_seeks(*input, txn)?),
from_var,
to_var,
rel_labels,
direction,
min_hops,
max_hops,
exclude_edge_vars,
exclude_edge_sets,
exclude_edge_var,
path_segment_var,
rel_list_var,
rel_props,
},
LogicalPlan::MatchRelList {
input,
from_var,
to_var,
rel_list_var,
rel_labels,
direction,
min_hops,
max_hops,
} => LogicalPlan::MatchRelList {
input: Box::new(apply_index_seeks(*input, txn)?),
from_var,
to_var,
rel_list_var,
rel_labels,
direction,
min_hops,
max_hops,
},
leaf @ (LogicalPlan::AllNodesScan { .. }
| LogicalPlan::NodeByLabelScan { .. }
| LogicalPlan::Seed { .. }
| LogicalPlan::IndexSeek { .. }) => leaf,
})
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use marsdb_graph::{GraphStore, PropertyValue, Txn};
use super::*;
use crate::ast::{QueryClause, Statement};
fn pattern_from(cypher: &str) -> crate::ast::Pattern {
part_from(cypher).pattern
}
fn part_from(cypher: &str) -> crate::ast::QueryPart {
let Statement::Match { clauses, .. } = crate::antlr_visitor::parse_antlr(cypher).unwrap()
else {
panic!("expected a Match statement");
};
let QueryClause::Match(part) = clauses.into_iter().next().unwrap() else {
panic!("expected a Match clause");
};
part
}
#[test]
fn fuses_node_pattern_property_into_index_seek_when_an_index_exists() {
let store = GraphStore::open_memory().unwrap();
store.create_index("Person", "email", false).unwrap();
let pattern = pattern_from("MATCH (n:Person {email: 'alice@x.com'}) RETURN n");
let write = store.begin_write().unwrap();
let plan = build_match_plan(&pattern, &None, &Default::default()).unwrap();
let plan = apply_index_seeks(plan, Txn::Write(&write)).unwrap();
match plan {
LogicalPlan::IndexSeek {
var,
label,
prop,
value,
} => {
assert_eq!(var, "n");
assert_eq!(label, "Person");
assert_eq!(prop, "email");
assert_eq!(value, PropertyValue::String("alice@x.com".to_string()));
}
other => panic!("expected an IndexSeek, got {other:?}"),
}
}
#[test]
fn falls_back_to_filter_over_scan_when_no_index_exists() {
let store = GraphStore::open_memory().unwrap();
let pattern = pattern_from("MATCH (n:Person {email: 'alice@x.com'}) RETURN n");
let write = store.begin_write().unwrap();
let plan = build_match_plan(&pattern, &None, &Default::default()).unwrap();
let plan = apply_index_seeks(plan, Txn::Write(&write)).unwrap();
match plan {
LogicalPlan::Filter { input, .. } => {
assert!(matches!(*input, LogicalPlan::NodeByLabelScan { .. }));
}
other => panic!("expected a Filter over a scan, got {other:?}"),
}
}
#[test]
fn fuses_a_where_clause_equality_into_index_seek() {
let store = GraphStore::open_memory().unwrap();
store.create_index("Person", "email", false).unwrap();
let part = part_from("MATCH (n:Person) WHERE n.email = 'alice@x.com' RETURN n");
let write = store.begin_write().unwrap();
let plan =
build_match_plan(&part.pattern, &part.where_clause, &Default::default()).unwrap();
let plan = apply_index_seeks(plan, Txn::Write(&write)).unwrap();
match plan {
LogicalPlan::IndexSeek {
var,
label,
prop,
value,
} => {
assert_eq!(var, "n");
assert_eq!(label, "Person");
assert_eq!(prop, "email");
assert_eq!(value, PropertyValue::String("alice@x.com".to_string()));
}
other => panic!("expected an IndexSeek, got {other:?}"),
}
}
#[test]
fn seeks_one_equality_and_keeps_the_other_conjunct_as_a_residual_filter() {
let store = GraphStore::open_memory().unwrap();
store.create_index("Person", "email", false).unwrap();
let part =
part_from("MATCH (n:Person) WHERE n.email = 'alice@x.com' AND n.age > 35 RETURN n");
let write = store.begin_write().unwrap();
let plan =
build_match_plan(&part.pattern, &part.where_clause, &Default::default()).unwrap();
let plan = apply_index_seeks(plan, Txn::Write(&write)).unwrap();
match plan {
LogicalPlan::Filter { input, predicate } => {
assert!(
matches!(*input, LogicalPlan::IndexSeek { .. }),
"expected the seek underneath"
);
match predicate {
Expr::Compare(pa, CompareOp::Gt, Literal::Int(35)) => {
assert_eq!(pa.prop, "age")
}
other => panic!("expected the residual age > 35 predicate, got {other:?}"),
}
}
other => panic!("expected a residual Filter over an IndexSeek, got {other:?}"),
}
}
#[test]
fn picks_the_more_selective_index_when_multiple_equality_candidates_are_indexed() {
let store = GraphStore::open_memory().unwrap();
store.create_index("Person", "country", false).unwrap();
store.create_index("Person", "email", false).unwrap();
for i in 0..20 {
let mut props = BTreeMap::new();
props.insert(
"country".to_string(),
PropertyValue::String("US".to_string()),
);
props.insert(
"email".to_string(),
PropertyValue::String(format!("user{i}@x.com")),
);
store.create_node(&["Person"], props).unwrap();
}
let part = part_from(
"MATCH (n:Person) WHERE n.country = 'US' AND n.email = 'user7@x.com' RETURN n",
);
let write = store.begin_write().unwrap();
let plan =
build_match_plan(&part.pattern, &part.where_clause, &Default::default()).unwrap();
let plan = apply_index_seeks(plan, Txn::Write(&write)).unwrap();
match plan {
LogicalPlan::Filter { input, predicate } => {
match *input {
LogicalPlan::IndexSeek { prop, value, .. } => {
assert_eq!(prop, "email");
assert_eq!(value, PropertyValue::String("user7@x.com".to_string()));
}
other => panic!("expected the seek underneath, got {other:?}"),
}
match predicate {
Expr::Compare(pa, CompareOp::Eq, Literal::String(s)) => {
assert_eq!(pa.prop, "country");
assert_eq!(s, "US");
}
other => panic!("expected the residual country predicate, got {other:?}"),
}
}
other => panic!("expected a residual Filter over an IndexSeek, got {other:?}"),
}
}
}