Skip to main content

fsqlite_core/
explain.rs

1//! EXPLAIN and EXPLAIN QUERY PLAN execution (§12.12, bd-7pxb).
2//!
3//! EXPLAIN returns VDBE bytecode as a result set with columns:
4//!   addr, opcode, p1, p2, p3, p4, p5, comment
5//!
6//! EXPLAIN QUERY PLAN returns a tree-structured plan with columns:
7//!   id, parent, notused, detail
8
9use std::collections::{HashMap, HashSet};
10
11use fsqlite_types::opcode::{Opcode, P4};
12use fsqlite_vdbe::VdbeProgram;
13
14// ---------------------------------------------------------------------------
15// EXPLAIN result row
16// ---------------------------------------------------------------------------
17
18/// A single row from EXPLAIN output (invariant #10).
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ExplainRow {
21    /// Instruction address (0-based).
22    pub addr: i32,
23    /// Opcode name (e.g., "Init", "OpenRead").
24    pub opcode: String,
25    /// First parameter.
26    pub p1: i32,
27    /// Second parameter (often a jump target).
28    pub p2: i32,
29    /// Third parameter.
30    pub p3: i32,
31    /// Fourth parameter (text representation).
32    pub p4: String,
33    /// Fifth parameter (flags).
34    pub p5: u16,
35    /// Comment (auto-generated from opcode semantics).
36    pub comment: String,
37}
38
39/// Generate EXPLAIN output for a compiled VDBE program.
40///
41/// Returns one row per instruction with columns:
42/// addr, opcode, p1, p2, p3, p4, p5, comment (invariant #10).
43#[must_use]
44pub fn explain_program(program: &VdbeProgram) -> Vec<ExplainRow> {
45    program
46        .ops()
47        .iter()
48        .enumerate()
49        .map(|(i, op)| {
50            #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
51            let addr = i as i32;
52            ExplainRow {
53                addr,
54                opcode: format!("{:?}", op.opcode),
55                p1: op.p1,
56                p2: op.p2,
57                p3: op.p3,
58                p4: format!("{:?}", op.p4),
59                p5: op.p5,
60                comment: opcode_comment(op.opcode, op.p1, op.p2, op.p3),
61            }
62        })
63        .collect()
64}
65
66/// Auto-generate a comment for an opcode based on its semantics.
67fn opcode_comment(opcode: Opcode, p1: i32, p2: i32, p3: i32) -> String {
68    match opcode {
69        Opcode::Init => format!("start at {p2}"),
70        Opcode::Goto => format!("goto {p2}"),
71        Opcode::Halt => {
72            if p1 == 0 {
73                String::new()
74            } else {
75                format!("error code {p1}")
76            }
77        }
78        Opcode::Transaction => {
79            if p2 == 0 {
80                "read transaction".to_owned()
81            } else {
82                "write transaction".to_owned()
83            }
84        }
85        Opcode::OpenRead | Opcode::OpenWrite => format!("root={p2}"),
86        Opcode::Column => format!("r[{p3}]=cursor[{p1}].column[{p2}]"),
87        Opcode::ResultRow => format!("output r[{p1}..{p1}+{p2}]"),
88        Opcode::Rewind => format!("if eof goto {p2}"),
89        Opcode::Next => format!("goto {p2} if more rows"),
90        Opcode::Close => format!("close cursor {p1}"),
91        _ => String::new(),
92    }
93}
94
95// ---------------------------------------------------------------------------
96// EXPLAIN QUERY PLAN
97// ---------------------------------------------------------------------------
98
99/// A single row from EXPLAIN QUERY PLAN output (invariant #11).
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct EqpRow {
102    /// Node id in the plan tree.
103    pub id: i32,
104    /// Parent node id (0 for root nodes).
105    pub parent: i32,
106    /// Not used (always 0, kept for compatibility).
107    pub notused: i32,
108    /// Human-readable description of this plan step.
109    pub detail: String,
110}
111
112/// Generate EXPLAIN QUERY PLAN output for a compiled VDBE program.
113///
114/// Returns a tree-structured plan with columns: id, parent, notused, detail
115/// (invariant #11). The tree structure is expressed via id/parent relationships
116/// (invariant #23).
117#[must_use]
118pub fn explain_query_plan(program: &VdbeProgram) -> Vec<EqpRow> {
119    let ops = program.ops();
120    let mut rows = Vec::new();
121    let mut next_id = 1_i32;
122    let mut index_step_by_cursor: HashMap<i32, usize> = HashMap::new();
123    let mut index_name_by_cursor: HashMap<i32, String> = HashMap::new();
124    let mut index_used = HashSet::new();
125    let mut index_with_table_lookup = HashSet::new();
126    let mut last_idxrowid_cursor = None;
127    let mut saw_sorter = false;
128
129    // Scan for table/index opens and infer index/sorter usage.
130    for op in ops {
131        match op.opcode {
132            Opcode::OpenRead | Opcode::OpenWrite => {
133                let scan_type = if op.opcode == Opcode::OpenRead {
134                    "SCAN"
135                } else {
136                    "SEARCH"
137                };
138                let detail = match &op.p4 {
139                    P4::Table(name) => format!("{scan_type} {name}"),
140                    P4::Index(name) => format!("{scan_type} INDEX {name}"),
141                    _ => format!("{scan_type} {:?}", op.p4),
142                };
143                rows.push(EqpRow {
144                    id: next_id,
145                    parent: 0,
146                    notused: 0,
147                    detail,
148                });
149                if let P4::Index(name) = &op.p4 {
150                    index_step_by_cursor.insert(op.p1, rows.len() - 1);
151                    index_name_by_cursor.insert(op.p1, name.clone());
152                }
153                next_id += 1;
154            }
155            Opcode::SeekGE
156            | Opcode::SeekLE
157            | Opcode::SeekGT
158            | Opcode::SeekLT
159            | Opcode::IdxGE
160            | Opcode::IdxGT
161            | Opcode::IdxLE
162            | Opcode::IdxLT
163            | Opcode::Rewind
164            | Opcode::Last
165            | Opcode::Next
166            | Opcode::Prev => {
167                if index_name_by_cursor.contains_key(&op.p1) {
168                    index_used.insert(op.p1);
169                }
170            }
171            Opcode::IdxRowid => {
172                if index_name_by_cursor.contains_key(&op.p1) {
173                    index_used.insert(op.p1);
174                    last_idxrowid_cursor = Some(op.p1);
175                }
176            }
177            Opcode::SeekRowid => {
178                if let Some(index_cursor) = last_idxrowid_cursor {
179                    index_with_table_lookup.insert(index_cursor);
180                }
181                last_idxrowid_cursor = None;
182            }
183            Opcode::SorterOpen | Opcode::SorterInsert | Opcode::SorterSort => {
184                saw_sorter = true;
185            }
186            _ => {
187                last_idxrowid_cursor = None;
188            }
189        }
190    }
191
192    // Annotate index rows as covering vs non-covering.
193    for index_cursor in index_used {
194        let Some(step_idx) = index_step_by_cursor.get(&index_cursor).copied() else {
195            continue;
196        };
197        let Some(index_name) = index_name_by_cursor.get(&index_cursor) else {
198            continue;
199        };
200        let usage = if index_with_table_lookup.contains(&index_cursor) {
201            format!(" USING INDEX {index_name}")
202        } else {
203            format!(" USING COVERING INDEX {index_name}")
204        };
205        if !rows[step_idx].detail.contains("USING ") {
206            rows[step_idx].detail.push_str(&usage);
207        }
208    }
209
210    if saw_sorter {
211        rows.push(EqpRow {
212            id: next_id,
213            parent: 0,
214            notused: 0,
215            detail: "USE TEMP B-TREE FOR ORDER BY".to_owned(),
216        });
217    }
218
219    // If no table opens found, emit a minimal plan.
220    if rows.is_empty() {
221        rows.push(EqpRow {
222            id: 1,
223            parent: 0,
224            notused: 0,
225            detail: "SCAN CONSTANT ROW".to_owned(),
226        });
227    }
228
229    rows
230}
231
232/// The index access path an aggregate program actually executes.
233///
234/// bd-2dgf5 / bd-jyyae.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct AggregateIndexSeek {
237    /// Name of the index the program seeks.
238    pub index_name: String,
239    /// True when the seek satisfies every aggregate from the index entry alone,
240    /// i.e. the program never looks the row up in the table.
241    pub covering: bool,
242}
243
244/// Read an aggregate's index access path off the *emitted program*.
245///
246/// bd-2dgf5 / bd-jyyae. `EXPLAIN QUERY PLAN` normally renders from the planner
247/// directive, and `planner_select_directive_with_stats` refuses to produce one for
248/// an aggregate — so an aggregate that seeks used to print `SCAN t`. The obvious
249/// repair, re-checking in the EQP path the same conditions codegen checked, is the
250/// bug bd-jyyae is about: the two copies drift, and then EQP confidently describes
251/// a plan the program never runs (it once reported `SEARCH` for an `IN (...)` whose
252/// program was a bare `Rewind`/`Next` scan).
253///
254/// So decide nothing here. An index cursor is SEARCHed exactly when a `Seek*`
255/// positions it, and the index is COVERING exactly when no `IdxRowid` on that cursor
256/// feeds a `SeekRowid` into the table. Both facts are properties of the opcodes.
257///
258/// Returns `None` unless the program is an aggregate (`AggStep`) driven by exactly
259/// one seeked index cursor; every other shape keeps the existing EQP rendering.
260#[must_use]
261pub fn aggregate_index_seek_facts(program: &VdbeProgram) -> Option<AggregateIndexSeek> {
262    let ops = program.ops();
263    if !ops.iter().any(|op| op.opcode == Opcode::AggStep) {
264        return None;
265    }
266
267    let mut index_name_by_cursor: HashMap<i32, &str> = HashMap::new();
268    for op in ops {
269        if op.opcode == Opcode::OpenRead
270            && let P4::Index(name) = &op.p4
271        {
272            index_name_by_cursor.insert(op.p1, name.as_str());
273        }
274    }
275
276    let mut seeked: Vec<i32> = Vec::new();
277    for op in ops {
278        let positions_index = matches!(
279            op.opcode,
280            Opcode::SeekGE | Opcode::SeekGT | Opcode::SeekLE | Opcode::SeekLT
281        ) && index_name_by_cursor.contains_key(&op.p1);
282        if positions_index && !seeked.contains(&op.p1) {
283            seeked.push(op.p1);
284        }
285    }
286    let [cursor] = seeked[..] else {
287        return None;
288    };
289
290    // A table lookup is an `IdxRowid` on the seeked cursor feeding a `SeekRowid`.
291    // Any other opcode in between breaks the pairing, exactly as in
292    // `explain_query_plan`'s covering-vs-non-covering annotation.
293    let mut rowid_from_seeked_cursor = false;
294    let mut table_lookup = false;
295    for op in ops {
296        match op.opcode {
297            Opcode::IdxRowid => rowid_from_seeked_cursor = op.p1 == cursor,
298            Opcode::SeekRowid => {
299                table_lookup |= rowid_from_seeked_cursor;
300                rowid_from_seeked_cursor = false;
301            }
302            _ => rowid_from_seeked_cursor = false,
303        }
304    }
305
306    Some(AggregateIndexSeek {
307        index_name: (*index_name_by_cursor.get(&cursor)?).to_owned(),
308        covering: !table_lookup,
309    })
310}
311
312/// True when the emitted program positions a cursor opened on `index_name`
313/// with a key seek (`SeekGE`/`SeekGT`/`SeekLE`/`SeekLT`).
314///
315/// bd-jyyae: `EXPLAIN QUERY PLAN` renders the planner directive, but codegen
316/// can decline the directive's access path — `IN (SELECT ...)` predicates
317/// compile to a full table scan probing an ephemeral membership index, while
318/// the directive still claims `SEARCH ... USING INDEX`. The claim is true
319/// exactly when a seek opcode positions a cursor opened on that index — a
320/// property of the opcodes, not of planner intent. `Rewind`/`Next` on an
321/// index cursor is a scan of the index, not a search, and deliberately does
322/// not count.
323#[must_use]
324pub fn program_seeks_named_index(program: &VdbeProgram, index_name: &str) -> bool {
325    let ops = program.ops();
326    let mut index_cursors: HashSet<i32> = HashSet::new();
327    for op in ops {
328        if matches!(op.opcode, Opcode::OpenRead | Opcode::OpenWrite)
329            && let P4::Index(name) = &op.p4
330            && name == index_name
331        {
332            index_cursors.insert(op.p1);
333        }
334    }
335    if index_cursors.is_empty() {
336        return false;
337    }
338    ops.iter().any(|op| {
339        matches!(
340            op.opcode,
341            Opcode::SeekGE | Opcode::SeekGT | Opcode::SeekLE | Opcode::SeekLT
342        ) && index_cursors.contains(&op.p1)
343    })
344}
345
346/// True when the emitted program probes a cursor opened on `table_name`
347/// directly by rowid (`SeekRowid`/`NotExists`).
348///
349/// bd-jyyae companion to [`program_seeks_named_index`] for the
350/// `SEARCH ... USING INTEGER PRIMARY KEY (rowid=?)` directive claim. The
351/// probe must target a cursor opened on the named table so that a rowid seek
352/// elsewhere in the program (an index's table-lookup leg, a subquery) cannot
353/// vouch for a main-table claim.
354#[must_use]
355pub fn program_seeks_rowid_on_table(program: &VdbeProgram, table_name: &str) -> bool {
356    let ops = program.ops();
357    let mut table_cursors: HashSet<i32> = HashSet::new();
358    for op in ops {
359        if matches!(op.opcode, Opcode::OpenRead | Opcode::OpenWrite)
360            && let P4::Table(name) = &op.p4
361            && name == table_name
362        {
363            table_cursors.insert(op.p1);
364        }
365    }
366    if table_cursors.is_empty() {
367        return false;
368    }
369    ops.iter().any(|op| {
370        matches!(op.opcode, Opcode::SeekRowid | Opcode::NotExists) && table_cursors.contains(&op.p1)
371    })
372}
373
374// ---------------------------------------------------------------------------
375// Tests
376// ---------------------------------------------------------------------------
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use fsqlite_types::opcode::{Opcode, P4};
382    use fsqlite_vdbe::ProgramBuilder;
383
384    fn build_simple_select_program() -> VdbeProgram {
385        let mut b = ProgramBuilder::new();
386        let end_label = b.emit_label();
387        let done_label = b.emit_label();
388
389        b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
390        b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
391        b.emit_op(Opcode::OpenRead, 0, 2, 0, P4::Table("t".to_owned()), 0);
392        b.emit_jump_to_label(Opcode::Rewind, 0, 0, done_label, P4::None, 0);
393        b.emit_op(Opcode::Column, 0, 0, 1, P4::None, 0);
394        b.emit_op(Opcode::ResultRow, 1, 1, 0, P4::None, 0);
395        b.emit_op(Opcode::Next, 0, 4, 0, P4::None, 0);
396        b.resolve_label(done_label);
397        b.emit_op(Opcode::Close, 0, 0, 0, P4::None, 0);
398        b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
399        b.resolve_label(end_label);
400
401        b.finish().unwrap()
402    }
403
404    // === Test 20: EXPLAIN returns VDBE bytecode with correct columns ===
405    #[test]
406    fn test_explain_returns_bytecode() {
407        let prog = build_simple_select_program();
408        let rows = explain_program(&prog);
409
410        // Should have rows for each instruction.
411        assert!(!rows.is_empty());
412        assert_eq!(rows[0].addr, 0);
413
414        // Check column presence (addr, opcode, p1, p2, p3, p4, p5, comment).
415        let init_row = &rows[0];
416        assert_eq!(init_row.opcode, "Init");
417        // p5 is present.
418        assert_eq!(init_row.p5, 0);
419        // Comment is present.
420        assert!(init_row.comment.contains("start at"));
421    }
422
423    // === Test 21: EXPLAIN QUERY PLAN returns id, parent, notused, detail ===
424    #[test]
425    fn test_explain_query_plan_columns() {
426        let prog = build_simple_select_program();
427        let rows = explain_query_plan(&prog);
428
429        assert!(!rows.is_empty());
430        let row = &rows[0];
431        // Verify all four columns are present.
432        assert!(row.id > 0);
433        assert_eq!(row.parent, 0);
434        assert_eq!(row.notused, 0);
435        assert!(!row.detail.is_empty());
436    }
437
438    // === Test 22: EQP detail shows index usage ===
439    #[test]
440    fn test_explain_query_plan_shows_index() {
441        // Build a program that probes an index and then looks up table rows.
442        let mut b = ProgramBuilder::new();
443        let end_label = b.emit_label();
444        let done_label = b.emit_label();
445        let skip_label = b.emit_label();
446
447        b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
448        b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
449        b.emit_op(Opcode::OpenRead, 0, 2, 0, P4::Table("t".to_owned()), 0);
450        b.emit_op(
451            Opcode::OpenRead,
452            1,
453            3,
454            0,
455            P4::Index("idx_t_a".to_owned()),
456            0,
457        );
458        b.emit_jump_to_label(Opcode::Rewind, 1, 0, done_label, P4::None, 0);
459        b.emit_op(Opcode::IdxRowid, 1, 2, 0, P4::None, 0);
460        b.emit_jump_to_label(Opcode::SeekRowid, 0, 2, skip_label, P4::None, 0);
461        b.emit_op(Opcode::Column, 0, 0, 1, P4::None, 0);
462        b.emit_op(Opcode::ResultRow, 1, 1, 0, P4::None, 0);
463        b.resolve_label(skip_label);
464        b.emit_op(Opcode::Next, 1, 5, 0, P4::None, 0);
465        b.resolve_label(done_label);
466        b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
467        b.resolve_label(end_label);
468
469        let prog = b.finish().unwrap();
470        let rows = explain_query_plan(&prog);
471
472        // Should show index usage.
473        let has_index = rows.iter().any(|r| r.detail.contains("USING INDEX"));
474        assert!(has_index, "EQP should show index usage, got: {rows:?}");
475    }
476
477    // === Test 23: EQP id/parent form correct tree ===
478    #[test]
479    fn test_explain_query_plan_tree_structure() {
480        let prog = build_simple_select_program();
481        let rows = explain_query_plan(&prog);
482
483        // Root nodes have parent=0.
484        for row in &rows {
485            if row.parent == 0 {
486                // Root node — id should be positive.
487                assert!(row.id > 0);
488            } else {
489                // Child node — parent should reference an existing node.
490                assert!(rows.iter().any(|r| r.id == row.parent));
491            }
492        }
493
494        // Ids should be unique.
495        let mut ids: Vec<i32> = rows.iter().map(|r| r.id).collect();
496        ids.sort_unstable();
497        ids.dedup();
498        assert_eq!(ids.len(), rows.len());
499    }
500
501    #[test]
502    fn test_explain_query_plan_shows_covering_index() {
503        let mut b = ProgramBuilder::new();
504        let end_label = b.emit_label();
505        let done_label = b.emit_label();
506
507        b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
508        b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
509        b.emit_op(
510            Opcode::OpenRead,
511            1,
512            3,
513            0,
514            P4::Index("idx_t_b".to_owned()),
515            0,
516        );
517        b.emit_jump_to_label(Opcode::Rewind, 1, 0, done_label, P4::None, 0);
518        b.emit_op(Opcode::Column, 1, 0, 1, P4::None, 0);
519        b.emit_op(Opcode::ResultRow, 1, 1, 0, P4::None, 0);
520        b.emit_op(Opcode::Next, 1, 4, 0, P4::None, 0);
521        b.resolve_label(done_label);
522        b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
523        b.resolve_label(end_label);
524
525        let prog = b.finish().unwrap();
526        let rows = explain_query_plan(&prog);
527        let has_covering = rows
528            .iter()
529            .any(|row| row.detail.contains("USING COVERING INDEX idx_t_b"));
530        assert!(
531            has_covering,
532            "EQP should show covering-index usage, got: {rows:?}"
533        );
534    }
535
536    #[test]
537    fn test_explain_query_plan_shows_temp_btree_for_order_by() {
538        let mut b = ProgramBuilder::new();
539        let end_label = b.emit_label();
540        let done_label = b.emit_label();
541
542        b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
543        b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
544        b.emit_op(Opcode::OpenRead, 0, 2, 0, P4::Table("t".to_owned()), 0);
545        b.emit_op(Opcode::SorterOpen, 1, 1, 0, P4::Str("+".to_owned()), 0);
546        b.emit_jump_to_label(Opcode::Rewind, 0, 0, done_label, P4::None, 0);
547        b.emit_op(Opcode::Column, 0, 0, 2, P4::None, 0);
548        b.emit_op(Opcode::MakeRecord, 2, 1, 3, P4::None, 0);
549        b.emit_op(Opcode::SorterInsert, 1, 3, 0, P4::None, 0);
550        b.resolve_label(done_label);
551        b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
552        b.resolve_label(end_label);
553
554        let prog = b.finish().unwrap();
555        let rows = explain_query_plan(&prog);
556        let has_temp_btree = rows
557            .iter()
558            .any(|row| row.detail == "USE TEMP B-TREE FOR ORDER BY");
559        assert!(
560            has_temp_btree,
561            "EQP should include temp B-tree marker for sorter plans, got: {rows:?}"
562        );
563    }
564
565    /// The truthful equality-probe shape: `SeekGE` positions the index
566    /// cursor, `IdxRowid`/`SeekRowid` fetch the table row (the program
567    /// `EXPLAIN SELECT * FROM t WHERE k = 2` actually emits).
568    fn build_index_seek_program() -> VdbeProgram {
569        let mut b = ProgramBuilder::new();
570        let end_label = b.emit_label();
571        let done_label = b.emit_label();
572        let skip_label = b.emit_label();
573
574        b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
575        b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
576        b.emit_op(Opcode::OpenRead, 0, 2, 0, P4::Table("t".to_owned()), 0);
577        b.emit_op(
578            Opcode::OpenRead,
579            1,
580            3,
581            0,
582            P4::Index("idx_t_k".to_owned()),
583            0,
584        );
585        b.emit_jump_to_label(Opcode::SeekGE, 1, 0, done_label, P4::None, 0);
586        b.emit_op(Opcode::IdxRowid, 1, 9, 0, P4::None, 0);
587        b.emit_jump_to_label(Opcode::SeekRowid, 0, 9, skip_label, P4::None, 0);
588        b.emit_op(Opcode::Column, 0, 1, 2, P4::None, 0);
589        b.emit_op(Opcode::ResultRow, 2, 1, 0, P4::None, 0);
590        b.resolve_label(skip_label);
591        b.emit_op(Opcode::Next, 1, 5, 0, P4::None, 0);
592        b.resolve_label(done_label);
593        b.emit_op(Opcode::Close, 1, 0, 0, P4::None, 0);
594        b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
595        b.resolve_label(end_label);
596
597        b.finish().unwrap()
598    }
599
600    /// The bd-jyyae divergence shape: full `Rewind`/`Next` scan of the table
601    /// with an ephemeral membership probe (`OpenAutoindex` + `Found`) — the
602    /// program `SELECT * FROM t WHERE k IN (SELECT id FROM r)` actually
603    /// emits. No cursor is ever opened on `idx_t_k`, and no seek runs.
604    fn build_in_subquery_scan_program() -> VdbeProgram {
605        let mut b = ProgramBuilder::new();
606        let end_label = b.emit_label();
607        let done_label = b.emit_label();
608        let skip_label = b.emit_label();
609
610        b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
611        b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
612        b.emit_op(Opcode::OpenRead, 0, 2, 0, P4::Table("t".to_owned()), 0);
613        b.emit_jump_to_label(Opcode::Rewind, 0, 0, done_label, P4::None, 0);
614        b.emit_op(Opcode::Column, 0, 1, 5, P4::None, 0);
615        b.emit_op(Opcode::MakeRecord, 5, 1, 7, P4::None, 0);
616        b.emit_jump_to_label(Opcode::Found, 12294, 0, skip_label, P4::None, 0);
617        b.emit_op(Opcode::Rowid, 0, 1, 0, P4::None, 0);
618        b.emit_op(Opcode::ResultRow, 1, 1, 0, P4::None, 0);
619        b.resolve_label(skip_label);
620        b.emit_op(Opcode::Next, 0, 4, 0, P4::None, 0);
621        b.resolve_label(done_label);
622        b.emit_op(Opcode::Close, 0, 0, 0, P4::None, 0);
623        b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
624        b.resolve_label(end_label);
625
626        b.finish().unwrap()
627    }
628
629    // === bd-jyyae: directive seek claims must be provable from the opcodes ===
630
631    #[test]
632    fn test_program_seeks_named_index_true_for_seek_shape() {
633        let prog = build_index_seek_program();
634        assert!(program_seeks_named_index(&prog, "idx_t_k"));
635    }
636
637    #[test]
638    fn test_program_seeks_named_index_false_for_in_subquery_scan() {
639        let prog = build_in_subquery_scan_program();
640        assert!(
641            !program_seeks_named_index(&prog, "idx_t_k"),
642            "a Rewind/Next membership-probe scan must not vouch for a SEARCH claim"
643        );
644    }
645
646    #[test]
647    fn test_program_seeks_named_index_false_for_index_only_scan() {
648        // An index cursor driven only by Rewind/Next is an index SCAN, not a
649        // SEARCH: the equality/range claim must not be vouched for.
650        let mut b = ProgramBuilder::new();
651        let end_label = b.emit_label();
652        let done_label = b.emit_label();
653
654        b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
655        b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
656        b.emit_op(
657            Opcode::OpenRead,
658            1,
659            3,
660            0,
661            P4::Index("idx_t_k".to_owned()),
662            0,
663        );
664        b.emit_jump_to_label(Opcode::Rewind, 1, 0, done_label, P4::None, 0);
665        b.emit_op(Opcode::Column, 1, 0, 1, P4::None, 0);
666        b.emit_op(Opcode::ResultRow, 1, 1, 0, P4::None, 0);
667        b.emit_op(Opcode::Next, 1, 4, 0, P4::None, 0);
668        b.resolve_label(done_label);
669        b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
670        b.resolve_label(end_label);
671
672        let prog = b.finish().unwrap();
673        assert!(!program_seeks_named_index(&prog, "idx_t_k"));
674    }
675
676    #[test]
677    fn test_program_seeks_rowid_on_table_true_for_rowid_probe() {
678        let mut b = ProgramBuilder::new();
679        let end_label = b.emit_label();
680        let done_label = b.emit_label();
681
682        b.emit_jump_to_label(Opcode::Init, 0, 0, end_label, P4::None, 0);
683        b.emit_op(Opcode::Transaction, 0, 0, 0, P4::None, 0);
684        b.emit_op(Opcode::OpenRead, 0, 2, 0, P4::Table("t".to_owned()), 0);
685        b.emit_jump_to_label(Opcode::SeekRowid, 0, 1, done_label, P4::None, 0);
686        b.emit_op(Opcode::Column, 0, 1, 2, P4::None, 0);
687        b.emit_op(Opcode::ResultRow, 2, 1, 0, P4::None, 0);
688        b.resolve_label(done_label);
689        b.emit_op(Opcode::Halt, 0, 0, 0, P4::None, 0);
690        b.resolve_label(end_label);
691
692        let prog = b.finish().unwrap();
693        assert!(program_seeks_rowid_on_table(&prog, "t"));
694        assert!(
695            !program_seeks_rowid_on_table(&prog, "other"),
696            "a rowid probe on t must not vouch for a claim about another table"
697        );
698    }
699
700    #[test]
701    fn test_program_seeks_rowid_on_table_false_for_scan() {
702        let prog = build_simple_select_program();
703        assert!(!program_seeks_rowid_on_table(&prog, "t"));
704    }
705
706    #[test]
707    fn test_index_table_lookup_leg_does_not_vouch_for_table_rowid_claim() {
708        // The SeekRowid in an index seek's table-lookup leg targets the table
709        // cursor, so it legitimately proves a rowid probe on that cursor —
710        // but the *index* claim for an unrelated index must still fail.
711        let prog = build_index_seek_program();
712        assert!(!program_seeks_named_index(&prog, "idx_other"));
713    }
714}