Merge 'Handle EXPLAIN QUERY PLAN like SQLite' from Lâm Hoàng Phúc

After this PR:
```
turso> EXPLAIN QUERY PLAN SELECT 1;
QUERY PLAN
`--SCAN CONSTANT ROW
turso> EXPLAIN QUERY PLAN SELECT 1 UNION SELECT 1;
QUERY PLAN
`--COMPOUND QUERY
   |--LEFT-MOST SUBQUERY
   |  `--SCAN CONSTANT ROW
   `--UNION USING TEMP B-TREE
      `--SCAN CONSTANT ROW
turso> CREATE TABLE x(y);
turso> CREATE TABLE z(y);
turso> EXPLAIN QUERY PLAN SELECT * from x,z;
QUERY PLAN
|--SCAN x
`--SCAN z
turso> EXPLAIN QUERY PLAN SELECT * from x,z ON x.y = z.y;
QUERY PLAN
|--SCAN x
`--SEARCH z USING INDEX ephemeral_z_t2
turso>
```

Closes #3057
This commit is contained in:
Pekka Enberg
2025-09-12 20:41:23 +03:00
committed by GitHub
9 changed files with 316 additions and 90 deletions

View File

@@ -740,6 +740,77 @@ impl Limbo {
) -> anyhow::Result<()> {
match output {
Ok(Some(ref mut rows)) => match (self.opts.output_mode, rows.get_query_mode()) {
(_, QueryMode::ExplainQueryPlan) => {
struct Entry {
id: usize,
detail: String,
child_prefix: String,
children: Vec<Entry>,
}
let mut root = Entry {
id: 0,
detail: "QUERY PLAN".to_owned(),
child_prefix: "".to_owned(),
children: vec![],
};
fn add_children(
id: usize,
parent_id: usize,
detail: String,
current: &mut Entry,
) -> bool {
if current.id == parent_id {
current.children.push(Entry {
id,
detail,
child_prefix: current.child_prefix.clone() + " ",
children: vec![],
});
if current.children.len() > 1 {
let idx = current.children.len() - 2;
current.children[idx].child_prefix =
current.child_prefix.clone() + "| ";
}
return false;
}
for child in &mut current.children {
if !add_children(id, parent_id, detail.clone(), child) {
return false;
}
}
true
}
fn print_entry(app: &mut Limbo, entry: &Entry, prefix: &str) {
writeln!(app, "{}{}", prefix, entry.detail).unwrap();
for (i, child) in entry.children.iter().enumerate() {
let is_last = i == entry.children.len() - 1;
let child_prefix = format!(
"{}{}",
entry.child_prefix,
if is_last { "`--" } else { "|--" }
);
print_entry(app, child, child_prefix.as_str());
}
}
loop {
row_step_result_query!(self, sql, rows, statistics, {
let row = rows.row().unwrap();
let id: usize = row.get_value(0).as_uint() as usize;
let parent_id: usize = row.get_value(1).as_uint() as usize;
let detail = row.get_value(3).to_string();
add_children(id, parent_id, detail, &mut root);
});
}
print_entry(self, &root, "");
}
(_, QueryMode::Explain) => {
fn get_explain_indent(
indent_count: usize,

View File

@@ -41,7 +41,6 @@ mod numeric;
use crate::incremental::view::AllViewsTxState;
use crate::storage::encryption::CipherMode;
use crate::translate::optimizer::optimize_plan;
use crate::translate::pragma::TURSO_CDC_DEFAULT_TABLE_NAME;
#[cfg(all(feature = "fs", feature = "conn_raw_api"))]
use crate::types::{WalFrameInfo, WalState};
@@ -67,7 +66,6 @@ use std::{
cell::{Cell, RefCell},
collections::HashMap,
fmt::{self, Display},
io::Write,
num::NonZero,
ops::Deref,
rc::Rc,
@@ -91,7 +89,6 @@ pub use storage::{
wal::{CheckpointMode, CheckpointResult, Wal, WalFile, WalFileShared},
};
use tracing::{instrument, Level};
use translate::select::prepare_select_plan;
use turso_macros::match_ignore_ascii_case;
use turso_parser::{ast, ast::Cmd, parser::Parser};
use types::IOResult;
@@ -99,7 +96,6 @@ pub use types::RefValue;
pub use types::Value;
use util::parse_schema_rows;
pub use util::IOExt;
use vdbe::builder::TableRefIdCounter;
pub use vdbe::{builder::QueryMode, explain::EXPLAIN_COLUMNS, explain::EXPLAIN_QUERY_PLAN_COLUMNS};
/// Configuration for database features
@@ -1168,43 +1164,18 @@ impl Connection {
let syms = self.syms.borrow();
let pager = self.pager.borrow().clone();
let mode = QueryMode::new(&cmd);
match cmd {
Cmd::Stmt(ref stmt) | Cmd::Explain(ref stmt) => {
let program = translate::translate(
self.schema.borrow().deref(),
stmt.clone(),
pager.clone(),
self.clone(),
&syms,
mode,
input,
)?;
let stmt = Statement::new(program, self._db.mv_store.clone(), pager, mode);
Ok(Some(stmt))
}
Cmd::ExplainQueryPlan(stmt) => {
let mut table_ref_counter = TableRefIdCounter::new();
// TODO: we need OP_Explain
match stmt {
ast::Stmt::Select(select) => {
let mut plan = prepare_select_plan(
self.schema.borrow().deref(),
select,
&syms,
&[],
&mut table_ref_counter,
translate::plan::QueryDestination::ResultRows,
&self.clone(),
)?;
optimize_plan(&mut plan, self.schema.borrow().deref())?;
let _ = std::io::stdout().write_all(plan.to_string().as_bytes());
}
_ => todo!(),
}
Ok(None)
}
}
let (Cmd::Stmt(stmt) | Cmd::Explain(stmt) | Cmd::ExplainQueryPlan(stmt)) = cmd;
let program = translate::translate(
self.schema.borrow().deref(),
stmt.clone(),
pager.clone(),
self.clone(),
&syms,
mode,
input,
)?;
let stmt = Statement::new(program, self._db.mv_store.clone(), pager, mode);
Ok(Some(stmt))
}
pub fn query_runner<'a>(self: &'a Arc<Connection>, sql: &'a [u8]) -> QueryRunner<'a> {
@@ -2075,7 +2046,6 @@ pub struct Statement {
/// Used to determine whether we need to check for schema changes when
/// starting a transaction.
accesses_db: bool,
/// indicates if the statement is a NORMAL/EXPLAIN/EXPLAIN QUERY PLAN
query_mode: QueryMode,
/// Flag to show if the statement was busy
@@ -2090,14 +2060,12 @@ impl Statement {
query_mode: QueryMode,
) -> Self {
let accesses_db = program.accesses_db;
let state = vdbe::ProgramState::new(
match query_mode {
QueryMode::Normal => program.max_registers,
QueryMode::Explain => EXPLAIN_COLUMNS.len(),
QueryMode::ExplainQueryPlan => EXPLAIN_QUERY_PLAN_COLUMNS.len(),
},
program.cursor_ref.len(),
);
let (max_registers, cursor_count) = match query_mode {
QueryMode::Normal => (program.max_registers, program.cursor_ref.len()),
QueryMode::Explain => (EXPLAIN_COLUMNS.len(), 0),
QueryMode::ExplainQueryPlan => (EXPLAIN_QUERY_PLAN_COLUMNS.len(), 0),
};
let state = vdbe::ProgramState::new(max_registers, cursor_count);
Self {
program,
state,
@@ -2225,14 +2193,12 @@ impl Statement {
};
// Save parameters before they are reset
let parameters = std::mem::take(&mut self.state.parameters);
self._reset(
Some(match self.query_mode {
QueryMode::Normal => self.program.max_registers,
QueryMode::Explain => EXPLAIN_COLUMNS.len(),
QueryMode::ExplainQueryPlan => EXPLAIN_QUERY_PLAN_COLUMNS.len(),
}),
Some(self.program.cursor_ref.len()),
);
let (max_registers, cursor_count) = match self.query_mode {
QueryMode::Normal => (self.program.max_registers, self.program.cursor_ref.len()),
QueryMode::Explain => (EXPLAIN_COLUMNS.len(), 0),
QueryMode::ExplainQueryPlan => (EXPLAIN_QUERY_PLAN_COLUMNS.len(), 0),
};
self._reset(Some(max_registers), Some(cursor_count));
// Load the parameters back into the state
self.state.parameters = parameters;
Ok(())

View File

@@ -6,7 +6,7 @@ use crate::translate::result_row::try_fold_expr_to_i64;
use crate::vdbe::builder::{CursorType, ProgramBuilder};
use crate::vdbe::insn::Insn;
use crate::vdbe::BranchOffset;
use crate::SymbolTable;
use crate::{emit_explain, QueryMode, SymbolTable};
use std::sync::Arc;
use tracing::instrument;
use turso_parser::ast::{CompoundOperator, SortOrder};
@@ -98,6 +98,7 @@ pub fn emit_program_for_compound_select(
_ => (None, None),
};
emit_explain!(program, true, "COMPOUND QUERY".to_owned());
emit_compound_select(
program,
plan,
@@ -108,6 +109,7 @@ pub fn emit_program_for_compound_select(
yield_reg,
reg_result_cols_start,
)?;
program.pop_current_parent_explain();
program.result_columns = right_plan.result_columns;
program.table_references.extend(right_plan.table_references);
@@ -187,7 +189,10 @@ fn emit_compound_select(
right_most.offset = offset;
right_most_ctx.reg_offset = offset_reg;
}
emit_explain!(program, true, "UNION ALL".to_owned());
emit_query(program, &mut right_most, &mut right_most_ctx)?;
program.pop_current_parent_explain();
program.preassign_label_to_next_insn(label_next_select);
}
CompoundOperator::Union => {
@@ -229,7 +234,10 @@ fn emit_compound_select(
index: dedupe_index.1.clone(),
is_delete: false,
};
emit_explain!(program, true, "UNION USING TEMP B-TREE".to_owned());
emit_query(program, &mut right_most, &mut right_most_ctx)?;
program.pop_current_parent_explain();
if new_dedupe_index {
read_deduplicated_union_or_except_rows(
@@ -282,7 +290,9 @@ fn emit_compound_select(
index: right_index,
is_delete: false,
};
emit_explain!(program, true, "INTERSECT USING TEMP B-TREE".to_owned());
emit_query(program, &mut right_most, &mut right_most_ctx)?;
program.pop_current_parent_explain();
read_intersect_rows(
program,
left_cursor_id,
@@ -332,7 +342,9 @@ fn emit_compound_select(
index: index.clone(),
is_delete: true,
};
emit_explain!(program, true, "EXCEPT USING TEMP B-TREE".to_owned());
emit_query(program, &mut right_most, &mut right_most_ctx)?;
program.pop_current_parent_explain();
if new_index {
read_deduplicated_union_or_except_rows(
program, cursor_id, &index, limit_ctx, offset_reg, yield_reg,
@@ -349,7 +361,9 @@ fn emit_compound_select(
right_most.offset = offset;
right_most_ctx.reg_offset = offset_reg;
}
emit_explain!(program, true, "LEFT-MOST SUBQUERY".to_owned());
emit_query(program, &mut right_most, &mut right_most_ctx)?;
program.pop_current_parent_explain();
}
}

View File

@@ -1,6 +1,7 @@
use turso_parser::ast::{self, SortOrder};
use crate::{
emit_explain,
schema::PseudoCursorType,
translate::collate::CollationSeq,
util::exprs_are_equivalent,
@@ -8,7 +9,7 @@ use crate::{
builder::{CursorType, ProgramBuilder},
insn::Insn,
},
Result,
QueryMode, Result,
};
use super::{
@@ -101,6 +102,10 @@ pub fn emit_order_by(
let sorter_column_count =
order_by.len() + remappings.iter().filter(|r| !r.deduplicated).count();
// TODO: we need to know how many indices used for sorting
// to emit correct explain output.
emit_explain!(program, false, "USE TEMP B-TREE FOR ORDER BY".to_owned());
let pseudo_cursor = program.alloc_cursor_id(CursorType::Pseudo(PseudoCursorType {
column_count: sorter_column_count,
}));

View File

@@ -1,13 +1,14 @@
use crate::{
emit_explain,
schema::Table,
vdbe::{builder::ProgramBuilder, insn::Insn},
Result,
QueryMode, Result,
};
use super::{
emitter::{emit_query, Resolver, TranslateCtx},
main_loop::LoopLabels,
plan::{QueryDestination, SelectPlan, TableReferences},
plan::{Operation, QueryDestination, Search, SelectPlan, TableReferences},
};
/// Emit the subqueries contained in the FROM clause.
@@ -17,7 +18,45 @@ pub fn emit_subqueries(
t_ctx: &mut TranslateCtx,
tables: &mut TableReferences,
) -> Result<()> {
if tables.joined_tables().is_empty() {
emit_explain!(program, false, "SCAN CONSTANT ROW".to_owned());
}
for table_reference in tables.joined_tables_mut() {
emit_explain!(
program,
true,
match &table_reference.op {
Operation::Scan { .. } => {
if table_reference.table.get_name() == table_reference.identifier {
format!("SCAN {}", table_reference.identifier)
} else {
format!(
"SCAN {} AS {}",
table_reference.table.get_name(),
table_reference.identifier
)
}
}
Operation::Search(search) => match search {
Search::RowidEq { .. } | Search::Seek { index: None, .. } => {
format!(
"SEARCH {} USING INTEGER PRIMARY KEY (rowid=?)",
table_reference.identifier
)
}
Search::Seek {
index: Some(index), ..
} => {
format!(
"SEARCH {} USING INDEX {}",
table_reference.identifier, index.name
)
}
},
}
);
if let Table::FromClauseSubquery(from_clause_subquery) = &mut table_reference.table {
// Emit the subquery and get the start register of the result columns.
let result_columns_start =
@@ -27,6 +66,8 @@ pub fn emit_subqueries(
// as if it were reading from a regular table.
from_clause_subquery.result_columns_start_reg = Some(result_columns_start);
}
program.pop_current_parent_explain();
}
Ok(())
}

View File

@@ -100,7 +100,7 @@ pub struct ProgramBuilder {
// Bitmask of cursors that have emitted a SeekRowid instruction.
seekrowid_emitted_bitmask: u64,
// map of instruction index to manual comment (used in EXPLAIN only)
comments: Option<Vec<(InsnReference, &'static str)>>,
comments: Vec<(InsnReference, &'static str)>,
pub parameters: Parameters,
pub result_columns: Vec<ResultSetColumn>,
pub table_references: TableReferences,
@@ -114,6 +114,10 @@ pub struct ProgramBuilder {
// TODO: when we support multiple dbs, this should be a write mask to track which DBs need to be written
txn_mode: TransactionMode,
rollback: bool,
/// The mode in which the query is being executed.
query_mode: QueryMode,
/// Current parent explain address, if any.
current_parent_explain_idx: Option<usize>,
}
#[derive(Debug, Clone)]
@@ -158,6 +162,18 @@ pub struct ProgramBuilderOpts {
pub approx_num_labels: usize,
}
/// Use this macro to emit an OP_Explain instruction.
/// Please use this macro instead of calling emit_explain() directly,
/// because we want to avoid allocating a String if we are not in explain mode.
#[macro_export]
macro_rules! emit_explain {
($builder:expr, $push:expr, $detail:expr) => {
if let QueryMode::ExplainQueryPlan = $builder.get_query_mode() {
$builder.emit_explain($push, $detail);
}
};
}
impl ProgramBuilder {
pub fn new(
query_mode: QueryMode,
@@ -173,11 +189,7 @@ impl ProgramBuilder {
constant_spans: Vec::new(),
label_to_resolved_offset: Vec::with_capacity(opts.approx_num_labels),
seekrowid_emitted_bitmask: 0,
comments: if let QueryMode::Explain | QueryMode::ExplainQueryPlan = query_mode {
Some(Vec::new())
} else {
None
},
comments: Vec::new(),
parameters: Parameters::new(),
result_columns: Vec::new(),
table_references: TableReferences::new(vec![], vec![]),
@@ -189,6 +201,8 @@ impl ProgramBuilder {
capture_data_changes_mode,
txn_mode: TransactionMode::None,
rollback: false,
query_mode,
current_parent_explain_idx: None,
}
}
@@ -378,8 +392,40 @@ impl ProgramBuilder {
}
pub fn add_comment(&mut self, insn_index: BranchOffset, comment: &'static str) {
if let Some(comments) = &mut self.comments {
comments.push((insn_index.as_offset_int(), comment));
if let QueryMode::Explain | QueryMode::ExplainQueryPlan = self.query_mode {
self.comments.push((insn_index.as_offset_int(), comment));
}
}
pub fn get_query_mode(&self) -> QueryMode {
self.query_mode
}
/// use emit_explain macro instead, because we don't want to allocate
/// String if we are not in explain mode
pub fn emit_explain(&mut self, push: bool, detail: String) {
if let QueryMode::ExplainQueryPlan = self.query_mode {
self.emit_insn(Insn::Explain {
p1: self.insns.len(),
p2: self.current_parent_explain_idx,
detail,
});
if push {
self.current_parent_explain_idx = Some(self.insns.len() - 1);
}
}
}
pub fn pop_current_parent_explain(&mut self) {
if let QueryMode::ExplainQueryPlan = self.query_mode {
if let Some(current) = self.current_parent_explain_idx {
let (Insn::Explain { p2, .. }, _, _) = &self.insns[current] else {
unreachable!("current_parent_explain_idx must point to an Explain insn");
};
self.current_parent_explain_idx = *p2;
}
} else {
debug_assert!(self.current_parent_explain_idx.is_none())
}
}
@@ -432,14 +478,44 @@ impl ProgramBuilder {
}
// Fix comments to refer to new locations
if let Some(comments) = &mut self.comments {
for (old_offset, _) in comments.iter_mut() {
let new_offset = self
.insns
.iter()
.position(|(_, _, index)| *old_offset == *index as u32)
.expect("comment must exist") as u32;
*old_offset = new_offset;
for (old_offset, _) in self.comments.iter_mut() {
let new_offset = self
.insns
.iter()
.position(|(_, _, index)| *old_offset == *index as u32)
.expect("comment must exist") as u32;
*old_offset = new_offset;
}
if let QueryMode::ExplainQueryPlan = self.query_mode {
self.current_parent_explain_idx =
if let Some(old_parent) = self.current_parent_explain_idx {
self.insns
.iter()
.position(|(_, _, index)| old_parent == *index)
} else {
None
};
for i in 0..self.insns.len() {
let (Insn::Explain { p2, .. }, _, _) = &self.insns[i] else {
continue;
};
let new_p2 = if p2.is_some() {
self.insns
.iter()
.position(|(_, _, index)| *p2 == Some(*index))
} else {
None
};
let (Insn::Explain { p1, p2, .. }, _, _) = &mut self.insns[i] else {
unreachable!();
};
*p1 = i;
*p2 = new_p2;
}
}
}

View File

@@ -1723,6 +1723,15 @@ pub fn insn_to_row(
0,
format!("if (r[{}] < 0) goto {}", reg, target_pc.as_debug_int()),
),
Insn::Explain { p1, p2, detail } => (
"Explain",
*p1 as i32,
p2.as_ref().map(|p| *p).unwrap_or(0) as i32,
0,
Value::build_text(detail.as_str()),
0,
String::new(),
),
}
}

View File

@@ -1086,6 +1086,13 @@ pub enum Insn {
reg: usize,
target_pc: BranchOffset,
},
// OP_Explain
Explain {
p1: usize, // P1: address of instruction
p2: Option<usize>, // P2: address of parent explain instruction
detail: String, // P4: detail text
},
}
impl Insn {
@@ -1224,6 +1231,7 @@ impl Insn {
Insn::MaxPgcnt { .. } => execute::op_max_pgcnt,
Insn::JournalMode { .. } => execute::op_journal_mode,
Insn::IfNeg { .. } => execute::op_if_neg,
Insn::Explain { .. } => execute::op_noop,
}
}
}

View File

@@ -477,7 +477,7 @@ pub struct Program {
pub max_registers: usize,
pub insns: Vec<(Insn, InsnFunction)>,
pub cursor_ref: Vec<(Option<CursorKey>, CursorType)>,
pub comments: Option<Vec<(InsnReference, &'static str)>>,
pub comments: Vec<(InsnReference, &'static str)>,
pub parameters: crate::parameters::Parameters,
pub connection: Arc<Connection>,
pub n_change: Cell<i64>,
@@ -541,13 +541,11 @@ impl Program {
let (opcode, p1, p2, p3, p4, p5, comment) = insn_to_row_with_comment(
self,
current_insn,
self.comments.as_ref().and_then(|comments| {
comments
.iter()
.find(|(offset, _)| *offset == state.pc)
.map(|(_, comment)| comment)
.copied()
}),
self.comments
.iter()
.find(|(offset, _)| *offset == state.pc)
.map(|(_, comment)| comment)
.copied(),
);
state.registers[0] = Register::Value(Value::Integer(state.pc as i64));
@@ -570,10 +568,47 @@ impl Program {
&self,
state: &mut ProgramState,
_mv_store: Option<Arc<MvStore>>,
_pager: Rc<Pager>,
pager: Rc<Pager>,
) -> Result<StepResult> {
debug_assert!(state.column_count() == EXPLAIN_QUERY_PLAN_COLUMNS.len());
todo!("we need OP_Explain to be implemented first")
loop {
if self.connection.closed.get() {
// Connection is closed for whatever reason, rollback the transaction.
let state = self.connection.transaction_state.get();
if let TransactionState::Write { .. } = state {
pager.io.block(|| pager.end_tx(true, &self.connection))?;
}
return Err(LimboError::InternalError("Connection closed".to_string()));
}
if state.is_interrupted() {
return Ok(StepResult::Interrupt);
}
// FIXME: do we need this?
state.metrics.vm_steps = state.metrics.vm_steps.saturating_add(1);
if state.pc as usize >= self.insns.len() {
return Ok(StepResult::Done);
}
let Insn::Explain { p1, p2, detail } = &self.insns[state.pc as usize].0 else {
state.pc += 1;
continue;
};
state.registers[0] = Register::Value(Value::Integer(*p1 as i64));
state.registers[1] =
Register::Value(Value::Integer(p2.as_ref().map(|p| *p).unwrap_or(0) as i64));
state.registers[2] = Register::Value(Value::Integer(0));
state.registers[3] = Register::Value(Value::from_text(detail.as_str()));
state.result_row = Some(Row {
values: &state.registers[0] as *const Register,
count: EXPLAIN_QUERY_PLAN_COLUMNS.len(),
});
state.pc += 1;
return Ok(StepResult::Row);
}
}
#[instrument(skip_all, level = Level::DEBUG)]
@@ -943,11 +978,12 @@ fn trace_insn(program: &Program, addr: InsnReference, insn: &Insn) {
addr,
insn,
String::new(),
program.comments.as_ref().and_then(|comments| comments
program
.comments
.iter()
.find(|(offset, _)| *offset == addr)
.map(|(_, comment)| comment)
.copied())
.copied()
)
);
}