triggers: add a lot of different kinds of tests

This commit is contained in:
Jussi Saurio
2025-11-18 13:08:39 +02:00
parent 9aa09d5ccf
commit e1dee4a072
4 changed files with 2023 additions and 16 deletions

View File

@@ -651,16 +651,21 @@ mod fuzz_tests {
#[test]
#[allow(unused_assignments)]
pub fn fk_deferred_constraints_fuzz() {
pub fn fk_deferred_constraints_and_triggers_fuzz() {
let _ = tracing_subscriber::fmt::try_init();
let _ = env_logger::try_init();
let (mut rng, seed) = rng_from_time_or_env();
println!("fk_deferred_constraints_fuzz seed: {seed}");
println!("fk_deferred_constraints_and_triggers_fuzz seed: {seed}");
const OUTER_ITERS: usize = 10;
const INNER_ITERS: usize = 100;
for outer in 0..OUTER_ITERS {
println!("fk_deferred_constraints_fuzz {}/{}", outer + 1, OUTER_ITERS);
println!(
"fk_deferred_constraints_and_triggers_fuzz {}/{}",
outer + 1,
OUTER_ITERS
);
let limbo_db = TempDatabase::new_empty();
let sqlite_db = TempDatabase::new_empty();
@@ -757,6 +762,99 @@ mod fuzz_tests {
}
}
// Add triggers on every outer iteration (max 2 triggers)
// Create a log table for trigger operations
let s = log_and_exec(
"CREATE TABLE trigger_log(action TEXT, table_name TEXT, id_val INT, extra_val INT)",
);
limbo_exec_rows(&limbo_db, &limbo, &s);
sqlite.execute(&s, params![]).unwrap();
// Create a stats table for tracking operations
let s = log_and_exec("CREATE TABLE trigger_stats(op_type TEXT PRIMARY KEY, count INT)");
limbo_exec_rows(&limbo_db, &limbo, &s);
sqlite.execute(&s, params![]).unwrap();
// Define all available trigger types
let trigger_definitions: Vec<&str> = vec![
// BEFORE INSERT trigger on parent - logs and potentially creates a child
"CREATE TRIGGER trig_parent_before_insert BEFORE INSERT ON parent BEGIN
INSERT INTO trigger_log VALUES ('BEFORE_INSERT', 'parent', NEW.id, NEW.a);
INSERT INTO trigger_stats VALUES ('parent_insert', 1) ON CONFLICT(op_type) DO UPDATE SET count=count+1;
-- Sometimes create a deferred child referencing this parent
INSERT INTO child_deferred VALUES (NEW.id + 10000, NEW.id, NEW.a);
END",
// AFTER INSERT trigger on child_deferred - logs and updates parent
"CREATE TRIGGER trig_child_deferred_after_insert AFTER INSERT ON child_deferred BEGIN
INSERT INTO trigger_log VALUES ('AFTER_INSERT', 'child_deferred', NEW.id, NEW.pid);
INSERT INTO trigger_stats VALUES ('child_deferred_insert', 1) ON CONFLICT(op_type) DO UPDATE SET count=count+1;
-- Update parent's 'a' column if parent exists
UPDATE parent SET a = a + 1 WHERE id = NEW.pid;
END",
// BEFORE UPDATE OF 'a' on parent - logs and modifies the update
"CREATE TRIGGER trig_parent_before_update_a BEFORE UPDATE OF a ON parent BEGIN
INSERT INTO trigger_log VALUES ('BEFORE_UPDATE_A', 'parent', OLD.id, OLD.a);
INSERT INTO trigger_stats VALUES ('parent_update_a', 1) ON CONFLICT(op_type) DO UPDATE SET count=count+1;
-- Also update 'b' column when 'a' is updated
UPDATE parent SET b = NEW.a * 2 WHERE id = NEW.id;
END",
// AFTER UPDATE OF 'pid' on child_deferred - logs and creates/updates related records
"CREATE TRIGGER trig_child_deferred_after_update_pid AFTER UPDATE OF pid ON child_deferred BEGIN
INSERT INTO trigger_log VALUES ('AFTER_UPDATE_PID', 'child_deferred', NEW.id, NEW.pid);
INSERT INTO trigger_stats VALUES ('child_deferred_update_pid', 1) ON CONFLICT(op_type) DO UPDATE SET count=count+1;
-- Create a child_immediate referencing the new parent
INSERT INTO child_immediate VALUES (NEW.id + 20000, NEW.pid, NEW.x);
-- Update parent's 'b' column
UPDATE parent SET b = b + 1 WHERE id = NEW.pid;
END",
// BEFORE DELETE on parent - logs and cascades to children
"CREATE TRIGGER trig_parent_before_delete BEFORE DELETE ON parent BEGIN
INSERT INTO trigger_log VALUES ('BEFORE_DELETE', 'parent', OLD.id, OLD.a);
INSERT INTO trigger_stats VALUES ('parent_delete', 1) ON CONFLICT(op_type) DO UPDATE SET count=count+1;
-- Delete all children that reference the deleted parent
DELETE FROM child_deferred WHERE pid = OLD.id;
END",
// AFTER DELETE on child_deferred - logs and updates parent stats
"CREATE TRIGGER trig_child_deferred_after_delete AFTER DELETE ON child_deferred BEGIN
INSERT INTO trigger_log VALUES ('AFTER_DELETE', 'child_deferred', OLD.id, OLD.pid);
INSERT INTO trigger_stats VALUES ('child_deferred_delete', 1) ON CONFLICT(op_type) DO UPDATE SET count=count+1;
-- Update parent's 'a' column
UPDATE parent SET a = a - 1 WHERE id = OLD.pid;
END",
// BEFORE INSERT on child_immediate - logs, creates parent if needed, updates stats
"CREATE TRIGGER trig_child_immediate_before_insert BEFORE INSERT ON child_immediate BEGIN
INSERT INTO trigger_log VALUES ('BEFORE_INSERT', 'child_immediate', NEW.id, NEW.pid);
INSERT INTO trigger_stats VALUES ('child_immediate_insert', 1) ON CONFLICT(op_type) DO UPDATE SET count=count+1;
-- Create parent if it doesn't exist (with a default value)
INSERT OR IGNORE INTO parent VALUES (NEW.pid, NEW.y, NEW.y * 2);
-- Update parent's 'a' column
UPDATE parent SET a = a + NEW.y WHERE id = NEW.pid;
END",
// AFTER UPDATE OF 'y' on child_immediate - logs and cascades updates
"CREATE TRIGGER trig_child_immediate_after_update_y AFTER UPDATE OF y ON child_immediate BEGIN
INSERT INTO trigger_log VALUES ('AFTER_UPDATE_Y', 'child_immediate', NEW.id, NEW.y);
INSERT INTO trigger_stats VALUES ('child_immediate_update_y', 1) ON CONFLICT(op_type) DO UPDATE SET count=count+1;
-- Update parent's 'a' based on the change
UPDATE parent SET a = a + (NEW.y - OLD.y) WHERE id = NEW.pid;
-- Also create a deferred child referencing the same parent
INSERT INTO child_deferred VALUES (NEW.id + 30000, NEW.pid, NEW.y);
END",
];
// Randomly select up to 2 triggers from the list
let num_triggers = rng.random_range(1..=2);
let mut selected_indices = std::collections::HashSet::new();
while selected_indices.len() < num_triggers {
selected_indices.insert(rng.random_range(0..trigger_definitions.len()));
}
// Create the selected triggers
for &idx in selected_indices.iter() {
let s = log_and_exec(trigger_definitions[idx]);
limbo_exec_rows(&limbo_db, &limbo, &s);
sqlite.execute(&s, params![]).unwrap();
}
// Transaction-based mutations with mix of deferred and immediate operations
let mut in_tx = false;
for tx_num in 0..INNER_ITERS {
@@ -1856,16 +1954,128 @@ mod fuzz_tests {
}
}
}
#[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() {
/// Format a nice diff between two result sets for better error messages
#[allow(clippy::too_many_arguments)]
fn format_rows_diff(
sqlite_rows: &[Vec<Value>],
limbo_rows: &[Vec<Value>],
seed: u64,
query: &str,
table_def: &str,
indexes: &[String],
trigger: Option<&String>,
dml_statements: &[String],
) -> String {
let mut diff = String::new();
let sqlite_rows_len = sqlite_rows.len();
let limbo_rows_len = limbo_rows.len();
diff.push_str(&format!(
"\n\n=== Row Count Difference ===\nSQLite: {sqlite_rows_len} rows, Limbo: {limbo_rows_len} rows\n",
));
// Find rows that differ at the same index
let max_len = sqlite_rows.len().max(limbo_rows.len());
let mut diff_indices = Vec::new();
for i in 0..max_len {
let sqlite_row = sqlite_rows.get(i);
let limbo_row = limbo_rows.get(i);
if sqlite_row != limbo_row {
diff_indices.push(i);
}
}
if !diff_indices.is_empty() {
diff.push_str("\n=== Rows Differing at Same Index (showing first 10) ===\n");
for &idx in diff_indices.iter().take(10) {
diff.push_str(&format!("\nIndex {idx}:\n"));
if let Some(sqlite_row) = sqlite_rows.get(idx) {
diff.push_str(&format!(" SQLite: {sqlite_row:?}\n"));
} else {
diff.push_str(" SQLite: <missing>\n");
}
if let Some(limbo_row) = limbo_rows.get(idx) {
diff.push_str(&format!(" Limbo: {limbo_row:?}\n"));
} else {
diff.push_str(" Limbo: <missing>\n");
}
}
if diff_indices.len() > 10 {
diff.push_str(&format!(
"\n... and {} more differences\n",
diff_indices.len() - 10
));
}
}
// Find rows that are in one but not the other (using linear search since Value doesn't implement Hash)
let mut only_in_sqlite = Vec::new();
for sqlite_row in sqlite_rows.iter() {
if !limbo_rows.iter().any(|limbo_row| limbo_row == sqlite_row) {
only_in_sqlite.push(sqlite_row);
}
}
let mut only_in_limbo = Vec::new();
for limbo_row in limbo_rows.iter() {
if !sqlite_rows.iter().any(|sqlite_row| sqlite_row == limbo_row) {
only_in_limbo.push(limbo_row);
}
}
if !only_in_sqlite.is_empty() {
diff.push_str("\n=== Rows Only in SQLite (showing first 10) ===\n");
for row in only_in_sqlite.iter().take(10) {
diff.push_str(&format!(" {row:?}\n"));
}
if only_in_sqlite.len() > 10 {
diff.push_str(&format!(
"\n... and {} more rows\n",
only_in_sqlite.len() - 10
));
}
}
if !only_in_limbo.is_empty() {
diff.push_str("\n=== Rows Only in Limbo (showing first 10) ===\n");
for row in only_in_limbo.iter().take(10) {
diff.push_str(&format!(" {row:?}\n"));
}
if only_in_limbo.len() > 10 {
diff.push_str(&format!(
"\n... and {} more rows\n",
only_in_limbo.len() - 10
));
}
}
diff.push_str(&format!(
"\n=== Context ===\nSeed: {seed}\nQuery: {query}\n",
));
diff.push_str("\n=== DDL/DML to Reproduce ===\n");
diff.push_str(&format!("{table_def};\n"));
for idx in indexes.iter() {
diff.push_str(&format!("{idx};\n"));
}
if let Some(trigger) = trigger {
diff.push_str(&format!("{trigger};\n"));
}
for dml in dml_statements.iter() {
diff.push_str(&format!("{dml};\n"));
}
diff
}
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;
const OUTER_ITERATIONS: usize = 30;
for i in 0..OUTER_ITERATIONS {
println!(
"table_index_mutation_fuzz iteration {}/{}",
@@ -1926,8 +2136,16 @@ mod fuzz_tests {
sqlite_conn.execute(t, params![]).unwrap();
}
let use_trigger = rng.random_bool(1.0);
// Generate initial data
let num_inserts = rng.random_range(10..=1000);
// Triggers can cause quadratic complexity to the tested operations so limit total row count
// whenever we have one to make the test runtime reasonable.
let num_inserts = if use_trigger {
rng.random_range(10..=100)
} else {
rng.random_range(10..=1000)
};
let mut tuples = HashSet::new();
while tuples.len() < num_inserts {
tuples.insert(
@@ -1953,13 +2171,15 @@ mod fuzz_tests {
.map(|i| format!("c{i}"))
.collect::<Vec<_>>()
.join(", ");
let insert_type = match rng.random_range(0..3) {
0 => "",
1 => "OR REPLACE",
2 => "OR IGNORE",
_ => unreachable!(),
};
let insert = format!(
"INSERT {} INTO t ({}) VALUES {}",
if rng.random_bool(0.4) {
"OR IGNORE"
} else {
""
},
insert_type,
col_names,
insert_values.join(", ")
);
@@ -1969,6 +2189,145 @@ mod fuzz_tests {
sqlite_conn.execute(&insert, params![]).unwrap();
limbo_exec_rows(&limbo_db, &limbo_conn, &insert);
// Self-affecting triggers (e.g CREATE TRIGGER t BEFORE DELETE ON t BEGIN UPDATE t ... END) are
// an easy source of bugs, so create one some of the time.
let trigger = if use_trigger {
// Create a random trigger
let trigger_time = if rng.random_bool(0.5) {
"BEFORE"
} else {
"AFTER"
};
let trigger_event = match rng.random_range(0..3) {
0 => "INSERT".to_string(),
1 => {
// Optionally specify columns for UPDATE trigger
if rng.random_bool(0.5) {
let update_col = rng.random_range(0..num_cols);
format!("UPDATE OF c{update_col}")
} else {
"UPDATE".to_string()
}
}
2 => "DELETE".to_string(),
_ => unreachable!(),
};
// Determine if OLD/NEW references are available based on trigger event
let has_old =
trigger_event.starts_with("UPDATE") || trigger_event.starts_with("DELETE");
let has_new =
trigger_event.starts_with("UPDATE") || trigger_event.starts_with("INSERT");
// Generate trigger action (INSERT, UPDATE, or DELETE)
let trigger_action = match rng.random_range(0..3) {
0 => {
// INSERT action
let values = (0..num_cols)
.map(|i| {
// Randomly use OLD/NEW values if available
if has_old && rng.random_bool(0.3) {
format!("OLD.c{i}")
} else if has_new && rng.random_bool(0.3) {
format!("NEW.c{i}")
} else {
rng.random_range(0..1000).to_string()
}
})
.collect::<Vec<_>>()
.join(", ");
let insert_conflict_action = match rng.random_range(0..3) {
0 => "",
1 => " OR REPLACE",
2 => " OR IGNORE",
_ => unreachable!(),
};
format!(
"INSERT{insert_conflict_action} INTO t ({col_names}) VALUES ({values})"
)
}
1 => {
// UPDATE action
let update_col = rng.random_range(0..num_cols);
let new_value = if has_old && rng.random_bool(0.3) {
let ref_col = rng.random_range(0..num_cols);
// Sometimes make it a function of the OLD column
if rng.random_bool(0.5) {
let operator = *["+", "-", "*"].choose(&mut rng).unwrap();
let amount = rng.random_range(1..100);
format!("OLD.c{ref_col} {operator} {amount}")
} else {
format!("OLD.c{ref_col}")
}
} else if has_new && rng.random_bool(0.3) {
let ref_col = rng.random_range(0..num_cols);
// Sometimes make it a function of the NEW column
if rng.random_bool(0.5) {
let operator = *["+", "-", "*"].choose(&mut rng).unwrap();
let amount = rng.random_range(1..100);
format!("NEW.c{ref_col} {operator} {amount}")
} else {
format!("NEW.c{ref_col}")
}
} else {
rng.random_range(0..1000).to_string()
};
let op = match rng.random_range(0..=3) {
0 => "<",
1 => "<=",
2 => ">",
3 => ">=",
_ => unreachable!(),
};
let threshold = if has_old && rng.random_bool(0.3) {
let ref_col = rng.random_range(0..num_cols);
format!("OLD.c{ref_col}")
} else if has_new && rng.random_bool(0.3) {
let ref_col = rng.random_range(0..num_cols);
format!("NEW.c{ref_col}")
} else {
rng.random_range(0..1000).to_string()
};
format!("UPDATE t SET c{update_col} = {new_value} WHERE c{update_col} {op} {threshold}")
}
2 => {
// DELETE action
let delete_col = rng.random_range(0..num_cols);
let op = match rng.random_range(0..=3) {
0 => "<",
1 => "<=",
2 => ">",
3 => ">=",
_ => unreachable!(),
};
let threshold = if has_old && rng.random_bool(0.3) {
let ref_col = rng.random_range(0..num_cols);
format!("OLD.c{ref_col}")
} else if has_new && rng.random_bool(0.3) {
let ref_col = rng.random_range(0..num_cols);
format!("NEW.c{ref_col}")
} else {
rng.random_range(0..1000).to_string()
};
format!("DELETE FROM t WHERE c{delete_col} {op} {threshold}")
}
_ => unreachable!(),
};
let create_trigger = format!(
"CREATE TRIGGER test_trigger {trigger_time} {trigger_event} ON t BEGIN {trigger_action}; END;",
);
sqlite_conn.execute(&create_trigger, params![]).unwrap();
limbo_exec_rows(&limbo_db, &limbo_conn, &create_trigger);
Some(create_trigger)
} else {
None
};
if let Some(ref trigger) = trigger {
println!("{trigger};");
}
const COMPARISONS: [&str; 3] = ["=", "<", ">"];
const INNER_ITERATIONS: usize = 20;
@@ -1976,7 +2335,6 @@ mod fuzz_tests {
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);
@@ -2000,6 +2358,7 @@ mod fuzz_tests {
};
let query = if do_update {
let affected_col = rng.random_range(0..num_cols);
let num_updates = rng.random_range(1..=num_cols);
let mut values = Vec::new();
for _ in 0..num_updates {
@@ -2048,10 +2407,19 @@ mod fuzz_tests {
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}",
);
if sqlite_rows != limbo_rows {
let diff_msg = format_rows_diff(
&sqlite_rows,
&limbo_rows,
seed,
&query,
&table_def,
&indexes,
trigger.as_ref(),
&dml_statements,
);
panic!("Different results after mutation!{diff_msg}");
}
// Run integrity check on limbo db using rusqlite
if let Err(e) = rusqlite_integrity_check(&limbo_db.path) {
@@ -2059,6 +2427,9 @@ mod fuzz_tests {
for t in indexes.iter() {
println!("{t};");
}
if let Some(trigger) = trigger {
println!("{trigger};");
}
for t in dml_statements.iter() {
println!("{t};");
}

View File

@@ -5,6 +5,7 @@ mod index_method;
mod pragma;
mod query_processing;
mod storage;
mod trigger;
mod wal;
#[cfg(test)]

View File

@@ -0,0 +1,888 @@
use crate::common::TempDatabase;
#[test]
fn test_create_trigger() {
let _ = tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.try_init();
let db = TempDatabase::new_empty();
let conn = db.connect_limbo();
conn.execute("CREATE TABLE test (x, y TEXT)").unwrap();
conn.execute(
"CREATE TRIGGER t1 BEFORE INSERT ON test BEGIN
INSERT INTO test VALUES (100, 'triggered');
END",
)
.unwrap();
conn.execute("INSERT INTO test VALUES (1, 'hello')")
.unwrap();
let mut stmt = conn.prepare("SELECT * FROM test ORDER BY rowid").unwrap();
let mut results = Vec::new();
loop {
match stmt.step().unwrap() {
turso_core::StepResult::Row => {
let row = stmt.row().unwrap();
results.push((
row.get_value(0).as_int().unwrap(),
row.get_value(1).cast_text().unwrap().to_string(),
));
}
turso_core::StepResult::Done => break,
turso_core::StepResult::IO => {
stmt.run_once().unwrap();
}
_ => panic!("Unexpected step result"),
}
}
// Row inserted by trigger goes first
assert_eq!(results[0], (100, "triggered".to_string()));
assert_eq!(results[1], (1, "hello".to_string()));
}
#[test]
fn test_drop_trigger() {
let db = TempDatabase::new_empty();
let conn = db.connect_limbo();
conn.execute("CREATE TABLE test (x INTEGER PRIMARY KEY)")
.unwrap();
conn.execute("CREATE TRIGGER t1 BEFORE INSERT ON test BEGIN SELECT 1; END")
.unwrap();
// Verify trigger exists
let mut stmt = conn
.prepare("SELECT name FROM sqlite_schema WHERE type='trigger' AND name='t1'")
.unwrap();
let mut results = Vec::new();
loop {
match stmt.step().unwrap() {
turso_core::StepResult::Row => {
let row = stmt.row().unwrap();
results.push(row.get_value(0).cast_text().unwrap().to_string());
}
turso_core::StepResult::Done => break,
turso_core::StepResult::IO => {
stmt.run_once().unwrap();
}
_ => panic!("Unexpected step result"),
}
}
assert_eq!(results.len(), 1);
conn.execute("DROP TRIGGER t1").unwrap();
// Verify trigger is gone
let mut stmt = conn
.prepare("SELECT name FROM sqlite_schema WHERE type='trigger' AND name='t1'")
.unwrap();
let mut results = Vec::new();
loop {
match stmt.step().unwrap() {
turso_core::StepResult::Row => {
let row = stmt.row().unwrap();
results.push(row.get_value(0).cast_text().unwrap().to_string());
}
turso_core::StepResult::Done => break,
turso_core::StepResult::IO => {
stmt.run_once().unwrap();
}
_ => panic!("Unexpected step result"),
}
}
assert_eq!(results.len(), 0);
}
#[test]
fn test_trigger_after_insert() {
let db = TempDatabase::new_empty();
let conn = db.connect_limbo();
conn.execute("CREATE TABLE test (x INTEGER PRIMARY KEY, y TEXT)")
.unwrap();
conn.execute("CREATE TABLE log (x INTEGER, y TEXT)")
.unwrap();
conn.execute(
"CREATE TRIGGER t1 AFTER INSERT ON test BEGIN
INSERT INTO log VALUES (NEW.x, NEW.y);
END",
)
.unwrap();
conn.execute("INSERT INTO test VALUES (1, 'hello')")
.unwrap();
let mut stmt = conn.prepare("SELECT * FROM log").unwrap();
let mut results = Vec::new();
loop {
match stmt.step().unwrap() {
turso_core::StepResult::Row => {
let row = stmt.row().unwrap();
results.push((
row.get_value(0).as_int().unwrap(),
row.get_value(1).cast_text().unwrap().to_string(),
));
}
turso_core::StepResult::Done => break,
turso_core::StepResult::IO => {
stmt.run_once().unwrap();
}
_ => panic!("Unexpected step result"),
}
}
assert_eq!(results.len(), 1);
assert_eq!(results[0], (1, "hello".to_string()));
}
#[test]
fn test_trigger_when_clause() {
let db = TempDatabase::new_empty();
let conn = db.connect_limbo();
conn.execute("CREATE TABLE test (x INTEGER PRIMARY KEY, y INTEGER)")
.unwrap();
conn.execute("CREATE TABLE log (x INTEGER)").unwrap();
conn.execute(
"CREATE TRIGGER t1 AFTER INSERT ON test WHEN NEW.y > 10 BEGIN
INSERT INTO log VALUES (NEW.x);
END",
)
.unwrap();
conn.execute("INSERT INTO test VALUES (1, 5)").unwrap();
conn.execute("INSERT INTO test VALUES (2, 15)").unwrap();
let mut stmt = conn.prepare("SELECT * FROM log").unwrap();
let mut results = Vec::new();
loop {
match stmt.step().unwrap() {
turso_core::StepResult::Row => {
let row = stmt.row().unwrap();
results.push(row.get_value(0).as_int().unwrap());
}
turso_core::StepResult::Done => break,
turso_core::StepResult::IO => {
stmt.run_once().unwrap();
}
_ => panic!("Unexpected step result"),
}
}
assert_eq!(results.len(), 1);
assert_eq!(results[0], 2);
}
#[test]
fn test_trigger_drop_table_drops_triggers() {
let db = TempDatabase::new_empty();
let conn = db.connect_limbo();
conn.execute("CREATE TABLE test (x INTEGER PRIMARY KEY)")
.unwrap();
conn.execute("CREATE TRIGGER t1 BEFORE INSERT ON test BEGIN SELECT 1; END")
.unwrap();
// Verify trigger exists
let mut stmt = conn
.prepare("SELECT name FROM sqlite_schema WHERE type='trigger' AND name='t1'")
.unwrap();
let mut results = Vec::new();
loop {
match stmt.step().unwrap() {
turso_core::StepResult::Row => {
let row = stmt.row().unwrap();
results.push(row.get_value(0).cast_text().unwrap().to_string());
}
turso_core::StepResult::Done => break,
turso_core::StepResult::IO => {
stmt.run_once().unwrap();
}
_ => panic!("Unexpected step result"),
}
}
assert_eq!(results.len(), 1);
conn.execute("DROP TABLE test").unwrap();
// Verify trigger is gone
let mut stmt = conn
.prepare("SELECT name FROM sqlite_schema WHERE type='trigger' AND name='t1'")
.unwrap();
let mut results = Vec::new();
loop {
match stmt.step().unwrap() {
turso_core::StepResult::Row => {
let row = stmt.row().unwrap();
results.push(row.get_value(0).cast_text().unwrap().to_string());
}
turso_core::StepResult::Done => break,
turso_core::StepResult::IO => {
stmt.run_once().unwrap();
}
_ => panic!("Unexpected step result"),
}
}
assert_eq!(results.len(), 0);
}
#[test]
fn test_trigger_new_old_references() {
let db = TempDatabase::new_empty();
let conn = db.connect_limbo();
conn.execute("CREATE TABLE test (x INTEGER PRIMARY KEY, y TEXT)")
.unwrap();
conn.execute("CREATE TABLE log (msg TEXT)").unwrap();
conn.execute("INSERT INTO test VALUES (1, 'hello')")
.unwrap();
conn.execute(
"CREATE TRIGGER t1 AFTER UPDATE ON test BEGIN
INSERT INTO log VALUES ('old=' || OLD.y || ' new=' || NEW.y);
END",
)
.unwrap();
conn.execute("UPDATE test SET y = 'world' WHERE x = 1")
.unwrap();
let mut stmt = conn.prepare("SELECT * FROM log").unwrap();
let mut results = Vec::new();
loop {
match stmt.step().unwrap() {
turso_core::StepResult::Row => {
let row = stmt.row().unwrap();
results.push(row.get_value(0).cast_text().unwrap().to_string());
}
turso_core::StepResult::Done => break,
turso_core::StepResult::IO => {
stmt.run_once().unwrap();
}
_ => panic!("Unexpected step result"),
}
}
assert_eq!(results.len(), 1);
assert_eq!(results[0], "old=hello new=world");
}
#[test]
fn test_multiple_triggers_same_event() {
let db = TempDatabase::new_empty();
let conn = db.connect_limbo();
conn.execute("CREATE TABLE test (x INTEGER PRIMARY KEY)")
.unwrap();
conn.execute("CREATE TABLE log (msg TEXT)").unwrap();
conn.execute(
"CREATE TRIGGER t1 BEFORE INSERT ON test BEGIN
INSERT INTO log VALUES ('trigger1');
END",
)
.unwrap();
conn.execute(
"CREATE TRIGGER t2 BEFORE INSERT ON test BEGIN
INSERT INTO log VALUES ('trigger2');
END",
)
.unwrap();
conn.execute("INSERT INTO test VALUES (1)").unwrap();
let mut stmt = conn.prepare("SELECT * FROM log ORDER BY msg").unwrap();
let mut results = Vec::new();
loop {
match stmt.step().unwrap() {
turso_core::StepResult::Row => {
let row = stmt.row().unwrap();
results.push(row.get_value(0).cast_text().unwrap().to_string());
}
turso_core::StepResult::Done => break,
turso_core::StepResult::IO => {
stmt.run_once().unwrap();
}
_ => panic!("Unexpected step result"),
}
}
assert_eq!(results.len(), 2);
assert_eq!(results[0], "trigger1");
assert_eq!(results[1], "trigger2");
}
#[test]
fn test_two_triggers_on_same_table() {
let db = TempDatabase::new_empty();
let conn = db.connect_limbo();
conn.execute("CREATE TABLE test (x, msg TEXT)").unwrap();
conn.execute("CREATE TABLE log (msg TEXT)").unwrap();
// Trigger A: fires on INSERT to test, inserts into log and test (which would trigger B)
conn.execute(
"CREATE TRIGGER trigger_a AFTER INSERT ON test BEGIN
INSERT INTO log VALUES ('trigger_a fired for x=' || NEW.x);
INSERT INTO test VALUES (NEW.x + 100, 'from_a');
END",
)
.unwrap();
// Trigger B: fires on INSERT to test, inserts into log and test (which would trigger A)
conn.execute(
"CREATE TRIGGER trigger_b AFTER INSERT ON test BEGIN
INSERT INTO log VALUES ('trigger_b fired for x=' || NEW.x);
INSERT INTO test VALUES (NEW.x + 200, 'from_b');
END",
)
.unwrap();
// Insert initial row - this should trigger A, which triggers B, which tries to trigger A again (prevented)
conn.execute("INSERT INTO test VALUES (1, 'initial')")
.unwrap();
// Check log entries to verify recursion was prevented
let mut stmt = conn.prepare("SELECT * FROM log ORDER BY rowid").unwrap();
let mut results = Vec::new();
loop {
match stmt.step().unwrap() {
turso_core::StepResult::Row => {
let row = stmt.row().unwrap();
results.push(row.get_value(0).cast_text().unwrap().to_string());
}
turso_core::StepResult::Done => break,
turso_core::StepResult::IO => {
stmt.run_once().unwrap();
}
_ => panic!("Unexpected step result"),
}
}
// At minimum, we should see both triggers fire and not infinite loop
assert!(
results.len() >= 2,
"Expected at least 2 log entries, got {}",
results.len()
);
assert!(
results.iter().any(|s| s.contains("trigger_a")),
"trigger_a should have fired"
);
assert!(
results.iter().any(|s| s.contains("trigger_b")),
"trigger_b should have fired"
);
}
#[test]
fn test_trigger_mutual_recursion() {
let db = TempDatabase::new_empty();
let conn = db.connect_limbo();
conn.execute("CREATE TABLE t (id INTEGER, msg TEXT)")
.unwrap();
conn.execute("CREATE TABLE u (id INTEGER, msg TEXT)")
.unwrap();
// Trigger on T: fires on INSERT to t, inserts into u
conn.execute(
"CREATE TRIGGER trigger_on_t AFTER INSERT ON t BEGIN
INSERT INTO u VALUES (NEW.id + 1000, 'from_t');
END",
)
.unwrap();
// Trigger on U: fires on INSERT to u, inserts into t
conn.execute(
"CREATE TRIGGER trigger_on_u AFTER INSERT ON u BEGIN
INSERT INTO t VALUES (NEW.id + 2000, 'from_u');
END",
)
.unwrap();
// Insert initial row into t - this should trigger the chain
conn.execute("INSERT INTO t VALUES (1, 'initial')").unwrap();
// Check that both tables have entries
let mut stmt = conn.prepare("SELECT * FROM t ORDER BY rowid").unwrap();
let mut t_results = Vec::new();
loop {
match stmt.step().unwrap() {
turso_core::StepResult::Row => {
let row = stmt.row().unwrap();
t_results.push((
row.get_value(0).as_int().unwrap(),
row.get_value(1).cast_text().unwrap().to_string(),
));
}
turso_core::StepResult::Done => break,
turso_core::StepResult::IO => {
stmt.run_once().unwrap();
}
_ => panic!("Unexpected step result"),
}
}
let mut stmt = conn.prepare("SELECT * FROM u ORDER BY rowid").unwrap();
let mut u_results = Vec::new();
loop {
match stmt.step().unwrap() {
turso_core::StepResult::Row => {
let row = stmt.row().unwrap();
u_results.push((
row.get_value(0).as_int().unwrap(),
row.get_value(1).cast_text().unwrap().to_string(),
));
}
turso_core::StepResult::Done => break,
turso_core::StepResult::IO => {
stmt.run_once().unwrap();
}
_ => panic!("Unexpected step result"),
}
}
// Verify the chain executed without infinite recursion
assert!(!t_results.is_empty(), "Expected at least 1 entry in t");
assert!(!u_results.is_empty(), "Expected at least 1 entry in u");
// Verify initial insert
assert_eq!(t_results[0], (1, "initial".to_string()));
// Verify trigger on t fired (inserted into u)
assert_eq!(u_results[0], (1001, "from_t".to_string()));
}
#[test]
fn test_after_insert_trigger() {
let db = TempDatabase::new_empty();
let conn = db.connect_limbo();
// Create table and log table
conn.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)")
.unwrap();
conn.execute("CREATE TABLE audit_log (action TEXT, item_id INTEGER, item_name TEXT)")
.unwrap();
// Create AFTER INSERT trigger
conn.execute(
"CREATE TRIGGER after_insert_items
AFTER INSERT ON items
BEGIN
INSERT INTO audit_log VALUES ('INSERT', NEW.id, NEW.name);
END",
)
.unwrap();
// Insert data
conn.execute("INSERT INTO items VALUES (1, 'apple')")
.unwrap();
conn.execute("INSERT INTO items VALUES (2, 'banana')")
.unwrap();
// Verify audit log
let mut stmt = conn
.prepare("SELECT * FROM audit_log ORDER BY rowid")
.unwrap();
let mut results = Vec::new();
loop {
match stmt.step().unwrap() {
turso_core::StepResult::Row => {
let row = stmt.row().unwrap();
results.push((
row.get_value(0).cast_text().unwrap().to_string(),
row.get_value(1).as_int().unwrap(),
row.get_value(2).cast_text().unwrap().to_string(),
));
}
turso_core::StepResult::Done => break,
turso_core::StepResult::IO => {
stmt.run_once().unwrap();
}
_ => panic!("Unexpected step result"),
}
}
assert_eq!(results.len(), 2);
assert_eq!(results[0], ("INSERT".to_string(), 1, "apple".to_string()));
assert_eq!(results[1], ("INSERT".to_string(), 2, "banana".to_string()));
}
#[test]
fn test_before_update_of_trigger() {
let db = TempDatabase::new_empty();
let conn = db.connect_limbo();
// Create table with multiple columns
conn.execute("CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price INTEGER)")
.unwrap();
conn.execute(
"CREATE TABLE price_history (product_id INTEGER, old_price INTEGER, new_price INTEGER)",
)
.unwrap();
// Create BEFORE UPDATE OF trigger - only fires when price column is updated
conn.execute(
"CREATE TRIGGER before_update_price
BEFORE UPDATE OF price ON products
BEGIN
INSERT INTO price_history VALUES (OLD.id, OLD.price, NEW.price);
END",
)
.unwrap();
// Insert initial data
conn.execute("INSERT INTO products VALUES (1, 'widget', 100)")
.unwrap();
conn.execute("INSERT INTO products VALUES (2, 'gadget', 200)")
.unwrap();
// Update price - should fire trigger
conn.execute("UPDATE products SET price = 150 WHERE id = 1")
.unwrap();
// Update name only - should NOT fire trigger
conn.execute("UPDATE products SET name = 'super widget' WHERE id = 1")
.unwrap();
// Update both name and price - should fire trigger
conn.execute("UPDATE products SET name = 'mega gadget', price = 250 WHERE id = 2")
.unwrap();
// Verify price history
let mut stmt = conn
.prepare("SELECT * FROM price_history ORDER BY rowid")
.unwrap();
let mut results = Vec::new();
loop {
match stmt.step().unwrap() {
turso_core::StepResult::Row => {
let row = stmt.row().unwrap();
results.push((
row.get_value(0).as_int().unwrap(),
row.get_value(1).as_int().unwrap(),
row.get_value(2).as_int().unwrap(),
));
}
turso_core::StepResult::Done => break,
turso_core::StepResult::IO => {
stmt.run_once().unwrap();
}
_ => panic!("Unexpected step result"),
}
}
// Should have 2 entries (not 3, because name-only update didn't fire)
assert_eq!(results.len(), 2);
assert_eq!(results[0], (1, 100, 150));
assert_eq!(results[1], (2, 200, 250));
}
#[test]
fn test_after_update_of_trigger() {
let db = TempDatabase::new_empty();
let conn = db.connect_limbo();
// Create table
conn.execute("CREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT, salary INTEGER)")
.unwrap();
conn.execute("CREATE TABLE salary_changes (emp_id INTEGER, old_salary INTEGER, new_salary INTEGER, change_amount INTEGER)")
.unwrap();
// Create AFTER UPDATE OF trigger with multiple statements
conn.execute(
"CREATE TRIGGER after_update_salary
AFTER UPDATE OF salary ON employees
BEGIN
INSERT INTO salary_changes VALUES (NEW.id, OLD.salary, NEW.salary, NEW.salary - OLD.salary);
END",
)
.unwrap();
// Insert initial data
conn.execute("INSERT INTO employees VALUES (1, 'Alice', 50000)")
.unwrap();
conn.execute("INSERT INTO employees VALUES (2, 'Bob', 60000)")
.unwrap();
// Update salary
conn.execute("UPDATE employees SET salary = 55000 WHERE id = 1")
.unwrap();
conn.execute("UPDATE employees SET salary = 65000 WHERE id = 2")
.unwrap();
// Verify salary changes
let mut stmt = conn
.prepare("SELECT * FROM salary_changes ORDER BY rowid")
.unwrap();
let mut results = Vec::new();
loop {
match stmt.step().unwrap() {
turso_core::StepResult::Row => {
let row = stmt.row().unwrap();
results.push((
row.get_value(0).as_int().unwrap(),
row.get_value(1).as_int().unwrap(),
row.get_value(2).as_int().unwrap(),
row.get_value(3).as_int().unwrap(),
));
}
turso_core::StepResult::Done => break,
turso_core::StepResult::IO => {
stmt.run_once().unwrap();
}
_ => panic!("Unexpected step result"),
}
}
assert_eq!(results.len(), 2);
assert_eq!(results[0], (1, 50000, 55000, 5000));
assert_eq!(results[1], (2, 60000, 65000, 5000));
}
fn log(s: &str) -> &str {
tracing::info!("{}", s);
s
}
#[test]
fn test_before_delete_trigger() {
let db = TempDatabase::new_empty();
let conn = db.connect_limbo();
// Create tables
conn.execute(log(
"CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT)",
))
.unwrap();
conn.execute(log(
"CREATE TABLE deleted_users (id INTEGER, username TEXT, deleted_at INTEGER)",
))
.unwrap();
// Create BEFORE DELETE trigger
conn.execute(log("CREATE TRIGGER before_delete_users
BEFORE DELETE ON users
BEGIN
INSERT INTO deleted_users VALUES (OLD.id, OLD.username, 12345);
END"))
.unwrap();
// Insert data
conn.execute(log("INSERT INTO users VALUES (1, 'alice')"))
.unwrap();
conn.execute(log("INSERT INTO users VALUES (2, 'bob')"))
.unwrap();
conn.execute(log("INSERT INTO users VALUES (3, 'charlie')"))
.unwrap();
// Delete some users
conn.execute(log("DELETE FROM users WHERE id = 2")).unwrap();
conn.execute(log("DELETE FROM users WHERE id = 3")).unwrap();
// Verify deleted_users table
let mut stmt = conn
.prepare(log("SELECT * FROM deleted_users ORDER BY id"))
.unwrap();
let mut results = Vec::new();
loop {
match stmt.step().unwrap() {
turso_core::StepResult::Row => {
let row = stmt.row().unwrap();
results.push((
row.get_value(0).as_int().unwrap(),
row.get_value(1).cast_text().unwrap().to_string(),
row.get_value(2).as_int().unwrap(),
));
}
turso_core::StepResult::Done => break,
turso_core::StepResult::IO => {
stmt.run_once().unwrap();
}
_ => panic!("Unexpected step result"),
}
}
assert_eq!(results.len(), 2);
assert_eq!(results[0], (2, "bob".to_string(), 12345));
assert_eq!(results[1], (3, "charlie".to_string(), 12345));
// Verify remaining users
let mut stmt = conn.prepare(log("SELECT COUNT(*) FROM users")).unwrap();
loop {
match stmt.step().unwrap() {
turso_core::StepResult::Row => {
let row = stmt.row().unwrap();
assert_eq!(row.get_value(0).as_int().unwrap(), 1);
break;
}
turso_core::StepResult::Done => break,
turso_core::StepResult::IO => {
stmt.run_once().unwrap();
}
_ => panic!("Unexpected step result"),
}
}
}
#[test]
fn test_after_delete_trigger() {
let db = TempDatabase::new_empty();
let conn = db.connect_limbo();
// Create tables
conn.execute(
"CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, amount INTEGER)",
)
.unwrap();
conn.execute(
"CREATE TABLE order_archive (order_id INTEGER, customer_id INTEGER, amount INTEGER)",
)
.unwrap();
// Create AFTER DELETE trigger
conn.execute(
"CREATE TRIGGER after_delete_orders
AFTER DELETE ON orders
BEGIN
INSERT INTO order_archive VALUES (OLD.id, OLD.customer_id, OLD.amount);
END",
)
.unwrap();
// Insert data
conn.execute("INSERT INTO orders VALUES (1, 100, 50)")
.unwrap();
conn.execute("INSERT INTO orders VALUES (2, 101, 75)")
.unwrap();
conn.execute("INSERT INTO orders VALUES (3, 100, 100)")
.unwrap();
// Delete orders
conn.execute("DELETE FROM orders WHERE customer_id = 100")
.unwrap();
// Verify archive
let mut stmt = conn
.prepare("SELECT * FROM order_archive ORDER BY order_id")
.unwrap();
let mut results = Vec::new();
loop {
match stmt.step().unwrap() {
turso_core::StepResult::Row => {
let row = stmt.row().unwrap();
results.push((
row.get_value(0).as_int().unwrap(),
row.get_value(1).as_int().unwrap(),
row.get_value(2).as_int().unwrap(),
));
}
turso_core::StepResult::Done => break,
turso_core::StepResult::IO => {
stmt.run_once().unwrap();
}
_ => panic!("Unexpected step result"),
}
}
assert_eq!(results.len(), 2);
assert_eq!(results[0], (1, 100, 50));
assert_eq!(results[1], (3, 100, 100));
}
#[test]
fn test_trigger_with_multiple_statements() {
let db = TempDatabase::new_empty();
let conn = db.connect_limbo();
// Create tables
conn.execute("CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER)")
.unwrap();
conn.execute(
"CREATE TABLE transactions (account_id INTEGER, old_balance INTEGER, new_balance INTEGER)",
)
.unwrap();
conn.execute("CREATE TABLE audit (message TEXT)").unwrap();
// Create trigger with multiple statements
conn.execute(
"CREATE TRIGGER track_balance_changes
AFTER UPDATE OF balance ON accounts
BEGIN
INSERT INTO transactions VALUES (NEW.id, OLD.balance, NEW.balance);
INSERT INTO audit VALUES ('Balance changed for account ' || NEW.id);
END",
)
.unwrap();
// Insert initial data
conn.execute("INSERT INTO accounts VALUES (1, 1000)")
.unwrap();
conn.execute("INSERT INTO accounts VALUES (2, 2000)")
.unwrap();
// Update balances
conn.execute("UPDATE accounts SET balance = 1500 WHERE id = 1")
.unwrap();
conn.execute("UPDATE accounts SET balance = 2500 WHERE id = 2")
.unwrap();
// Verify transactions table
let mut stmt = conn
.prepare("SELECT * FROM transactions ORDER BY rowid")
.unwrap();
let mut trans_results = Vec::new();
loop {
match stmt.step().unwrap() {
turso_core::StepResult::Row => {
let row = stmt.row().unwrap();
trans_results.push((
row.get_value(0).as_int().unwrap(),
row.get_value(1).as_int().unwrap(),
row.get_value(2).as_int().unwrap(),
));
}
turso_core::StepResult::Done => break,
turso_core::StepResult::IO => {
stmt.run_once().unwrap();
}
_ => panic!("Unexpected step result"),
}
}
assert_eq!(trans_results.len(), 2);
assert_eq!(trans_results[0], (1, 1000, 1500));
assert_eq!(trans_results[1], (2, 2000, 2500));
// Verify audit table
let mut stmt = conn.prepare("SELECT * FROM audit ORDER BY rowid").unwrap();
let mut audit_results = Vec::new();
loop {
match stmt.step().unwrap() {
turso_core::StepResult::Row => {
let row = stmt.row().unwrap();
audit_results.push(row.get_value(0).cast_text().unwrap().to_string());
}
turso_core::StepResult::Done => break,
turso_core::StepResult::IO => {
stmt.run_once().unwrap();
}
_ => panic!("Unexpected step result"),
}
}
assert_eq!(audit_results.len(), 2);
assert_eq!(audit_results[0], "Balance changed for account 1");
assert_eq!(audit_results[1], "Balance changed for account 2");
}