mirror of
https://github.com/aljazceru/turso.git
synced 2025-12-24 03:34:18 +01:00
This separates fuzz tests from integration tests so that you can run the fast test cases with: ``` cargo test --test integration_tests ``` and the longer fuzz cases with: ``` cargo test --test fuzz_tests ```
4169 lines
173 KiB
Rust
4169 lines
173 KiB
Rust
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<i32> = 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::<Vec<_>>()
|
||
.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<String> {
|
||
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::<Vec<_>>()
|
||
.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::<Vec<_>>();
|
||
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::<Vec<_>>();
|
||
let limbo_conns = dbs.iter().map(|db| db.connect_limbo()).collect::<Vec<_>>();
|
||
|
||
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::<Vec<_>>();
|
||
let col_comp_second = COMPARISONS
|
||
.iter()
|
||
.cloned()
|
||
.map(|x| (Some("="), Some(x), None))
|
||
.collect::<Vec<_>>();
|
||
let col_comp_third = COMPARISONS
|
||
.iter()
|
||
.cloned()
|
||
.map(|x| (Some("="), Some("="), Some(x)))
|
||
.collect::<Vec<_>>();
|
||
|
||
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::<Vec<_>>()
|
||
.iter()
|
||
.map(|x| x.to_string())
|
||
.collect::<Vec<_>>();
|
||
|
||
// 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::<Vec<_>>();
|
||
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::<Vec<_>>();
|
||
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::<Vec<_>>();
|
||
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::<Vec<_>>();
|
||
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<String> = 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<TempDatabase> = 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<rusqlite::Connection> = 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)]
|
||
#[ignore] // ignoring because every error I can find is due to sqlite sub-transaction behavior
|
||
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 = 50;
|
||
|
||
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<String> = 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
|
||
for tx_num in 0..INNER_ITERS {
|
||
// Decide if we're in a transaction
|
||
let mut in_tx = false;
|
||
let use_transaction = rng.random_bool(0.7);
|
||
|
||
if use_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 use_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 !use_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'");
|
||
}
|
||
}
|
||
}
|
||
|
||
if use_transaction && in_tx {
|
||
// Randomly COMMIT or ROLLBACK
|
||
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`"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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<String> = 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::<i64>() {
|
||
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<usize>,
|
||
limbo_res: Result<Vec<Vec<rusqlite::types::Value>>, 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<String> = Vec::new();
|
||
let log = |s: &str, stmts: &mut Vec<String>| {
|
||
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<String> = Vec::new();
|
||
let log = |s: &str, stmts: &mut Vec<String>| {
|
||
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<String> = Vec::new();
|
||
let log = |s: &str, stmts: &mut Vec<String>| {
|
||
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<String> = Vec::new();
|
||
let log = |s: &str, stmts: &mut Vec<String>| {
|
||
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::<i64>() {
|
||
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<String> = 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::<Vec<_>>(),
|
||
);
|
||
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<usize> = (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::<Vec<_>>(),
|
||
);
|
||
}
|
||
let mut insert_values = Vec::new();
|
||
for tuple in tuples {
|
||
insert_values.push(format!(
|
||
"({})",
|
||
tuple
|
||
.iter()
|
||
.map(|x| x.to_string())
|
||
.collect::<Vec<_>>()
|
||
.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::<Vec<_>>()
|
||
.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::<Vec<_>>()
|
||
.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<String> = 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<String> = (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<String> = 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<String> = 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<String> = 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::<Vec<_>>();
|
||
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::<String>(),
|
||
(0..(num_cols - 1))
|
||
.map(|i| format!(", c{i}"))
|
||
.collect::<String>(),
|
||
);
|
||
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::<Vec<_>>()
|
||
.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::<Vec<_>>();
|
||
|
||
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::<Vec<_>>();
|
||
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<String> = (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<String> = 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<String> = 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<String> = Vec::new();
|
||
if use_table_pk {
|
||
let mut spec_parts: Vec<String> = 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<String> = 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::<String>()
|
||
), // 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::<String>()
|
||
), // 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::<String>()
|
||
),
|
||
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::<String>()
|
||
), // 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::<Vec<_>>();
|
||
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::<Vec<_>>();
|
||
let query = format!(
|
||
"INSERT INTO t VALUES ({})",
|
||
values
|
||
.iter()
|
||
.map(|v| v.to_string())
|
||
.collect::<Vec<_>>()
|
||
.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<String, Vec<String>> =
|
||
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::<Vec<_>>();
|
||
|
||
// 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::<Vec<_>>()
|
||
.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<String> = 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<String> = 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<String> = 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::<Vec<_>>();
|
||
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 <table> 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::<Vec<_>>();
|
||
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(())
|
||
}
|
||
}
|