use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QueryPlan {
pub root: PlanNode,
pub estimated_cost: f64,
pub estimated_cardinality: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PlanNode {
TripleScan {
pattern: String,
index_used: IndexType,
estimated_rows: u64,
},
HashJoin {
left: Box<PlanNode>,
right: Box<PlanNode>,
join_vars: Vec<String>,
},
NestedLoopJoin {
outer: Box<PlanNode>,
inner: Box<PlanNode>,
},
Filter {
expr: String,
child: Box<PlanNode>,
},
Sort {
vars: Vec<String>,
child: Box<PlanNode>,
},
Limit {
limit: usize,
offset: usize,
child: Box<PlanNode>,
},
Distinct { child: Box<PlanNode> },
Union {
left: Box<PlanNode>,
right: Box<PlanNode>,
},
Optional {
left: Box<PlanNode>,
right: Box<PlanNode>,
},
Aggregate {
group_by: Vec<String>,
aggs: Vec<String>,
child: Box<PlanNode>,
},
Subquery { plan: Box<QueryPlan> },
PropertyPath {
subject: String,
path: String,
object: String,
},
Service {
endpoint: String,
subplan: Box<QueryPlan>,
},
MergeJoin {
left: Box<PlanNode>,
right: Box<PlanNode>,
join_vars: Vec<String>,
},
ValuesScan { vars: Vec<String>, row_count: usize },
NamedGraph { graph: String, child: Box<PlanNode> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum IndexType {
Spo,
Pos,
Osp,
FullScan,
}
impl fmt::Display for IndexType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Spo => write!(f, "SPO"),
Self::Pos => write!(f, "POS"),
Self::Osp => write!(f, "OSP"),
Self::FullScan => write!(f, "FULL_SCAN"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ExplainFormat {
Text,
Json,
Dot,
}
#[derive(Debug, Clone)]
pub struct QueryExplainer {
show_estimates: bool,
show_costs: bool,
format: ExplainFormat,
}
impl Default for QueryExplainer {
fn default() -> Self {
Self {
show_estimates: true,
show_costs: true,
format: ExplainFormat::Text,
}
}
}
impl QueryExplainer {
pub fn new() -> Self {
Self::default()
}
pub fn builder() -> QueryExplainerBuilder {
QueryExplainerBuilder::default()
}
pub fn explain(&self, plan: &QueryPlan) -> String {
match self.format {
ExplainFormat::Text => self.explain_text(plan),
ExplainFormat::Json => self.explain_json(plan),
ExplainFormat::Dot => self.explain_dot(plan),
}
}
pub fn explain_with_format(&self, plan: &QueryPlan, format: ExplainFormat) -> String {
match format {
ExplainFormat::Text => self.explain_text(plan),
ExplainFormat::Json => self.explain_json(plan),
ExplainFormat::Dot => self.explain_dot(plan),
}
}
pub fn explain_text(&self, plan: &QueryPlan) -> String {
let mut out = String::new();
out.push_str("Query Plan\n");
out.push_str("==========\n");
if self.show_costs {
out.push_str(&format!(
"Estimated cost : {:.4}\n",
plan.estimated_cost
));
}
if self.show_estimates {
out.push_str(&format!(
"Estimated cardinality : {}\n",
plan.estimated_cardinality
));
}
out.push('\n');
self.format_node_text(&plan.root, &mut out, 0);
out
}
fn format_node_text(&self, node: &PlanNode, out: &mut String, depth: usize) {
let indent = " ".repeat(depth);
let prefix = if depth == 0 {
String::new()
} else {
format!("{indent}└─ ")
};
match node {
PlanNode::TripleScan {
pattern,
index_used,
estimated_rows,
} => {
out.push_str(&format!("{prefix}TripleScan\n"));
let child_indent = " ".repeat(depth + 1);
out.push_str(&format!("{child_indent} pattern : {pattern}\n"));
out.push_str(&format!("{child_indent} index : {index_used}\n"));
if self.show_estimates {
out.push_str(&format!("{child_indent} est. rows : {estimated_rows}\n"));
}
}
PlanNode::HashJoin {
left,
right,
join_vars,
} => {
out.push_str(&format!(
"{prefix}HashJoin [key: {}]\n",
join_vars.join(", ")
));
self.format_node_text(left, out, depth + 1);
self.format_node_text(right, out, depth + 1);
}
PlanNode::NestedLoopJoin { outer, inner } => {
out.push_str(&format!("{prefix}NestedLoopJoin\n"));
self.format_node_text(outer, out, depth + 1);
self.format_node_text(inner, out, depth + 1);
}
PlanNode::Filter { expr, child } => {
out.push_str(&format!("{prefix}Filter [{expr}]\n"));
self.format_node_text(child, out, depth + 1);
}
PlanNode::Sort { vars, child } => {
out.push_str(&format!("{prefix}Sort [{}]\n", vars.join(", ")));
self.format_node_text(child, out, depth + 1);
}
PlanNode::Limit {
limit,
offset,
child,
} => {
out.push_str(&format!("{prefix}Limit [{limit} offset {offset}]\n"));
self.format_node_text(child, out, depth + 1);
}
PlanNode::Distinct { child } => {
out.push_str(&format!("{prefix}Distinct\n"));
self.format_node_text(child, out, depth + 1);
}
PlanNode::Union { left, right } => {
out.push_str(&format!("{prefix}Union\n"));
self.format_node_text(left, out, depth + 1);
self.format_node_text(right, out, depth + 1);
}
PlanNode::Optional { left, right } => {
out.push_str(&format!("{prefix}Optional\n"));
self.format_node_text(left, out, depth + 1);
self.format_node_text(right, out, depth + 1);
}
PlanNode::Aggregate {
group_by,
aggs,
child,
} => {
out.push_str(&format!(
"{prefix}Aggregate [group: {}] [aggs: {}]\n",
group_by.join(", "),
aggs.join(", ")
));
self.format_node_text(child, out, depth + 1);
}
PlanNode::Subquery { plan } => {
out.push_str(&format!("{prefix}Subquery\n"));
let child_indent = " ".repeat(depth + 1);
if self.show_costs {
out.push_str(&format!(
"{child_indent} est. cost : {:.4}\n",
plan.estimated_cost
));
}
if self.show_estimates {
out.push_str(&format!(
"{child_indent} est. card : {}\n",
plan.estimated_cardinality
));
}
self.format_node_text(&plan.root, out, depth + 1);
}
PlanNode::PropertyPath {
subject,
path,
object,
} => {
out.push_str(&format!(
"{prefix}PropertyPath [{subject} {path} {object}]\n"
));
}
PlanNode::Service { endpoint, subplan } => {
out.push_str(&format!("{prefix}Service [{endpoint}]\n"));
let child_indent = " ".repeat(depth + 1);
if self.show_costs {
out.push_str(&format!(
"{child_indent} est. cost : {:.4}\n",
subplan.estimated_cost
));
}
self.format_node_text(&subplan.root, out, depth + 1);
}
PlanNode::MergeJoin {
left,
right,
join_vars,
} => {
out.push_str(&format!(
"{prefix}MergeJoin [key: {}]\n",
join_vars.join(", ")
));
self.format_node_text(left, out, depth + 1);
self.format_node_text(right, out, depth + 1);
}
PlanNode::ValuesScan { vars, row_count } => {
out.push_str(&format!(
"{prefix}ValuesScan [vars: {}] [{row_count} rows]\n",
vars.join(", ")
));
}
PlanNode::NamedGraph { graph, child } => {
out.push_str(&format!("{prefix}NamedGraph [{graph}]\n"));
self.format_node_text(child, out, depth + 1);
}
}
}
pub fn explain_json(&self, plan: &QueryPlan) -> String {
match serde_json::to_string_pretty(plan) {
Ok(s) => s,
Err(e) => format!("{{\"error\": \"{e}\"}}"),
}
}
pub fn explain_dot(&self, plan: &QueryPlan) -> String {
let mut state = DotState::default();
let mut out = String::new();
out.push_str("digraph QueryPlan {\n");
out.push_str(" node [shape=box fontname=\"Helvetica\" fontsize=10];\n");
out.push_str(" rankdir=TB;\n");
let root_id = state.next_id();
if self.show_costs {
out.push_str(&format!(
" {root_id} [label=\"QueryPlan\\ncost={:.4}\\ncard={}\"];\n",
plan.estimated_cost, plan.estimated_cardinality
));
} else {
out.push_str(&format!(" {root_id} [label=\"QueryPlan\"];\n"));
}
let child_id = self.emit_dot_node(&plan.root, &mut state, &mut out);
out.push_str(&format!(" {root_id} -> {child_id};\n"));
out.push_str("}\n");
out
}
fn emit_dot_node(&self, node: &PlanNode, state: &mut DotState, out: &mut String) -> usize {
let id = state.next_id();
match node {
PlanNode::TripleScan {
pattern,
index_used,
estimated_rows,
} => {
let label = if self.show_estimates {
format!("TripleScan\\n{pattern}\\nidx={index_used}\\nrows={estimated_rows}")
} else {
format!("TripleScan\\n{pattern}\\nidx={index_used}")
};
out.push_str(&format!(" {id} [label=\"{label}\"];\n"));
}
PlanNode::HashJoin {
left,
right,
join_vars,
} => {
let vars = join_vars.join(",");
out.push_str(&format!(" {id} [label=\"HashJoin\\nkey={vars}\"];\n"));
let l = self.emit_dot_node(left, state, out);
let r = self.emit_dot_node(right, state, out);
out.push_str(&format!(" {id} -> {l} [label=\"left\"];\n"));
out.push_str(&format!(" {id} -> {r} [label=\"right\"];\n"));
}
PlanNode::NestedLoopJoin { outer, inner } => {
out.push_str(&format!(" {id} [label=\"NestedLoopJoin\"];\n"));
let o = self.emit_dot_node(outer, state, out);
let i = self.emit_dot_node(inner, state, out);
out.push_str(&format!(" {id} -> {o} [label=\"outer\"];\n"));
out.push_str(&format!(" {id} -> {i} [label=\"inner\"];\n"));
}
PlanNode::Filter { expr, child } => {
out.push_str(&format!(" {id} [label=\"Filter\\n{expr}\"];\n"));
let c = self.emit_dot_node(child, state, out);
out.push_str(&format!(" {id} -> {c};\n"));
}
PlanNode::Sort { vars, child } => {
let keys = vars.join(",");
out.push_str(&format!(" {id} [label=\"Sort\\n{keys}\"];\n"));
let c = self.emit_dot_node(child, state, out);
out.push_str(&format!(" {id} -> {c};\n"));
}
PlanNode::Limit {
limit,
offset,
child,
} => {
out.push_str(&format!(
" {id} [label=\"Limit {limit}\\noffset {offset}\"];\n"
));
let c = self.emit_dot_node(child, state, out);
out.push_str(&format!(" {id} -> {c};\n"));
}
PlanNode::Distinct { child } => {
out.push_str(&format!(" {id} [label=\"Distinct\"];\n"));
let c = self.emit_dot_node(child, state, out);
out.push_str(&format!(" {id} -> {c};\n"));
}
PlanNode::Union { left, right } => {
out.push_str(&format!(" {id} [label=\"Union\"];\n"));
let l = self.emit_dot_node(left, state, out);
let r = self.emit_dot_node(right, state, out);
out.push_str(&format!(" {id} -> {l} [label=\"left\"];\n"));
out.push_str(&format!(" {id} -> {r} [label=\"right\"];\n"));
}
PlanNode::Optional { left, right } => {
out.push_str(&format!(" {id} [label=\"Optional\"];\n"));
let l = self.emit_dot_node(left, state, out);
let r = self.emit_dot_node(right, state, out);
out.push_str(&format!(" {id} -> {l} [label=\"left\"];\n"));
out.push_str(&format!(" {id} -> {r} [label=\"right\"];\n"));
}
PlanNode::Aggregate {
group_by,
aggs,
child,
} => {
let gb = group_by.join(",");
let ag = aggs.join(",");
out.push_str(&format!(
" {id} [label=\"Aggregate\\ngroup={gb}\\naggs={ag}\"];\n"
));
let c = self.emit_dot_node(child, state, out);
out.push_str(&format!(" {id} -> {c};\n"));
}
PlanNode::Subquery { plan } => {
out.push_str(&format!(" {id} [label=\"Subquery\"];\n"));
let c = self.emit_dot_node(&plan.root, state, out);
out.push_str(&format!(" {id} -> {c};\n"));
}
PlanNode::PropertyPath {
subject,
path,
object,
} => {
out.push_str(&format!(
" {id} [label=\"PropertyPath\\n{subject} {path} {object}\"];\n"
));
}
PlanNode::Service { endpoint, subplan } => {
out.push_str(&format!(" {id} [label=\"Service\\n{endpoint}\"];\n"));
let c = self.emit_dot_node(&subplan.root, state, out);
out.push_str(&format!(" {id} -> {c};\n"));
}
PlanNode::MergeJoin {
left,
right,
join_vars,
} => {
let vars = join_vars.join(",");
out.push_str(&format!(" {id} [label=\"MergeJoin\\nkey={vars}\"];\n"));
let l = self.emit_dot_node(left, state, out);
let r = self.emit_dot_node(right, state, out);
out.push_str(&format!(" {id} -> {l} [label=\"left\"];\n"));
out.push_str(&format!(" {id} -> {r} [label=\"right\"];\n"));
}
PlanNode::ValuesScan { vars, row_count } => {
let v = vars.join(",");
out.push_str(&format!(
" {id} [label=\"ValuesScan\\nvars={v}\\nrows={row_count}\"];\n"
));
}
PlanNode::NamedGraph { graph, child } => {
out.push_str(&format!(" {id} [label=\"NamedGraph\\n{graph}\"];\n"));
let c = self.emit_dot_node(child, state, out);
out.push_str(&format!(" {id} -> {c};\n"));
}
}
id
}
}
#[derive(Debug, Clone, Default)]
pub struct QueryExplainerBuilder {
show_estimates: Option<bool>,
show_costs: Option<bool>,
format: Option<ExplainFormat>,
}
impl QueryExplainerBuilder {
pub fn show_estimates(mut self, val: bool) -> Self {
self.show_estimates = Some(val);
self
}
pub fn show_costs(mut self, val: bool) -> Self {
self.show_costs = Some(val);
self
}
pub fn format(mut self, fmt: ExplainFormat) -> Self {
self.format = Some(fmt);
self
}
pub fn build(self) -> QueryExplainer {
QueryExplainer {
show_estimates: self.show_estimates.unwrap_or(true),
show_costs: self.show_costs.unwrap_or(true),
format: self.format.unwrap_or(ExplainFormat::Text),
}
}
}
#[derive(Debug, Default)]
struct DotState {
counter: usize,
}
impl DotState {
fn next_id(&mut self) -> usize {
self.counter += 1;
self.counter
}
}
impl PlanNode {
pub fn triple_scan(pattern: impl Into<String>, index: IndexType, rows: u64) -> Self {
Self::TripleScan {
pattern: pattern.into(),
index_used: index,
estimated_rows: rows,
}
}
pub fn hash_join(left: PlanNode, right: PlanNode, vars: Vec<String>) -> Self {
Self::HashJoin {
left: Box::new(left),
right: Box::new(right),
join_vars: vars,
}
}
pub fn filter(expr: impl Into<String>, child: PlanNode) -> Self {
Self::Filter {
expr: expr.into(),
child: Box::new(child),
}
}
pub fn sort(vars: Vec<String>, child: PlanNode) -> Self {
Self::Sort {
vars,
child: Box::new(child),
}
}
pub fn limit(limit: usize, offset: usize, child: PlanNode) -> Self {
Self::Limit {
limit,
offset,
child: Box::new(child),
}
}
pub fn distinct(child: PlanNode) -> Self {
Self::Distinct {
child: Box::new(child),
}
}
pub fn union(left: PlanNode, right: PlanNode) -> Self {
Self::Union {
left: Box::new(left),
right: Box::new(right),
}
}
pub fn optional(left: PlanNode, right: PlanNode) -> Self {
Self::Optional {
left: Box::new(left),
right: Box::new(right),
}
}
pub fn node_count(&self) -> usize {
match self {
Self::TripleScan { .. } | Self::PropertyPath { .. } | Self::ValuesScan { .. } => 1,
Self::Distinct { child }
| Self::Filter { child, .. }
| Self::Sort { child, .. }
| Self::Limit { child, .. }
| Self::NamedGraph { child, .. } => 1 + child.node_count(),
Self::HashJoin { left, right, .. }
| Self::NestedLoopJoin {
outer: left,
inner: right,
}
| Self::Union { left, right }
| Self::Optional { left, right }
| Self::MergeJoin { left, right, .. } => 1 + left.node_count() + right.node_count(),
Self::Aggregate { child, .. } => 1 + child.node_count(),
Self::Subquery { plan } => 1 + plan.root.node_count(),
Self::Service { subplan, .. } => 1 + subplan.root.node_count(),
}
}
pub fn depth(&self) -> usize {
match self {
Self::TripleScan { .. } | Self::PropertyPath { .. } | Self::ValuesScan { .. } => 0,
Self::Distinct { child }
| Self::Filter { child, .. }
| Self::Sort { child, .. }
| Self::Limit { child, .. }
| Self::NamedGraph { child, .. }
| Self::Aggregate { child, .. } => 1 + child.depth(),
Self::HashJoin { left, right, .. }
| Self::NestedLoopJoin {
outer: left,
inner: right,
}
| Self::Union { left, right }
| Self::Optional { left, right }
| Self::MergeJoin { left, right, .. } => 1 + left.depth().max(right.depth()),
Self::Subquery { plan } => 1 + plan.root.depth(),
Self::Service { subplan, .. } => 1 + subplan.root.depth(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_scan(pattern: &str, index: IndexType, rows: u64) -> PlanNode {
PlanNode::triple_scan(pattern, index, rows)
}
fn make_plan(root: PlanNode) -> QueryPlan {
let cardinality = 100;
QueryPlan {
estimated_cost: 5.0,
estimated_cardinality: cardinality,
root,
}
}
#[test]
fn test_triple_scan_construction() {
let node = make_scan("?s rdf:type ?t", IndexType::Spo, 500);
if let PlanNode::TripleScan {
pattern,
index_used,
estimated_rows,
} = &node
{
assert_eq!(pattern, "?s rdf:type ?t");
assert_eq!(*index_used, IndexType::Spo);
assert_eq!(*estimated_rows, 500);
} else {
panic!("expected TripleScan");
}
}
#[test]
fn test_hash_join_construction() {
let left = make_scan("?s ?p ?o", IndexType::FullScan, 1000);
let right = make_scan("?s foaf:name ?n", IndexType::Spo, 50);
let join = PlanNode::hash_join(left, right, vec!["?s".to_string()]);
assert!(matches!(join, PlanNode::HashJoin { .. }));
}
#[test]
fn test_nested_loop_join_construction() {
let outer = make_scan("?s a ?t", IndexType::Pos, 10);
let inner = make_scan("?s ?p ?o", IndexType::Spo, 5);
let node = PlanNode::NestedLoopJoin {
outer: Box::new(outer),
inner: Box::new(inner),
};
assert!(matches!(node, PlanNode::NestedLoopJoin { .. }));
}
#[test]
fn test_filter_construction() {
let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
let node = PlanNode::filter("?o > 5", scan);
if let PlanNode::Filter { expr, .. } = &node {
assert_eq!(expr, "?o > 5");
} else {
panic!("expected Filter");
}
}
#[test]
fn test_sort_construction() {
let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
let node = PlanNode::sort(vec!["+?s".into(), "-?o".into()], scan);
if let PlanNode::Sort { vars, .. } = &node {
assert_eq!(vars, &["+?s", "-?o"]);
} else {
panic!("expected Sort");
}
}
#[test]
fn test_limit_construction() {
let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
let node = PlanNode::limit(10, 20, scan);
if let PlanNode::Limit { limit, offset, .. } = &node {
assert_eq!(*limit, 10);
assert_eq!(*offset, 20);
} else {
panic!("expected Limit");
}
}
#[test]
fn test_distinct_construction() {
let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
let node = PlanNode::distinct(scan);
assert!(matches!(node, PlanNode::Distinct { .. }));
}
#[test]
fn test_union_construction() {
let left = make_scan("?s a owl:Class", IndexType::Pos, 30);
let right = make_scan("?s a rdfs:Class", IndexType::Pos, 10);
let node = PlanNode::union(left, right);
assert!(matches!(node, PlanNode::Union { .. }));
}
#[test]
fn test_optional_construction() {
let main = make_scan("?s foaf:name ?n", IndexType::Spo, 200);
let opt = make_scan("?s foaf:mbox ?m", IndexType::Spo, 80);
let node = PlanNode::optional(main, opt);
assert!(matches!(node, PlanNode::Optional { .. }));
}
#[test]
fn test_aggregate_construction() {
let scan = make_scan("?s ?p ?o", IndexType::FullScan, 1000);
let node = PlanNode::Aggregate {
group_by: vec!["?s".into()],
aggs: vec!["COUNT(?o) AS ?cnt".into()],
child: Box::new(scan),
};
if let PlanNode::Aggregate { aggs, group_by, .. } = &node {
assert_eq!(group_by[0], "?s");
assert!(aggs[0].contains("COUNT"));
} else {
panic!("expected Aggregate");
}
}
#[test]
fn test_subquery_construction() {
let inner_plan = make_plan(make_scan("?x ?y ?z", IndexType::FullScan, 5));
let node = PlanNode::Subquery {
plan: Box::new(inner_plan),
};
assert!(matches!(node, PlanNode::Subquery { .. }));
}
#[test]
fn test_property_path_construction() {
let node = PlanNode::PropertyPath {
subject: "?s".into(),
path: "foaf:knows+".into(),
object: "?o".into(),
};
if let PlanNode::PropertyPath { path, .. } = &node {
assert!(path.contains("foaf"));
} else {
panic!("expected PropertyPath");
}
}
#[test]
fn test_service_construction() {
let sub = make_plan(make_scan("?s ?p ?o", IndexType::FullScan, 50));
let node = PlanNode::Service {
endpoint: "http://remote.example.org/sparql".into(),
subplan: Box::new(sub),
};
if let PlanNode::Service { endpoint, .. } = &node {
assert!(endpoint.contains("remote"));
} else {
panic!("expected Service");
}
}
#[test]
fn test_merge_join_construction() {
let left = make_scan("?s ?p ?o", IndexType::Spo, 500);
let right = make_scan("?s a ?t", IndexType::Pos, 100);
let node = PlanNode::MergeJoin {
left: Box::new(left),
right: Box::new(right),
join_vars: vec!["?s".into()],
};
assert!(matches!(node, PlanNode::MergeJoin { .. }));
}
#[test]
fn test_values_scan_construction() {
let node = PlanNode::ValuesScan {
vars: vec!["?s".into(), "?p".into()],
row_count: 3,
};
if let PlanNode::ValuesScan { row_count, .. } = &node {
assert_eq!(*row_count, 3);
} else {
panic!("expected ValuesScan");
}
}
#[test]
fn test_named_graph_construction() {
let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
let node = PlanNode::NamedGraph {
graph: "http://example.org/g1".into(),
child: Box::new(scan),
};
if let PlanNode::NamedGraph { graph, .. } = &node {
assert!(graph.contains("g1"));
} else {
panic!("expected NamedGraph");
}
}
#[test]
fn test_node_count_leaf() {
let node = make_scan("?s ?p ?o", IndexType::Spo, 0);
assert_eq!(node.node_count(), 1);
}
#[test]
fn test_node_count_nested() {
let left = make_scan("?s a ?t", IndexType::Pos, 10);
let right = make_scan("?s ?p ?o", IndexType::Spo, 5);
let join = PlanNode::hash_join(left, right, vec![]);
assert_eq!(join.node_count(), 3);
}
#[test]
fn test_node_count_deep() {
let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
let filter = PlanNode::filter("?o > 0", scan);
let sort = PlanNode::sort(vec!["?s".into()], filter);
let limit = PlanNode::limit(10, 0, sort);
assert_eq!(limit.node_count(), 4);
}
#[test]
fn test_depth_leaf() {
let node = make_scan("?s ?p ?o", IndexType::Spo, 0);
assert_eq!(node.depth(), 0);
}
#[test]
fn test_depth_chain() {
let scan = make_scan("?s ?p ?o", IndexType::FullScan, 10);
let filter = PlanNode::filter("true", scan);
let sort = PlanNode::sort(vec![], filter);
assert_eq!(sort.depth(), 2);
}
#[test]
fn test_depth_join() {
let left = make_scan("?s a ?t", IndexType::Pos, 10);
let right = make_scan("?s ?p ?o", IndexType::Spo, 5);
let join = PlanNode::hash_join(left, right, vec![]);
assert_eq!(join.depth(), 1);
}
#[test]
fn test_index_type_display_spo() {
assert_eq!(IndexType::Spo.to_string(), "SPO");
}
#[test]
fn test_index_type_display_pos() {
assert_eq!(IndexType::Pos.to_string(), "POS");
}
#[test]
fn test_index_type_display_osp() {
assert_eq!(IndexType::Osp.to_string(), "OSP");
}
#[test]
fn test_index_type_display_fullscan() {
assert_eq!(IndexType::FullScan.to_string(), "FULL_SCAN");
}
#[test]
fn test_explain_text_contains_header() {
let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
let out = QueryExplainer::new().explain_text(&plan);
assert!(out.contains("Query Plan"));
assert!(out.contains("=========="));
}
#[test]
fn test_explain_text_contains_cost() {
let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
let out = QueryExplainer::new().explain_text(&plan);
assert!(out.contains("5.0000"));
}
#[test]
fn test_explain_text_contains_cardinality() {
let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
let out = QueryExplainer::new().explain_text(&plan);
assert!(out.contains("100"));
}
#[test]
fn test_explain_text_triple_scan() {
let plan = make_plan(make_scan("?s rdf:type owl:Class", IndexType::Pos, 42));
let out = QueryExplainer::new().explain_text(&plan);
assert!(out.contains("TripleScan"));
assert!(out.contains("owl:Class"));
assert!(out.contains("POS"));
assert!(out.contains("42"));
}
#[test]
fn test_explain_text_hash_join() {
let left = make_scan("?s a ?t", IndexType::Pos, 10);
let right = make_scan("?s ?p ?o", IndexType::Spo, 5);
let join = PlanNode::hash_join(left, right, vec!["?s".into()]);
let plan = make_plan(join);
let out = QueryExplainer::new().explain_text(&plan);
assert!(out.contains("HashJoin"));
assert!(out.contains("?s"));
}
#[test]
fn test_explain_text_filter() {
let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
let filtered = PlanNode::filter("?o > 10", scan);
let plan = make_plan(filtered);
let out = QueryExplainer::new().explain_text(&plan);
assert!(out.contains("Filter"));
assert!(out.contains("?o > 10"));
}
#[test]
fn test_explain_text_sort() {
let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
let sorted = PlanNode::sort(vec!["+?s".into()], scan);
let plan = make_plan(sorted);
let out = QueryExplainer::new().explain_text(&plan);
assert!(out.contains("Sort"));
assert!(out.contains("+?s"));
}
#[test]
fn test_explain_text_limit() {
let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
let limited = PlanNode::limit(25, 0, scan);
let plan = make_plan(limited);
let out = QueryExplainer::new().explain_text(&plan);
assert!(out.contains("Limit"));
assert!(out.contains("25"));
}
#[test]
fn test_explain_text_distinct() {
let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
let node = PlanNode::distinct(scan);
let plan = make_plan(node);
let out = QueryExplainer::new().explain_text(&plan);
assert!(out.contains("Distinct"));
}
#[test]
fn test_explain_text_union() {
let left = make_scan("?s a owl:Class", IndexType::Pos, 30);
let right = make_scan("?s a rdfs:Class", IndexType::Pos, 10);
let node = PlanNode::union(left, right);
let plan = make_plan(node);
let out = QueryExplainer::new().explain_text(&plan);
assert!(out.contains("Union"));
}
#[test]
fn test_explain_text_optional() {
let main = make_scan("?s foaf:name ?n", IndexType::Spo, 200);
let opt = make_scan("?s foaf:mbox ?m", IndexType::Spo, 80);
let node = PlanNode::optional(main, opt);
let plan = make_plan(node);
let out = QueryExplainer::new().explain_text(&plan);
assert!(out.contains("Optional"));
}
#[test]
fn test_explain_text_aggregate() {
let scan = make_scan("?s ?p ?o", IndexType::FullScan, 1000);
let node = PlanNode::Aggregate {
group_by: vec!["?s".into()],
aggs: vec!["COUNT(?o) AS ?cnt".into()],
child: Box::new(scan),
};
let plan = make_plan(node);
let out = QueryExplainer::new().explain_text(&plan);
assert!(out.contains("Aggregate"));
assert!(out.contains("COUNT"));
}
#[test]
fn test_explain_text_subquery() {
let inner = make_plan(make_scan("?x ?y ?z", IndexType::FullScan, 5));
let node = PlanNode::Subquery {
plan: Box::new(inner),
};
let plan = make_plan(node);
let out = QueryExplainer::new().explain_text(&plan);
assert!(out.contains("Subquery"));
}
#[test]
fn test_explain_text_property_path() {
let node = PlanNode::PropertyPath {
subject: "?s".into(),
path: "foaf:knows+".into(),
object: "?o".into(),
};
let plan = make_plan(node);
let out = QueryExplainer::new().explain_text(&plan);
assert!(out.contains("PropertyPath"));
assert!(out.contains("foaf:knows+"));
}
#[test]
fn test_explain_text_service() {
let sub = make_plan(make_scan("?s ?p ?o", IndexType::FullScan, 50));
let node = PlanNode::Service {
endpoint: "http://remote.example.org/sparql".into(),
subplan: Box::new(sub),
};
let plan = make_plan(node);
let out = QueryExplainer::new().explain_text(&plan);
assert!(out.contains("Service"));
assert!(out.contains("remote.example.org"));
}
#[test]
fn test_explain_text_merge_join() {
let left = make_scan("?s ?p ?o", IndexType::Spo, 500);
let right = make_scan("?s a ?t", IndexType::Pos, 100);
let node = PlanNode::MergeJoin {
left: Box::new(left),
right: Box::new(right),
join_vars: vec!["?s".into()],
};
let plan = make_plan(node);
let out = QueryExplainer::new().explain_text(&plan);
assert!(out.contains("MergeJoin"));
}
#[test]
fn test_explain_text_values_scan() {
let node = PlanNode::ValuesScan {
vars: vec!["?s".into()],
row_count: 7,
};
let plan = make_plan(node);
let out = QueryExplainer::new().explain_text(&plan);
assert!(out.contains("ValuesScan"));
assert!(out.contains("7"));
}
#[test]
fn test_explain_text_named_graph() {
let scan = make_scan("?s ?p ?o", IndexType::FullScan, 50);
let node = PlanNode::NamedGraph {
graph: "http://example.org/g1".into(),
child: Box::new(scan),
};
let plan = make_plan(node);
let out = QueryExplainer::new().explain_text(&plan);
assert!(out.contains("NamedGraph"));
assert!(out.contains("g1"));
}
#[test]
fn test_explain_json_is_valid() {
let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
let out = QueryExplainer::new().explain_json(&plan);
let parsed: serde_json::Value = serde_json::from_str(&out).expect("invalid JSON");
assert!(parsed.is_object());
}
#[test]
fn test_explain_json_contains_type_field() {
let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
let out = QueryExplainer::new().explain_json(&plan);
assert!(out.contains("\"type\""));
}
#[test]
fn test_explain_json_triple_scan_fields() {
let plan = make_plan(make_scan("?s rdf:type owl:Class", IndexType::Pos, 42));
let out = QueryExplainer::new().explain_json(&plan);
assert!(out.contains("triple_scan"));
assert!(out.contains("owl:Class"));
assert!(out.contains("POS"));
assert!(out.contains("42"));
}
#[test]
fn test_explain_json_hash_join() {
let left = make_scan("?s a ?t", IndexType::Pos, 10);
let right = make_scan("?s ?p ?o", IndexType::Spo, 5);
let join = PlanNode::hash_join(left, right, vec!["?s".into()]);
let plan = make_plan(join);
let out = QueryExplainer::new().explain_json(&plan);
assert!(out.contains("hash_join"));
assert!(out.contains("join_vars"));
}
#[test]
fn test_explain_json_filter() {
let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
let node = PlanNode::filter("?o > 10", scan);
let plan = make_plan(node);
let out = QueryExplainer::new().explain_json(&plan);
assert!(out.contains("filter"));
assert!(out.contains("expr"));
}
#[test]
fn test_explain_json_estimated_cost() {
let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
let out = QueryExplainer::new().explain_json(&plan);
assert!(out.contains("estimated_cost"));
assert!(out.contains("5.0"));
}
#[test]
fn test_explain_json_aggregate() {
let scan = make_scan("?s ?p ?o", IndexType::FullScan, 1000);
let node = PlanNode::Aggregate {
group_by: vec!["?s".into()],
aggs: vec!["SUM(?v) AS ?total".into()],
child: Box::new(scan),
};
let plan = make_plan(node);
let out = QueryExplainer::new().explain_json(&plan);
assert!(out.contains("aggregate"));
assert!(out.contains("group_by"));
assert!(out.contains("aggs"));
}
#[test]
fn test_explain_json_union() {
let left = make_scan("?s a owl:Class", IndexType::Pos, 30);
let right = make_scan("?s a rdfs:Class", IndexType::Pos, 10);
let node = PlanNode::union(left, right);
let plan = make_plan(node);
let out = QueryExplainer::new().explain_json(&plan);
assert!(out.contains("union"));
}
#[test]
fn test_explain_dot_starts_with_digraph() {
let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
let out = QueryExplainer::new().explain_dot(&plan);
assert!(out.starts_with("digraph QueryPlan {"));
}
#[test]
fn test_explain_dot_ends_with_brace() {
let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
let out = QueryExplainer::new().explain_dot(&plan);
assert!(out.trim().ends_with('}'));
}
#[test]
fn test_explain_dot_contains_triple_scan() {
let plan = make_plan(make_scan("?s a owl:Class", IndexType::Pos, 42));
let out = QueryExplainer::new().explain_dot(&plan);
assert!(out.contains("TripleScan"));
}
#[test]
fn test_explain_dot_contains_edge_arrows() {
let left = make_scan("?s a ?t", IndexType::Pos, 10);
let right = make_scan("?s ?p ?o", IndexType::Spo, 5);
let join = PlanNode::hash_join(left, right, vec!["?s".into()]);
let plan = make_plan(join);
let out = QueryExplainer::new().explain_dot(&plan);
assert!(out.contains("->"));
}
#[test]
fn test_explain_dot_hash_join_labels() {
let left = make_scan("?s a ?t", IndexType::Pos, 10);
let right = make_scan("?s ?p ?o", IndexType::Spo, 5);
let join = PlanNode::hash_join(left, right, vec!["?s".into()]);
let plan = make_plan(join);
let out = QueryExplainer::new().explain_dot(&plan);
assert!(out.contains("HashJoin"));
assert!(out.contains("left"));
assert!(out.contains("right"));
}
#[test]
fn test_explain_dot_filter() {
let scan = make_scan("?s ?p ?o", IndexType::FullScan, 100);
let node = PlanNode::filter("?o > 10", scan);
let plan = make_plan(node);
let out = QueryExplainer::new().explain_dot(&plan);
assert!(out.contains("Filter"));
}
#[test]
fn test_explain_dot_union() {
let left = make_scan("?s a owl:Class", IndexType::Pos, 30);
let right = make_scan("?s a rdfs:Class", IndexType::Pos, 10);
let node = PlanNode::union(left, right);
let plan = make_plan(node);
let out = QueryExplainer::new().explain_dot(&plan);
assert!(out.contains("Union"));
}
#[test]
fn test_explain_dot_service() {
let sub = make_plan(make_scan("?s ?p ?o", IndexType::FullScan, 50));
let node = PlanNode::Service {
endpoint: "http://remote.example.org/sparql".into(),
subplan: Box::new(sub),
};
let plan = make_plan(node);
let out = QueryExplainer::new().explain_dot(&plan);
assert!(out.contains("Service"));
}
#[test]
fn test_explain_with_format_text() {
let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
let exp = QueryExplainer::new();
let out = exp.explain_with_format(&plan, ExplainFormat::Text);
assert!(out.contains("Query Plan"));
}
#[test]
fn test_explain_with_format_json() {
let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
let exp = QueryExplainer::new();
let out = exp.explain_with_format(&plan, ExplainFormat::Json);
let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert!(v.is_object());
}
#[test]
fn test_explain_with_format_dot() {
let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
let exp = QueryExplainer::new();
let out = exp.explain_with_format(&plan, ExplainFormat::Dot);
assert!(out.contains("digraph"));
}
#[test]
fn test_builder_default() {
let exp = QueryExplainer::builder().build();
assert!(exp.show_estimates);
assert!(exp.show_costs);
assert_eq!(exp.format, ExplainFormat::Text);
}
#[test]
fn test_builder_no_estimates() {
let exp = QueryExplainer::builder().show_estimates(false).build();
let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 999));
let out = exp.explain_text(&plan);
assert!(!out.contains("999"));
}
#[test]
fn test_builder_no_costs() {
let exp = QueryExplainer::builder().show_costs(false).build();
let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
let out = exp.explain_text(&plan);
assert!(!out.contains("5.0000"));
}
#[test]
fn test_builder_json_format() {
let exp = QueryExplainer::builder()
.format(ExplainFormat::Json)
.build();
let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
let out = exp.explain(&plan); let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert!(v.is_object());
}
#[test]
fn test_builder_dot_format() {
let exp = QueryExplainer::builder().format(ExplainFormat::Dot).build();
let plan = make_plan(make_scan("?s ?p ?o", IndexType::Spo, 10));
let out = exp.explain(&plan);
assert!(out.contains("digraph"));
}
#[test]
fn test_nested_plan_text() {
let s1 = make_scan("?s a ?t", IndexType::Pos, 500);
let s2 = make_scan("?s foaf:name ?n", IndexType::Spo, 200);
let join = PlanNode::hash_join(s1, s2, vec!["?s".into()]);
let filter = PlanNode::filter("LANG(?n) = 'en'", join);
let sort = PlanNode::sort(vec!["+?n".into()], filter);
let distinct = PlanNode::distinct(sort);
let plan = QueryPlan {
root: distinct,
estimated_cost: 42.7,
estimated_cardinality: 150,
};
let out = QueryExplainer::new().explain_text(&plan);
assert!(out.contains("Distinct"));
assert!(out.contains("Sort"));
assert!(out.contains("Filter"));
assert!(out.contains("HashJoin"));
assert!(out.contains("TripleScan"));
}
#[test]
fn test_nested_plan_json_roundtrip() {
let scan = make_scan("?s ?p ?o", IndexType::Spo, 100);
let filter = PlanNode::filter("?o > 0", scan);
let plan = QueryPlan {
root: filter,
estimated_cost: std::f64::consts::PI,
estimated_cardinality: 80,
};
let exp = QueryExplainer::new();
let json = exp.explain_json(&plan);
let decoded: QueryPlan = serde_json::from_str(&json).expect("roundtrip failed");
assert_eq!(decoded.estimated_cardinality, 80);
assert!((decoded.estimated_cost - std::f64::consts::PI).abs() < 1e-9);
}
#[test]
fn test_deeply_nested_node_count() {
let s = make_scan("?s ?p ?o", IndexType::FullScan, 10);
let f = PlanNode::filter("true", s);
let so = PlanNode::sort(vec![], f);
let li = PlanNode::limit(5, 0, so);
let di = PlanNode::distinct(li);
assert_eq!(di.node_count(), 5);
assert_eq!(di.depth(), 4);
}
#[test]
fn test_subquery_in_text() {
let inner_scan = make_scan("?x ?y ?z", IndexType::FullScan, 5);
let inner_plan = QueryPlan {
root: inner_scan,
estimated_cost: 1.0,
estimated_cardinality: 5,
};
let outer_scan = make_scan("?a ?b ?c", IndexType::Spo, 100);
let sub = PlanNode::Subquery {
plan: Box::new(inner_plan),
};
let join = PlanNode::hash_join(outer_scan, sub, vec!["?x".into()]);
let plan = make_plan(join);
let out = QueryExplainer::new().explain_text(&plan);
assert!(out.contains("Subquery"));
assert!(out.contains("HashJoin"));
}
}