use surrealdb_types::{SqlFormat, ToSql};
use crate::expr::{Expr, Idiom};
use crate::val::TableName;
pub(crate) type BindingId = u32;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct MatchPlan {
pub(crate) bindings: Vec<BindingDef>,
pub(crate) stages: Vec<MatchStage>,
pub(crate) output: Option<MatchOutput>,
}
impl MatchPlan {
pub(crate) fn has_mutations(&self) -> bool {
self.stages.iter().any(|s| matches!(s, MatchStage::Mutate(_)))
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) enum MatchStage {
Read(MatchClausePlan),
Mutate(MutationStage),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) enum MutationStage {
Update {
target: BindingId,
data: UpdateData,
},
Delete {
target: BindingId,
detach: DetachMode,
},
Insert(InsertStage),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) enum UpdateData {
Set(Vec<(Idiom, Expr)>),
Unset(Vec<Idiom>),
Content(Expr),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum DetachMode {
Detach,
NoDetach,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct InsertStage {
pub(crate) nodes: Vec<InsertNodePlan>,
pub(crate) edges: Vec<InsertEdgePlan>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct InsertNodePlan {
pub(crate) binding: BindingId,
pub(crate) label: TableName,
pub(crate) props: Expr,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct InsertEdgePlan {
pub(crate) binding: BindingId,
pub(crate) label: TableName,
pub(crate) from: BindingId,
pub(crate) to: BindingId,
pub(crate) props: Expr,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct BindingDef {
pub(crate) name: String,
pub(crate) kind: BindingKind,
pub(crate) user_named: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum BindingKind {
Node,
Edge,
EdgeGroup,
Path,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct MatchClausePlan {
pub(crate) optional_group: Option<u32>,
pub(crate) patterns: Vec<PatternPlan>,
pub(crate) predicates: Vec<MatchPredicate>,
}
impl MatchClausePlan {
pub(crate) fn is_optional(&self) -> bool {
self.optional_group.is_some()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct PatternPlan {
pub(crate) path_var: Option<BindingId>,
pub(crate) search: Option<PathPrefixPlan>,
pub(crate) start: NodeStep,
pub(crate) steps: Vec<(EdgeStep, NodeStep)>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct NodeStep {
pub(crate) binding: BindingId,
pub(crate) label: Option<TableName>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct EdgeStep {
pub(crate) binding: BindingId,
pub(crate) label: Option<TableName>,
pub(crate) direction: ExpandDirection,
pub(crate) quantifier: Option<EdgeQuantifier>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum ExpandDirection {
Out,
In,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) struct EdgeQuantifier {
pub(crate) min: u32,
pub(crate) max: Option<u32>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) struct PathPrefixPlan {
pub(crate) search: PathSearch,
pub(crate) mode: PathMode,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum PathSearch {
All,
Any {
count: u32,
},
AllShortest,
AnyShortest,
ShortestCounted {
count: u32,
},
ShortestGroups {
count: u32,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum PathMode {
Walk,
Trail,
Simple,
Acyclic,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct MatchPredicate {
pub(crate) expr: Expr,
pub(crate) deps: Vec<BindingId>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct MatchOutput {
pub(crate) columns: Vec<MatchColumn>,
pub(crate) distinct: bool,
pub(crate) group_by: Option<Vec<Expr>>,
pub(crate) order: Vec<MatchOrder>,
pub(crate) skip: Option<Expr>,
pub(crate) limit: Option<Expr>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct MatchColumn {
pub(crate) name: String,
pub(crate) expr: Expr,
pub(crate) hidden: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct MatchOrder {
pub(crate) expr: Expr,
pub(crate) ascending: bool,
}
impl MatchPlan {
pub(crate) fn binding(&self, id: BindingId) -> &BindingDef {
&self.bindings[id as usize]
}
fn binding_name(&self, id: BindingId) -> &str {
self.bindings.get(id as usize).map(|b| b.name.as_str()).unwrap_or("?")
}
}
impl EdgeQuantifier {
pub(crate) fn is_exact(&self) -> bool {
self.max == Some(self.min)
}
}
impl ExpandDirection {
pub(crate) fn reverse(self) -> Self {
match self {
ExpandDirection::Out => ExpandDirection::In,
ExpandDirection::In => ExpandDirection::Out,
}
}
}
impl ToSql for MatchPlan {
fn fmt_sql(&self, f: &mut String, _fmt: SqlFormat) {
for (i, stage) in self.stages.iter().enumerate() {
if i > 0 {
f.push(' ');
}
match stage {
MatchStage::Read(clause) => self.render_clause(f, clause),
MatchStage::Mutate(mutation) => self.render_mutation(f, mutation),
}
}
let Some(output) = self.output.as_ref() else {
return;
};
if !self.stages.is_empty() {
f.push(' ');
}
f.push_str("RETURN");
if output.distinct {
f.push_str(" DISTINCT");
}
for (i, column) in output.columns.iter().enumerate() {
if i > 0 {
f.push(',');
}
f.push(' ');
column.expr.fmt_sql(f, SqlFormat::SingleLine);
f.push_str(" AS ");
f.push_str(&column.name);
}
if let Some(keys) = output.group_by.as_ref()
&& !keys.is_empty()
{
f.push_str(" GROUP BY");
for (i, key) in keys.iter().enumerate() {
if i > 0 {
f.push(',');
}
f.push(' ');
key.fmt_sql(f, SqlFormat::SingleLine);
}
}
if !output.order.is_empty() {
f.push_str(" ORDER BY");
for (i, order) in output.order.iter().enumerate() {
if i > 0 {
f.push(',');
}
f.push(' ');
order.expr.fmt_sql(f, SqlFormat::SingleLine);
f.push_str(if order.ascending {
" ASC"
} else {
" DESC"
});
}
}
if let Some(skip) = output.skip.as_ref() {
f.push_str(" SKIP ");
skip.fmt_sql(f, SqlFormat::SingleLine);
}
if let Some(limit) = output.limit.as_ref() {
f.push_str(" LIMIT ");
limit.fmt_sql(f, SqlFormat::SingleLine);
}
}
}
impl MatchPlan {
fn render_clause(&self, f: &mut String, clause: &MatchClausePlan) {
if clause.is_optional() {
f.push_str("OPTIONAL ");
}
f.push_str("MATCH ");
for (i, pattern) in clause.patterns.iter().enumerate() {
if i > 0 {
f.push_str(", ");
}
self.render_pattern(f, pattern);
}
if !clause.predicates.is_empty() {
f.push_str(" WHERE ");
for (i, predicate) in clause.predicates.iter().enumerate() {
if i > 0 {
f.push_str(" AND ");
}
predicate.expr.fmt_sql(f, SqlFormat::SingleLine);
}
}
}
fn render_mutation(&self, f: &mut String, stage: &MutationStage) {
match stage {
MutationStage::Update {
target,
data,
} => match data {
UpdateData::Set(assignments) => {
f.push_str("SET");
for (i, (place, value)) in assignments.iter().enumerate() {
if i > 0 {
f.push(',');
}
f.push(' ');
place.fmt_sql(f, SqlFormat::SingleLine);
f.push_str(" = ");
value.fmt_sql(f, SqlFormat::SingleLine);
}
}
UpdateData::Unset(fields) => {
f.push_str("REMOVE");
for (i, place) in fields.iter().enumerate() {
if i > 0 {
f.push(',');
}
f.push(' ');
place.fmt_sql(f, SqlFormat::SingleLine);
}
}
UpdateData::Content(expr) => {
f.push_str("SET ");
f.push_str(self.binding_name(*target));
f.push_str(" = ");
expr.fmt_sql(f, SqlFormat::SingleLine);
}
},
MutationStage::Delete {
target,
detach,
} => {
if matches!(detach, DetachMode::Detach) {
f.push_str("DETACH ");
}
f.push_str("DELETE ");
f.push_str(self.binding_name(*target));
}
MutationStage::Insert(stage) => {
f.push_str("INSERT");
for node in stage.nodes.iter() {
f.push_str(" (");
f.push_str(self.binding_name(node.binding));
f.push(':');
node.label.fmt_sql(f, SqlFormat::SingleLine);
f.push(' ');
node.props.fmt_sql(f, SqlFormat::SingleLine);
f.push(')');
}
for edge in stage.edges.iter() {
f.push(' ');
f.push_str(self.binding_name(edge.from));
f.push_str("-[");
f.push_str(self.binding_name(edge.binding));
f.push(':');
edge.label.fmt_sql(f, SqlFormat::SingleLine);
f.push_str("]->");
f.push_str(self.binding_name(edge.to));
}
}
}
}
fn render_pattern(&self, f: &mut String, pattern: &PatternPlan) {
if let Some(path_var) = pattern.path_var {
f.push_str(self.binding_name(path_var));
f.push_str(" = ");
}
if let Some(prefix) = pattern.search.as_ref() {
Self::render_prefix(f, prefix);
}
self.render_node(f, &pattern.start);
for (edge, node) in pattern.steps.iter() {
self.render_edge(f, edge);
self.render_node(f, node);
}
}
fn render_prefix(f: &mut String, prefix: &PathPrefixPlan) {
match prefix.search {
PathSearch::All => f.push_str("ALL"),
PathSearch::Any {
count,
} => {
f.push_str("ANY");
if count != 1 {
f.push(' ');
f.push_str(&count.to_string());
}
}
PathSearch::AllShortest => f.push_str("ALL SHORTEST"),
PathSearch::AnyShortest => f.push_str("ANY SHORTEST"),
PathSearch::ShortestCounted {
count,
} => {
f.push_str("SHORTEST ");
f.push_str(&count.to_string());
}
PathSearch::ShortestGroups {
count,
} => {
f.push_str("SHORTEST ");
f.push_str(&count.to_string());
f.push_str(if count == 1 {
" GROUP"
} else {
" GROUPS"
});
}
}
match prefix.mode {
PathMode::Walk => {}
PathMode::Trail => f.push_str(" TRAIL"),
PathMode::Simple => f.push_str(" SIMPLE"),
PathMode::Acyclic => f.push_str(" ACYCLIC"),
}
f.push(' ');
}
fn render_node(&self, f: &mut String, node: &NodeStep) {
f.push('(');
let def = self.bindings.get(node.binding as usize);
if let Some(def) = def
&& def.user_named
{
f.push_str(&def.name);
}
if let Some(label) = node.label.as_ref() {
f.push(':');
label.fmt_sql(f, SqlFormat::SingleLine);
}
f.push(')');
}
fn render_edge(&self, f: &mut String, edge: &EdgeStep) {
match edge.direction {
ExpandDirection::Out => f.push('-'),
ExpandDirection::In => f.push_str("<-"),
}
f.push('[');
let def = self.bindings.get(edge.binding as usize);
if let Some(def) = def
&& def.user_named
{
f.push_str(&def.name);
}
if let Some(label) = edge.label.as_ref() {
f.push(':');
label.fmt_sql(f, SqlFormat::SingleLine);
}
f.push(']');
match edge.direction {
ExpandDirection::Out => f.push_str("->"),
ExpandDirection::In => f.push('-'),
}
if let Some(quantifier) = edge.quantifier.as_ref() {
self.render_quantifier(f, quantifier);
}
}
fn render_quantifier(&self, f: &mut String, quantifier: &EdgeQuantifier) {
f.push('{');
if quantifier.is_exact() {
f.push_str(&quantifier.min.to_string());
} else {
f.push_str(&quantifier.min.to_string());
f.push(',');
if let Some(max) = quantifier.max {
f.push_str(&max.to_string());
}
}
f.push('}');
}
}
#[cfg(test)]
mod tests {
use surrealdb_types::ToSql;
use super::*;
use crate::expr::{Expr, Idiom, Literal, Part};
fn stages_of(clauses: Vec<MatchClausePlan>) -> Vec<MatchStage> {
clauses.into_iter().map(MatchStage::Read).collect()
}
fn clause_mut(plan: &mut MatchPlan, i: usize) -> &mut MatchClausePlan {
match &mut plan.stages[i] {
MatchStage::Read(c) => c,
MatchStage::Mutate(_) => panic!("stage {i} is not a read clause"),
}
}
fn clause(plan: &MatchPlan, i: usize) -> &MatchClausePlan {
match &plan.stages[i] {
MatchStage::Read(c) => c,
MatchStage::Mutate(_) => panic!("stage {i} is not a read clause"),
}
}
fn field_path(binding: &str, field: &str) -> Expr {
Expr::Idiom(Idiom(vec![
Part::Field(binding.to_string().into()),
Part::Field(field.to_string().into()),
]))
}
fn sample_plan() -> MatchPlan {
let bindings = vec![
BindingDef {
name: "a".to_string(),
kind: BindingKind::Node,
user_named: true,
},
BindingDef {
name: "k".to_string(),
kind: BindingKind::Edge,
user_named: true,
},
BindingDef {
name: "b".to_string(),
kind: BindingKind::Node,
user_named: true,
},
];
let predicate = MatchPredicate {
expr: Expr::Binary {
left: Box::new(field_path("k", "since")),
op: crate::expr::BinaryOperator::MoreThan,
right: Box::new(Expr::Literal(Literal::Integer(2020))),
},
deps: vec![1],
};
let pattern = PatternPlan {
path_var: None,
search: None,
start: NodeStep {
binding: 0,
label: Some(TableName::new("person".to_string())),
},
steps: vec![(
EdgeStep {
binding: 1,
label: Some(TableName::new("knows".to_string())),
direction: ExpandDirection::Out,
quantifier: None,
},
NodeStep {
binding: 2,
label: Some(TableName::new("person".to_string())),
},
)],
};
MatchPlan {
bindings,
stages: stages_of(vec![MatchClausePlan {
optional_group: None,
patterns: vec![pattern],
predicates: vec![predicate],
}]),
output: Some(MatchOutput {
columns: vec![
MatchColumn {
name: "a_name".to_string(),
expr: field_path("a", "name"),
hidden: false,
},
MatchColumn {
name: "b_name".to_string(),
expr: field_path("b", "name"),
hidden: false,
},
],
distinct: false,
group_by: None,
order: Vec::new(),
skip: None,
limit: None,
}),
}
}
#[test]
fn to_sql_renders_single_pattern() {
let plan = sample_plan();
assert_eq!(
plan.to_sql(),
"MATCH (a:person)-[k:knows]->(b:person) WHERE k.since > 2020 RETURN a.name AS \
a_name, b.name AS b_name"
);
}
#[test]
fn to_sql_renders_distinct_order_quantifier_path() {
let mut plan = sample_plan();
plan.bindings.push(BindingDef {
name: "p".to_string(),
kind: BindingKind::Path,
user_named: true,
});
plan.bindings[1].kind = BindingKind::EdgeGroup;
let path_id = (plan.bindings.len() - 1) as BindingId;
let clause = clause_mut(&mut plan, 0);
clause.patterns[0].path_var = Some(path_id);
clause.patterns[0].steps[0].0.quantifier = Some(EdgeQuantifier {
min: 1,
max: Some(3),
});
clause.predicates.clear();
let output = plan.output.as_mut().unwrap();
output.distinct = true;
output.order = vec![MatchOrder {
expr: field_path("a", "age"),
ascending: false,
}];
output.skip = Some(Expr::Literal(Literal::Integer(5)));
output.limit = Some(Expr::Literal(Literal::Integer(10)));
assert_eq!(
plan.to_sql(),
"MATCH p = (a:person)-[k:knows]->{1,3}(b:person) RETURN DISTINCT a.name AS a_name, \
b.name AS b_name ORDER BY a.age DESC SKIP 5 LIMIT 10"
);
}
#[test]
fn to_sql_renders_path_search_prefix() {
let mut plan = sample_plan();
plan.bindings[1].kind = BindingKind::EdgeGroup;
clause_mut(&mut plan, 0).patterns[0].steps[0].0.quantifier = Some(EdgeQuantifier {
min: 1,
max: None,
});
clause_mut(&mut plan, 0).patterns[0].search = Some(PathPrefixPlan {
search: PathSearch::AnyShortest,
mode: PathMode::Simple,
});
clause_mut(&mut plan, 0).predicates.clear();
plan.output.as_mut().unwrap().columns.truncate(1);
assert_eq!(
plan.to_sql(),
"MATCH ANY SHORTEST SIMPLE (a:person)-[k:knows]->{1,}(b:person) RETURN a.name AS a_name"
);
}
#[test]
fn to_sql_omits_default_search_prefix() {
let plan = sample_plan();
assert!(clause(&plan, 0).patterns[0].search.is_none());
let rendered = plan.to_sql();
assert!(!rendered.contains("SHORTEST"), "{rendered}");
assert!(!rendered.contains("ANY"), "{rendered}");
assert!(rendered.starts_with("MATCH (a:person)"), "{rendered}");
}
#[test]
fn to_sql_renders_shortest_group_count() {
let mut plan = sample_plan();
plan.bindings[1].kind = BindingKind::EdgeGroup;
clause_mut(&mut plan, 0).patterns[0].steps[0].0.quantifier = Some(EdgeQuantifier {
min: 1,
max: None,
});
clause_mut(&mut plan, 0).patterns[0].search = Some(PathPrefixPlan {
search: PathSearch::ShortestGroups {
count: 2,
},
mode: PathMode::Walk,
});
clause_mut(&mut plan, 0).predicates.clear();
plan.output.as_mut().unwrap().columns.truncate(1);
assert_eq!(
plan.to_sql(),
"MATCH SHORTEST 2 GROUPS (a:person)-[k:knows]->{1,}(b:person) RETURN a.name AS a_name"
);
}
#[test]
fn to_sql_renders_hidden_bindings_anonymously() {
let mut plan = sample_plan();
plan.bindings[1].user_named = false;
plan.bindings[1].name = "__e0".to_string();
clause_mut(&mut plan, 0).predicates.clear();
plan.output.as_mut().unwrap().columns.truncate(1);
assert_eq!(plan.to_sql(), "MATCH (a:person)-[:knows]->(b:person) RETURN a.name AS a_name");
}
#[test]
fn debug_does_not_panic() {
let plan = sample_plan();
let rendered = format!("{plan:?}");
assert!(rendered.contains("MatchPlan"));
}
#[test]
fn binding_accessor_returns_def() {
let plan = sample_plan();
assert_eq!(plan.binding(1).name, "k");
assert_eq!(plan.binding(1).kind, BindingKind::Edge);
}
#[test]
fn to_sql_tolerates_out_of_range_binding() {
let mut plan = sample_plan();
clause_mut(&mut plan, 0).patterns[0].start.binding = 99;
let rendered = plan.to_sql();
assert!(rendered.contains("(:person)"));
}
#[test]
fn revisioned_serialize_of_match_fails_loud() {
use revision::SerializeRevisioned;
let expr = Expr::Match(Box::new(sample_plan()));
let mut bytes = Vec::new();
let serialize = std::panic::AssertUnwindSafe(|| {
let _ = SerializeRevisioned::serialize_revisioned(&expr, &mut bytes);
});
let outcome = std::panic::catch_unwind(serialize);
if cfg!(debug_assertions) {
assert!(
outcome.is_err(),
"Expr::Match serialize_revisioned must panic in debug builds"
);
} else {
assert!(outcome.is_ok());
}
}
}