use crate::ir::{IrGroup, IrRowSource, IrSelect, IrShapePointer, IrStmt};
#[derive(Debug, Clone, PartialEq)]
pub struct ShapePathAlias {
pub sql_alias: String,
pub path: String,
pub marker_offset: Option<usize>,
}
const ROOT_PATH: &str = "root";
pub fn collect_shape_path_aliases(stmt: &IrStmt) -> Vec<ShapePathAlias> {
let mut out = Vec::new();
match stmt {
IrStmt::Select(sel) => collect_select(sel, ROOT_PATH, None, &mut out),
IrStmt::Insert(ins) => {
out.push(ShapePathAlias {
sql_alias: ins.target.alias.clone(),
path: ROOT_PATH.to_string(),
marker_offset: None,
});
collect_shape(&ins.returning, ROOT_PATH, &mut out);
}
IrStmt::Update(upd) => {
out.push(ShapePathAlias {
sql_alias: upd.target.alias.clone(),
path: ROOT_PATH.to_string(),
marker_offset: None,
});
collect_shape(&upd.returning, ROOT_PATH, &mut out);
}
IrStmt::Delete(del) => {
out.push(ShapePathAlias {
sql_alias: del.target.alias.clone(),
path: ROOT_PATH.to_string(),
marker_offset: None,
});
collect_shape(&del.returning, ROOT_PATH, &mut out);
}
IrStmt::Group(g) => {
let IrGroup { source, shape, .. } = g;
out.push(ShapePathAlias {
sql_alias: source.alias.clone(),
path: ROOT_PATH.to_string(),
marker_offset: None,
});
collect_shape(shape, ROOT_PATH, &mut out);
}
IrStmt::PathSelect(_)
| IrStmt::For(_)
| IrStmt::FunctionSelect(_)
| IrStmt::VectorSearch(_)
| IrStmt::FtsSearch(_)
| IrStmt::ScalarUnion(_) => {}
}
out
}
pub fn root_marker_offset(stmt: &crate::parse::Stmt) -> Option<usize> {
use crate::parse::{Expr, Stmt};
match stmt {
Stmt::Analyze(inner) => root_marker_offset(inner),
Stmt::Select(sel) => match &sel.result {
Expr::Shape(shape) => shape.marker_offset,
_ => None,
},
_ => None,
}
}
fn collect_select(sel: &IrSelect, path: &str, marker_offset: Option<usize>, out: &mut Vec<ShapePathAlias>) {
for row in &sel.rows {
if let IrRowSource::Bound { source, shape } = row {
out.push(ShapePathAlias {
sql_alias: source.alias.clone(),
path: path.to_string(),
marker_offset,
});
collect_shape(shape, path, out);
}
}
}
fn collect_shape(shape: &[IrShapePointer], parent_path: &str, out: &mut Vec<ShapePathAlias>) {
for ptr in shape {
match ptr {
IrShapePointer::SingleLink(p) => {
let path = format!("{parent_path}.{}", p.alias);
collect_select(&p.subquery, &path, p.marker_offset, out);
}
IrShapePointer::MultiLink(p) => {
let path = format!("{parent_path}.{}", p.alias);
collect_select(&p.subquery, &path, p.marker_offset, out);
}
IrShapePointer::Scalar(_) | IrShapePointer::Computed(_) | IrShapePointer::ScalarSet(_) => {}
IrShapePointer::Asserted(a) => collect_shape(std::slice::from_ref(&a.inner), parent_path, out),
}
}
}
use std::collections::HashMap;
#[derive(Debug, Clone, serde::Deserialize)]
struct RawExplainRoot {
#[serde(rename = "Plan")]
plan: RawPlanNode,
}
#[derive(Debug, Clone, serde::Deserialize)]
struct RawPlanNode {
#[serde(rename = "Alias")]
alias: Option<String>,
#[serde(rename = "Relation Name")]
relation_name: Option<String>,
#[serde(rename = "Startup Cost", default)]
startup_cost: f64,
#[serde(rename = "Total Cost", default)]
total_cost: f64,
#[serde(rename = "Plan Rows", default)]
plan_rows: f64,
#[serde(rename = "Plan Width", default)]
plan_width: i64,
#[serde(rename = "Actual Startup Time")]
actual_startup_time: Option<f64>,
#[serde(rename = "Actual Total Time")]
actual_total_time: Option<f64>,
#[serde(rename = "Actual Rows")]
actual_rows: Option<f64>,
#[serde(rename = "Actual Loops")]
actual_loops: Option<f64>,
#[serde(rename = "Plans", default)]
plans: Vec<RawPlanNode>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct PlanCost {
pub startup_cost: f64,
pub total_cost: f64,
pub plan_rows: f64,
pub plan_width: i64,
pub actual_startup_time: Option<f64>,
pub actual_total_time: Option<f64>,
pub actual_rows: Option<f64>,
pub actual_loops: Option<f64>,
}
impl From<&RawPlanNode> for PlanCost {
fn from(raw: &RawPlanNode) -> Self {
PlanCost {
startup_cost: raw.startup_cost,
total_cost: raw.total_cost,
plan_rows: raw.plan_rows,
plan_width: raw.plan_width,
actual_startup_time: raw.actual_startup_time,
actual_total_time: raw.actual_total_time,
actual_rows: raw.actual_rows,
actual_loops: raw.actual_loops,
}
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct CoarseGrainedNode {
pub path: String,
pub marker_offset: Option<usize>,
pub relations: Vec<String>,
pub cost: PlanCost,
pub children: Vec<ChildEntry>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct ChildEntry {
pub name: String,
pub node: CoarseGrainedNode,
}
pub fn build_coarse_grained(raw_json: &str, path_aliases: &[ShapePathAlias]) -> Result<CoarseGrainedNode, String> {
let roots: Vec<RawExplainRoot> =
serde_json::from_str(raw_json).map_err(|e| format!("malformed EXPLAIN (FORMAT JSON) output: {e}"))?;
let root = roots
.into_iter()
.next()
.ok_or_else(|| "EXPLAIN produced no plan".to_string())?;
let alias_to_path: HashMap<&str, &str> = path_aliases
.iter()
.map(|p| (p.sql_alias.as_str(), p.path.as_str()))
.collect();
let path_to_marker: HashMap<&str, Option<usize>> = path_aliases
.iter()
.map(|p| (p.path.as_str(), p.marker_offset))
.collect();
Ok(build_node(&root.plan, ROOT_PATH, &alias_to_path, &path_to_marker))
}
fn build_node(
raw: &RawPlanNode,
path: &str,
alias_to_path: &HashMap<&str, &str>,
path_to_marker: &HashMap<&str, Option<usize>>,
) -> CoarseGrainedNode {
let mut relations = Vec::new();
let mut seen_relations = std::collections::HashSet::new();
let mut children = Vec::new();
collect_plan_nodes(
raw,
path,
alias_to_path,
&mut relations,
&mut seen_relations,
&mut children,
path_to_marker,
);
let marker_offset = path_to_marker.get(path).copied().flatten();
CoarseGrainedNode {
path: path.to_string(),
marker_offset,
relations,
cost: PlanCost::from(raw),
children,
}
}
fn collect_plan_nodes(
raw: &RawPlanNode,
path: &str,
alias_to_path: &HashMap<&str, &str>,
relations: &mut Vec<String>,
seen_relations: &mut std::collections::HashSet<String>,
children: &mut Vec<ChildEntry>,
path_to_marker: &HashMap<&str, Option<usize>>,
) {
if let Some(rel) = &raw.relation_name
&& seen_relations.insert(rel.clone())
{
relations.push(rel.clone());
}
for child in &raw.plans {
match resolve_subtree_path(child, alias_to_path) {
Some(child_path) if child_path != path => {
let name = child_path.rsplit('.').next().unwrap_or(child_path).to_string();
children.push(ChildEntry {
name,
node: build_node(child, child_path, alias_to_path, path_to_marker),
});
}
_ => collect_plan_nodes(
child,
path,
alias_to_path,
relations,
seen_relations,
children,
path_to_marker,
),
}
}
}
fn resolve_subtree_path<'a>(node: &RawPlanNode, alias_to_path: &HashMap<&str, &'a str>) -> Option<&'a str> {
if let Some(path) = node.alias.as_deref().and_then(|a| alias_to_path.get(a).copied()) {
return Some(path);
}
node.plans
.iter()
.find_map(|child| resolve_subtree_path(child, alias_to_path))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::{LinkDescriptor, MultiLinkDescriptor, PropertyDescriptor, SchemaDescriptor, TypeDescriptor};
use crate::{ir, parse};
fn id_prop() -> PropertyDescriptor {
PropertyDescriptor {
name: "id".into(),
pg_type: "uuid".into(),
nullable: false,
default_sql: Some("uuidv7()".into()),
default_pyql: None,
description: None,
check_constraints: vec![],
is_exclusive: true,
is_pk: true,
is_readonly: true,
rewrites: vec![],
tuple_members: None,
column_type: None,
}
}
fn name_prop() -> PropertyDescriptor {
PropertyDescriptor {
name: "name".into(),
pg_type: "text".into(),
nullable: false,
default_sql: None,
default_pyql: None,
description: None,
check_constraints: vec![],
is_exclusive: false,
is_pk: false,
is_readonly: false,
rewrites: vec![],
tuple_members: None,
column_type: None,
}
}
fn make_schema() -> SchemaDescriptor {
SchemaDescriptor {
types: vec![
TypeDescriptor {
name: "Hero".into(),
module: "default".into(),
table: "hero".into(),
abstract_: false,
materialized: false,
description: None,
parents: vec![],
interfaces: vec![],
bases: vec![],
properties: vec![id_prop(), name_prop()],
links: vec![],
multilinks: vec![MultiLinkDescriptor {
name: "villains".into(),
target: "default::Villain".into(),
through: None,
nullable: false,
description: None,
default_pyql: None,
on_delete: vec![],
is_exclusive: false,
}],
computed: vec![],
constraints: vec![],
indexes: vec![],
partition: None,
vector_indexes: vec![],
search_indexes: vec![],
triggers: vec![],
junction: false,
signals: vec![],
},
TypeDescriptor {
name: "Villain".into(),
module: "default".into(),
table: "villain".into(),
abstract_: false,
materialized: false,
description: None,
parents: vec![],
interfaces: vec![],
bases: vec![],
properties: vec![id_prop(), name_prop()],
links: vec![LinkDescriptor {
name: "nemesis".into(),
target: "default::Hero".into(),
nullable: true,
through: None,
description: None,
default_pyql: None,
is_exclusive: false,
is_readonly: false,
rewrites: vec![],
on_delete: vec![],
}],
multilinks: vec![],
computed: vec![],
constraints: vec![],
indexes: vec![],
partition: None,
vector_indexes: vec![],
search_indexes: vec![],
triggers: vec![],
junction: false,
signals: vec![],
},
],
scalars: vec![],
enums: vec![],
named_tuples: vec![],
globals: vec![],
functions: vec![],
aliases: vec![],
channels: vec![],
..Default::default()
}
}
fn compile(query: &str) -> ir::IrOutput {
let schema = make_schema();
let ast = parse::parse(query).unwrap();
let ast = match ast {
parse::Stmt::Analyze(inner) => *inner,
other => other,
};
ir::compile(&ast, &schema).unwrap()
}
#[test]
fn test_root_only_query_produces_a_single_root_alias() {
let ir = compile("select Hero { name }");
let paths = collect_shape_path_aliases(&ir.stmt);
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].path, "root");
}
#[test]
fn test_nested_multilink_produces_root_and_nested_paths() {
let ir = compile("select Hero { name, villains: { name, nemesis: { name } } }");
let paths = collect_shape_path_aliases(&ir.stmt);
let by_path: Vec<&str> = paths.iter().map(|p| p.path.as_str()).collect();
assert_eq!(by_path, vec!["root", "root.villains", "root.villains.nemesis"]);
assert_ne!(paths[0].sql_alias, paths[1].sql_alias);
assert_ne!(paths[1].sql_alias, paths[2].sql_alias);
}
#[test]
fn test_marker_offsets_survive_from_ast_to_the_alias_map() {
let query = "select Hero { name, villains: { name } }";
let ir = compile(query);
let paths = collect_shape_path_aliases(&ir.stmt);
let villains = paths.iter().find(|p| p.path == "root.villains").unwrap();
let offset = villains
.marker_offset
.expect("villains path should carry a marker offset");
assert_eq!(&query[offset..offset + "villains".len()], "villains");
}
#[test]
fn test_analyze_wrapped_query_compiles_to_the_same_ir_as_the_bare_inner_query() {
let wrapped = compile("analyze select Hero { name, villains: { name } }");
let bare = compile("select Hero { name, villains: { name } }");
assert_eq!(
collect_shape_path_aliases(&wrapped.stmt).len(),
collect_shape_path_aliases(&bare.stmt).len()
);
}
fn explain_json_fixture(root_alias: &str, villain_alias: &str) -> String {
format!(
r#"[
{{
"Plan": {{
"Node Type": "Seq Scan",
"Alias": "{root_alias}",
"Relation Name": "hero",
"Startup Cost": 0.0,
"Total Cost": 12.5,
"Plan Rows": 100,
"Plan Width": 40,
"Actual Startup Time": 0.01,
"Actual Total Time": 0.05,
"Actual Rows": 100,
"Actual Loops": 1,
"Plans": [
{{
"Node Type": "Nested Loop",
"Startup Cost": 0.0,
"Total Cost": 8.2,
"Plan Rows": 3,
"Plan Width": 32,
"Actual Startup Time": 0.0,
"Actual Total Time": 0.01,
"Actual Rows": 3,
"Actual Loops": 100,
"Plans": [
{{
"Node Type": "Seq Scan",
"Alias": "hv",
"Relation Name": "hero.villains",
"Startup Cost": 0.0,
"Total Cost": 2.0,
"Plan Rows": 3,
"Plan Width": 16,
"Actual Startup Time": 0.0,
"Actual Total Time": 0.0,
"Actual Rows": 3,
"Actual Loops": 100,
"Plans": []
}},
{{
"Node Type": "Seq Scan",
"Alias": "{villain_alias}",
"Relation Name": "villain",
"Startup Cost": 0.0,
"Total Cost": 2.0,
"Plan Rows": 1,
"Plan Width": 32,
"Actual Startup Time": 0.0,
"Actual Total Time": 0.0,
"Actual Rows": 1,
"Actual Loops": 3,
"Plans": []
}}
]
}}
]
}},
"Planning Time": 0.1,
"Execution Time": 0.2
}}
]"#
)
}
#[test]
fn test_build_coarse_grained_groups_nodes_by_shape_path() {
let ir = compile("select Hero { name, villains: { name } }");
let paths = collect_shape_path_aliases(&ir.stmt);
let root_alias = paths.iter().find(|p| p.path == "root").unwrap().sql_alias.clone();
let villains_alias = paths
.iter()
.find(|p| p.path == "root.villains")
.unwrap()
.sql_alias
.clone();
let raw_json = explain_json_fixture(&root_alias, &villains_alias);
let tree = build_coarse_grained(&raw_json, &paths).unwrap();
assert_eq!(tree.path, "root");
assert_eq!(tree.relations, vec!["hero".to_string()]);
assert_eq!(tree.cost.total_cost, 12.5);
assert_eq!(tree.children.len(), 1);
let ChildEntry {
name,
node: villains_node,
} = &tree.children[0];
assert_eq!(name, "villains");
assert_eq!(villains_node.path, "root.villains");
assert_eq!(
villains_node.relations,
vec!["hero.villains".to_string(), "villain".to_string()]
);
assert_eq!(villains_node.cost.total_cost, 8.2);
assert!(villains_node.children.is_empty());
}
#[test]
fn test_build_coarse_grained_carries_marker_offsets_for_repl_rendering() {
let query = "select Hero { name, villains: { name } }";
let ir = compile(query);
let mut paths = collect_shape_path_aliases(&ir.stmt);
let ast = crate::parse::parse(query).unwrap();
if let Some(root) = paths.iter_mut().find(|p| p.path == "root") {
root.marker_offset = root_marker_offset(&ast);
}
let root_alias = paths.iter().find(|p| p.path == "root").unwrap().sql_alias.clone();
let villains_alias = paths
.iter()
.find(|p| p.path == "root.villains")
.unwrap()
.sql_alias
.clone();
let raw_json = explain_json_fixture(&root_alias, &villains_alias);
let tree = build_coarse_grained(&raw_json, &paths).unwrap();
let root_offset = tree.marker_offset.expect("root should carry a marker offset");
assert_eq!(&query[root_offset..root_offset + "Hero".len()], "Hero");
let villains_offset = tree.children[0]
.node
.marker_offset
.expect("villains should carry a marker offset");
assert_eq!(&query[villains_offset..villains_offset + "villains".len()], "villains");
}
#[test]
fn test_build_coarse_grained_with_no_path_aliases_still_produces_a_root_node() {
let raw_json = explain_json_fixture("t0", "t1");
let tree = build_coarse_grained(&raw_json, &[]).unwrap();
assert_eq!(tree.path, "root");
assert!(tree.children.is_empty());
}
#[test]
fn test_build_coarse_grained_rejects_malformed_json() {
assert!(build_coarse_grained("not json", &[]).is_err());
}
}