use std::collections::{HashMap, HashSet};
use fsqlite_types::opcode::{Opcode, P4};
use fsqlite_vdbe::VdbeProgram;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExplainRow {
pub addr: i32,
pub opcode: String,
pub p1: i32,
pub p2: i32,
pub p3: i32,
pub p4: String,
pub p5: u16,
pub comment: String,
}
#[must_use]
pub fn explain_program(program: &VdbeProgram) -> Vec<ExplainRow> {
program
.ops()
.iter()
.enumerate()
.map(|(i, op)| {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let addr = i as i32;
ExplainRow {
addr,
opcode: format!("{:?}", op.opcode),
p1: op.p1,
p2: op.p2,
p3: op.p3,
p4: format!("{:?}", op.p4),
p5: op.p5,
comment: opcode_comment(op.opcode, op.p1, op.p2, op.p3),
}
})
.collect()
}
fn opcode_comment(opcode: Opcode, p1: i32, p2: i32, p3: i32) -> String {
match opcode {
Opcode::Init => format!("start at {p2}"),
Opcode::Goto => format!("goto {p2}"),
Opcode::Halt => {
if p1 == 0 {
String::new()
} else {
format!("error code {p1}")
}
}
Opcode::Transaction => {
if p2 == 0 {
"read transaction".to_owned()
} else {
"write transaction".to_owned()
}
}
Opcode::OpenRead | Opcode::OpenWrite => format!("root={p2}"),
Opcode::Column => format!("r[{p3}]=cursor[{p1}].column[{p2}]"),
Opcode::ResultRow => format!("output r[{p1}..{p1}+{p2}]"),
Opcode::Rewind => format!("if eof goto {p2}"),
Opcode::Next => format!("goto {p2} if more rows"),
Opcode::Close => format!("close cursor {p1}"),
_ => String::new(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EqpRow {
pub id: i32,
pub parent: i32,
pub notused: i32,
pub detail: String,
}
#[must_use]
pub fn explain_query_plan(program: &VdbeProgram) -> Vec<EqpRow> {
let ops = program.ops();
let mut rows = Vec::new();
let mut next_id = 1_i32;
let mut index_step_by_cursor: HashMap<i32, usize> = HashMap::new();
let mut index_name_by_cursor: HashMap<i32, String> = HashMap::new();
let mut index_used = HashSet::new();
let mut index_with_table_lookup = HashSet::new();
let mut last_idxrowid_cursor = None;
let mut saw_sorter = false;
for op in ops {
match op.opcode {
Opcode::OpenRead | Opcode::OpenWrite => {
let scan_type = if op.opcode == Opcode::OpenRead {
"SCAN"
} else {
"SEARCH"
};
let detail = match &op.p4 {
P4::Table(name) => format!("{scan_type} {name}"),
P4::Index(name) => format!("{scan_type} INDEX {name}"),
_ => format!("{scan_type} {:?}", op.p4),
};
rows.push(EqpRow {
id: next_id,
parent: 0,
notused: 0,
detail,
});
if let P4::Index(name) = &op.p4 {
index_step_by_cursor.insert(op.p1, rows.len() - 1);
index_name_by_cursor.insert(op.p1, name.clone());
}
next_id += 1;
}
Opcode::SeekGE
| Opcode::SeekLE
| Opcode::SeekGT
| Opcode::SeekLT
| Opcode::IdxGE
| Opcode::IdxGT
| Opcode::IdxLE
| Opcode::IdxLT
| Opcode::Rewind
| Opcode::Last
| Opcode::Next
| Opcode::Prev => {
if index_name_by_cursor.contains_key(&op.p1) {
index_used.insert(op.p1);
}
}
Opcode::IdxRowid => {
if index_name_by_cursor.contains_key(&op.p1) {
index_used.insert(op.p1);
last_idxrowid_cursor = Some(op.p1);
}
}
Opcode::SeekRowid => {
if let Some(index_cursor) = last_idxrowid_cursor {
index_with_table_lookup.insert(index_cursor);
}
last_idxrowid_cursor = None;
}
Opcode::SorterOpen | Opcode::SorterInsert | Opcode::SorterSort => {
saw_sorter = true;
}
_ => {
last_idxrowid_cursor = None;
}
}
}
for index_cursor in index_used {
let Some(step_idx) = index_step_by_cursor.get(&index_cursor).copied() else {
continue;
};
let Some(index_name) = index_name_by_cursor.get(&index_cursor) else {
continue;
};
let usage = if index_with_table_lookup.contains(&index_cursor) {
format!(" USING INDEX {index_name}")
} else {
format!(" USING COVERING INDEX {index_name}")
};
if !rows[step_idx].detail.contains("USING ") {
rows[step_idx].detail.push_str(&usage);
}
}
if saw_sorter {
rows.push(EqpRow {
id: next_id,
parent: 0,
notused: 0,
detail: "USE TEMP B-TREE FOR ORDER BY".to_owned(),
});
}
if rows.is_empty() {
rows.push(EqpRow {
id: 1,
parent: 0,
notused: 0,
detail: "SCAN CONSTANT ROW".to_owned(),
});
}
rows
}
#[cfg(test)]
mod tests {
use super::*;
use fsqlite_types::opcode::{Opcode, P4};
use fsqlite_vdbe::ProgramBuilder;
fn build_simple_select_program() -> VdbeProgram {
let mut b = ProgramBuilder::new();
let end_label = b.emit_label();
let done_label = b.emit_label();
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
b.emit_op(Opcode::OpenRead, 0, 2, 0, P4::Table("t".to_owned()), 0);
b.emit_jump_to_label(Opcode::Rewind, 0, 0, done_label, P4::None, 0);
b.emit_op(Opcode::Column, 0, 0, 1, P4::None, 0);
b.emit_op(Opcode::ResultRow, 1, 1, 0, P4::None, 0);
b.emit_op(Opcode::Next, 0, 4, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Close, 0, 0, 0, P4::None, 0);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
b.finish().unwrap()
}
#[test]
fn test_explain_returns_bytecode() {
let prog = build_simple_select_program();
let rows = explain_program(&prog);
assert!(!rows.is_empty());
assert_eq!(rows[0].addr, 0);
let init_row = &rows[0];
assert_eq!(init_row.opcode, "Init");
assert_eq!(init_row.p5, 0);
assert!(init_row.comment.contains("start at"));
}
#[test]
fn test_explain_query_plan_columns() {
let prog = build_simple_select_program();
let rows = explain_query_plan(&prog);
assert!(!rows.is_empty());
let row = &rows[0];
assert!(row.id > 0);
assert_eq!(row.parent, 0);
assert_eq!(row.notused, 0);
assert!(!row.detail.is_empty());
}
#[test]
fn test_explain_query_plan_shows_index() {
let mut b = ProgramBuilder::new();
let end_label = b.emit_label();
let done_label = b.emit_label();
let skip_label = b.emit_label();
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
b.emit_op(Opcode::OpenRead, 0, 2, 0, P4::Table("t".to_owned()), 0);
b.emit_op(
Opcode::OpenRead,
1,
3,
0,
P4::Index("idx_t_a".to_owned()),
0,
);
b.emit_jump_to_label(Opcode::Rewind, 1, 0, done_label, P4::None, 0);
b.emit_op(Opcode::IdxRowid, 1, 2, 0, P4::None, 0);
b.emit_jump_to_label(Opcode::SeekRowid, 0, 2, skip_label, P4::None, 0);
b.emit_op(Opcode::Column, 0, 0, 1, P4::None, 0);
b.emit_op(Opcode::ResultRow, 1, 1, 0, P4::None, 0);
b.resolve_label(skip_label);
b.emit_op(Opcode::Next, 1, 5, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
let prog = b.finish().unwrap();
let rows = explain_query_plan(&prog);
let has_index = rows.iter().any(|r| r.detail.contains("USING INDEX"));
assert!(has_index, "EQP should show index usage, got: {rows:?}");
}
#[test]
fn test_explain_query_plan_tree_structure() {
let prog = build_simple_select_program();
let rows = explain_query_plan(&prog);
for row in &rows {
if row.parent == 0 {
assert!(row.id > 0);
} else {
assert!(rows.iter().any(|r| r.id == row.parent));
}
}
let mut ids: Vec<i32> = rows.iter().map(|r| r.id).collect();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), rows.len());
}
#[test]
fn test_explain_query_plan_shows_covering_index() {
let mut b = ProgramBuilder::new();
let end_label = b.emit_label();
let done_label = b.emit_label();
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
b.emit_op(
Opcode::OpenRead,
1,
3,
0,
P4::Index("idx_t_b".to_owned()),
0,
);
b.emit_jump_to_label(Opcode::Rewind, 1, 0, done_label, P4::None, 0);
b.emit_op(Opcode::Column, 1, 0, 1, P4::None, 0);
b.emit_op(Opcode::ResultRow, 1, 1, 0, P4::None, 0);
b.emit_op(Opcode::Next, 1, 4, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
let prog = b.finish().unwrap();
let rows = explain_query_plan(&prog);
let has_covering = rows
.iter()
.any(|row| row.detail.contains("USING COVERING INDEX idx_t_b"));
assert!(
has_covering,
"EQP should show covering-index usage, got: {rows:?}"
);
}
#[test]
fn test_explain_query_plan_shows_temp_btree_for_order_by() {
let mut b = ProgramBuilder::new();
let end_label = b.emit_label();
let done_label = b.emit_label();
b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
b.emit_op(Opcode::OpenRead, 0, 2, 0, P4::Table("t".to_owned()), 0);
b.emit_op(Opcode::SorterOpen, 1, 1, 0, P4::Str("+".to_owned()), 0);
b.emit_jump_to_label(Opcode::Rewind, 0, 0, done_label, P4::None, 0);
b.emit_op(Opcode::Column, 0, 0, 2, P4::None, 0);
b.emit_op(Opcode::MakeRecord, 2, 1, 3, P4::None, 0);
b.emit_op(Opcode::SorterInsert, 1, 3, 0, P4::None, 0);
b.resolve_label(done_label);
b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
b.resolve_label(end_label);
let prog = b.finish().unwrap();
let rows = explain_query_plan(&prog);
let has_temp_btree = rows
.iter()
.any(|row| row.detail == "USE TEMP B-TREE FOR ORDER BY");
assert!(
has_temp_btree,
"EQP should include temp B-tree marker for sorter plans, got: {rows:?}"
);
}
}