From fc4364c0a782d32c4dd2bbc1bb879db6a1362e58 Mon Sep 17 00:00:00 2001 From: Ilja Heitlager Date: Thu, 27 Aug 2026 13:09:42 +0200 Subject: [PATCH 1/2] test: raise line coverage to 85%+ across 21 of 22 files below threshold (#603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds test-only coverage for error Display/From-conversion paths, subquery flatten/pushdown expression-rewrite branches, join ordering, integrity-check branches, parser error paths, printer round-trips, readline dispatch/redraw logic, and VFS/pager edge cases. No production code changes. TOTAL line coverage: 89.22% -> 92.88%. 21 of the 22 files now clear 85%; src/bin/sqlite-rs/readline/term.rs remains at 66% because RawMode::enable's success path, its Drop impl, and read_byte all require a real controlling tty, which `cargo test` never has — flagged per the ticket's carve-out rather than faked with a fragile pty harness. Spend: ~1.4M tokens (workflow fan-out across 17 agents covering the bulk of the files) plus direct follow-up work closing the remaining gaps (flatten.rs, pushdown.rs, readline.rs, term.rs) and fixing clippy/mvl-limit fallout from the generated tests (missing lint allows, two `Box` uses outside the qualified subset, custom assert macros disallowed by the mvl-limit gate). In line with the issue's "large" estimate. Co-Authored-By: Claude Sonnet 5 --- src/bin/sqlite-rs/readline.rs | 32 ++ src/bin/sqlite-rs/readline/term.rs | 36 ++ src/btree/error.rs | 154 +++++++++ src/codegen/select.rs | 95 ++++++ src/codegen/select/join_access.rs | 400 ++++++++++++++++++++++ src/codegen/select/join_order.rs | 395 ++++++++++++++++++++++ src/codegen/subquery/flatten.rs | 290 ++++++++++++++++ src/codegen/subquery/from_clause.rs | 189 +++++++++++ src/codegen/subquery/pushdown.rs | 167 +++++++++ src/codegen/subquery/scalar.rs | 252 ++++++++++++++ src/integrity.rs | 505 ++++++++++++++++++++++++++++ src/pager/checkpoint.rs | 59 +++- src/pager/error.rs | 71 ++++ src/parser/error.rs | 200 +++++++++++ src/parser/printer.rs | 367 ++++++++++++++++++++ src/record/error.rs | 55 +++ src/sys/termios.rs | 32 ++ src/vdbe/cursor.rs | 414 ++++++++++++++++++++++- src/vdbe/exec.rs | 346 +++++++++++++++++++ src/vfs/page_source.rs | 107 ++++++ src/vfs/unix.rs | 168 +++++++++ tests/unit/repl_dot_commands.rs | 145 ++++++++ 22 files changed, 4477 insertions(+), 2 deletions(-) diff --git a/src/bin/sqlite-rs/readline.rs b/src/bin/sqlite-rs/readline.rs index 86243a65..27e082d0 100644 --- a/src/bin/sqlite-rs/readline.rs +++ b/src/bin/sqlite-rs/readline.rs @@ -582,6 +582,19 @@ mod tests { assert_eq!(editor.as_str(), "zab"); } + #[test] + fn dispatch_ctrl_e_moves_to_end() { + let mut editor = LineEditor::new(); + editor.set("abc"); + editor.move_home(); + let mut src = byte_source(&[]); + assert!(matches!( + dispatch_byte(0x05, &mut editor, &mut src), // Ctrl-E + Ok(Dispatch::Continue) + )); + assert_eq!(editor.cursor(), 3); + } + #[test] fn dispatch_ctrl_k_and_ctrl_u_clear_to_end_and_home() { let mut editor = LineEditor::new(); @@ -664,10 +677,29 @@ mod tests { apply_escape_action(&mut editor, EscapeAction::Home, &mut history); assert_eq!(editor.cursor(), 0); + apply_escape_action(&mut editor, EscapeAction::Right, &mut history); + assert_eq!(editor.cursor(), 1); + apply_escape_action(&mut editor, EscapeAction::End, &mut history); assert_eq!(editor.cursor(), 2); } + #[test] + fn readline_error_display_matches_each_variant() { + assert_eq!(ReadlineError::Eof.to_string(), "EOF"); + assert_eq!(ReadlineError::Interrupted.to_string(), "interrupted"); + let io_err = ReadlineError::Io(io::Error::other("boom")); + assert_eq!(io_err.to_string(), "boom"); + } + + #[test] + fn redraw_writes_highlighted_and_plain_prompt_lines() { + let mut editor = LineEditor::new(); + editor.set("select 1"); + redraw("> ", &editor, true); + redraw("> ", &editor, false); + } + #[test] fn apply_escape_action_up_and_down_navigate_history() { let mut history = History::new(); diff --git a/src/bin/sqlite-rs/readline/term.rs b/src/bin/sqlite-rs/readline/term.rs index ea29b88e..21121b9e 100644 --- a/src/bin/sqlite-rs/readline/term.rs +++ b/src/bin/sqlite-rs/readline/term.rs @@ -74,3 +74,39 @@ pub fn cursor_to_col(col: usize) -> String { /// Clears from the cursor to the end of the line. pub const CLEAR_TO_EOL: &str = "\x1b[K"; + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn cursor_to_col_formats_escape_sequence() { + assert_eq!(cursor_to_col(0), "\r\x1b[0C"); + assert_eq!(cursor_to_col(42), "\r\x1b[42C"); + } + + #[test] + fn color_constants_are_expected_escape_codes() { + assert_eq!(RESET, "\x1b[0m"); + assert_eq!(BOLD_BLUE, "\x1b[1;34m"); + assert_eq!(GREEN, "\x1b[32m"); + assert_eq!(CYAN, "\x1b[36m"); + assert_eq!(GRAY, "\x1b[90m"); + assert_eq!(YELLOW, "\x1b[33m"); + assert_eq!(CLEAR_TO_EOL, "\x1b[K"); + } + + #[test] + fn write_flush_writes_to_stdout() { + write_flush("").unwrap(); + } + + // `enable()` falls back to `None` when stdin isn't a tty (piped input), + // which is exactly how `cargo test` runs — no controlling tty attached. + #[test] + fn raw_mode_enable_returns_none_without_tty() { + let result = RawMode::enable().unwrap(); + assert!(result.is_none()); + } +} diff --git a/src/btree/error.rs b/src/btree/error.rs index 74bc4fc4..d2660ec3 100644 --- a/src/btree/error.rs +++ b/src/btree/error.rs @@ -249,3 +249,157 @@ impl From for BtreeError { BtreeError::Pager(source) } } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + #[test] + fn display_all_variants() { + assert!(BtreeError::InvalidKeyRecord(RecordError::InvalidUtf8) + .to_string() + .contains("decoding a key record")); + assert!(BtreeError::PageSource { + page_num: 1, + source: PageError::InvalidPageNumber, + } + .to_string() + .contains("reading page 1")); + assert_eq!( + BtreeError::PageTooShort { + page_num: 1, + len: 3 + } + .to_string(), + "page 1 is too short (3 bytes) to contain a b-tree page header" + ); + assert_eq!( + BtreeError::CursorNotPositioned { + operation: "next", + required: "seek", + } + .to_string(), + "next called on a cursor that was never positioned by seek" + ); + assert_eq!( + BtreeError::UnexpectedPageType { + page_num: 2, + page_type: 0xff + } + .to_string(), + "page 2 has unexpected b-tree page type 0xff" + ); + assert_eq!( + BtreeError::InvalidCellPointer { + page_num: 2, + index: 9 + } + .to_string(), + "page 2 cell pointer at index 9 is out of bounds" + ); + assert!(BtreeError::InvalidCellVarint { + page_num: 2, + source: RecordError::InvalidUtf8, + } + .to_string() + .contains("cell varint decode failed")); + assert_eq!( + BtreeError::PayloadTooShort { page_num: 2 }.to_string(), + "page 2 cell payload is shorter than its declared local size" + ); + assert_eq!( + BtreeError::PayloadTooLarge { + page_num: 2, + payload_len: 999 + } + .to_string(), + "page 2 declares an implausible payload length 999" + ); + assert_eq!( + BtreeError::OverflowChainTooLong { + page_num: 2, + max: 10 + } + .to_string(), + "overflow chain from page 2 exceeded 10 pages (possible cycle)" + ); + assert_eq!( + BtreeError::OverflowChainCycle { + page_num: 2, + revisited_page: 3 + } + .to_string(), + "overflow chain from page 2 revisited page 3 (cycle)" + ); + assert_eq!( + BtreeError::OverflowChainTruncated { page_num: 2 }.to_string(), + "overflow chain from page 2 ended before all payload bytes were read" + ); + assert_eq!( + BtreeError::TraversalTooLong { max: 100 }.to_string(), + "b-tree traversal visited more than 100 pages (possible cycle)" + ); + assert!(BtreeError::Pager(PagerError::PendingTransaction) + .to_string() + .contains("pager error")); + assert_eq!( + BtreeError::DuplicateRowid { rowid: 5 }.to_string(), + "cannot insert duplicate rowid 5" + ); + assert_eq!( + BtreeError::MissingChildRoute { + page_num: 2, + child: 4 + } + .to_string(), + "interior page 2 has no routing entry for child page 4" + ); + assert_eq!( + BtreeError::RowidNotFound { rowid: 5 }.to_string(), + "cannot delete rowid 5: no such row" + ); + assert_eq!( + BtreeError::DuplicateKey.to_string(), + "cannot insert duplicate index key" + ); + assert_eq!( + BtreeError::KeyNotFound.to_string(), + "cannot delete index key: no such entry" + ); + assert_eq!( + BtreeError::InvalidRootPage { + name: "t".to_string(), + rootpage: -1 + } + .to_string(), + "sqlite_master entry \"t\" has out-of-range rootpage -1" + ); + assert_eq!( + BtreeError::MasterEntryNotFound { + name: "t".to_string() + } + .to_string(), + "cannot delete sqlite_master entry \"t\": no such entry" + ); + assert_eq!( + BtreeError::Internal("bad state").to_string(), + "internal invariant violated: bad state" + ); + } + + #[test] + fn from_conversions() { + let e: BtreeError = RecordError::InvalidUtf8.into(); + assert!(matches!(e, BtreeError::InvalidKeyRecord(_))); + + let e: BtreeError = PagerError::PendingTransaction.into(); + assert!(matches!(e, BtreeError::Pager(_))); + } + + #[test] + fn implements_std_error() { + let err = BtreeError::DuplicateKey; + assert!(std::error::Error::source(&err).is_none()); + } +} diff --git a/src/codegen/select.rs b/src/codegen/select.rs index a45b7576..1d41534c 100644 --- a/src/codegen/select.rs +++ b/src/codegen/select.rs @@ -205,3 +205,98 @@ pub(crate) use entry::{ }; pub(crate) use joins::compile_select_joined_scan; pub(crate) use limit_scan::{is_rowid_reference, top_level_equality_operands}; + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + #[test] + fn codegen_error_display_variants() { + assert_eq!( + CodegenError::NoFromClause.to_string(), + "SELECT has no FROM clause — not supported by this V2-scope compiler" + ); + assert_eq!( + CodegenError::UnknownColumn { + name: "x".to_string() + } + .to_string(), + "unknown column \"x\"" + ); + assert_eq!( + CodegenError::AmbiguousColumn { + name: "x".to_string() + } + .to_string(), + "ambiguous column name: \"x\"" + ); + assert_eq!( + CodegenError::Unsupported { + reason: "foo".to_string() + } + .to_string(), + "unsupported: foo" + ); + assert_eq!( + CodegenError::RowShapeMismatch { + table: "t".to_string(), + expected: 2, + found: 3, + } + .to_string(), + "t has 2 columns but 3 values were supplied" + ); + assert_eq!( + CodegenError::CompoundColumnMismatch { + expected: 2, + found: 3, + } + .to_string(), + "SELECTs to the left and right of UNION ALL do not have the same number of result \ + columns: expected 2, found 3" + ); + assert_eq!( + CodegenError::CircularView { + name: "v".to_string() + } + .to_string(), + "view v is circularly defined" + ); + } + + #[test] + fn codegen_error_is_std_error() { + let err = CodegenError::NoFromClause; + assert!(std::error::Error::source(&err).is_none()); + } + + #[test] + fn scan_cursors_for_standalone_select() { + let cursors = ScanCursors::for_standalone_select(); + assert_eq!(cursors.table, TABLE_CURSOR); + assert_eq!(cursors.sort, SORT_CURSOR); + assert_eq!(cursors.pseudo, PSEUDO_CURSOR); + assert_eq!(cursors.distinct, DISTINCT_CURSOR); + } + + #[test] + fn scan_cursors_for_arm_offsets_by_four() { + let arm0 = ScanCursors::for_arm(0); + assert_eq!( + (arm0.table, arm0.sort, arm0.pseudo, arm0.distinct), + (0, 1, 2, 3) + ); + let arm1 = ScanCursors::for_arm(1); + assert_eq!( + (arm1.table, arm1.sort, arm1.pseudo, arm1.distinct), + (4, 5, 6, 7) + ); + } + + #[test] + fn scan_cursors_after_arms() { + assert_eq!(ScanCursors::after_arms(0), 0); + assert_eq!(ScanCursors::after_arms(3), 12); + } +} diff --git a/src/codegen/select/join_access.rs b/src/codegen/select/join_access.rs index 18319afa..8a64a346 100644 --- a/src/codegen/select/join_access.rs +++ b/src/codegen/select/join_access.rs @@ -811,3 +811,403 @@ pub(super) fn compile_join_level_for_sort( }, ) } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::parser::ast::BinaryOp; + use crate::parser::tokenizer::Span; + use crate::planner::Stats; + use crate::schema::{IndexSchema, IndexedColumn}; + + fn span() -> Span { + Span { + line: 0, + column: 0, + offset: 0, + len: 0, + } + } + + fn col(table: Option<&str>, name: &str) -> Expr { + Expr { + kind: ExprKind::Column { + table: table.map(str::to_string), + catalog: None, + name: name.to_string(), + }, + span: span(), + } + } + + fn lit_int(n: i64) -> Expr { + Expr { + kind: ExprKind::Literal(crate::parser::ast::Literal::Integer(n)), + span: span(), + } + } + + fn eq(lhs: Expr, rhs: Expr) -> Expr { + Expr { + kind: ExprKind::Binary { + op: BinaryOp::Eq, + lhs: Box::new(lhs), + rhs: Box::new(rhs), + }, + span: span(), + } + } + + fn schema(name: &str, columns: &[&str], indexes: Vec) -> TableSchema { + TableSchema { + name: name.to_string(), + root_page: 0, + columns: columns.iter().map(|c| (*c).to_string()).collect(), + column_types: vec![String::new(); columns.len()], + column_collations: vec![], + without_rowid: false, + strict: false, + is_virtual: false, + sql: String::new(), + indexes, + rowid_alias: None, + } + } + + fn binding(schema: TableSchema, alias: Option<&str>) -> TableBinding { + TableBinding { + alias: alias.map(str::to_string), + name: schema.name.clone(), + schema, + cursor: 0, + forced_null: false, + stats: Stats::default(), + } + } + + fn unique_index(name: &str, col: &str) -> IndexSchema { + IndexSchema { + name: name.to_string(), + unique: true, + columns: vec![IndexedColumn { + name: col.to_string(), + desc: false, + collation: crate::vdbe::Collation::Binary, + }], + root_page: 0, + } + } + + #[test] + fn column_belongs_to_binding_qualifier_mismatch() { + let b = binding(schema("t", &["a"], vec![]), Some("alias")); + assert!(!column_belongs_to_binding(&b, Some("other"), "a")); + } + + #[test] + fn column_belongs_to_binding_no_qualifier_checks_schema() { + let b = binding(schema("t", &["a"], vec![]), None); + assert!(column_belongs_to_binding(&b, None, "a")); + assert!(!column_belongs_to_binding(&b, None, "missing")); + } + + #[test] + fn expr_is_safe_join_probe_literal_and_param() { + let priors: Vec = vec![]; + assert!(expr_is_safe_join_probe(&lit_int(1), &priors)); + let param = Expr { + kind: ExprKind::Param(crate::parser::ast::ParamKind::Numbered(1)), + span: span(), + }; + assert!(expr_is_safe_join_probe(¶m, &priors)); + } + + #[test] + fn expr_is_safe_join_probe_rejects_unknown_column() { + let priors = vec![binding(schema("t0", &["id"], vec![]), None)]; + assert!(!expr_is_safe_join_probe( + &col(Some("t0"), "missing"), + &priors + )); + } + + #[test] + fn expr_is_safe_join_probe_rejects_other_expr_kinds() { + let priors: Vec = vec![]; + // A binary expression is neither Literal/Param/Column. + let expr = eq(lit_int(1), lit_int(2)); + assert!(!expr_is_safe_join_probe(&expr, &priors)); + } + + #[test] + fn choose_join_access_rowid_seek() { + let t0 = binding(schema("t0", &["id"], vec![]), None); + let t1 = schema("t1", &["rowid", "name"], vec![]); + let t1b = binding(t1, None); + let on_expr = eq(col(Some("t1"), "rowid"), col(Some("t0"), "id")); + let result = choose_join_access(&t1b, &on_expr, &[t0]); + assert!(matches!(result, Some(JoinAccess::Rowid(_)))); + } + + #[test] + fn choose_join_access_none_when_not_equality() { + let t0 = binding(schema("t0", &["id"], vec![]), None); + let t1b = binding(schema("t1", &["id"], vec![]), None); + let on_expr = col(Some("t1"), "id"); + assert!(choose_join_access(&t1b, &on_expr, &[t0]).is_none()); + } + + #[test] + fn choose_join_access_none_when_neither_side_belongs() { + let t0 = binding(schema("t0", &["id"], vec![]), None); + let t1b = binding(schema("t1", &["id"], vec![]), None); + let on_expr = eq(col(Some("other"), "x"), col(Some("t0"), "id")); + assert!(choose_join_access(&t1b, &on_expr, &[t0]).is_none()); + } + + #[test] + fn choose_join_access_none_when_probe_unsafe() { + let t0 = binding(schema("t0", &["id"], vec![]), None); + let t1b = binding(schema("t1", &["id"], vec![]), None); + // Other side references t1 itself (not-yet-bound), unsafe. + let on_expr = eq(col(Some("t1"), "id"), col(Some("t1"), "id")); + assert!(choose_join_access(&t1b, &on_expr, &[t0]).is_none()); + } + + #[test] + fn choose_join_access_none_when_no_matching_index() { + let t0 = binding(schema("t0", &["id"], vec![]), None); + let t1b = binding(schema("t1", &["other"], vec![]), None); + let on_expr = eq(col(Some("t1"), "other"), col(Some("t0"), "id")); + assert!(choose_join_access(&t1b, &on_expr, &[t0]).is_none()); + } + + #[test] + fn choose_join_access_unique_index_pick() { + let t0 = binding(schema("t0", &["id"], vec![]), None); + let idx = unique_index("idx_key", "key"); + let t1b = binding(schema("t1", &["id", "key"], vec![idx]), None); + let on_expr = eq(col(Some("t1"), "key"), col(Some("t0"), "id")); + let result = choose_join_access(&t1b, &on_expr, &[t0]); + assert!(matches!(result, Some(JoinAccess::UniqueIndex { .. }))); + } + + #[test] + fn choose_join_access_vetoed_by_expensive_index_stats() { + let t0 = binding(schema("t0", &["id"], vec![]), None); + let idx = unique_index("idx_key", "key"); + let mut t1_schema = schema("t1", &["id", "key"], vec![idx]); + t1_schema.name = "t1".to_string(); + let stats = Stats::from_stat1_rows(vec![ + (None, "10".to_string()), + (Some("idx_key".to_string()), "1000 1000".to_string()), + ]); + let mut t1b = binding(t1_schema, None); + t1b.stats = stats; + let on_expr = eq(col(Some("t1"), "key"), col(Some("t0"), "id")); + assert!(choose_join_access(&t1b, &on_expr, &[t0]).is_none()); + } + + #[test] + fn choose_join_access_rhs_belongs_to_binding() { + let t0 = binding(schema("t0", &["id"], vec![]), None); + let t1b = binding(schema("t1", &["rowid"], vec![]), None); + // lhs belongs to prior t0, rhs belongs to t1 -> swapped branch. + let on_expr = eq(col(Some("t0"), "id"), col(Some("t1"), "rowid")); + let result = choose_join_access(&t1b, &on_expr, &[t0]); + assert!(matches!(result, Some(JoinAccess::Rowid(_)))); + } + + #[test] + fn choose_auto_index_probe_requires_worthwhile_stats() { + let t0 = binding(schema("t0", &["id"], vec![]), None); + let t1b = binding(schema("t1", &["key"], vec![]), None); + let on_expr = eq(col(Some("t1"), "key"), col(Some("t0"), "id")); + // No stats at all -> not worthwhile. + assert!(choose_auto_index_probe(&t1b, &on_expr, &[t0]).is_none()); + } + + #[test] + fn choose_auto_index_probe_picks_when_worthwhile() { + let t0 = binding(schema("t0", &["id"], vec![]), None); + let stats = Stats::from_stat1_rows(vec![(None, "10000".to_string())]); + let mut t1b = binding(schema("t1", &["id", "key"], vec![]), None); + t1b.stats = stats; + let on_expr = eq(col(Some("t1"), "key"), col(Some("t0"), "id")); + let probe = choose_auto_index_probe(&t1b, &on_expr, &[t0]); + assert!(probe.is_some()); + assert_eq!(probe.unwrap().key_column, 1); + } + + #[test] + fn choose_auto_index_probe_none_when_equality_fails() { + let stats = Stats::from_stat1_rows(vec![(None, "10000".to_string())]); + let mut t1b = binding(schema("t1", &["id"], vec![]), None); + t1b.stats = stats; + let on_expr = col(Some("t1"), "id"); + assert!(choose_auto_index_probe(&t1b, &on_expr, &[]).is_none()); + } + + #[test] + fn choose_auto_index_probe_none_when_neither_side_belongs() { + let stats = Stats::from_stat1_rows(vec![(None, "10000".to_string())]); + let mut t1b = binding(schema("t1", &["id"], vec![]), None); + t1b.stats = stats; + let on_expr = eq(col(Some("other"), "x"), col(Some("other2"), "y")); + assert!(choose_auto_index_probe(&t1b, &on_expr, &[]).is_none()); + } + + #[test] + fn choose_auto_index_probe_none_when_probe_unsafe() { + let stats = Stats::from_stat1_rows(vec![(None, "10000".to_string())]); + let mut t1b = binding(schema("t1", &["id"], vec![]), None); + t1b.stats = stats; + let on_expr = eq(col(Some("t1"), "id"), col(Some("t1"), "id")); + assert!(choose_auto_index_probe(&t1b, &on_expr, &[]).is_none()); + } + + #[test] + fn choose_bloom_probe_requires_min_rows() { + let t0 = binding(schema("t0", &["id"], vec![]), None); + let mut t1b = binding(schema("t1", &["key"], vec![]), None); + t1b.stats = Stats::from_stat1_rows(vec![(None, "5".to_string())]); + let on_expr = eq(col(Some("t1"), "key"), col(Some("t0"), "id")); + assert!(choose_bloom_probe(&t1b, &on_expr, &[t0]).is_none()); + } + + #[test] + fn choose_bloom_probe_none_when_no_stats() { + let t0 = binding(schema("t0", &["id"], vec![]), None); + let t1b = binding(schema("t1", &["key"], vec![]), None); + let on_expr = eq(col(Some("t1"), "key"), col(Some("t0"), "id")); + assert!(choose_bloom_probe(&t1b, &on_expr, &[t0]).is_none()); + } + + #[test] + fn choose_bloom_probe_picks_when_rows_large_enough() { + let t0 = binding(schema("t0", &["id"], vec![]), None); + let mut t1b = binding(schema("t1", &["id", "key"], vec![]), None); + t1b.stats = Stats::from_stat1_rows(vec![(None, "1000".to_string())]); + let on_expr = eq(col(Some("t1"), "key"), col(Some("t0"), "id")); + let probe = choose_bloom_probe(&t1b, &on_expr, &[t0]).unwrap(); + assert_eq!(probe.key_column, 1); + assert_eq!(probe.rows, 1000); + } + + #[test] + fn choose_bloom_probe_none_when_equality_fails() { + let mut t1b = binding(schema("t1", &["id"], vec![]), None); + t1b.stats = Stats::from_stat1_rows(vec![(None, "1000".to_string())]); + let on_expr = col(Some("t1"), "id"); + assert!(choose_bloom_probe(&t1b, &on_expr, &[]).is_none()); + } + + #[test] + fn choose_bloom_probe_none_when_neither_side_belongs() { + let mut t1b = binding(schema("t1", &["id"], vec![]), None); + t1b.stats = Stats::from_stat1_rows(vec![(None, "1000".to_string())]); + let on_expr = eq(col(Some("other"), "x"), col(Some("other2"), "y")); + assert!(choose_bloom_probe(&t1b, &on_expr, &[]).is_none()); + } + + #[test] + fn choose_bloom_probe_none_when_probe_unsafe() { + let mut t1b = binding(schema("t1", &["id"], vec![]), None); + t1b.stats = Stats::from_stat1_rows(vec![(None, "1000".to_string())]); + let on_expr = eq(col(Some("t1"), "id"), col(Some("t1"), "id")); + assert!(choose_bloom_probe(&t1b, &on_expr, &[]).is_none()); + } + + #[test] + fn choose_bloom_probe_none_when_column_unknown_to_schema() { + let mut t1b = binding(schema("t1", &["id"], vec![]), None); + t1b.stats = Stats::from_stat1_rows(vec![(None, "1000".to_string())]); + let on_expr = eq(col(Some("t1"), "missing"), lit_int(1)); + assert!(choose_bloom_probe(&t1b, &on_expr, &[]).is_none()); + } + + #[test] + fn joined_column_offset_sums_prior_bindings() { + let scope = Scope { + tables: vec![ + binding(schema("t0", &["a", "b"], vec![]), None), + binding(schema("t1", &["c"], vec![]), None), + ], + ..Scope::default() + }; + assert_eq!(joined_column_offset(&scope, 0), 0); + assert_eq!(joined_column_offset(&scope, 1), 2); + assert_eq!(joined_column_offset(&scope, 2), 3); + } + + #[test] + fn resolve_scope_column_finds_binding_and_local_index() { + let scope = Scope { + tables: vec![ + binding(schema("t0", &["a", "b"], vec![]), None), + binding(schema("t1", &["c"], vec![]), None), + ], + ..Scope::default() + }; + let (binding_idx, local_idx) = resolve_scope_column(&scope, Some("t1"), "c").unwrap(); + assert_eq!(binding_idx, 1); + assert_eq!(local_idx, 0); + } + + #[test] + fn resolve_scope_column_unknown_errors() { + let scope = Scope { + tables: vec![binding(schema("t0", &["a"], vec![]), None)], + ..Scope::default() + }; + assert!(resolve_scope_column(&scope, None, "missing").is_err()); + } + + #[test] + fn join_order_plan_ascending_offset_defaults() { + let plan = JoinOrderPlan::ascending_offset(3, Collation::Binary); + assert!(matches!(plan.target, JoinOrderTarget::Offset(3))); + assert!(!plan.descending); + assert!(plan.nulls_first); + } + + fn empty_select(columns: Vec) -> Select { + Select { + with_clause: None, + distinct: None, + columns, + from: None, + where_clause: None, + group_by: vec![], + having: None, + compound: vec![], + order_by: vec![], + limit: None, + span: span(), + } + } + + #[test] + fn resolve_join_order_by_target_prefers_alias() { + let select = empty_select(vec![ResultColumn::Expr { + expr: col(Some("t0"), "a"), + alias: Some("aliased".to_string()), + }]); + let scope = Scope { + tables: vec![binding(schema("t0", &["a"], vec![]), None)], + ..Scope::default() + }; + let target = resolve_join_order_by_target(&col(None, "aliased"), &select, &scope).unwrap(); + assert!(matches!(target, JoinOrderTarget::Offset(0))); + } + + #[test] + fn resolve_join_order_by_target_non_column_becomes_expr() { + let select = empty_select(vec![]); + let scope = Scope::default(); + let expr = lit_int(42); + let target = resolve_join_order_by_target(&expr, &select, &scope).unwrap(); + assert!(matches!(target, JoinOrderTarget::Expr(_))); + } +} diff --git a/src/codegen/select/join_order.rs b/src/codegen/select/join_order.rs index bbe5f69b..e2c13448 100644 --- a/src/codegen/select/join_order.rs +++ b/src/codegen/select/join_order.rs @@ -241,8 +241,71 @@ fn collect_referenced_binding_indices( } #[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] mod tests { use super::*; + use crate::codegen::TableBinding; + use crate::parser::ast::BinaryOp; + use crate::parser::tokenizer::Span; + use crate::schema::IndexSchema; + + fn span() -> Span { + Span { + line: 0, + column: 0, + offset: 0, + len: 0, + } + } + + fn col(table: Option<&str>, name: &str) -> Expr { + Expr { + kind: ExprKind::Column { + table: table.map(str::to_string), + catalog: None, + name: name.to_string(), + }, + span: span(), + } + } + + fn eq(lhs: Expr, rhs: Expr) -> Expr { + Expr { + kind: ExprKind::Binary { + op: BinaryOp::Eq, + lhs: Box::new(lhs), + rhs: Box::new(rhs), + }, + span: span(), + } + } + + fn schema(name: &str, columns: &[&str], indexes: Vec) -> TableSchema { + TableSchema { + name: name.to_string(), + root_page: 0, + columns: columns.iter().map(|c| (*c).to_string()).collect(), + column_types: vec![String::new(); columns.len()], + column_collations: vec![], + without_rowid: false, + strict: false, + is_virtual: false, + sql: String::new(), + indexes, + rowid_alias: None, + } + } + + fn binding(schema: TableSchema, alias: Option<&str>) -> TableBinding { + TableBinding { + alias: alias.map(str::to_string), + name: schema.name.clone(), + schema, + cursor: 0, + forced_null: false, + stats: crate::planner::Stats::default(), + } + } #[test] fn plan_join_order_sorts_by_cost_ascending() { @@ -256,4 +319,336 @@ mod tests { let costs = vec![u64::MAX, u64::MAX, u64::MAX]; assert_eq!(plan_join_order(&costs), vec![0, 1, 2]); } + + #[test] + fn plan_join_order_missing_cost_defaults_to_max() { + // costs.get(i) is None for an out-of-range index; unwrap_or(MAX) + // sorts it last. + assert_eq!(plan_join_order(&[]), Vec::::new()); + } + + #[test] + fn scan_costs_marks_seekable_table_as_max_regardless_of_stats() { + let schemas = vec![schema("t", &["a"], vec![])]; + let mut stats = std::collections::HashMap::new(); + stats.insert("t".to_string(), Stats::default()); + let costs = scan_costs(&schemas, &stats, &[true]); + assert_eq!(costs, vec![u64::MAX]); + } + + #[test] + fn scan_costs_uses_default_stats_for_unknown_table() { + let schemas = vec![schema("t", &["a"], vec![])]; + let stats = std::collections::HashMap::new(); + let costs = scan_costs(&schemas, &stats, &[false]); + assert_eq!( + costs, + vec![estimate_scan_cost(&Stats::default()).estimated_rows] + ); + } + + #[test] + fn seekable_tables_table_zero_never_seekable() { + let schemas = vec![schema("t0", &["id"], vec![])]; + let seekable = seekable_tables(&schemas, &[]); + assert_eq!(seekable, vec![false]); + } + + #[test] + fn seekable_tables_true_for_rowid_equality() { + let t0 = schema("t0", &["id"], vec![]); + // is_rowid_reference matches the literal "rowid"/"_rowid_"/"oid" + // names regardless of rowid_alias, so this triggers that path. + let t1 = schema("t1", &["rowid", "t0_id"], vec![]); + let constraint = eq(col(Some("t1"), "rowid"), col(Some("t0"), "id")); + let seekable = seekable_tables(&[t0, t1], &[Some(constraint)]); + assert_eq!(seekable, vec![false, true]); + } + + #[test] + fn seekable_tables_true_for_unique_index_equality() { + let t0 = schema("t0", &["id"], vec![]); + let index = IndexSchema { + name: "idx_t1_key".to_string(), + unique: true, + columns: vec![crate::schema::IndexedColumn { + name: "key".to_string(), + desc: false, + collation: crate::vdbe::Collation::Binary, + }], + root_page: 0, + }; + let t1 = schema("t1", &["id", "key"], vec![index]); + let constraint = eq(col(Some("t1"), "key"), col(Some("t0"), "id")); + let seekable = seekable_tables(&[t0, t1], &[Some(constraint)]); + assert_eq!(seekable, vec![false, true]); + } + + #[test] + fn seekable_tables_false_for_non_equality_constraint() { + let t0 = schema("t0", &["id"], vec![]); + let t1 = schema("t1", &["id"], vec![]); + // Not a Binary/Eq expression, so top_level_equality_operands is None. + let constraint = col(Some("t1"), "id"); + let seekable = seekable_tables(&[t0, t1], &[Some(constraint)]); + assert_eq!(seekable, vec![false, false]); + } + + #[test] + fn seekable_tables_false_when_column_not_in_schema() { + let t0 = schema("t0", &["id"], vec![]); + let t1 = schema("t1", &["id"], vec![]); + let constraint = eq(col(Some("t1"), "missing"), col(Some("t0"), "id")); + let seekable = seekable_tables(&[t0, t1], &[Some(constraint)]); + assert_eq!(seekable, vec![false, false]); + } + + #[test] + fn seekable_tables_skips_missing_constraint_or_schema() { + let t0 = schema("t0", &["id"], vec![]); + let t1 = schema("t1", &["id"], vec![]); + let seekable = seekable_tables(&[t0, t1], &[None]); + assert_eq!(seekable, vec![false, false]); + } + + #[test] + fn referenced_binding_indices_qualified_column() { + let bindings = vec![ + binding(schema("t0", &["id"], vec![]), None), + binding(schema("t1", &["id"], vec![]), Some("u")), + ]; + let expr = col(Some("u"), "id"); + assert_eq!(referenced_binding_indices(&expr, &bindings), vec![1]); + } + + #[test] + fn referenced_binding_indices_unqualified_column_matches_all_with_it() { + let bindings = vec![ + binding(schema("t0", &["id"], vec![]), None), + binding(schema("t1", &["id"], vec![]), None), + ]; + let expr = col(None, "id"); + assert_eq!(referenced_binding_indices(&expr, &bindings), vec![0, 1]); + } + + #[test] + fn referenced_binding_indices_literal_and_param_reference_nothing() { + let bindings = vec![binding(schema("t0", &["id"], vec![]), None)]; + let lit = Expr { + kind: ExprKind::Literal(Literal::Null), + span: span(), + }; + assert_eq!( + referenced_binding_indices(&lit, &bindings), + Vec::::new() + ); + let param = Expr { + kind: ExprKind::Param(ParamKind::Anonymous), + span: span(), + }; + assert_eq!( + referenced_binding_indices(¶m, &bindings), + Vec::::new() + ); + } + + #[test] + fn referenced_binding_indices_function_call_list_args() { + let bindings = vec![binding(schema("t0", &["id"], vec![]), None)]; + let expr = Expr { + kind: ExprKind::FunctionCall { + name: "coalesce".to_string(), + distinct: false, + args: FunctionArgs::List(vec![col(None, "id")]), + }, + span: span(), + }; + assert_eq!(referenced_binding_indices(&expr, &bindings), vec![0]); + } + + #[test] + fn referenced_binding_indices_function_call_star_args_empty() { + let bindings = vec![binding(schema("t0", &["id"], vec![]), None)]; + let expr = Expr { + kind: ExprKind::FunctionCall { + name: "count".to_string(), + distinct: false, + args: FunctionArgs::Star, + }, + span: span(), + }; + assert_eq!( + referenced_binding_indices(&expr, &bindings), + Vec::::new() + ); + } + + #[test] + fn referenced_binding_indices_unary_isnull_cast_collate_paren() { + let bindings = vec![binding(schema("t0", &["id"], vec![]), None)]; + let inner = || col(None, "id"); + let cases = vec![ + Expr { + kind: ExprKind::Unary { + op: crate::parser::ast::UnaryOp::Not, + expr: Box::new(inner()), + }, + span: span(), + }, + Expr { + kind: ExprKind::IsNull { + expr: Box::new(inner()), + negated: false, + }, + span: span(), + }, + Expr { + kind: ExprKind::Cast { + expr: Box::new(inner()), + type_name: "TEXT".to_string(), + }, + span: span(), + }, + Expr { + kind: ExprKind::Collate { + expr: Box::new(inner()), + collation: "BINARY".to_string(), + }, + span: span(), + }, + Expr { + kind: ExprKind::Paren(Box::new(inner())), + span: span(), + }, + ]; + for expr in cases { + assert_eq!(referenced_binding_indices(&expr, &bindings), vec![0]); + } + } + + #[test] + fn referenced_binding_indices_is_operator() { + let bindings = vec![binding(schema("t0", &["id"], vec![]), None)]; + let expr = Expr { + kind: ExprKind::Is { + lhs: Box::new(col(None, "id")), + rhs: Box::new(Expr { + kind: ExprKind::Literal(Literal::Null), + span: span(), + }), + negated: false, + }, + span: span(), + }; + assert_eq!(referenced_binding_indices(&expr, &bindings), vec![0]); + } + + #[test] + fn referenced_binding_indices_between() { + let bindings = vec![binding(schema("t0", &["id"], vec![]), None)]; + let expr = Expr { + kind: ExprKind::Between { + expr: Box::new(col(None, "id")), + lo: Box::new(Expr { + kind: ExprKind::Literal(Literal::Integer(1)), + span: span(), + }), + hi: Box::new(Expr { + kind: ExprKind::Literal(Literal::Integer(2)), + span: span(), + }), + negated: false, + }, + span: span(), + }; + assert_eq!(referenced_binding_indices(&expr, &bindings), vec![0]); + } + + #[test] + fn referenced_binding_indices_in_list() { + let bindings = vec![binding(schema("t0", &["id"], vec![]), None)]; + let expr = Expr { + kind: ExprKind::In { + expr: Box::new(col(None, "id")), + list: vec![Expr { + kind: ExprKind::Literal(Literal::Integer(1)), + span: span(), + }], + negated: false, + }, + span: span(), + }; + assert_eq!(referenced_binding_indices(&expr, &bindings), vec![0]); + } + + #[test] + fn referenced_binding_indices_like_with_escape() { + let bindings = vec![binding(schema("t0", &["id"], vec![]), None)]; + let expr = Expr { + kind: ExprKind::Like { + expr: Box::new(col(None, "id")), + pattern: Box::new(Expr { + kind: ExprKind::Literal(Literal::Str("x".to_string())), + span: span(), + }), + escape: Some(Box::new(Expr { + kind: ExprKind::Literal(Literal::Str("!".to_string())), + span: span(), + })), + glob: false, + negated: false, + }, + span: span(), + }; + assert_eq!(referenced_binding_indices(&expr, &bindings), vec![0]); + } + + #[test] + fn referenced_binding_indices_case_with_operand_and_else() { + let bindings = vec![binding(schema("t0", &["id"], vec![]), None)]; + let expr = Expr { + kind: ExprKind::Case { + operand: Some(Box::new(col(None, "id"))), + whens: vec![( + Expr { + kind: ExprKind::Literal(Literal::Integer(1)), + span: span(), + }, + Expr { + kind: ExprKind::Literal(Literal::Integer(2)), + span: span(), + }, + )], + else_: Some(Box::new(col(None, "id"))), + }, + span: span(), + }; + assert_eq!(referenced_binding_indices(&expr, &bindings), vec![0]); + } + + #[test] + fn referenced_binding_indices_subquery_variants_reference_everything() { + let bindings = vec![ + binding(schema("t0", &["id"], vec![]), None), + binding(schema("t1", &["id"], vec![]), None), + ]; + let inner_select = Select { + with_clause: None, + distinct: None, + columns: vec![], + from: None, + where_clause: None, + group_by: vec![], + having: None, + compound: vec![], + order_by: vec![], + limit: None, + span: span(), + }; + let subquery = Expr { + kind: ExprKind::Subquery(Box::new(inner_select)), + span: span(), + }; + assert_eq!(referenced_binding_indices(&subquery, &bindings), vec![0, 1]); + } } diff --git a/src/codegen/subquery/flatten.rs b/src/codegen/subquery/flatten.rs index 8d579b4f..18deb3d5 100644 --- a/src/codegen/subquery/flatten.rs +++ b/src/codegen/subquery/flatten.rs @@ -625,3 +625,293 @@ fn recurse_into_from_subqueries(select: &mut Select) { } } } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::panic)] +mod tests { + use super::*; + use crate::parser::error::{parse_select, ParseOutcome}; + + fn parse(sql: &str) -> Select { + match parse_select(sql) { + ParseOutcome::Accepted(select) => *select, + other => panic!("expected Accepted, got {other:?}"), + } + } + + fn flatten(sql: &str) -> Select { + let mut select = parse(sql); + flatten_from_subqueries(&mut select); + select + } + + #[test] + fn flattens_star_subquery_into_base_table() { + let select = flatten("SELECT * FROM (SELECT * FROM t) AS s WHERE s.a > 1"); + let from = select.from.unwrap(); + assert!(matches!(&from.first.kind, TableRefKind::Name(n) if n == "t")); + assert_eq!(from.first.alias.as_deref(), Some("s")); + // s.a > 1 rewritten to unqualified a > 1 (no sibling join). + let where_str = format!("{:?}", select.where_clause.unwrap()); + assert!(where_str.contains("\"a\"")); + assert!(!where_str.contains("\"s\"")); + } + + #[test] + fn flattens_explicit_column_list_and_rewrites_alias() { + let select = flatten("SELECT s.x FROM (SELECT a AS x, b FROM t) AS s WHERE s.x > 1"); + let from = select.from.unwrap(); + assert!(matches!(&from.first.kind, TableRefKind::Name(n) if n == "t")); + let ResultColumn::Expr { expr, .. } = &select.columns[0] else { + panic!("expected Expr result column"); + }; + let ExprKind::Column { name, .. } = &expr.kind else { + panic!("expected Column expr"); + }; + assert_eq!(name, "a"); + } + + #[test] + fn merges_inner_where_with_outer_where() { + let select = flatten("SELECT * FROM (SELECT * FROM t WHERE b > 0) AS s WHERE s.a > 1"); + let where_clause = select.where_clause.unwrap(); + assert!(matches!( + where_clause.kind, + ExprKind::Binary { + op: BinaryOp::And, + .. + } + )); + } + + #[test] + fn requalifies_when_sibling_join_present() { + let select = + flatten("SELECT * FROM (SELECT a FROM t) AS s JOIN u ON s.a = u.a WHERE s.a > 1"); + let from = select.from.unwrap(); + assert!(matches!(&from.first.kind, TableRefKind::Name(n) if n == "t")); + let where_str = format!("{:?}", select.where_clause.unwrap()); + assert!(where_str.contains("\"s\"")); + } + + #[test] + fn does_not_flatten_distinct_subquery() { + let select = flatten("SELECT * FROM (SELECT DISTINCT a FROM t) AS s"); + let from = select.from.unwrap(); + assert!(matches!(&from.first.kind, TableRefKind::Subquery(_))); + } + + #[test] + fn does_not_flatten_aggregate_subquery() { + let select = flatten("SELECT * FROM (SELECT count(*) FROM t) AS s"); + let from = select.from.unwrap(); + assert!(matches!(&from.first.kind, TableRefKind::Subquery(_))); + } + + #[test] + fn does_not_flatten_subquery_with_limit() { + let select = flatten("SELECT * FROM (SELECT a FROM t LIMIT 5) AS s"); + let from = select.from.unwrap(); + assert!(matches!(&from.first.kind, TableRefKind::Subquery(_))); + } + + #[test] + fn does_not_flatten_subquery_with_join_in_its_own_from() { + let select = flatten("SELECT * FROM (SELECT t.a FROM t JOIN u ON t.a = u.a) AS s"); + let from = select.from.unwrap(); + assert!(matches!(&from.first.kind, TableRefKind::Subquery(_))); + } + + #[test] + fn does_not_flatten_across_left_join() { + let select = flatten("SELECT * FROM t LEFT JOIN (SELECT a FROM u) AS s ON t.a = s.a"); + let from = select.from.unwrap(); + assert!(matches!( + &from.joins[0].table.kind, + TableRefKind::Subquery(_) + )); + } + + #[test] + fn bails_on_alias_star_reference() { + let select = flatten("SELECT s.* FROM (SELECT a FROM t) AS s"); + let from = select.from.unwrap(); + assert!(matches!(&from.first.kind, TableRefKind::Subquery(_))); + } + + #[test] + fn bails_when_alias_referenced_inside_nested_subquery() { + let select = flatten( + "SELECT * FROM (SELECT a FROM t) AS s WHERE EXISTS (SELECT 1 FROM v WHERE v.x = s.a)", + ); + let from = select.from.unwrap(); + assert!(matches!(&from.first.kind, TableRefKind::Subquery(_))); + } + + #[test] + fn flattens_join_slot_subquery() { + let select = flatten("SELECT * FROM t JOIN (SELECT a FROM u) AS s ON t.a = s.a"); + let from = select.from.unwrap(); + assert!(matches!(&from.joins[0].table.kind, TableRefKind::Name(n) if n == "u")); + } + + #[test] + fn rewrite_expr_covers_every_expr_kind_that_can_reference_the_alias() { + let select = flatten( + "SELECT s.a, count(s.a) FROM (SELECT a FROM t) AS s JOIN u ON s.a = u.a \ + WHERE CASE WHEN s.a BETWEEN 1 AND 10 THEN s.a IN (1, 2, 3) \ + ELSE s.a LIKE 'x%' ESCAPE '\\' END \ + AND CAST(s.a AS INTEGER) IS NULL \ + AND (-s.a) IS NOT NULL \ + AND s.a IS 1 \ + AND (s.a) = 1 \ + GROUP BY s.a HAVING s.a > 1 ORDER BY s.a", + ); + let from = select.from.unwrap(); + assert!(matches!(&from.first.kind, TableRefKind::Name(n) if n == "t")); + // Sibling join present, so require_qualified is true: every + // rewritten reference is re-qualified as `s.a`, not bare `a`. + let where_str = format!("{:?}", select.where_clause.unwrap()); + assert!(where_str.contains("\"s\"")); + } + + #[test] + fn rewrite_expr_rejects_unmapped_column_in_nested_position() { + // `s.missing` inside a CASE arm isn't in the explicit column + // map, so the whole rewrite bails and the subquery stays put. + let select = flatten( + "SELECT * FROM (SELECT a FROM t) AS s WHERE CASE WHEN 1 THEN s.missing ELSE 0 END", + ); + let from = select.from.unwrap(); + assert!(matches!(&from.first.kind, TableRefKind::Subquery(_))); + } + + #[test] + fn inner_where_qualify_covers_every_expr_kind() { + // The inner subquery's own WHERE gets re-qualified via + // `qualify_with_outer_alias` once merged into the outer WHERE — + // exercise that recursion's arms directly through a rich inner + // predicate, in a sibling-join query (so `require_qualified` is + // true and the alias actually gets substituted back in). + let select = flatten( + "SELECT * FROM (SELECT a FROM t \ + WHERE CASE WHEN a BETWEEN 1 AND 10 THEN a IN (1, 2) ELSE a LIKE 'y' END \ + AND -a IS NOT NULL AND (a) = 1 AND foo(a, a)) AS s \ + JOIN u ON s.a = u.a", + ); + let where_str = format!("{:?}", select.where_clause.unwrap()); + assert!(where_str.contains("\"s\"")); + } + + #[test] + fn select_references_alias_checks_group_by_having_order_by_and_joins() { + for sql in [ + "SELECT * FROM (SELECT a FROM t) AS s WHERE EXISTS (SELECT 1 FROM v GROUP BY s.a)", + "SELECT * FROM (SELECT a FROM t) AS s WHERE EXISTS (SELECT 1 FROM v HAVING s.a > 1)", + "SELECT * FROM (SELECT a FROM t) AS s WHERE EXISTS (SELECT 1 FROM v ORDER BY s.a)", + "SELECT * FROM (SELECT a FROM t) AS s WHERE EXISTS (SELECT 1 FROM v JOIN w ON s.a = w.a)", + "SELECT * FROM (SELECT a FROM t) AS s WHERE EXISTS (SELECT s.* FROM v)", + "SELECT * FROM (SELECT a FROM t) AS s WHERE EXISTS (SELECT 1 FROM (SELECT 1 FROM v WHERE s.a = 1) AS z)", + ] { + let select = flatten(sql); + let from = select.from.unwrap(); + assert!( + matches!(&from.first.kind, TableRefKind::Subquery(_)), + "expected {sql} to stay unflattened" + ); + } + } + + #[test] + fn in_subquery_and_in_subquery_multi_veto_flattening_when_correlated() { + let select = flatten( + "SELECT * FROM (SELECT a FROM t) AS s \ + WHERE s.a IN (SELECT x FROM v WHERE v.x = s.a)", + ); + let from = select.from.unwrap(); + assert!(matches!(&from.first.kind, TableRefKind::Subquery(_))); + + let select = flatten( + "SELECT * FROM (SELECT a FROM t) AS s \ + WHERE (s.a, s.a) IN (SELECT x, y FROM v WHERE v.x = s.a)", + ); + let from = select.from.unwrap(); + assert!(matches!(&from.first.kind, TableRefKind::Subquery(_))); + } + + #[test] + fn expr_references_alias_covers_every_expr_kind() { + let select = flatten( + "SELECT * FROM (SELECT a FROM t) AS s WHERE EXISTS ( \ + SELECT 1 FROM v WHERE \ + CASE WHEN s.a BETWEEN 1 AND 10 THEN s.a IN (1, 2) ELSE s.a LIKE 'x' END \ + AND CAST(s.a AS INTEGER) IS NULL \ + AND (-s.a) \ + AND foo(s.a) \ + )", + ); + let from = select.from.unwrap(); + assert!(matches!(&from.first.kind, TableRefKind::Subquery(_))); + } + + #[test] + fn expr_references_alias_descends_into_in_subquery_variants() { + let select = flatten( + "SELECT * FROM (SELECT a FROM t) AS s WHERE EXISTS ( \ + SELECT 1 FROM v WHERE v.x IN (SELECT y FROM w WHERE w.y = s.a) \ + )", + ); + let from = select.from.unwrap(); + assert!(matches!(&from.first.kind, TableRefKind::Subquery(_))); + + let select = flatten( + "SELECT * FROM (SELECT a FROM t) AS s WHERE EXISTS ( \ + SELECT 1 FROM v WHERE (v.x, v.y) IN (SELECT p, q FROM w WHERE w.p = s.a) \ + )", + ); + let from = select.from.unwrap(); + assert!(matches!(&from.first.kind, TableRefKind::Subquery(_))); + } + + #[test] + fn try_flatten_table_ref_at_early_return_branches() { + // First table ref isn't a Subquery at all. + let select = flatten("SELECT * FROM t"); + assert!(matches!(&select.from.unwrap().first.kind, TableRefKind::Name(n) if n == "t")); + + // Subquery's own FROM is missing entirely (`SELECT 1`). + let select = flatten("SELECT * FROM (SELECT 1) AS s"); + assert!(matches!( + &select.from.unwrap().first.kind, + TableRefKind::Subquery(_) + )); + + // Subquery's own FROM is itself a subquery, not a base table Name. + let select = flatten("SELECT * FROM (SELECT * FROM (SELECT 1) AS z) AS s"); + assert!(matches!( + &select.from.unwrap().first.kind, + TableRefKind::Subquery(_) + )); + } + + #[test] + fn flatten_from_subqueries_is_a_noop_without_a_from_clause() { + let select = flatten("SELECT 1"); + assert!(select.from.is_none()); + } + + #[test] + fn recurses_into_nested_from_subqueries() { + // The outer subquery's own FROM is itself a subquery, so + // `subquery_flatten_safe` rejects flattening `s` directly — but + // `recurse_into_from_subqueries` still descends and flattens the + // inner `inner_s` subquery into its base table. + let select = flatten("SELECT * FROM (SELECT * FROM (SELECT * FROM t) AS inner_s) AS s"); + let from = select.from.unwrap(); + let TableRefKind::Subquery(inner) = &from.first.kind else { + panic!("expected outer subquery to remain unflattened"); + }; + let inner_from = inner.from.as_ref().unwrap(); + assert!(matches!(&inner_from.first.kind, TableRefKind::Name(n) if n == "t")); + } +} diff --git a/src/codegen/subquery/from_clause.rs b/src/codegen/subquery/from_clause.rs index dc949da0..f406a01d 100644 --- a/src/codegen/subquery/from_clause.rs +++ b/src/codegen/subquery/from_clause.rs @@ -369,3 +369,192 @@ pub(crate) fn materialize_from_subquery( Ok(synthetic_schema) } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::panic)] +mod tests { + use super::*; + use crate::codegen::{Emitter, RegAlloc}; + use crate::parser::error::{parse_select, ParseOutcome}; + + fn parse(sql: &str) -> Select { + match parse_select(sql) { + ParseOutcome::Accepted(select) => *select, + other => panic!("expected Accepted, got {other:?}"), + } + } + + fn table(name: &str, root_page: u32) -> TableSchema { + TableSchema { + name: name.to_string(), + root_page, + columns: vec!["a".to_string(), "b".to_string()], + without_rowid: false, + strict: false, + column_types: vec![String::new(), String::new()], + column_collations: vec![], + is_virtual: false, + sql: format!("CREATE TABLE {name}(a, b)"), + indexes: Vec::new(), + rowid_alias: None, + } + } + + fn from_of(select: &Select) -> &TableRef { + &select.from.as_ref().unwrap().first + } + + #[test] + fn resolve_subquery_schema_none_when_no_from() { + let select = parse("SELECT (SELECT 1)"); + let inner = match &select.columns[0] { + ResultColumn::Expr { expr, .. } => match &expr.kind { + ExprKind::Subquery(sub) => (**sub).clone(), + _ => panic!("expected subquery expr"), + }, + _ => panic!("expected Expr result column"), + }; + let result = resolve_subquery_schema(&inner, &[]).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn resolve_subquery_schema_rejects_join_in_own_from() { + let sub = parse("SELECT a FROM t JOIN u ON t.a = u.a"); + let err = resolve_subquery_schema(&sub, &[]).unwrap_err(); + assert!(matches!(err, CodegenError::Unsupported { reason } if reason.contains("JOIN"))); + } + + #[test] + fn resolve_subquery_schema_rejects_nested_subquery_from() { + let sub = parse("SELECT a FROM (SELECT a FROM t) AS x"); + let err = resolve_subquery_schema(&sub, &[]).unwrap_err(); + assert!( + matches!(err, CodegenError::Unsupported { reason } if reason.contains("subquery-expression")) + ); + } + + #[test] + fn resolve_subquery_schema_rejects_unknown_table() { + let sub = parse("SELECT a FROM missing"); + let err = resolve_subquery_schema(&sub, &[]).unwrap_err(); + assert!(matches!(err, CodegenError::Unsupported { reason } if reason.contains("missing"))); + } + + #[test] + fn resolve_subquery_schema_finds_catalog_table_case_insensitively() { + let sub = parse("SELECT a FROM T"); + let catalog = vec![table("t", 2)]; + let schema = resolve_subquery_schema(&sub, &catalog).unwrap().unwrap(); + assert_eq!(schema.name, "t"); + } + + #[test] + fn resolve_from_table_schema_name_not_found() { + let select = parse("SELECT a FROM missing"); + let err = resolve_from_table_schema(from_of(&select), &[]).unwrap_err(); + assert!( + matches!(err, CodegenError::Unsupported { reason } if reason.contains("no such table")) + ); + } + + #[test] + fn resolve_from_table_schema_name_found() { + let select = parse("SELECT a FROM t"); + let catalog = vec![table("t", 2)]; + let schema = resolve_from_table_schema(from_of(&select), &catalog).unwrap(); + assert_eq!(schema.name, "t"); + } + + #[test] + fn resolve_from_table_schema_star_and_alias_expr() { + let select = parse("SELECT * FROM (SELECT a, b AS c FROM t) AS s"); + let catalog = vec![table("t", 2)]; + let schema = resolve_from_table_schema(from_of(&select), &catalog).unwrap(); + assert_eq!(schema.name, "s"); + assert_eq!(schema.columns, vec!["a".to_string(), "c".to_string()]); + } + + #[test] + fn resolve_from_table_schema_table_star() { + let select = parse("SELECT s.* FROM (SELECT a, b FROM t) AS s"); + let catalog = vec![table("t", 2)]; + let schema = resolve_from_table_schema(from_of(&select), &catalog).unwrap(); + assert_eq!(schema.columns, vec!["a".to_string(), "b".to_string()]); + } + + #[test] + fn resolve_from_table_schema_computed_expr_gets_positional_name() { + let select = parse("SELECT * FROM (SELECT a + 1 FROM t) AS s"); + let catalog = vec![table("t", 2)]; + let schema = resolve_from_table_schema(from_of(&select), &catalog).unwrap(); + assert_eq!(schema.columns, vec!["column1".to_string()]); + } + + #[test] + fn resolve_from_table_schema_unaliased_column_expr_uses_column_name() { + let select = parse("SELECT a FROM (SELECT a FROM t) AS s"); + let catalog = vec![table("t", 2)]; + let schema = resolve_from_table_schema(from_of(&select), &catalog).unwrap(); + assert_eq!(schema.columns, vec!["a".to_string()]); + } + + #[test] + fn materialize_from_subquery_rejects_compound() { + let subquery = parse("SELECT a FROM t UNION SELECT a FROM t2"); + let mut em = Emitter::new(); + let mut reg = RegAlloc::default(); + let err = materialize_from_subquery(&mut em, &mut reg, &subquery, &[], 1).unwrap_err(); + assert!(matches!(err, CodegenError::Unsupported { reason } if reason.contains("UNION"))); + } + + #[test] + fn materialize_from_subquery_rejects_unknown_table() { + let subquery = parse("SELECT a FROM missing"); + let mut em = Emitter::new(); + let mut reg = RegAlloc::default(); + let err = materialize_from_subquery(&mut em, &mut reg, &subquery, &[], 1).unwrap_err(); + assert!(matches!(err, CodegenError::Unsupported { .. })); + } + + #[test] + fn materialize_from_subquery_single_table_and_cache_reuse() { + let subquery = parse("SELECT a, b FROM t"); + let catalog = vec![table("t", 2)]; + let mut em = Emitter::new(); + let mut reg = RegAlloc::default(); + + let schema = materialize_from_subquery(&mut em, &mut reg, &subquery, &catalog, 10).unwrap(); + assert_eq!(schema.columns, vec!["a".to_string(), "b".to_string()]); + + // #425: an identical subquery reuses the cached materialization via + // OpenDup instead of re-running the scan. + let before = em.here(); + let schema2 = + materialize_from_subquery(&mut em, &mut reg, &subquery, &catalog, 11).unwrap(); + assert_eq!(schema2.columns, schema.columns); + let program = em.finish(); + assert_eq!(program.len(), before + 1); + assert_eq!(program.get(before).unwrap().opcode, Opcode::OpenDup); + } + + #[test] + fn materialize_from_subquery_joined_own_from() { + let subquery = parse("SELECT t.a FROM t JOIN t2 ON t.a = t2.a"); + let catalog = vec![table("t", 2), table("t2", 3)]; + let mut em = Emitter::new(); + let mut reg = RegAlloc::default(); + let schema = materialize_from_subquery(&mut em, &mut reg, &subquery, &catalog, 10).unwrap(); + assert_eq!(schema.columns, vec!["a".to_string()]); + } + + #[test] + fn materialize_from_subquery_nested_subquery_in_own_from() { + let subquery = parse("SELECT a FROM (SELECT a FROM t) AS inner_s"); + let catalog = vec![table("t", 2)]; + let mut em = Emitter::new(); + let mut reg = RegAlloc::default(); + let schema = materialize_from_subquery(&mut em, &mut reg, &subquery, &catalog, 10).unwrap(); + assert_eq!(schema.columns, vec!["a".to_string()]); + } +} diff --git a/src/codegen/subquery/pushdown.rs b/src/codegen/subquery/pushdown.rs index c02b9135..2eb31ae1 100644 --- a/src/codegen/subquery/pushdown.rs +++ b/src/codegen/subquery/pushdown.rs @@ -332,3 +332,170 @@ fn recurse_into_from_subqueries(select: &mut Select) { } } } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::panic)] +mod tests { + use super::*; + use crate::parser::error::{parse_select, ParseOutcome}; + + fn parse(sql: &str) -> Select { + match parse_select(sql) { + ParseOutcome::Accepted(select) => *select, + other => panic!("expected Accepted, got {other:?}"), + } + } + + fn pushed(sql: &str) -> String { + let mut select = parse(sql); + push_down_where_predicates(&mut select); + select.to_string() + } + + #[test] + fn pushes_predicate_through_wildcard_projection() { + let out = pushed("SELECT * FROM (SELECT a, b FROM t) AS sub WHERE sub.a = 1"); + assert!(out.contains("WHERE a = 1"), "{out}"); + } + + #[test] + fn pushes_predicate_and_rewrites_aliased_column() { + let out = pushed("SELECT * FROM (SELECT a, b AS c FROM t) AS sub WHERE sub.c = 1"); + assert!(out.contains("WHERE b = 1"), "{out}"); + } + + #[test] + fn does_not_push_when_column_unknown_in_explicit_map() { + let out = pushed("SELECT * FROM (SELECT a FROM t) AS sub WHERE sub.zzz = 1"); + assert!(out.contains("zzz"), "{out}"); + } + + #[test] + fn does_not_push_when_subquery_has_group_by() { + let out = pushed( + "SELECT * FROM (SELECT a, count(*) AS c FROM t GROUP BY a) AS sub WHERE sub.a = 1", + ); + assert!(out.contains("sub.a = 1"), "{out}"); + } + + #[test] + fn does_not_push_when_subquery_has_distinct() { + let out = pushed("SELECT * FROM (SELECT DISTINCT a FROM t) AS sub WHERE sub.a = 1"); + assert!(out.contains("sub.a = 1"), "{out}"); + } + + #[test] + fn does_not_push_when_subquery_has_limit() { + let out = pushed("SELECT * FROM (SELECT a FROM t LIMIT 5) AS sub WHERE sub.a = 1"); + assert!(out.contains("sub.a = 1"), "{out}"); + } + + #[test] + fn does_not_push_when_subquery_is_compound() { + let out = + pushed("SELECT * FROM (SELECT a FROM t UNION SELECT a FROM t2) AS sub WHERE sub.a = 1"); + assert!(out.contains("sub.a = 1"), "{out}"); + } + + #[test] + fn does_not_push_when_subquery_has_own_join() { + let out = pushed( + "SELECT * FROM (SELECT t.a FROM t JOIN t2 ON t.a = t2.a) AS sub WHERE sub.a = 1", + ); + assert!(out.contains("sub.a = 1"), "{out}"); + } + + #[test] + fn does_not_push_computed_projection() { + let out = pushed("SELECT * FROM (SELECT a + 1 AS c FROM t) AS sub WHERE sub.c = 1"); + assert!(out.contains("sub.c = 1"), "{out}"); + } + + #[test] + fn does_not_push_catalog_qualified_column() { + let out = pushed("SELECT * FROM (SELECT a FROM t) AS sub WHERE main.sub.a = 1"); + assert!(out.contains("main.sub.a = 1"), "{out}"); + } + + #[test] + fn requires_qualification_when_join_present() { + let out = pushed("SELECT * FROM (SELECT a FROM t) AS sub JOIN t2 ON t2.x = 1 WHERE a = 1"); + assert!(out.contains("WHERE a = 1"), "{out}"); + } + + #[test] + fn allows_unqualified_column_without_join() { + let out = pushed("SELECT * FROM (SELECT a FROM t) AS sub WHERE a = 1"); + assert!(!out.contains("WHERE a = 1") || out.contains("(SELECT a FROM t WHERE a = 1)")); + } + + #[test] + fn does_not_push_nested_subquery_expression() { + let out = + pushed("SELECT * FROM (SELECT a FROM t) AS sub WHERE sub.a IN (SELECT x FROM t2)"); + assert!(out.contains("sub.a IN"), "{out}"); + } + + #[test] + fn pushes_into_second_join_table() { + let out = pushed( + "SELECT * FROM t0 JOIN (SELECT a FROM t) AS sub ON t0.x = sub.a WHERE sub.a = 1", + ); + assert!(out.contains("(SELECT a FROM t WHERE a = 1)"), "{out}"); + } + + #[test] + fn splits_conjuncts_pushing_only_the_movable_one() { + let out = pushed("SELECT * FROM (SELECT a FROM t) AS sub WHERE sub.a = 1 AND sub.zzz = 1"); + assert!(out.contains("t WHERE a = 1"), "{out}"); + assert!(out.contains("sub.zzz = 1"), "{out}"); + } + + #[test] + fn rewrite_for_pushdown_covers_every_expr_kind() { + let out = pushed( + "SELECT * FROM (SELECT a FROM t) AS sub WHERE \ + CASE WHEN sub.a BETWEEN 1 AND 10 THEN sub.a IN (1, 2) ELSE sub.a LIKE 'x' END \ + AND CAST(sub.a AS INTEGER) IS NULL \ + AND (-sub.a) IS NOT 1 \ + AND (sub.a) COLLATE NOCASE = 1 \ + AND foo(sub.a)", + ); + assert!(out.contains("FROM t WHERE"), "{out}"); + assert!(!out.contains("sub.a"), "{out}"); + } + + #[test] + fn does_not_push_like_escape_referencing_unmapped_column() { + let out = + pushed("SELECT * FROM (SELECT a FROM t) AS sub WHERE sub.a LIKE 'x' ESCAPE sub.zzz"); + assert!(out.contains("zzz"), "{out}"); + } + + #[test] + fn does_not_push_exists_or_scalar_subquery_conjunct() { + let out = pushed( + "SELECT * FROM (SELECT a FROM t) AS sub WHERE EXISTS (SELECT 1 FROM t2 WHERE t2.x = sub.a)", + ); + assert!(out.contains("EXISTS"), "{out}"); + + let out = pushed("SELECT * FROM (SELECT a FROM t) AS sub WHERE (SELECT x FROM t2) = sub.a"); + assert!(out.contains("sub.a"), "{out}"); + } + + #[test] + fn does_not_push_in_subquery_multi_conjunct() { + let out = pushed( + "SELECT * FROM (SELECT a FROM t) AS sub WHERE (sub.a, 1) IN (SELECT x, y FROM t2)", + ); + assert!(out.contains("sub.a"), "{out}"); + } + + #[test] + fn recurses_into_nested_subqueries() { + let out = pushed( + "SELECT * FROM (SELECT * FROM (SELECT a FROM t) AS inner1) AS outer1 WHERE outer1.a = 1", + ); + assert!(out.contains("(SELECT a FROM t WHERE a = 1)"), "{out}"); + } +} diff --git a/src/codegen/subquery/scalar.rs b/src/codegen/subquery/scalar.rs index 2ad6ee7c..6a8f6147 100644 --- a/src/codegen/subquery/scalar.rs +++ b/src/codegen/subquery/scalar.rs @@ -672,3 +672,255 @@ pub(crate) fn compile_in_subquery_multi( } Ok(()) } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::panic)] +mod tests { + use super::*; + use crate::codegen::select::compile_select_with_catalog; + use crate::parser::{parse_select, ParseOutcome}; + use crate::schema::{IndexSchema, IndexedColumn, TableSchema}; + + fn table(name: &str, root_page: u32, columns: &[&str], sql: &str) -> TableSchema { + TableSchema { + name: name.to_string(), + root_page, + columns: columns.iter().map(|c| c.to_string()).collect(), + without_rowid: false, + strict: false, + column_types: vec![String::new(); columns.len()], + column_collations: vec![], + is_virtual: false, + sql: sql.to_string(), + indexes: vec![], + rowid_alias: None, + } + .with_computed_rowid_alias() + } + + fn select(sql: &str) -> Select { + match parse_select(sql) { + ParseOutcome::Accepted(s) => *s, + other => panic!("failed to parse {sql:?}: {other:?}"), + } + } + + fn t() -> TableSchema { + table("t", 2, &["x"], "CREATE TABLE t(x)") + } + + fn s_rowid() -> TableSchema { + table( + "s", + 3, + &["id", "v"], + "CREATE TABLE s(id INTEGER PRIMARY KEY, v)", + ) + } + + fn s2_unique() -> TableSchema { + let mut s2 = table("s2", 4, &["k", "v"], "CREATE TABLE s2(k, v)"); + s2.indexes.push(IndexSchema { + name: "idx_k".to_string(), + unique: true, + columns: vec![IndexedColumn { + name: "k".to_string(), + desc: false, + collation: Collation::Binary, + }], + root_page: 5, + }); + s2 + } + + fn compile(sql: &str, catalog: &[TableSchema]) -> Result { + let sel = select(sql); + compile_select_with_catalog(&sel, &t(), catalog) + } + + fn opcodes(program: &crate::vdbe::Program) -> Vec { + program.instructions.iter().map(|i| i.opcode).collect() + } + + #[test] + fn scalar_subquery_plain_scan_with_where() { + let catalog = [t(), s_rowid()]; + let program = compile("SELECT (SELECT v FROM s WHERE v > 0) FROM t", &catalog).unwrap(); + let ops = opcodes(&program); + assert!(ops.contains(&Opcode::Rewind)); + assert!(ops.contains(&Opcode::Next)); + } + + #[test] + fn scalar_subquery_correlated_rowid_seek() { + let catalog = [t(), s_rowid()]; + let program = + compile("SELECT (SELECT v FROM s WHERE s.id = t.x) FROM t", &catalog).unwrap(); + let ops = opcodes(&program); + assert!(ops.contains(&Opcode::SeekRowid)); + } + + #[test] + fn scalar_subquery_correlated_unique_index_seek() { + let catalog = [t(), s2_unique()]; + let program = compile( + "SELECT (SELECT v FROM s2 WHERE s2.k = t.x) FROM t", + &catalog, + ) + .unwrap(); + let ops = opcodes(&program); + assert!(ops.contains(&Opcode::SeekIndexEq)); + assert!(ops.contains(&Opcode::IdxRowid)); + assert!(ops.contains(&Opcode::SeekRowid)); + } + + #[test] + fn scalar_subquery_with_aggregate() { + let catalog = [t(), s_rowid()]; + let program = compile("SELECT (SELECT max(v) FROM s) FROM t", &catalog).unwrap(); + assert!(!program.instructions.is_empty()); + } + + #[test] + fn scalar_subquery_from_less_computed_expression() { + let catalog = [t()]; + let program = compile("SELECT (SELECT 1 + 1)", &catalog).unwrap(); + assert!(opcodes(&program).contains(&Opcode::Copy)); + } + + #[test] + fn scalar_subquery_star_projection_is_unsupported() { + let catalog = [t(), s_rowid()]; + let err = compile("SELECT (SELECT * FROM s) FROM t", &catalog).unwrap_err(); + match err { + CodegenError::Unsupported { reason } => { + assert!(reason.contains("exactly one expression column")); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } + + #[test] + fn exists_plain_scan() { + let catalog = [t(), s_rowid()]; + let program = compile("SELECT x FROM t WHERE EXISTS (SELECT 1 FROM s)", &catalog).unwrap(); + let ops = opcodes(&program); + assert!(ops.contains(&Opcode::Rewind)); + } + + #[test] + fn not_exists_plain_scan_with_where() { + let catalog = [t(), s_rowid()]; + let program = compile( + "SELECT x FROM t WHERE NOT EXISTS (SELECT 1 FROM s WHERE v > 0)", + &catalog, + ) + .unwrap(); + let ops = opcodes(&program); + assert!(ops.contains(&Opcode::Rewind)); + assert!(ops.contains(&Opcode::Next)); + } + + #[test] + fn exists_correlated_rowid_seek() { + let catalog = [t(), s_rowid()]; + let program = compile( + "SELECT x FROM t WHERE EXISTS (SELECT 1 FROM s WHERE s.id = t.x)", + &catalog, + ) + .unwrap(); + let ops = opcodes(&program); + assert!(ops.contains(&Opcode::SeekRowid)); + } + + #[test] + fn not_exists_correlated_unique_index_seek() { + let catalog = [t(), s2_unique()]; + let program = compile( + "SELECT x FROM t WHERE NOT EXISTS (SELECT 1 FROM s2 WHERE s2.k = t.x)", + &catalog, + ) + .unwrap(); + let ops = opcodes(&program); + assert!(ops.contains(&Opcode::SeekIndexEq)); + } + + #[test] + fn exists_from_less_is_unsupported() { + let catalog = [t()]; + let err = compile("SELECT x FROM t WHERE EXISTS (SELECT 1)", &catalog).unwrap_err(); + match err { + CodegenError::Unsupported { reason } => { + assert!(reason.contains("EXISTS (SELECT ...) requires a FROM clause")); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } + + #[test] + fn in_subquery_hoisted_uncorrelated() { + let catalog = [t(), s_rowid()]; + let program = compile("SELECT x FROM t WHERE x IN (SELECT v FROM s)", &catalog).unwrap(); + let ops = opcodes(&program); + assert!(ops.contains(&Opcode::Found)); + assert!(ops.contains(&Opcode::OpenEphemeral)); + } + + #[test] + fn in_subquery_not_hoisted_when_not_a_bare_conjunct() { + let catalog = [t(), s_rowid()]; + let program = compile( + "SELECT x FROM t WHERE (x IN (SELECT v FROM s)) OR (x IS NULL)", + &catalog, + ) + .unwrap(); + let ops = opcodes(&program); + assert!(ops.contains(&Opcode::Found)); + assert!(ops.contains(&Opcode::Rewind)); + } + + #[test] + fn multi_column_in_subquery() { + let catalog = [t(), s_rowid()]; + let program = compile( + "SELECT x FROM t WHERE (x, x) IN (SELECT id, v FROM s)", + &catalog, + ) + .unwrap(); + let ops = opcodes(&program); + assert!(ops.contains(&Opcode::IdxInsert)); + assert!(ops.contains(&Opcode::Found)); + } + + #[test] + fn multi_column_in_subquery_arity_mismatch_is_unsupported() { + let catalog = [t(), s_rowid()]; + let err = compile( + "SELECT x FROM t WHERE (x, x) IN (SELECT id FROM s)", + &catalog, + ) + .unwrap_err(); + match err { + CodegenError::Unsupported { reason } => { + assert!(reason.contains("left-hand tuple has 2 column(s)")); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } + + #[test] + fn multi_column_in_subquery_star_is_unsupported() { + let catalog = [t(), s_rowid()]; + let err = compile( + "SELECT x FROM t WHERE (x, x) IN (SELECT * FROM s)", + &catalog, + ) + .unwrap_err(); + match err { + CodegenError::Unsupported { reason } => { + assert!(reason.contains("no * / table.*")); + } + other => panic!("expected Unsupported, got {other:?}"), + } + } +} diff --git a/src/integrity.rs b/src/integrity.rs index 688c7e45..7cbbf54d 100644 --- a/src/integrity.rs +++ b/src/integrity.rs @@ -318,3 +318,508 @@ fn check_freelist(source: &P, header: &DatabaseHeader, problems: )); } } + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::indexing_slicing, + clippy::panic, + clippy::arithmetic_side_effects +)] +mod tests { + use std::collections::HashMap; + use std::rc::Rc; + + use super::*; + use crate::record::TextEncoding; + use crate::schema::IndexedColumn; + use crate::vfs::{PageError, PageSource}; + + struct FakePageSource { + pages: HashMap>, + } + + impl PageSource for FakePageSource { + fn read_page(&self, page_num: u32) -> Result, PageError> { + self.pages + .get(&page_num) + .map(|page| Rc::from(page.as_slice())) + .ok_or(PageError::InvalidPageNumber) + } + } + + impl Clone for FakePageSource { + fn clone(&self) -> Self { + FakePageSource { + pages: self.pages.clone(), + } + } + } + + fn fake_header(page_count: u32) -> DatabaseHeader { + DatabaseHeader { + page_size: 512, + write_version: 1, + read_version: 1, + reserved_space: 0, + page_count, + freelist_trunk_page: 0, + freelist_page_count: 0, + schema_cookie: 0, + schema_format: 0, + largest_root_btree_page: 0, + text_encoding: TextEncoding::Utf8, + user_version: 0, + application_id: 0, + } + } + + fn table_schema(name: &str, root_page: u32, indexes: Vec) -> TableSchema { + TableSchema { + name: name.to_string(), + root_page, + columns: vec![], + column_types: vec![], + column_collations: vec![], + without_rowid: false, + strict: false, + is_virtual: false, + sql: String::new(), + indexes, + rowid_alias: None, + } + } + + fn index_schema(name: &str, root_page: u32) -> IndexSchema { + IndexSchema { + name: name.to_string(), + unique: false, + columns: vec![IndexedColumn { + name: "a".to_string(), + desc: false, + collation: crate::vdbe::Collation::Binary, + }], + root_page, + } + } + + // ---- compare_values / compare_index_keys ---- + + #[test] + fn compare_values_covers_every_type_pairing() { + use std::cmp::Ordering; + assert_eq!(compare_values(&Value::Null, &Value::Null), Ordering::Equal); + assert_eq!( + compare_values(&Value::Null, &Value::Integer(1)), + Ordering::Less + ); + assert_eq!( + compare_values(&Value::Integer(1), &Value::Null), + Ordering::Greater + ); + assert_eq!( + compare_values(&Value::Integer(1), &Value::Integer(2)), + Ordering::Less + ); + assert_eq!( + compare_values(&Value::Real(1.0), &Value::Real(2.0)), + Ordering::Less + ); + assert_eq!( + compare_values(&Value::Real(f64::NAN), &Value::Real(1.0)), + Ordering::Equal + ); + assert_eq!( + compare_values(&Value::Integer(1), &Value::Real(2.0)), + Ordering::Less + ); + assert_eq!( + compare_values(&Value::Real(2.0), &Value::Integer(1)), + Ordering::Greater + ); + assert_eq!( + compare_values(&Value::Text("a".into()), &Value::Text("b".into())), + Ordering::Less + ); + assert_eq!( + compare_values( + &Value::Blob(vec![1u8].into()), + &Value::Blob(vec![2u8].into()) + ), + Ordering::Less + ); + // Mismatched, non-NULL types fall through to the catch-all arm. + assert_eq!( + compare_values(&Value::Integer(1), &Value::Text("x".into())), + Ordering::Equal + ); + } + + #[test] + fn compare_index_keys_breaks_ties_by_length() { + use std::cmp::Ordering; + let a = vec![Value::Integer(1)]; + let b = vec![Value::Integer(1), Value::Integer(2)]; + assert_eq!(compare_index_keys(&a, &b), Ordering::Less); + assert_eq!(compare_index_keys(&b, &a), Ordering::Greater); + assert_eq!(compare_index_keys(&a, &a), Ordering::Equal); + } + + // ---- table b-tree page building ---- + + fn leaf_table_page(rows: &[i64]) -> Vec { + let page_size = 512usize; + let mut page = vec![0u8; page_size]; + page[0] = 0x0d; + page[3..5].copy_from_slice(&(rows.len() as u16).to_be_bytes()); + let ptr_base = 8usize; + let mut cursor = ptr_base + rows.len() * 2; + for (i, &rowid) in rows.iter().enumerate() { + let cell = vec![0u8, rowid as u8]; // payload_len=0, rowid (small, positive) + let start = cursor; + page[start..start + cell.len()].copy_from_slice(&cell); + page[ptr_base + i * 2..ptr_base + i * 2 + 2] + .copy_from_slice(&(start as u16).to_be_bytes()); + cursor += cell.len(); + } + page + } + + #[test] + fn check_table_reports_out_of_order_and_duplicate_rowids() { + let page = leaf_table_page(&[5, 3, 3]); + let mut pages = HashMap::new(); + pages.insert(7u32, page); + let source = FakePageSource { pages }; + let header = fake_header(10); + let table = table_schema("t", 7, vec![]); + let mut problems = Vec::new(); + + let rowids = check_table(&source, &header, &table, &mut problems); + + assert!(problems.iter().any(|p| p.contains("out of order"))); + assert!(problems.iter().any(|p| p.contains("duplicate rowid"))); + assert_eq!(rowids.len(), 2); + } + + #[test] + fn check_table_reports_cursor_error_on_missing_root_page() { + let source = FakePageSource { + pages: HashMap::new(), + }; + let header = fake_header(10); + let table = table_schema("missing", 99, vec![]); + let mut problems = Vec::new(); + + let rowids = check_table(&source, &header, &table, &mut problems); + + assert!(rowids.is_empty()); + assert_eq!(problems.len(), 1); + assert!(problems[0].contains("missing")); + } + + // ---- index b-tree page building ---- + + fn encode_record_ints(values: &[i64]) -> Vec { + let n = values.len(); + let header_size = 1 + n; + let mut out = Vec::with_capacity(header_size + n); + out.push(header_size as u8); + out.extend(std::iter::repeat_n(1u8, n)); // serial type 1: 8-bit signed int + for v in values { + out.push(*v as i8 as u8); + } + out + } + + fn leaf_index_page(rows: &[Vec]) -> Vec { + let page_size = 512usize; + let mut page = vec![0u8; page_size]; + page[0] = 0x0a; + page[3..5].copy_from_slice(&(rows.len() as u16).to_be_bytes()); + let ptr_base = 8usize; + let mut cursor = ptr_base + rows.len() * 2; + for (i, payload) in rows.iter().enumerate() { + let mut cell = vec![payload.len() as u8]; + cell.extend_from_slice(payload); + let start = cursor; + page[start..start + cell.len()].copy_from_slice(&cell); + page[ptr_base + i * 2..ptr_base + i * 2 + 2] + .copy_from_slice(&(start as u16).to_be_bytes()); + cursor += cell.len(); + } + page + } + + #[test] + fn check_index_reports_missing_trailing_rowid_and_cross_check() { + let entries = vec![ + encode_record_ints(&[9, 1]), + vec![2u8, 0u8], // header_size=2, one NULL column -> no trailing rowid + ]; + let page = leaf_index_page(&entries); + let mut pages = HashMap::new(); + pages.insert(8u32, page); + let source = FakePageSource { pages }; + let header = fake_header(10); + let table = table_schema("t", 7, vec![]); + let index = index_schema("t_a", 8); + let mut table_rowids = HashSet::new(); + table_rowids.insert(2i64); // does not contain rowid 1, referenced above + table_rowids.insert(3i64); // makes `seen` (1) mismatch table_rowids.len() (2) + let mut problems = Vec::new(); + + check_index( + &source, + &header, + &table, + &index, + &table_rowids, + &mut problems, + ); + + assert!(problems.iter().any(|p| p.contains("not present in table"))); + assert!(problems.iter().any(|p| p.contains("no trailing rowid"))); + assert!(problems.iter().any(|p| p.contains("wrong # of entries"))); + } + + #[test] + fn check_index_reports_out_of_order_keys() { + let entries = vec![encode_record_ints(&[5, 100]), encode_record_ints(&[3, 101])]; + let page = leaf_index_page(&entries); + let mut pages = HashMap::new(); + pages.insert(8u32, page); + let source = FakePageSource { pages }; + let header = fake_header(10); + let table = table_schema("t", 7, vec![]); + let index = index_schema("t_a", 8); + let mut table_rowids = HashSet::new(); + table_rowids.insert(100i64); + table_rowids.insert(101i64); + let mut problems = Vec::new(); + + check_index( + &source, + &header, + &table, + &index, + &table_rowids, + &mut problems, + ); + + assert!(problems.iter().any(|p| p.contains("keys out of order"))); + } + + #[test] + fn check_index_reports_malformed_entry() { + // header_len=5 but the payload is only 1 byte long, so the + // header-walk itself runs off the end of the buffer. + let entries = vec![vec![5u8]]; + let page = leaf_index_page(&entries); + let mut pages = HashMap::new(); + pages.insert(8u32, page); + let source = FakePageSource { pages }; + let header = fake_header(10); + let table = table_schema("t", 7, vec![]); + let index = index_schema("t_a", 8); + let table_rowids = HashSet::new(); + let mut problems = Vec::new(); + + check_index( + &source, + &header, + &table, + &index, + &table_rowids, + &mut problems, + ); + + assert!(problems.iter().any(|p| p.contains("malformed entry"))); + } + + #[test] + fn check_index_reports_cursor_error_on_missing_root_page() { + let source = FakePageSource { + pages: HashMap::new(), + }; + let header = fake_header(10); + let table = table_schema("t", 7, vec![]); + let index = index_schema("missing_idx", 99); + let table_rowids = HashSet::new(); + let mut problems = Vec::new(); + + check_index( + &source, + &header, + &table, + &index, + &table_rowids, + &mut problems, + ); + + assert_eq!(problems.len(), 1); + assert!(problems[0].contains("missing_idx")); + } + + // ---- run_integrity_check top-level ---- + + #[test] + fn run_integrity_check_flags_auto_vacuum_databases() { + let mut header = fake_header(1); + header.largest_root_btree_page = 3; + // Page 1 must at least parse as an empty leaf table (sqlite_master). + let mut page1 = vec![0u8; 512]; + page1[0] = 0x0d; + let mut pages = HashMap::new(); + pages.insert(1u32, page1); + let source = FakePageSource { pages }; + + let problems = run_integrity_check(source, &header, false); + + assert!(problems.iter().any(|p| p.contains("auto-vacuum"))); + } + + #[test] + fn run_integrity_check_reports_schema_read_failure() { + let header = fake_header(1); + let source = FakePageSource { + pages: HashMap::new(), + }; + + let problems = run_integrity_check(source, &header, false); + + assert_eq!(problems.len(), 1); + assert!(problems[0].contains("sqlite_master")); + } + + // ---- freelist ---- + + fn trunk_page(next_trunk: u32, leaves: &[u32]) -> Vec { + let mut buf = vec![0u8; 512]; + buf[0..4].copy_from_slice(&next_trunk.to_be_bytes()); + buf[4..8].copy_from_slice(&(leaves.len() as u32).to_be_bytes()); + for (i, leaf) in leaves.iter().enumerate() { + let off = 8 + i * 4; + buf[off..off + 4].copy_from_slice(&leaf.to_be_bytes()); + } + buf + } + + #[test] + fn check_freelist_reports_count_mismatch_when_no_trunk_page() { + let mut header = fake_header(5); + header.freelist_trunk_page = 0; + header.freelist_page_count = 3; + let source = FakePageSource { + pages: HashMap::new(), + }; + let mut problems = Vec::new(); + + check_freelist(&source, &header, &mut problems); + + assert_eq!(problems.len(), 1); + assert!(problems[0].contains("no freelist trunk page")); + } + + #[test] + fn check_freelist_is_silent_when_empty_and_consistent() { + let header = fake_header(5); + let source = FakePageSource { + pages: HashMap::new(), + }; + let mut problems = Vec::new(); + + check_freelist(&source, &header, &mut problems); + + assert!(problems.is_empty()); + } + + #[test] + fn check_freelist_reports_out_of_range_trunk() { + let mut header = fake_header(2); + header.freelist_trunk_page = 5; // > page_count + header.freelist_page_count = 1; + let source = FakePageSource { + pages: HashMap::new(), + }; + let mut problems = Vec::new(); + + check_freelist(&source, &header, &mut problems); + + assert!(problems + .iter() + .any(|p| p.contains("out of range or repeated"))); + } + + #[test] + fn check_freelist_reports_repeated_trunk_cycle() { + let mut header = fake_header(2); + header.freelist_trunk_page = 1; + header.freelist_page_count = 99; // deliberately wrong, also exercises the mismatch line + let mut pages = HashMap::new(); + pages.insert(1u32, trunk_page(2, &[])); + pages.insert(2u32, trunk_page(1, &[])); + let source = FakePageSource { pages }; + let mut problems = Vec::new(); + + check_freelist(&source, &header, &mut problems); + + assert!(problems + .iter() + .any(|p| p.contains("out of range or repeated"))); + assert!(problems + .iter() + .any(|p| p.contains("but the trunk chain has"))); + } + + #[test] + fn check_freelist_reports_read_error_on_missing_trunk_page() { + let mut header = fake_header(5); + header.freelist_trunk_page = 2; + header.freelist_page_count = 1; + let source = FakePageSource { + pages: HashMap::new(), + }; + let mut problems = Vec::new(); + + check_freelist(&source, &header, &mut problems); + + assert!(problems + .iter() + .any(|p| p.contains("reading freelist trunk page"))); + } + + #[test] + fn check_freelist_reports_parse_error_on_truncated_trunk_page() { + let mut header = fake_header(5); + header.freelist_trunk_page = 2; + header.freelist_page_count = 1; + let mut pages = HashMap::new(); + pages.insert(2u32, vec![0u8; 4]); // too short for the 8-byte trunk header + let source = FakePageSource { pages }; + let mut problems = Vec::new(); + + check_freelist(&source, &header, &mut problems); + + assert!(problems + .iter() + .any(|p| p.contains("parsing freelist trunk page"))); + } + + #[test] + fn check_freelist_reports_out_of_range_leaf() { + let mut header = fake_header(2); + header.freelist_trunk_page = 1; + header.freelist_page_count = 2; + let mut pages = HashMap::new(); + pages.insert(1u32, trunk_page(0, &[999])); + let source = FakePageSource { pages }; + let mut problems = Vec::new(); + + check_freelist(&source, &header, &mut problems); + + assert!(problems + .iter() + .any(|p| p.contains("freelist leaf page 999 is out of range"))); + } +} diff --git a/src/pager/checkpoint.rs b/src/pager/checkpoint.rs index 569c3f36..7e5c8dd9 100644 --- a/src/pager/checkpoint.rs +++ b/src/pager/checkpoint.rs @@ -194,7 +194,7 @@ pub fn checkpoint_passive( } #[cfg(test)] -#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::panic)] mod tests { use super::*; use crate::pager::wal::WalWriter; @@ -318,4 +318,61 @@ mod tests { release_all(held); std::fs::remove_dir_all(&dir).unwrap(); } + + #[test] + fn page_size_mismatch_is_an_error() { + let (vfs, db_path) = setup(512); + let wal_path = companion_path(&db_path, "-wal"); + let header = WalHeader::new(true, 512, 0x5555, 0x6666, 1); + let mut writer = WalWriter::create(&vfs, &wal_path, header).unwrap(); + writer.append_frame(1, &vec![0xAAu8; 512], 1).unwrap(); + writer.sync().unwrap(); + + let err = checkpoint_passive(&vfs, &db_path, 1024).unwrap_err(); + match err { + PagerError::Wal { source, .. } => { + assert!(matches!( + source, + wal::WalError::InvalidPageSize { page_size: 512 } + )); + } + other => panic!("expected PagerError::Wal, got {other:?}"), + } + } + + #[test] + fn empty_wal_with_header_only_is_a_complete_no_op() { + let (vfs, db_path) = setup(512); + let wal_path = companion_path(&db_path, "-wal"); + let header = WalHeader::new(true, 512, 0x7777, 0x8888, 1); + let writer = WalWriter::create(&vfs, &wal_path, header).unwrap(); + writer.sync().unwrap(); + + let result = checkpoint_passive(&vfs, &db_path, 512).unwrap(); + assert_eq!( + result, + CheckpointResult { + backfilled_frames: 0, + total_frames: 0, + checkpoint_complete: true, + } + ); + } + + #[test] + fn second_pass_with_no_new_frames_is_a_no_op() { + let (vfs, db_path) = setup(512); + let wal_path = companion_path(&db_path, "-wal"); + let header = WalHeader::new(true, 512, 0x9999, 0xAAAA, 1); + let mut writer = WalWriter::create(&vfs, &wal_path, header).unwrap(); + writer.append_frame(1, &vec![0xDDu8; 512], 1).unwrap(); + writer.sync().unwrap(); + + let first = checkpoint_passive(&vfs, &db_path, 512).unwrap(); + assert_eq!(first.backfilled_frames, 1); + assert!(first.checkpoint_complete); + + let second = checkpoint_passive(&vfs, &db_path, 512).unwrap(); + assert_eq!(second, first); + } } diff --git a/src/pager/error.rs b/src/pager/error.rs index 7b52086d..b35ff651 100644 --- a/src/pager/error.rs +++ b/src/pager/error.rs @@ -89,3 +89,74 @@ impl From for PagerError { PagerError::Freelist(source) } } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + #[test] + fn display_all_variants() { + assert!(PagerError::HotJournal { + path: "db.sqlite-journal".to_string() + } + .to_string() + .contains("hot rollback journal present at db.sqlite-journal")); + assert!(PagerError::Journal(JournalError::HeaderTooShort(3)) + .to_string() + .contains("rollback journal is corrupt")); + assert!(PagerError::Wal { + path: "db.sqlite-wal".to_string(), + source: WalError::HeaderTooShort { len: 2 }, + } + .to_string() + .contains("reading WAL at db.sqlite-wal")); + assert!( + PagerError::Page(PageError::InvalidPageNumber) + .to_string() + .contains("page number") + || !PagerError::Page(PageError::InvalidPageNumber) + .to_string() + .is_empty() + ); + assert!(!PagerError::Vfs(VfsError::NotFound { + path: "x".to_string() + }) + .to_string() + .is_empty()); + assert!( + !PagerError::Freelist(FreelistError::PageTooShort { offset: 1, len: 2 }) + .to_string() + .is_empty() + ); + assert_eq!( + PagerError::PendingTransaction.to_string(), + "cannot change journal_mode with a pending transaction" + ); + assert_eq!( + PagerError::CheckpointIncomplete.to_string(), + "checkpoint did not fully back-fill the WAL while switching journal_mode out of WAL" + ); + } + + #[test] + fn from_conversions() { + let e: PagerError = PageError::InvalidPageNumber.into(); + assert!(matches!(e, PagerError::Page(PageError::InvalidPageNumber))); + + let e: PagerError = VfsError::NotFound { + path: "x".to_string(), + } + .into(); + assert!(matches!(e, PagerError::Vfs(VfsError::NotFound { .. }))); + + let e: PagerError = FreelistError::PageTooShort { offset: 1, len: 2 }.into(); + assert!(matches!(e, PagerError::Freelist(_))); + } + + #[test] + fn implements_std_error() { + let err = PagerError::PendingTransaction; + assert!(std::error::Error::source(&err).is_none()); + } +} diff --git a/src/parser/error.rs b/src/parser/error.rs index 3c137c82..da2cf4dc 100644 --- a/src/parser/error.rs +++ b/src/parser/error.rs @@ -391,3 +391,203 @@ pub fn parse_analyze(src: &str) -> ParseOutcome { Err(ParseFail::Invalid { message, span }) => ParseOutcome::Invalid { message, span }, } } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::panic)] +mod tests { + use super::*; + + fn assert_accepted(outcome: ParseOutcome, src: &str) { + match outcome { + ParseOutcome::Accepted(_) => {} + other => panic!("expected accepted for {src:?}, got {other:?}"), + } + } + + fn assert_unsupported(outcome: ParseOutcome, src: &str) { + match outcome { + ParseOutcome::Unsupported { .. } => {} + other => panic!("expected unsupported for {src:?}, got {other:?}"), + } + } + + fn assert_invalid(outcome: ParseOutcome, src: &str) { + match outcome { + ParseOutcome::Invalid { .. } => {} + other => panic!("expected invalid for {src:?}, got {other:?}"), + } + } + + #[test] + fn select_three_way_outcome() { + assert_accepted(parse_select("SELECT 1"), "SELECT 1"); + assert_unsupported( + parse_select("SELECT 1 UNION SELECT 2 INTERSECT SELECT 3"), + "SELECT 1 UNION SELECT 2 INTERSECT SELECT 3", + ); + assert_invalid(parse_select("SELECT FROM t"), "SELECT FROM t"); + } + + #[test] + fn explain_three_way_outcome() { + assert_accepted( + parse_explain("EXPLAIN QUERY PLAN SELECT 1"), + "EXPLAIN QUERY PLAN SELECT 1", + ); + assert_unsupported( + parse_explain("EXPLAIN QUERY PLAN SELECT 1 UNION SELECT 2 INTERSECT SELECT 3"), + "EXPLAIN QUERY PLAN SELECT 1 UNION SELECT 2 INTERSECT SELECT 3", + ); + assert_invalid(parse_explain("EXPLAIN QUERY PLAN"), "EXPLAIN QUERY PLAN"); + } + + #[test] + fn insert_three_way_outcome() { + assert_accepted( + parse_insert("INSERT INTO t VALUES (1, 2)"), + "INSERT INTO t VALUES (1, 2)", + ); + assert_unsupported( + parse_insert("INSERT INTO t VALUES (CURRENT_TIMESTAMP)"), + "INSERT INTO t VALUES (CURRENT_TIMESTAMP)", + ); + assert_invalid(parse_insert("INSERT INTO t"), "INSERT INTO t"); + } + + #[test] + fn delete_three_way_outcome() { + assert_accepted(parse_delete("DELETE FROM t"), "DELETE FROM t"); + assert_unsupported( + parse_delete("DELETE FROM t UNION SELECT 1"), + "DELETE FROM t UNION SELECT 1", + ); + assert_invalid(parse_delete("DELETE FROM"), "DELETE FROM"); + } + + #[test] + fn update_three_way_outcome() { + assert_accepted( + parse_update("UPDATE t1 SET x=1 WHERE x>0"), + "UPDATE t1 SET x=1 WHERE x>0", + ); + assert_unsupported( + parse_update("UPDATE t1 SET x=1 UNION SELECT 1"), + "UPDATE t1 SET x=1 UNION SELECT 1", + ); + assert_invalid(parse_update("UPDATE t1 SET"), "UPDATE t1 SET"); + } + + #[test] + fn create_table_three_way_outcome() { + assert_accepted( + parse_create_table("CREATE TABLE t (a INTEGER, b TEXT)"), + "CREATE TABLE t (a INTEGER, b TEXT)", + ); + assert_unsupported( + parse_create_table("CREATE TEMP TABLE t (a)"), + "CREATE TEMP TABLE t (a)", + ); + assert_invalid( + parse_create_table("CREATE TABLE t a INTEGER)"), + "CREATE TABLE t a INTEGER)", + ); + } + + #[test] + fn create_index_three_way_outcome() { + assert_accepted( + parse_create_index("CREATE INDEX i ON t (a)"), + "CREATE INDEX i ON t (a)", + ); + assert_unsupported( + parse_create_index("CREATE INDEX i ON t (a) UNION SELECT 1"), + "CREATE INDEX i ON t (a) UNION SELECT 1", + ); + assert_invalid(parse_create_index("CREATE INDEX"), "CREATE INDEX"); + } + + #[test] + fn create_view_three_way_outcome() { + assert_accepted( + parse_create_view("CREATE VIEW v AS SELECT 1"), + "CREATE VIEW v AS SELECT 1", + ); + assert_unsupported( + parse_create_view("CREATE TEMP VIEW v AS SELECT 1"), + "CREATE TEMP VIEW v AS SELECT 1", + ); + assert_invalid(parse_create_view("CREATE VIEW"), "CREATE VIEW"); + } + + #[test] + fn drop_view_three_way_outcome() { + assert_accepted(parse_drop_view("DROP VIEW v"), "DROP VIEW v"); + assert_unsupported( + parse_drop_view("DROP VIEW v UNION SELECT 1"), + "DROP VIEW v UNION SELECT 1", + ); + assert_invalid(parse_drop_view("DROP VIEW"), "DROP VIEW"); + } + + #[test] + fn drop_table_three_way_outcome() { + assert_accepted(parse_drop_table("DROP TABLE t"), "DROP TABLE t"); + assert_unsupported( + parse_drop_table("DROP TABLE t UNION SELECT 1"), + "DROP TABLE t UNION SELECT 1", + ); + assert_invalid(parse_drop_table("DROP TABLE"), "DROP TABLE"); + } + + #[test] + fn drop_index_three_way_outcome() { + assert_accepted(parse_drop_index("DROP INDEX i"), "DROP INDEX i"); + assert_unsupported( + parse_drop_index("DROP INDEX i UNION SELECT 1"), + "DROP INDEX i UNION SELECT 1", + ); + assert_invalid(parse_drop_index("DROP INDEX"), "DROP INDEX"); + } + + #[test] + fn begin_three_way_outcome() { + assert_accepted(parse_begin("BEGIN"), "BEGIN"); + assert_invalid( + parse_begin("BEGIN TRANSACTION EXTRA"), + "BEGIN TRANSACTION EXTRA", + ); + } + + #[test] + fn commit_three_way_outcome() { + assert_accepted(parse_commit("COMMIT"), "COMMIT"); + assert_invalid(parse_commit("COMMIT EXTRA"), "COMMIT EXTRA"); + } + + #[test] + fn rollback_three_way_outcome() { + assert_accepted(parse_rollback("ROLLBACK"), "ROLLBACK"); + assert_invalid(parse_rollback("ROLLBACK EXTRA"), "ROLLBACK EXTRA"); + } + + #[test] + fn pragma_three_way_outcome() { + assert_accepted( + parse_pragma("PRAGMA journal_mode = WAL"), + "PRAGMA journal_mode = WAL", + ); + assert_unsupported( + parse_pragma("PRAGMA cache_size = 10"), + "PRAGMA cache_size = 10", + ); + assert_invalid(parse_pragma("PRAGMA"), "PRAGMA"); + } + + #[test] + fn analyze_three_way_outcome() { + assert_accepted(parse_analyze("ANALYZE"), "ANALYZE"); + assert_accepted(parse_analyze("ANALYZE t"), "ANALYZE t"); + assert_unsupported(parse_analyze("ANALYZE main.t"), "ANALYZE main.t"); + assert_invalid(parse_analyze("ANALYZE 123"), "ANALYZE 123"); + } +} diff --git a/src/parser/printer.rs b/src/parser/printer.rs index 99f79aeb..6a974b54 100644 --- a/src/parser/printer.rs +++ b/src/parser/printer.rs @@ -712,3 +712,370 @@ impl fmt::Display for ParamKind { } } } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::panic)] +mod tests { + use super::super::error::{ + parse_begin, parse_commit, parse_create_index, parse_create_table, parse_create_view, + parse_delete, parse_drop_index, parse_drop_table, parse_drop_view, parse_insert, + parse_rollback, parse_select, ParseOutcome, + }; + + fn ok_select(sql: &str) -> String { + match parse_select(sql) { + ParseOutcome::Accepted(select) => select.to_string(), + other => panic!("expected accepted select, got {other:?}"), + } + } + + #[test] + fn select_distinct_all_group_having_order_limit_offset() { + assert_eq!( + ok_select( + "SELECT DISTINCT a, b FROM t WHERE a > 1 GROUP BY a HAVING b < 2 \ + ORDER BY a ASC NULLS FIRST, b DESC NULLS LAST LIMIT 5 OFFSET 10" + ), + "SELECT DISTINCT a, b FROM t WHERE a > 1 GROUP BY a HAVING b < 2 \ + ORDER BY a ASC NULLS FIRST, b DESC NULLS LAST LIMIT 5 OFFSET 10" + ); + assert_eq!(ok_select("SELECT ALL a FROM t"), "SELECT ALL a FROM t"); + } + + #[test] + fn select_with_clause_and_cte_columns() { + assert_eq!( + ok_select("WITH x (a, b) AS (SELECT 1, 2), y AS (SELECT 3) SELECT * FROM x"), + "WITH x (a, b) AS (SELECT 1, 2), y AS (SELECT 3) SELECT * FROM x" + ); + } + + #[test] + fn compound_select_union_and_union_all() { + assert_eq!( + ok_select("SELECT a FROM t UNION SELECT b FROM u"), + "SELECT a FROM t UNION SELECT b FROM u" + ); + assert_eq!( + ok_select("SELECT a FROM t UNION ALL SELECT DISTINCT b FROM u WHERE b > 0 GROUP BY b HAVING b < 9"), + "SELECT a FROM t UNION ALL SELECT DISTINCT b FROM u WHERE b > 0 GROUP BY b HAVING b < 9" + ); + } + + #[test] + fn result_column_star_table_star_and_alias() { + assert_eq!(ok_select("SELECT * FROM t"), "SELECT * FROM t"); + assert_eq!(ok_select("SELECT t.* FROM t"), "SELECT t.* FROM t"); + assert_eq!(ok_select("SELECT a AS x FROM t"), "SELECT a AS x FROM t"); + } + + #[test] + fn from_clause_subquery_alias_and_joins() { + assert_eq!( + ok_select("SELECT * FROM (SELECT 1) AS sub"), + "SELECT * FROM (SELECT 1) AS sub" + ); + assert_eq!( + ok_select("SELECT * FROM a NATURAL JOIN b"), + "SELECT * FROM a NATURAL JOIN b" + ); + assert_eq!( + ok_select("SELECT * FROM a LEFT JOIN b ON a.x = b.x"), + "SELECT * FROM a LEFT JOIN b ON a.x = b.x" + ); + assert_eq!( + ok_select("SELECT * FROM a CROSS JOIN b USING (x)"), + "SELECT * FROM a CROSS JOIN b USING (x)" + ); + assert_eq!( + ok_select("SELECT * FROM a RIGHT JOIN b ON a.x = b.x"), + "SELECT * FROM a RIGHT JOIN b ON a.x = b.x" + ); + assert_eq!( + ok_select("SELECT * FROM a FULL JOIN b ON a.x = b.x"), + "SELECT * FROM a FULL JOIN b ON a.x = b.x" + ); + assert_eq!( + ok_select("SELECT * FROM a JOIN b ON a.x = b.x"), + "SELECT * FROM a JOIN b ON a.x = b.x" + ); + } + + #[test] + fn expr_display_covers_all_kinds() { + assert_eq!(ok_select("SELECT 1"), "SELECT 1"); + assert_eq!(ok_select("SELECT ?"), "SELECT ?"); + assert_eq!(ok_select("SELECT ?1"), "SELECT ?1"); + assert_eq!(ok_select("SELECT :name"), "SELECT :name"); + assert_eq!(ok_select("SELECT @name"), "SELECT @name"); + assert_eq!(ok_select("SELECT $name"), "SELECT $name"); + assert_eq!(ok_select("SELECT db.tbl.col"), "SELECT db.tbl.col"); + assert_eq!(ok_select("SELECT tbl.col"), "SELECT tbl.col"); + assert_eq!(ok_select("SELECT col"), "SELECT col"); + assert_eq!( + ok_select("SELECT count(DISTINCT a, b)"), + "SELECT count(DISTINCT a, b)" + ); + assert_eq!(ok_select("SELECT count(*)"), "SELECT count(*)"); + assert_eq!(ok_select("SELECT NOT a"), "SELECT NOT a"); + assert_eq!(ok_select("SELECT +a"), "SELECT +a"); + assert_eq!(ok_select("SELECT -a"), "SELECT -a"); + assert_eq!(ok_select("SELECT ~a"), "SELECT ~a"); + assert_eq!(ok_select("SELECT a AND b"), "SELECT a AND b"); + assert_eq!(ok_select("SELECT a OR b"), "SELECT a OR b"); + assert_eq!(ok_select("SELECT a = b"), "SELECT a = b"); + assert_eq!(ok_select("SELECT a != b"), "SELECT a != b"); + assert_eq!(ok_select("SELECT a < b"), "SELECT a < b"); + assert_eq!(ok_select("SELECT a <= b"), "SELECT a <= b"); + assert_eq!(ok_select("SELECT a > b"), "SELECT a > b"); + assert_eq!(ok_select("SELECT a >= b"), "SELECT a >= b"); + assert_eq!(ok_select("SELECT a & b"), "SELECT a & b"); + assert_eq!(ok_select("SELECT a | b"), "SELECT a | b"); + assert_eq!(ok_select("SELECT a << b"), "SELECT a << b"); + assert_eq!(ok_select("SELECT a >> b"), "SELECT a >> b"); + assert_eq!(ok_select("SELECT a + b"), "SELECT a + b"); + assert_eq!(ok_select("SELECT a - b"), "SELECT a - b"); + assert_eq!(ok_select("SELECT a * b"), "SELECT a * b"); + assert_eq!(ok_select("SELECT a / b"), "SELECT a / b"); + assert_eq!(ok_select("SELECT a % b"), "SELECT a % b"); + assert_eq!(ok_select("SELECT a || b"), "SELECT a || b"); + assert_eq!(ok_select("SELECT a IS b"), "SELECT a IS b"); + assert_eq!(ok_select("SELECT a IS NOT b"), "SELECT a IS NOT b"); + assert_eq!(ok_select("SELECT a ISNULL"), "SELECT a ISNULL"); + assert_eq!(ok_select("SELECT a NOTNULL"), "SELECT a NOTNULL"); + assert_eq!( + ok_select("SELECT a BETWEEN 1 AND 2"), + "SELECT a BETWEEN 1 AND 2" + ); + assert_eq!( + ok_select("SELECT a NOT BETWEEN 1 AND 2"), + "SELECT a NOT BETWEEN 1 AND 2" + ); + assert_eq!(ok_select("SELECT a IN (1, 2)"), "SELECT a IN (1, 2)"); + assert_eq!( + ok_select("SELECT a NOT IN (1, 2)"), + "SELECT a NOT IN (1, 2)" + ); + assert_eq!(ok_select("SELECT a LIKE 'x'"), "SELECT a LIKE 'x'"); + assert_eq!(ok_select("SELECT a NOT LIKE 'x'"), "SELECT a NOT LIKE 'x'"); + assert_eq!(ok_select("SELECT a GLOB 'x'"), "SELECT a GLOB 'x'"); + assert_eq!( + ok_select("SELECT a LIKE 'x' ESCAPE '\\'"), + "SELECT a LIKE 'x' ESCAPE '\\'" + ); + assert_eq!( + ok_select("SELECT CASE a WHEN 1 THEN 'x' ELSE 'y' END"), + "SELECT CASE a WHEN 1 THEN 'x' ELSE 'y' END" + ); + assert_eq!( + ok_select("SELECT CASE WHEN a THEN 'x' END"), + "SELECT CASE WHEN a THEN 'x' END" + ); + assert_eq!( + ok_select("SELECT CAST(a AS INTEGER)"), + "SELECT CAST(a AS INTEGER)" + ); + assert_eq!(ok_select("SELECT a COLLATE bin"), "SELECT a COLLATE bin"); + assert_eq!(ok_select("SELECT (a)"), "SELECT (a)"); + assert_eq!(ok_select("SELECT (SELECT 1)"), "SELECT (SELECT 1)"); + assert_eq!( + ok_select("SELECT EXISTS (SELECT 1)"), + "SELECT EXISTS (SELECT 1)" + ); + assert_eq!( + ok_select("SELECT NOT EXISTS (SELECT 1)"), + "SELECT NOT EXISTS (SELECT 1)" + ); + assert_eq!( + ok_select("SELECT a IN (SELECT 1)"), + "SELECT a IN (SELECT 1)" + ); + assert_eq!( + ok_select("SELECT a NOT IN (SELECT 1)"), + "SELECT a NOT IN (SELECT 1)" + ); + assert_eq!( + ok_select("SELECT (a, b) IN (SELECT 1, 2)"), + "SELECT (a, b) IN (SELECT 1, 2)" + ); + assert_eq!( + ok_select("SELECT (a, b) NOT IN (SELECT 1, 2)"), + "SELECT (a, b) NOT IN (SELECT 1, 2)" + ); + } + + #[test] + fn literal_display_covers_all_kinds() { + assert_eq!(ok_select("SELECT 1.5"), "SELECT 1.5"); + assert_eq!(ok_select("SELECT 'it''s'"), "SELECT 'it''s'"); + assert_eq!(ok_select("SELECT x'AB01'"), "SELECT X'AB01'"); + assert_eq!(ok_select("SELECT NULL"), "SELECT NULL"); + assert_eq!(ok_select("SELECT TRUE"), "SELECT TRUE"); + assert_eq!(ok_select("SELECT FALSE"), "SELECT FALSE"); + } + + #[test] + fn insert_variants() { + match parse_insert("INSERT OR REPLACE INTO t (a, b) VALUES (1, 2), (3, 4)") { + ParseOutcome::Accepted(insert) => assert_eq!( + insert.to_string(), + "INSERT OR REPLACE INTO t (a, b) VALUES (1, 2), (3, 4)" + ), + other => panic!("{other:?}"), + } + for (action, sql) in [ + ("IGNORE", "INSERT OR IGNORE INTO t DEFAULT VALUES"), + ("ABORT", "INSERT OR ABORT INTO t DEFAULT VALUES"), + ("ROLLBACK", "INSERT OR ROLLBACK INTO t DEFAULT VALUES"), + ("FAIL", "INSERT OR FAIL INTO t DEFAULT VALUES"), + ] { + match parse_insert(sql) { + ParseOutcome::Accepted(insert) => { + assert!(insert.to_string().contains(action)); + assert!(insert.to_string().contains("DEFAULT VALUES")); + } + other => panic!("{other:?}"), + } + } + match parse_insert("INSERT INTO t SELECT * FROM u") { + ParseOutcome::Accepted(insert) => { + assert_eq!(insert.to_string(), "INSERT INTO t SELECT * FROM u"); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn delete_display() { + match parse_delete("DELETE FROM t WHERE a = 1") { + ParseOutcome::Accepted(delete) => { + assert_eq!(delete.to_string(), "DELETE FROM t WHERE a = 1"); + } + other => panic!("{other:?}"), + } + match parse_delete("DELETE FROM t") { + ParseOutcome::Accepted(delete) => assert_eq!(delete.to_string(), "DELETE FROM t"), + other => panic!("{other:?}"), + } + } + + #[test] + fn create_table_variants() { + match parse_create_table( + "CREATE TABLE IF NOT EXISTS t (a INTEGER PRIMARY KEY ASC AUTOINCREMENT NOT NULL UNIQUE DEFAULT (1) CHECK (a > 0) COLLATE bin, UNIQUE (a, b), PRIMARY KEY (a), CHECK (a > 0)) WITHOUT ROWID", + ) { + ParseOutcome::Accepted(ct) => { + let s = ct.to_string(); + assert!(s.starts_with("CREATE TABLE IF NOT EXISTS t (")); + assert!(s.contains("PRIMARY KEY ASC AUTOINCREMENT")); + assert!(s.contains("NOT NULL")); + assert!(s.contains("UNIQUE")); + assert!(s.contains("DEFAULT (1)")); + assert!(s.contains("CHECK (a > 0)")); + assert!(s.contains("COLLATE bin")); + assert!(s.contains("WITHOUT ROWID")); + } + other => panic!("{other:?}"), + } + match parse_create_table("CREATE TABLE t (a INTEGER, b TEXT) STRICT") { + ParseOutcome::Accepted(ct) => { + assert_eq!(ct.to_string(), "CREATE TABLE t (a INTEGER, b TEXT) STRICT"); + } + other => panic!("{other:?}"), + } + match parse_create_table("CREATE TABLE t (a, b PRIMARY KEY DESC)") { + ParseOutcome::Accepted(ct) => { + assert_eq!(ct.to_string(), "CREATE TABLE t (a, b PRIMARY KEY DESC)"); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn create_index_and_drop_variants() { + match parse_create_index( + "CREATE UNIQUE INDEX IF NOT EXISTS idx ON t (a ASC, b DESC) WHERE a > 0", + ) { + ParseOutcome::Accepted(ci) => assert_eq!( + ci.to_string(), + "CREATE UNIQUE INDEX IF NOT EXISTS idx ON t (a ASC, b DESC) WHERE a > 0" + ), + other => panic!("{other:?}"), + } + match parse_create_index("CREATE INDEX idx ON t (a)") { + ParseOutcome::Accepted(ci) => { + assert_eq!(ci.to_string(), "CREATE INDEX idx ON t (a)"); + } + other => panic!("{other:?}"), + } + match parse_drop_index("DROP INDEX IF EXISTS idx") { + ParseOutcome::Accepted(di) => { + assert_eq!(di.to_string(), "DROP INDEX IF EXISTS idx"); + } + other => panic!("{other:?}"), + } + match parse_drop_index("DROP INDEX idx") { + ParseOutcome::Accepted(di) => assert_eq!(di.to_string(), "DROP INDEX idx"), + other => panic!("{other:?}"), + } + match parse_drop_table("DROP TABLE IF EXISTS t") { + ParseOutcome::Accepted(dt) => assert_eq!(dt.to_string(), "DROP TABLE IF EXISTS t"), + other => panic!("{other:?}"), + } + match parse_drop_table("DROP TABLE t") { + ParseOutcome::Accepted(dt) => assert_eq!(dt.to_string(), "DROP TABLE t"), + other => panic!("{other:?}"), + } + } + + #[test] + fn create_view_and_drop_view_variants() { + match parse_create_view("CREATE VIEW IF NOT EXISTS v (a, b) AS SELECT 1, 2") { + ParseOutcome::Accepted(cv) => assert_eq!( + cv.to_string(), + "CREATE VIEW IF NOT EXISTS v (a, b) AS SELECT 1, 2" + ), + other => panic!("{other:?}"), + } + match parse_create_view("CREATE VIEW v AS SELECT 1") { + ParseOutcome::Accepted(cv) => { + assert_eq!(cv.to_string(), "CREATE VIEW v AS SELECT 1"); + } + other => panic!("{other:?}"), + } + match parse_drop_view("DROP VIEW IF EXISTS v") { + ParseOutcome::Accepted(dv) => assert_eq!(dv.to_string(), "DROP VIEW IF EXISTS v"), + other => panic!("{other:?}"), + } + match parse_drop_view("DROP VIEW v") { + ParseOutcome::Accepted(dv) => assert_eq!(dv.to_string(), "DROP VIEW v"), + other => panic!("{other:?}"), + } + } + + #[test] + fn transaction_statements() { + match parse_begin("BEGIN") { + ParseOutcome::Accepted(b) => assert_eq!(b.to_string(), "BEGIN"), + other => panic!("{other:?}"), + } + for (mode, sql) in [ + ("DEFERRED", "BEGIN DEFERRED"), + ("IMMEDIATE", "BEGIN IMMEDIATE"), + ("EXCLUSIVE", "BEGIN EXCLUSIVE"), + ] { + match parse_begin(sql) { + ParseOutcome::Accepted(b) => assert_eq!(b.to_string(), format!("BEGIN {mode}")), + other => panic!("{other:?}"), + } + } + match parse_commit("COMMIT") { + ParseOutcome::Accepted(c) => assert_eq!(c.to_string(), "COMMIT"), + other => panic!("{other:?}"), + } + match parse_rollback("ROLLBACK") { + ParseOutcome::Accepted(r) => assert_eq!(r.to_string(), "ROLLBACK"), + other => panic!("{other:?}"), + } + } +} diff --git a/src/record/error.rs b/src/record/error.rs index cbd314f4..ccbfd229 100644 --- a/src/record/error.rs +++ b/src/record/error.rs @@ -67,3 +67,58 @@ impl std::fmt::Display for RecordError { } impl std::error::Error for RecordError {} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + #[test] + fn display_all_variants() { + assert_eq!( + RecordError::UnexpectedEof { offset: 5 }.to_string(), + "unexpected end of input at byte offset 5" + ); + assert_eq!( + RecordError::HeaderTooShort { + declared: 1, + varint_len: 2 + } + .to_string(), + "record header length 1 is shorter than its own header-length varint (2 bytes)" + ); + assert_eq!( + RecordError::HeaderOverrun { + offset: 3, + header_len: 4 + } + .to_string(), + "record header entry at offset 3 extends past the declared header length 4" + ); + assert_eq!( + RecordError::TrailingData { trailing: 7 }.to_string(), + "record has 7 unconsumed trailing byte(s) after decoding all columns" + ); + assert_eq!( + RecordError::InvalidUtf8.to_string(), + "invalid UTF-8 in text value" + ); + assert_eq!( + RecordError::InvalidUtf16.to_string(), + "invalid UTF-16 in text value" + ); + } + + #[test] + fn debug_and_eq() { + let err = RecordError::InvalidUtf8; + assert_eq!(err, RecordError::InvalidUtf8); + assert_ne!(format!("{err:?}"), ""); + } + + #[test] + fn implements_std_error() { + let err = RecordError::InvalidUtf8; + assert!(std::error::Error::source(&err).is_none()); + } +} diff --git a/src/sys/termios.rs b/src/sys/termios.rs index 96ef4ab9..2ffe70fd 100644 --- a/src/sys/termios.rs +++ b/src/sys/termios.rs @@ -155,6 +155,8 @@ pub fn cfmakeraw_call(t: &mut termios) { #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { + use std::os::fd::AsFd; + use super::*; // `ICANON`/`ECHO` (`c_lflag` bits `cfmakeraw` must clear) are at @@ -178,4 +180,34 @@ mod tests { assert_eq!(t.c_lflag as u64 & ICANON, 0, "ICANON must be cleared"); assert_eq!(t.c_lflag as u64 & ECHO, 0, "ECHO must be cleared"); } + + #[test] + fn stdin_fd_borrows_fd_zero() { + assert_eq!(stdin_fd().as_raw_fd(), 0); + } + + // A regular file's fd is valid but not a tty, so `tcgetattr`/ + // `tcsetattr` fail with `ENOTTY` — exercising the `ret == -1` error + // branch without needing a real controlling tty. + #[test] + fn tcgetattr_call_errors_on_non_tty_fd() { + let file = std::fs::File::open("Cargo.toml").unwrap(); + let fd = file.as_fd(); + assert!(tcgetattr_call(fd).is_err()); + } + + #[test] + fn tcsetattr_call_errors_on_non_tty_fd() { + let file = std::fs::File::open("Cargo.toml").unwrap(); + let fd = file.as_fd(); + let t: termios = unsafe { std::mem::zeroed() }; + assert!(tcsetattr_call(fd, SetArg::TCSANOW, &t).is_err()); + assert!(tcsetattr_call(fd, SetArg::TCSAFLUSH, &t).is_err()); + } + + #[test] + fn is_tty_false_on_non_tty_fd() { + let file = std::fs::File::open("Cargo.toml").unwrap(); + assert!(!is_tty(file.as_fd())); + } } diff --git a/src/vdbe/cursor.rs b/src/vdbe/cursor.rs index ac5c20cc..2e57df0b 100644 --- a/src/vdbe/cursor.rs +++ b/src/vdbe/cursor.rs @@ -2390,7 +2390,12 @@ pub fn analyze(vm: &mut Vm, instr: &Instruction) -> Result { } #[cfg(test)] -#[allow(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)] +#[allow( + clippy::unwrap_used, + clippy::panic, + clippy::indexing_slicing, + clippy::arithmetic_side_effects +)] mod tests { use super::*; use crate::header::DatabaseHeader; @@ -3694,4 +3699,411 @@ mod tests { let err = rowid(&mut vm, &Instruction::new(Opcode::Rowid, 0, 10, 0)).unwrap_err(); assert!(matches!(err, ExecError::MalformedInstruction { .. })); } + + // --- index-read cursor: OpenRead(P5!=0)/SeekIndexEq/IdxRowid/ + // IdxRewind/IdxLast/IdxNext/IdxPrev --- + + /// Opens a real LEAF_INDEX b-tree with `n` entries `(i, i*10)` for + /// `i` in `1..=n`, and returns a `Vm` with a write cursor open on + /// slot 1 so the caller can `idx_insert` before switching to an + /// index-read cursor on slot 0. + fn writable_vm_with_index_entries(n: i64) -> Vm { + let mut vm = writable_vm(0x0a); + let mut open_instr = Instruction::new(Opcode::OpenWrite, 1, 1, 0); + open_instr.p5 = 1; + open_write(&mut vm, &open_instr).unwrap(); + for i in 1..=n { + vm.set_register(0, Value::Integer(i)).unwrap(); + vm.set_register(1, Value::Integer(i * 10)).unwrap(); + idx_insert( + &mut vm, + &Instruction::with_p4(Opcode::IdxInsert, 1, 0, 0, P4::Int(2)), + ) + .unwrap(); + } + vm + } + + fn open_index_read(vm: &mut Vm, slot: i32) { + let mut open_instr = Instruction::new(Opcode::OpenRead, slot, 1, 0); + open_instr.p5 = 1; + open_read(vm, &open_instr).unwrap(); + } + + #[test] + fn seek_index_eq_hits_and_idx_rowid_reads_the_trailing_rowid() { + let mut vm = writable_vm_with_index_entries(3); + open_index_read(&mut vm, 0); + + vm.set_register(5, Value::Integer(2)).unwrap(); + let step = seek_index_eq( + &mut vm, + &Instruction::with_p4(Opcode::SeekIndexEq, 0, 99, 5, P4::Int(1)), + ) + .unwrap(); + assert_eq!(step, Step::Next); + idx_rowid(&mut vm, &Instruction::new(Opcode::IdxRowid, 0, 10, 0)).unwrap(); + assert_eq!(*vm.register(10).unwrap(), Value::Integer(20)); + } + + #[test] + fn seek_index_eq_misses_and_jumps_to_p2() { + let mut vm = writable_vm_with_index_entries(3); + open_index_read(&mut vm, 0); + + vm.set_register(5, Value::Integer(999)).unwrap(); + let step = seek_index_eq( + &mut vm, + &Instruction::with_p4(Opcode::SeekIndexEq, 0, 99, 5, P4::Int(1)), + ) + .unwrap(); + assert_eq!(step, Step::Jump(99)); + } + + #[test] + fn idx_rowid_without_a_prior_seek_errors() { + let mut vm = writable_vm_with_index_entries(1); + open_index_read(&mut vm, 0); + let err = idx_rowid(&mut vm, &Instruction::new(Opcode::IdxRowid, 0, 10, 0)).unwrap_err(); + assert!(matches!(err, ExecError::MalformedInstruction { .. })); + } + + #[test] + fn idx_rewind_idx_next_walk_the_index_in_ascending_order() { + let mut vm = writable_vm_with_index_entries(3); + open_index_read(&mut vm, 0); + + let step = idx_rewind(&mut vm, &Instruction::new(Opcode::IdxRewind, 0, 99, 0)).unwrap(); + assert_eq!(step, Step::Next); + + let mut rowids = Vec::new(); + loop { + idx_rowid(&mut vm, &Instruction::new(Opcode::IdxRowid, 0, 10, 0)).unwrap(); + rowids.push(vm.register(10).unwrap().clone()); + match idx_next(&mut vm, &Instruction::new(Opcode::IdxNext, 0, 1, 0)).unwrap() { + Step::Jump(1) => continue, + Step::Next => break, + other => panic!("unexpected step {other:?}"), + } + } + assert_eq!( + rowids, + vec![Value::Integer(10), Value::Integer(20), Value::Integer(30)] + ); + } + + #[test] + fn idx_last_idx_prev_walk_the_index_in_descending_order() { + let mut vm = writable_vm_with_index_entries(3); + open_index_read(&mut vm, 0); + + let step = idx_last(&mut vm, &Instruction::new(Opcode::IdxLast, 0, 99, 0)).unwrap(); + assert_eq!(step, Step::Next); + + let mut rowids = Vec::new(); + loop { + idx_rowid(&mut vm, &Instruction::new(Opcode::IdxRowid, 0, 10, 0)).unwrap(); + rowids.push(vm.register(10).unwrap().clone()); + match idx_prev(&mut vm, &Instruction::new(Opcode::IdxPrev, 0, 1, 0)).unwrap() { + Step::Jump(1) => continue, + Step::Next => break, + other => panic!("unexpected step {other:?}"), + } + } + assert_eq!( + rowids, + vec![Value::Integer(30), Value::Integer(20), Value::Integer(10)] + ); + } + + #[test] + fn idx_rewind_and_idx_last_jump_to_p2_on_an_empty_index() { + let mut vm = writable_vm(0x0a); + open_index_read(&mut vm, 0); + let step = idx_rewind(&mut vm, &Instruction::new(Opcode::IdxRewind, 0, 99, 0)).unwrap(); + assert_eq!(step, Step::Jump(99)); + let step = idx_last(&mut vm, &Instruction::new(Opcode::IdxLast, 0, 99, 0)).unwrap(); + assert_eq!(step, Step::Jump(99)); + } + + #[test] + fn index_read_state_mut_type_mismatch_errors() { + let mut vm = Vm::new(); + open_ephemeral(&mut vm, &Instruction::new(Opcode::OpenEphemeral, 0, 0, 0)).unwrap(); + let err = idx_rewind(&mut vm, &Instruction::new(Opcode::IdxRewind, 0, 99, 0)).unwrap_err(); + assert!(matches!(err, ExecError::CursorTypeMismatch { .. })); + } + + #[test] + fn column_reads_through_an_index_read_cursor() { + let mut vm = writable_vm_with_index_entries(1); + open_index_read(&mut vm, 0); + idx_rewind(&mut vm, &Instruction::new(Opcode::IdxRewind, 0, 99, 0)).unwrap(); + column(&mut vm, &Instruction::new(Opcode::Column, 0, 0, 10)).unwrap(); + assert_eq!(*vm.register(10).unwrap(), Value::Integer(1)); + } + + // --- automatic-index cursor: OpenEphemeral(P5==2)/AutoIndexInsert/ + // AutoIndexSeek/AutoIndexRowid/AutoIndexNext --- + + fn open_auto_index(vm: &mut Vm, slot: i32) { + open_ephemeral( + vm, + &Instruction { + opcode: Opcode::OpenEphemeral, + p1: slot, + p2: 0, + p3: 0, + p4: P4::None, + p5: 2, + }, + ) + .unwrap(); + } + + #[test] + fn auto_index_seek_finds_every_rowid_sharing_a_duplicate_key() { + let mut vm = Vm::new(); + open_auto_index(&mut vm, 0); + + vm.set_register(0, Value::Text("k".to_string().into())) + .unwrap(); + vm.set_register(1, Value::Integer(100)).unwrap(); + auto_index_insert( + &mut vm, + &Instruction::with_p4(Opcode::AutoIndexInsert, 0, 0, 1, P4::Int(1)), + ) + .unwrap(); + vm.set_register(1, Value::Integer(200)).unwrap(); + auto_index_insert( + &mut vm, + &Instruction::with_p4(Opcode::AutoIndexInsert, 0, 0, 1, P4::Int(1)), + ) + .unwrap(); + + vm.set_register(5, Value::Text("k".to_string().into())) + .unwrap(); + let step = auto_index_seek( + &mut vm, + &Instruction::with_p4(Opcode::AutoIndexSeek, 0, 99, 5, P4::Int(1)), + ) + .unwrap(); + assert_eq!(step, Step::Next); + + let mut rowids = Vec::new(); + loop { + auto_index_rowid(&mut vm, &Instruction::new(Opcode::AutoIndexRowid, 0, 10, 0)).unwrap(); + rowids.push(vm.register(10).unwrap().clone()); + match auto_index_next(&mut vm, &Instruction::new(Opcode::AutoIndexNext, 0, 1, 0)) + .unwrap() + { + Step::Jump(1) => continue, + Step::Next => break, + other => panic!("unexpected step {other:?}"), + } + } + assert_eq!(rowids, vec![Value::Integer(100), Value::Integer(200)]); + } + + #[test] + fn auto_index_seek_jumps_to_p2_on_a_miss() { + let mut vm = Vm::new(); + open_auto_index(&mut vm, 0); + vm.set_register(5, Value::Text("nope".to_string().into())) + .unwrap(); + let step = auto_index_seek( + &mut vm, + &Instruction::with_p4(Opcode::AutoIndexSeek, 0, 99, 5, P4::Int(1)), + ) + .unwrap(); + assert_eq!(step, Step::Jump(99)); + } + + #[test] + fn auto_index_rowid_without_a_prior_seek_errors() { + let mut vm = Vm::new(); + open_auto_index(&mut vm, 0); + let err = auto_index_rowid(&mut vm, &Instruction::new(Opcode::AutoIndexRowid, 0, 10, 0)) + .unwrap_err(); + assert!(matches!(err, ExecError::MalformedInstruction { .. })); + } + + #[test] + fn auto_index_insert_rejects_a_non_integer_rowid_register() { + let mut vm = Vm::new(); + open_auto_index(&mut vm, 0); + vm.set_register(0, Value::Text("k".to_string().into())) + .unwrap(); + vm.set_register(1, Value::Text("nope".to_string().into())) + .unwrap(); + let err = auto_index_insert( + &mut vm, + &Instruction::with_p4(Opcode::AutoIndexInsert, 0, 0, 1, P4::Int(1)), + ) + .unwrap_err(); + assert!(matches!(err, ExecError::MalformedInstruction { .. })); + } + + #[test] + fn auto_index_next_with_no_current_position_is_a_no_op() { + let mut vm = Vm::new(); + open_auto_index(&mut vm, 0); + let step = + auto_index_next(&mut vm, &Instruction::new(Opcode::AutoIndexNext, 0, 1, 0)).unwrap(); + assert_eq!(step, Step::Next); + } + + #[test] + fn auto_index_mut_type_mismatch_errors() { + let mut vm = Vm::new(); + open_ephemeral(&mut vm, &Instruction::new(Opcode::OpenEphemeral, 0, 0, 0)).unwrap(); + let err = auto_index_seek( + &mut vm, + &Instruction::with_p4(Opcode::AutoIndexSeek, 0, 99, 0, P4::Int(1)), + ) + .unwrap_err(); + assert!(matches!(err, ExecError::CursorTypeMismatch { .. })); + } + + // --- Count / Analyze --- + + #[test] + fn count_opcode_reports_the_exact_row_count() { + let mut vm = open_vm("table_multipage.db"); + count(&mut vm, &Instruction::new(Opcode::Count, 2, 10, 0)).unwrap(); + assert_eq!(*vm.register(10).unwrap(), Value::Integer(3000)); + } + + #[test] + fn analyze_populates_sqlite_stat1_for_a_table_and_its_index() { + let mut vm = writable_vm(0x0d); + create_table( + &mut vm, + &Instruction::with_p4( + Opcode::CreateTable, + 0, + 0, + 0, + P4::CreateTable { + name: "t".to_string(), + sql: "CREATE TABLE t (a)".to_string(), + }, + ), + ) + .unwrap(); + open_read(&mut vm, &Instruction::new(Opcode::OpenRead, 1, 1, 0)).unwrap(); + rewind(&mut vm, &Instruction::new(Opcode::Rewind, 1, 999, 0)).unwrap(); + column(&mut vm, &Instruction::new(Opcode::Column, 1, 3, 22)).unwrap(); + let table_root = match vm.register(22).unwrap() { + Value::Integer(n) => u32::try_from(*n).unwrap(), + other => panic!("expected integer rootpage, got {other:?}"), + }; + + open_write( + &mut vm, + &Instruction::new(Opcode::OpenWrite, 2, i32::try_from(table_root).unwrap(), 0), + ) + .unwrap(); + for i in 1..=3i64 { + vm.set_register(3, Value::Integer(i)).unwrap(); + crate::vdbe::result::make_record( + &mut vm, + &Instruction::new(Opcode::MakeRecord, 3, 1, 4), + ) + .unwrap(); + vm.set_register(5, Value::Integer(i)).unwrap(); + insert(&mut vm, &Instruction::new(Opcode::Insert, 2, 5, 4)).unwrap(); + } + + create_index( + &mut vm, + &Instruction::with_p4( + Opcode::CreateIndex, + 0, + 0, + 0, + P4::CreateIndex { + name: "idx".to_string(), + table_name: "t".to_string(), + table_root_page: table_root, + sql: "CREATE INDEX idx ON t (a)".to_string(), + column_indices: vec![0], + unique: false, + }, + ), + ) + .unwrap(); + + // Find the freshly created index's own root page back out of + // sqlite_master (CreateIndex only returns via the schema, not a + // register), the same way the create_index round-trip test does. + let mut index_root = None; + let step = rewind(&mut vm, &Instruction::new(Opcode::Rewind, 1, 999, 0)).unwrap(); + assert_eq!(step, Step::Next); + loop { + column(&mut vm, &Instruction::new(Opcode::Column, 1, 0, 30)).unwrap(); + column(&mut vm, &Instruction::new(Opcode::Column, 1, 3, 31)).unwrap(); + if vm.register(30).unwrap() == &Value::Text("index".to_string().into()) { + if let Value::Integer(n) = vm.register(31).unwrap() { + index_root = Some(u32::try_from(*n).unwrap()); + } + } + match next(&mut vm, &Instruction::new(Opcode::Next, 1, 1, 0)).unwrap() { + Step::Jump(1) => continue, + Step::Next => break, + other => panic!("unexpected step {other:?}"), + } + } + let index_root = index_root.unwrap(); + + analyze( + &mut vm, + &Instruction::with_p4( + Opcode::Analyze, + 0, + 0, + 0, + P4::Analyze { + targets: vec![crate::vdbe::program::AnalyzeTarget { + table_name: "t".to_string(), + table_root_page: table_root, + indexes: vec![crate::vdbe::program::AnalyzeIndexTarget { + index_name: "idx".to_string(), + root_page: index_root, + }], + }], + }, + ), + ) + .unwrap(); + + // sqlite_stat1 now holds one row for the table and one for the index. + let db = vm.db().unwrap(); + let stat1_root = btree::ensure_sqlite_stat1_table( + &mut vm.writer("test").unwrap().borrow_mut(), + &db.header, + ) + .unwrap(); + let mut stat_cursor = TableCursor::new(Rc::clone(&db.source), &db.header, stat1_root); + let mut rows = Vec::new(); + let mut row = stat_cursor.first_row().unwrap(); + while let Some(r) = row { + rows.push(decode_record(&r.payload, TextEncoding::Utf8).unwrap()); + row = stat_cursor.next_row().unwrap(); + } + assert_eq!(rows.len(), 2); + assert!(rows + .iter() + .any(|r| r[1] == Value::Null && r[2] == Value::Text("3".into()))); + assert!(rows + .iter() + .any(|r| r[1] == Value::Text("idx".into()) && r[2] == Value::Text("3 1".into()))); + } + + #[test] + fn analyze_rejects_a_mismatched_p4() { + let mut vm = writable_vm(0x0d); + let err = analyze(&mut vm, &Instruction::new(Opcode::Analyze, 0, 0, 0)).unwrap_err(); + assert!(matches!(err, ExecError::MalformedInstruction { .. })); + } } diff --git a/src/vdbe/exec.rs b/src/vdbe/exec.rs index 5a85b0b3..9f32c633 100644 --- a/src/vdbe/exec.rs +++ b/src/vdbe/exec.rs @@ -1574,4 +1574,350 @@ mod tests { Step::Next ); } + + #[test] + fn display_covers_every_exec_error_variant() { + let cases: Vec<(ExecError, &str)> = vec![ + ( + ExecError::RegisterOutOfRange { + opcode: "Op", + index: 5, + }, + "Op: register index 5 is out of range", + ), + ( + ExecError::RegisterRangeTooLarge { + opcode: "Op", + count: 9, + }, + "Op: register range count 9 exceeds the maximum", + ), + ( + ExecError::TypeMismatch { + opcode: "Op", + found: "text", + }, + "Op: expected a different value type, found text", + ), + (ExecError::MustBeInt, "MustBeInt: value cannot be converted"), + ( + ExecError::MalformedInstruction { + opcode: "Op", + reason: "bad p4".to_string(), + }, + "Op: malformed instruction (bad p4)", + ), + ( + ExecError::Unimplemented { + opcode: Opcode::Halt, + }, + "opcode Halt is not yet implemented", + ), + ( + ExecError::CursorNotOpen { slot: 3 }, + "cursor slot 3 is not open", + ), + ( + ExecError::CursorTypeMismatch { + opcode: "Op", + slot: 1, + found: "sorter", + expected: "table", + }, + "Op: cursor slot 1 is a sorter, not a table", + ), + ( + ExecError::NoDatabase { opcode: "Op" }, + "Op requires a database attached", + ), + ( + ExecError::ProgramCounterOutOfRange { pc: 42 }, + "program counter 42 is out of range", + ), + ( + ExecError::StepLimitExceeded, + "program exceeded the maximum step count", + ), + ( + ExecError::EphemeralRowLimitExceeded { + opcode: "Op", + limit: 100, + }, + "Op: ephemeral table/index exceeded the maximum row count (100)", + ), + ( + ExecError::Halted { + code: 1, + message: None, + }, + "statement halted with SQLite result code 1", + ), + ( + ExecError::Halted { + code: 1, + message: Some("oops".to_string()), + }, + "statement halted with SQLite result code 1: oops", + ), + ( + ExecError::TransactionAlreadyActive, + "cannot start a transaction within a transaction", + ), + ( + ExecError::NoActiveTransactionToCommit, + "cannot commit - no transaction is active", + ), + ( + ExecError::NoActiveTransactionToRollback, + "cannot rollback - no transaction is active", + ), + ( + ExecError::JournalModeChangeDuringTransaction, + "cannot change journal_mode within a transaction", + ), + ]; + for (err, expected_prefix) in cases { + let rendered = err.to_string(); + assert!( + rendered.contains(expected_prefix), + "expected {rendered:?} to contain {expected_prefix:?}" + ); + } + } + + #[test] + fn flush_failed_display_and_source_delegate_to_pager_error() { + let pager_err = crate::pager::PagerError::PendingTransaction; + let err = ExecError::FlushFailed(pager_err); + assert!(err.to_string().contains("failed to flush pending writes")); + assert!(std::error::Error::source(&err).is_some()); + assert!(std::error::Error::source(&ExecError::MustBeInt).is_none()); + } + + #[test] + fn from_pager_error_wraps_as_flush_failed() { + let pager_err = crate::pager::PagerError::PendingTransaction; + let err: ExecError = pager_err.into(); + assert!(matches!(err, ExecError::FlushFailed(_))); + } + + #[test] + fn cast_forces_target_affinity_regardless_of_source_type() { + let mut vm = Vm::new(); + vm.set_register(0, Value::Text("123".to_string().into())) + .unwrap(); + cast( + &mut vm, + &Instruction::new( + Opcode::Cast, + 0, + i32::from(Affinity::Integer.to_p4_byte()), + 0, + ), + ) + .unwrap(); + assert_eq!(*vm.register(0).unwrap(), Value::Integer(123)); + } + + #[test] + fn filter_add_and_filter_check_round_trip_through_dispatch_helpers() { + let mut vm = Vm::new(); + vm.set_register(0, Value::Integer(7)).unwrap(); + filter_add( + &mut vm, + &Instruction::with_p4(Opcode::FilterAdd, 0, 0, 0, P4::Int(10)), + ) + .unwrap(); + // Present value: never jumps. + assert_eq!( + filter_check(&mut vm, &Instruction::new(Opcode::Filter, 0, 99, 0)).unwrap(), + Step::Next + ); + // A different slot was never populated, so it's still empty -- + // absent values on an unopened slot report "maybe present" and + // never jump either. + assert_eq!( + filter_check(&mut vm, &Instruction::new(Opcode::Filter, 1, 99, 0)).unwrap(), + Step::Next + ); + } + + #[test] + fn function_rejects_non_string_p4() { + let mut vm = Vm::new(); + assert!(matches!( + function(&mut vm, &Instruction::new(Opcode::Function, 0, 0, 0)), + Err(ExecError::MalformedInstruction { + opcode: "Function", + .. + }) + )); + } + + #[test] + fn function_rejects_malformed_descriptor() { + let mut vm = Vm::new(); + assert!(matches!( + function( + &mut vm, + &Instruction::with_p4( + Opcode::Function, + 0, + 0, + 0, + P4::Str("not_a_descriptor".to_string()), + ), + ), + Err(ExecError::MalformedInstruction { + opcode: "Function", + .. + }) + )); + } + + #[test] + fn function_rejects_unknown_function_name() { + let mut vm = Vm::new(); + vm.set_register(0, Value::Integer(1)).unwrap(); + assert!(matches!( + function( + &mut vm, + &Instruction::with_p4( + Opcode::Function, + 0, + 0, + 1, + P4::Str("not_a_real_fn(1)".to_string()), + ), + ), + Err(ExecError::MalformedInstruction { + opcode: "Function", + .. + }) + )); + } + + #[test] + fn function_dispatches_a_known_scalar_function() { + let mut vm = Vm::new(); + vm.set_register(0, Value::Integer(-5)).unwrap(); + function( + &mut vm, + &Instruction::with_p4(Opcode::Function, 0, 0, 1, P4::Str("abs(1)".to_string())), + ) + .unwrap(); + assert_eq!(*vm.register(1).unwrap(), Value::Integer(5)); + } + + #[test] + fn agg_final_rejects_non_string_p4() { + let mut vm = Vm::new(); + assert!(matches!( + agg_final(&mut vm, &Instruction::new(Opcode::AggFinal, 0, 0, 0)), + Err(ExecError::MalformedInstruction { + opcode: "AggFinal", + .. + }) + )); + } + + #[test] + fn agg_final_rejects_malformed_descriptor() { + let mut vm = Vm::new(); + assert!(matches!( + agg_final( + &mut vm, + &Instruction::with_p4(Opcode::AggFinal, 0, 0, 0, P4::Str("garbage".to_string()),), + ), + Err(ExecError::MalformedInstruction { + opcode: "AggFinal", + .. + }) + )); + } + + #[test] + fn parse_function_descriptor_rejects_missing_paren_and_bad_arity() { + assert_eq!(parse_function_descriptor("noparen"), None); + assert_eq!(parse_function_descriptor("abs(1"), None); + assert_eq!(parse_function_descriptor("abs(x)"), None); + assert_eq!(parse_function_descriptor("abs(1)"), Some(("abs", 1))); + } + + #[test] + fn vm_debug_and_default_helpers_are_reachable() { + let vm = Vm::new(); + assert!(vm.db.is_none()); + let debug_str = format!("{vm:?}"); + assert!(debug_str.contains("Vm")); + } + + fn open_read_vm() -> Vm { + use crate::vfs::{UnixVfs, Vfs, VfsPageSource}; + use std::path::Path; + let path = Path::new("tests/corpus/fixtures/btrees").join("table_single_page.db"); + let vfs = UnixVfs; + let file = vfs.open_read(&path).unwrap(); + let mut header_buf = [0u8; 100]; + file.read_at(&mut header_buf, 0).unwrap(); + let header = DatabaseHeader::parse(&header_buf).unwrap(); + let source = VfsPageSource::open(&vfs, &path, header.page_size).unwrap(); + Vm::with_db(Rc::new(source), header) + } + + #[test] + fn vm_db_debug_omits_source_and_writer_fields() { + let vm = open_read_vm(); + let rendered = format!("{:?}", vm.db.as_ref().unwrap()); + assert!(rendered.contains("VmDb")); + } + + #[test] + fn writer_errors_without_a_database_or_without_write_access() { + let vm = Vm::new(); + assert!(matches!( + vm.writer("Op"), + Err(ExecError::NoDatabase { opcode: "Op" }) + )); + + let vm = open_read_vm(); + assert!(matches!( + vm.writer("Op"), + Err(ExecError::NoDatabase { opcode: "Op" }) + )); + } + + #[test] + fn param_reads_are_one_based_and_out_of_range_is_none() { + let mut vm = Vm::new(); + vm.bind_params(vec![Value::Integer(10), Value::Integer(20)]); + assert_eq!(vm.param(1), Some(&Value::Integer(10))); + assert_eq!(vm.param(2), Some(&Value::Integer(20))); + assert_eq!(vm.param(0), None); + assert_eq!(vm.param(3), None); + assert_eq!(vm.param(-1), None); + } + + #[test] + fn cursor_mut_errors_when_slot_is_unopened() { + let mut vm = Vm::new(); + assert!(matches!( + vm.cursor_mut(0), + Err(ExecError::CursorNotOpen { slot: 0 }) + )); + vm.set_cursor(0, CursorSlot::Pseudo { register: 1 }) + .unwrap(); + assert!(vm.cursor_mut(0).is_ok()); + } + + #[test] + fn take_register_leaves_null_behind() { + let mut vm = Vm::new(); + vm.set_register(0, Value::Integer(9)).unwrap(); + let taken = vm.take_register(0).unwrap(); + assert_eq!(taken, Value::Integer(9)); + assert_eq!(*vm.register(0).unwrap(), Value::Null); + // Never-written register also takes as NULL without growing. + assert_eq!(vm.take_register(50).unwrap(), Value::Null); + } } diff --git a/src/vfs/page_source.rs b/src/vfs/page_source.rs index 714cac4e..95d1eaf2 100644 --- a/src/vfs/page_source.rs +++ b/src/vfs/page_source.rs @@ -313,4 +313,111 @@ mod tests { other => panic!("expected ShortRead, got {other:?}"), } } + + #[test] + fn display_and_source_for_each_variant() { + assert_eq!( + PageError::InvalidPageNumber.to_string(), + "invalid page number 0" + ); + let short = PageError::ShortRead { + page_num: 3, + expected: 16, + got: 4, + }; + assert_eq!( + short.to_string(), + "short read on page 3: expected 16 bytes, got 4" + ); + let wrong = PageError::WrongLength { + page_num: 2, + expected: 16, + got: 8, + }; + assert_eq!( + wrong.to_string(), + "wrong buffer length writing page 2: expected 16 bytes, got 8" + ); + + use std::error::Error; + assert!(PageError::InvalidPageNumber.source().is_none()); + assert!(short.source().is_none()); + assert!(wrong.source().is_none()); + + let vfs = MemoryVfs::new(); + let missing = match vfs.open_read(Path::new("/missing")) { + Err(e) => e, + Ok(_) => panic!("expected open_read to fail for a missing file"), + }; + let page_err: PageError = missing.into(); + assert!(matches!(page_err, PageError::Vfs(_))); + assert!(page_err.source().is_some()); + assert_eq!(page_err.to_string(), format!("{}", page_err)); + } + + #[test] + fn writable_page_source_round_trip() { + let mut vfs = MemoryVfs::new(); + vfs.insert("/db", vec![0u8; 16]); + let source = WritablePageSource::open(&vfs, Path::new("/db"), 16).unwrap(); + source.write_page(1, &[7u8; 16]).unwrap(); + source.sync().unwrap(); + let page = source.read_page(1).unwrap(); + assert_eq!(&*page, &[7u8; 16][..]); + drop(source.lock_shared().unwrap()); + } + + #[test] + fn writable_page_source_rejects_page_zero() { + let mut vfs = MemoryVfs::new(); + vfs.insert("/db", vec![0u8; 16]); + let source = WritablePageSource::open(&vfs, Path::new("/db"), 16).unwrap(); + assert!(matches!( + source.write_page(0, &[0u8; 16]), + Err(PageError::InvalidPageNumber) + )); + } + + #[test] + fn writable_page_source_rejects_wrong_length() { + let mut vfs = MemoryVfs::new(); + vfs.insert("/db", vec![0u8; 16]); + let source = WritablePageSource::open(&vfs, Path::new("/db"), 16).unwrap(); + match source.write_page(1, &[0u8; 8]) { + Err(PageError::WrongLength { + page_num, + expected, + got, + }) => { + assert_eq!(page_num, 1); + assert_eq!(expected, 16); + assert_eq!(got, 8); + } + other => panic!("expected WrongLength, got {other:?}"), + } + } + + #[test] + fn writable_page_source_from_file_and_read_into() { + let mut vfs = MemoryVfs::new(); + vfs.insert("/db", vec![9u8; 16]); + let any_vfs = crate::vfs::AnyVfs::new(vfs); + let file = any_vfs.open_write(Path::new("/db")).unwrap(); + let source = WritablePageSource::from_file(file, 16); + let mut buf = vec![0u8; 16]; + source.read_page_into(1, &mut buf).unwrap(); + assert_eq!(buf, vec![9u8; 16]); + } + + #[test] + fn rc_and_ref_page_source_forward() { + let mut vfs = MemoryVfs::new(); + vfs.insert("/db", vec![5u8; 16]); + let source = VfsPageSource::open(&vfs, Path::new("/db"), 16).unwrap(); + let rc: Rc = Rc::new(source); + assert_eq!(&*rc.read_page(1).unwrap(), &[5u8; 16][..]); + + let by_ref: &dyn PageSource = &*rc; + assert_eq!(&*by_ref.read_page(1).unwrap(), &[5u8; 16][..]); + } } diff --git a/src/vfs/unix.rs b/src/vfs/unix.rs index 9b871eed..ee71cf37 100644 --- a/src/vfs/unix.rs +++ b/src/vfs/unix.rs @@ -280,3 +280,171 @@ fn to_lock_error(path: &Path, source: std::io::Error) -> VfsError { _ => to_vfs_error(path, source), } } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + + fn tmp_path(name: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "sqlite_rs_unix_vfs_test_{}_{}_{}", + std::process::id(), + name, + fastrand_stub() + )); + p + } + + // Cheap unique suffix without pulling in a rand dependency. + fn fastrand_stub() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() as u64 + } + + #[test] + fn open_read_missing_file_is_not_found() { + let vfs = UnixVfs; + let path = tmp_path("missing_read"); + let err = vfs.open_read(&path).err().unwrap(); + assert!(matches!(err, VfsError::NotFound { .. })); + } + + #[test] + fn open_write_missing_file_is_not_found() { + let vfs = UnixVfs; + let path = tmp_path("missing_write"); + let err = vfs.open_write(&path).err().unwrap(); + assert!(matches!(err, VfsError::NotFound { .. })); + } + + #[test] + fn exists_reports_true_and_false() { + let vfs = UnixVfs; + let path = tmp_path("exists"); + assert!(!vfs.exists(&path).unwrap()); + std::fs::write(&path, b"hi").unwrap(); + assert!(vfs.exists(&path).unwrap()); + std::fs::remove_file(&path).ok(); + } + + #[test] + fn create_or_open_write_creates_then_reopens_without_truncating() { + let vfs = UnixVfs; + let path = tmp_path("create_or_open"); + std::fs::remove_file(&path).ok(); + + let file = vfs.create_or_open_write(&path).unwrap(); + file.write_at(b"hello", 0).unwrap(); + drop(file); + + let file = vfs.create_or_open_write(&path).unwrap(); + assert_eq!(file.size().unwrap(), 5); + let mut buf = [0u8; 5]; + file.read_at(&mut buf, 0).unwrap(); + assert_eq!(&buf, b"hello"); + + std::fs::remove_file(&path).ok(); + } + + #[test] + fn delete_missing_file_is_ok() { + let vfs = UnixVfs; + let path = tmp_path("delete_missing"); + vfs.delete(&path).unwrap(); + } + + #[test] + fn delete_existing_file_removes_it() { + let vfs = UnixVfs; + let path = tmp_path("delete_existing"); + std::fs::write(&path, b"x").unwrap(); + vfs.delete(&path).unwrap(); + assert!(!path.exists()); + } + + #[test] + fn wal_shm_helpers_return_none_or_empty_without_shm_file() { + let vfs = UnixVfs; + let path = tmp_path("no_shm.db"); + std::fs::remove_file(companion_path(&path, "-shm")).ok(); + + assert!(vfs.claim_wal_checkpoint_lock(&path).unwrap().is_none()); + assert!(vfs.active_wal_reader_marks(&path).unwrap().is_empty()); + vfs.publish_wal_backfill(&path, 3).unwrap(); + assert_eq!(vfs.read_wal_backfill(&path).unwrap(), 0); + assert!(vfs.claim_wal_write_lock(&path).unwrap().is_none()); + vfs.publish_wal_mx_frame(&path, 7).unwrap(); + assert!(vfs.open_wal_shm(&path).unwrap().is_none()); + } + + #[test] + fn vfs_file_write_read_truncate_sync() { + let vfs = UnixVfs; + let path = tmp_path("rw"); + std::fs::remove_file(&path).ok(); + let file = vfs.create_or_open_write(&path).unwrap(); + + file.write_at(b"abcdef", 0).unwrap(); + assert_eq!(file.size().unwrap(), 6); + + file.truncate(3).unwrap(); + assert_eq!(file.size().unwrap(), 3); + + file.sync().unwrap(); + + let mut buf = [0u8; 3]; + file.read_at(&mut buf, 0).unwrap(); + assert_eq!(&buf, b"abc"); + + std::fs::remove_file(&path).ok(); + } + + #[test] + fn lock_shared_guard_checks_reserved_and_escalates() { + let vfs = UnixVfs; + let path = tmp_path("lock"); + std::fs::remove_file(&path).ok(); + let file = vfs.create_or_open_write(&path).unwrap(); + + let mut guard = file.lock_shared().unwrap(); + assert!(!guard.check_reserved().unwrap()); + guard.escalate_to_exclusive().unwrap(); + guard.de_escalate_to_shared().unwrap(); + guard.set_level(lock::LockLevel::Unlocked).unwrap(); + drop(guard); + + std::fs::remove_file(&path).ok(); + } + + #[test] + fn to_vfs_error_display_variants() { + let path = Path::new("/some/path"); + let not_found = to_vfs_error(path, std::io::Error::from(std::io::ErrorKind::NotFound)); + assert!(matches!(not_found, VfsError::NotFound { .. })); + + let other = to_vfs_error( + path, + std::io::Error::from(std::io::ErrorKind::PermissionDenied), + ); + assert!(matches!(other, VfsError::Io { .. })); + } + + #[test] + fn to_lock_error_maps_eagain_and_eacces_to_locked() { + let path = Path::new("/some/path"); + + let eagain = to_lock_error(path, std::io::Error::from_raw_os_error(EAGAIN)); + assert!(matches!(eagain, VfsError::Locked { .. })); + + let eacces = to_lock_error(path, std::io::Error::from_raw_os_error(EACCES)); + assert!(matches!(eacces, VfsError::Locked { .. })); + + let other = to_lock_error(path, std::io::Error::from(std::io::ErrorKind::NotFound)); + assert!(matches!(other, VfsError::NotFound { .. })); + } +} diff --git a/tests/unit/repl_dot_commands.rs b/tests/unit/repl_dot_commands.rs index e9827b6f..630cc0b8 100644 --- a/tests/unit/repl_dot_commands.rs +++ b/tests/unit/repl_dot_commands.rs @@ -217,3 +217,148 @@ fn prefix_matching_reaches_new_commands() { let out = stdout_of(&db, ".ver\n.quit\n"); assert!(out.contains(env!("CARGO_PKG_VERSION")), "{out}"); } + +#[test] +fn unknown_dot_command_prints_error_and_continues() { + let db = scratch_db("unknown-cmd"); + seed(&db, "CREATE TABLE t(a)"); + let out = run_repl_script(&db, ".bogus\nSELECT 1;\n.quit\n"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("unknown command"), "{stderr}"); + assert!(out.status.success()); +} + +#[test] +fn color_toggle_accepts_on_off_and_rejects_garbage() { + let db = scratch_db("color"); + seed(&db, "CREATE TABLE t(a)"); + let out = run_repl_script(&db, ".color on\n.color off\n.color sideways\n.quit\n"); + assert!(out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("usage: .color on|off"), "{stderr}"); +} + +#[test] +fn headers_rejects_invalid_argument() { + let db = scratch_db("headers-bad"); + seed(&db, "CREATE TABLE t(a)"); + let out = run_repl_script(&db, ".headers sideways\n.quit\n"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("usage: .headers on|off"), "{stderr}"); +} + +#[test] +fn mode_rejects_invalid_argument() { + let db = scratch_db("mode-bad"); + seed(&db, "CREATE TABLE t(a)"); + let out = run_repl_script(&db, ".mode sideways\n.quit\n"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("usage: .mode csv|column|line|list"), + "{stderr}" + ); +} + +#[test] +fn schema_includes_view_statements() { + let db = scratch_db("schema-view"); + seed(&db, "CREATE TABLE t(a, b)"); + seed(&db, "CREATE VIEW v AS SELECT a FROM t"); + + let all = stdout_of(&db, ".schema\n.quit\n"); + assert!(all.contains("CREATE VIEW v AS SELECT a FROM t;"), "{all}"); + + let named = stdout_of(&db, ".schema v\n.quit\n"); + assert!( + named.contains("CREATE VIEW v AS SELECT a FROM t;"), + "{named}" + ); +} + +#[test] +fn indices_with_no_matching_table_prints_nothing() { + let db = scratch_db("indices-none"); + seed(&db, "CREATE TABLE t(a)"); + seed(&db, "CREATE INDEX idx_t_a ON t(a)"); + let out = stdout_of(&db, ".indices nosuchtable\n.quit\n"); + assert!(!out.contains("idx_t_a"), "{out}"); +} + +#[test] +fn dump_filtered_by_table_omits_other_tables() { + let db = scratch_db("dump-filtered"); + seed(&db, "CREATE TABLE t(a)"); + seed(&db, "CREATE TABLE other(x)"); + seed(&db, "INSERT INTO t VALUES (1)"); + seed(&db, "INSERT INTO other VALUES (2)"); + + let out = stdout_of(&db, ".dump t\n.quit\n"); + assert!(out.contains("CREATE TABLE t(a);"), "{out}"); + assert!(out.contains("INSERT INTO \"t\" VALUES(1);"), "{out}"); + assert!(!out.contains("other"), "{out}"); +} + +#[test] +fn pragma_query_runs_through_the_repl_loop() { + let db = scratch_db("pragma"); + seed(&db, "CREATE TABLE t(a INTEGER, b TEXT)"); + let out = stdout_of(&db, "PRAGMA table_info(t);\n.quit\n"); + assert!(out.contains('a'), "{out}"); + assert!(out.contains('b'), "{out}"); +} + +#[test] +fn transaction_control_survives_across_statements() { + let db = scratch_db("txn"); + seed(&db, "CREATE TABLE t(a)"); + let out = stdout_of( + &db, + "BEGIN;\nINSERT INTO t VALUES (1);\nSELECT * FROM t;\nCOMMIT;\n.quit\n", + ); + assert!(out.contains('1'), "{out}"); +} + +#[test] +fn select_syntax_error_is_reported_and_repl_continues() { + let db = scratch_db("syntax-error"); + seed(&db, "CREATE TABLE t(a)"); + let out = run_repl_script(&db, "SELECT FROM;\nSELECT 1;\n.quit\n"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("Error: syntax error"), "{stderr}"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains('1'), "{stdout}"); +} + +#[test] +fn multiple_statements_on_one_line_all_execute() { + let db = scratch_db("multi-stmt"); + seed(&db, "CREATE TABLE t(a)"); + let out = stdout_of(&db, "INSERT INTO t VALUES (1); SELECT * FROM t;\n.quit\n"); + assert!(out.contains('1'), "{out}"); +} + +#[test] +fn crlf_line_endings_are_trimmed_like_bare_newlines() { + let db = scratch_db("crlf"); + seed(&db, "CREATE TABLE t(a)"); + let out = stdout_of( + &db, + "INSERT INTO t VALUES (1);\r\nSELECT * FROM t;\r\n.quit\r\n", + ); + assert!(out.contains('1'), "{out}"); +} + +#[test] +fn select_with_join_falls_back_to_positional_headers() { + let db = scratch_db("join-headers"); + seed(&db, "CREATE TABLE t(a)"); + seed(&db, "CREATE TABLE u(b)"); + seed(&db, "INSERT INTO t VALUES (1)"); + seed(&db, "INSERT INTO u VALUES (2)"); + + let out = stdout_of( + &db, + ".headers on\nSELECT t.a, u.b FROM t JOIN u ON 1;\n.quit\n", + ); + assert!(out.contains("column1|column2"), "{out}"); +} From 150e39b9ccbcb31fbcaaebbf5c52f7d5d1fd60c2 Mon Sep 17 00:00:00 2001 From: Ilja Heitlager Date: Thu, 27 Aug 2026 13:34:12 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20PR=20#604=20review=20findi?= =?UTF-8?q?ngs=20=E2=80=94=20tautological/vacuous=20assertions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pager/error.rs, pushdown.rs, page_source.rs: replace `A || B`-style tautologies (and a to_string()==format!() self-comparison) with exact expected-string assertions. - flatten.rs: add the missing HAVING/compound veto tests that the sibling pushdown.rs already covers for the analogous safety check. - scalar.rs: assert specific AggStep/AggFinal opcodes instead of just "instructions is non-empty" (already implied by the preceding .unwrap()). - vdbe/exec.rs: assert real register state renders in Debug output instead of the tautological "contains the type name" check. Co-Authored-By: Claude Sonnet 5 --- src/codegen/subquery/flatten.rs | 14 ++++++++++++++ src/codegen/subquery/pushdown.rs | 2 +- src/codegen/subquery/scalar.rs | 4 +++- src/pager/error.rs | 29 +++++++++++++---------------- src/vdbe/exec.rs | 5 +++-- src/vfs/page_source.rs | 2 +- 6 files changed, 35 insertions(+), 21 deletions(-) diff --git a/src/codegen/subquery/flatten.rs b/src/codegen/subquery/flatten.rs index 18deb3d5..8c39ae18 100644 --- a/src/codegen/subquery/flatten.rs +++ b/src/codegen/subquery/flatten.rs @@ -715,6 +715,20 @@ mod tests { assert!(matches!(&from.first.kind, TableRefKind::Subquery(_))); } + #[test] + fn does_not_flatten_subquery_with_having() { + let select = flatten("SELECT * FROM (SELECT a FROM t GROUP BY a HAVING count(*) > 1) AS s"); + let from = select.from.unwrap(); + assert!(matches!(&from.first.kind, TableRefKind::Subquery(_))); + } + + #[test] + fn does_not_flatten_compound_subquery() { + let select = flatten("SELECT * FROM (SELECT a FROM t UNION SELECT a FROM t2) AS s"); + let from = select.from.unwrap(); + assert!(matches!(&from.first.kind, TableRefKind::Subquery(_))); + } + #[test] fn does_not_flatten_subquery_with_join_in_its_own_from() { let select = flatten("SELECT * FROM (SELECT t.a FROM t JOIN u ON t.a = u.a) AS s"); diff --git a/src/codegen/subquery/pushdown.rs b/src/codegen/subquery/pushdown.rs index 2eb31ae1..1fcf7ea1 100644 --- a/src/codegen/subquery/pushdown.rs +++ b/src/codegen/subquery/pushdown.rs @@ -426,7 +426,7 @@ mod tests { #[test] fn allows_unqualified_column_without_join() { let out = pushed("SELECT * FROM (SELECT a FROM t) AS sub WHERE a = 1"); - assert!(!out.contains("WHERE a = 1") || out.contains("(SELECT a FROM t WHERE a = 1)")); + assert!(out.contains("(SELECT a FROM t WHERE a = 1)"), "{out}"); } #[test] diff --git a/src/codegen/subquery/scalar.rs b/src/codegen/subquery/scalar.rs index 6a8f6147..be9546c0 100644 --- a/src/codegen/subquery/scalar.rs +++ b/src/codegen/subquery/scalar.rs @@ -778,7 +778,9 @@ mod tests { fn scalar_subquery_with_aggregate() { let catalog = [t(), s_rowid()]; let program = compile("SELECT (SELECT max(v) FROM s) FROM t", &catalog).unwrap(); - assert!(!program.instructions.is_empty()); + let ops = opcodes(&program); + assert!(ops.contains(&Opcode::AggStep)); + assert!(ops.contains(&Opcode::AggFinal)); } #[test] diff --git a/src/pager/error.rs b/src/pager/error.rs index b35ff651..0bb98d3e 100644 --- a/src/pager/error.rs +++ b/src/pager/error.rs @@ -111,23 +111,20 @@ mod tests { } .to_string() .contains("reading WAL at db.sqlite-wal")); - assert!( - PagerError::Page(PageError::InvalidPageNumber) - .to_string() - .contains("page number") - || !PagerError::Page(PageError::InvalidPageNumber) - .to_string() - .is_empty() + assert_eq!( + PagerError::Page(PageError::InvalidPageNumber).to_string(), + "invalid page number 0" ); - assert!(!PagerError::Vfs(VfsError::NotFound { - path: "x".to_string() - }) - .to_string() - .is_empty()); - assert!( - !PagerError::Freelist(FreelistError::PageTooShort { offset: 1, len: 2 }) - .to_string() - .is_empty() + assert_eq!( + PagerError::Vfs(VfsError::NotFound { + path: "x".to_string() + }) + .to_string(), + "file not found: x" + ); + assert_eq!( + PagerError::Freelist(FreelistError::PageTooShort { offset: 1, len: 2 }).to_string(), + "freelist trunk page is 2 bytes, too short to read a field at offset 1" ); assert_eq!( PagerError::PendingTransaction.to_string(), diff --git a/src/vdbe/exec.rs b/src/vdbe/exec.rs index 9f32c633..27e50a72 100644 --- a/src/vdbe/exec.rs +++ b/src/vdbe/exec.rs @@ -1846,10 +1846,11 @@ mod tests { #[test] fn vm_debug_and_default_helpers_are_reachable() { - let vm = Vm::new(); + let mut vm = Vm::new(); assert!(vm.db.is_none()); + vm.set_register(0, Value::Integer(42)).unwrap(); let debug_str = format!("{vm:?}"); - assert!(debug_str.contains("Vm")); + assert!(debug_str.contains("Integer(42)"), "{debug_str}"); } fn open_read_vm() -> Vm { diff --git a/src/vfs/page_source.rs b/src/vfs/page_source.rs index 95d1eaf2..a54949fc 100644 --- a/src/vfs/page_source.rs +++ b/src/vfs/page_source.rs @@ -352,7 +352,7 @@ mod tests { let page_err: PageError = missing.into(); assert!(matches!(page_err, PageError::Vfs(_))); assert!(page_err.source().is_some()); - assert_eq!(page_err.to_string(), format!("{}", page_err)); + assert_eq!(page_err.to_string(), "file not found: /missing"); } #[test]