vibesql_executor/explain.rs
1//! EXPLAIN statement execution
2//!
3//! This module provides the ExplainExecutor for analyzing query execution plans.
4//! It shows information about:
5//! - Table scans vs index scans
6//! - Skip-scan optimization (non-prefix index usage)
7//! - Join types and order
8//! - Filter pushdown information
9//! - Estimated row counts (when statistics are available)
10//!
11//! Supports two output formats:
12//! - PostgreSQL-style text output (default for EXPLAIN)
13//! - SQLite-style EXPLAIN QUERY PLAN output (for TCL test compatibility)
14
15use std::fmt::Write;
16
17use std::collections::HashSet;
18
19use vibesql_ast::pretty_print::ToSql;
20use vibesql_ast::{ExplainFormat, ExplainStmt, Expression, SelectItem, SelectStmt, Statement};
21use vibesql_storage::Database;
22
23use crate::{
24 errors::ExecutorError,
25 optimizer::index_planner::IndexPlanner,
26 select::scan::index_scan::{
27 cost_based_index_selection, eqp_ordering_index, needs_temp_btree_for_order_by_eqp,
28 },
29};
30
31/// SQLite-style scan type for EQP output
32#[derive(Debug, Clone, PartialEq)]
33pub enum ScanType {
34 /// Sequential scan (SCAN table)
35 Scan,
36 /// Index search (SEARCH table USING INDEX ...)
37 Search,
38 /// Covering index scan (SEARCH table USING COVERING INDEX ...)
39 CoveringIndex,
40 /// Integer primary key lookup (SEARCH table USING INTEGER PRIMARY KEY ...)
41 IntegerPrimaryKey,
42}
43
44/// Represents index predicate information for SQLite EQP output
45#[derive(Debug, Clone)]
46pub struct IndexPredicate {
47 /// Column name
48 pub column: String,
49 /// Predicate type: "=", ">", "<", ">=", "<=", etc.
50 pub predicate_type: String,
51}
52
53/// Represents a single node in the query execution plan
54#[derive(Debug, Clone)]
55pub struct PlanNode {
56 /// Type of operation (e.g., "Seq Scan", "Index Scan", "Hash Join")
57 pub operation: String,
58 /// Target object (e.g., table name, index name)
59 pub object: Option<String>,
60 /// Additional details about this operation
61 pub details: Vec<String>,
62 /// Estimated rows (if statistics available)
63 pub estimated_rows: Option<f64>,
64 /// Child nodes in the plan tree
65 pub children: Vec<PlanNode>,
66 /// SQLite-style scan type (for EQP output)
67 pub scan_type: Option<ScanType>,
68 /// Index name used (for SEARCH operations)
69 pub index_name: Option<String>,
70 /// True when `index_name` covers every column the query reads from this
71 /// table (SELECT list + WHERE, with the rowid alias carried implicitly).
72 /// Ordering scans then render as SQLite's
73 /// `SCAN <table> USING COVERING INDEX <index>` (#5371).
74 pub index_covering: bool,
75 /// Index predicates for SQLite EQP format (e.g., "w=?", "x>? AND x<?")
76 pub index_predicates: Vec<IndexPredicate>,
77 /// Whether this query requires a temp B-tree for ORDER BY
78 pub needs_temp_btree_for_order_by: bool,
79 /// Whether this query requires a temp structure for GROUP BY — rendered
80 /// as SQLite's `USE TEMP B-TREE FOR GROUP BY`. VibeSQL's runtime always
81 /// groups via a hash table and then sorts the groups by key
82 /// (select/grouping/hash.rs), so the line truthfully describes the temp
83 /// grouping structure; it is suppressed when an index delivers group
84 /// order, mirroring SQLite's EQP (same permissive EQP-level convention
85 /// as the ORDER BY stabilization-sort suppression in
86 /// `needs_temp_btree_for_order_by_eqp`).
87 pub needs_temp_btree_for_group_by: bool,
88 /// Whether this query requires a temp structure for DISTINCT — rendered
89 /// as SQLite's `USE TEMP B-TREE FOR DISTINCT`. VibeSQL's runtime dedups
90 /// via a hash set preserving input order (select/helpers.rs
91 /// `apply_distinct`); the line truthfully describes the temp dedup
92 /// structure and is suppressed when an index delivers the SELECT-list
93 /// order, mirroring SQLite's EQP.
94 pub needs_temp_btree_for_distinct: bool,
95 /// Number of distinct window-function sorting passes (PARTITION BY +
96 /// ORDER BY keys) not satisfied by an index. Each contributes one
97 /// `USE TEMP B-TREE FOR ORDER BY` entry in EQP output.
98 pub window_sort_count: usize,
99 /// Set operation type for compound queries (UNION, INTERSECT, EXCEPT)
100 pub set_operation_type: Option<String>,
101 /// Whether this is a compound query root
102 pub is_compound_query: bool,
103 /// When set, this node is a window-function subquery/view rendered as a
104 /// SQLite-style `CO-ROUTINE <name>` block in EQP output: the inner plan
105 /// is nested under the CO-ROUTINE entry and the outer query reads it via
106 /// a trailing `SCAN <name>` entry (windowpushd.test, #5347).
107 pub coroutine: Option<String>,
108}
109
110impl PlanNode {
111 fn new(operation: &str) -> Self {
112 PlanNode {
113 operation: operation.to_string(),
114 object: None,
115 details: Vec::new(),
116 estimated_rows: None,
117 children: Vec::new(),
118 scan_type: None,
119 index_name: None,
120 index_covering: false,
121 index_predicates: Vec::new(),
122 needs_temp_btree_for_order_by: false,
123 needs_temp_btree_for_group_by: false,
124 needs_temp_btree_for_distinct: false,
125 window_sort_count: 0,
126 set_operation_type: None,
127 is_compound_query: false,
128 coroutine: None,
129 }
130 }
131
132 fn with_object(mut self, object: &str) -> Self {
133 self.object = Some(object.to_string());
134 self
135 }
136
137 fn with_detail(mut self, detail: String) -> Self {
138 self.details.push(detail);
139 self
140 }
141
142 fn with_estimated_rows(mut self, rows: f64) -> Self {
143 self.estimated_rows = Some(rows);
144 self
145 }
146
147 fn with_scan_type(mut self, scan_type: ScanType) -> Self {
148 self.scan_type = Some(scan_type);
149 self
150 }
151
152 fn with_index_name(mut self, index_name: &str) -> Self {
153 self.index_name = Some(index_name.to_string());
154 self
155 }
156
157 fn with_index_predicate(mut self, column: &str, predicate_type: &str) -> Self {
158 self.index_predicates.push(IndexPredicate {
159 column: column.to_string(),
160 predicate_type: predicate_type.to_string(),
161 });
162 self
163 }
164
165 fn add_child(&mut self, child: PlanNode) {
166 self.children.push(child);
167 }
168}
169
170/// Result of EXPLAIN operation
171#[derive(Debug)]
172pub struct ExplainResult {
173 /// Root node of the execution plan
174 pub plan: PlanNode,
175 /// Output format
176 pub format: ExplainFormat,
177}
178
179impl ExplainResult {
180 /// Format the plan as text output (PostgreSQL-style)
181 pub fn to_text(&self) -> String {
182 let mut output = String::new();
183 format_node_text(&self.plan, 0, &mut output);
184 output
185 }
186
187 /// Format the plan as JSON output
188 pub fn to_json(&self) -> String {
189 format_node_json(&self.plan)
190 }
191
192 /// Format the plan as SQLite-compatible EXPLAIN QUERY PLAN output
193 ///
194 /// Produces output like:
195 /// ```text
196 /// QUERY PLAN
197 /// |--SCAN t1 USING INDEX i1
198 /// `--USE TEMP B-TREE FOR LAST TERM OF ORDER BY
199 /// ```
200 ///
201 /// For compound queries:
202 /// ```text
203 /// QUERY PLAN
204 /// `--COMPOUND QUERY
205 /// |--LEFT-MOST SUBQUERY
206 /// | `--SCAN t1
207 /// `--UNION ALL
208 /// `--SCAN t2
209 /// ```
210 ///
211 /// The "QUERY PLAN" header is included to match SQLite's EQP format used in
212 /// TCL tests with full format comparison (do_eqp_test with QUERY PLAN prefix).
213 pub fn to_sqlite_eqp(&self) -> String {
214 let mut output = String::new();
215
216 // Add "QUERY PLAN" header to match SQLite's EQP format
217 writeln!(output, "QUERY PLAN").unwrap();
218
219 // Collect all EQP entries (scan nodes, CO-ROUTINE blocks, COMPOUND
220 // QUERY blocks, temp b-tree entries) and render the tree.
221 let entries = collect_eqp_entries(&self.plan);
222 write_eqp_entries(&entries, "", &mut output);
223
224 output
225 }
226
227 /// Format the plan as SQLite-compatible EXPLAIN output (VM bytecode style)
228 ///
229 /// This produces output mimicking SQLite's VDBE bytecode format:
230 /// ```text
231 /// addr opcode p1 p2 p3 p4 p5 comment
232 /// ---- ------------- ---- ---- ---- ------------- -- -------------
233 /// 0 Init 0 8 0 0 Start at 8
234 /// 1 OpenRead 0 2 0 2 0 root=2 iDb=0; t1
235 /// ...
236 /// ```
237 ///
238 /// Note: This is a synthetic representation since VibeSQL doesn't use SQLite's VM.
239 /// The opcodes are generated to approximate what SQLite would produce.
240 pub fn to_sqlite_vm(&self) -> SqliteVmOutput {
241 let mut instructions = Vec::new();
242 let mut addr = 0;
243
244 // Collect scan nodes to determine table access patterns
245 let scan_nodes = collect_scan_nodes(&self.plan);
246
247 // Generate Init instruction (always first)
248 // p2 will be updated to point to Transaction after we know total instructions
249 let init_addr = addr;
250 instructions.push(VmInstruction {
251 addr,
252 opcode: "Init".to_string(),
253 p1: 0,
254 p2: 0, // Will be patched later
255 p3: 0,
256 p4: String::new(),
257 p5: 0,
258 comment: "Start at ?".to_string(),
259 });
260 addr += 1;
261
262 // Generate OpenRead for each table
263 let mut cursor = 0;
264 let mut table_cursors = std::collections::HashMap::new();
265
266 for node in &scan_nodes {
267 if let Some(ref table_name) = node.object {
268 if !table_cursors.contains_key(table_name) {
269 let root_page = 2 + cursor; // Synthetic root page
270 instructions.push(VmInstruction {
271 addr,
272 opcode: "OpenRead".to_string(),
273 p1: cursor,
274 p2: root_page,
275 p3: 0,
276 p4: "2".to_string(), // Number of columns (synthetic)
277 p5: 0,
278 comment: format!("root={} iDb=0; {}", root_page, table_name),
279 });
280 table_cursors.insert(table_name.clone(), cursor);
281 cursor += 1;
282 addr += 1;
283 }
284 }
285 }
286
287 // Generate seek/scan instructions based on scan type
288 let result_row_addr = addr + scan_nodes.len() * 2 + 1;
289 let halt_addr = result_row_addr + 1;
290
291 for node in &scan_nodes {
292 if let Some(ref table_name) = node.object {
293 let cursor_id = *table_cursors.get(table_name).unwrap_or(&0);
294
295 match node.scan_type.as_ref() {
296 Some(ScanType::Search) | Some(ScanType::CoveringIndex) => {
297 // Index seek
298 instructions.push(VmInstruction {
299 addr,
300 opcode: "SeekGE".to_string(),
301 p1: cursor_id,
302 p2: halt_addr as i32,
303 p3: 1,
304 p4: String::new(),
305 p5: 0,
306 comment: format!("key=r[1]; {}", table_name),
307 });
308 addr += 1;
309 }
310 Some(ScanType::IntegerPrimaryKey) => {
311 // Primary key lookup
312 instructions.push(VmInstruction {
313 addr,
314 opcode: "SeekRowid".to_string(),
315 p1: cursor_id,
316 p2: halt_addr as i32,
317 p3: 1,
318 p4: String::new(),
319 p5: 0,
320 comment: format!("pk; {}", table_name),
321 });
322 addr += 1;
323 }
324 Some(ScanType::Scan) | None => {
325 // Full table scan - use Rewind
326 instructions.push(VmInstruction {
327 addr,
328 opcode: "Rewind".to_string(),
329 p1: cursor_id,
330 p2: halt_addr as i32,
331 p3: 0,
332 p4: String::new(),
333 p5: 0,
334 comment: table_name.clone(),
335 });
336 addr += 1;
337 }
338 }
339 }
340 }
341
342 // Generate Column and ResultRow instructions
343 let mut register = 1;
344 for (i, node) in scan_nodes.iter().enumerate() {
345 if let Some(ref table_name) = node.object {
346 let cursor_id = *table_cursors.get(table_name).unwrap_or(&0);
347 // Column instruction for each output column
348 instructions.push(VmInstruction {
349 addr,
350 opcode: "Column".to_string(),
351 p1: cursor_id,
352 p2: i as i32, // Column index
353 p3: register,
354 p4: String::new(),
355 p5: 0,
356 comment: format!("r[{}]=cursor {} column {}", register, cursor_id, i),
357 });
358 register += 1;
359 addr += 1;
360 }
361 }
362
363 // ResultRow instruction
364 instructions.push(VmInstruction {
365 addr,
366 opcode: "ResultRow".to_string(),
367 p1: 1,
368 p2: register - 1,
369 p3: 0,
370 p4: String::new(),
371 p5: 0,
372 comment: format!("output=r[1..{}]", register - 1),
373 });
374 addr += 1;
375
376 // Next/Goto for each scan
377 for node in &scan_nodes {
378 if let Some(ref table_name) = node.object {
379 let cursor_id = *table_cursors.get(table_name).unwrap_or(&0);
380 let seek_addr = scan_nodes
381 .iter()
382 .position(|n| n.object.as_ref() == Some(table_name))
383 .map(|pos| 1 + table_cursors.len() + pos)
384 .unwrap_or(1);
385
386 instructions.push(VmInstruction {
387 addr,
388 opcode: "Next".to_string(),
389 p1: cursor_id,
390 p2: seek_addr as i32,
391 p3: 0,
392 p4: String::new(),
393 p5: 0,
394 comment: table_name.clone(),
395 });
396 addr += 1;
397 }
398 }
399
400 // Halt instruction
401 instructions.push(VmInstruction {
402 addr,
403 opcode: "Halt".to_string(),
404 p1: 0,
405 p2: 0,
406 p3: 0,
407 p4: String::new(),
408 p5: 0,
409 comment: String::new(),
410 });
411 addr += 1;
412
413 // Transaction instruction (usually near the end in SQLite)
414 let transaction_addr = addr;
415 instructions.push(VmInstruction {
416 addr,
417 opcode: "Transaction".to_string(),
418 p1: 0,
419 p2: 0,
420 p3: 1,
421 p4: "0".to_string(),
422 p5: 1,
423 comment: "usesStmtJournal=0".to_string(),
424 });
425 addr += 1;
426
427 // Goto to first instruction after Init
428 instructions.push(VmInstruction {
429 addr,
430 opcode: "Goto".to_string(),
431 p1: 0,
432 p2: 1,
433 p3: 0,
434 p4: String::new(),
435 p5: 0,
436 comment: String::new(),
437 });
438
439 // Patch Init instruction to jump to Transaction
440 if let Some(init) = instructions.get_mut(init_addr) {
441 init.p2 = transaction_addr as i32;
442 init.comment = format!("Start at {}", transaction_addr);
443 }
444
445 SqliteVmOutput { instructions }
446 }
447}
448
449/// Represents a single SQLite VM instruction for EXPLAIN output
450#[derive(Debug, Clone)]
451pub struct VmInstruction {
452 /// Instruction address (sequential)
453 pub addr: usize,
454 /// Opcode name (e.g., "OpenRead", "SeekGE", "Column")
455 pub opcode: String,
456 /// First integer parameter
457 pub p1: i32,
458 /// Second integer parameter
459 pub p2: i32,
460 /// Third integer parameter
461 pub p3: i32,
462 /// String parameter
463 pub p4: String,
464 /// Fifth integer parameter (flags)
465 pub p5: i32,
466 /// Human-readable comment
467 pub comment: String,
468}
469
470/// SQLite VM EXPLAIN output
471#[derive(Debug)]
472pub struct SqliteVmOutput {
473 /// List of VM instructions
474 pub instructions: Vec<VmInstruction>,
475}
476
477impl SqliteVmOutput {
478 /// Get the column names for SQLite EXPLAIN output
479 pub fn column_names() -> Vec<&'static str> {
480 vec!["addr", "opcode", "p1", "p2", "p3", "p4", "p5", "comment"]
481 }
482
483 /// Convert to rows for display
484 pub fn to_rows(&self) -> Vec<Vec<String>> {
485 self.instructions
486 .iter()
487 .map(|inst| {
488 vec![
489 inst.addr.to_string(),
490 inst.opcode.clone(),
491 inst.p1.to_string(),
492 inst.p2.to_string(),
493 inst.p3.to_string(),
494 inst.p4.clone(),
495 inst.p5.to_string(),
496 inst.comment.clone(),
497 ]
498 })
499 .collect()
500 }
501}
502
503/// Collect all scan/search nodes from the plan tree for SQLite EQP output
504fn collect_scan_nodes(node: &PlanNode) -> Vec<&PlanNode> {
505 let mut nodes = Vec::new();
506
507 // Check if this is a scan/search node (has scan_type set)
508 if node.scan_type.is_some() {
509 nodes.push(node);
510 }
511
512 // Recursively collect from children
513 for child in &node.children {
514 nodes.extend(collect_scan_nodes(child));
515 }
516
517 nodes
518}
519
520/// A single EQP output entry with nested children (used for SQLite-style
521/// `CO-ROUTINE <name>` blocks whose inner plan renders indented).
522struct EqpEntry {
523 text: String,
524 children: Vec<EqpEntry>,
525}
526
527impl EqpEntry {
528 fn leaf(text: String) -> Self {
529 EqpEntry { text, children: Vec::new() }
530 }
531}
532
533/// Render a list of EQP entries as SQLite's tree format with `|--`/`` `-- ``
534/// connectors and `| `/three-space child indentation.
535fn write_eqp_entries(entries: &[EqpEntry], indent: &str, output: &mut String) {
536 for (i, entry) in entries.iter().enumerate() {
537 let is_last = i == entries.len() - 1;
538 let connector = if is_last { "`--" } else { "|--" };
539 writeln!(output, "{}{}{}", indent, connector, entry.text).unwrap();
540 if !entry.children.is_empty() {
541 let child_indent = format!("{}{}", indent, if is_last { " " } else { "| " });
542 write_eqp_entries(&entry.children, &child_indent, output);
543 }
544 }
545}
546
547/// Collect scan entries from the plan tree. Window-subquery/view nodes
548/// marked as co-routines render as a `CO-ROUTINE <name>` block containing
549/// the inner plan's entries (including the inner query's window-sort temp
550/// B-tree entries), followed by the outer query's `SCAN <name>` of the
551/// co-routine output — matching SQLite's EQP shape for window views
552/// (windowpushd.test, #5347).
553fn append_scan_entries(node: &PlanNode, entries: &mut Vec<EqpEntry>) {
554 if let Some(ref name) = node.coroutine {
555 let mut children = Vec::new();
556 for child in &node.children {
557 children.extend(collect_eqp_entries(child));
558 }
559 entries.push(EqpEntry { text: format!("CO-ROUTINE {}", name), children });
560 entries.push(EqpEntry::leaf(format!("SCAN {}", name)));
561 return;
562 }
563
564 // Compound (UNION/INTERSECT/EXCEPT) roots render as a nested
565 // `COMPOUND QUERY` block. This both drives the top-level rendering in
566 // `to_sqlite_eqp` and lets compound view/subquery bodies nest inside
567 // `CO-ROUTINE` blocks (#5361), matching SQLite's shape:
568 // |--CO-ROUTINE cv
569 // | `--COMPOUND QUERY
570 // | |--LEFT-MOST SUBQUERY
571 // ...
572 if node.is_compound_query {
573 entries.push(compound_eqp_entry(node));
574 return;
575 }
576
577 if node.scan_type.is_some() {
578 entries.push(EqpEntry::leaf(format_sqlite_eqp_node(node)));
579 }
580
581 for child in &node.children {
582 append_scan_entries(child, entries);
583 }
584}
585
586/// Build the `COMPOUND QUERY` entry tree for a compound-query plan root:
587/// a `LEFT-MOST SUBQUERY` child for the first branch, then one child per
588/// set operation (`UNION USING TEMP B-TREE`, `UNION ALL`, ...), each
589/// containing that branch's full EQP entries — including the branch's own
590/// `USE TEMP B-TREE FOR GROUP BY` / `FOR DISTINCT` lines, which sqlite3
591/// renders inside the branch block (verified live).
592fn compound_eqp_entry(node: &PlanNode) -> EqpEntry {
593 let mut children = Vec::new();
594 for (i, branch) in node.children.iter().enumerate() {
595 let label = if i == 0 {
596 "LEFT-MOST SUBQUERY".to_string()
597 } else {
598 branch.set_operation_type.clone().unwrap_or_else(|| "UNION ALL".to_string())
599 };
600 children.push(EqpEntry { text: label, children: collect_eqp_entries(branch) });
601 }
602 EqpEntry { text: "COMPOUND QUERY".to_string(), children }
603}
604
605/// True when any node in `node`'s subtree needs a temp B-tree for ORDER BY.
606///
607/// Used by the view-flattening branch to hoist a body's sort flag onto the
608/// `Subquery` node it nests under, since [`collect_eqp_entries`] only checks
609/// a node and its DIRECT children. Co-routine subtrees are skipped: their
610/// inner entries (including temp B-tree lines) are re-collected inside the
611/// `CO-ROUTINE` block by [`append_scan_entries`], so counting them here
612/// would emit the line twice.
613fn subtree_needs_order_by_temp_btree(node: &PlanNode) -> bool {
614 if node.coroutine.is_some() {
615 return false;
616 }
617 node.needs_temp_btree_for_order_by
618 || node.children.iter().any(subtree_needs_order_by_temp_btree)
619}
620
621/// Collect all EQP entries from the plan tree, including TEMP B-TREE entries
622///
623/// Line order matches sqlite3 3.51.0: scans, then `... FOR GROUP BY`, then
624/// window sorting passes, then `... FOR DISTINCT`, then `... FOR ORDER BY`
625/// (GROUP BY-before-DISTINCT and DISTINCT/GROUP BY-before-ORDER BY verified
626/// live; windows restructure SQLite's plan entirely, so their relative slot
627/// follows the grouping-before-dedup pipeline order).
628fn collect_eqp_entries(node: &PlanNode) -> Vec<EqpEntry> {
629 let mut entries = Vec::new();
630
631 // Collect scan entries first (co-routine blocks nest their inner plan)
632 append_scan_entries(node, &mut entries);
633
634 // Add TEMP B-TREE entry if needed for GROUP BY
635 if node.needs_temp_btree_for_group_by {
636 entries.push(EqpEntry::leaf("USE TEMP B-TREE FOR GROUP BY".to_string()));
637 }
638
639 // Add one TEMP B-TREE entry per distinct window sort key not satisfied
640 // by an index. Window sorting passes run before the statement-level
641 // ORDER BY, and SQLite never dedups them against it — they are
642 // separate passes.
643 for _ in 0..node.window_sort_count {
644 entries.push(EqpEntry::leaf("USE TEMP B-TREE FOR ORDER BY".to_string()));
645 }
646
647 // Add TEMP B-TREE entry if needed for DISTINCT
648 if node.needs_temp_btree_for_distinct {
649 entries.push(EqpEntry::leaf("USE TEMP B-TREE FOR DISTINCT".to_string()));
650 }
651
652 // Add TEMP B-TREE entry if needed for ORDER BY
653 if node.needs_temp_btree_for_order_by {
654 entries.push(EqpEntry::leaf("USE TEMP B-TREE FOR ORDER BY".to_string()));
655 }
656
657 // Check children for temp b-tree needs as well
658 for child in &node.children {
659 if child.needs_temp_btree_for_order_by && !node.needs_temp_btree_for_order_by {
660 entries.push(EqpEntry::leaf("USE TEMP B-TREE FOR ORDER BY".to_string()));
661 break;
662 }
663 }
664
665 entries
666}
667
668/// Format a single node in SQLite EQP style
669fn format_sqlite_eqp_node(node: &PlanNode) -> String {
670 // Handle constant row scan (no FROM clause / VALUES), e.g.
671 // `SCAN CONSTANT ROW` or `SCAN 2 CONSTANT ROWS`.
672 if node.operation == "SCAN CONSTANT ROW" || node.operation.ends_with("CONSTANT ROWS") {
673 return node.operation.clone();
674 }
675
676 let table_name = node.object.as_deref().unwrap_or("?");
677
678 match node.scan_type.as_ref() {
679 Some(ScanType::Scan) => {
680 // Check if we have an index for ordering (SCAN table USING INDEX idx).
681 // When the index also covers every column read, SQLite renders
682 // `SCAN <table> USING COVERING INDEX <index>` (verified live
683 // against sqlite3 3.51.0, #5371).
684 if let Some(ref index_name) = node.index_name {
685 if node.index_covering {
686 format!("SCAN {} USING COVERING INDEX {}", table_name, index_name)
687 } else {
688 format!("SCAN {} USING INDEX {}", table_name, index_name)
689 }
690 } else {
691 format!("SCAN {}", table_name)
692 }
693 }
694 Some(ScanType::Search) | Some(ScanType::CoveringIndex) => {
695 let index_name = node.index_name.as_deref().unwrap_or("?");
696 let covering = if matches!(node.scan_type, Some(ScanType::CoveringIndex)) {
697 "COVERING "
698 } else {
699 ""
700 };
701
702 if node.index_predicates.is_empty() {
703 format!("SEARCH {} USING {}INDEX {}", table_name, covering, index_name)
704 } else {
705 let predicates: Vec<String> = node
706 .index_predicates
707 .iter()
708 .map(|p| format!("{}{}?", p.column, p.predicate_type))
709 .collect();
710 format!(
711 "SEARCH {} USING {}INDEX {} ({})",
712 table_name,
713 covering,
714 index_name,
715 predicates.join(" AND ")
716 )
717 }
718 }
719 Some(ScanType::IntegerPrimaryKey) => {
720 if node.index_predicates.is_empty() {
721 format!("SEARCH {} USING INTEGER PRIMARY KEY (rowid=?)", table_name)
722 } else {
723 let predicates: Vec<String> = node
724 .index_predicates
725 .iter()
726 .map(|p| format!("rowid{}?", p.predicate_type))
727 .collect();
728 format!(
729 "SEARCH {} USING INTEGER PRIMARY KEY ({})",
730 table_name,
731 predicates.join(" AND ")
732 )
733 }
734 }
735 None => {
736 // Fallback for nodes without scan_type (e.g., joins)
737 node.operation.clone()
738 }
739 }
740}
741
742fn format_node_text(node: &PlanNode, depth: usize, output: &mut String) {
743 let indent = " ".repeat(depth);
744 let arrow = if depth > 0 { "-> " } else { "" };
745
746 // Format the main operation line
747 let mut line = format!("{}{}{}", indent, arrow, node.operation);
748
749 if let Some(ref obj) = node.object {
750 write!(line, " on {}", obj).unwrap();
751 }
752
753 if let Some(rows) = node.estimated_rows {
754 write!(line, " (rows={:.0})", rows).unwrap();
755 }
756
757 writeln!(output, "{}", line).unwrap();
758
759 // Format details
760 for detail in &node.details {
761 writeln!(output, "{} {}", indent, detail).unwrap();
762 }
763
764 // Format children
765 for child in &node.children {
766 format_node_text(child, depth + 1, output);
767 }
768}
769
770fn format_node_json(node: &PlanNode) -> String {
771 let mut parts = vec![format!("\"operation\": \"{}\"", node.operation)];
772
773 if let Some(ref obj) = node.object {
774 parts.push(format!("\"object\": \"{}\"", obj));
775 }
776
777 if !node.details.is_empty() {
778 let details: Vec<String> = node.details.iter().map(|d| format!("\"{}\"", d)).collect();
779 parts.push(format!("\"details\": [{}]", details.join(", ")));
780 }
781
782 if let Some(rows) = node.estimated_rows {
783 parts.push(format!("\"estimated_rows\": {:.0}", rows));
784 }
785
786 if !node.children.is_empty() {
787 let children: Vec<String> = node.children.iter().map(format_node_json).collect();
788 parts.push(format!("\"children\": [{}]", children.join(", ")));
789 }
790
791 format!("{{{}}}", parts.join(", "))
792}
793
794/// Executor for EXPLAIN statements
795pub struct ExplainExecutor;
796
797impl ExplainExecutor {
798 /// Execute an EXPLAIN statement
799 pub fn execute(
800 stmt: &ExplainStmt,
801 database: &Database,
802 ) -> Result<ExplainResult, ExecutorError> {
803 let plan = match stmt.statement.as_ref() {
804 Statement::Select(select_stmt) => {
805 Self::explain_select(select_stmt, database, &HashSet::new())?
806 }
807 Statement::Insert(_) => {
808 PlanNode::new("Insert").with_detail("Inserts rows into target table".to_string())
809 }
810 Statement::Update(_) => PlanNode::new("Update")
811 .with_detail("Updates rows matching WHERE clause".to_string()),
812 Statement::Delete(_) => PlanNode::new("Delete")
813 .with_detail("Deletes rows matching WHERE clause".to_string()),
814 _ => {
815 return Err(ExecutorError::Other(
816 "EXPLAIN only supports SELECT, INSERT, UPDATE, DELETE statements".to_string(),
817 ));
818 }
819 };
820
821 Ok(ExplainResult { plan, format: stmt.format.clone() })
822 }
823
824 /// Generate execution plan for a SELECT statement
825 ///
826 /// `outer_ctes` holds the lowercased names of CTEs in scope from
827 /// enclosing queries; CTE names shadow same-named catalog views, so the
828 /// window push-down rewrite and view expansion must not fire for them.
829 fn explain_select(
830 stmt: &SelectStmt,
831 database: &Database,
832 outer_ctes: &HashSet<String>,
833 ) -> Result<PlanNode, ExecutorError> {
834 // Mirror the runtime optimizer (#5292): WHERE conjuncts on a
835 // PARTITION BY prefix of every window are pushed into window-function
836 // views/subqueries before planning, so EQP reflects the inner access
837 // path (windowpushd.test 1.4, 2.1.*, #5347). When the pass does not
838 // fire the statement is returned unchanged.
839 let stmt =
840 crate::optimizer::push_where_into_window_subqueries(stmt.clone(), database, outer_ctes);
841 let stmt = &stmt;
842
843 // Extend the CTE scope with this statement's own WITH clause for
844 // nested FROM-clause analysis.
845 let mut ctes = outer_ctes.clone();
846 if let Some(ref with) = stmt.with_clause {
847 for cte in with.iter() {
848 ctes.insert(cte.name.to_ascii_lowercase());
849 }
850 }
851
852 // Check if this is a compound query (UNION, INTERSECT, EXCEPT)
853 if stmt.set_operation.is_some() {
854 return Self::explain_compound_select(stmt, database, &ctes);
855 }
856
857 let mut root = PlanNode::new("Select");
858
859 // Extract columns needed by the SELECT list for covering index detection
860 let needed_columns = extract_select_columns(&stmt.select_list);
861
862 // Temp-structure suppression keys (#5367), computed BEFORE the FROM
863 // analysis because the base scan's effective ordering requirement
864 // depends on which suppression (if any) fires (#5371): when an index
865 // delivers GROUP BY / DISTINCT order, sqlite3 shows the scan riding
866 // that index (`SCAN t USING [COVERING ]INDEX i`, verified live).
867 //
868 // The simple GROUP BY key (ordinals and output aliases resolved like
869 // SQLite) is also reused below to suppress the ORDER BY line when the
870 // statement-level ORDER BY matches the grouping output order.
871 let group_key: Option<Vec<&Expression>> =
872 stmt.group_by.as_ref().map(|g| g.as_simple()).and_then(|simple| {
873 simple.map(|exprs| {
874 exprs
875 .iter()
876 .map(|e| Self::resolve_output_expr(e, &stmt.select_list))
877 .collect()
878 })
879 });
880 // The index-delivered group order (as written or permuted into an
881 // index's column order); `Some` exactly when the GROUP BY temp line
882 // is suppressed.
883 let group_index_order: Option<Vec<vibesql_ast::OrderByItem>> = if stmt.group_by.is_some()
884 {
885 group_key.as_ref().and_then(|key| {
886 Self::group_key_index_order(
887 stmt.from.as_ref(),
888 stmt.where_clause.as_ref(),
889 key,
890 database,
891 )
892 })
893 } else {
894 None
895 };
896
897 // The DISTINCT key is the SELECT list. SQLite suppresses the line
898 // when an index delivers the SELECT-list order — but never when a
899 // GROUP BY intervenes (`SELECT DISTINCT a FROM t GROUP BY a` with an
900 // index on `a` still shows the DISTINCT line; verified live).
901 let distinct_key: Option<Vec<&Expression>> = if stmt.distinct {
902 Self::distinct_key_exprs(&stmt.select_list)
903 } else {
904 None
905 };
906 // The index-delivered SELECT-list order for DISTINCT; `Some` exactly
907 // when the DISTINCT temp line is suppressed.
908 let distinct_index_order: Option<Vec<vibesql_ast::OrderByItem>> =
909 if stmt.distinct && stmt.group_by.is_none() {
910 distinct_key.as_ref().and_then(|key| {
911 let items = Self::exprs_as_asc_order_items(key);
912 if Self::needs_temp_btree_for_order_by(
913 stmt.from.as_ref(),
914 stmt.where_clause.as_ref(),
915 &items,
916 database,
917 ) {
918 None
919 } else {
920 Some(items)
921 }
922 })
923 } else {
924 None
925 };
926
927 // Analyze FROM clause. The base scan's effective ordering
928 // requirement follows SQLite's pipeline priority:
929 // - When the SELECT list contains window functions, the scan feeds
930 // the INNERMOST window sorting pass (SQLite's co-routine rewrite,
931 // see count_window_sorts) — SQLite picks an index that delivers
932 // PARTITION BY/ORDER BY order even without any predicate
933 // (windowpushd.test 2.1.3.6).
934 // - Otherwise the grouping/dedup pass consumes the scan's order:
935 // when its suppression fired, the scan rides the delivering index
936 // (#5371, sqlite3 3.51.0 verified live).
937 // - Otherwise the statement-level ORDER BY applies; the scan may
938 // ride an ordering index exactly when the ORDER BY temp-line
939 // suppression fires, so non-suppressed shapes keep their
940 // pre-existing rendering.
941 //
942 // The scan rendering and the suppression stay COUPLED on purpose
943 // (#5373, investigated): sqlite3 also rides an index satisfying only
944 // a partial prefix of the key while keeping the temp line (partial
945 // GROUP BY/DISTINCT keys, mixed ASC/DESC — it then renames the sort
946 // line `USE TEMP B-TREE FOR LAST TERM OF ORDER BY`), but VibeSQL's
947 // runtime never does: `cost_based_index_selection` accepts an index
948 // for ordering only on a full direction-uniform match, and the
949 // aggregation path passes no ordering hint to the scan at all. In
950 // every partial case the runtime seq-scans, so an uncoupled
951 // `SCAN t USING INDEX i` would misstate the access path — documented
952 // divergence (explain_temp_btree_annotation_tests.rs, #5373 section).
953 let window_scan_key = Self::distinct_window_keys(stmt).pop();
954 let (scan_order_by, prefer_ordering_scan): (Option<Vec<vibesql_ast::OrderByItem>>, bool) =
955 if window_scan_key.is_some() {
956 (window_scan_key, true)
957 } else if group_index_order.is_some() {
958 (group_index_order.clone(), true)
959 } else if distinct_index_order.is_some() {
960 (distinct_index_order.clone(), true)
961 } else if stmt.group_by.is_none() && !stmt.distinct {
962 let order_by_suppressed = stmt.order_by.as_ref().is_some_and(|ob| {
963 !Self::needs_temp_btree_for_order_by(
964 stmt.from.as_ref(),
965 stmt.where_clause.as_ref(),
966 ob,
967 database,
968 )
969 });
970 (stmt.order_by.clone(), order_by_suppressed)
971 } else {
972 (stmt.order_by.clone(), false)
973 };
974 if let Some(ref from_clause) = stmt.from {
975 let scan_node = Self::explain_from_clause(
976 from_clause,
977 &stmt.where_clause,
978 &scan_order_by,
979 prefer_ordering_scan,
980 &needed_columns,
981 database,
982 &ctes,
983 )?;
984 root.add_child(scan_node);
985 } else {
986 // No FROM clause - this is a constant expression scan. Multi-row
987 // VALUES bodies render SQLite's plural `SCAN <n> CONSTANT ROWS`;
988 // a single row (or a plain FROM-less SELECT) keeps the singular
989 // `SCAN CONSTANT ROW` (verified against sqlite3 3.51.0, #5361).
990 let mut constant_node = PlanNode::new("Constant Row");
991 constant_node.scan_type = Some(ScanType::Scan);
992 constant_node.operation = match &stmt.values {
993 Some(rows) if rows.len() > 1 => format!("SCAN {} CONSTANT ROWS", rows.len()),
994 _ => "SCAN CONSTANT ROW".to_string(),
995 };
996 root.add_child(constant_node);
997 }
998
999 // Temp-structure annotations for GROUP BY and DISTINCT (#5367).
1000 //
1001 // SQLite emits `USE TEMP B-TREE FOR GROUP BY` / `FOR DISTINCT` when
1002 // grouping/dedup is not satisfied by the scan's delivery order, and
1003 // suppresses the line when an index delivers it (verified against
1004 // sqlite3 3.51.0). VibeSQL's runtime always hash-groups then sorts
1005 // groups by key, and always hash-dedups DISTINCT — the emitted lines
1006 // truthfully describe those temp structures; the index suppression
1007 // mirrors the established permissive EQP-level convention (see
1008 // `needs_temp_btree_for_order_by_eqp`).
1009 if stmt.group_by.is_some() {
1010 // ROLLUP/CUBE/GROUPING SETS (`group_key` is None) always build
1011 // temp structures; a simple key suppresses exactly when an index
1012 // delivers group order (`group_index_order`, computed above).
1013 root.needs_temp_btree_for_group_by = group_index_order.is_none();
1014 }
1015 if stmt.distinct {
1016 // Wildcard SELECT lists and grouped queries never suppress;
1017 // `distinct_index_order` (computed above) is `Some` exactly when
1018 // an index delivers the SELECT-list order.
1019 root.needs_temp_btree_for_distinct = distinct_index_order.is_none();
1020 }
1021
1022 // Check if we need a temp B-tree for ORDER BY
1023 // This happens when ORDER BY cannot be satisfied by an index. The WHERE
1024 // clause is passed through so the planner can pin leading index columns
1025 // and accept trailing ORDER BY columns that would otherwise be rejected
1026 // due to nullability.
1027 //
1028 // The entry is also suppressed when the statement-level ORDER BY is a
1029 // structural prefix of (or equal to) the FIRST SELECT-list window's
1030 // combined sort key. SQLite's window co-routine rewrite places the
1031 // first window's sorting pass outermost, so its key is the final
1032 // output order and the outer sort is satisfied without a second temp
1033 // B-tree (verified against sqlite3 3.51.0). An empty key (`OVER ()`)
1034 // never suppresses; extensions, direction/COLLATE mismatches, and
1035 // matches against later windows do not suppress.
1036 //
1037 // With GROUP BY, the grouping pass replaces the scan as the order
1038 // source: SQLite suppresses the ORDER BY line exactly when the ORDER
1039 // BY terms equal the GROUP BY terms — same expressions, same
1040 // sequence, same length, directions ignored (the group structure is
1041 // traversed in either direction); a bare prefix does NOT suppress
1042 // (all verified against sqlite3 3.51.0). With DISTINCT, the dedup
1043 // structure delivers SELECT-list order: an exact all-ASC match
1044 // suppresses; otherwise the line renders unless the dedup itself
1045 // rode an index (in which case the index check applies as usual).
1046 if let Some(ref order_by) = stmt.order_by {
1047 let satisfied_by_window_sort =
1048 Self::first_window_combined_key(stmt).is_some_and(|key| {
1049 !key.is_empty()
1050 && order_by.len() <= key.len()
1051 && key[..order_by.len()] == order_by[..]
1052 });
1053 root.needs_temp_btree_for_order_by = if satisfied_by_window_sort {
1054 false
1055 } else if stmt.group_by.is_some() {
1056 match &group_key {
1057 Some(key) => !Self::order_by_matches_exprs(
1058 order_by,
1059 key,
1060 &stmt.select_list,
1061 false, // directions ignored
1062 ),
1063 // ROLLUP/CUBE/GROUPING SETS output order is unspecified.
1064 None => true,
1065 }
1066 } else if stmt.distinct {
1067 let matches_distinct = distinct_key.as_ref().is_some_and(|key| {
1068 Self::order_by_matches_exprs(
1069 order_by,
1070 key,
1071 &stmt.select_list,
1072 true, // exact ASC match required (verified live)
1073 )
1074 });
1075 if matches_distinct {
1076 false
1077 } else if root.needs_temp_btree_for_distinct {
1078 // Hash/b-tree dedup does not deliver the requested order.
1079 true
1080 } else {
1081 // Dedup rode the index; the scan's delivery order may
1082 // still satisfy the ORDER BY (e.g. reverse traversal).
1083 Self::needs_temp_btree_for_order_by(
1084 stmt.from.as_ref(),
1085 stmt.where_clause.as_ref(),
1086 order_by,
1087 database,
1088 )
1089 }
1090 } else {
1091 Self::needs_temp_btree_for_order_by(
1092 stmt.from.as_ref(),
1093 stmt.where_clause.as_ref(),
1094 order_by,
1095 database,
1096 )
1097 };
1098 }
1099
1100 // Count window-function sorting passes for EQP output. SQLite emits
1101 // one "USE TEMP B-TREE FOR ORDER BY" entry per distinct window sort
1102 // key not satisfied by an index (window1.test section 23).
1103 root.window_sort_count = Self::count_window_sorts(stmt, database);
1104
1105 // Add WHERE clause info
1106 if stmt.where_clause.is_some() {
1107 root.details.push("Filter: <where clause>".to_string());
1108 }
1109
1110 // Add GROUP BY info
1111 if stmt.group_by.is_some() {
1112 root.details.push("Group: <group by clause>".to_string());
1113 }
1114
1115 // Add ORDER BY info
1116 if stmt.order_by.is_some() {
1117 root.details.push("Sort: <order by clause>".to_string());
1118 }
1119
1120 // Add LIMIT info
1121 if let Some(ref limit) = stmt.limit {
1122 root.details.push(format!("Limit: {}", limit.to_sql()));
1123 }
1124
1125 Ok(root)
1126 }
1127
1128 /// Generate execution plan for a compound SELECT (UNION, INTERSECT, EXCEPT)
1129 fn explain_compound_select(
1130 stmt: &SelectStmt,
1131 database: &Database,
1132 ctes: &HashSet<String>,
1133 ) -> Result<PlanNode, ExecutorError> {
1134 let mut root = PlanNode::new("CompoundQuery");
1135 root.is_compound_query = true;
1136
1137 // Add left-most subquery
1138 let left_stmt = SelectStmt {
1139 with_clause: stmt.with_clause.clone(),
1140 distinct: stmt.distinct,
1141 select_list: stmt.select_list.clone(),
1142 into_table: stmt.into_table.clone(),
1143 into_variables: stmt.into_variables.clone(),
1144 from: stmt.from.clone(),
1145 where_clause: stmt.where_clause.clone(),
1146 group_by: stmt.group_by.clone(),
1147 having: stmt.having.clone(),
1148 window_definitions: stmt.window_definitions.clone(),
1149 order_by: None, // ORDER BY applies to the compound result
1150 limit: None,
1151 offset: None,
1152 set_operation: None,
1153 values: stmt.values.clone(),
1154 };
1155 let left_plan = Self::explain_select(&left_stmt, database, ctes)?;
1156 root.add_child(left_plan);
1157
1158 // Add subsequent set operations
1159 let mut current_set_op = stmt.set_operation.as_ref();
1160 while let Some(set_op) = current_set_op {
1161 // Dedup branches are labeled `<OP> USING TEMP B-TREE` like
1162 // sqlite3 3.51.0 (verified live for UNION/INTERSECT/EXCEPT).
1163 // Truthful: the runtime dedups via temp hash structures
1164 // (select/set_operations.rs). `ALL` variants stay bare; the
1165 // non-standard INTERSECT ALL / EXCEPT ALL have no SQLite
1166 // reference output and keep their bare labels.
1167 let op_label = match (&set_op.op, set_op.all) {
1168 (vibesql_ast::SetOperator::Union, true) => "UNION ALL",
1169 (vibesql_ast::SetOperator::Union, false) => "UNION USING TEMP B-TREE",
1170 (vibesql_ast::SetOperator::Intersect, true) => "INTERSECT ALL",
1171 (vibesql_ast::SetOperator::Intersect, false) => "INTERSECT USING TEMP B-TREE",
1172 (vibesql_ast::SetOperator::Except, true) => "EXCEPT ALL",
1173 (vibesql_ast::SetOperator::Except, false) => "EXCEPT USING TEMP B-TREE",
1174 };
1175
1176 // Create plan for right side of set operation
1177 let right_stmt = SelectStmt {
1178 with_clause: set_op.right.with_clause.clone(),
1179 distinct: set_op.right.distinct,
1180 select_list: set_op.right.select_list.clone(),
1181 into_table: set_op.right.into_table.clone(),
1182 into_variables: set_op.right.into_variables.clone(),
1183 from: set_op.right.from.clone(),
1184 where_clause: set_op.right.where_clause.clone(),
1185 group_by: set_op.right.group_by.clone(),
1186 having: set_op.right.having.clone(),
1187 window_definitions: set_op.right.window_definitions.clone(),
1188 order_by: None,
1189 limit: None,
1190 offset: None,
1191 set_operation: None,
1192 values: set_op.right.values.clone(),
1193 };
1194 let mut right_plan = Self::explain_select(&right_stmt, database, ctes)?;
1195 right_plan.set_operation_type = Some(op_label.to_string());
1196 root.add_child(right_plan);
1197
1198 // Continue to nested set operations
1199 current_set_op = set_op.right.set_operation.as_ref();
1200 }
1201
1202 // A compound's statement-level ORDER BY renders as a trailing
1203 // `USE TEMP B-TREE FOR ORDER BY` line after the COMPOUND QUERY
1204 // block. Truthful (#5371): the runtime materializes the combined
1205 // result and sorts it in one pass (`sort_set_operation_results`,
1206 // select/executor/execute.rs) for every operator, ALL or not.
1207 //
1208 // Documented divergence: sqlite3 3.51.0 instead renders a
1209 // `MERGE (UNION)` / `MERGE (UNION ALL)` / ... block with per-branch
1210 // `USE TEMP B-TREE FOR ORDER BY` lines (verified live) because its
1211 // runtime sorts each branch and merges them. VibeSQL does not
1212 // merge pre-sorted branches, so rendering MERGE would fabricate a
1213 // plan shape that never executes (per the #5355/#5360/#5366
1214 // truthfulness precedent).
1215 root.needs_temp_btree_for_order_by = stmt.order_by.is_some();
1216
1217 Ok(root)
1218 }
1219
1220 /// Count the distinct window-function sorting passes required by the
1221 /// SELECT list that are not satisfied by an index.
1222 ///
1223 /// SQLite semantics (window1.test section 23, `do_ordercount_test`):
1224 /// - The sort key for a window is its PARTITION BY expressions (treated
1225 /// as ASC with default null ordering) followed by its ORDER BY items.
1226 /// `OVER (PARTITION BY a ORDER BY b)` and `OVER (ORDER BY a, b)` share
1227 /// the key `(a, b)`.
1228 /// - Keys are deduplicated by exact structural equality (including
1229 /// direction and COLLATE); frame clauses are ignored entirely.
1230 /// - Windows with neither PARTITION BY nor ORDER BY (`OVER ()`) need no
1231 /// sorting pass.
1232 /// - Only the INNERMOST sorting pass scans the base table and can be
1233 /// satisfied by an index (e.g. key `(a, b)` with index `t5ab(a, b)`).
1234 /// SQLite's nested co-routine rewrite emits passes in reverse
1235 /// SELECT-list order, so the innermost pass corresponds to the LAST
1236 /// distinct key (by first occurrence).
1237 /// - When the innermost pass IS satisfied by an index, sortedness
1238 /// propagates outward: each outer pass whose key is a structural
1239 /// prefix of the order delivered by the pass below it needs no sort
1240 /// either, because the rows flow through in an order that already
1241 /// satisfies it (window9.test 5.1.1: index `i1(a,b,c,d,e)` satisfies
1242 /// keys `(a,b,c,d)`, `(a,b,c)`, `(a,b)` and `(a)` — zero sorts).
1243 /// The chain breaks at the first outer key that is not such a prefix;
1244 /// that key and all keys outside it need temp B-trees (the temp
1245 /// B-tree sort is not guaranteed to preserve residual ordering, so no
1246 /// further propagation is attempted — matching the conservative
1247 /// pre-existing behavior validated by window1.test section 23).
1248 fn count_window_sorts(stmt: &SelectStmt, database: &Database) -> usize {
1249 let distinct_keys = Self::distinct_window_keys(stmt);
1250
1251 // Only the innermost pass — the last distinct key — scans the base
1252 // table; it alone is eligible for direct index suppression. When it
1253 // is satisfied by the index, sortedness propagates outward to every
1254 // consecutive outer key that is a structural prefix of the order
1255 // delivered by the pass below it. Once a pass needs a temp B-tree,
1256 // all passes outside it count as well.
1257 let Some((innermost, outer)) = distinct_keys.split_last() else {
1258 return 0;
1259 };
1260
1261 let mut count = 0;
1262 // The order delivered to outer passes while the suppression chain
1263 // is unbroken. `None` once any pass has required a temp B-tree.
1264 let mut delivered: Option<&[vibesql_ast::OrderByItem]> =
1265 if Self::window_key_satisfied_by_index(
1266 stmt.from.as_ref(),
1267 stmt.where_clause.as_ref(),
1268 innermost,
1269 database,
1270 ) {
1271 Some(innermost.as_slice())
1272 } else {
1273 count += 1;
1274 None
1275 };
1276
1277 // Walk outward (last-but-one key back to the first).
1278 for key in outer.iter().rev() {
1279 match delivered {
1280 Some(order)
1281 if key.len() <= order.len() && key.as_slice() == &order[..key.len()] =>
1282 {
1283 // Structural prefix of the incoming order — rows flow
1284 // through already sorted; no temp B-tree needed. The
1285 // delivered order is unchanged.
1286 }
1287 _ => {
1288 count += 1;
1289 delivered = None;
1290 }
1291 }
1292 }
1293
1294 count
1295 }
1296
1297 /// The distinct window sort keys of the SELECT list, in first-occurrence
1298 /// order. Each key is PARTITION BY exprs (as ASC) + window ORDER BY
1299 /// items; empty keys (`OVER ()`) are skipped. SQLite's nested co-routine
1300 /// rewrite emits sorting passes in reverse order, so the LAST key is the
1301 /// innermost pass — the one that scans the base table. Resolution errors
1302 /// (e.g. unknown named window) surface during execution; EXPLAIN just
1303 /// sees no window keys.
1304 fn distinct_window_keys(stmt: &SelectStmt) -> Vec<Vec<vibesql_ast::OrderByItem>> {
1305 let Ok(specs) = crate::select::window::collect_resolved_window_specs(
1306 &stmt.select_list,
1307 stmt.window_definitions.as_ref(),
1308 ) else {
1309 return Vec::new();
1310 };
1311
1312 let mut distinct_keys: Vec<Vec<vibesql_ast::OrderByItem>> = Vec::new();
1313 for spec in &specs {
1314 let key = Self::window_combined_key(spec);
1315
1316 // OVER () — no partitioning or ordering — needs no sort pass.
1317 if key.is_empty() {
1318 continue;
1319 }
1320
1321 if !distinct_keys.contains(&key) {
1322 distinct_keys.push(key);
1323 }
1324 }
1325 distinct_keys
1326 }
1327
1328 /// Build the combined sort key for a window spec: PARTITION BY
1329 /// expressions (treated as ASC with default null ordering) followed by
1330 /// the window's ORDER BY items. `OVER ()` yields an empty key.
1331 fn window_combined_key(spec: &vibesql_ast::WindowSpec) -> Vec<vibesql_ast::OrderByItem> {
1332 let mut key: Vec<vibesql_ast::OrderByItem> = Vec::new();
1333 if let Some(partition_by) = &spec.partition_by {
1334 for expr in partition_by {
1335 key.push(vibesql_ast::OrderByItem {
1336 expr: expr.clone(),
1337 direction: vibesql_ast::OrderDirection::Asc,
1338 nulls_order: None,
1339 });
1340 }
1341 }
1342 if let Some(order_by) = &spec.order_by {
1343 key.extend(order_by.iter().cloned());
1344 }
1345 key
1346 }
1347
1348 /// Combined sort key of the FIRST window function in the SELECT list, if
1349 /// any. SELECT-list order is preserved by
1350 /// `collect_resolved_window_specs`, and SQLite's co-routine rewrite makes
1351 /// the first window's sorting pass the outermost one — its key is the
1352 /// order of the final output.
1353 fn first_window_combined_key(stmt: &SelectStmt) -> Option<Vec<vibesql_ast::OrderByItem>> {
1354 let specs = crate::select::window::collect_resolved_window_specs(
1355 &stmt.select_list,
1356 stmt.window_definitions.as_ref(),
1357 )
1358 .ok()?;
1359 specs.first().map(Self::window_combined_key)
1360 }
1361
1362 /// EQP-level check: can the base-table scan deliver `key` order for the
1363 /// INNERMOST window sorting pass? Unlike the statement-level ORDER BY
1364 /// check, the window pass is fed directly by the FROM scan, and SQLite
1365 /// picks an index that delivers PARTITION BY/ORDER BY order even when no
1366 /// predicate can use it (windowpushd.test 2.1.1.5, 2.1.3.6) — hence
1367 /// `prefer_ordering_scan = true`.
1368 fn window_key_satisfied_by_index(
1369 from: Option<&vibesql_ast::FromClause>,
1370 where_clause: Option<&vibesql_ast::Expression>,
1371 key: &[vibesql_ast::OrderByItem],
1372 database: &Database,
1373 ) -> bool {
1374 let Some(from_clause) = from else {
1375 return true; // No FROM — single constant row, no sort needed.
1376 };
1377 let vibesql_ast::FromClause::Table { name, .. } = from_clause else {
1378 return false; // Joins/subqueries always need a sorting pass.
1379 };
1380 eqp_ordering_index(name, where_clause, key, database, true).is_some()
1381 }
1382
1383 /// Check if ORDER BY requires a temp B-tree (sorting pass) for EQP rendering.
1384 ///
1385 /// Delegates to [`needs_temp_btree_for_order_by_eqp`] in the index-scan
1386 /// selection module, which threads the WHERE clause through and applies a
1387 /// permissive EQP-level check: when an index has its leading columns pinned
1388 /// by equality/IN predicates and the ORDER BY structurally aligns, no temp
1389 /// B-tree is shown — matching SQLite's behavior on plans like
1390 /// `WHERE a IN (1,2,3) ORDER BY a, b`. Runtime correctness for nullable
1391 /// trailing-column ordering is handled separately by the post-scan
1392 /// `apply_order_by` pass.
1393 fn needs_temp_btree_for_order_by(
1394 from: Option<&vibesql_ast::FromClause>,
1395 where_clause: Option<&vibesql_ast::Expression>,
1396 order_by: &[vibesql_ast::OrderByItem],
1397 database: &Database,
1398 ) -> bool {
1399 // If no FROM clause, this is a constant expression - no sorting needed
1400 // (e.g., "SELECT 5 ORDER BY 1" just returns one row)
1401 let Some(from_clause) = from else {
1402 return false;
1403 };
1404
1405 // Get the table name
1406 let table_name = match from_clause {
1407 vibesql_ast::FromClause::Table { name, .. } => name.as_str(),
1408 _ => return true, // Joins/subqueries need temp B-tree
1409 };
1410
1411 needs_temp_btree_for_order_by_eqp(table_name, where_clause, order_by, database)
1412 }
1413
1414 /// Resolve an ORDER BY / GROUP BY term against the SELECT list the way
1415 /// SQLite does for EQP purposes: an integer literal is a 1-based output
1416 /// ordinal, and a bare (unqualified) column reference matching an output
1417 /// alias resolves to that output expression. Anything else (including
1418 /// out-of-range ordinals) is returned unchanged. Verified live:
1419 /// `GROUP BY b ORDER BY 1` and `SELECT b AS z ... GROUP BY b ORDER BY z`
1420 /// both suppress the ORDER BY temp B-tree line.
1421 fn resolve_output_expr<'a>(
1422 expr: &'a Expression,
1423 select_list: &'a [SelectItem],
1424 ) -> &'a Expression {
1425 use vibesql_types::SqlValue;
1426
1427 let ordinal = match expr {
1428 Expression::Literal(SqlValue::Integer(n)) | Expression::Literal(SqlValue::Bigint(n)) => {
1429 Some(*n)
1430 }
1431 Expression::Literal(SqlValue::Smallint(n)) => Some(i64::from(*n)),
1432 _ => None,
1433 };
1434 if let Some(n) = ordinal {
1435 if n >= 1 && (n as usize) <= select_list.len() {
1436 if let SelectItem::Expression { expr: target, .. } = &select_list[n as usize - 1] {
1437 return target;
1438 }
1439 }
1440 return expr;
1441 }
1442
1443 if let Expression::ColumnRef(col_id) = expr {
1444 if col_id.schema_canonical().is_none() && col_id.table_canonical().is_none() {
1445 for item in select_list {
1446 if let SelectItem::Expression { expr: target, alias: Some(alias), .. } = item {
1447 if alias.eq_ignore_ascii_case(col_id.column_canonical()) {
1448 return target;
1449 }
1450 }
1451 }
1452 }
1453 }
1454
1455 expr
1456 }
1457
1458 /// The DISTINCT key: the SELECT-list expressions in order, or `None`
1459 /// when the list contains wildcards (which never suppress the DISTINCT
1460 /// temp-structure line).
1461 fn distinct_key_exprs(select_list: &[SelectItem]) -> Option<Vec<&Expression>> {
1462 select_list
1463 .iter()
1464 .map(|item| match item {
1465 SelectItem::Expression { expr, .. } => Some(expr),
1466 SelectItem::Wildcard { .. } | SelectItem::QualifiedWildcard { .. } => None,
1467 })
1468 .collect()
1469 }
1470
1471 /// Wrap key expressions as ASC ORDER BY items for the index-delivery
1472 /// checks (grouping and dedup keys have no inherent direction).
1473 fn exprs_as_asc_order_items(exprs: &[&Expression]) -> Vec<vibesql_ast::OrderByItem> {
1474 exprs
1475 .iter()
1476 .map(|e| vibesql_ast::OrderByItem {
1477 expr: (*e).clone(),
1478 direction: vibesql_ast::OrderDirection::Asc,
1479 nulls_order: None,
1480 })
1481 .collect()
1482 }
1483
1484 /// True when the statement-level ORDER BY matches `exprs` exactly: same
1485 /// expressions (output ordinals/aliases resolved), same sequence, same
1486 /// length, no explicit NULLS ordering. With `require_asc`, every term
1487 /// must also be ASC (the DISTINCT rule); otherwise directions are
1488 /// ignored (the GROUP BY rule — sqlite3 suppresses for DESC and even
1489 /// mixed-direction matches, verified live). Bare prefixes and
1490 /// permutations never match (verified live).
1491 fn order_by_matches_exprs(
1492 order_by: &[vibesql_ast::OrderByItem],
1493 exprs: &[&Expression],
1494 select_list: &[SelectItem],
1495 require_asc: bool,
1496 ) -> bool {
1497 order_by.len() == exprs.len()
1498 && order_by.iter().zip(exprs).all(|(item, expr)| {
1499 item.nulls_order.is_none()
1500 && (!require_asc
1501 || item.direction == vibesql_ast::OrderDirection::Asc)
1502 && Self::resolve_output_expr(&item.expr, select_list) == *expr
1503 })
1504 }
1505
1506 /// The ASC ORDER BY items under which an index delivers the rows in
1507 /// GROUP BY order, if any — `Some` suppresses the
1508 /// `USE TEMP B-TREE FOR GROUP BY` line, and the returned key becomes the
1509 /// base scan's ordering requirement so the scan line shows the
1510 /// delivering index like sqlite3 (#5371).
1511 ///
1512 /// Grouping is order-insensitive, so SQLite reorders the GROUP BY terms
1513 /// to match a candidate index (`GROUP BY b, a` with index `(a, b)`
1514 /// suppresses — verified live). We first try the key as written, then
1515 /// retry with the terms permuted into each index's column order.
1516 fn group_key_index_order(
1517 from: Option<&vibesql_ast::FromClause>,
1518 where_clause: Option<&vibesql_ast::Expression>,
1519 group_key: &[&Expression],
1520 database: &Database,
1521 ) -> Option<Vec<vibesql_ast::OrderByItem>> {
1522 let items = Self::exprs_as_asc_order_items(group_key);
1523 if !Self::needs_temp_btree_for_order_by(from, where_clause, &items, database) {
1524 return Some(items);
1525 }
1526
1527 // Order-insensitive retry: only bare column references can be
1528 // realigned to an index's column order.
1529 let Some(vibesql_ast::FromClause::Table { name, .. }) = from else {
1530 return None;
1531 };
1532 let col_names: Option<Vec<&str>> = group_key
1533 .iter()
1534 .map(|e| match e {
1535 Expression::ColumnRef(col_id) => Some(col_id.column_canonical()),
1536 _ => None,
1537 })
1538 .collect();
1539 let col_names = col_names?;
1540
1541 for index_name in database.list_indexes_for_table(name) {
1542 let Some(index) = database.get_index(&index_name) else { continue };
1543 // Permute the group terms into this index's column order; every
1544 // term must appear as an index column for the realignment to be
1545 // meaningful.
1546 let positions: Option<Vec<usize>> = col_names
1547 .iter()
1548 .map(|col| {
1549 index.columns.iter().position(|ic| {
1550 ic.column_name().is_some_and(|n| n.eq_ignore_ascii_case(col))
1551 })
1552 })
1553 .collect();
1554 let Some(positions) = positions else { continue };
1555
1556 let mut order: Vec<usize> = (0..group_key.len()).collect();
1557 order.sort_by_key(|&i| positions[i]);
1558 if order.iter().zip(0..group_key.len()).all(|(&a, b)| a == b) {
1559 continue; // Same as the as-written key already checked.
1560 }
1561 let permuted: Vec<&Expression> = order.iter().map(|&i| group_key[i]).collect();
1562 let permuted_items = Self::exprs_as_asc_order_items(&permuted);
1563 if !Self::needs_temp_btree_for_order_by(from, where_clause, &permuted_items, database)
1564 {
1565 return Some(permuted_items);
1566 }
1567 }
1568
1569 None
1570 }
1571
1572 /// True when the SELECT list (or named WINDOW clause) of `stmt` contains
1573 /// window functions — such subqueries/views render as CO-ROUTINE blocks
1574 /// in EQP output because SQLite cannot flatten them.
1575 fn select_has_window_functions(stmt: &SelectStmt) -> bool {
1576 crate::select::window::collect_resolved_window_specs(
1577 &stmt.select_list,
1578 stmt.window_definitions.as_ref(),
1579 )
1580 .map(|specs| !specs.is_empty())
1581 .unwrap_or(false)
1582 }
1583
1584 /// True when a plain (window-free) view body is simple enough for its
1585 /// plan to be inlined into the outer EQP output (#5355).
1586 ///
1587 /// Mirrors SQLite's query-flattener blocking conditions conservatively:
1588 /// aggregates, GROUP BY/HAVING, DISTINCT, LIMIT/OFFSET, compound bodies,
1589 /// VALUES, and WITH clauses all render as `CO-ROUTINE <view>` blocks
1590 /// showing the body's inner plan (#5361) — truthful because the runtime
1591 /// materializes every view body. Window functions take the same
1592 /// CO-ROUTINE path (#5347).
1593 fn view_body_is_flattenable(stmt: &SelectStmt) -> bool {
1594 let select_list_has_aggregate = Self::select_list_has_aggregate(stmt);
1595
1596 stmt.set_operation.is_none()
1597 && !stmt.distinct
1598 && stmt.group_by.is_none()
1599 && stmt.having.is_none()
1600 && stmt.limit.is_none()
1601 && stmt.offset.is_none()
1602 && stmt.values.is_none()
1603 && stmt.with_clause.is_none()
1604 && !select_list_has_aggregate
1605 }
1606
1607 /// True when any SELECT-list expression contains an aggregate function.
1608 fn select_list_has_aggregate(stmt: &SelectStmt) -> bool {
1609 stmt.select_list.iter().any(|item| match item {
1610 SelectItem::Expression { expr, .. } => contains_aggregate_function(expr),
1611 SelectItem::Wildcard { .. } | SelectItem::QualifiedWildcard { .. } => false,
1612 })
1613 }
1614
1615 /// True when a compound body contains any deduplicating set operation
1616 /// (UNION/INTERSECT/EXCEPT without ALL). SQLite cannot flatten such
1617 /// derived tables and renders them as CO-ROUTINE blocks; UNION-ALL-only
1618 /// chains flatten into a top-level COMPOUND QUERY (verified live).
1619 fn compound_has_dedup(stmt: &SelectStmt) -> bool {
1620 let mut set_op = stmt.set_operation.as_ref();
1621 while let Some(op) = set_op {
1622 if !op.all {
1623 return true;
1624 }
1625 set_op = op.right.set_operation.as_ref();
1626 }
1627 false
1628 }
1629
1630 /// Generate plan node for FROM clause
1631 ///
1632 /// `prefer_ordering_scan` is true when `order_by` is an ordering
1633 /// requirement the scan may ride an index for even without predicates —
1634 /// a window sort key (windowpushd.test 2.1.3.6), an index-delivered
1635 /// GROUP BY/DISTINCT key, or a statement-level ORDER BY whose temp-line
1636 /// suppression fired (#5371).
1637 fn explain_from_clause(
1638 from: &vibesql_ast::FromClause,
1639 where_clause: &Option<vibesql_ast::Expression>,
1640 order_by: &Option<Vec<vibesql_ast::OrderByItem>>,
1641 prefer_ordering_scan: bool,
1642 needed_columns: &HashSet<String>,
1643 database: &Database,
1644 ctes: &HashSet<String>,
1645 ) -> Result<PlanNode, ExecutorError> {
1646 match from {
1647 vibesql_ast::FromClause::Table { name, alias, .. } => {
1648 // Expand views in EQP output instead of showing an opaque
1649 // `SCAN <view>`. CTE names shadow same-named views and are
1650 // never expanded.
1651 //
1652 // - Window-function views (#5347) and blocked bodies —
1653 // aggregates, GROUP BY/HAVING, DISTINCT, LIMIT/OFFSET,
1654 // compound, VALUES, WITH (#5361): SQLite cannot flatten
1655 // them, so the body's plan renders as a CO-ROUTINE block
1656 // plus a trailing `SCAN <name>` (windowpushd.test 2.1.3.6;
1657 // sqlite3 3.51.0 verified per category). VibeSQL's runtime
1658 // materializes every view body, so the block + inner plan
1659 // is truthful even where SQLite manages to flatten a
1660 // specific shape (LIMIT-only bodies, UNION ALL bodies,
1661 // single-use plain CTEs — documented divergences in
1662 // explain_view_expansion_tests.rs).
1663 // - Plain flattenable views (#5355): SQLite inlines the body
1664 // into the outer query and shows the underlying table
1665 // access with no mention of the view. VibeSQL's runtime
1666 // MATERIALIZES views (select/scan/table.rs executes the
1667 // full body, then post-filters the outer WHERE), so the
1668 // inner scans shown here are the truthful access path; we
1669 // deliberately do NOT fabricate SQLite's outer-WHERE
1670 // push-down (`SEARCH <table> (x=?)`) because no index
1671 // probe happens at runtime. Where no index applies the
1672 // output matches SQLite exactly (`SCAN <table>`).
1673 if !ctes.contains(&name.to_ascii_lowercase()) {
1674 if let Some(view) = database.catalog.get_view(name) {
1675 let source = alias.as_deref().unwrap_or(name.as_str());
1676 let child = Self::explain_select(&view.query, database, ctes)?;
1677 let mut view_node = PlanNode::new("Subquery");
1678 view_node.object = Some(format!("AS {}", source));
1679
1680 if Self::select_has_window_functions(&view.query)
1681 || !Self::view_body_is_flattenable(&view.query)
1682 {
1683 // CO-ROUTINE block: the inner plan (including
1684 // the body's own temp B-tree entries) renders
1685 // inside the block via collect_eqp_entries, so
1686 // no flag hoisting — subtree_needs_order_by_
1687 // temp_btree skips co-routine subtrees to avoid
1688 // double emission.
1689 view_node.coroutine = Some(source.to_string());
1690 } else {
1691 // The body's ORDER BY genuinely sorts at runtime
1692 // (views are materialized), and SQLite's
1693 // flattened plan keeps the body's `USE TEMP
1694 // B-TREE FOR ORDER BY` line. collect_eqp_entries
1695 // only checks the root's DIRECT children for the
1696 // flag, but the body root sits one level deeper
1697 // (root -> Subquery -> body) — and deeper still
1698 // for nested ORDER BY views — so hoist the
1699 // subtree's flag onto this node to keep the line
1700 // rendered (verified against sqlite3 3.51.0).
1701 view_node.needs_temp_btree_for_order_by =
1702 subtree_needs_order_by_temp_btree(&child);
1703 }
1704
1705 view_node.add_child(child);
1706 return Ok(view_node);
1707 }
1708 }
1709
1710 Self::explain_table_scan(
1711 name,
1712 alias.as_deref(),
1713 where_clause,
1714 order_by,
1715 prefer_ordering_scan,
1716 needed_columns,
1717 database,
1718 )
1719 }
1720 vibesql_ast::FromClause::Join {
1721 left,
1722 right,
1723 join_type,
1724 condition,
1725 using_columns,
1726 natural,
1727 ..
1728 } => {
1729 let join_name = match join_type {
1730 vibesql_ast::JoinType::Inner => "Inner Join",
1731 vibesql_ast::JoinType::LeftOuter => "Left Outer Join",
1732 vibesql_ast::JoinType::RightOuter => "Right Outer Join",
1733 vibesql_ast::JoinType::FullOuter => "Full Outer Join",
1734 vibesql_ast::JoinType::Cross => "Cross Join",
1735 vibesql_ast::JoinType::Semi => "Semi Join",
1736 vibesql_ast::JoinType::Anti => "Anti Join",
1737 };
1738
1739 let mut join_node = PlanNode::new(join_name);
1740
1741 if *natural {
1742 join_node.details.push("NATURAL join".to_string());
1743 }
1744
1745 if condition.is_some() {
1746 join_node.details.push("Join condition: <on clause>".to_string());
1747 }
1748
1749 if let Some(cols) = using_columns {
1750 join_node.details.push(format!("USING ({})", cols.join(", ")));
1751 }
1752
1753 // Add left child
1754 let left_child = Self::explain_from_clause(
1755 left,
1756 where_clause,
1757 order_by,
1758 prefer_ordering_scan,
1759 needed_columns,
1760 database,
1761 ctes,
1762 )?;
1763 join_node.add_child(left_child);
1764
1765 // Add right child (no WHERE pushdown for right side in simple case)
1766 let empty_cols = HashSet::new();
1767 let right_child = Self::explain_from_clause(
1768 right,
1769 &None,
1770 &None,
1771 false,
1772 &empty_cols,
1773 database,
1774 ctes,
1775 )?;
1776 join_node.add_child(right_child);
1777
1778 Ok(join_node)
1779 }
1780 vibesql_ast::FromClause::Subquery { query, alias, .. } => {
1781 let mut subquery_node = PlanNode::new("Subquery");
1782 subquery_node.object = Some(format!("AS {}", alias));
1783
1784 // Derived tables SQLite cannot flatten render as a
1785 // `CO-ROUTINE <alias>` block: window functions (#5347),
1786 // aggregates, GROUP BY/HAVING, DISTINCT, and compounds with
1787 // a deduplicating set operation (#5367 — sqlite3 3.51.0
1788 // shows `CO-ROUTINE q` around the COMPOUND QUERY for dedup
1789 // UNION/INTERSECT/EXCEPT bodies, verified live). Truthful:
1790 // the runtime materializes derived tables. UNION-ALL-only
1791 // compounds, LIMIT-only, and plain bodies keep the existing
1792 // flat rendering, matching SQLite's flattener exactly.
1793 if Self::select_has_window_functions(query)
1794 || query.group_by.is_some()
1795 || query.distinct
1796 || query.having.is_some()
1797 || Self::select_list_has_aggregate(query)
1798 || Self::compound_has_dedup(query)
1799 {
1800 subquery_node.coroutine = Some(alias.clone());
1801 }
1802
1803 let child = Self::explain_select(query, database, ctes)?;
1804 subquery_node.add_child(child);
1805
1806 Ok(subquery_node)
1807 }
1808 vibesql_ast::FromClause::Values { rows, alias, column_aliases } => {
1809 let mut values_node = PlanNode::new("Values");
1810 values_node.object = Some(format!("AS {}", alias));
1811
1812 values_node.details.push(format!("{} row(s)", rows.len()));
1813
1814 if let Some(aliases) = column_aliases {
1815 values_node.details.push(format!("Columns: {}", aliases.join(", ")));
1816 }
1817
1818 Ok(values_node)
1819 }
1820 }
1821 }
1822
1823 /// True when `index_name` covers every column the query reads from
1824 /// `table_name`: all SELECT-list and WHERE columns are index columns,
1825 /// with the rowid-alias column carried implicitly (SQLite indexes always
1826 /// store the rowid — windowpushd.test 1.4). An empty `needed_columns`
1827 /// set means a wildcard SELECT, which can never be covering.
1828 fn index_covers_scan(
1829 table_name: &str,
1830 index_name: &str,
1831 where_clause: &Option<vibesql_ast::Expression>,
1832 needed_columns: &HashSet<String>,
1833 database: &Database,
1834 ) -> bool {
1835 if needed_columns.is_empty() {
1836 return false;
1837 }
1838 let mut all_needed_columns = needed_columns.clone();
1839 if let Some(where_expr) = where_clause {
1840 collect_column_refs(where_expr, &mut all_needed_columns);
1841 }
1842 if let Some(table) = database.get_table(table_name) {
1843 if let Some(rowid_idx) = table.schema.rowid_alias_column {
1844 if let Some(col) = table.schema.columns.get(rowid_idx) {
1845 all_needed_columns.remove(&col.name.to_lowercase());
1846 }
1847 }
1848 }
1849 is_covering_index(index_name, &all_needed_columns, database)
1850 }
1851
1852 /// Generate plan node for table scan (sequential or index)
1853 fn explain_table_scan(
1854 table_name: &str,
1855 alias: Option<&str>,
1856 where_clause: &Option<vibesql_ast::Expression>,
1857 order_by: &Option<Vec<vibesql_ast::OrderByItem>>,
1858 prefer_ordering_scan: bool,
1859 needed_columns: &HashSet<String>,
1860 database: &Database,
1861 ) -> Result<PlanNode, ExecutorError> {
1862 // First check for regular index scan
1863 let index_info = cost_based_index_selection(
1864 table_name,
1865 where_clause.as_ref(),
1866 order_by.as_ref().map(|v| v.as_slice()),
1867 database,
1868 );
1869
1870 // If no regular index scan, check for skip-scan optimization
1871 let skip_scan_plan = if index_info.is_none() {
1872 if let Some(where_expr) = where_clause {
1873 let planner = IndexPlanner::new(database);
1874 planner.plan_skip_scan(table_name, where_expr)
1875 } else {
1876 None
1877 }
1878 } else {
1879 None
1880 };
1881
1882 // Check if we're using a primary key lookup
1883 let is_pk_lookup = Self::is_primary_key_lookup(table_name, where_clause, database);
1884
1885 let mut node = if let Some(skip_plan) = skip_scan_plan {
1886 // Skip-scan detected - display skip-scan specific information
1887 let skip_info = skip_plan.skip_scan_info.as_ref().unwrap();
1888
1889 let mut skip_node = PlanNode::new("Skip Scan")
1890 .with_object(table_name)
1891 .with_scan_type(ScanType::Search)
1892 .with_index_name(&skip_plan.index_name);
1893 skip_node.details.push(format!("USING INDEX {} ", skip_plan.index_name));
1894 skip_node.details.push(format!(
1895 "Skip columns: {} (cardinality: {})",
1896 skip_info.prefix_columns.join(", "),
1897 skip_info.prefix_cardinality
1898 ));
1899 skip_node.details.push(format!("Filter column: {}", skip_info.filter_column));
1900 skip_node.details.push(format!("Estimated cost: {:.2}", skip_info.estimated_cost));
1901
1902 // Add filter column predicate for SQLite EQP
1903 skip_node = skip_node.with_index_predicate(&skip_info.filter_column, "=");
1904
1905 skip_node
1906 } else if let Some((index_name, sorted_cols)) = index_info {
1907 // Check if this is a covering index (all needed columns are in
1908 // the index) — see `index_covers_scan` for the rules.
1909 let is_covering = Self::index_covers_scan(
1910 table_name,
1911 &index_name,
1912 where_clause,
1913 needed_columns,
1914 database,
1915 );
1916
1917 // Check if we have any predicates on the index (determines SCAN vs SEARCH)
1918 let has_index_predicates = if let Some(where_expr) = where_clause {
1919 let predicates = extract_index_predicates(where_expr, &index_name, database);
1920 !predicates.is_empty()
1921 } else {
1922 false
1923 };
1924
1925 // Determine scan type:
1926 // - If we have predicates → SEARCH (or COVERING SEARCH)
1927 // - If no predicates but using index for ordering → SCAN USING INDEX
1928 let scan_type = if is_pk_lookup {
1929 ScanType::IntegerPrimaryKey
1930 } else if has_index_predicates {
1931 if is_covering {
1932 ScanType::CoveringIndex
1933 } else {
1934 ScanType::Search
1935 }
1936 } else {
1937 // No predicates - this is a SCAN using index for ordering
1938 ScanType::Scan
1939 };
1940
1941 let mut idx_node = PlanNode::new("Index Scan")
1942 .with_object(table_name)
1943 .with_scan_type(scan_type.clone())
1944 .with_index_name(&index_name);
1945 // Pure ordering scans (no predicates) render `SCAN t USING
1946 // COVERING INDEX i` when the index covers all read columns,
1947 // matching sqlite3 (#5371). SEARCH paths carry covering via
1948 // `ScanType::CoveringIndex` instead.
1949 idx_node.index_covering = is_covering;
1950 idx_node.details.push(format!("USING INDEX {} ", index_name));
1951
1952 // Extract predicates from WHERE clause for SQLite EQP format
1953 if let Some(where_expr) = where_clause {
1954 let predicates = extract_index_predicates(where_expr, &index_name, database);
1955 for (col, op) in predicates {
1956 idx_node = idx_node.with_index_predicate(&col, &op);
1957 }
1958 }
1959
1960 if let Some(cols) = sorted_cols {
1961 let col_strs: Vec<String> = cols
1962 .iter()
1963 .map(|(col, dir)| {
1964 format!(
1965 "{} {}",
1966 col,
1967 match dir {
1968 vibesql_ast::OrderDirection::Asc => "ASC",
1969 vibesql_ast::OrderDirection::Desc => "DESC",
1970 }
1971 )
1972 })
1973 .collect();
1974 idx_node.details.push(format!("Sorted by: {}", col_strs.join(", ")));
1975 }
1976
1977 idx_node
1978 } else if let Some(ordering_index) =
1979 order_by.as_deref().filter(|_| prefer_ordering_scan).and_then(|items| {
1980 eqp_ordering_index(table_name, where_clause.as_ref(), items, database, true)
1981 })
1982 {
1983 // No filtering index, but the scan feeds a pass whose key an
1984 // index delivers — a window sorting pass (windowpushd.test
1985 // 2.1.3.6: `SCAN t1 USING INDEX i2`, chosen purely for PARTITION
1986 // BY order), or a GROUP BY/DISTINCT/ORDER BY whose temp-line
1987 // suppression fired (#5371): SQLite scans that index instead of
1988 // sorting, rendering COVERING when it covers all read columns.
1989 let mut idx_node = PlanNode::new("Index Scan")
1990 .with_object(table_name)
1991 .with_scan_type(ScanType::Scan)
1992 .with_index_name(&ordering_index);
1993 idx_node.index_covering = Self::index_covers_scan(
1994 table_name,
1995 &ordering_index,
1996 where_clause,
1997 needed_columns,
1998 database,
1999 );
2000 idx_node
2001 } else {
2002 PlanNode::new("Seq Scan").with_object(table_name).with_scan_type(ScanType::Scan)
2003 };
2004
2005 // Add alias if present
2006 if let Some(a) = alias {
2007 node.details.push(format!("Alias: {}", a));
2008 }
2009
2010 // Add row estimate if table exists
2011 if let Some(table) = database.get_table(table_name) {
2012 let row_count = table.row_count();
2013 node = node.with_estimated_rows(row_count as f64);
2014 }
2015
2016 Ok(node)
2017 }
2018
2019 /// Check if this is a primary key lookup (INTEGER PRIMARY KEY in SQLite)
2020 fn is_primary_key_lookup(
2021 table_name: &str,
2022 where_clause: &Option<Expression>,
2023 database: &Database,
2024 ) -> bool {
2025 let Some(table) = database.get_table(table_name) else {
2026 return false;
2027 };
2028
2029 let Some(pk_columns) = table.schema.primary_key.as_ref() else {
2030 return false;
2031 };
2032
2033 // Only consider single-column integer primary keys
2034 if pk_columns.len() != 1 {
2035 return false;
2036 }
2037
2038 let pk_col = &pk_columns[0];
2039 let pk_col_lower = pk_col.to_lowercase();
2040
2041 // Check if WHERE clause references the primary key column
2042 if let Some(where_expr) = where_clause {
2043 return expression_references_column(where_expr, &pk_col_lower);
2044 }
2045
2046 false
2047 }
2048}
2049
2050/// Recursively check if an expression contains aggregate functions.
2051///
2052/// Used by the EQP view-flattening gate (#5355): a view body whose SELECT
2053/// list aggregates cannot be flattened. Subqueries are conservatively treated
2054/// as containing aggregates (blocking flattening keeps the safe opaque
2055/// rendering).
2056fn contains_aggregate_function(expr: &Expression) -> bool {
2057 match expr {
2058 Expression::AggregateFunction { .. } => true,
2059 Expression::Function { args, .. } => args.iter().any(contains_aggregate_function),
2060 Expression::BinaryOp { left, right, .. } => {
2061 contains_aggregate_function(left) || contains_aggregate_function(right)
2062 }
2063 Expression::UnaryOp { expr, .. } => contains_aggregate_function(expr),
2064 Expression::IsNull { expr, .. } => contains_aggregate_function(expr),
2065 Expression::Cast { expr, .. } => contains_aggregate_function(expr),
2066 Expression::Conjunction(exprs) | Expression::Disjunction(exprs) => {
2067 exprs.iter().any(contains_aggregate_function)
2068 }
2069 Expression::Case { operand, when_clauses, else_result, .. } => {
2070 operand.as_ref().is_some_and(|e| contains_aggregate_function(e))
2071 || when_clauses.iter().any(|clause| {
2072 clause.conditions.iter().any(contains_aggregate_function)
2073 || contains_aggregate_function(&clause.result)
2074 })
2075 || else_result.as_ref().is_some_and(|e| contains_aggregate_function(e))
2076 }
2077 Expression::InList { expr, values, .. } => {
2078 contains_aggregate_function(expr) || values.iter().any(contains_aggregate_function)
2079 }
2080 Expression::Between { expr, low, high, .. } => {
2081 contains_aggregate_function(expr)
2082 || contains_aggregate_function(low)
2083 || contains_aggregate_function(high)
2084 }
2085 // Conservative: subqueries may contain anything; block flattening.
2086 Expression::ScalarSubquery(_) | Expression::In { .. } | Expression::Exists { .. } => true,
2087 _ => false,
2088 }
2089}
2090
2091/// Check if an expression references a specific column (case-insensitive)
2092fn expression_references_column(expr: &Expression, column: &str) -> bool {
2093 match expr {
2094 Expression::ColumnRef(col_id) => col_id.column_canonical().to_lowercase() == column,
2095 Expression::BinaryOp { left, right, .. } => {
2096 expression_references_column(left, column)
2097 || expression_references_column(right, column)
2098 }
2099 Expression::Conjunction(exprs) | Expression::Disjunction(exprs) => {
2100 exprs.iter().any(|e| expression_references_column(e, column))
2101 }
2102 Expression::UnaryOp { expr: inner, .. } => expression_references_column(inner, column),
2103 Expression::IsNull { expr: inner, .. } => expression_references_column(inner, column),
2104 Expression::Between { expr, low, high, .. } => {
2105 expression_references_column(expr, column)
2106 || expression_references_column(low, column)
2107 || expression_references_column(high, column)
2108 }
2109 Expression::InList { expr, values, .. } => {
2110 expression_references_column(expr, column)
2111 || values.iter().any(|e| expression_references_column(e, column))
2112 }
2113 _ => false,
2114 }
2115}
2116
2117/// Extract column references from a SELECT list for covering index detection
2118fn extract_select_columns(select_list: &[SelectItem]) -> HashSet<String> {
2119 let mut columns = HashSet::new();
2120 for item in select_list {
2121 match item {
2122 SelectItem::Wildcard { .. } => {
2123 // Wildcard means all columns needed - can't be a covering index
2124 // Return empty set with a special marker
2125 return HashSet::new();
2126 }
2127 SelectItem::QualifiedWildcard { .. } => {
2128 // table.* also means all columns from that table
2129 return HashSet::new();
2130 }
2131 SelectItem::Expression { expr, .. } => {
2132 collect_column_refs(expr, &mut columns);
2133 }
2134 }
2135 }
2136 columns
2137}
2138
2139/// Recursively collect column references from an expression
2140fn collect_column_refs(expr: &Expression, columns: &mut HashSet<String>) {
2141 match expr {
2142 Expression::ColumnRef(col_id) => {
2143 // `count(*)` parses with a `*` pseudo-column argument; it reads
2144 // no actual column and must not defeat covering-index detection
2145 // (sqlite3 renders `SCAN t USING COVERING INDEX i` for
2146 // `SELECT x, count(*) ... GROUP BY x`, verified live — #5371).
2147 if col_id.column_canonical() != "*" {
2148 columns.insert(col_id.column_canonical().to_lowercase());
2149 }
2150 }
2151 Expression::BinaryOp { left, right, .. } => {
2152 collect_column_refs(left, columns);
2153 collect_column_refs(right, columns);
2154 }
2155 Expression::UnaryOp { expr: inner, .. } => {
2156 collect_column_refs(inner, columns);
2157 }
2158 Expression::Conjunction(exprs) | Expression::Disjunction(exprs) => {
2159 for e in exprs {
2160 collect_column_refs(e, columns);
2161 }
2162 }
2163 Expression::Function { args, .. } | Expression::AggregateFunction { args, .. } => {
2164 for arg in args {
2165 collect_column_refs(arg, columns);
2166 }
2167 }
2168 Expression::IsNull { expr: inner, .. } => {
2169 collect_column_refs(inner, columns);
2170 }
2171 Expression::Between { expr, low, high, .. } => {
2172 collect_column_refs(expr, columns);
2173 collect_column_refs(low, columns);
2174 collect_column_refs(high, columns);
2175 }
2176 Expression::InList { expr, values, .. } => {
2177 collect_column_refs(expr, columns);
2178 for v in values {
2179 collect_column_refs(v, columns);
2180 }
2181 }
2182 Expression::Case { operand, when_clauses, else_result, .. } => {
2183 if let Some(op) = operand {
2184 collect_column_refs(op, columns);
2185 }
2186 for case_when in when_clauses {
2187 for cond in &case_when.conditions {
2188 collect_column_refs(cond, columns);
2189 }
2190 collect_column_refs(&case_when.result, columns);
2191 }
2192 if let Some(else_res) = else_result {
2193 collect_column_refs(else_res, columns);
2194 }
2195 }
2196 Expression::Cast { expr: inner, .. } => {
2197 collect_column_refs(inner, columns);
2198 }
2199 Expression::ScalarSubquery(_)
2200 | Expression::In { .. }
2201 | Expression::Literal(_)
2202 | Expression::Placeholder(_)
2203 | Expression::NumberedPlaceholder(_)
2204 | Expression::NamedPlaceholder(_)
2205 | Expression::Wildcard => {}
2206 _ => {} // Handle any other variants
2207 }
2208}
2209
2210/// Check if all needed columns are covered by an index (covering index)
2211fn is_covering_index(
2212 index_name: &str,
2213 needed_columns: &HashSet<String>,
2214 database: &Database,
2215) -> bool {
2216 // If we need all columns (wildcard was used), not a covering index
2217 if needed_columns.is_empty() {
2218 return false;
2219 }
2220
2221 let Some(index) = database.get_index(index_name) else {
2222 return false;
2223 };
2224
2225 // Get index columns - only include column indexes, not expression indexes
2226 // Expression indexes cannot directly cover columns for covering index detection
2227 let index_columns: HashSet<String> = index
2228 .columns
2229 .iter()
2230 .filter_map(|c| c.column_name())
2231 .map(|name| name.to_lowercase())
2232 .collect();
2233
2234 // Check if all needed columns are in the index
2235 needed_columns.iter().all(|col| index_columns.contains(col))
2236}
2237
2238/// Extract index predicates from a WHERE clause for SQLite EQP format
2239///
2240/// Returns a list of (column_name, operator) tuples for predicates that can
2241/// use the index. The operator is one of "=", ">", "<", ">=", "<=".
2242/// Predicates are sorted by their position in the index column order.
2243/// For expression indexes, the expression name is used (e.g., "lower(name)").
2244fn extract_index_predicates(
2245 expr: &Expression,
2246 index_name: &str,
2247 database: &Database,
2248) -> Vec<(String, String)> {
2249 use vibesql_ast::pretty_print::ToSql;
2250
2251 let mut predicates = Vec::new();
2252
2253 // Get the index columns - for column indexes, use the column name
2254 // For expression indexes, use the expression as a string for display
2255 let index_columns: Vec<String> = database
2256 .get_index(index_name)
2257 .map(|idx| {
2258 idx.columns
2259 .iter()
2260 .map(|c| {
2261 if let Some(name) = c.column_name() {
2262 name.to_lowercase()
2263 } else if let Some(expr) = c.get_expression() {
2264 // For expression indexes, use the expression string
2265 expr.to_sql().to_lowercase()
2266 } else {
2267 String::new()
2268 }
2269 })
2270 .collect()
2271 })
2272 .unwrap_or_default();
2273
2274 extract_predicates_recursive(expr, &index_columns, &mut predicates);
2275
2276 // Sort predicates by their position in the index column order
2277 predicates.sort_by_key(|(col, _)| {
2278 let col_lower = col.to_lowercase();
2279 index_columns.iter().position(|c| c == &col_lower).unwrap_or(usize::MAX)
2280 });
2281
2282 predicates
2283}
2284
2285fn extract_predicates_recursive(
2286 expr: &Expression,
2287 index_columns: &[String],
2288 predicates: &mut Vec<(String, String)>,
2289) {
2290 use vibesql_ast::pretty_print::ToSql;
2291 use vibesql_ast::BinaryOperator;
2292
2293 /// Check if an expression matches any index column (column ref or expression index)
2294 fn expr_matches_index(expr: &Expression, index_columns: &[String]) -> Option<String> {
2295 match expr {
2296 Expression::ColumnRef(col_id) => {
2297 let col_name = col_id.column_canonical().to_lowercase();
2298 if index_columns.iter().any(|c| c == &col_name) {
2299 Some(col_id.column_canonical().to_string())
2300 } else {
2301 None
2302 }
2303 }
2304 // Check if the expression matches an expression index
2305 Expression::Function { .. } | Expression::BinaryOp { .. } => {
2306 let expr_str = expr.to_sql().to_lowercase();
2307 if index_columns.iter().any(|c| c == &expr_str) {
2308 Some(expr.to_sql())
2309 } else {
2310 None
2311 }
2312 }
2313 _ => None,
2314 }
2315 }
2316
2317 match expr {
2318 Expression::BinaryOp { op, left, right } => {
2319 // Check if this is a comparison operator
2320 let op_str = match op {
2321 BinaryOperator::Equal => Some("="),
2322 BinaryOperator::NotEqual => None, // Don't use for index
2323 BinaryOperator::LessThan => Some("<"),
2324 BinaryOperator::LessThanOrEqual => Some("<="),
2325 BinaryOperator::GreaterThan => Some(">"),
2326 BinaryOperator::GreaterThanOrEqual => Some(">="),
2327 BinaryOperator::And => {
2328 // Recurse for AND (but we typically have Conjunction)
2329 extract_predicates_recursive(left, index_columns, predicates);
2330 extract_predicates_recursive(right, index_columns, predicates);
2331 return;
2332 }
2333 _ => None,
2334 };
2335
2336 if let Some(op_str) = op_str {
2337 // Check if left side matches an index column or expression index
2338 if let Some(expr_name) = expr_matches_index(left, index_columns) {
2339 predicates.push((expr_name, op_str.to_string()));
2340 }
2341 // Check if right side matches (for reversed comparisons)
2342 else if let Some(expr_name) = expr_matches_index(right, index_columns) {
2343 // Reverse the operator for column on right side
2344 let reversed_op = match op {
2345 BinaryOperator::Equal => "=",
2346 BinaryOperator::LessThan => ">",
2347 BinaryOperator::LessThanOrEqual => ">=",
2348 BinaryOperator::GreaterThan => "<",
2349 BinaryOperator::GreaterThanOrEqual => "<=",
2350 _ => return,
2351 };
2352 predicates.push((expr_name, reversed_op.to_string()));
2353 }
2354 }
2355 }
2356 Expression::Conjunction(exprs) => {
2357 for e in exprs {
2358 extract_predicates_recursive(e, index_columns, predicates);
2359 }
2360 }
2361 Expression::Between { expr, negated: false, .. } => {
2362 // BETWEEN is equivalent to >= AND <=
2363 if let Expression::ColumnRef(col_id) = expr.as_ref() {
2364 let col_name = col_id.column_canonical().to_lowercase();
2365 if index_columns.iter().any(|c| c == &col_name) {
2366 let col = col_id.column_canonical().to_string();
2367 predicates.push((col.clone(), ">=".to_string()));
2368 predicates.push((col, "<=".to_string()));
2369 }
2370 }
2371 }
2372 Expression::InList { expr, negated: false, .. } => {
2373 // IN list treated as equality
2374 if let Expression::ColumnRef(col_id) = expr.as_ref() {
2375 let col_name = col_id.column_canonical().to_lowercase();
2376 if index_columns.iter().any(|c| c == &col_name) {
2377 predicates.push((col_id.column_canonical().to_string(), "=".to_string()));
2378 }
2379 }
2380 }
2381 // IS (NULL-safe equals): negated=true means "IS NOT DISTINCT FROM" = "IS"
2382 // Displayed as = in EQP output
2383 Expression::IsDistinctFrom { left, right, negated: true } => {
2384 // Check if left side is a column reference that matches index columns
2385 if let Expression::ColumnRef(col_id) = left.as_ref() {
2386 let col_name = col_id.column_canonical().to_lowercase();
2387 if index_columns.iter().any(|c| c == &col_name) {
2388 predicates.push((col_id.column_canonical().to_string(), "=".to_string()));
2389 }
2390 }
2391 // Check if right side is a column reference
2392 else if let Expression::ColumnRef(col_id) = right.as_ref() {
2393 let col_name = col_id.column_canonical().to_lowercase();
2394 if index_columns.iter().any(|c| c == &col_name) {
2395 predicates.push((col_id.column_canonical().to_string(), "=".to_string()));
2396 }
2397 }
2398 }
2399 _ => {}
2400 }
2401}