pub mod grammar_generator; pub mod rowid_alias; #[cfg(test)] mod fuzz_tests { use rand::seq::{IndexedRandom, IteratorRandom, SliceRandom}; use rand::Rng; use rand_chacha::ChaCha8Rng; use rusqlite::{params, types::Value}; use std::{collections::HashSet, io::Write}; use turso_core::DatabaseOpts; use core_tester::common::{ do_flush, limbo_exec_rows, limbo_exec_rows_fallible, limbo_stmt_get_column_names, maybe_setup_tracing, rng_from_time_or_env, rusqlite_integrity_check, sqlite_exec_rows, TempDatabase, }; use super::grammar_generator::{const_str, rand_int, rand_str, GrammarGenerator}; use super::grammar_generator::SymbolHandle; /// [See this issue for more info](https://github.com/tursodatabase/turso/issues/1763) #[test] pub fn fuzz_failure_issue_1763() { let db = TempDatabase::new_empty(false); let limbo_conn = db.connect_limbo(); let sqlite_conn = rusqlite::Connection::open_in_memory().unwrap(); let offending_query = "SELECT ((ceil(pow((((2.0))), (-2.0 - -1.0) / log(0.5)))) - -2.0)"; let limbo_result = limbo_exec_rows(&db, &limbo_conn, offending_query); let sqlite_result = sqlite_exec_rows(&sqlite_conn, offending_query); assert_eq!( limbo_result, sqlite_result, "query: {offending_query}, limbo: {limbo_result:?}, sqlite: {sqlite_result:?}" ); } #[test] pub fn arithmetic_expression_fuzz_ex1() { let db = TempDatabase::new_empty(false); let limbo_conn = db.connect_limbo(); let sqlite_conn = rusqlite::Connection::open_in_memory().unwrap(); for query in [ "SELECT ~1 >> 1536", "SELECT ~ + 3 << - ~ (~ (8)) - + -1 - 3 >> 3 + -6 * (-7 * 9 >> - 2)", ] { let limbo = limbo_exec_rows(&db, &limbo_conn, query); let sqlite = sqlite_exec_rows(&sqlite_conn, query); assert_eq!( limbo, sqlite, "query: {query}, limbo: {limbo:?}, sqlite: {sqlite:?}" ); } } #[test] pub fn rowid_seek_fuzz() { let db = TempDatabase::new_with_rusqlite( "CREATE TABLE t (x INTEGER PRIMARY KEY autoincrement)", false, ); // INTEGER PRIMARY KEY is a rowid alias, so an index is not created let sqlite_conn = rusqlite::Connection::open(db.path.clone()).unwrap(); let (mut rng, _seed) = rng_from_time_or_env(); let mut values: Vec = Vec::with_capacity(3000); while values.len() < 3000 { let val = rng.random_range(-100000..100000); if !values.contains(&val) { values.push(val); } } let insert = format!( "INSERT INTO t VALUES {}", values .iter() .map(|x| format!("({x})")) .collect::>() .join(", ") ); sqlite_conn.execute(&insert, params![]).unwrap(); sqlite_conn.close().unwrap(); let sqlite_conn = rusqlite::Connection::open(db.path.clone()).unwrap(); let limbo_conn = db.connect_limbo(); const COMPARISONS: [&str; 4] = ["<", "<=", ">", ">="]; const ORDER_BY: [Option<&str>; 4] = [ None, Some("ORDER BY x"), Some("ORDER BY x DESC"), Some("ORDER BY x ASC"), ]; let (mut rng, seed) = rng_from_time_or_env(); tracing::info!("rowid_seek_fuzz seed: {}", seed); for iteration in 0..2 { tracing::trace!("rowid_seek_fuzz iteration: {}", iteration); for comp in COMPARISONS.iter() { for order_by in ORDER_BY.iter() { let test_values = generate_random_comparison_values(&mut rng); for test_value in test_values.iter() { let query = format!( "SELECT * FROM t WHERE x {} {} {}", comp, test_value, order_by.unwrap_or("") ); log::trace!("query: {query}"); let limbo_result = limbo_exec_rows(&db, &limbo_conn, &query); let sqlite_result = sqlite_exec_rows(&sqlite_conn, &query); assert_eq!( limbo_result, sqlite_result, "query: {query}, limbo: {limbo_result:?}, sqlite: {sqlite_result:?}, seed: {seed}" ); } } } } } fn generate_random_comparison_values(rng: &mut ChaCha8Rng) -> Vec { let mut values = Vec::new(); for _ in 0..1000 { let val = rng.random_range(-10000..10000); values.push(val.to_string()); } values.push(i64::MAX.to_string()); values.push(i64::MIN.to_string()); values.push("0".to_string()); for _ in 0..5 { let val: f64 = rng.random_range(-10000.0..10000.0); values.push(val.to_string()); } values.push("NULL".to_string()); // Man's greatest mistake values.push("'NULL'".to_string()); // SQLite dared to one up on that mistake values.push("0.0".to_string()); values.push("-0.0".to_string()); values.push("1.5".to_string()); values.push("-1.5".to_string()); values.push("999.999".to_string()); values.push("'text'".to_string()); values.push("'123'".to_string()); values.push("''".to_string()); values.push("'0'".to_string()); values.push("'hello'".to_string()); values.push("'0x10'".to_string()); values.push("'+123'".to_string()); values.push("' 123 '".to_string()); values.push("'1.5e2'".to_string()); values.push("'inf'".to_string()); values.push("'-inf'".to_string()); values.push("'nan'".to_string()); values.push("X'41'".to_string()); values.push("X''".to_string()); values.push("(1 + 1)".to_string()); // values.push("(SELECT 1)".to_string()); subqueries ain't implemented yet homes. values } #[test] pub fn index_scan_fuzz() { let db = TempDatabase::new_with_rusqlite("CREATE TABLE t (x PRIMARY KEY)", true); let sqlite_conn = rusqlite::Connection::open(db.path.clone()).unwrap(); let insert = format!( "INSERT INTO t VALUES {}", (0..10000) .map(|x| format!("({x})")) .collect::>() .join(", ") ); sqlite_conn.execute(&insert, params![]).unwrap(); sqlite_conn.close().unwrap(); let sqlite_conn = rusqlite::Connection::open(db.path.clone()).unwrap(); let limbo_conn = db.connect_limbo(); const COMPARISONS: [&str; 5] = ["=", "<", "<=", ">", ">="]; const ORDER_BY: [Option<&str>; 4] = [ None, Some("ORDER BY x"), Some("ORDER BY x DESC"), Some("ORDER BY x ASC"), ]; for comp in COMPARISONS.iter() { for order_by in ORDER_BY.iter() { for max in 0..=10000 { let query = format!( "SELECT * FROM t WHERE x {} {} {} LIMIT 3", comp, max, order_by.unwrap_or(""), ); let limbo = limbo_exec_rows(&db, &limbo_conn, &query); let sqlite = sqlite_exec_rows(&sqlite_conn, &query); assert_eq!( limbo, sqlite, "query: {query}, limbo: {limbo:?}, sqlite: {sqlite:?}", ); } } } } #[test] /// A test for verifying that index seek+scan works correctly for compound keys /// on indexes with various column orderings. pub fn index_scan_compound_key_fuzz() { let (mut rng, seed) = rng_from_time_or_env(); let table_defs: [&str; 8] = [ "CREATE TABLE t (x, y, z, nonindexed_col, PRIMARY KEY (x, y, z))", "CREATE TABLE t (x, y, z, nonindexed_col, PRIMARY KEY (x desc, y, z))", "CREATE TABLE t (x, y, z, nonindexed_col, PRIMARY KEY (x, y desc, z))", "CREATE TABLE t (x, y, z, nonindexed_col, PRIMARY KEY (x, y, z desc))", "CREATE TABLE t (x, y, z, nonindexed_col, PRIMARY KEY (x desc, y desc, z))", "CREATE TABLE t (x, y, z, nonindexed_col, PRIMARY KEY (x desc, y, z desc))", "CREATE TABLE t (x, y, z, nonindexed_col, PRIMARY KEY (x, y desc, z desc))", "CREATE TABLE t (x, y, z, nonindexed_col, PRIMARY KEY (x desc, y desc, z desc))", ]; // Create all different 3-column primary key permutations let dbs = [ TempDatabase::new_with_rusqlite(table_defs[0], true), TempDatabase::new_with_rusqlite(table_defs[1], true), TempDatabase::new_with_rusqlite(table_defs[2], true), TempDatabase::new_with_rusqlite(table_defs[3], true), TempDatabase::new_with_rusqlite(table_defs[4], true), TempDatabase::new_with_rusqlite(table_defs[5], true), TempDatabase::new_with_rusqlite(table_defs[6], true), TempDatabase::new_with_rusqlite(table_defs[7], true), ]; let mut pk_tuples = HashSet::new(); while pk_tuples.len() < 100000 { pk_tuples.insert(( rng.random_range(0..3000), rng.random_range(0..3000), rng.random_range(0..3000), )); } let mut tuples = Vec::new(); for pk_tuple in pk_tuples { tuples.push(format!( "({}, {}, {}, {})", pk_tuple.0, pk_tuple.1, pk_tuple.2, rng.random_range(0..3000) )); } let insert = format!("INSERT INTO t VALUES {}", tuples.join(", ")); // Insert all tuples into all databases let sqlite_conns = dbs .iter() .map(|db| rusqlite::Connection::open(db.path.clone()).unwrap()) .collect::>(); for sqlite_conn in sqlite_conns.into_iter() { sqlite_conn.execute(&insert, params![]).unwrap(); sqlite_conn.close().unwrap(); } let sqlite_conns = dbs .iter() .map(|db| rusqlite::Connection::open(db.path.clone()).unwrap()) .collect::>(); let limbo_conns = dbs.iter().map(|db| db.connect_limbo()).collect::>(); const COMPARISONS: [&str; 5] = ["=", "<", "<=", ">", ">="]; // For verifying index scans, we only care about cases where all but potentially the last column are constrained by an equality (=), // because this is the only way to utilize an index efficiently for seeking. This is called the "left-prefix rule" of indexes. // Hence we generate constraint combinations in this manner; as soon as a comparison is not an equality, we stop generating more constraints for the where clause. // Examples: // x = 1 AND y = 2 AND z > 3 // x = 1 AND y > 2 // x > 1 let col_comp_first = COMPARISONS .iter() .cloned() .map(|x| (Some(x), None, None)) .collect::>(); let col_comp_second = COMPARISONS .iter() .cloned() .map(|x| (Some("="), Some(x), None)) .collect::>(); let col_comp_third = COMPARISONS .iter() .cloned() .map(|x| (Some("="), Some("="), Some(x))) .collect::>(); let all_comps = [col_comp_first, col_comp_second, col_comp_third].concat(); const ORDER_BY: [Option<&str>; 3] = [None, Some("DESC"), Some("ASC")]; const ITERATIONS: usize = 10000; for i in 0..ITERATIONS { if i % (ITERATIONS / 100) == 0 { println!( "index_scan_compound_key_fuzz: iteration {}/{}", i + 1, ITERATIONS ); } // let's choose random columns from the table let col_choices = ["x", "y", "z", "nonindexed_col"]; let col_choices_weights = [10.0, 10.0, 10.0, 3.0]; let num_cols_in_select = rng.random_range(1..=4); let mut select_cols = col_choices .choose_multiple_weighted(&mut rng, num_cols_in_select, |s| { let idx = col_choices.iter().position(|c| c == s).unwrap(); col_choices_weights[idx] }) .unwrap() .collect::>() .iter() .map(|x| x.to_string()) .collect::>(); // sort select cols by index of col_choices select_cols.sort_by_cached_key(|x| col_choices.iter().position(|c| c == x).unwrap()); let (comp1, comp2, comp3) = all_comps[rng.random_range(0..all_comps.len())]; // Similarly as for the constraints, generate order by permutations so that the only columns involved in the index seek are potentially part of the ORDER BY. let (order_by1, order_by2, order_by3) = { if comp1.is_some() && comp2.is_some() && comp3.is_some() { ( ORDER_BY[rng.random_range(0..ORDER_BY.len())], ORDER_BY[rng.random_range(0..ORDER_BY.len())], ORDER_BY[rng.random_range(0..ORDER_BY.len())], ) } else if comp1.is_some() && comp2.is_some() { ( ORDER_BY[rng.random_range(0..ORDER_BY.len())], ORDER_BY[rng.random_range(0..ORDER_BY.len())], None, ) } else { (ORDER_BY[rng.random_range(0..ORDER_BY.len())], None, None) } }; // Generate random values for the WHERE clause constraints. Only involve primary key columns. let (col_val_first, col_val_second, col_val_third) = { if comp1.is_some() && comp2.is_some() && comp3.is_some() { ( Some(rng.random_range(0..=3000)), Some(rng.random_range(0..=3000)), Some(rng.random_range(0..=3000)), ) } else if comp1.is_some() && comp2.is_some() { ( Some(rng.random_range(0..=3000)), Some(rng.random_range(0..=3000)), None, ) } else { (Some(rng.random_range(0..=3000)), None, None) } }; // Use a small limit to make the test complete faster let limit = 5; /// Generate a comparison string (e.g. x > 10 AND x < 20) or just x > 10. fn generate_comparison( operator: &str, col_name: &str, col_val: i32, rng: &mut ChaCha8Rng, ) -> String { if operator != "=" && rng.random_range(0..3) == 1 { let val2 = rng.random_range(0..=3000); let op2 = COMPARISONS[rng.random_range(0..COMPARISONS.len())]; format!("{col_name} {operator} {col_val} AND {col_name} {op2} {val2}") } else { format!("{col_name} {operator} {col_val}") } } // Generate WHERE clause string. // Sometimes add another inequality to the WHERE clause (e.g. x > 10 AND x < 20) to exercise range queries. let where_clause_components = vec![ comp1.map(|x| generate_comparison(x, "x", col_val_first.unwrap(), &mut rng)), comp2.map(|x| generate_comparison(x, "y", col_val_second.unwrap(), &mut rng)), comp3.map(|x| generate_comparison(x, "z", col_val_third.unwrap(), &mut rng)), ] .into_iter() .flatten() .collect::>(); let where_clause = if where_clause_components.is_empty() { "".to_string() } else { format!("WHERE {}", where_clause_components.join(" AND ")) }; // Generate ORDER BY string let order_by_components = vec![ order_by1.map(|x| format!("x {x}")), order_by2.map(|x| format!("y {x}")), order_by3.map(|x| format!("z {x}")), ] .into_iter() .flatten() .collect::>(); let order_by = if order_by_components.is_empty() { "".to_string() } else { format!("ORDER BY {}", order_by_components.join(", ")) }; // Generate final query string let query = format!( "SELECT {} FROM t {} {} LIMIT {}", select_cols.join(", "), where_clause, order_by, limit ); log::debug!("query: {query}"); // Execute the query on all databases and compare the results for (i, sqlite_conn) in sqlite_conns.iter().enumerate() { let limbo = limbo_exec_rows(&dbs[i], &limbo_conns[i], &query); let sqlite = sqlite_exec_rows(sqlite_conn, &query); if limbo != sqlite { // if the order by contains exclusively components that are constrained by an equality (=), // sqlite sometimes doesn't bother with ASC/DESC because it doesn't semantically matter // so we need to check that limbo and sqlite return the same results when the ordering is reversed. // because we are generally using LIMIT (to make the test complete faster), we need to rerun the query // without limit and then check that the results are the same if reversed. let order_by_only_equalities = !order_by_components.is_empty() && order_by_components.iter().all(|o: &String| { if o.starts_with("x ") { comp1 == Some("=") } else if o.starts_with("y ") { comp2 == Some("=") } else { comp3 == Some("=") } }); let query_no_limit = format!("SELECT * FROM t {} {} {}", where_clause, order_by, ""); let limbo_no_limit = limbo_exec_rows(&dbs[i], &limbo_conns[i], &query_no_limit); let sqlite_no_limit = sqlite_exec_rows(sqlite_conn, &query_no_limit); let limbo_rev = limbo_no_limit.iter().cloned().rev().collect::>(); if limbo_rev == sqlite_no_limit && order_by_only_equalities { continue; } // finally, if the order by columns specified contain duplicates, sqlite might've returned the rows in an arbitrary different order. // e.g. SELECT x,y,z FROM t ORDER BY x,y -- if there are duplicates on (x,y), the ordering returned might be different for limbo and sqlite. // let's check this case and forgive ourselves if the ordering is different for this reason (but no other reason!) let order_by_cols = select_cols .iter() .enumerate() .filter(|(i, _)| { order_by_components .iter() .any(|o| o.starts_with(col_choices[*i])) }) .map(|(i, _)| i) .collect::>(); let duplicate_on_order_by_exists = { let mut exists = false; 'outer: for (i, row) in limbo_no_limit.iter().enumerate() { for (j, other_row) in limbo_no_limit.iter().enumerate() { if i != j && order_by_cols.iter().all(|&col| row[col] == other_row[col]) { exists = true; break 'outer; } } } exists }; if duplicate_on_order_by_exists { let len_equal = limbo_no_limit.len() == sqlite_no_limit.len(); let all_contained = len_equal && limbo_no_limit.iter().all(|x| sqlite_no_limit.contains(x)); if all_contained { continue; } } panic!( "DIFFERENT RESULTS! limbo: {:?}, sqlite: {:?}, seed: {}, query: {}, table def: {}", limbo, sqlite, seed, query, table_defs[i] ); } } } } #[test] pub fn collation_fuzz() { let _ = env_logger::try_init(); let (mut rng, seed) = rng_from_time_or_env(); println!("collation_fuzz seed: {seed}"); // Build six table variants that assign BINARY/NOCASE/RTRIM across (a,b,c) // and include UNIQUE constraints so that auto-created indexes must honor column collations. let variants: [(&str, &str, &str); 6] = [ ("BINARY", "NOCASE", "RTRIM"), ("BINARY", "RTRIM", "NOCASE"), ("NOCASE", "BINARY", "RTRIM"), ("NOCASE", "RTRIM", "BINARY"), ("RTRIM", "BINARY", "NOCASE"), ("RTRIM", "NOCASE", "BINARY"), ]; let table_defs: Vec = variants .iter() .flat_map(|(ca, cb, cc)| { // Create unique indexes so that index seek/scan behavior with unique constraints is exercised too. vec![ // No unique constraints format!( "CREATE TABLE t (a TEXT COLLATE {ca}, b TEXT COLLATE {cb}, c TEXT COLLATE {cc})" ), // Single column unique constraints format!( "CREATE TABLE t (a TEXT COLLATE {ca}, b TEXT COLLATE {cb}, c TEXT COLLATE {cc}, UNIQUE(a))" ), format!( "CREATE TABLE t (a TEXT COLLATE {ca}, b TEXT COLLATE {cb}, c TEXT COLLATE {cc}, UNIQUE(b))" ), format!( "CREATE TABLE t (a TEXT COLLATE {ca}, b TEXT COLLATE {cb}, c TEXT COLLATE {cc}, UNIQUE(c))" ), // Two column unique constraints format!( "CREATE TABLE t (a TEXT COLLATE {ca}, b TEXT COLLATE {cb}, c TEXT COLLATE {cc}, UNIQUE(a,b))" ), format!( "CREATE TABLE t (a TEXT COLLATE {ca}, b TEXT COLLATE {cb}, c TEXT COLLATE {cc}, UNIQUE(a,c))" ), format!( "CREATE TABLE t (a TEXT COLLATE {ca}, b TEXT COLLATE {cb}, c TEXT COLLATE {cc}, UNIQUE(b,c))" ), // Three column unique constraint format!( "CREATE TABLE t (a TEXT COLLATE {ca}, b TEXT COLLATE {cb}, c TEXT COLLATE {cc}, UNIQUE(a,b,c))" ), ] }) .collect(); // Create databases for each variant using rusqlite, then open limbo on the same file. let dbs: Vec = table_defs .iter() .map(|ddl| TempDatabase::new_with_rusqlite(ddl, true)) .collect(); // Seed data focuses on case and trailing spaces to exercise NOCASE and RTRIM semantics. const STR_POOL: [&str; 36] = [ "", " ", " ", "a", "A", "a ", "A ", "aa", "Aa", "AA", "aa ", "AA ", "abc", "ABC", "abc ", "ABC ", "b", "B", "b ", "B ", "ba", "BA", "ba ", "BA ", "c", "C", "c ", " C", "c C", "C c", "foo", "Foo", "FOO", "bar", "Bar", "BAR", ]; // Insert rows into the SQLite side (shared file) and ignore uniqueness errors to keep seeding going. let row_target = 800usize; for db in dbs.iter() { let sqlite_conn = rusqlite::Connection::open(db.path.clone()).unwrap(); for _ in 0..row_target { let a = STR_POOL[rng.random_range(0..STR_POOL.len())]; let b = STR_POOL[rng.random_range(0..STR_POOL.len())]; let c = STR_POOL[rng.random_range(0..STR_POOL.len())]; let insert = format!( "INSERT INTO t(a,b,c) VALUES ('{}','{}','{}')", a.replace("'", "''"), b.replace("'", "''"), c.replace("'", "''"), ); let _ = sqlite_conn.execute(&insert, params![]); } sqlite_conn.close().unwrap(); } // Open connections for query phase let sqlite_conns: Vec = dbs .iter() .map(|db| rusqlite::Connection::open(db.path.clone()).unwrap()) .collect(); let limbo_conns: Vec<_> = dbs.iter().map(|db| db.connect_limbo()).collect(); // Fuzz WHERE clauses with and without explicit COLLATE on a/b/c let columns = ["a", "b", "c"]; let collates = [None, Some("BINARY"), Some("NOCASE"), Some("RTRIM")]; let (mut rng, seed) = rng_from_time_or_env(); println!("collation_fuzz seed: {seed}"); const ITERS: usize = 1000; for iter in 0..ITERS { if iter % (ITERS / 100).max(1) == 0 { println!("collation_fuzz: iteration {}/{}", iter + 1, ITERS); } // Choose predicate spec let col = columns[rng.random_range(0..columns.len())]; let coll = collates[rng.random_range(0..collates.len())]; let val = STR_POOL[rng.random_range(0..STR_POOL.len())]; let collate_clause = coll.map(|c| format!(" COLLATE {c}")).unwrap_or_default(); let where_clause = format!("WHERE {col}{collate_clause} = '{}'", val.replace("'", "''")); let mut cols_clone = columns.to_vec(); cols_clone.shuffle(&mut rng); let order_by = { let mut order_by = String::new(); for col in cols_clone.iter() { let collate = collates .choose(&mut rng) .unwrap() .map(|c| format!(" COLLATE {c}")) .unwrap_or_default(); let sort_order = if rng.random_bool(0.5) { "ASC" } else { "DESC" }; order_by.push_str(&format!("{col}{collate} {sort_order}, ")); } order_by.push_str("rowid ASC"); // sqlite and turso might return within-group rows in different orders which is semantically ok, so let's add rowid as a tiebreaker order_by }; let query = format!("SELECT a, b, c FROM t {where_clause} ORDER BY {order_by}"); for i in 0..sqlite_conns.len() { let sqlite_rows = sqlite_exec_rows(&sqlite_conns[i], &query); let limbo_rows = limbo_exec_rows(&dbs[i], &limbo_conns[i], &query); assert_eq!( sqlite_rows, limbo_rows, "Different results! limbo: {:?}, sqlite: {:?}, seed: {}, query: {}, table def: {}", limbo_rows, sqlite_rows, seed, query, table_defs[i] ); } } } #[test] #[allow(unused_assignments)] pub fn fk_deferred_constraints_fuzz() { let _ = env_logger::try_init(); let (mut rng, seed) = rng_from_time_or_env(); println!("fk_deferred_constraints_fuzz seed: {seed}"); const OUTER_ITERS: usize = 10; const INNER_ITERS: usize = 100; for outer in 0..OUTER_ITERS { println!("fk_deferred_constraints_fuzz {}/{}", outer + 1, OUTER_ITERS); let limbo_db = TempDatabase::new_empty(true); let sqlite_db = TempDatabase::new_empty(true); let limbo = limbo_db.connect_limbo(); let sqlite = rusqlite::Connection::open(sqlite_db.path.clone()).unwrap(); let mut stmts: Vec = Vec::new(); let mut log_and_exec = |sql: &str| { stmts.push(sql.to_string()); sql.to_string() }; // Enable FKs let s = log_and_exec("PRAGMA foreign_keys=ON"); limbo_exec_rows(&limbo_db, &limbo, &s); sqlite.execute(&s, params![]).unwrap(); // Mix of immediate and deferred FK constraints let s = log_and_exec("CREATE TABLE parent(id INTEGER PRIMARY KEY, a INT, b INT)"); limbo_exec_rows(&limbo_db, &limbo, &s); sqlite.execute(&s, params![]).unwrap(); // Child with DEFERRABLE INITIALLY DEFERRED FK let s = log_and_exec( "CREATE TABLE child_deferred(id INTEGER PRIMARY KEY, pid INT, x INT, \ FOREIGN KEY(pid) REFERENCES parent(id) DEFERRABLE INITIALLY DEFERRED)", ); limbo_exec_rows(&limbo_db, &limbo, &s); sqlite.execute(&s, params![]).unwrap(); // Child with immediate FK (default) let s = log_and_exec( "CREATE TABLE child_immediate(id INTEGER PRIMARY KEY, pid INT, y INT, \ FOREIGN KEY(pid) REFERENCES parent(id))", ); limbo_exec_rows(&limbo_db, &limbo, &s); sqlite.execute(&s, params![]).unwrap(); // Composite key parent for deferred testing let s = log_and_exec( "CREATE TABLE parent_comp(a INT NOT NULL, b INT NOT NULL, c INT, PRIMARY KEY(a,b))", ); limbo_exec_rows(&limbo_db, &limbo, &s); sqlite.execute(&s, params![]).unwrap(); // Child with composite deferred FK let s = log_and_exec( "CREATE TABLE child_comp_deferred(id INTEGER PRIMARY KEY, ca INT, cb INT, z INT, \ FOREIGN KEY(ca,cb) REFERENCES parent_comp(a,b) DEFERRABLE INITIALLY DEFERRED)", ); limbo_exec_rows(&limbo_db, &limbo, &s); sqlite.execute(&s, params![]).unwrap(); // Seed initial data let mut parent_ids = std::collections::HashSet::new(); for _ in 0..rng.random_range(10..=25) { let id = rng.random_range(1..=50) as i64; if parent_ids.insert(id) { let a = rng.random_range(-5..=25); let b = rng.random_range(-5..=25); let stmt = log_and_exec(&format!("INSERT INTO parent VALUES ({id}, {a}, {b})")); limbo_exec_rows(&limbo_db, &limbo, &stmt); sqlite.execute(&stmt, params![]).unwrap(); } } // Seed composite parent let mut comp_pairs = std::collections::HashSet::new(); for _ in 0..rng.random_range(3..=10) { let a = rng.random_range(-3..=6) as i64; let b = rng.random_range(-3..=6) as i64; if comp_pairs.insert((a, b)) { let c = rng.random_range(0..=20); let stmt = log_and_exec(&format!("INSERT INTO parent_comp VALUES ({a}, {b}, {c})")); limbo_exec_rows(&limbo_db, &limbo, &stmt); sqlite.execute(&stmt, params![]).unwrap(); } } // Transaction-based mutations with mix of deferred and immediate operations let mut in_tx = false; for tx_num in 0..INNER_ITERS { // Decide if we're in a transaction let start_a_transaction = rng.random_bool(0.7); if start_a_transaction && !in_tx { in_tx = true; let s = log_and_exec("BEGIN"); let sres = sqlite.execute(&s, params![]); let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &s); match (&sres, &lres) { (Ok(_), Ok(_)) | (Err(_), Err(_)) => {} _ => { eprintln!("BEGIN mismatch"); eprintln!("sqlite result: {sres:?}"); eprintln!("limbo result: {lres:?}"); let file = std::fs::File::create("fk_deferred.sql").unwrap(); for stmt in stmts.iter() { writeln!(&file, "{stmt};").unwrap(); } eprintln!("Wrote `tests/fk_deferred.sql` for debugging"); eprintln!("turso path: {}", limbo_db.path.display()); eprintln!("sqlite path: {}", sqlite_db.path.display()); panic!("BEGIN mismatch"); } } } let op = rng.random_range(0..12); let stmt = match op { // Insert into child_deferred (can violate temporarily in transaction) 0 => { let id = rng.random_range(1000..=2000); let pid = if rng.random_bool(0.6) { *parent_ids.iter().choose(&mut rng).unwrap_or(&1) } else { // Non-existent parent - OK if deferred and fixed before commit rng.random_range(200..=300) as i64 }; let x = rng.random_range(-10..=10); format!("INSERT INTO child_deferred VALUES ({id}, {pid}, {x})") } // Insert into child_immediate (must satisfy FK immediately) 1 => { let id = rng.random_range(3000..=4000); let pid = if rng.random_bool(0.8) { *parent_ids.iter().choose(&mut rng).unwrap_or(&1) } else { rng.random_range(200..=300) as i64 }; let y = rng.random_range(-10..=10); format!("INSERT INTO child_immediate VALUES ({id}, {pid}, {y})") } // Insert parent (may fix deferred violations) 2 => { let id = rng.random_range(1..=300); let a = rng.random_range(-5..=25); let b = rng.random_range(-5..=25); parent_ids.insert(id as i64); format!("INSERT INTO parent VALUES ({id}, {a}, {b})") } // Delete parent (may cause violations) 3 => { let id = if rng.random_bool(0.5) { *parent_ids.iter().choose(&mut rng).unwrap_or(&1) } else { rng.random_range(1..=300) as i64 }; format!("DELETE FROM parent WHERE id={id}") } // Update parent PK 4 => { let old = rng.random_range(1..=300); let new = rng.random_range(1..=350); format!("UPDATE parent SET id={new} WHERE id={old}") } // Update child_deferred FK 5 => { let id = rng.random_range(1000..=2000); let pid = if rng.random_bool(0.5) { *parent_ids.iter().choose(&mut rng).unwrap_or(&1) } else { rng.random_range(200..=400) as i64 }; format!("UPDATE child_deferred SET pid={pid} WHERE id={id}") } // Insert into composite deferred child 6 => { let id = rng.random_range(5000..=6000); let (ca, cb) = if rng.random_bool(0.6) { *comp_pairs.iter().choose(&mut rng).unwrap_or(&(1, 1)) } else { // Non-existent composite parent ( rng.random_range(-5..=8) as i64, rng.random_range(-5..=8) as i64, ) }; let z = rng.random_range(0..=10); format!( "INSERT INTO child_comp_deferred VALUES ({id}, {ca}, {cb}, {z}) ON CONFLICT DO NOTHING" ) } // Insert composite parent 7 => { let a = rng.random_range(-5..=8) as i64; let b = rng.random_range(-5..=8) as i64; let c = rng.random_range(0..=20); comp_pairs.insert((a, b)); format!("INSERT INTO parent_comp VALUES ({a}, {b}, {c})") } // UPSERT with deferred child 8 => { let id = rng.random_range(1000..=2000); let pid = if rng.random_bool(0.5) { *parent_ids.iter().choose(&mut rng).unwrap_or(&1) } else { rng.random_range(200..=400) as i64 }; let x = rng.random_range(-10..=10); format!( "INSERT INTO child_deferred VALUES ({id}, {pid}, {x}) ON CONFLICT(id) DO UPDATE SET pid=excluded.pid, x=excluded.x" ) } // Delete from child_deferred 9 => { let id = rng.random_range(1000..=2000); format!("DELETE FROM child_deferred WHERE id={id}") } // Self-referential deferred insert (create temp violation then fix) 10 if start_a_transaction => { let id = rng.random_range(400..=500); let pid = id + 1; // References non-existent yet format!("INSERT INTO child_deferred VALUES ({id}, {pid}, 0)") } _ => { // Default: simple parent insert let id = rng.random_range(1..=300); format!("INSERT INTO parent VALUES ({id}, 0, 0)") } }; let stmt = log_and_exec(&stmt); let sres = sqlite.execute(&stmt, params![]); let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt); if !start_a_transaction && !in_tx { match (sres, lres) { (Ok(_), Ok(_)) | (Err(_), Err(_)) => {} (s, l) => { eprintln!("Non-tx mismatch: sqlite={s:?}, limbo={l:?}"); eprintln!("Statement: {stmt}"); eprintln!("Seed: {seed}, outer: {outer}, tx: {tx_num}, in_tx={in_tx}"); let mut file = std::fs::File::create("fk_deferred.sql").unwrap(); for stmt in stmts.iter() { writeln!(file, "{stmt};").expect("write to file"); } eprintln!("turso path: {}", limbo_db.path.display()); eprintln!("sqlite path: {}", sqlite_db.path.display()); panic!("Non-transactional operation mismatch, file written to 'tests/fk_deferred.sql'"); } } } // Randomly COMMIT or ROLLBACK some of the time if in_tx && rng.random_bool(0.4) { let commit = rng.random_bool(0.7); let s = log_and_exec("COMMIT"); let sres = sqlite.execute(&s, params![]); let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &s); match (sres, lres) { (Ok(_), Ok(_)) => {} (Err(_), Err(_)) => { // Both failed - OK, deferred constraint violation at commit if commit && in_tx { in_tx = false; let s = if commit { log_and_exec("ROLLBACK") } else { log_and_exec("SELECT 1") // noop if we already rolled back }; let sres = sqlite.execute(&s, params![]); let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &s); match (sres, lres) { (Ok(_), Ok(_)) => {} (s, l) => { eprintln!("Post-failed-commit cleanup mismatch: sqlite={s:?}, limbo={l:?}"); let mut file = std::fs::File::create("fk_deferred.sql").unwrap(); for stmt in stmts.iter() { writeln!(file, "{stmt};").expect("write to file"); } eprintln!("turso path: {}", limbo_db.path.display()); eprintln!("sqlite path: {}", sqlite_db.path.display()); panic!("Post-failed-commit cleanup mismatch, file written to 'tests/fk_deferred.sql'"); } } } } (s, l) => { eprintln!("\n=== COMMIT/ROLLBACK mismatch ==="); eprintln!("Operation: {s:?}"); eprintln!("sqlite={s:?}, limbo={l:?}"); eprintln!("Seed: {seed}, outer: {outer}, tx: {tx_num}, in_tx={in_tx}"); eprintln!("--- Replay statements ({}) ---", stmts.len()); let mut file = std::fs::File::create("fk_deferred.sql").unwrap(); for stmt in stmts.iter() { writeln!(file, "{stmt};").expect("write to file"); } eprintln!("Turso path: {}", limbo_db.path.display()); eprintln!("Sqlite path: {}", sqlite_db.path.display()); panic!( "outcome mismatch, .sql file written to `tests/fk_deferred.sql`" ); } } in_tx = false; } } // Print all statements if std::env::var("VERBOSE").is_ok() { println!("{}", stmts.join("\n")); println!("--------- ITERATION COMPLETED ---------"); } } } #[test] pub fn fk_single_pk_mutation_fuzz() { let _ = env_logger::try_init(); let (mut rng, seed) = rng_from_time_or_env(); println!("fk_single_pk_mutation_fuzz seed: {seed}"); const OUTER_ITERS: usize = 20; const INNER_ITERS: usize = 100; for outer in 0..OUTER_ITERS { println!("fk_single_pk_mutation_fuzz {}/{}", outer + 1, OUTER_ITERS); let limbo_db = TempDatabase::new_empty(true); let sqlite_db = TempDatabase::new_empty(true); let limbo = limbo_db.connect_limbo(); let sqlite = rusqlite::Connection::open(sqlite_db.path.clone()).unwrap(); // Statement log for this iteration let mut stmts: Vec = Vec::new(); let mut log_and_exec = |sql: &str| { stmts.push(sql.to_string()); sql.to_string() }; // Enable FKs in both engines let s = log_and_exec("PRAGMA foreign_keys=ON"); limbo_exec_rows(&limbo_db, &limbo, &s); sqlite.execute(&s, params![]).unwrap(); let s = log_and_exec("CREATE TABLE p(id INTEGER PRIMARY KEY, a INT, b INT)"); limbo_exec_rows(&limbo_db, &limbo, &s); sqlite.execute(&s, params![]).unwrap(); let s = log_and_exec( "CREATE TABLE c(id INTEGER PRIMARY KEY, x INT, y INT, FOREIGN KEY(x) REFERENCES p(id))", ); limbo_exec_rows(&limbo_db, &limbo, &s); sqlite.execute(&s, params![]).unwrap(); // Seed parent let n_par = rng.random_range(5..=40); let mut used_ids = std::collections::HashSet::new(); for _ in 0..n_par { let mut id; loop { id = rng.random_range(1..=200) as i64; if used_ids.insert(id) { break; } } let a = rng.random_range(-5..=25); let b = rng.random_range(-5..=25); let stmt = log_and_exec(&format!("INSERT INTO p VALUES ({id}, {a}, {b})")); let l_res = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt); let s_res = sqlite.execute(&stmt, params![]); match (l_res, s_res) { (Ok(_), Ok(_)) | (Err(_), Err(_)) => {} _ => { panic!("Seeding parent insert mismatch"); } } } // Seed child let n_child = rng.random_range(5..=80); for i in 0..n_child { let id = 1000 + i as i64; let x = if rng.random_bool(0.8) { *used_ids.iter().choose(&mut rng).unwrap() } else { rng.random_range(1..=220) as i64 }; let y = rng.random_range(-10..=10); let stmt = log_and_exec(&format!("INSERT INTO c VALUES ({id}, {x}, {y})")); match ( sqlite.execute(&stmt, params![]), limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt), ) { (Ok(_), Ok(_)) => {} (Err(_), Err(_)) => {} (x, y) => { eprintln!("\n=== FK fuzz failure (seeding mismatch) ==="); eprintln!("seed: {seed}, outer: {}", outer + 1); eprintln!("sqlite: {x:?}, limbo: {y:?}"); eprintln!("last stmt: {stmt}"); eprintln!("--- replay statements ({}) ---", stmts.len()); for (i, s) in stmts.iter().enumerate() { eprintln!("{:04}: {};", i + 1, s); } panic!("Seeding child insert mismatch"); } } } // Mutations for _ in 0..INNER_ITERS { let action = rng.random_range(0..8); let stmt = match action { // Parent INSERT 0 => { let mut id; let mut tries = 0; loop { id = rng.random_range(1..=250) as i64; if !used_ids.contains(&id) || tries > 10 { break; } tries += 1; } let a = rng.random_range(-5..=25); let b = rng.random_range(-5..=25); format!("INSERT INTO p VALUES({id}, {a}, {b})") } // Parent UPDATE 1 => { if rng.random_bool(0.5) { let old = rng.random_range(1..=250); let new_id = rng.random_range(1..=260); format!("UPDATE p SET id={new_id} WHERE id={old}") } else { let a = rng.random_range(-5..=25); let b = rng.random_range(-5..=25); let tgt = rng.random_range(1..=260); format!("UPDATE p SET a={a}, b={b} WHERE id={tgt}") } } // Parent DELETE 2 => { let del_id = rng.random_range(1..=260); format!("DELETE FROM p WHERE id={del_id}") } // Child INSERT 3 => { let id = rng.random_range(1000..=2000); let x = if rng.random_bool(0.7) { if let Some(p) = used_ids.iter().choose(&mut rng) { *p } else { rng.random_range(1..=260) as i64 } } else { rng.random_range(1..=260) as i64 }; let y = rng.random_range(-10..=10); format!("INSERT INTO c VALUES({id}, {x}, {y})") } // Child UPDATE 4 => { let pick = rng.random_range(1000..=2000); if rng.random_bool(0.6) { let new_x = if rng.random_bool(0.7) { if let Some(p) = used_ids.iter().choose(&mut rng) { *p } else { rng.random_range(1..=260) as i64 } } else { rng.random_range(1..=260) as i64 }; format!("UPDATE c SET x={new_x} WHERE id={pick}") } else { let new_y = rng.random_range(-10..=10); format!("UPDATE c SET y={new_y} WHERE id={pick}") } } 5 => { // UPSERT parent let pick = rng.random_range(1..=250); if rng.random_bool(0.5) { let a = rng.random_range(-5..=25); let b = rng.random_range(-5..=25); format!( "INSERT INTO p VALUES({pick}, {a}, {b}) ON CONFLICT(id) DO UPDATE SET a=excluded.a, b=excluded.b" ) } else { let a = rng.random_range(-5..=25); let b = rng.random_range(-5..=25); format!( "INSERT INTO p VALUES({pick}, {a}, {b}) \ ON CONFLICT(id) DO NOTHING" ) } } 6 => { // UPSERT child let pick = rng.random_range(1000..=2000); if rng.random_bool(0.5) { let x = if rng.random_bool(0.7) { if let Some(p) = used_ids.iter().choose(&mut rng) { *p } else { rng.random_range(1..=260) as i64 } } else { rng.random_range(1..=260) as i64 }; format!( "INSERT INTO c VALUES({pick}, {x}, 0) ON CONFLICT(id) DO UPDATE SET x=excluded.x" ) } else { let x = if rng.random_bool(0.7) { if let Some(p) = used_ids.iter().choose(&mut rng) { *p } else { rng.random_range(1..=260) as i64 } } else { rng.random_range(1..=260) as i64 }; format!( "INSERT INTO c VALUES({pick}, {x}, 0) ON CONFLICT(id) DO NOTHING" ) } } // Child DELETE _ => { let pick = rng.random_range(1000..=2000); format!("DELETE FROM c WHERE id={pick}") } }; let stmt = log_and_exec(&stmt); let sres = sqlite.execute(&stmt, params![]); let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt); match (sres, lres) { (Ok(_), Ok(_)) => { if stmt.starts_with("INSERT INTO p VALUES(") { if let Some(tok) = stmt.split_whitespace().nth(4) { if let Some(idtok) = tok.split(['(', ',']).nth(1) { if let Ok(idnum) = idtok.parse::() { used_ids.insert(idnum); } } } } let sp = sqlite_exec_rows(&sqlite, "SELECT id,a,b FROM p ORDER BY id"); let sc = sqlite_exec_rows(&sqlite, "SELECT id,x,y FROM c ORDER BY id"); let lp = limbo_exec_rows(&limbo_db, &limbo, "SELECT id,a,b FROM p ORDER BY id"); let lc = limbo_exec_rows(&limbo_db, &limbo, "SELECT id,x,y FROM c ORDER BY id"); if sp != lp || sc != lc { eprintln!("\n=== FK fuzz failure (state mismatch) ==="); eprintln!("seed: {seed}, outer: {}", outer + 1); eprintln!("last stmt: {stmt}"); eprintln!("sqlite p: {sp:?}\nsqlite c: {sc:?}"); eprintln!("limbo p: {lp:?}\nlimbo c: {lc:?}"); eprintln!("--- replay statements ({}) ---", stmts.len()); for (i, s) in stmts.iter().enumerate() { eprintln!("{:04}: {};", i + 1, s); } panic!("State mismatch"); } } (Err(_), Err(_)) => { /* parity OK */ } (ok_sqlite, ok_limbo) => { eprintln!("\n=== FK fuzz failure (outcome mismatch) ==="); eprintln!("seed: {seed}, outer: {}", outer + 1); eprintln!("sqlite: {ok_sqlite:?}, limbo: {ok_limbo:?}"); eprintln!("last stmt: {stmt}"); // dump final states to help decide who is right let sp = sqlite_exec_rows(&sqlite, "SELECT id,a,b FROM p ORDER BY id"); let sc = sqlite_exec_rows(&sqlite, "SELECT id,x,y FROM c ORDER BY id"); let lp = limbo_exec_rows(&limbo_db, &limbo, "SELECT id,a,b FROM p ORDER BY id"); let lc = limbo_exec_rows(&limbo_db, &limbo, "SELECT id,x,y FROM c ORDER BY id"); eprintln!("sqlite p: {sp:?}\nsqlite c: {sc:?}"); eprintln!("turso p: {lp:?}\nturso c: {lc:?}"); eprintln!( "--- writing ({}) statements to fk_fuzz_statements.sql ---", stmts.len() ); let mut file = std::fs::File::create("fk_fuzz_statements.sql").unwrap(); for s in stmts.iter() { let _ = file.write_fmt(format_args!("{s};\n")); } file.flush().unwrap(); panic!("DML outcome mismatch, statements written to tests/fk_fuzz_statements.sql"); } } } } } #[test] pub fn fk_edgecases_fuzzing() { let _ = env_logger::try_init(); let (mut rng, seed) = rng_from_time_or_env(); println!("fk_edgecases_minifuzz seed: {seed}"); const OUTER_ITERS: usize = 20; const INNER_ITERS: usize = 100; fn assert_parity( seed: u64, stmts: &[String], sqlite_res: rusqlite::Result, limbo_res: Result>, turso_core::LimboError>, last_stmt: &str, tag: &str, ) { match (sqlite_res.is_ok(), limbo_res.is_ok()) { (true, true) | (false, false) => (), _ => { eprintln!("\n=== {tag} mismatch ==="); eprintln!("seed: {seed}"); eprintln!("sqlite: {sqlite_res:?}, limbo: {limbo_res:?}"); eprintln!("stmt: {last_stmt}"); eprintln!("--- replay statements ({}) ---", stmts.len()); for (i, s) in stmts.iter().enumerate() { eprintln!("{:04}: {};", i + 1, s); } panic!("{tag}: engines disagree"); } } } // parent rowid, child textified integers -> MustBeInt coercion path for outer in 0..OUTER_ITERS { let limbo_db = TempDatabase::new_empty(true); let sqlite_db = TempDatabase::new_empty(true); let limbo = limbo_db.connect_limbo(); let sqlite = rusqlite::Connection::open(sqlite_db.path.clone()).unwrap(); let mut stmts: Vec = Vec::new(); let log = |s: &str, stmts: &mut Vec| { stmts.push(s.to_string()); s.to_string() }; for s in [ "PRAGMA foreign_keys=ON", "CREATE TABLE p(id INTEGER PRIMARY KEY, a INT)", "CREATE TABLE c(id INTEGER PRIMARY KEY, x INT, FOREIGN KEY(x) REFERENCES p(id))", ] { let s = log(s, &mut stmts); let _ = limbo_exec_rows_fallible(&limbo_db, &limbo, &s); let _ = sqlite.execute(&s, params![]); } // Seed a few parents for _ in 0..rng.random_range(2..=5) { let id = rng.random_range(1..=15); let a = rng.random_range(-5..=5); let s = log(&format!("INSERT INTO p VALUES({id},{a})"), &mut stmts); let _ = limbo_exec_rows_fallible(&limbo_db, &limbo, &s); let _ = sqlite.execute(&s, params![]); } // try random child inserts with weird text-ints for i in 0..INNER_ITERS { let id = 1000 + i as i64; let raw = if rng.random_bool(0.7) { 1 + rng.random_range(0..=15) } else { rng.random_range(100..=200) as i64 }; // Randomly decorate the integer as text with spacing/zeros/plus let pad_left_zeros = rng.random_range(0..=2); let spaces_left = rng.random_range(0..=2); let spaces_right = rng.random_range(0..=2); let plus = if rng.random_bool(0.3) { "+" } else { "" }; let txt_num = format!( "{plus}{:0width$}", raw, width = (1 + pad_left_zeros) as usize ); let txt = format!( "'{}{}{}'", " ".repeat(spaces_left), txt_num, " ".repeat(spaces_right) ); let stmt = log(&format!("INSERT INTO c VALUES({id}, {txt})"), &mut stmts); let sres = sqlite.execute(&stmt, params![]); let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt); assert_parity(seed, &stmts, sres, lres, &stmt, "A: rowid-coercion"); } println!("A {}/{} ok", outer + 1, OUTER_ITERS); } // slf-referential rowid FK for outer in 0..OUTER_ITERS { let limbo_db = TempDatabase::new_empty(true); let sqlite_db = TempDatabase::new_empty(true); let limbo = limbo_db.connect_limbo(); let sqlite = rusqlite::Connection::open(sqlite_db.path.clone()).unwrap(); let mut stmts: Vec = Vec::new(); let log = |s: &str, stmts: &mut Vec| { stmts.push(s.to_string()); s.to_string() }; for s in [ "PRAGMA foreign_keys=ON", "CREATE TABLE t(id INTEGER PRIMARY KEY, rid REFERENCES t(id))", ] { let s = log(s, &mut stmts); limbo_exec_rows(&limbo_db, &limbo, &s); sqlite.execute(&s, params![]).unwrap(); } // Self-match should succeed for many ids for _ in 0..INNER_ITERS { let id = rng.random_range(1..=500); let stmt = log( &format!("INSERT INTO t(id,rid) VALUES({id},{id})"), &mut stmts, ); let sres = sqlite.execute(&stmt, params![]); let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt); assert_parity(seed, &stmts, sres, lres, &stmt, "B1: self-row ok"); } // Mismatch (rid != id) should fail (unless the referenced id already exists). for _ in 0..rng.random_range(1..=10) { let id = rng.random_range(1..=20); let s = log( &format!("INSERT INTO t(id,rid) VALUES({id},{id})"), &mut stmts, ); let s_res = sqlite.execute(&s, params![]); let turso_rs = limbo_exec_rows_fallible(&limbo_db, &limbo, &s); match (s_res.is_ok(), turso_rs.is_ok()) { (true, true) | (false, false) => {} _ => panic!("Seeding self-ref failed differently"), } } for _ in 0..INNER_ITERS { let id = rng.random_range(600..=900); let ref_ = rng.random_range(1..=25); let stmt = log( &format!("INSERT INTO t(id,rid) VALUES({id},{ref_})"), &mut stmts, ); let sres = sqlite.execute(&stmt, params![]); let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt); assert_parity(seed, &stmts, sres, lres, &stmt, "B2: self-row mismatch"); } println!("B {}/{} ok", outer + 1, OUTER_ITERS); } // self-referential UNIQUE(u,v) parent (fast-path for composite) for outer in 0..OUTER_ITERS { let limbo_db = TempDatabase::new_empty(true); let sqlite_db = TempDatabase::new_empty(true); let limbo = limbo_db.connect_limbo(); let sqlite = rusqlite::Connection::open(sqlite_db.path.clone()).unwrap(); let mut stmts: Vec = Vec::new(); let log = |s: &str, stmts: &mut Vec| { stmts.push(s.to_string()); s.to_string() }; let s = log("PRAGMA foreign_keys=ON", &mut stmts); limbo_exec_rows(&limbo_db, &limbo, &s); sqlite.execute(&s, params![]).unwrap(); // Variant the schema a bit: TEXT/TEXT, NUMERIC/TEXT, etc. let decls = [ ("TEXT", "TEXT"), ("TEXT", "NUMERIC"), ("NUMERIC", "TEXT"), ("TEXT", "BLOB"), ]; let (tu, tv) = decls[rng.random_range(0..decls.len())]; let s = log( &format!( "CREATE TABLE sr(u {tu}, v {tv}, cu {tu}, cv {tv}, UNIQUE(u,v), \ FOREIGN KEY(cu,cv) REFERENCES sr(u,v))" ), &mut stmts, ); limbo_exec_rows(&limbo_db, &limbo, &s); sqlite.execute(&s, params![]).unwrap(); // Self-matching composite rows should succeed for _ in 0..INNER_ITERS { // Random small tokens, possibly padded let u = format!("U{}", rng.random_range(0..50)); let v = format!("V{}", rng.random_range(0..50)); let mut cu = u.clone(); let mut cv = v.clone(); // occasionally wrap child refs as blobs/text to stress coercion on parent index if rng.random_bool(0.2) { // child cv as hex blob of ascii v let hex: String = v.bytes().map(|b| format!("{b:02X}")).collect(); cv = format!("x'{hex}'"); } else { cu = format!("'{cu}'"); cv = format!("'{cv}'"); } let stmt = log( &format!("INSERT INTO sr(u,v,cu,cv) VALUES('{u}','{v}',{cu},{cv})"), &mut stmts, ); let sres = sqlite.execute(&stmt, params![]); let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt); assert_parity(seed, &stmts, sres, lres, &stmt, "C1: self-UNIQUE ok"); } // Non-self-match likely fails unless earlier rows happen to satisfy (u,v) for _ in 0..INNER_ITERS { let u = format!("U{}", rng.random_range(60..100)); let v = format!("V{}", rng.random_range(60..100)); let cu = format!("'U{}'", rng.random_range(0..40)); let cv = format!("'{}{}'", "V", rng.random_range(0..40)); let stmt = log( &format!("INSERT INTO sr(u,v,cu,cv) VALUES('{u}','{v}',{cu},{cv})"), &mut stmts, ); let sres = sqlite.execute(&stmt, params![]); let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt); assert_parity(seed, &stmts, sres, lres, &stmt, "C2: self-UNIQUE mismatch"); } println!("C {}/{} ok", outer + 1, OUTER_ITERS); } // parent TEXT UNIQUE(u,v), child types differ; rely on parent-index affinities for outer in 0..OUTER_ITERS { let limbo_db = TempDatabase::new_empty(true); let sqlite_db = TempDatabase::new_empty(true); let limbo = limbo_db.connect_limbo(); let sqlite = rusqlite::Connection::open(sqlite_db.path.clone()).unwrap(); let mut stmts: Vec = Vec::new(); let log = |s: &str, stmts: &mut Vec| { stmts.push(s.to_string()); s.to_string() }; for s in [ "PRAGMA foreign_keys=ON", "CREATE TABLE parent(u TEXT, v TEXT, UNIQUE(u,v))", "CREATE TABLE child(id INTEGER PRIMARY KEY, cu INT, cv BLOB, \ FOREIGN KEY(cu,cv) REFERENCES parent(u,v))", ] { let s = log(s, &mut stmts); limbo_exec_rows(&limbo_db, &limbo, &s); sqlite.execute(&s, params![]).unwrap(); } for _ in 0..rng.random_range(3..=8) { let u_raw = rng.random_range(0..=9); let v_raw = rng.random_range(0..=9); let u = if rng.random_bool(0.4) { format!("+{u_raw}") } else { format!("{u_raw}") }; let v = if rng.random_bool(0.5) { format!("{v_raw:02}",) } else { format!("{v_raw}") }; let s = log( &format!("INSERT INTO parent VALUES('{u}','{v}')"), &mut stmts, ); let l_res = limbo_exec_rows_fallible(&limbo_db, &limbo, &s); let s_res = sqlite.execute(&s, params![]); match (s_res, l_res) { (Ok(_), Ok(_)) | (Err(_), Err(_)) => {} (x, y) => { panic!("Parent seeding mismatch: sqlite {x:?}, limbo {y:?}"); } } } for i in 0..INNER_ITERS { let id = i as i64 + 1; let u_txt = if rng.random_bool(0.7) { format!("+{}", rng.random_range(0..=9)) } else { format!("{}", rng.random_range(0..=9)) }; let v_txt = if rng.random_bool(0.5) { format!("{:02}", rng.random_range(0..=9)) } else { format!("{}", rng.random_range(0..=9)) }; // produce child literals that *look different* but should match under TEXT affinity // cu uses integer-ish form of u; cv uses blob of ASCII v or quoted v randomly. let cu = if let Ok(u_int) = u_txt.trim().trim_start_matches('+').parse::() { if rng.random_bool(0.5) { format!("{u_int}",) } else { format!("'{u_txt}'") } } else { format!("'{u_txt}'") }; let cv = if rng.random_bool(0.6) { let hex: String = v_txt .as_bytes() .iter() .map(|b| format!("{b:02X}")) .collect(); format!("x'{hex}'") } else { format!("'{v_txt}'") }; let stmt = log( &format!("INSERT INTO child VALUES({id}, {cu}, {cv})"), &mut stmts, ); let sres = sqlite.execute(&stmt, params![]); let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt); assert_parity(seed, &stmts, sres, lres, &stmt, "D1: parent-index affinity"); } for i in 0..(INNER_ITERS / 3) { let id = 10_000 + i as i64; let cu = rng.random_range(0..=9); let miss = rng.random_range(10..=19); let stmt = log( &format!("INSERT INTO child VALUES({id}, {cu}, x'{miss:02X}')"), &mut stmts, ); let sres = sqlite.execute(&stmt, params![]); let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt); assert_parity(seed, &stmts, sres, lres, &stmt, "D2: parent-index negative"); } println!("D {}/{} ok", outer + 1, OUTER_ITERS); } println!("fk_edgecases_minifuzz complete (seed {seed})"); } #[test] pub fn fk_composite_pk_mutation_fuzz() { let _ = env_logger::try_init(); let (mut rng, seed) = rng_from_time_or_env(); println!("fk_composite_pk_mutation_fuzz seed: {seed}"); const OUTER_ITERS: usize = 10; const INNER_ITERS: usize = 100; for outer in 0..OUTER_ITERS { println!( "fk_composite_pk_mutation_fuzz {}/{}", outer + 1, OUTER_ITERS ); let limbo_db = TempDatabase::new_empty(true); let sqlite_db = TempDatabase::new_empty(true); let limbo = limbo_db.connect_limbo(); let sqlite = rusqlite::Connection::open(sqlite_db.path.clone()).unwrap(); let mut stmts: Vec = Vec::new(); let mut log_and_exec = |sql: &str| { stmts.push(sql.to_string()); sql.to_string() }; // Enable FKs in both engines let _ = log_and_exec("PRAGMA foreign_keys=ON"); limbo_exec_rows(&limbo_db, &limbo, "PRAGMA foreign_keys=ON"); sqlite.execute("PRAGMA foreign_keys=ON", params![]).unwrap(); // Parent PK is composite (a,b). Child references (x,y) -> (a,b). let s = log_and_exec( "CREATE TABLE p(a INT NOT NULL, b INT NOT NULL, v INT, PRIMARY KEY(a,b))", ); limbo_exec_rows(&limbo_db, &limbo, &s); sqlite.execute(&s, params![]).unwrap(); let s = log_and_exec( "CREATE TABLE c(id INTEGER PRIMARY KEY, x INT, y INT, w INT, \ FOREIGN KEY(x,y) REFERENCES p(a,b))", ); limbo_exec_rows(&limbo_db, &limbo, &s); sqlite.execute(&s, params![]).unwrap(); // Seed parent: small grid of (a,b) let mut pairs: Vec<(i64, i64)> = Vec::new(); for _ in 0..rng.random_range(5..=25) { let a = rng.random_range(-3..=6); let b = rng.random_range(-3..=6); if !pairs.contains(&(a, b)) { pairs.push((a, b)); let v = rng.random_range(0..=20); let stmt = log_and_exec(&format!("INSERT INTO p VALUES({a},{b},{v})")); limbo_exec_rows(&limbo_db, &limbo, &stmt); sqlite.execute(&stmt, params![]).unwrap(); } } // Seed child rows, 70% chance to reference existing (a,b) for i in 0..rng.random_range(5..=60) { let id = 5000 + i as i64; let (x, y) = if rng.random_bool(0.7) { *pairs.choose(&mut rng).unwrap_or(&(0, 0)) } else { (rng.random_range(-4..=7), rng.random_range(-4..=7)) }; let w = rng.random_range(-10..=10); let stmt = log_and_exec(&format!("INSERT INTO c VALUES({id}, {x}, {y}, {w})")); let _ = sqlite.execute(&stmt, params![]); let _ = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt); } for _ in 0..INNER_ITERS { let op = rng.random_range(0..7); let stmt = log_and_exec(&match op { // INSERT parent 0 => { let a = rng.random_range(-4..=8); let b = rng.random_range(-4..=8); let v = rng.random_range(0..=20); format!("INSERT INTO p VALUES({a},{b},{v})") } // UPDATE parent composite key (a,b) 1 => { let a_old = rng.random_range(-4..=8); let b_old = rng.random_range(-4..=8); let a_new = rng.random_range(-4..=8); let b_new = rng.random_range(-4..=8); format!("UPDATE p SET a={a_new}, b={b_new} WHERE a={a_old} AND b={b_old}") } // DELETE parent 2 => { let a = rng.random_range(-4..=8); let b = rng.random_range(-4..=8); format!("DELETE FROM p WHERE a={a} AND b={b}") } // INSERT child 3 => { let id = rng.random_range(5000..=7000); let (x, y) = if rng.random_bool(0.7) { *pairs.choose(&mut rng).unwrap_or(&(0, 0)) } else { (rng.random_range(-4..=8), rng.random_range(-4..=8)) }; let w = rng.random_range(-10..=10); format!("INSERT INTO c VALUES({id},{x},{y},{w})") } // UPDATE child FK columns (x,y) 4 => { let id = rng.random_range(5000..=7000); let (x, y) = if rng.random_bool(0.7) { *pairs.choose(&mut rng).unwrap_or(&(0, 0)) } else { (rng.random_range(-4..=8), rng.random_range(-4..=8)) }; format!("UPDATE c SET x={x}, y={y} WHERE id={id}") } 5 => { // UPSERT parent if rng.random_bool(0.5) { let a = rng.random_range(-4..=8); let b = rng.random_range(-4..=8); let v = rng.random_range(0..=20); format!( "INSERT INTO p VALUES({a},{b},{v}) ON CONFLICT(a,b) DO UPDATE SET v=excluded.v" ) } else { let a = rng.random_range(-4..=8); let b = rng.random_range(-4..=8); format!( "INSERT INTO p VALUES({a},{b},{}) ON CONFLICT(a,b) DO NOTHING", rng.random_range(0..=20) ) } } 6 => { // UPSERT child let id = rng.random_range(5000..=7000); let (x, y) = if rng.random_bool(0.7) { *pairs.choose(&mut rng).unwrap_or(&(0, 0)) } else { (rng.random_range(-4..=8), rng.random_range(-4..=8)) }; format!( "INSERT INTO c VALUES({id},{x},{y},{}) ON CONFLICT(id) DO UPDATE SET x=excluded.x, y=excluded.y", rng.random_range(-10..=10) ) } // DELETE child _ => { let id = rng.random_range(5000..=7000); format!("DELETE FROM c WHERE id={id}") } }); let sres = sqlite.execute(&stmt, params![]); let lres = limbo_exec_rows_fallible(&limbo_db, &limbo, &stmt); match (sres, lres) { (Ok(_), Ok(_)) => { // Compare canonical states let sp = sqlite_exec_rows(&sqlite, "SELECT a,b,v FROM p ORDER BY a,b,v"); let sc = sqlite_exec_rows(&sqlite, "SELECT id,x,y,w FROM c ORDER BY id"); let lp = limbo_exec_rows( &limbo_db, &limbo, "SELECT a,b,v FROM p ORDER BY a,b,v", ); let lc = limbo_exec_rows( &limbo_db, &limbo, "SELECT id,x,y,w FROM c ORDER BY id", ); assert_eq!(sp, lp, "seed {seed}, stmt {stmt}"); assert_eq!(sc, lc, "seed {seed}, stmt {stmt}"); } (Err(_), Err(_)) => { /* both errored -> parity OK */ } (ok_s, ok_l) => { eprintln!( "Mismatch sqlite={ok_s:?}, limbo={ok_l:?}, stmt={stmt}, seed={seed}" ); let sp = sqlite_exec_rows(&sqlite, "SELECT a,b,v FROM p ORDER BY a,b,v"); let sc = sqlite_exec_rows(&sqlite, "SELECT id,x,y,w FROM c ORDER BY id"); let lp = limbo_exec_rows( &limbo_db, &limbo, "SELECT a,b,v FROM p ORDER BY a,b,v", ); let lc = limbo_exec_rows( &limbo_db, &limbo, "SELECT id,x,y,w FROM c ORDER BY id", ); eprintln!( "sqlite p={sp:?}\nsqlite c={sc:?}\nlimbo p={lp:?}\nlimbo c={lc:?}" ); let mut file = std::fs::File::create("fk_composite_fuzz_statements.sql").unwrap(); for s in stmts.iter() { let _ = writeln!(&file, "{s};"); } file.flush().unwrap(); panic!("DML outcome mismatch, sql file written to tests/fk_composite_fuzz_statements.sql"); } } } } } #[test] /// Create a table with a random number of columns and indexes, and then randomly update or delete rows from the table. /// Verify that the results are the same for SQLite and Turso. pub fn table_index_mutation_fuzz() { let _ = env_logger::try_init(); let (mut rng, seed) = rng_from_time_or_env(); println!("table_index_mutation_fuzz seed: {seed}"); const OUTER_ITERATIONS: usize = 100; for i in 0..OUTER_ITERATIONS { println!( "table_index_mutation_fuzz iteration {}/{}", i + 1, OUTER_ITERATIONS ); let limbo_db = TempDatabase::new_empty(true); let sqlite_db = TempDatabase::new_empty(true); let num_cols = rng.random_range(1..=10); let mut table_cols = vec!["id INTEGER PRIMARY KEY AUTOINCREMENT".to_string()]; table_cols.extend( (0..num_cols) .map(|i| format!("c{i} INTEGER")) .collect::>(), ); let table_def = table_cols.join(", "); let table_def = format!("CREATE TABLE t ({table_def})"); let num_indexes = rng.random_range(0..=num_cols); let mut indexes = Vec::new(); for i in 0..num_indexes { // Decide if this should be a single-column or multi-column index let is_multi_column = rng.random_bool(0.5) && num_cols > 1; if is_multi_column { // Create a multi-column index with 2-3 columns let num_index_cols = rng.random_range(2..=3.min(num_cols)); let mut index_cols = Vec::new(); let mut available_cols: Vec = (0..num_cols).collect(); for _ in 0..num_index_cols { let idx = rng.random_range(0..available_cols.len()); let col = available_cols.remove(idx); index_cols.push(format!("c{col}")); } indexes.push(format!( "CREATE INDEX idx_{i} ON t({})", index_cols.join(", ") )); } else { // Single-column index let col = rng.random_range(0..num_cols); indexes.push(format!("CREATE INDEX idx_{i} ON t(c{col})")); } } // Create tables and indexes in both databases let limbo_conn = limbo_db.connect_limbo(); limbo_exec_rows(&limbo_db, &limbo_conn, &table_def); for t in indexes.iter() { limbo_exec_rows(&limbo_db, &limbo_conn, t); } let sqlite_conn = rusqlite::Connection::open(sqlite_db.path.clone()).unwrap(); sqlite_conn.execute(&table_def, params![]).unwrap(); for t in indexes.iter() { sqlite_conn.execute(t, params![]).unwrap(); } // Generate initial data let num_inserts = rng.random_range(10..=1000); let mut tuples = HashSet::new(); while tuples.len() < num_inserts { tuples.insert( (0..num_cols) .map(|_| rng.random_range(0..1000)) .collect::>(), ); } let mut insert_values = Vec::new(); for tuple in tuples { insert_values.push(format!( "({})", tuple .iter() .map(|x| x.to_string()) .collect::>() .join(", ") )); } // Track executed statements in case we fail let mut dml_statements = Vec::new(); let col_names = (0..num_cols) .map(|i| format!("c{i}")) .collect::>() .join(", "); let insert = format!( "INSERT INTO t ({}) VALUES {}", col_names, insert_values.join(", ") ); dml_statements.push(insert.clone()); // Insert initial data into both databases sqlite_conn.execute(&insert, params![]).unwrap(); limbo_exec_rows(&limbo_db, &limbo_conn, &insert); const COMPARISONS: [&str; 3] = ["=", "<", ">"]; const INNER_ITERATIONS: usize = 20; for _ in 0..INNER_ITERATIONS { let do_update = rng.random_range(0..2) == 0; let comparison = COMPARISONS[rng.random_range(0..COMPARISONS.len())]; let affected_col = rng.random_range(0..num_cols); let predicate_col = rng.random_range(0..num_cols); let predicate_value = rng.random_range(0..1000); enum WhereClause { Normal, Gaps, Omit, } let where_kind = match rng.random_range(0..10) { 0..8 => WhereClause::Normal, 8 => WhereClause::Gaps, 9 => WhereClause::Omit, _ => unreachable!(), }; let where_clause = match where_kind { WhereClause::Normal => format!("WHERE c{predicate_col} {comparison} {predicate_value}"), WhereClause::Gaps => format!("WHERE c{predicate_col} {comparison} {predicate_value} AND c{predicate_col} % 2 = 0"), WhereClause::Omit => "".to_string(), }; let query = if do_update { let num_updates = rng.random_range(1..=num_cols); let mut values = Vec::new(); for _ in 0..num_updates { let new_y = if rng.random_bool(0.5) { // Update to a constant value rng.random_range(0..1000).to_string() } else { let source_col = rng.random_range(0..num_cols); // Update to a value that is a function of the another column let operator = *["+", "-"].choose(&mut rng).unwrap(); let amount = rng.random_range(0..1000); format!("c{source_col} {operator} {amount}") }; values.push(format!("c{affected_col} = {new_y}")); } format!("UPDATE t SET {} {where_clause}", values.join(", ")) } else { format!("DELETE FROM t {where_clause}") }; dml_statements.push(query.clone()); // Execute on both databases sqlite_conn.execute(&query, params![]).unwrap(); let limbo_res = limbo_exec_rows_fallible(&limbo_db, &limbo_conn, &query); if let Err(e) = &limbo_res { // print all the DDL and DML statements println!("{table_def};"); for t in indexes.iter() { println!("{t};"); } for t in dml_statements.iter() { println!("{t};"); } panic!("Error executing query: {e}"); } // Verify results match exactly let verify_query = format!( "SELECT * FROM t ORDER BY {}", (0..num_cols) .map(|i| format!("c{i}")) .collect::>() .join(", ") ); let sqlite_rows = sqlite_exec_rows(&sqlite_conn, &verify_query); let limbo_rows = limbo_exec_rows(&limbo_db, &limbo_conn, &verify_query); assert_eq!( sqlite_rows, limbo_rows, "Different results after mutation! limbo: {limbo_rows:?}, sqlite: {sqlite_rows:?}, seed: {seed}, query: {query}", ); // Run integrity check on limbo db using rusqlite if let Err(e) = rusqlite_integrity_check(&limbo_db.path) { println!("{table_def};"); for t in indexes.iter() { println!("{t};"); } for t in dml_statements.iter() { println!("{t};"); } println!("{query};"); panic!("seed: {seed}, error: {e}"); } if sqlite_rows.is_empty() { break; } } } } #[test] pub fn partial_index_mutation_and_upsert_fuzz() { index_mutation_upsert_fuzz(1.0, 4); } #[test] pub fn simple_index_mutation_and_upsert_fuzz() { index_mutation_upsert_fuzz(0.0, 4); } fn index_mutation_upsert_fuzz(partial_index_prob: f64, conflict_chain_max_len: u32) { let _ = env_logger::try_init(); const OUTER_ITERS: usize = 5; const INNER_ITERS: usize = 500; let (mut rng, seed) = rng_from_time_or_env(); println!("partial_index_mutation_and_upsert_fuzz seed: {seed}"); // we want to hit unique constraints fairly often so limit the insert values const K_POOL: [&str; 35] = [ "a", "aa", "abc", "A", "B", "zzz", "foo", "bar", "baz", "fizz", "buzz", "bb", "cc", "dd", "ee", "ff", "gg", "hh", "jj", "kk", "ll", "mm", "nn", "oo", "pp", "qq", "rr", "ss", "tt", "uu", "vv", "ww", "xx", "yy", "zz", ]; for outer in 0..OUTER_ITERS { println!(" "); println!( "partial_index_mutation_and_upsert_fuzz iteration {}/{}", outer + 1, OUTER_ITERS ); // Columns: id (rowid PK), plus a few data columns we can reference in predicates/keys. let limbo_db = TempDatabase::new_empty(true); let sqlite_db = TempDatabase::new_empty(true); let limbo_conn = limbo_db.connect_limbo(); let sqlite = rusqlite::Connection::open(sqlite_db.path.clone()).unwrap(); let num_cols = rng.random_range(2..=4); // We'll always include a TEXT "k" and a couple INT columns to give predicates variety. // Build: id INTEGER PRIMARY KEY, k TEXT, c0 INT, c1 INT, ... let mut cols: Vec = vec![ "id INTEGER PRIMARY KEY AUTOINCREMENT".into(), "k TEXT".into(), ]; for i in 0..(num_cols - 1) { cols.push(format!("c{i} INT")); } let create = format!("CREATE TABLE t ({})", cols.join(", ")); println!("{create};"); limbo_exec_rows(&limbo_db, &limbo_conn, &create); sqlite.execute(&create, rusqlite::params![]).unwrap(); // Helper to list usable columns for keys/predicates let int_cols: Vec = (0..(num_cols - 1)).map(|i| format!("c{i}")).collect(); let functions = ["lower", "upper", "length"]; let num_pidx = rng.random_range(0..=3); let mut conflict_match_targets = vec!["".to_string(), "(id)".to_string()]; let mut idx_ddls: Vec = Vec::new(); for i in 0..num_pidx { // Pick 1 or 2 key columns; always include "k" sometimes to get frequent conflicts. let mut key_cols = Vec::new(); if rng.random_bool(0.7) { key_cols.push("k".to_string()); } if key_cols.is_empty() || rng.random_bool(0.5) { // Add one INT col to make compound keys common if !int_cols.is_empty() { let c = int_cols[rng.random_range(0..int_cols.len())].clone(); if !key_cols.contains(&c) { key_cols.push(c); } } } // Ensure at least one key column if key_cols.is_empty() { key_cols.push("k".to_string()); } // Build a simple deterministic partial predicate: // Examples: // c0 > 10 AND c1 < 50 // c0 IS NOT NULL // id > 5 AND c0 >= 0 // lower(k) = k let pred = { // parts we can AND/OR (we’ll only AND for stability) let mut parts: Vec = Vec::new(); // Maybe include rowid (id) bound if rng.random_bool(0.4) { let n = rng.random_range(0..20); let op = *["<", "<=", ">", ">="].choose(&mut rng).unwrap(); parts.push(format!("id {op} {n}")); } // Maybe include int column comparison if !int_cols.is_empty() && rng.random_bool(0.8) { let c = &int_cols[rng.random_range(0..int_cols.len())]; match rng.random_range(0..3) { 0 => parts.push(format!("{c} IS NOT NULL")), 1 => { let n = rng.random_range(-10..=20); let op = *["<", "<=", "=", ">=", ">"].choose(&mut rng).unwrap(); parts.push(format!("{c} {op} {n}")); } _ => { let n = rng.random_range(0..=1); parts.push(format!( "{c} IS {}", if n == 0 { "NULL" } else { "NOT NULL" } )); } } } if rng.random_bool(0.2) { parts.push(format!("{}(k) = k", functions.choose(&mut rng).unwrap())); } // Guarantee at least one part if parts.is_empty() { parts.push("1".to_string()); } parts.join(" AND ") }; let ddl = if rng.random_bool(partial_index_prob) { format!( "CREATE UNIQUE INDEX idx_p{}_{} ON t({}) WHERE {}", outer, i, key_cols.join(","), pred ) } else { // ON CONFLICT (...) can use only column set with non-partial UNIQUE constraint conflict_match_targets.push(format!("({})", key_cols.join(","))); format!( "CREATE UNIQUE INDEX idx_p{}_{} ON t({})", outer, i, key_cols.join(","), ) }; idx_ddls.push(ddl.clone()); // Create in both engines println!("{ddl};"); limbo_exec_rows(&limbo_db, &limbo_conn, &ddl); sqlite.execute(&ddl, rusqlite::params![]).unwrap(); } let seed_rows = rng.random_range(10..=80); for _ in 0..seed_rows { let k = *K_POOL.choose(&mut rng).unwrap(); let mut vals: Vec = vec!["NULL".into(), format!("'{k}'")]; // id NULL -> auto for _ in 0..(num_cols - 1) { // bias a bit toward small ints & NULL to make predicate flipping common let v = match rng.random_range(0..6) { 0 => "NULL".into(), _ => rng.random_range(-5..=15).to_string(), }; vals.push(v); } let ins = format!("INSERT INTO t VALUES ({})", vals.join(", ")); println!("{ins}; -- seed"); // Execute on both; ignore errors due to partial unique conflicts (keep seeding going) let sqlite_res = sqlite.execute(&ins, rusqlite::params![]); let limbo_res = limbo_exec_rows_fallible(&limbo_db, &limbo_conn, &ins); assert!(sqlite_res.is_ok() == limbo_res.is_ok()); } for _ in 0..INNER_ITERS { let action = rng.random_range(0..4); // 0: INSERT, 1: UPDATE, 2: DELETE, 3: UPSERT (catch-all) let stmt = match action { // INSERT 0 => { let k = *K_POOL.choose(&mut rng).unwrap(); let mut cols_list = vec!["k".to_string()]; let mut vals_list = vec![format!("'{k}'")]; for i in 0..(num_cols - 1) { if rng.random_bool(0.8) { cols_list.push(format!("c{i}")); vals_list.push(if rng.random_bool(0.15) { "NULL".into() } else { rng.random_range(-5..=15).to_string() }); } } format!( "INSERT INTO t({}) VALUES({})", cols_list.join(","), vals_list.join(",") ) } // UPDATE (randomly touch either key or predicate column) 1 => { // choose a column let col_pick = if rng.random_bool(0.5) { "k".to_string() } else { format!("c{}", rng.random_range(0..(num_cols - 1))) }; let new_val = if col_pick == "k" { format!("'{}'", K_POOL.choose(&mut rng).unwrap()) } else if rng.random_bool(0.2) { "NULL".into() } else { rng.random_range(-5..=15).to_string() }; // predicate to affect some rows let wc = if rng.random_bool(0.6) { let pred_col = format!("c{}", rng.random_range(0..(num_cols - 1))); let op = *["<", "<=", "=", ">=", ">"].choose(&mut rng).unwrap(); let n = rng.random_range(-5..=15); format!("WHERE {pred_col} {op} {n}") } else { // toggle rows by id parity "WHERE (id % 2) = 0".into() }; format!("UPDATE t SET {col_pick} = {new_val} {wc}") } // DELETE 2 => { let wc = if rng.random_bool(0.5) { // delete rows inside partial predicate zones match int_cols.len() { 0 => "WHERE lower(k) = k".to_string(), _ => { let c = &int_cols[rng.random_range(0..int_cols.len())]; let n = rng.random_range(-5..=15); let op = *["<", "<=", "=", ">=", ">"].choose(&mut rng).unwrap(); format!("WHERE {c} {op} {n}") } } } else { "WHERE id % 3 = 1".to_string() }; format!("DELETE FROM t {wc}") } // UPSERT catch-all is allowed even if only partial unique constraints exist 3 => { let k = *K_POOL.choose(&mut rng).unwrap(); let mut cols_list = vec!["k".to_string()]; let mut vals_list = vec![format!("'{k}'")]; for i in 0..(num_cols - 1) { if rng.random_bool(0.8) { cols_list.push(format!("c{i}")); vals_list.push(if rng.random_bool(0.2) { "NULL".into() } else { rng.random_range(-5..=15).to_string() }); } } let chain_length = rng.random_range(0..=conflict_chain_max_len); let mut on_conflict = String::new(); for _ in 0..chain_length { let idx = rng.random_range(0..conflict_match_targets.len()); let target = &conflict_match_targets[idx]; if rng.random_bool(0.8) { let mut set_list = Vec::new(); let num_set = rng.random_range(1..=cols_list.len()); let set_cols = cols_list .choose_multiple(&mut rng, num_set) .cloned() .collect::>(); for c in set_cols.iter() { let v = if c == "k" { format!("'{}'", K_POOL.choose(&mut rng).unwrap()) } else if rng.random_bool(0.2) { "NULL".into() } else { rng.random_range(-5..=15).to_string() }; set_list.push(format!("{c} = {v}")); } on_conflict.push_str(&format!( " ON CONFLICT{} DO UPDATE SET {}", target, set_list.join(", ") )); } else { on_conflict.push_str(&format!(" ON CONFLICT{target} DO NOTHING")); } } format!( "INSERT INTO t({}) VALUES({}) {}", cols_list.join(","), vals_list.join(","), on_conflict ) } _ => unreachable!(), }; // Execute on SQLite first; capture success/error, then run on turso and demand same outcome. let sqlite_res = sqlite.execute(&stmt, rusqlite::params![]); let limbo_res = limbo_exec_rows_fallible(&limbo_db, &limbo_conn, &stmt); match (sqlite_res, limbo_res) { (Ok(_), Ok(_)) => { println!("{stmt};"); // Compare canonical table state let verify = format!( "SELECT id, k{} FROM t ORDER BY id, k{}", (0..(num_cols - 1)) .map(|i| format!(", c{i}")) .collect::(), (0..(num_cols - 1)) .map(|i| format!(", c{i}")) .collect::(), ); let s = sqlite_exec_rows(&sqlite, &verify); let l = limbo_exec_rows(&limbo_db, &limbo_conn, &verify); assert_eq!( l, s, "stmt: {stmt}, seed: {seed}, create: {create}, idx: {idx_ddls:?}" ); } (Err(_), Err(_)) => { // Both errored continue; } // Mismatch: dump context (ok_sqlite, ok_turso) => { println!("{stmt};"); eprintln!("Schema: {create};"); for d in idx_ddls.iter() { eprintln!("{d};"); } panic!( "DML outcome mismatch (sqlite: {ok_sqlite:?}, turso ok: {ok_turso:?}) \n stmt: {stmt}, seed: {seed}" ); } } } } } #[test] pub fn compound_select_fuzz() { let _ = env_logger::try_init(); let (mut rng, seed) = rng_from_time_or_env(); log::info!("compound_select_fuzz seed: {seed}"); // Constants for fuzzing parameters const MAX_TABLES: usize = 7; const MIN_TABLES: usize = 1; const MAX_ROWS_PER_TABLE: usize = 40; const MIN_ROWS_PER_TABLE: usize = 5; const NUM_FUZZ_ITERATIONS: usize = 2000; // How many more SELECTs than tables can be in a UNION (e.g., if 2 tables, max 2+2=4 SELECTs) const MAX_SELECTS_IN_UNION_EXTRA: usize = 2; const MAX_LIMIT_VALUE: usize = 50; let db = TempDatabase::new_empty(true); let limbo_conn = db.connect_limbo(); let sqlite_conn = rusqlite::Connection::open_in_memory().unwrap(); let mut table_names = Vec::new(); let num_tables = rng.random_range(MIN_TABLES..=MAX_TABLES); const COLS: [&str; 3] = ["c1", "c2", "c3"]; for i in 0..num_tables { let table_name = format!("t{i}"); let create_table_sql = format!( "CREATE TABLE {} ({})", table_name, COLS.iter() .map(|c| format!("{c} INTEGER")) .collect::>() .join(", ") ); limbo_exec_rows(&db, &limbo_conn, &create_table_sql); sqlite_exec_rows(&sqlite_conn, &create_table_sql); let num_rows_to_insert = rng.random_range(MIN_ROWS_PER_TABLE..=MAX_ROWS_PER_TABLE); for _ in 0..num_rows_to_insert { let c1_val: i64 = rng.random_range(-3..3); let c2_val: i64 = rng.random_range(-3..3); let c3_val: i64 = rng.random_range(-3..3); let insert_sql = format!("INSERT INTO {table_name} VALUES ({c1_val}, {c2_val}, {c3_val})",); limbo_exec_rows(&db, &limbo_conn, &insert_sql); sqlite_exec_rows(&sqlite_conn, &insert_sql); } table_names.push(table_name); } for iter_num in 0..NUM_FUZZ_ITERATIONS { // Number of SELECT clauses let num_selects_in_union = rng.random_range(1..=(table_names.len() + MAX_SELECTS_IN_UNION_EXTRA)); let mut select_statements = Vec::new(); // Randomly pick a subset of columns to select from let num_cols_to_select = rng.random_range(1..=COLS.len()); let cols_to_select = COLS .choose_multiple(&mut rng, num_cols_to_select) .map(|c| c.to_string()) .collect::>(); let mut has_right_most_values = false; for i in 0..num_selects_in_union { let p = 1.0 / table_names.len() as f64; // Randomly decide whether to use a VALUES clause or a SELECT clause if rng.random_bool(p) { let values = (0..cols_to_select.len()) .map(|_| rng.random_range(-3..3)) .map(|val| val.to_string()) .collect::>(); select_statements.push(format!("VALUES({})", values.join(", "))); if i == (num_selects_in_union - 1) { has_right_most_values = true; } } else { // Randomly pick a table let table_to_select_from = &table_names[rng.random_range(0..table_names.len())]; select_statements.push(format!( "SELECT {} FROM {}", cols_to_select.join(", "), table_to_select_from )); } } const COMPOUND_OPERATORS: [&str; 4] = [" UNION ALL ", " UNION ", " INTERSECT ", " EXCEPT "]; let mut query = String::new(); for (i, select_statement) in select_statements.iter().enumerate() { if i > 0 { query.push_str(COMPOUND_OPERATORS.choose(&mut rng).unwrap()); } query.push_str(select_statement); } // if the right most SELECT is a VALUES clause, no limit is not allowed if rng.random_bool(0.8) && !has_right_most_values { let limit_val = rng.random_range(0..=MAX_LIMIT_VALUE); // LIMIT 0 is valid if rng.random_bool(0.8) { query = format!("{query} LIMIT {limit_val}"); } else { let offset_val = rng.random_range(0..=MAX_LIMIT_VALUE); query = format!("{query} LIMIT {limit_val} OFFSET {offset_val}"); } } log::debug!( "Iteration {}/{}: Query: {}", iter_num + 1, NUM_FUZZ_ITERATIONS, query ); let limbo_results = limbo_exec_rows(&db, &limbo_conn, &query); let sqlite_results = sqlite_exec_rows(&sqlite_conn, &query); assert_eq!( limbo_results, sqlite_results, "query: {}, limbo.len(): {}, sqlite.len(): {}, limbo: {:?}, sqlite: {:?}, seed: {}", query, limbo_results.len(), sqlite_results.len(), limbo_results, sqlite_results, seed ); } } #[test] pub fn ddl_compatibility_fuzz() { let _ = env_logger::try_init(); let (mut rng, seed) = rng_from_time_or_env(); const ITERATIONS: usize = 1000; for i in 0..ITERATIONS { let db = TempDatabase::new_empty(true); let conn = db.connect_limbo(); let num_cols = rng.random_range(1..=5); let col_names: Vec = (0..num_cols).map(|c| format!("c{c}")).collect(); // Decide whether to use a table-level PRIMARY KEY (possibly compound) let use_table_pk = num_cols >= 1 && rng.random_bool(0.6); let pk_len = if use_table_pk { if num_cols == 1 { 1 } else { rng.random_range(1..=num_cols.min(3)) } } else { 0 }; let pk_cols: Vec = if use_table_pk { let mut col_names_shuffled = col_names.clone(); col_names_shuffled.shuffle(&mut rng); col_names_shuffled.iter().take(pk_len).cloned().collect() } else { Vec::new() }; let mut has_primary_key = false; // Column definitions with optional types and column-level constraints let mut column_defs: Vec = Vec::new(); for name in col_names.iter() { let mut parts = vec![name.clone()]; if rng.random_bool(0.7) { let types = ["INTEGER", "TEXT", "REAL", "BLOB", "NUMERIC"]; let t = types[rng.random_range(0..types.len())]; parts.push(t.to_string()); } if !use_table_pk && !has_primary_key && rng.random_bool(0.3) { has_primary_key = true; parts.push("PRIMARY KEY".to_string()); } else if rng.random_bool(0.2) { parts.push("UNIQUE".to_string()); } column_defs.push(parts.join(" ")); } // Table-level constraints: PRIMARY KEY and some UNIQUE constraints (including compound) let mut table_constraints: Vec = Vec::new(); if use_table_pk { let mut spec_parts: Vec = Vec::new(); for col in pk_cols.iter() { if rng.random_bool(0.5) { let dir = if rng.random_bool(0.5) { "DESC" } else { "ASC" }; spec_parts.push(format!("{col} {dir}")); } else { spec_parts.push(col.clone()); } } table_constraints.push(format!("PRIMARY KEY ({})", spec_parts.join(", "))); } let num_uniques = if num_cols >= 2 { rng.random_range(0..=2) } else { rng.random_range(0..=1) }; for _ in 0..num_uniques { let len = if num_cols == 1 { 1 } else { rng.random_range(1..=num_cols.min(3)) }; let start = rng.random_range(0..num_cols); let mut uniq_cols: Vec = Vec::new(); for k in 0..len { let idx = (start + k) % num_cols; uniq_cols.push(col_names[idx].clone()); } table_constraints.push(format!("UNIQUE ({})", uniq_cols.join(", "))); } let mut elements = column_defs; elements.extend(table_constraints); let table_name = format!("t{i}"); let create_sql = format!("CREATE TABLE {table_name} ({})", elements.join(", ")); println!("{create_sql}"); limbo_exec_rows(&db, &conn, &create_sql); do_flush(&conn, &db).unwrap(); // Open with rusqlite and verify integrity_check returns OK let sqlite_conn = rusqlite::Connection::open(db.path.clone()).unwrap(); let rows = sqlite_exec_rows(&sqlite_conn, "PRAGMA integrity_check"); assert!( !rows.is_empty(), "integrity_check returned no rows (seed: {seed})" ); match &rows[0][0] { Value::Text(s) => assert!( s.eq_ignore_ascii_case("ok"), "integrity_check failed (seed: {seed}): {rows:?}", ), other => panic!("unexpected integrity_check result (seed: {seed}): {other:?}",), } // Verify the stored SQL matches the create table statement let conn = db.connect_limbo(); let verify_sql = format!( "SELECT sql FROM sqlite_schema WHERE name = '{table_name}' and type = 'table'" ); let res = limbo_exec_rows(&db, &conn, &verify_sql); assert!(res.len() == 1, "Expected 1 row, got {res:?}"); let Value::Text(s) = &res[0][0] else { panic!("sql should be TEXT"); }; assert_eq!(s.as_str(), create_sql); } } #[test] pub fn arithmetic_expression_fuzz() { let _ = env_logger::try_init(); let g = GrammarGenerator::new(); let (expr, expr_builder) = g.create_handle(); let (bin_op, bin_op_builder) = g.create_handle(); let (unary_op, unary_op_builder) = g.create_handle(); let (paren, paren_builder) = g.create_handle(); paren_builder .concat("") .push_str("(") .push(expr) .push_str(")") .build(); unary_op_builder .concat(" ") .push(g.create().choice().options_str(["~", "+", "-"]).build()) .push(expr) .build(); bin_op_builder .concat(" ") .push(expr) .push( g.create() .choice() .options_str(["+", "-", "*", "/", "%", "&", "|", "<<", ">>"]) .build(), ) .push(expr) .build(); expr_builder .choice() .option_w(unary_op, 1.0) .option_w(bin_op, 1.0) .option_w(paren, 1.0) .option_symbol_w(rand_int(-10..10), 1.0) .build(); let sql = g.create().concat(" ").push_str("SELECT").push(expr).build(); let db = TempDatabase::new_empty(false); let limbo_conn = db.connect_limbo(); let sqlite_conn = rusqlite::Connection::open_in_memory().unwrap(); let (mut rng, seed) = rng_from_time_or_env(); log::info!("seed: {seed}"); for _ in 0..1024 { let query = g.generate(&mut rng, sql, 50); let limbo = limbo_exec_rows(&db, &limbo_conn, &query); let sqlite = sqlite_exec_rows(&sqlite_conn, &query); assert_eq!( limbo, sqlite, "query: {query}, limbo: {limbo:?}, sqlite: {sqlite:?} seed: {seed}" ); } } #[test] pub fn fuzz_ex() { let _ = env_logger::try_init(); let db = TempDatabase::new_empty(false); let limbo_conn = db.connect_limbo(); let sqlite_conn = rusqlite::Connection::open_in_memory().unwrap(); for query in [ "SELECT FALSE", "SELECT NOT FALSE", "SELECT ((NULL) IS NOT TRUE <= ((NOT (FALSE))))", "SELECT ifnull(0, NOT 0)", "SELECT like('a%', 'a') = 1", "SELECT CASE ( NULL < NULL ) WHEN ( 0 ) THEN ( NULL ) ELSE ( 2.0 ) END;", "SELECT (COALESCE(0, COALESCE(0, 0)));", "SELECT CAST((1 > 0) AS INTEGER);", "SELECT substr('ABC', -1)", ] { let limbo = limbo_exec_rows(&db, &limbo_conn, query); let sqlite = sqlite_exec_rows(&sqlite_conn, query); assert_eq!( limbo, sqlite, "query: {query}, limbo: {limbo:?}, sqlite: {sqlite:?}" ); } } #[test] pub fn math_expression_fuzz_run() { let _ = env_logger::try_init(); let g = GrammarGenerator::new(); let (expr, expr_builder) = g.create_handle(); let (bin_op, bin_op_builder) = g.create_handle(); let (scalar, scalar_builder) = g.create_handle(); let (paren, paren_builder) = g.create_handle(); paren_builder .concat("") .push_str("(") .push(expr) .push_str(")") .build(); bin_op_builder .concat(" ") .push(expr) .push( g.create() .choice() .options_str(["+", "-", "/", "*"]) .build(), ) .push(expr) .build(); scalar_builder .choice() .option( g.create() .concat("") .push( g.create() .choice() .options_str([ "acos", "acosh", "asin", "asinh", "atan", "atanh", "ceil", "ceiling", "cos", "cosh", "degrees", "exp", "floor", "ln", "log", "log10", "log2", "radians", "sin", "sinh", "sqrt", "tan", "tanh", "trunc", ]) .build(), ) .push_str("(") .push(expr) .push_str(")") .build(), ) .option( g.create() .concat("") .push( g.create() .choice() .options_str(["atan2", "log", "mod", "pow", "power"]) .build(), ) .push_str("(") .push(g.create().concat("").push(expr).repeat(2..3, ", ").build()) .push_str(")") .build(), ) .build(); expr_builder .choice() .options_str(["-2.0", "-1.0", "0.0", "0.5", "1.0", "2.0"]) .option_w(bin_op, 10.0) .option_w(paren, 10.0) .option_w(scalar, 10.0) .build(); let sql = g.create().concat(" ").push_str("SELECT").push(expr).build(); let db = TempDatabase::new_empty(false); let limbo_conn = db.connect_limbo(); let sqlite_conn = rusqlite::Connection::open_in_memory().unwrap(); let (mut rng, seed) = rng_from_time_or_env(); log::info!("seed: {seed}"); for _ in 0..1024 { let query = g.generate(&mut rng, sql, 50); log::info!("query: {query}"); let limbo = limbo_exec_rows(&db, &limbo_conn, &query); let sqlite = sqlite_exec_rows(&sqlite_conn, &query); match (&limbo[0][0], &sqlite[0][0]) { // compare only finite results because some evaluations are not so stable around infinity (rusqlite::types::Value::Real(limbo), rusqlite::types::Value::Real(sqlite)) if limbo.is_finite() && sqlite.is_finite() => { assert!( (limbo - sqlite).abs() < 1e-9 || (limbo - sqlite) / (limbo.abs().max(sqlite.abs())) < 1e-9, "query: {query}, limbo: {limbo:?}, sqlite: {sqlite:?} seed: {seed}" ) } _ => {} } } } #[test] pub fn string_expression_fuzz_run() { let _ = env_logger::try_init(); let g = GrammarGenerator::new(); let (expr, expr_builder) = g.create_handle(); let (bin_op, bin_op_builder) = g.create_handle(); let (scalar, scalar_builder) = g.create_handle(); let (paren, paren_builder) = g.create_handle(); let (number, number_builder) = g.create_handle(); number_builder .choice() .option_symbol(rand_int(-5..10)) .option( g.create() .concat(" ") .push(number) .push(g.create().choice().options_str(["+", "-", "*"]).build()) .push(number) .build(), ) .build(); paren_builder .concat("") .push_str("(") .push(expr) .push_str(")") .build(); bin_op_builder .concat(" ") .push(expr) .push(g.create().choice().options_str(["||"]).build()) .push(expr) .build(); scalar_builder .choice() .option( g.create() .concat("") .push_str("char(") .push( g.create() .concat("") .push_symbol(rand_int(65..91)) .repeat(1..8, ", ") .build(), ) .push_str(")") .build(), ) .option( g.create() .concat("") .push( g.create() .choice() .options_str(["ltrim", "rtrim", "trim"]) .build(), ) .push_str("(") .push(g.create().concat("").push(expr).repeat(2..3, ", ").build()) .push_str(")") .build(), ) .option( g.create() .concat("") .push( g.create() .choice() .options_str([ "ltrim", "rtrim", "lower", "upper", "quote", "hex", "trim", ]) .build(), ) .push_str("(") .push(expr) .push_str(")") .build(), ) .option( g.create() .concat("") .push(g.create().choice().options_str(["replace"]).build()) .push_str("(") .push(g.create().concat("").push(expr).repeat(3..4, ", ").build()) .push_str(")") .build(), ) .option( g.create() .concat("") .push( g.create() .choice() .options_str(["substr", "substring"]) .build(), ) .push_str("(") .push(expr) .push_str(", ") .push( g.create() .concat("") .push(number) .repeat(1..3, ", ") .build(), ) .push_str(")") .build(), ) .build(); expr_builder .choice() .option_w(bin_op, 1.0) .option_w(paren, 1.0) .option_w(scalar, 1.0) .option( g.create() .concat("") .push_str("'") .push_symbol(rand_str("", 2)) .push_str("'") .build(), ) .build(); let sql = g.create().concat(" ").push_str("SELECT").push(expr).build(); let db = TempDatabase::new_empty(false); let limbo_conn = db.connect_limbo(); let sqlite_conn = rusqlite::Connection::open_in_memory().unwrap(); let (mut rng, seed) = rng_from_time_or_env(); log::info!("seed: {seed}"); for _ in 0..1024 { let query = g.generate(&mut rng, sql, 50); log::info!("query: {query}"); let limbo = limbo_exec_rows(&db, &limbo_conn, &query); let sqlite = sqlite_exec_rows(&sqlite_conn, &query); assert_eq!( limbo, sqlite, "query: {query}, limbo: {limbo:?}, sqlite: {sqlite:?} seed: {seed}" ); } } struct TestTable { pub name: &'static str, pub columns: Vec<&'static str>, } /// Expressions that can be used in both SELECT and WHERE positions. struct CommonBuilders { pub bin_op: SymbolHandle, pub unary_infix_op: SymbolHandle, pub scalar: SymbolHandle, pub paren: SymbolHandle, pub coalesce_expr: SymbolHandle, pub cast_expr: SymbolHandle, pub case_expr: SymbolHandle, pub cmp_op: SymbolHandle, pub number: SymbolHandle, } /// Expressions that can be used only in WHERE position due to Limbo limitations. struct PredicateBuilders { pub in_op: SymbolHandle, } fn common_builders(g: &GrammarGenerator, tables: Option<&[TestTable]>) -> CommonBuilders { let (expr, expr_builder) = g.create_handle(); let (bin_op, bin_op_builder) = g.create_handle(); let (unary_infix_op, unary_infix_op_builder) = g.create_handle(); let (scalar, scalar_builder) = g.create_handle(); let (paren, paren_builder) = g.create_handle(); let (like_pattern, like_pattern_builder) = g.create_handle(); let (glob_pattern, glob_pattern_builder) = g.create_handle(); let (coalesce_expr, coalesce_expr_builder) = g.create_handle(); let (cast_expr, cast_expr_builder) = g.create_handle(); let (case_expr, case_expr_builder) = g.create_handle(); let (cmp_op, cmp_op_builder) = g.create_handle(); let (column, column_builder) = g.create_handle(); paren_builder .concat("") .push_str("(") .push(expr) .push_str(")") .build(); unary_infix_op_builder .concat(" ") .push(g.create().choice().options_str(["NOT"]).build()) .push(expr) .build(); bin_op_builder .concat(" ") .push(expr) .push( g.create() .choice() .options_str(["AND", "OR", "IS", "IS NOT", "=", "<>", ">", "<", ">=", "<="]) .build(), ) .push(expr) .build(); like_pattern_builder .choice() .option_str("%") .option_str("_") .option_symbol(rand_str("", 1)) .repeat(1..10, "") .build(); glob_pattern_builder .choice() .option_str("*") .option_str("**") .option_str("A") .option_str("B") .repeat(1..10, "") .build(); coalesce_expr_builder .concat("") .push_str("COALESCE(") .push(g.create().concat("").push(expr).repeat(2..5, ",").build()) .push_str(")") .build(); cast_expr_builder .concat(" ") .push_str("CAST ( (") .push(expr) .push_str(") AS ") // cast to INTEGER/REAL/TEXT types can be added when Limbo will use proper equality semantic between values (e.g. 1 = 1.0) .push(g.create().choice().options_str(["NUMERIC"]).build()) .push_str(")") .build(); case_expr_builder .concat(" ") .push_str("CASE (") .push(expr) .push_str(")") .push( g.create() .concat(" ") .push_str("WHEN (") .push(expr) .push_str(") THEN (") .push(expr) .push_str(")") .repeat(1..5, " ") .build(), ) .push_str("ELSE (") .push(expr) .push_str(") END") .build(); scalar_builder .choice() .option(coalesce_expr) .option( g.create() .concat("") .push_str("like('") .push(like_pattern) .push_str("', '") .push(like_pattern) .push_str("')") .build(), ) .option( g.create() .concat("") .push_str("glob('") .push(glob_pattern) .push_str("', '") .push(glob_pattern) .push_str("')") .build(), ) .option( g.create() .concat("") .push_str("ifnull(") .push(expr) .push_str(",") .push(expr) .push_str(")") .build(), ) .option( g.create() .concat("") .push_str("iif(") .push(expr) .push_str(",") .push(expr) .push_str(",") .push(expr) .push_str(")") .build(), ) .build(); let number = g .create() .choice() .option_symbol(rand_int(-0xff..0x100)) .option_symbol(rand_int(-0xffff..0x10000)) .option_symbol(rand_int(-0xffffff..0x1000000)) .option_symbol(rand_int(-0xffffffff..0x100000000)) .option_symbol(rand_int(-0xffffffffffff..0x1000000000000)) .build(); let mut column_builder = column_builder .choice() .option( g.create() .concat(" ") .push_str("(") .push(column) .push_str(")") .build(), ) .option(number) .option( g.create() .concat(" ") .push_str("(") .push(column) .push( g.create() .choice() .options_str([ "+", "-", "*", "/", "||", "=", "<>", ">", "<", ">=", "<=", "IS", "IS NOT", ]) .build(), ) .push(column) .push_str(")") .build(), ); if let Some(tables) = tables { for table in tables.iter() { for column in table.columns.iter() { column_builder = column_builder .option_symbol_w(const_str(&format!("{}.{}", table.name, column)), 1.0); } } } column_builder.build(); cmp_op_builder .concat(" ") .push(column) .push( g.create() .choice() .options_str(["=", "<>", ">", "<", ">=", "<=", "IS", "IS NOT"]) .build(), ) .push(column) .build(); expr_builder .choice() .option_w(bin_op, 3.0) .option_w(unary_infix_op, 2.0) .option_w(paren, 2.0) .option_w(scalar, 4.0) .option_w(coalesce_expr, 1.0) .option_w(cast_expr, 1.0) .option_w(case_expr, 1.0) .option_w(cmp_op, 1.0) .options_str(["1", "0", "NULL", "2.0", "1.5", "-0.5", "-2.0", "(1 / 0)"]) .build(); CommonBuilders { bin_op, unary_infix_op, scalar, paren, coalesce_expr, cast_expr, case_expr, cmp_op, number, } } fn predicate_builders(g: &GrammarGenerator, tables: Option<&[TestTable]>) -> PredicateBuilders { let (in_op, in_op_builder) = g.create_handle(); let (column, column_builder) = g.create_handle(); let mut column_builder = column_builder .choice() .option( g.create() .concat(" ") .push_str("(") .push(column) .push_str(")") .build(), ) .option_symbol(rand_int(-0xffffffff..0x100000000)) .option( g.create() .concat(" ") .push_str("(") .push(column) .push(g.create().choice().options_str(["+", "-"]).build()) .push(column) .push_str(")") .build(), ); if let Some(tables) = tables { for table in tables.iter() { for column in table.columns.iter() { column_builder = column_builder .option_symbol_w(const_str(&format!("{}.{}", table.name, column)), 1.0); } } } column_builder.build(); in_op_builder .concat(" ") .push(column) .push(g.create().choice().options_str(["IN", "NOT IN"]).build()) .push_str("(") .push( g.create() .concat("") .push(column) .repeat(1..5, ", ") .build(), ) .push_str(")") .build(); PredicateBuilders { in_op } } fn build_logical_expr( g: &GrammarGenerator, common: &CommonBuilders, predicate: Option<&PredicateBuilders>, ) -> SymbolHandle { let (handle, builder) = g.create_handle(); let mut builder = builder .choice() .option_w(common.cast_expr, 1.0) .option_w(common.case_expr, 1.0) .option_w(common.cmp_op, 1.0) .option_w(common.coalesce_expr, 1.0) .option_w(common.unary_infix_op, 2.0) .option_w(common.bin_op, 3.0) .option_w(common.paren, 2.0) .option_w(common.scalar, 4.0) // unfortunately, sqlite behaves weirdly when IS operator is used with TRUE/FALSE constants // e.g. 8 IS TRUE == 1 (although 8 = TRUE == 0) // so, we do not use TRUE/FALSE constants as they will produce diff with sqlite results .options_str(["1", "0", "NULL", "2.0", "1.5", "-0.5", "-2.0", "(1 / 0)"]); if let Some(predicate) = predicate { builder = builder.option_w(predicate.in_op, 1.0); } builder.build(); handle } #[test] pub fn logical_expression_fuzz_run() { let _ = env_logger::try_init(); let g = GrammarGenerator::new(); let builders = common_builders(&g, None); let expr = build_logical_expr(&g, &builders, None); let sql = g .create() .concat(" ") .push_str("SELECT ") .push(expr) .build(); let db = TempDatabase::new_empty(false); let limbo_conn = db.connect_limbo(); let sqlite_conn = rusqlite::Connection::open_in_memory().unwrap(); let (mut rng, seed) = rng_from_time_or_env(); log::info!("seed: {seed}"); for _ in 0..1024 { let query = g.generate(&mut rng, sql, 50); log::info!("query: {query}"); let limbo = limbo_exec_rows(&db, &limbo_conn, &query); let sqlite = sqlite_exec_rows(&sqlite_conn, &query); assert_eq!( limbo, sqlite, "query: {query}, limbo: {limbo:?}, sqlite: {sqlite:?} seed: {seed}" ); } } #[test] pub fn table_logical_expression_fuzz_ex1() { let _ = env_logger::try_init(); for queries in [ [ "CREATE TABLE t (x)", "INSERT INTO t VALUES (10)", "SELECT * FROM t WHERE x = 1 AND 1 OR 0", ], [ "CREATE TABLE t (x)", "INSERT INTO t VALUES (-3258184727)", "SELECT * FROM t", ], ] { let db = TempDatabase::new_empty(false); let limbo_conn = db.connect_limbo(); let sqlite_conn = rusqlite::Connection::open_in_memory().unwrap(); for query in queries.iter() { let limbo = limbo_exec_rows(&db, &limbo_conn, query); let sqlite = sqlite_exec_rows(&sqlite_conn, query); assert_eq!( limbo, sqlite, "queries: {queries:?}, query: {query}, limbo: {limbo:?}, sqlite: {sqlite:?}" ); } } } #[test] pub fn min_max_agg_fuzz() { let _ = env_logger::try_init(); let datatypes = ["INTEGER", "TEXT", "REAL", "BLOB"]; let (mut rng, seed) = rng_from_time_or_env(); log::info!("seed: {seed}"); for _ in 0..1000 { // Create table with random datatype let datatype = datatypes[rng.random_range(0..datatypes.len())]; let create_table = format!("CREATE TABLE t (x {datatype})"); let db = TempDatabase::new_empty(false); let limbo_conn = db.connect_limbo(); let sqlite_conn = rusqlite::Connection::open_in_memory().unwrap(); limbo_exec_rows(&db, &limbo_conn, &create_table); sqlite_exec_rows(&sqlite_conn, &create_table); // Insert 5 random values of random types let mut values = Vec::new(); for _ in 0..5 { let value = match rng.random_range(0..4) { 0 => rng.random_range(-1000..1000).to_string(), // Integer 1 => format!( "'{}'", (0..10) .map(|_| rng.random_range(b'a'..=b'z') as char) .collect::() ), // Text 2 => format!("{:.2}", rng.random_range(-100..100) as f64 / 10.0), // Real 3 => "NULL".to_string(), // NULL _ => unreachable!(), }; values.push(format!("({value})")); } let insert = format!("INSERT INTO t VALUES {}", values.join(",")); limbo_exec_rows(&db, &limbo_conn, &insert); sqlite_exec_rows(&sqlite_conn, &insert); // Test min and max for agg in ["min(x)", "max(x)"] { let query = format!("SELECT {agg} FROM t"); let limbo = limbo_exec_rows(&db, &limbo_conn, &query); let sqlite = sqlite_exec_rows(&sqlite_conn, &query); assert_eq!( limbo, sqlite, "query: {query}, limbo: {limbo:?}, sqlite: {sqlite:?}, seed: {seed}, values: {values:?}, schema: {create_table}" ); } } } #[test] pub fn affinity_fuzz() { let _ = env_logger::try_init(); let (mut rng, seed) = rng_from_time_or_env(); log::info!("affinity_fuzz seed: {seed}"); for iteration in 0..500 { let db = TempDatabase::new_empty(false); let limbo_conn = db.connect_limbo(); let sqlite_conn = rusqlite::Connection::open_in_memory().unwrap(); // Test different column affinities - cover all SQLite affinity types let affinities = [ "INTEGER", "TEXT", "REAL", "NUMERIC", "BLOB", "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT", "UNSIGNED BIG INT", "INT2", "INT8", "CHARACTER(20)", "VARCHAR(255)", "VARYING CHARACTER(255)", "NCHAR(55)", "NATIVE CHARACTER(70)", "NVARCHAR(100)", "CLOB", "DOUBLE", "DOUBLE PRECISION", "FLOAT", "DECIMAL(10,5)", "BOOLEAN", "DATE", "DATETIME", ]; let affinity = affinities[rng.random_range(0..affinities.len())]; let create_table = format!("CREATE TABLE t (x {affinity})"); limbo_exec_rows(&db, &limbo_conn, &create_table); sqlite_exec_rows(&sqlite_conn, &create_table); // Insert various values that test affinity conversion rules let mut values = Vec::new(); for _ in 0..20 { let value = match rng.random_range(0..9) { 0 => format!("'{}'", rng.random_range(-10000..10000)), // Pure integer as text 1 => format!( "'{}.{}'", rng.random_range(-1000..1000), rng.random_range(1..999) // Ensure non-zero decimal part ), // Float as text with decimal 2 => format!("'a{}'", rng.random_range(0..1000)), // Text with integer suffix 3 => format!("' {} '", rng.random_range(-100..100)), // Integer with whitespace 4 => format!("'-{}'", rng.random_range(1..1000)), // Negative integer as text 5 => format!("{}", rng.random_range(-10000..10000)), // Direct integer 6 => format!( "{}.{}", rng.random_range(-100..100), rng.random_range(1..999) // Ensure non-zero decimal part ), // Direct float 7 => "'text_value'".to_string(), // Pure text that won't convert 8 => "NULL".to_string(), // NULL value _ => unreachable!(), }; values.push(format!("({value})")); } let insert = format!("INSERT INTO t VALUES {}", values.join(",")); limbo_exec_rows(&db, &limbo_conn, &insert); sqlite_exec_rows(&sqlite_conn, &insert); // Query values and their types to verify affinity rules are applied correctly let query = "SELECT x, typeof(x) FROM t"; let limbo_result = limbo_exec_rows(&db, &limbo_conn, query); let sqlite_result = sqlite_exec_rows(&sqlite_conn, query); assert_eq!( limbo_result, sqlite_result, "iteration: {iteration}, seed: {seed}, affinity: {affinity}, values: {values:?}" ); // Also test with ORDER BY to ensure affinity affects sorting let query_ordered = "SELECT x FROM t ORDER BY x"; let limbo_ordered = limbo_exec_rows(&db, &limbo_conn, query_ordered); let sqlite_ordered = sqlite_exec_rows(&sqlite_conn, query_ordered); assert_eq!( limbo_ordered, sqlite_ordered, "ORDER BY failed - iteration: {iteration}, seed: {seed}, affinity: {affinity}" ); } } #[test] // Simple fuzz test for SUM with floats pub fn sum_agg_fuzz_floats() { let _ = env_logger::try_init(); let (mut rng, seed) = rng_from_time_or_env(); log::info!("seed: {seed}"); for _ in 0..100 { let db = TempDatabase::new_empty(false); let limbo_conn = db.connect_limbo(); let sqlite_conn = rusqlite::Connection::open_in_memory().unwrap(); limbo_exec_rows(&db, &limbo_conn, "CREATE TABLE t(x)"); sqlite_exec_rows(&sqlite_conn, "CREATE TABLE t(x)"); // Insert 50-100 mixed values: floats, text, NULL let mut values = Vec::new(); for _ in 0..rng.random_range(50..=100) { let value = rng.random_range(-100.0..100.0).to_string(); values.push(format!("({value})")); } let insert = format!("INSERT INTO t VALUES {}", values.join(",")); limbo_exec_rows(&db, &limbo_conn, &insert); sqlite_exec_rows(&sqlite_conn, &insert); let query = "SELECT sum(x) FROM t ORDER BY x"; let limbo_result = limbo_exec_rows(&db, &limbo_conn, query); let sqlite_result = sqlite_exec_rows(&sqlite_conn, query); let limbo_val = match limbo_result.first().and_then(|row| row.first()) { Some(Value::Real(f)) => *f, Some(Value::Null) | None => 0.0, _ => panic!("Unexpected type in limbo result: {limbo_result:?}"), }; let sqlite_val = match sqlite_result.first().and_then(|row| row.first()) { Some(Value::Real(f)) => *f, Some(Value::Null) | None => 0.0, _ => panic!("Unexpected type in limbo result: {limbo_result:?}"), }; assert_eq!(limbo_val, sqlite_val, "seed: {seed}, values: {values:?}"); } } #[test] // Simple fuzz test for SUM with mixed numeric/non-numeric values (issue #2133) pub fn sum_agg_fuzz() { let _ = env_logger::try_init(); let (mut rng, seed) = rng_from_time_or_env(); log::info!("seed: {seed}"); for _ in 0..100 { let db = TempDatabase::new_empty(false); let limbo_conn = db.connect_limbo(); let sqlite_conn = rusqlite::Connection::open_in_memory().unwrap(); limbo_exec_rows(&db, &limbo_conn, "CREATE TABLE t(x)"); sqlite_exec_rows(&sqlite_conn, "CREATE TABLE t(x)"); // Insert 3-4 mixed values: integers, text, NULL let mut values = Vec::new(); for _ in 0..rng.random_range(3..=4) { let value = match rng.random_range(0..3) { 0 => rng.random_range(-100..100).to_string(), // Integer 1 => format!( "'{}'", (0..3) .map(|_| rng.random_range(b'a'..=b'z') as char) .collect::() ), // Text 2 => "NULL".to_string(), // NULL _ => unreachable!(), }; values.push(format!("({value})")); } let insert = format!("INSERT INTO t VALUES {}", values.join(",")); limbo_exec_rows(&db, &limbo_conn, &insert); sqlite_exec_rows(&sqlite_conn, &insert); let query = "SELECT sum(x) FROM t"; let limbo = limbo_exec_rows(&db, &limbo_conn, query); let sqlite = sqlite_exec_rows(&sqlite_conn, query); assert_eq!(limbo, sqlite, "seed: {seed}, values: {values:?}"); } } #[test] fn concat_ws_fuzz() { let _ = env_logger::try_init(); let (mut rng, seed) = rng_from_time_or_env(); log::info!("seed: {seed}"); for _ in 0..100 { let db = TempDatabase::new_empty(false); let limbo_conn = db.connect_limbo(); let sqlite_conn = rusqlite::Connection::open_in_memory().unwrap(); let num_args = rng.random_range(7..=17); let mut args = Vec::new(); for _ in 0..num_args { let arg = match rng.random_range(0..3) { 0 => rng.random_range(-100..100).to_string(), 1 => format!( "'{}'", (0..rng.random_range(1..=5)) .map(|_| rng.random_range(b'a'..=b'z') as char) .collect::() ), 2 => "NULL".to_string(), _ => unreachable!(), }; args.push(arg); } let sep = match rng.random_range(0..=2) { 0 => "','", 1 => "'-'", 2 => "NULL", _ => unreachable!(), }; let query = format!("SELECT concat_ws({}, {})", sep, args.join(", ")); let limbo = limbo_exec_rows(&db, &limbo_conn, &query); let sqlite = sqlite_exec_rows(&sqlite_conn, &query); assert_eq!(limbo, sqlite, "seed: {seed}, sep: {sep}, args: {args:?}"); } } #[test] // Simple fuzz test for TOTAL with mixed numeric/non-numeric values pub fn total_agg_fuzz() { let _ = env_logger::try_init(); let (mut rng, seed) = rng_from_time_or_env(); log::info!("seed: {seed}"); for _ in 0..100 { let db = TempDatabase::new_empty(false); let limbo_conn = db.connect_limbo(); let sqlite_conn = rusqlite::Connection::open_in_memory().unwrap(); limbo_exec_rows(&db, &limbo_conn, "CREATE TABLE t(x)"); sqlite_exec_rows(&sqlite_conn, "CREATE TABLE t(x)"); // Insert 3-4 mixed values: integers, text, NULL let mut values = Vec::new(); for _ in 0..rng.random_range(3..=4) { let value = match rng.random_range(0..3) { 0 => rng.random_range(-100..100).to_string(), // Integer 1 => format!( "'{}'", (0..3) .map(|_| rng.random_range(b'a'..=b'z') as char) .collect::() ), // Text 2 => "NULL".to_string(), // NULL _ => unreachable!(), }; values.push(format!("({value})")); } let insert = format!("INSERT INTO t VALUES {}", values.join(",")); limbo_exec_rows(&db, &limbo_conn, &insert); sqlite_exec_rows(&sqlite_conn, &insert); let query = "SELECT total(x) FROM t"; let limbo = limbo_exec_rows(&db, &limbo_conn, query); let sqlite = sqlite_exec_rows(&sqlite_conn, query); assert_eq!(limbo, sqlite, "seed: {seed}, values: {values:?}"); } } #[test] pub fn table_logical_expression_fuzz_run() { let _ = env_logger::try_init(); let g = GrammarGenerator::new(); let tables = vec![TestTable { name: "t", columns: vec!["x", "y", "z"], }]; let builders = common_builders(&g, Some(&tables)); let predicate = predicate_builders(&g, Some(&tables)); let expr = build_logical_expr(&g, &builders, Some(&predicate)); let db = TempDatabase::new_empty(true); let limbo_conn = db.connect_limbo(); let sqlite_conn = rusqlite::Connection::open_in_memory().unwrap(); for table in tables.iter() { let columns_with_first_column_as_pk = { let mut columns = vec![]; columns.push(format!("{} PRIMARY KEY", table.columns[0])); columns.extend(table.columns[1..].iter().map(|c| c.to_string())); columns.join(", ") }; let query = format!( "CREATE TABLE {} ({})", table.name, columns_with_first_column_as_pk ); dbg!(&query); let limbo = limbo_exec_rows(&db, &limbo_conn, &query); let sqlite = sqlite_exec_rows(&sqlite_conn, &query); assert_eq!( limbo, sqlite, "query: {query}, limbo: {limbo:?}, sqlite: {sqlite:?}", ); } let (mut rng, seed) = rng_from_time_or_env(); log::info!("seed: {seed}"); let mut i = 0; let mut primary_key_set = HashSet::with_capacity(100); while i < 100 { let x = g.generate(&mut rng, builders.number, 1); if primary_key_set.contains(&x) { continue; } primary_key_set.insert(x.clone()); let (y, z) = ( g.generate(&mut rng, builders.number, 1), g.generate(&mut rng, builders.number, 1), ); let query = format!("INSERT INTO t VALUES ({x}, {y}, {z})"); log::info!("insert: {query}"); dbg!(&query); assert_eq!( limbo_exec_rows(&db, &limbo_conn, &query), sqlite_exec_rows(&sqlite_conn, &query), "seed: {seed}", ); i += 1; } // verify the same number of rows in both tables let query = "SELECT COUNT(*) FROM t".to_string(); let limbo = limbo_exec_rows(&db, &limbo_conn, &query); let sqlite = sqlite_exec_rows(&sqlite_conn, &query); assert_eq!(limbo, sqlite, "seed: {seed}"); let sql = g .create() .concat(" ") .push_str("SELECT * FROM t WHERE ") .push(expr) .build(); for _ in 0..1024 { let query = g.generate(&mut rng, sql, 50); log::info!("query: {query}"); let limbo = limbo_exec_rows(&db, &limbo_conn, &query); let sqlite = sqlite_exec_rows(&sqlite_conn, &query); if limbo.len() != sqlite.len() { panic!("MISMATCHING ROW COUNT (limbo: {}, sqlite: {}) for query: {}\n\n limbo: {:?}\n\n sqlite: {:?}", limbo.len(), sqlite.len(), query, limbo, sqlite); } // find first row where limbo and sqlite differ let diff_rows = limbo .iter() .zip(sqlite.iter()) .filter(|(l, s)| l != s) .collect::>(); if !diff_rows.is_empty() { // due to different choices in index usage (usually in these cases sqlite is smart enough to use an index and we aren't), // sqlite might return rows in a different order // check if all limbo rows are present in sqlite let all_present = limbo.iter().all(|l| sqlite.iter().any(|s| l == s)); if !all_present { panic!("MISMATCHING ROWS (limbo: {}, sqlite: {}) for query: {}\n\n limbo: {:?}\n\n sqlite: {:?}\n\n differences: {:?}", limbo.len(), sqlite.len(), query, limbo, sqlite, diff_rows); } } } } #[test] pub fn fuzz_distinct() { let db = TempDatabase::new_empty(true); let limbo_conn = db.connect_limbo(); let sqlite_conn = rusqlite::Connection::open_in_memory().unwrap(); let (mut rng, seed) = rng_from_time_or_env(); tracing::info!("fuzz_distinct seed: {}", seed); let columns = ["a", "b", "c", "d", "e"]; // Create table with 3 integer columns let create_table = format!("CREATE TABLE t ({})", columns.join(", ")); limbo_exec_rows(&db, &limbo_conn, &create_table); sqlite_exec_rows(&sqlite_conn, &create_table); // Insert some random data for _ in 0..1000 { let values = (0..columns.len()) .map(|_| rng.random_range(1..3)) // intentionally narrow range .collect::>(); let query = format!( "INSERT INTO t VALUES ({})", values .iter() .map(|v| v.to_string()) .collect::>() .join(",") ); limbo_exec_rows(&db, &limbo_conn, &query); sqlite_exec_rows(&sqlite_conn, &query); } // Test different DISTINCT + ORDER BY combinations for _ in 0..300 { // Randomly select columns for DISTINCT let num_distinct_cols = rng.random_range(1..=columns.len()); let mut available_cols = columns.to_vec(); let mut distinct_cols = Vec::with_capacity(num_distinct_cols); for _ in 0..num_distinct_cols { let idx = rng.random_range(0..available_cols.len()); distinct_cols.push(available_cols.remove(idx)); } let distinct_cols = distinct_cols.join(", "); // Randomly select columns for ORDER BY let num_order_cols = rng.random_range(1..=columns.len()); let mut available_cols = columns.to_vec(); let mut order_cols = Vec::with_capacity(num_order_cols); for _ in 0..num_order_cols { let idx = rng.random_range(0..available_cols.len()); order_cols.push(available_cols.remove(idx)); } let order_cols = order_cols.join(", "); let query = format!("SELECT DISTINCT {distinct_cols} FROM t ORDER BY {order_cols}"); let limbo = limbo_exec_rows(&db, &limbo_conn, &query); let sqlite = sqlite_exec_rows(&sqlite_conn, &query); assert_eq!(limbo, sqlite, "seed: {seed}, query: {query}"); } } #[test] pub fn fuzz_long_create_table_drop_table_alter_table_normal() { _fuzz_long_create_table_drop_table_alter_table(false); } #[test] pub fn fuzz_long_create_table_drop_table_alter_table_mvcc() { _fuzz_long_create_table_drop_table_alter_table(true); } fn _fuzz_long_create_table_drop_table_alter_table(mvcc: bool) { let db = TempDatabase::new_with_opts( "fuzz_long_create_table_drop_table_alter_table", DatabaseOpts::new().with_mvcc(mvcc).with_indexes(!mvcc), ); let limbo_conn = db.connect_limbo(); let (mut rng, seed) = rng_from_time_or_env(); tracing::info!("create_table_drop_table_fuzz seed: {seed}, mvcc: {mvcc}"); // Keep track of current tables and their columns in memory let mut current_tables: std::collections::HashMap> = std::collections::HashMap::new(); let mut table_counter = 0; // Column types for random generation const COLUMN_TYPES: [&str; 6] = ["INTEGER", "TEXT", "REAL", "BLOB", "BOOLEAN", "NUMERIC"]; const COLUMN_NAMES: [&str; 8] = [ "id", "name", "value", "data", "info", "field", "col", "attr", ]; let mut undroppable_cols = HashSet::new(); let mut stmts = vec![]; for iteration in 0..2000 { println!("iteration: {iteration} (seed: {seed})"); let operation = rng.random_range(0..100); // 0: create, 1: drop, 2: alter, 3: alter rename match operation { 0..20 => { // Create table if current_tables.len() < 10 { // Limit number of tables let table_name = format!("table_{table_counter}"); table_counter += 1; let num_columns = rng.random_range(1..6); let mut columns = Vec::new(); for i in 0..num_columns { let col_name = if i == 0 && rng.random_bool(0.3) { "id".to_string() } else { format!( "{}_{}", COLUMN_NAMES[rng.random_range(0..COLUMN_NAMES.len())], rng.random_range(0..u64::MAX) ) }; let col_type = COLUMN_TYPES[rng.random_range(0..COLUMN_TYPES.len())]; let constraint = if i == 0 && rng.random_bool(0.2) { if !mvcc || col_type == "INTEGER" { " PRIMARY KEY" } else { "" } } else if rng.random_bool(0.1) { if !mvcc { " UNIQUE" } else { "" } } else { "" }; if constraint.contains("UNIQUE") || constraint.contains("PRIMARY KEY") { undroppable_cols.insert((table_name.clone(), col_name.clone())); } columns.push(format!("{col_name} {col_type}{constraint}")); } let create_sql = format!("CREATE TABLE {table_name} ({})", columns.join(", ")); // Execute the create table statement stmts.push(create_sql.clone()); limbo_exec_rows(&db, &limbo_conn, &create_sql); let column_names = columns .iter() .map(|c| c.split_whitespace().next().unwrap().to_string()) .collect::>(); // Insert a single row into the table let insert_sql = format!( "INSERT INTO {table_name} ({}) VALUES ({})", column_names.join(", "), (0..columns.len()) .map(|i| i.to_string()) .collect::>() .join(", ") ); stmts.push(insert_sql.clone()); limbo_exec_rows(&db, &limbo_conn, &insert_sql); // Successfully created table, update our tracking current_tables.insert(table_name.clone(), column_names); } } 20..30 => { // Drop table if !current_tables.is_empty() { let table_names: Vec = current_tables.keys().cloned().collect(); let table_to_drop = &table_names[rng.random_range(0..table_names.len())]; let drop_sql = format!("DROP TABLE {table_to_drop}"); stmts.push(drop_sql.clone()); limbo_exec_rows(&db, &limbo_conn, &drop_sql); // Successfully dropped table, update our tracking current_tables.remove(table_to_drop); } } 30..60 => { // Alter table - add column if !current_tables.is_empty() { let table_names: Vec = current_tables.keys().cloned().collect(); let table_to_alter = &table_names[rng.random_range(0..table_names.len())]; let new_col_name = format!("new_col_{}", rng.random_range(0..u64::MAX)); let col_type = COLUMN_TYPES[rng.random_range(0..COLUMN_TYPES.len())]; let alter_sql = format!( "ALTER TABLE {} ADD COLUMN {} {}", table_to_alter, &new_col_name, col_type ); stmts.push(alter_sql.clone()); limbo_exec_rows(&db, &limbo_conn, &alter_sql); // Successfully added column, update our tracking let table_name = table_to_alter.clone(); if let Some(columns) = current_tables.get_mut(&table_name) { columns.push(new_col_name); } } } 60..100 => { // Alter table - drop column if !current_tables.is_empty() { let table_names: Vec = current_tables.keys().cloned().collect(); let table_to_alter = &table_names[rng.random_range(0..table_names.len())]; let table_name = table_to_alter.clone(); if let Some(columns) = current_tables.get(&table_name) { let droppable_cols = columns .iter() .filter(|c| { !undroppable_cols.contains(&(table_name.clone(), c.to_string())) }) .collect::>(); if columns.len() > 1 && !droppable_cols.is_empty() { // Don't drop the last column let col_index = rng.random_range(0..droppable_cols.len()); let col_to_drop = droppable_cols[col_index].clone(); let alter_sql = format!( "ALTER TABLE {table_to_alter} DROP COLUMN {col_to_drop}" ); stmts.push(alter_sql.clone()); limbo_exec_rows(&db, &limbo_conn, &alter_sql); // Successfully dropped column, update our tracking let columns = current_tables.get_mut(&table_name).unwrap(); columns.retain(|c| c != &col_to_drop); } } } } _ => unreachable!(), } // Do SELECT * FROM for all current tables and just verify there is 1 row and the column count and names match the expected columns in the table for (table_name, columns) in current_tables.iter() { let select_sql = format!("SELECT * FROM {table_name}"); let col_names_actual = limbo_stmt_get_column_names(&db, &limbo_conn, &select_sql); let col_names_expected = columns .iter() .map(|c| c.split_whitespace().next().unwrap().to_string()) .collect::>(); assert_eq!( col_names_actual, col_names_expected, "seed: {seed}, mvcc: {mvcc}, table: {table_name}" ); let limbo = limbo_exec_rows(&db, &limbo_conn, &select_sql); assert_eq!( limbo.len(), 1, "seed: {seed}, mvcc: {mvcc}, table: {table_name}" ); assert_eq!( limbo[0].len(), columns.len(), "seed: {seed}, mvcc: {mvcc}, table: {table_name}" ); } if !mvcc { if let Err(e) = rusqlite_integrity_check(&db.path) { for stmt in stmts.iter() { println!("{stmt};"); } panic!("seed: {seed}, mvcc: {mvcc}, error: {e}"); } } } // Final verification - the test passes if we didn't crash println!( "create_table_drop_table_fuzz completed successfully with {} tables remaining. (mvcc: {mvcc}, seed: {seed})", current_tables.len() ); } #[test] #[cfg(feature = "test_helper")] pub fn fuzz_pending_byte_database() -> anyhow::Result<()> { use core_tester::common::rusqlite_integrity_check; maybe_setup_tracing(); let (mut rng, seed) = rng_from_time_or_env(); tracing::debug!(seed); // TODO: currently assume that page size is 4096 bytes (4 Kib) const PAGE_SIZE: u32 = 4 * 2u32.pow(10); /// 100 Mib const MAX_DB_SIZE_BYTES: u32 = 100 * 2u32.pow(20); const MAX_PAGENO: u32 = MAX_DB_SIZE_BYTES / PAGE_SIZE; for _ in 0..10 { // generate a random pending page that is smaller than the 100 MB mark let pending_byte_pgno = rng.random_range(2..MAX_PAGENO); let pending_byte = pending_byte_pgno * PAGE_SIZE; tracing::debug!(pending_byte_pgno, pending_byte); let db_path = tempfile::NamedTempFile::new()?; { let db = TempDatabase::new_with_existent(db_path.path(), true); let prev_pending_byte = TempDatabase::get_pending_byte(); tracing::debug!(prev_pending_byte); TempDatabase::set_pending_byte(pending_byte); let new_pending_byte = TempDatabase::get_pending_byte(); tracing::debug!(new_pending_byte); // Insert more than enough to pass the PENDING_BYTE let query = format!("insert into t select replace(zeroblob({PAGE_SIZE}), x'00', 'A') from generate_series(1, {});", MAX_PAGENO * 2); let conn = db.connect_limbo(); conn.execute("create table t(x);")?; conn.execute(&query)?; conn.close()?; } rusqlite_integrity_check(db_path.path())?; TempDatabase::reset_pending_byte(); } Ok(()) } }