Skip to main content

fsqlite_core/
explain.rs

1//! EXPLAIN and EXPLAIN QUERY PLAN execution (§12.12, bd-7pxb).
2//!
3//! EXPLAIN returns VDBE bytecode as a result set with columns:
4//!   addr, opcode, p1, p2, p3, p4, p5, comment
5//!
6//! EXPLAIN QUERY PLAN returns a tree-structured plan with columns:
7//!   id, parent, notused, detail
8
9use std::collections::{HashMap, HashSet};
10
11use fsqlite_types::opcode::{Opcode, P4};
12use fsqlite_vdbe::VdbeProgram;
13
14// ---------------------------------------------------------------------------
15// EXPLAIN result row
16// ---------------------------------------------------------------------------
17
18/// A single row from EXPLAIN output (invariant #10).
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ExplainRow {
21    /// Instruction address (0-based).
22    pub addr: i32,
23    /// Opcode name (e.g., "Init", "OpenRead").
24    pub opcode: String,
25    /// First parameter.
26    pub p1: i32,
27    /// Second parameter (often a jump target).
28    pub p2: i32,
29    /// Third parameter.
30    pub p3: i32,
31    /// Fourth parameter (text representation).
32    pub p4: String,
33    /// Fifth parameter (flags).
34    pub p5: u16,
35    /// Comment (auto-generated from opcode semantics).
36    pub comment: String,
37}
38
39/// Generate EXPLAIN output for a compiled VDBE program.
40///
41/// Returns one row per instruction with columns:
42/// addr, opcode, p1, p2, p3, p4, p5, comment (invariant #10).
43#[must_use]
44pub fn explain_program(program: &VdbeProgram) -> Vec<ExplainRow> {
45    program
46        .ops()
47        .iter()
48        .enumerate()
49        .map(|(i, op)| {
50            #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
51            let addr = i as i32;
52            ExplainRow {
53                addr,
54                opcode: format!("{:?}", op.opcode),
55                p1: op.p1,
56                p2: op.p2,
57                p3: op.p3,
58                p4: format!("{:?}", op.p4),
59                p5: op.p5,
60                comment: opcode_comment(op.opcode, op.p1, op.p2, op.p3),
61            }
62        })
63        .collect()
64}
65
66/// Auto-generate a comment for an opcode based on its semantics.
67fn opcode_comment(opcode: Opcode, p1: i32, p2: i32, p3: i32) -> String {
68    match opcode {
69        Opcode::Init => format!("start at {p2}"),
70        Opcode::Goto => format!("goto {p2}"),
71        Opcode::Halt => {
72            if p1 == 0 {
73                String::new()
74            } else {
75                format!("error code {p1}")
76            }
77        }
78        Opcode::Transaction => {
79            if p2 == 0 {
80                "read transaction".to_owned()
81            } else {
82                "write transaction".to_owned()
83            }
84        }
85        Opcode::OpenRead | Opcode::OpenWrite => format!("root={p2}"),
86        Opcode::Column => format!("r[{p3}]=cursor[{p1}].column[{p2}]"),
87        Opcode::ResultRow => format!("output r[{p1}..{p1}+{p2}]"),
88        Opcode::Rewind => format!("if eof goto {p2}"),
89        Opcode::Next => format!("goto {p2} if more rows"),
90        Opcode::Close => format!("close cursor {p1}"),
91        _ => String::new(),
92    }
93}
94
95// ---------------------------------------------------------------------------
96// EXPLAIN QUERY PLAN
97// ---------------------------------------------------------------------------
98
99/// A single row from EXPLAIN QUERY PLAN output (invariant #11).
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct EqpRow {
102    /// Node id in the plan tree.
103    pub id: i32,
104    /// Parent node id (0 for root nodes).
105    pub parent: i32,
106    /// Not used (always 0, kept for compatibility).
107    pub notused: i32,
108    /// Human-readable description of this plan step.
109    pub detail: String,
110}
111
112/// Generate EXPLAIN QUERY PLAN output for a compiled VDBE program.
113///
114/// Returns a tree-structured plan with columns: id, parent, notused, detail
115/// (invariant #11). The tree structure is expressed via id/parent relationships
116/// (invariant #23).
117#[must_use]
118pub fn explain_query_plan(program: &VdbeProgram) -> Vec<EqpRow> {
119    let ops = program.ops();
120    let mut rows = Vec::new();
121    let mut next_id = 1_i32;
122    let mut index_step_by_cursor: HashMap<i32, usize> = HashMap::new();
123    let mut index_name_by_cursor: HashMap<i32, String> = HashMap::new();
124    let mut index_used = HashSet::new();
125    let mut index_with_table_lookup = HashSet::new();
126    let mut last_idxrowid_cursor = None;
127    let mut saw_sorter = false;
128
129    // Scan for table/index opens and infer index/sorter usage.
130    for op in ops {
131        match op.opcode {
132            Opcode::OpenRead | Opcode::OpenWrite => {
133                let scan_type = if op.opcode == Opcode::OpenRead {
134                    "SCAN"
135                } else {
136                    "SEARCH"
137                };
138                let detail = match &op.p4 {
139                    P4::Table(name) => format!("{scan_type} {name}"),
140                    P4::Index(name) => format!("{scan_type} INDEX {name}"),
141                    _ => format!("{scan_type} {:?}", op.p4),
142                };
143                rows.push(EqpRow {
144                    id: next_id,
145                    parent: 0,
146                    notused: 0,
147                    detail,
148                });
149                if let P4::Index(name) = &op.p4 {
150                    index_step_by_cursor.insert(op.p1, rows.len() - 1);
151                    index_name_by_cursor.insert(op.p1, name.clone());
152                }
153                next_id += 1;
154            }
155            Opcode::SeekGE
156            | Opcode::SeekLE
157            | Opcode::SeekGT
158            | Opcode::SeekLT
159            | Opcode::IdxGE
160            | Opcode::IdxGT
161            | Opcode::IdxLE
162            | Opcode::IdxLT
163            | Opcode::Rewind
164            | Opcode::Last
165            | Opcode::Next
166            | Opcode::Prev => {
167                if index_name_by_cursor.contains_key(&op.p1) {
168                    index_used.insert(op.p1);
169                }
170            }
171            Opcode::IdxRowid => {
172                if index_name_by_cursor.contains_key(&op.p1) {
173                    index_used.insert(op.p1);
174                    last_idxrowid_cursor = Some(op.p1);
175                }
176            }
177            Opcode::SeekRowid => {
178                if let Some(index_cursor) = last_idxrowid_cursor {
179                    index_with_table_lookup.insert(index_cursor);
180                }
181                last_idxrowid_cursor = None;
182            }
183            Opcode::SorterOpen | Opcode::SorterInsert | Opcode::SorterSort => {
184                saw_sorter = true;
185            }
186            _ => {
187                last_idxrowid_cursor = None;
188            }
189        }
190    }
191
192    // Annotate index rows as covering vs non-covering.
193    for index_cursor in index_used {
194        let Some(step_idx) = index_step_by_cursor.get(&index_cursor).copied() else {
195            continue;
196        };
197        let Some(index_name) = index_name_by_cursor.get(&index_cursor) else {
198            continue;
199        };
200        let usage = if index_with_table_lookup.contains(&index_cursor) {
201            format!(" USING INDEX {index_name}")
202        } else {
203            format!(" USING COVERING INDEX {index_name}")
204        };
205        if !rows[step_idx].detail.contains("USING ") {
206            rows[step_idx].detail.push_str(&usage);
207        }
208    }
209
210    if saw_sorter {
211        rows.push(EqpRow {
212            id: next_id,
213            parent: 0,
214            notused: 0,
215            detail: "USE TEMP B-TREE FOR ORDER BY".to_owned(),
216        });
217    }
218
219    // If no table opens found, emit a minimal plan.
220    if rows.is_empty() {
221        rows.push(EqpRow {
222            id: 1,
223            parent: 0,
224            notused: 0,
225            detail: "SCAN CONSTANT ROW".to_owned(),
226        });
227    }
228
229    rows
230}
231
232// ---------------------------------------------------------------------------
233// Tests
234// ---------------------------------------------------------------------------
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use fsqlite_types::opcode::{Opcode, P4};
240    use fsqlite_vdbe::ProgramBuilder;
241
242    fn build_simple_select_program() -> VdbeProgram {
243        let mut b = ProgramBuilder::new();
244        let end_label = b.emit_label();
245        let done_label = b.emit_label();
246
247        b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
248        b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
249        b.emit_op(Opcode::OpenRead, 0, 2, 0, P4::Table("t".to_owned()), 0);
250        b.emit_jump_to_label(Opcode::Rewind, 0, 0, done_label, P4::None, 0);
251        b.emit_op(Opcode::Column, 0, 0, 1, P4::None, 0);
252        b.emit_op(Opcode::ResultRow, 1, 1, 0, P4::None, 0);
253        b.emit_op(Opcode::Next, 0, 4, 0, P4::None, 0);
254        b.resolve_label(done_label);
255        b.emit_op(Opcode::Close, 0, 0, 0, P4::None, 0);
256        b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
257        b.resolve_label(end_label);
258
259        b.finish().unwrap()
260    }
261
262    // === Test 20: EXPLAIN returns VDBE bytecode with correct columns ===
263    #[test]
264    fn test_explain_returns_bytecode() {
265        let prog = build_simple_select_program();
266        let rows = explain_program(&prog);
267
268        // Should have rows for each instruction.
269        assert!(!rows.is_empty());
270        assert_eq!(rows[0].addr, 0);
271
272        // Check column presence (addr, opcode, p1, p2, p3, p4, p5, comment).
273        let init_row = &rows[0];
274        assert_eq!(init_row.opcode, "Init");
275        // p5 is present.
276        assert_eq!(init_row.p5, 0);
277        // Comment is present.
278        assert!(init_row.comment.contains("start at"));
279    }
280
281    // === Test 21: EXPLAIN QUERY PLAN returns id, parent, notused, detail ===
282    #[test]
283    fn test_explain_query_plan_columns() {
284        let prog = build_simple_select_program();
285        let rows = explain_query_plan(&prog);
286
287        assert!(!rows.is_empty());
288        let row = &rows[0];
289        // Verify all four columns are present.
290        assert!(row.id > 0);
291        assert_eq!(row.parent, 0);
292        assert_eq!(row.notused, 0);
293        assert!(!row.detail.is_empty());
294    }
295
296    // === Test 22: EQP detail shows index usage ===
297    #[test]
298    fn test_explain_query_plan_shows_index() {
299        // Build a program that probes an index and then looks up table rows.
300        let mut b = ProgramBuilder::new();
301        let end_label = b.emit_label();
302        let done_label = b.emit_label();
303        let skip_label = b.emit_label();
304
305        b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
306        b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
307        b.emit_op(Opcode::OpenRead, 0, 2, 0, P4::Table("t".to_owned()), 0);
308        b.emit_op(
309            Opcode::OpenRead,
310            1,
311            3,
312            0,
313            P4::Index("idx_t_a".to_owned()),
314            0,
315        );
316        b.emit_jump_to_label(Opcode::Rewind, 1, 0, done_label, P4::None, 0);
317        b.emit_op(Opcode::IdxRowid, 1, 2, 0, P4::None, 0);
318        b.emit_jump_to_label(Opcode::SeekRowid, 0, 2, skip_label, P4::None, 0);
319        b.emit_op(Opcode::Column, 0, 0, 1, P4::None, 0);
320        b.emit_op(Opcode::ResultRow, 1, 1, 0, P4::None, 0);
321        b.resolve_label(skip_label);
322        b.emit_op(Opcode::Next, 1, 5, 0, P4::None, 0);
323        b.resolve_label(done_label);
324        b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
325        b.resolve_label(end_label);
326
327        let prog = b.finish().unwrap();
328        let rows = explain_query_plan(&prog);
329
330        // Should show index usage.
331        let has_index = rows.iter().any(|r| r.detail.contains("USING INDEX"));
332        assert!(has_index, "EQP should show index usage, got: {rows:?}");
333    }
334
335    // === Test 23: EQP id/parent form correct tree ===
336    #[test]
337    fn test_explain_query_plan_tree_structure() {
338        let prog = build_simple_select_program();
339        let rows = explain_query_plan(&prog);
340
341        // Root nodes have parent=0.
342        for row in &rows {
343            if row.parent == 0 {
344                // Root node — id should be positive.
345                assert!(row.id > 0);
346            } else {
347                // Child node — parent should reference an existing node.
348                assert!(rows.iter().any(|r| r.id == row.parent));
349            }
350        }
351
352        // Ids should be unique.
353        let mut ids: Vec<i32> = rows.iter().map(|r| r.id).collect();
354        ids.sort_unstable();
355        ids.dedup();
356        assert_eq!(ids.len(), rows.len());
357    }
358
359    #[test]
360    fn test_explain_query_plan_shows_covering_index() {
361        let mut b = ProgramBuilder::new();
362        let end_label = b.emit_label();
363        let done_label = b.emit_label();
364
365        b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
366        b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
367        b.emit_op(
368            Opcode::OpenRead,
369            1,
370            3,
371            0,
372            P4::Index("idx_t_b".to_owned()),
373            0,
374        );
375        b.emit_jump_to_label(Opcode::Rewind, 1, 0, done_label, P4::None, 0);
376        b.emit_op(Opcode::Column, 1, 0, 1, P4::None, 0);
377        b.emit_op(Opcode::ResultRow, 1, 1, 0, P4::None, 0);
378        b.emit_op(Opcode::Next, 1, 4, 0, P4::None, 0);
379        b.resolve_label(done_label);
380        b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
381        b.resolve_label(end_label);
382
383        let prog = b.finish().unwrap();
384        let rows = explain_query_plan(&prog);
385        let has_covering = rows
386            .iter()
387            .any(|row| row.detail.contains("USING COVERING INDEX idx_t_b"));
388        assert!(
389            has_covering,
390            "EQP should show covering-index usage, got: {rows:?}"
391        );
392    }
393
394    #[test]
395    fn test_explain_query_plan_shows_temp_btree_for_order_by() {
396        let mut b = ProgramBuilder::new();
397        let end_label = b.emit_label();
398        let done_label = b.emit_label();
399
400        b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
401        b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
402        b.emit_op(Opcode::OpenRead, 0, 2, 0, P4::Table("t".to_owned()), 0);
403        b.emit_op(Opcode::SorterOpen, 1, 1, 0, P4::Str("+".to_owned()), 0);
404        b.emit_jump_to_label(Opcode::Rewind, 0, 0, done_label, P4::None, 0);
405        b.emit_op(Opcode::Column, 0, 0, 2, P4::None, 0);
406        b.emit_op(Opcode::MakeRecord, 2, 1, 3, P4::None, 0);
407        b.emit_op(Opcode::SorterInsert, 1, 3, 0, P4::None, 0);
408        b.resolve_label(done_label);
409        b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
410        b.resolve_label(end_label);
411
412        let prog = b.finish().unwrap();
413        let rows = explain_query_plan(&prog);
414        let has_temp_btree = rows
415            .iter()
416            .any(|row| row.detail == "USE TEMP B-TREE FOR ORDER BY");
417        assert!(
418            has_temp_btree,
419            "EQP should include temp B-tree marker for sorter plans, got: {rows:?}"
420        );
421    }
422}