1use std::collections::{HashMap, HashSet};
10
11use fsqlite_types::opcode::{Opcode, P4};
12use fsqlite_vdbe::VdbeProgram;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ExplainRow {
21 pub addr: i32,
23 pub opcode: String,
25 pub p1: i32,
27 pub p2: i32,
29 pub p3: i32,
31 pub p4: String,
33 pub p5: u16,
35 pub comment: String,
37}
38
39#[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
66fn 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#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct EqpRow {
102 pub id: i32,
104 pub parent: i32,
106 pub notused: i32,
108 pub detail: String,
110}
111
112#[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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct AggregateIndexSeek {
237 pub index_name: String,
239 pub covering: bool,
242}
243
244#[must_use]
261pub fn aggregate_index_seek_facts(program: &VdbeProgram) -> Option<AggregateIndexSeek> {
262 let ops = program.ops();
263 if !ops.iter().any(|op| op.opcode == Opcode::AggStep) {
264 return None;
265 }
266
267 let mut index_name_by_cursor: HashMap<i32, &str> = HashMap::new();
268 for op in ops {
269 if op.opcode == Opcode::OpenRead
270 && let P4::Index(name) = &op.p4
271 {
272 index_name_by_cursor.insert(op.p1, name.as_str());
273 }
274 }
275
276 let mut seeked: Vec<i32> = Vec::new();
277 for op in ops {
278 let positions_index = matches!(
279 op.opcode,
280 Opcode::SeekGE | Opcode::SeekGT | Opcode::SeekLE | Opcode::SeekLT
281 ) && index_name_by_cursor.contains_key(&op.p1);
282 if positions_index && !seeked.contains(&op.p1) {
283 seeked.push(op.p1);
284 }
285 }
286 let [cursor] = seeked[..] else {
287 return None;
288 };
289
290 let mut rowid_from_seeked_cursor = false;
294 let mut table_lookup = false;
295 for op in ops {
296 match op.opcode {
297 Opcode::IdxRowid => rowid_from_seeked_cursor = op.p1 == cursor,
298 Opcode::SeekRowid => {
299 table_lookup |= rowid_from_seeked_cursor;
300 rowid_from_seeked_cursor = false;
301 }
302 _ => rowid_from_seeked_cursor = false,
303 }
304 }
305
306 Some(AggregateIndexSeek {
307 index_name: (*index_name_by_cursor.get(&cursor)?).to_owned(),
308 covering: !table_lookup,
309 })
310}
311
312#[must_use]
324pub fn program_seeks_named_index(program: &VdbeProgram, index_name: &str) -> bool {
325 let ops = program.ops();
326 let mut index_cursors: HashSet<i32> = HashSet::new();
327 for op in ops {
328 if matches!(op.opcode, Opcode::OpenRead | Opcode::OpenWrite)
329 && let P4::Index(name) = &op.p4
330 && name == index_name
331 {
332 index_cursors.insert(op.p1);
333 }
334 }
335 if index_cursors.is_empty() {
336 return false;
337 }
338 ops.iter().any(|op| {
339 matches!(
340 op.opcode,
341 Opcode::SeekGE | Opcode::SeekGT | Opcode::SeekLE | Opcode::SeekLT
342 ) && index_cursors.contains(&op.p1)
343 })
344}
345
346#[must_use]
355pub fn program_seeks_rowid_on_table(program: &VdbeProgram, table_name: &str) -> bool {
356 let ops = program.ops();
357 let mut table_cursors: HashSet<i32> = HashSet::new();
358 for op in ops {
359 if matches!(op.opcode, Opcode::OpenRead | Opcode::OpenWrite)
360 && let P4::Table(name) = &op.p4
361 && name == table_name
362 {
363 table_cursors.insert(op.p1);
364 }
365 }
366 if table_cursors.is_empty() {
367 return false;
368 }
369 ops.iter().any(|op| {
370 matches!(op.opcode, Opcode::SeekRowid | Opcode::NotExists) && table_cursors.contains(&op.p1)
371 })
372}
373
374#[cfg(test)]
379mod tests {
380 use super::*;
381 use fsqlite_types::opcode::{Opcode, P4};
382 use fsqlite_vdbe::ProgramBuilder;
383
384 fn build_simple_select_program() -> VdbeProgram {
385 let mut b = ProgramBuilder::new();
386 let end_label = b.emit_label();
387 let done_label = b.emit_label();
388
389 b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
390 b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
391 b.emit_op(Opcode::OpenRead, 0, 2, 0, P4::Table("t".to_owned()), 0);
392 b.emit_jump_to_label(Opcode::Rewind, 0, 0, done_label, P4::None, 0);
393 b.emit_op(Opcode::Column, 0, 0, 1, P4::None, 0);
394 b.emit_op(Opcode::ResultRow, 1, 1, 0, P4::None, 0);
395 b.emit_op(Opcode::Next, 0, 4, 0, P4::None, 0);
396 b.resolve_label(done_label);
397 b.emit_op(Opcode::Close, 0, 0, 0, P4::None, 0);
398 b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
399 b.resolve_label(end_label);
400
401 b.finish().unwrap()
402 }
403
404 #[test]
406 fn test_explain_returns_bytecode() {
407 let prog = build_simple_select_program();
408 let rows = explain_program(&prog);
409
410 assert!(!rows.is_empty());
412 assert_eq!(rows[0].addr, 0);
413
414 let init_row = &rows[0];
416 assert_eq!(init_row.opcode, "Init");
417 assert_eq!(init_row.p5, 0);
419 assert!(init_row.comment.contains("start at"));
421 }
422
423 #[test]
425 fn test_explain_query_plan_columns() {
426 let prog = build_simple_select_program();
427 let rows = explain_query_plan(&prog);
428
429 assert!(!rows.is_empty());
430 let row = &rows[0];
431 assert!(row.id > 0);
433 assert_eq!(row.parent, 0);
434 assert_eq!(row.notused, 0);
435 assert!(!row.detail.is_empty());
436 }
437
438 #[test]
440 fn test_explain_query_plan_shows_index() {
441 let mut b = ProgramBuilder::new();
443 let end_label = b.emit_label();
444 let done_label = b.emit_label();
445 let skip_label = b.emit_label();
446
447 b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
448 b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
449 b.emit_op(Opcode::OpenRead, 0, 2, 0, P4::Table("t".to_owned()), 0);
450 b.emit_op(
451 Opcode::OpenRead,
452 1,
453 3,
454 0,
455 P4::Index("idx_t_a".to_owned()),
456 0,
457 );
458 b.emit_jump_to_label(Opcode::Rewind, 1, 0, done_label, P4::None, 0);
459 b.emit_op(Opcode::IdxRowid, 1, 2, 0, P4::None, 0);
460 b.emit_jump_to_label(Opcode::SeekRowid, 0, 2, skip_label, P4::None, 0);
461 b.emit_op(Opcode::Column, 0, 0, 1, P4::None, 0);
462 b.emit_op(Opcode::ResultRow, 1, 1, 0, P4::None, 0);
463 b.resolve_label(skip_label);
464 b.emit_op(Opcode::Next, 1, 5, 0, P4::None, 0);
465 b.resolve_label(done_label);
466 b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
467 b.resolve_label(end_label);
468
469 let prog = b.finish().unwrap();
470 let rows = explain_query_plan(&prog);
471
472 let has_index = rows.iter().any(|r| r.detail.contains("USING INDEX"));
474 assert!(has_index, "EQP should show index usage, got: {rows:?}");
475 }
476
477 #[test]
479 fn test_explain_query_plan_tree_structure() {
480 let prog = build_simple_select_program();
481 let rows = explain_query_plan(&prog);
482
483 for row in &rows {
485 if row.parent == 0 {
486 assert!(row.id > 0);
488 } else {
489 assert!(rows.iter().any(|r| r.id == row.parent));
491 }
492 }
493
494 let mut ids: Vec<i32> = rows.iter().map(|r| r.id).collect();
496 ids.sort_unstable();
497 ids.dedup();
498 assert_eq!(ids.len(), rows.len());
499 }
500
501 #[test]
502 fn test_explain_query_plan_shows_covering_index() {
503 let mut b = ProgramBuilder::new();
504 let end_label = b.emit_label();
505 let done_label = b.emit_label();
506
507 b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
508 b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
509 b.emit_op(
510 Opcode::OpenRead,
511 1,
512 3,
513 0,
514 P4::Index("idx_t_b".to_owned()),
515 0,
516 );
517 b.emit_jump_to_label(Opcode::Rewind, 1, 0, done_label, P4::None, 0);
518 b.emit_op(Opcode::Column, 1, 0, 1, P4::None, 0);
519 b.emit_op(Opcode::ResultRow, 1, 1, 0, P4::None, 0);
520 b.emit_op(Opcode::Next, 1, 4, 0, P4::None, 0);
521 b.resolve_label(done_label);
522 b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
523 b.resolve_label(end_label);
524
525 let prog = b.finish().unwrap();
526 let rows = explain_query_plan(&prog);
527 let has_covering = rows
528 .iter()
529 .any(|row| row.detail.contains("USING COVERING INDEX idx_t_b"));
530 assert!(
531 has_covering,
532 "EQP should show covering-index usage, got: {rows:?}"
533 );
534 }
535
536 #[test]
537 fn test_explain_query_plan_shows_temp_btree_for_order_by() {
538 let mut b = ProgramBuilder::new();
539 let end_label = b.emit_label();
540 let done_label = b.emit_label();
541
542 b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
543 b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
544 b.emit_op(Opcode::OpenRead, 0, 2, 0, P4::Table("t".to_owned()), 0);
545 b.emit_op(Opcode::SorterOpen, 1, 1, 0, P4::Str("+".to_owned()), 0);
546 b.emit_jump_to_label(Opcode::Rewind, 0, 0, done_label, P4::None, 0);
547 b.emit_op(Opcode::Column, 0, 0, 2, P4::None, 0);
548 b.emit_op(Opcode::MakeRecord, 2, 1, 3, P4::None, 0);
549 b.emit_op(Opcode::SorterInsert, 1, 3, 0, P4::None, 0);
550 b.resolve_label(done_label);
551 b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
552 b.resolve_label(end_label);
553
554 let prog = b.finish().unwrap();
555 let rows = explain_query_plan(&prog);
556 let has_temp_btree = rows
557 .iter()
558 .any(|row| row.detail == "USE TEMP B-TREE FOR ORDER BY");
559 assert!(
560 has_temp_btree,
561 "EQP should include temp B-tree marker for sorter plans, got: {rows:?}"
562 );
563 }
564
565 fn build_index_seek_program() -> VdbeProgram {
569 let mut b = ProgramBuilder::new();
570 let end_label = b.emit_label();
571 let done_label = b.emit_label();
572 let skip_label = b.emit_label();
573
574 b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
575 b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
576 b.emit_op(Opcode::OpenRead, 0, 2, 0, P4::Table("t".to_owned()), 0);
577 b.emit_op(
578 Opcode::OpenRead,
579 1,
580 3,
581 0,
582 P4::Index("idx_t_k".to_owned()),
583 0,
584 );
585 b.emit_jump_to_label(Opcode::SeekGE, 1, 0, done_label, P4::None, 0);
586 b.emit_op(Opcode::IdxRowid, 1, 9, 0, P4::None, 0);
587 b.emit_jump_to_label(Opcode::SeekRowid, 0, 9, skip_label, P4::None, 0);
588 b.emit_op(Opcode::Column, 0, 1, 2, P4::None, 0);
589 b.emit_op(Opcode::ResultRow, 2, 1, 0, P4::None, 0);
590 b.resolve_label(skip_label);
591 b.emit_op(Opcode::Next, 1, 5, 0, P4::None, 0);
592 b.resolve_label(done_label);
593 b.emit_op(Opcode::Close, 1, 0, 0, P4::None, 0);
594 b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
595 b.resolve_label(end_label);
596
597 b.finish().unwrap()
598 }
599
600 fn build_in_subquery_scan_program() -> VdbeProgram {
605 let mut b = ProgramBuilder::new();
606 let end_label = b.emit_label();
607 let done_label = b.emit_label();
608 let skip_label = b.emit_label();
609
610 b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
611 b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
612 b.emit_op(Opcode::OpenRead, 0, 2, 0, P4::Table("t".to_owned()), 0);
613 b.emit_jump_to_label(Opcode::Rewind, 0, 0, done_label, P4::None, 0);
614 b.emit_op(Opcode::Column, 0, 1, 5, P4::None, 0);
615 b.emit_op(Opcode::MakeRecord, 5, 1, 7, P4::None, 0);
616 b.emit_jump_to_label(Opcode::Found, 12294, 0, skip_label, P4::None, 0);
617 b.emit_op(Opcode::Rowid, 0, 1, 0, P4::None, 0);
618 b.emit_op(Opcode::ResultRow, 1, 1, 0, P4::None, 0);
619 b.resolve_label(skip_label);
620 b.emit_op(Opcode::Next, 0, 4, 0, P4::None, 0);
621 b.resolve_label(done_label);
622 b.emit_op(Opcode::Close, 0, 0, 0, P4::None, 0);
623 b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
624 b.resolve_label(end_label);
625
626 b.finish().unwrap()
627 }
628
629 #[test]
632 fn test_program_seeks_named_index_true_for_seek_shape() {
633 let prog = build_index_seek_program();
634 assert!(program_seeks_named_index(&prog, "idx_t_k"));
635 }
636
637 #[test]
638 fn test_program_seeks_named_index_false_for_in_subquery_scan() {
639 let prog = build_in_subquery_scan_program();
640 assert!(
641 !program_seeks_named_index(&prog, "idx_t_k"),
642 "a Rewind/Next membership-probe scan must not vouch for a SEARCH claim"
643 );
644 }
645
646 #[test]
647 fn test_program_seeks_named_index_false_for_index_only_scan() {
648 let mut b = ProgramBuilder::new();
651 let end_label = b.emit_label();
652 let done_label = b.emit_label();
653
654 b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
655 b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
656 b.emit_op(
657 Opcode::OpenRead,
658 1,
659 3,
660 0,
661 P4::Index("idx_t_k".to_owned()),
662 0,
663 );
664 b.emit_jump_to_label(Opcode::Rewind, 1, 0, done_label, P4::None, 0);
665 b.emit_op(Opcode::Column, 1, 0, 1, P4::None, 0);
666 b.emit_op(Opcode::ResultRow, 1, 1, 0, P4::None, 0);
667 b.emit_op(Opcode::Next, 1, 4, 0, P4::None, 0);
668 b.resolve_label(done_label);
669 b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
670 b.resolve_label(end_label);
671
672 let prog = b.finish().unwrap();
673 assert!(!program_seeks_named_index(&prog, "idx_t_k"));
674 }
675
676 #[test]
677 fn test_program_seeks_rowid_on_table_true_for_rowid_probe() {
678 let mut b = ProgramBuilder::new();
679 let end_label = b.emit_label();
680 let done_label = b.emit_label();
681
682 b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
683 b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
684 b.emit_op(Opcode::OpenRead, 0, 2, 0, P4::Table("t".to_owned()), 0);
685 b.emit_jump_to_label(Opcode::SeekRowid, 0, 1, done_label, P4::None, 0);
686 b.emit_op(Opcode::Column, 0, 1, 2, P4::None, 0);
687 b.emit_op(Opcode::ResultRow, 2, 1, 0, P4::None, 0);
688 b.resolve_label(done_label);
689 b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
690 b.resolve_label(end_label);
691
692 let prog = b.finish().unwrap();
693 assert!(program_seeks_rowid_on_table(&prog, "t"));
694 assert!(
695 !program_seeks_rowid_on_table(&prog, "other"),
696 "a rowid probe on t must not vouch for a claim about another table"
697 );
698 }
699
700 #[test]
701 fn test_program_seeks_rowid_on_table_false_for_scan() {
702 let prog = build_simple_select_program();
703 assert!(!program_seeks_rowid_on_table(&prog, "t"));
704 }
705
706 #[test]
707 fn test_index_table_lookup_leg_does_not_vouch_for_table_rowid_claim() {
708 let prog = build_index_seek_program();
712 assert!(!program_seeks_named_index(&prog, "idx_other"));
713 }
714}