Skip to main content

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}