diff --git a/core/lib.rs b/core/lib.rs index a32e5497b..39c2cb1c4 100644 --- a/core/lib.rs +++ b/core/lib.rs @@ -41,6 +41,7 @@ pub mod numeric; mod numeric; use crate::index_method::IndexMethod; +use crate::schema::Trigger; use crate::storage::checksum::CHECKSUM_REQUIRED_RESERVED_BYTES; use crate::storage::encryption::AtomicCipherMode; use crate::storage::pager::{AutoVacuumMode, HeaderRef}; @@ -642,6 +643,8 @@ impl Database { view_transaction_states: AllViewsTxState::new(), metrics: RwLock::new(ConnectionMetrics::new()), nestedness: AtomicI32::new(0), + compiling_triggers: RwLock::new(Vec::new()), + executing_triggers: RwLock::new(Vec::new()), encryption_key: RwLock::new(None), encryption_cipher_mode: AtomicCipherMode::new(CipherMode::None), sync_mode: AtomicSyncMode::new(SyncMode::Full), @@ -1167,6 +1170,11 @@ pub struct Connection { /// The state is integer as we may want to spawn deep nested programs (e.g. Root -[run]-> S1 -[run]-> S2 -[run]-> ...) /// and we need to track current nestedness depth in order to properly understand when we will reach the root back again nestedness: AtomicI32, + /// Stack of currently compiling triggers to prevent recursive trigger subprogram compilation + compiling_triggers: RwLock>>, + /// Stack of currently executing triggers to prevent recursive trigger execution + /// Only prevents the same trigger from firing again, allowing different triggers on the same table to fire + executing_triggers: RwLock>>, encryption_key: RwLock>, encryption_cipher_mode: AtomicCipherMode, sync_mode: AtomicSyncMode, @@ -1212,6 +1220,52 @@ impl Connection { pub fn end_nested(&self) { self.nestedness.fetch_add(-1, Ordering::SeqCst); } + + /// Check if a specific trigger is currently compiling (for recursive trigger prevention) + pub fn trigger_is_compiling(&self, trigger: impl AsRef) -> bool { + let compiling = self.compiling_triggers.read(); + if let Some(trigger) = compiling.iter().find(|t| t.name == trigger.as_ref().name) { + tracing::debug!("Trigger is already compiling: {}", trigger.name); + return true; + } + false + } + + pub fn start_trigger_compilation(&self, trigger: Arc) { + tracing::debug!("Starting trigger compilation: {}", trigger.name); + self.compiling_triggers.write().push(trigger.clone()); + } + + pub fn end_trigger_compilation(&self) { + tracing::debug!( + "Ending trigger compilation: {:?}", + self.compiling_triggers.read().last().map(|t| &t.name) + ); + self.compiling_triggers.write().pop(); + } + + /// Check if a specific trigger is currently executing (for recursive trigger prevention) + pub fn is_trigger_executing(&self, trigger: impl AsRef) -> bool { + let executing = self.executing_triggers.read(); + if let Some(trigger) = executing.iter().find(|t| t.name == trigger.as_ref().name) { + tracing::debug!("Trigger is already executing: {}", trigger.name); + return true; + } + false + } + + pub fn start_trigger_execution(&self, trigger: Arc) { + tracing::debug!("Starting trigger execution: {}", trigger.name); + self.executing_triggers.write().push(trigger.clone()); + } + + pub fn end_trigger_execution(&self) { + tracing::debug!( + "Ending trigger execution: {:?}", + self.executing_triggers.read().last().map(|t| &t.name) + ); + self.executing_triggers.write().pop(); + } pub fn prepare(self: &Arc, sql: impl AsRef) -> Result { if self.is_mvcc_bootstrap_connection() { // Never use MV store for bootstrapping - we read state directly from sqlite_schema in the DB file. @@ -2053,6 +2107,10 @@ impl Connection { self.db.mvcc_enabled() } + pub fn mv_store(&self) -> Option<&Arc> { + self.db.mv_store.as_ref() + } + /// Query the current value(s) of `pragma_name` associated to /// `pragma_value`. /// @@ -2536,6 +2594,12 @@ pub struct Statement { busy_timeout: Option, } +impl std::fmt::Debug for Statement { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Statement").finish() + } +} + impl Drop for Statement { fn drop(&mut self) { self.reset(); @@ -2567,6 +2631,11 @@ impl Statement { busy_timeout: None, } } + + pub fn get_trigger(&self) -> Option> { + self.program.trigger.clone() + } + pub fn get_query_mode(&self) -> QueryMode { self.query_mode } @@ -2881,12 +2950,8 @@ impl Statement { fn reset_internal(&mut self, max_registers: Option, max_cursors: Option) { // as abort uses auto_txn_cleanup value - it needs to be called before state.reset - self.program.abort( - self.mv_store.as_ref(), - &self.pager, - None, - &mut self.state.auto_txn_cleanup, - ); + self.program + .abort(self.mv_store.as_ref(), &self.pager, None, &mut self.state); self.state.reset(max_registers, max_cursors); self.busy = false; self.busy_timeout = None; diff --git a/core/schema.rs b/core/schema.rs index affb19440..84e3895ea 100644 --- a/core/schema.rs +++ b/core/schema.rs @@ -74,6 +74,47 @@ impl Clone for View { /// Type alias for regular views collection pub type ViewsMap = HashMap>; +/// Trigger structure +#[derive(Debug, Clone)] +pub struct Trigger { + pub name: String, + pub sql: String, + pub table_name: String, + pub time: turso_parser::ast::TriggerTime, + pub event: turso_parser::ast::TriggerEvent, + pub for_each_row: bool, + pub when_clause: Option, + pub commands: Vec, + pub temporary: bool, +} + +impl Trigger { + #[allow(clippy::too_many_arguments)] + pub fn new( + name: String, + sql: String, + table_name: String, + time: Option, + event: turso_parser::ast::TriggerEvent, + for_each_row: bool, + when_clause: Option, + commands: Vec, + temporary: bool, + ) -> Self { + Self { + name, + sql, + table_name, + time: time.unwrap_or(turso_parser::ast::TriggerTime::Before), + event, + for_each_row, + when_clause, + commands, + temporary, + } + } +} + use crate::storage::btree::{BTreeCursor, CursorTrait}; use crate::translate::collate::CollationSeq; use crate::translate::plan::{SelectPlan, TableReferences}; @@ -130,6 +171,9 @@ pub struct Schema { pub views: ViewsMap, + /// table_name to list of triggers + pub triggers: HashMap>>, + /// table_name to list of indexes for the table pub indexes: HashMap>>, pub has_indexes: std::collections::HashSet, @@ -163,6 +207,7 @@ impl Schema { let materialized_view_sql = HashMap::new(); let incremental_views = HashMap::new(); let views: ViewsMap = HashMap::new(); + let triggers = HashMap::new(); let table_to_materialized_views: HashMap> = HashMap::new(); let incompatible_views = HashSet::new(); Self { @@ -171,6 +216,7 @@ impl Schema { materialized_view_sql, incremental_views, views, + triggers, indexes, has_indexes, indexes_enabled, @@ -310,6 +356,72 @@ impl Schema { self.views.get(&name).cloned() } + pub fn add_trigger(&mut self, trigger: Trigger, table_name: &str) -> Result<()> { + self.check_object_name_conflict(&trigger.name)?; + let table_name = normalize_ident(table_name); + + // See [Schema::add_index] for why we push to the front of the deque. + self.triggers + .entry(table_name) + .or_default() + .push_front(Arc::new(trigger)); + + Ok(()) + } + + pub fn remove_trigger(&mut self, name: &str) -> Result<()> { + let name = normalize_ident(name); + + let mut removed = false; + for triggers_list in self.triggers.values_mut() { + for i in 0..triggers_list.len() { + let trigger = &triggers_list[i]; + if normalize_ident(&trigger.name) == name { + removed = true; + triggers_list.remove(i); + break; + } + } + if removed { + break; + } + } + if !removed { + return Err(crate::LimboError::ParseError(format!( + "no such trigger: {name}" + ))); + } + Ok(()) + } + + pub fn get_trigger_for_table(&self, table_name: &str, name: &str) -> Option> { + let table_name = normalize_ident(table_name); + let name = normalize_ident(name); + self.triggers + .get(&table_name) + .and_then(|triggers| triggers.iter().find(|t| t.name == name).cloned()) + } + + pub fn get_triggers_for_table( + &self, + table_name: &str, + ) -> impl Iterator> + Clone { + let table_name = normalize_ident(table_name); + self.triggers + .get(&table_name) + .map(|triggers| triggers.iter()) + .unwrap_or_default() + } + + pub fn get_trigger(&self, name: &str) -> Option> { + let name = normalize_ident(name); + self.triggers + .values() + .flatten() + .find(|t| t.name == name) + .cloned() + } + pub fn add_btree_table(&mut self, table: Arc) -> Result<()> { self.check_object_name_conflict(&table.name)?; let name = normalize_ident(&table.name); @@ -856,6 +968,45 @@ impl Schema { } } } + "trigger" => { + use turso_parser::ast::{Cmd, Stmt}; + use turso_parser::parser::Parser; + + let sql = maybe_sql.expect("sql should be present for trigger"); + let trigger_name = name.to_string(); + + let mut parser = Parser::new(sql.as_bytes()); + let Ok(Some(Cmd::Stmt(Stmt::CreateTrigger { + temporary, + if_not_exists: _, + trigger_name: _, + time, + event, + tbl_name, + for_each_row, + when_clause, + commands, + }))) = parser.next_cmd() + else { + return Err(crate::LimboError::ParseError(format!( + "invalid trigger sql: {sql}" + ))); + }; + self.add_trigger( + Trigger::new( + trigger_name.clone(), + sql.to_string(), + tbl_name.name.to_string(), + time, + event, + for_each_row, + when_clause.map(|e| *e), + commands, + temporary, + ), + tbl_name.name.as_str(), + )?; + } _ => {} }; @@ -1198,6 +1349,16 @@ impl Clone for Schema { .iter() .map(|(name, view)| (name.clone(), Arc::new((**view).clone()))) .collect(); + let triggers = self + .triggers + .iter() + .map(|(table_name, triggers)| { + ( + table_name.clone(), + triggers.iter().map(|t| Arc::new((**t).clone())).collect(), + ) + }) + .collect(); let incompatible_views = self.incompatible_views.clone(); Self { tables, @@ -1205,6 +1366,7 @@ impl Clone for Schema { materialized_view_sql, incremental_views, views, + triggers, indexes, has_indexes: self.has_indexes.clone(), indexes_enabled: self.indexes_enabled, diff --git a/core/storage/pager.rs b/core/storage/pager.rs index e809673bb..8f57577d7 100644 --- a/core/storage/pager.rs +++ b/core/storage/pager.rs @@ -817,14 +817,14 @@ impl Pager { /// Rollback to the newest savepoint. This basically just means reading the subjournal from the start offset /// of the savepoint to the end of the subjournal and restoring the page images to the page cache. - pub fn rollback_to_newest_savepoint(&self) -> Result<()> { + pub fn rollback_to_newest_savepoint(&self) -> Result { let subjournal = self.subjournal.read(); let Some(subjournal) = subjournal.as_ref() else { - return Ok(()); + return Ok(false); }; let mut savepoints = self.savepoints.write(); let Some(savepoint) = savepoints.pop() else { - return Ok(()); + return Ok(false); }; let journal_start_offset = savepoint.start_offset.load(Ordering::SeqCst); @@ -901,7 +901,7 @@ impl Pager { self.page_cache.write().truncate(db_size as usize)?; - Ok(()) + Ok(true) } #[cfg(feature = "test_helper")] diff --git a/core/translate/delete.rs b/core/translate/delete.rs index e853f8142..f67c5354a 100644 --- a/core/translate/delete.rs +++ b/core/translate/delete.rs @@ -2,13 +2,16 @@ use crate::schema::{Schema, Table}; use crate::translate::emitter::{emit_program, Resolver}; use crate::translate::expr::process_returning_clause; use crate::translate::optimizer::optimize_plan; -use crate::translate::plan::{DeletePlan, Operation, Plan}; +use crate::translate::plan::{ + DeletePlan, JoinOrderMember, Operation, Plan, QueryDestination, ResultSetColumn, SelectPlan, +}; use crate::translate::planner::{parse_limit, parse_where}; +use crate::translate::trigger_exec::has_relevant_triggers_type_only; use crate::util::normalize_ident; use crate::vdbe::builder::{ProgramBuilder, ProgramBuilderOpts}; use crate::Result; use std::sync::Arc; -use turso_parser::ast::{Expr, Limit, QualifiedName, ResultColumn}; +use turso_parser::ast::{Expr, Limit, QualifiedName, ResultColumn, TriggerEvent}; use super::plan::{ColumnUsedMask, JoinedTable, TableReferences}; @@ -93,6 +96,8 @@ pub fn prepare_delete_plan( ); } + let btree_table_for_triggers = table.btree(); + let table = if let Some(table) = table.virtual_table() { Table::Virtual(table.clone()) } else if let Some(table) = table.btree() { @@ -130,18 +135,84 @@ pub fn prepare_delete_plan( let (resolved_limit, resolved_offset) = limit.map_or(Ok((None, None)), |l| parse_limit(l, connection))?; - let plan = DeletePlan { - table_references, - result_columns, - where_clause: where_predicates, - order_by: vec![], - limit: resolved_limit, - offset: resolved_offset, - contains_constant_false_condition: false, - indexes, - }; + // Check if there are DELETE triggers. If so, we need to materialize the write set into a RowSet first. + // This is done in SQLite for all DELETE triggers on the affected table even if the trigger would not have an impact + // on the target table -- presumably due to lack of static analysis capabilities to determine whether it's safe + // to skip the rowset materialization. + let has_delete_triggers = btree_table_for_triggers + .as_ref() + .map(|bt| has_relevant_triggers_type_only(schema, TriggerEvent::Delete, None, bt)) + .unwrap_or(false); - Ok(Plan::Delete(plan)) + if has_delete_triggers { + // Create a SelectPlan that materializes rowids into a RowSet + let rowid_internal_id = table_references + .joined_tables() + .first() + .unwrap() + .internal_id; + let rowset_reg = program.alloc_register(); + + let rowset_plan = SelectPlan { + table_references: table_references.clone(), + result_columns: vec![ResultSetColumn { + expr: Expr::RowId { + database: None, + table: rowid_internal_id, + }, + alias: None, + contains_aggregates: false, + }], + where_clause: where_predicates, + group_by: None, + order_by: vec![], + aggregates: vec![], + limit: resolved_limit, + query_destination: QueryDestination::RowSet { rowset_reg }, + join_order: table_references + .joined_tables() + .iter() + .enumerate() + .map(|(i, t)| JoinOrderMember { + table_id: t.internal_id, + original_idx: i, + is_outer: false, + }) + .collect(), + offset: resolved_offset, + contains_constant_false_condition: false, + distinctness: super::plan::Distinctness::NonDistinct, + values: vec![], + window: None, + non_from_clause_subqueries: vec![], + }; + + Ok(Plan::Delete(DeletePlan { + table_references, + result_columns, + where_clause: vec![], + order_by: vec![], + limit: None, + offset: None, + contains_constant_false_condition: false, + indexes, + rowset_plan: Some(rowset_plan), + rowset_reg: Some(rowset_reg), + })) + } else { + Ok(Plan::Delete(DeletePlan { + table_references, + result_columns, + where_clause: where_predicates, + order_by: vec![], + limit: resolved_limit, + offset: resolved_offset, + contains_constant_false_condition: false, + indexes, + rowset_plan: None, + rowset_reg: None, + })) + } } fn estimate_num_instructions(plan: &DeletePlan) -> usize { diff --git a/core/translate/emitter.rs b/core/translate/emitter.rs index edae8ecbc..63a4f63d7 100644 --- a/core/translate/emitter.rs +++ b/core/translate/emitter.rs @@ -5,7 +5,7 @@ use std::num::NonZeroUsize; use std::sync::Arc; use tracing::{instrument, Level}; -use turso_parser::ast::{self, Expr, Literal}; +use turso_parser::ast::{self, Expr, Literal, TriggerEvent, TriggerTime}; use super::aggregation::emit_ungrouped_aggregation; use super::expr::translate_expr; @@ -39,6 +39,10 @@ use crate::translate::plan::{ }; use crate::translate::planner::ROWID_STRS; use crate::translate::subquery::emit_non_from_clause_subquery; +use crate::translate::trigger_exec::{ + fire_trigger, get_relevant_triggers_type_and_time, has_relevant_triggers_type_only, + TriggerContext, +}; use crate::translate::values::emit_values; use crate::translate::window::{emit_window_results, init_window, WindowMetadata}; use crate::util::{exprs_are_equivalent, normalize_ident}; @@ -481,6 +485,18 @@ fn emit_program_for_delete( }); } + let join_order = plan + .table_references + .joined_tables() + .iter() + .enumerate() + .map(|(i, t)| JoinOrderMember { + table_id: t.internal_id, + original_idx: i, + is_outer: false, + }) + .collect::>(); + // Initialize cursors and other resources needed for query execution init_loop( program, @@ -490,38 +506,132 @@ fn emit_program_for_delete( None, OperationMode::DELETE, &plan.where_clause, - &[JoinOrderMember::default()], + &join_order, &mut [], )?; - // Set up main query execution loop - open_loop( - program, - &mut t_ctx, - &plan.table_references, - &[JoinOrderMember::default()], - &plan.where_clause, - None, - OperationMode::DELETE, - &mut [], - )?; + // If there's a rowset_plan, materialize rowids into a RowSet first and then iterate the RowSet + // to delete the rows. + if let Some(rowset_plan) = plan.rowset_plan.take() { + let rowset_reg = plan + .rowset_reg + .expect("rowset_reg must be Some if rowset_plan is Some"); - emit_delete_insns( - connection, - program, - &mut t_ctx, - &mut plan.table_references, - &plan.result_columns, - )?; + // Initialize the RowSet register with NULL (RowSet will be created on first RowSetAdd) + program.emit_insn(Insn::Null { + dest: rowset_reg, + dest_end: None, + }); - // Clean up and close the main execution loop - close_loop( - program, - &mut t_ctx, - &plan.table_references, - &[JoinOrderMember::default()], - OperationMode::DELETE, - )?; + // Execute the rowset SELECT plan to populate the rowset. + program.incr_nesting(); + emit_program_for_select(program, resolver, rowset_plan)?; + program.decr_nesting(); + + // Close the read cursor(s) opened by the rowset plan before opening for writing + let table_ref = plan.table_references.joined_tables().first().unwrap(); + let table_cursor_id_read = + program.resolve_cursor_id(&CursorKey::table(table_ref.internal_id)); + program.emit_insn(Insn::Close { + cursor_id: table_cursor_id_read, + }); + + // Open the table cursor for writing + let table_cursor_id = table_cursor_id_read; + + if let Some(btree_table) = table_ref.table.btree() { + program.emit_insn(Insn::OpenWrite { + cursor_id: table_cursor_id, + root_page: RegisterOrLiteral::Literal(btree_table.root_page), + db: table_ref.database_id, + }); + + // Open all indexes for writing (needed for DELETE) + for index in resolver.schema.get_indices(table_ref.table.get_name()) { + let index_cursor_id = program.alloc_cursor_index( + Some(CursorKey::index(table_ref.internal_id, index.clone())), + index, + )?; + program.emit_insn(Insn::OpenWrite { + cursor_id: index_cursor_id, + root_page: RegisterOrLiteral::Literal(index.root_page), + db: table_ref.database_id, + }); + } + } + + // Now iterate over the RowSet and delete each rowid + let rowset_loop_start = program.allocate_label(); + let rowset_loop_end = program.allocate_label(); + let rowid_reg = program.alloc_register(); + if table_ref.table.virtual_table().is_some() { + // VUpdate requires a NULL second argument ("new rowid") for deletion + let new_rowid_reg = program.alloc_register(); + program.emit_insn(Insn::Null { + dest: new_rowid_reg, + dest_end: None, + }); + } + + program.preassign_label_to_next_insn(rowset_loop_start); + + // Read next rowid from RowSet + // Note: rowset_loop_end will be resolved later when we assign it + program.emit_insn(Insn::RowSetRead { + rowset_reg, + pc_if_empty: rowset_loop_end, + dest_reg: rowid_reg, + }); + + emit_delete_insns_when_triggers_present( + connection, + program, + &mut t_ctx, + &mut plan.table_references, + &plan.result_columns, + rowid_reg, + table_cursor_id, + )?; + + // Continue loop + program.emit_insn(Insn::Goto { + target_pc: rowset_loop_start, + }); + + // Assign the end label here, after all loop body code + program.preassign_label_to_next_insn(rowset_loop_end); + } else { + // Normal DELETE path without RowSet + + // Set up main query execution loop + open_loop( + program, + &mut t_ctx, + &plan.table_references, + &join_order, + &plan.where_clause, + None, + OperationMode::DELETE, + &mut [], + )?; + + emit_delete_insns( + connection, + program, + &mut t_ctx, + &mut plan.table_references, + &plan.result_columns, + )?; + + // Clean up and close the main execution loop + close_loop( + program, + &mut t_ctx, + &plan.table_references, + &join_order, + OperationMode::DELETE, + )?; + } program.preassign_label_to_next_insn(after_main_loop_label); // Finalize program program.result_columns = plan.result_columns; @@ -683,7 +793,6 @@ fn emit_delete_insns<'a>( } let internal_id = unsafe { (*table_reference).internal_id }; - let table_name = unsafe { &*table_reference }.table.get_name(); let cursor_id = match unsafe { &(*table_reference).op } { Operation::Scan { .. } => program.resolve_cursor_id(&CursorKey::table(internal_id)), Operation::Search(search) => match search { @@ -698,14 +807,111 @@ fn emit_delete_insns<'a>( panic!("access through IndexMethod is not supported for delete statements") } }; + let btree_table = unsafe { &*table_reference }.btree(); let main_table_cursor_id = program.resolve_cursor_id(&CursorKey::table(internal_id)); + let has_returning = !result_columns.is_empty(); + let has_delete_triggers = if let Some(btree_table) = btree_table { + has_relevant_triggers_type_only( + t_ctx.resolver.schema, + TriggerEvent::Delete, + None, + &btree_table, + ) + } else { + false + }; + let cols_len = unsafe { &*table_reference }.columns().len(); + let (columns_start_reg, rowid_reg): (Option, usize) = { + // Get rowid for RETURNING + let rowid_reg = program.alloc_register(); + program.emit_insn(Insn::RowId { + cursor_id: main_table_cursor_id, + dest: rowid_reg, + }); + if unsafe { &*table_reference }.virtual_table().is_some() { + // VUpdate requires a NULL second argument ("new rowid") for deletion + let new_rowid_reg = program.alloc_register(); + program.emit_insn(Insn::Null { + dest: new_rowid_reg, + dest_end: None, + }); + } - // Emit the instructions to delete the row - let key_reg = program.alloc_register(); - program.emit_insn(Insn::RowId { - cursor_id: main_table_cursor_id, - dest: key_reg, - }); + if !has_returning && !has_delete_triggers { + (None, rowid_reg) + } else { + // Allocate registers for column values + let columns_start_reg = program.alloc_registers(cols_len); + + // Read all column values from the row to be deleted + for (i, _column) in unsafe { &*table_reference }.columns().iter().enumerate() { + program.emit_column_or_rowid(main_table_cursor_id, i, columns_start_reg + i); + } + + (Some(columns_start_reg), rowid_reg) + } + }; + + // Get the index that is being used to iterate the deletion loop, if there is one. + let iteration_index = unsafe { &*table_reference }.op.index(); + + emit_delete_row_common( + connection, + program, + t_ctx, + table_references, + result_columns, + table_reference, + rowid_reg, + columns_start_reg, + main_table_cursor_id, + iteration_index, + Some(cursor_id), // Use the cursor_id from the operation for virtual tables + )?; + + // Delete from the iteration index after deleting from the main table + if let Some(index) = iteration_index { + let iteration_index_cursor = + program.resolve_cursor_id(&CursorKey::index(internal_id, index.clone())); + program.emit_insn(Insn::Delete { + cursor_id: iteration_index_cursor, + table_name: index.name.clone(), + is_part_of_update: false, + }); + } + if let Some(limit_ctx) = t_ctx.limit_ctx { + program.emit_insn(Insn::DecrJumpZero { + reg: limit_ctx.reg_limit, + target_pc: t_ctx.label_main_loop_end.unwrap(), + }) + } + + Ok(()) +} + +/// Common deletion logic shared between normal DELETE and RowSet-based DELETE. +/// +/// Parameters: +/// - `rowid_reg`: Register containing the rowid of the row to delete +/// - `columns_start_reg`: Start register containing column values (already read) +/// - `skip_iteration_index`: If Some(index), skip deleting from this index (used when iterating over an index) +/// - `virtual_table_cursor_id`: If Some, use this cursor for virtual table deletion +#[allow(clippy::too_many_arguments)] +fn emit_delete_row_common( + connection: &Arc, + program: &mut ProgramBuilder, + t_ctx: &mut TranslateCtx, + table_references: &mut TableReferences, + result_columns: &[super::plan::ResultSetColumn], + table_reference: *const JoinedTable, + rowid_reg: usize, + columns_start_reg: Option, // must be provided when there are triggers or RETURNING + main_table_cursor_id: usize, + skip_iteration_index: Option<&Arc>, + virtual_table_cursor_id: Option, +) -> Result<()> { + let internal_id = unsafe { (*table_reference).internal_id }; + let table_name = unsafe { &*table_reference }.table.get_name(); if connection.foreign_keys_enabled() { if let Some(table) = unsafe { &*table_reference }.btree() { @@ -719,7 +925,7 @@ fn emit_delete_insns<'a>( &t_ctx.resolver, table_name, main_table_cursor_id, - key_reg, + rowid_reg, )?; } if t_ctx.resolver.schema.has_child_fks(table_name) { @@ -729,7 +935,7 @@ fn emit_delete_insns<'a>( &table, table_name, main_table_cursor_id, - key_reg, + rowid_reg, )?; } } @@ -737,31 +943,24 @@ fn emit_delete_insns<'a>( if unsafe { &*table_reference }.virtual_table().is_some() { let conflict_action = 0u16; - let start_reg = key_reg; + let cursor_id = virtual_table_cursor_id.unwrap_or(main_table_cursor_id); - let new_rowid_reg = program.alloc_register(); - program.emit_insn(Insn::Null { - dest: new_rowid_reg, - dest_end: None, - }); program.emit_insn(Insn::VUpdate { cursor_id, arg_count: 2, - start_reg, + start_reg: rowid_reg, conflict_action, }); } else { // Delete from all indexes before deleting from the main table. let indexes = t_ctx.resolver.schema.get_indices(table_name); - // Get the index that is being used to iterate the deletion loop, if there is one. - let iteration_index = unsafe { &*table_reference }.op.index(); - // Get all indexes that are not the iteration index. - let other_indexes = indexes + // Get indexes to delete from (skip the iteration index if specified) + let indexes_to_delete = indexes .filter(|index| { - iteration_index + skip_iteration_index .as_ref() - .is_none_or(|it_idx| !Arc::ptr_eq(it_idx, index)) + .is_none_or(|skip_idx| !Arc::ptr_eq(skip_idx, index)) }) .map(|index| { ( @@ -771,7 +970,7 @@ fn emit_delete_insns<'a>( }) .collect::>(); - for (index, index_cursor_id) in other_indexes { + for (index, index_cursor_id) in indexes_to_delete { let skip_delete_label = if index.where_clause.is_some() { let where_copy = index .bind_where_expr(Some(table_references), connection) @@ -826,11 +1025,6 @@ fn emit_delete_insns<'a>( // Emit update in the CDC table if necessary (before DELETE updated the table) if let Some(cdc_cursor_id) = t_ctx.cdc_cursor_id { - let rowid_reg = program.alloc_register(); - program.emit_insn(Insn::RowId { - cursor_id: main_table_cursor_id, - dest: rowid_reg, - }); let cdc_has_before = program.capture_data_changes_mode().has_before(); let before_record_reg = if cdc_has_before { Some(emit_cdc_full_record( @@ -857,22 +1051,8 @@ fn emit_delete_insns<'a>( // Emit RETURNING results if specified (must be before DELETE) if !result_columns.is_empty() { - // Get rowid for RETURNING - let rowid_reg = program.alloc_register(); - program.emit_insn(Insn::RowId { - cursor_id: main_table_cursor_id, - dest: rowid_reg, - }); - let cols_len = unsafe { &*table_reference }.columns().len(); - - // Allocate registers for column values - let columns_start_reg = program.alloc_registers(cols_len); - - // Read all column values from the row to be deleted - for (i, _column) in unsafe { &*table_reference }.columns().iter().enumerate() { - program.emit_column_or_rowid(main_table_cursor_id, i, columns_start_reg + i); - } - + let columns_start_reg = columns_start_reg + .expect("columns_start_reg must be provided when there are triggers or RETURNING"); // Emit RETURNING results using the values we just read emit_returning_results( program, @@ -889,24 +1069,158 @@ fn emit_delete_insns<'a>( table_name: table_name.to_string(), is_part_of_update: false, }); + } - if let Some(index) = iteration_index { - let iteration_index_cursor = - program.resolve_cursor_id(&CursorKey::index(internal_id, index.clone())); - program.emit_insn(Insn::Delete { - cursor_id: iteration_index_cursor, - table_name: index.name.clone(), - is_part_of_update: false, - }); + Ok(()) +} + +/// Helper function to delete a row when we've already seeked to it (e.g., from a RowSet). +/// This is similar to emit_delete_insns but assumes the cursor is already positioned at the row. +fn emit_delete_insns_when_triggers_present( + connection: &Arc, + program: &mut ProgramBuilder, + t_ctx: &mut TranslateCtx, + table_references: &mut TableReferences, + result_columns: &[super::plan::ResultSetColumn], + rowid_reg: usize, + main_table_cursor_id: usize, +) -> Result<()> { + // Seek to the rowid and delete it + let skip_not_found_label = program.allocate_label(); + + // Skip if row with rowid pulled from the rowset does not exist in the table. + program.emit_insn(Insn::NotExists { + cursor: main_table_cursor_id, + rowid_reg, + target_pc: skip_not_found_label, + }); + + let table_reference: *const JoinedTable = table_references.joined_tables().first().unwrap(); + if unsafe { &*table_reference } + .virtual_table() + .is_some_and(|t| t.readonly()) + { + return Err(crate::LimboError::ReadOnly); + } + let btree_table = unsafe { &*table_reference }.btree(); + let has_returning = !result_columns.is_empty(); + let has_delete_triggers = if let Some(btree_table) = btree_table { + has_relevant_triggers_type_only( + t_ctx.resolver.schema, + TriggerEvent::Delete, + None, + &btree_table, + ) + } else { + false + }; + let cols_len = unsafe { &*table_reference }.columns().len(); + + let columns_start_reg = if !has_returning && !has_delete_triggers { + None + } else { + let columns_start_reg = program.alloc_registers(cols_len); + for (i, _column) in unsafe { &*table_reference }.columns().iter().enumerate() { + program.emit_column_or_rowid(main_table_cursor_id, i, columns_start_reg + i); + } + Some(columns_start_reg) + }; + + let cols_len = unsafe { &*table_reference }.columns().len(); + + // Fire BEFORE DELETE triggers + if let Some(btree_table) = unsafe { &*table_reference }.btree() { + let relevant_triggers = get_relevant_triggers_type_and_time( + t_ctx.resolver.schema, + TriggerEvent::Delete, + TriggerTime::Before, + None, + &btree_table, + ); + let has_relevant_triggers = relevant_triggers.clone().count() > 0; + if has_relevant_triggers { + let columns_start_reg = columns_start_reg + .expect("columns_start_reg must be provided when there are triggers or RETURNING"); + let old_registers = (0..cols_len) + .map(|i| columns_start_reg + i) + .chain(std::iter::once(rowid_reg)) + .collect::>(); + let trigger_ctx = TriggerContext::new( + btree_table.clone(), + None, // No NEW for DELETE + Some(old_registers), + ); + + for trigger in relevant_triggers { + fire_trigger( + program, + &mut t_ctx.resolver, + trigger, + &trigger_ctx, + connection, + )?; + } } } - if let Some(limit_ctx) = t_ctx.limit_ctx { - program.emit_insn(Insn::DecrJumpZero { - reg: limit_ctx.reg_limit, - target_pc: t_ctx.label_main_loop_end.unwrap(), - }) + + // BEFORE DELETE Triggers may have altered the btree so we need to seek again. + program.emit_insn(Insn::NotExists { + cursor: main_table_cursor_id, + rowid_reg, + target_pc: skip_not_found_label, + }); + + emit_delete_row_common( + connection, + program, + t_ctx, + table_references, + result_columns, + table_reference, + rowid_reg, + columns_start_reg, + main_table_cursor_id, + None, // Don't skip any indexes when deleting from RowSet + None, // Use main_table_cursor_id for virtual tables + )?; + + // Fire AFTER DELETE triggers + if let Some(btree_table) = unsafe { &*table_reference }.btree() { + let relevant_triggers = get_relevant_triggers_type_and_time( + t_ctx.resolver.schema, + TriggerEvent::Delete, + TriggerTime::After, + None, + &btree_table, + ); + let has_relevant_triggers = relevant_triggers.clone().count() > 0; + if has_relevant_triggers { + let columns_start_reg = columns_start_reg + .expect("columns_start_reg must be provided when there are triggers or RETURNING"); + let old_registers = (0..cols_len) + .map(|i| columns_start_reg + i) + .chain(std::iter::once(rowid_reg)) + .collect::>(); + let trigger_ctx_after = TriggerContext::new( + btree_table.clone(), + None, // No NEW for DELETE + Some(old_registers), + ); + + for trigger in relevant_triggers { + fire_trigger( + program, + &mut t_ctx.resolver, + trigger, + &trigger_ctx_after, + connection, + )?; + } + } } + program.preassign_label_to_next_insn(skip_not_found_label); + Ok(()) } @@ -982,6 +1296,18 @@ fn emit_program_for_update( UpdateRowSource::Normal }); + let join_order = plan + .table_references + .joined_tables() + .iter() + .enumerate() + .map(|(i, t)| JoinOrderMember { + table_id: t.internal_id, + original_idx: i, + is_outer: false, + }) + .collect::>(); + // Initialize the main loop init_loop( program, @@ -991,7 +1317,7 @@ fn emit_program_for_update( None, mode.clone(), &plan.where_clause, - &[JoinOrderMember::default()], + &join_order, &mut [], )?; @@ -1025,7 +1351,7 @@ fn emit_program_for_update( program, &mut t_ctx, &plan.table_references, - &[JoinOrderMember::default()], + &join_order, &plan.where_clause, temp_cursor_id, mode.clone(), @@ -1063,7 +1389,7 @@ fn emit_program_for_update( program, &mut t_ctx, &plan.table_references, - &[JoinOrderMember::default()], + &join_order, mode.clone(), )?; @@ -1075,6 +1401,168 @@ fn emit_program_for_update( Ok(()) } +/// Helper function to evaluate SET expressions and read column values for UPDATE. +/// This is invoked once for every UPDATE, but will be invoked again if there are +/// any BEFORE UPDATE triggers that fired, because the triggers may have modified the row, +/// in which case the previously read values are stale. +#[allow(clippy::too_many_arguments)] +fn emit_update_column_values<'a>( + program: &mut ProgramBuilder, + table_references: &mut TableReferences, + set_clauses: &[(usize, Box)], + cdc_update_alter_statement: Option<&str>, + target_table: &Arc, + target_table_cursor_id: usize, + start: usize, + col_len: usize, + table_name: &str, + has_direct_rowid_update: bool, + has_user_provided_rowid: bool, + rowid_set_clause_reg: Option, + is_virtual: bool, + index: &Option<(Arc, usize)>, + cdc_updates_register: Option, + t_ctx: &mut TranslateCtx<'a>, + skip_set_clauses: bool, +) -> crate::Result<()> { + if has_direct_rowid_update { + if let Some((_, expr)) = set_clauses.iter().find(|(i, _)| *i == ROWID_SENTINEL) { + if !skip_set_clauses { + let rowid_set_clause_reg = rowid_set_clause_reg.unwrap(); + translate_expr( + program, + Some(table_references), + expr, + rowid_set_clause_reg, + &t_ctx.resolver, + )?; + program.emit_insn(Insn::MustBeInt { + reg: rowid_set_clause_reg, + }); + } + } + } + for (idx, table_column) in target_table.table.columns().iter().enumerate() { + let target_reg = start + idx; + if let Some((col_idx, expr)) = set_clauses.iter().find(|(i, _)| *i == idx) { + if !skip_set_clauses { + // Skip if this is the sentinel value + if *col_idx == ROWID_SENTINEL { + continue; + } + if has_user_provided_rowid + && (table_column.primary_key() || table_column.is_rowid_alias()) + && !is_virtual + { + let rowid_set_clause_reg = rowid_set_clause_reg.unwrap(); + translate_expr( + program, + Some(table_references), + expr, + rowid_set_clause_reg, + &t_ctx.resolver, + )?; + + program.emit_insn(Insn::MustBeInt { + reg: rowid_set_clause_reg, + }); + + program.emit_null(target_reg, None); + } else { + translate_expr( + program, + Some(table_references), + expr, + target_reg, + &t_ctx.resolver, + )?; + if table_column.notnull() { + use crate::error::SQLITE_CONSTRAINT_NOTNULL; + program.emit_insn(Insn::HaltIfNull { + target_reg, + err_code: SQLITE_CONSTRAINT_NOTNULL, + description: format!( + "{}.{}", + table_name, + table_column + .name + .as_ref() + .expect("Column name must be present") + ), + }); + } + } + + if let Some(cdc_updates_register) = cdc_updates_register { + let change_reg = cdc_updates_register + idx; + let value_reg = cdc_updates_register + col_len + idx; + program.emit_bool(true, change_reg); + program.mark_last_insn_constant(); + let mut updated = false; + if let Some(ddl_query_for_cdc_update) = cdc_update_alter_statement { + if table_column.name.as_deref() == Some("sql") { + program.emit_string8(ddl_query_for_cdc_update.to_string(), value_reg); + updated = true; + } + } + if !updated { + program.emit_insn(Insn::Copy { + src_reg: target_reg, + dst_reg: value_reg, + extra_amount: 0, + }); + } + } + } + } else { + // Column is not being updated, read it from the table + let column_idx_in_index = index.as_ref().and_then(|(idx, _)| { + idx.columns + .iter() + .position(|c| Some(&c.name) == table_column.name.as_ref()) + }); + + // don't emit null for pkey of virtual tables. they require first two args + // before the 'record' to be explicitly non-null + if table_column.is_rowid_alias() && !is_virtual { + program.emit_null(target_reg, None); + } else if is_virtual { + program.emit_insn(Insn::VColumn { + cursor_id: target_table_cursor_id, + column: idx, + dest: target_reg, + }); + } else { + let cursor_id = *index + .as_ref() + .and_then(|(_, id)| { + if column_idx_in_index.is_some() { + Some(id) + } else { + None + } + }) + .unwrap_or(&target_table_cursor_id); + program.emit_column_or_rowid( + cursor_id, + column_idx_in_index.unwrap_or(idx), + target_reg, + ); + } + + if let Some(cdc_updates_register) = cdc_updates_register { + let change_bit_reg = cdc_updates_register + idx; + let value_reg = cdc_updates_register + col_len + idx; + program.emit_bool(false, change_bit_reg); + program.mark_last_insn_constant(); + program.emit_null(value_reg, None); + program.mark_last_insn_constant(); + } + } + } + Ok(()) +} + #[instrument(skip_all, level = Level::DEBUG)] #[allow(clippy::too_many_arguments)] /// Emits the instructions for the UPDATE loop. @@ -1219,134 +1707,153 @@ fn emit_update_insns<'a>( let start = if is_virtual { beg + 2 } else { beg + 1 }; - if has_direct_rowid_update { - if let Some((_, expr)) = set_clauses.iter().find(|(i, _)| *i == ROWID_SENTINEL) { - let rowid_set_clause_reg = rowid_set_clause_reg.unwrap(); - translate_expr( - program, - Some(table_references), - expr, - rowid_set_clause_reg, - &t_ctx.resolver, - )?; - program.emit_insn(Insn::MustBeInt { - reg: rowid_set_clause_reg, - }); - } - } - for (idx, table_column) in target_table.table.columns().iter().enumerate() { - let target_reg = start + idx; - if let Some((col_idx, expr)) = set_clauses.iter().find(|(i, _)| *i == idx) { - // Skip if this is the sentinel value - if *col_idx == ROWID_SENTINEL { - continue; - } - if has_user_provided_rowid - && (table_column.primary_key() || table_column.is_rowid_alias()) - && !is_virtual - { - let rowid_set_clause_reg = rowid_set_clause_reg.unwrap(); - translate_expr( - program, - Some(table_references), - expr, - rowid_set_clause_reg, - &t_ctx.resolver, - )?; + let skip_set_clauses = false; - program.emit_insn(Insn::MustBeInt { - reg: rowid_set_clause_reg, - }); + emit_update_column_values( + program, + table_references, + set_clauses, + cdc_update_alter_statement, + &target_table, + target_table_cursor_id, + start, + col_len, + table_name, + has_direct_rowid_update, + has_user_provided_rowid, + rowid_set_clause_reg, + is_virtual, + &index, + cdc_updates_register, + t_ctx, + skip_set_clauses, + )?; - program.emit_null(target_reg, None); + // Fire BEFORE UPDATE triggers and preserve old_registers for AFTER triggers + let preserved_old_registers: Option> = + if let Some(btree_table) = target_table.table.btree() { + let updated_column_indices: std::collections::HashSet = + set_clauses.iter().map(|(col_idx, _)| *col_idx).collect(); + let relevant_before_update_triggers = get_relevant_triggers_type_and_time( + t_ctx.resolver.schema, + TriggerEvent::Update, + TriggerTime::Before, + Some(updated_column_indices.clone()), + &btree_table, + ); + // Read OLD row values for trigger context + let old_registers: Vec = (0..col_len) + .map(|i| { + let reg = program.alloc_register(); + program.emit_column_or_rowid(target_table_cursor_id, i, reg); + reg + }) + .chain(std::iter::once(beg)) + .collect(); + let has_relevant_triggers = relevant_before_update_triggers.clone().count() > 0; + if !has_relevant_triggers { + Some(old_registers) } else { - translate_expr( - program, - Some(table_references), - expr, - target_reg, - &t_ctx.resolver, - )?; - if table_column.notnull() { - use crate::error::SQLITE_CONSTRAINT_NOTNULL; - program.emit_insn(Insn::HaltIfNull { - target_reg, - err_code: SQLITE_CONSTRAINT_NOTNULL, - description: format!( - "{}.{}", - table_name, - table_column - .name - .as_ref() - .expect("Column name must be present") - ), - }); - } - } + // NEW row values are already in 'start' registers + let new_registers = (0..col_len) + .map(|i| start + i) + .chain(std::iter::once(beg)) + .collect(); - if let Some(cdc_updates_register) = cdc_updates_register { - let change_reg = cdc_updates_register + idx; - let value_reg = cdc_updates_register + col_len + idx; - program.emit_bool(true, change_reg); - program.mark_last_insn_constant(); - let mut updated = false; - if let Some(ddl_query_for_cdc_update) = &cdc_update_alter_statement { - if table_column.name.as_deref() == Some("sql") { - program.emit_string8(ddl_query_for_cdc_update.to_string(), value_reg); - updated = true; - } + let trigger_ctx = TriggerContext::new( + btree_table.clone(), + Some(new_registers), + Some(old_registers.clone()), // Clone for AFTER trigger + ); + + for trigger in relevant_before_update_triggers { + fire_trigger( + program, + &mut t_ctx.resolver, + trigger, + &trigger_ctx, + connection, + )?; } - if !updated { - program.emit_insn(Insn::Copy { - src_reg: target_reg, - dst_reg: value_reg, - extra_amount: 0, - }); + + // BEFORE UPDATE Triggers may have altered the btree so we need to seek again. + program.emit_insn(Insn::NotExists { + cursor: target_table_cursor_id, + rowid_reg: beg, + target_pc: check_rowid_not_exists_label.expect( + "check_rowid_not_exists_label must be set if there are BEFORE UPDATE triggers", + ), + }); + + let has_relevant_after_triggers = get_relevant_triggers_type_and_time( + t_ctx.resolver.schema, + TriggerEvent::Update, + TriggerTime::After, + Some(updated_column_indices), + &btree_table, + ) + .clone() + .count() + > 0; + if has_relevant_after_triggers { + // Preserve pseudo-row 'OLD' for AFTER triggers by copying to new registers + // (since registers might be overwritten during trigger execution) + let preserved: Vec = old_registers + .iter() + .map(|old_reg| { + let preserved_reg = program.alloc_register(); + program.emit_insn(Insn::Copy { + src_reg: *old_reg, + dst_reg: preserved_reg, + extra_amount: 0, + }); + preserved_reg + }) + .collect(); + Some(preserved) + } else { + Some(old_registers) } } } else { - let column_idx_in_index = index.as_ref().and_then(|(idx, _)| { - idx.columns - .iter() - .position(|c| Some(&c.name) == table_column.name.as_ref()) - }); + None + }; - // don't emit null for pkey of virtual tables. they require first two args - // before the 'record' to be explicitly non-null - if table_column.is_rowid_alias() && !is_virtual { - program.emit_null(target_reg, None); - } else if is_virtual { - program.emit_insn(Insn::VColumn { - cursor_id: target_table_cursor_id, - column: idx, - dest: target_reg, - }); - } else { - let cursor_id = *index - .as_ref() - .and_then(|(_, id)| { - if column_idx_in_index.is_some() { - Some(id) - } else { - None - } - }) - .unwrap_or(&target_table_cursor_id); - program.emit_column_or_rowid( - cursor_id, - column_idx_in_index.unwrap_or(idx), - target_reg, - ); - } - - if let Some(cdc_updates_register) = cdc_updates_register { - let change_bit_reg = cdc_updates_register + idx; - let value_reg = cdc_updates_register + col_len + idx; - program.emit_bool(false, change_bit_reg); - program.mark_last_insn_constant(); - program.emit_null(value_reg, None); - program.mark_last_insn_constant(); - } + // If BEFORE UPDATE triggers fired, they may have modified the row being updated. + // According to the SQLite documentation, the behavior in these cases is undefined: + // https://sqlite.org/lang_createtrigger.html + // However, based on fuzz testing and observations, the logic seems to be: + // The values that are NOT referred to in SET clauses will be evaluated again, + // and values in SET clauses are evaluated using the old values. + // sqlite> create table t(c0,c1,c2); + // sqlite> create trigger tu before update on t begin update t set c1=666, c2=666; end; + // sqlite> insert into t values (1,1,1); + // sqlite> update t set c0 = c1+1; + // sqlite> select * from t; + // 2|666|666 + if target_table.table.btree().is_some() { + let before_update_triggers_fired = preserved_old_registers.is_some(); + let skip_set_clauses = true; + if before_update_triggers_fired { + emit_update_column_values( + program, + table_references, + set_clauses, + cdc_update_alter_statement, + &target_table, + target_table_cursor_id, + start, + col_len, + table_name, + has_direct_rowid_update, + has_user_provided_rowid, + rowid_set_clause_reg, + is_virtual, + &index, + cdc_updates_register, + t_ctx, + skip_set_clauses, + )?; } } @@ -1752,6 +2259,46 @@ fn emit_update_insns<'a>( table_name: target_table.identifier.clone(), }); + // Fire AFTER UPDATE triggers + if let Some(btree_table) = target_table.table.btree() { + let updated_column_indices: std::collections::HashSet = + set_clauses.iter().map(|(col_idx, _)| *col_idx).collect(); + let relevant_triggers = get_relevant_triggers_type_and_time( + t_ctx.resolver.schema, + TriggerEvent::Update, + TriggerTime::After, + Some(updated_column_indices), + &btree_table, + ); + let has_relevant_triggers = relevant_triggers.clone().count() > 0; + if has_relevant_triggers { + let new_rowid_reg = rowid_set_clause_reg.unwrap_or(beg); + let new_registers_after = (0..col_len) + .map(|i| start + i) + .chain(std::iter::once(new_rowid_reg)) + .collect(); + + // Use preserved OLD registers from BEFORE trigger + let old_registers_after = preserved_old_registers; + + let trigger_ctx_after = TriggerContext::new( + btree_table.clone(), + Some(new_registers_after), + old_registers_after, // OLD values preserved from BEFORE trigger + ); + + for trigger in relevant_triggers { + fire_trigger( + program, + &mut t_ctx.resolver, + trigger, + &trigger_ctx_after, + connection, + )?; + } + } + } + // Emit RETURNING results if specified if let Some(returning_columns) = &returning { if !returning_columns.is_empty() { diff --git a/core/translate/insert.rs b/core/translate/insert.rs index ed63b80b6..33c37b0e4 100644 --- a/core/translate/insert.rs +++ b/core/translate/insert.rs @@ -1,7 +1,8 @@ use std::num::NonZeroUsize; use std::sync::Arc; use turso_parser::ast::{ - self, Expr, InsertBody, OneSelect, QualifiedName, ResolveType, ResultColumn, Upsert, UpsertDo, + self, Expr, InsertBody, OneSelect, QualifiedName, ResolveType, ResultColumn, TriggerEvent, + TriggerTime, Upsert, UpsertDo, }; use crate::error::{ @@ -24,6 +25,7 @@ use crate::translate::plan::{ ColumnUsedMask, JoinedTable, Operation, ResultSetColumn, TableReferences, }; use crate::translate::planner::ROWID_STRS; +use crate::translate::trigger_exec::{fire_trigger, get_relevant_triggers_type_and_time}; use crate::translate::upsert::{ collect_set_clauses_for_upsert, emit_upsert, resolve_upsert_target, ResolvedUpsertTarget, }; @@ -45,6 +47,7 @@ use super::emitter::Resolver; use super::expr::{translate_expr, translate_expr_no_constant_opt, NoConstantOptReason}; use super::plan::QueryDestination; use super::select::translate_select; +use super::trigger_exec::{has_relevant_triggers_type_only, TriggerContext}; /// Validate anything with this insert statement that should throw an early parse error fn validate(table_name: &str, resolver: &Resolver, table: &Table) -> Result<()> { @@ -316,6 +319,40 @@ pub fn translate_insert( init_autoincrement(&mut program, &mut ctx, resolver)?; } + // Fire BEFORE INSERT triggers + + let relevant_before_triggers = get_relevant_triggers_type_and_time( + resolver.schema, + TriggerEvent::Insert, + TriggerTime::Before, + None, + &btree_table, + ); + let has_relevant_before_triggers = relevant_before_triggers.clone().count() > 0; + if has_relevant_before_triggers { + // Build NEW registers: for rowid alias columns, use the rowid register; otherwise use column register + let new_registers: Vec = insertion + .col_mappings + .iter() + .map(|col_mapping| { + if col_mapping.column.is_rowid_alias() { + insertion.key_register() + } else { + col_mapping.register + } + }) + .chain(std::iter::once(insertion.key_register())) + .collect(); + let trigger_ctx = TriggerContext::new( + btree_table.clone(), + Some(new_registers), + None, // No OLD for INSERT + ); + for trigger in relevant_before_triggers { + fire_trigger(&mut program, resolver, trigger, &trigger_ctx, connection)?; + } + } + if has_user_provided_rowid { let must_be_int_label = program.allocate_label(); @@ -427,6 +464,42 @@ pub fn translate_insert( table_name: table_name.to_string(), }); + // Fire AFTER INSERT triggers + let relevant_after_triggers = get_relevant_triggers_type_and_time( + resolver.schema, + TriggerEvent::Insert, + TriggerTime::After, + None, + &btree_table, + ); + let has_relevant_after_triggers = relevant_after_triggers.clone().count() > 0; + if has_relevant_after_triggers { + // Build NEW registers: for rowid alias columns, use the rowid register; otherwise use column register + let new_registers_after: Vec = insertion + .col_mappings + .iter() + .map(|col_mapping| { + if col_mapping.column.is_rowid_alias() { + insertion.key_register() + } else { + col_mapping.register + } + }) + .chain(std::iter::once(insertion.key_register())) + .collect(); + let trigger_ctx_after = + TriggerContext::new(btree_table.clone(), Some(new_registers_after), None); + for trigger in relevant_after_triggers { + fire_trigger( + &mut program, + resolver, + trigger, + &trigger_ctx_after, + connection, + )?; + } + } + if has_fks { // After the row is actually present, repair deferred counters for children referencing this NEW parent key. // For REPLACE: delete increments counters above; the insert path should try to repay @@ -1069,6 +1142,7 @@ fn bind_insert( } match on_conflict { ResolveType::Ignore => { + program.set_resolve_type(ResolveType::Ignore); upsert.replace(Box::new(ast::Upsert { do_clause: UpsertDo::Nothing, index: None, @@ -1163,6 +1237,14 @@ fn init_source_emission<'a>( ); } } + // Check if INSERT triggers exist - if so, we need to use ephemeral table for VALUES with more than one row + let has_insert_triggers = has_relevant_triggers_type_only( + resolver.schema, + TriggerEvent::Insert, + None, + ctx.table.as_ref(), + ); + let (num_values, cursor_id) = match body { InsertBody::Select(select, _) => { // Simple common case of INSERT INTO VALUES (...) without compounds. @@ -1221,7 +1303,7 @@ fn init_source_emission<'a>( ** of the tables being read by the SELECT statement. Also use a ** temp table in the case of row triggers. */ - if program.is_table_open(table) { + if program.is_table_open(table) || has_insert_triggers { let temp_cursor_id = program.alloc_cursor_id(CursorType::BTreeTable(ctx.table.clone())); ctx.temp_table_ctx = Some(TempTableCtx { diff --git a/core/translate/mod.rs b/core/translate/mod.rs index 76e0cdddb..11beca000 100644 --- a/core/translate/mod.rs +++ b/core/translate/mod.rs @@ -35,6 +35,8 @@ pub(crate) mod schema; pub(crate) mod select; pub(crate) mod subquery; pub(crate) mod transaction; +pub(crate) mod trigger; +pub(crate) mod trigger_exec; pub(crate) mod update; pub(crate) mod upsert; mod values; @@ -169,7 +171,40 @@ pub fn translate_inner( program, connection, )?, - ast::Stmt::CreateTrigger { .. } => bail_parse_error!("CREATE TRIGGER not supported yet"), + ast::Stmt::CreateTrigger { + temporary, + if_not_exists, + trigger_name, + time, + event, + tbl_name, + for_each_row, + when_clause, + commands, + } => { + // Reconstruct SQL for storage + let sql = trigger::create_trigger_to_sql( + temporary, + if_not_exists, + &trigger_name, + time, + &event, + &tbl_name, + for_each_row, + when_clause.as_deref(), + &commands, + ); + trigger::translate_create_trigger( + trigger_name, + resolver, + temporary, + if_not_exists, + time, + tbl_name, + program, + sql, + )? + } ast::Stmt::CreateView { view_name, select, @@ -232,7 +267,15 @@ pub fn translate_inner( if_exists, tbl_name, } => translate_drop_table(tbl_name, resolver, if_exists, program, connection)?, - ast::Stmt::DropTrigger { .. } => bail_parse_error!("DROP TRIGGER not supported yet"), + ast::Stmt::DropTrigger { + if_exists, + trigger_name, + } => trigger::translate_drop_trigger( + resolver.schema, + trigger_name.name.as_str(), + if_exists, + program, + )?, ast::Stmt::DropView { if_exists, view_name, diff --git a/core/translate/optimizer/mod.rs b/core/translate/optimizer/mod.rs index cc706b898..2d2751c7f 100644 --- a/core/translate/optimizer/mod.rs +++ b/core/translate/optimizer/mod.rs @@ -1,7 +1,7 @@ use std::{ cell::RefCell, cmp::Ordering, - collections::{HashMap, VecDeque}, + collections::{HashMap, HashSet, VecDeque}, sync::Arc, }; @@ -13,7 +13,7 @@ use join::{compute_best_join_order, BestJoinOrderResult}; use lift_common_subexpressions::lift_common_subexpressions_from_binary_or_terms; use order::{compute_order_target, plan_satisfies_order_target, EliminatesSortBy}; use turso_ext::{ConstraintInfo, ConstraintUsage}; -use turso_parser::ast::{self, Expr, SortOrder}; +use turso_parser::ast::{self, Expr, SortOrder, TriggerEvent}; use crate::{ schema::{BTreeTable, Index, IndexColumn, Schema, Table, ROWID_SENTINEL}, @@ -27,6 +27,7 @@ use crate::{ ColumnUsedMask, IndexMethodQuery, NonFromClauseSubquery, OuterQueryReference, QueryDestination, ResultSetColumn, Scan, SeekKeyComponent, }, + trigger_exec::has_relevant_triggers_type_only, }, types::SeekOp, util::{ @@ -118,6 +119,10 @@ fn optimize_delete_plan(plan: &mut DeletePlan, schema: &Schema) -> Result<()> { return Ok(()); } + if let Some(rowset_plan) = plan.rowset_plan.as_mut() { + optimize_select_plan(rowset_plan, schema)?; + } + let _ = optimize_table_access( schema, &mut plan.result_columns, @@ -161,14 +166,31 @@ fn optimize_update_plan( let table_ref = &mut plan.table_references.joined_tables_mut()[0]; - // An ephemeral table is required if the UPDATE modifies any column that is present in the key of the - // btree used to iterate over the table. - // For regular table scans or seeks, this is just the rowid or the rowid alias column (INTEGER PRIMARY KEY) - // For index scans and seeks, this is any column in the index used. + // An ephemeral table is required if: + // 1. The UPDATE modifies any column that is present in the key of the btree used to iterate over the table. + // For regular table scans or seeks, this is just the rowid or the rowid alias column (INTEGER PRIMARY KEY) + // For index scans and seeks, this is any column in the index used. + // 2. There are UPDATE triggers on the table. This is done in SQLite for all UPDATE triggers on + // the affected table even if the trigger would not have an impact on the target table -- + // presumably due to lack of static analysis capabilities to determine whether it's safe + // to skip the rowset materialization. let requires_ephemeral_table = 'requires: { - let Some(btree_table) = table_ref.table.btree() else { + let Some(btree_table_arc) = table_ref.table.btree() else { break 'requires false; }; + let btree_table = btree_table_arc.as_ref(); + + // Check if there are UPDATE triggers + let updated_cols: HashSet = plan.set_clauses.iter().map(|(i, _)| *i).collect(); + if has_relevant_triggers_type_only( + schema, + TriggerEvent::Update, + Some(&updated_cols), + btree_table, + ) { + break 'requires true; + } + let Some(index) = table_ref.op.index() else { let rowid_alias_used = plan.set_clauses.iter().fold(false, |accum, (idx, _)| { accum || (*idx != ROWID_SENTINEL && btree_table.columns[*idx].is_rowid_alias()) @@ -198,10 +220,11 @@ fn optimize_update_plan( add_ephemeral_table_to_update_plan(program, plan) } -/// An ephemeral table is required if the UPDATE modifies any column that is present in the key of the -/// btree used to iterate over the table. -/// For regular table scans or seeks, the key is the rowid or the rowid alias column (INTEGER PRIMARY KEY). -/// For index scans and seeks, the key is any column in the index used. +/// An ephemeral table is required if: +/// 1. The UPDATE modifies any column that is present in the key of the btree used to iterate over the table. +/// For regular table scans or seeks, the key is the rowid or the rowid alias column (INTEGER PRIMARY KEY). +/// For index scans and seeks, the key is any column in the index used. +/// 2. There are UPDATE triggers on the table (SQLite always uses ephemeral tables when triggers exist). /// /// The ephemeral table will accumulate all the rowids of the rows that are affected by the UPDATE, /// and then the temp table will be iterated over and the actual row updates performed. diff --git a/core/translate/plan.rs b/core/translate/plan.rs index 61638923f..b27f3bba3 100644 --- a/core/translate/plan.rs +++ b/core/translate/plan.rs @@ -259,6 +259,12 @@ pub enum QueryDestination { /// The number of registers that hold the result of the subquery. num_regs: usize, }, + /// The results of the query are stored in a RowSet (for DELETE operations with triggers). + /// Rowids are added to the RowSet using RowSetAdd, then read back using RowSetRead. + RowSet { + /// The register that holds the RowSet object. + rowset_reg: usize, + }, /// Decision made at some point after query plan construction. Unset, } @@ -474,6 +480,11 @@ pub struct DeletePlan { pub contains_constant_false_condition: bool, /// Indexes that must be updated by the delete operation. pub indexes: Vec>, + /// If there are DELETE triggers, materialize rowids into a RowSet first. + /// This ensures triggers see a stable set of rows to delete. + pub rowset_plan: Option, + /// Register ID for the RowSet (if rowset_plan is Some) + pub rowset_reg: Option, } #[derive(Debug, Clone)] @@ -1004,10 +1015,10 @@ impl JoinedTable { } else { CursorType::BTreeTable(btree.clone()) }; - Some( - program - .alloc_cursor_id_keyed(CursorKey::table(self.internal_id), cursor_type), - ) + Some(program.alloc_cursor_id_keyed_if_not_exists( + CursorKey::table(self.internal_id), + cursor_type, + )) }; let index_cursor_id = index diff --git a/core/translate/result_row.rs b/core/translate/result_row.rs index 244e51d17..60ffd808a 100644 --- a/core/translate/result_row.rs +++ b/core/translate/result_row.rs @@ -160,6 +160,18 @@ pub fn emit_result_row_and_limit( extra_amount: num_regs - 1, }); } + QueryDestination::RowSet { rowset_reg } => { + // For RowSet, we add the rowid (which should be the only result column) to the RowSet + assert_eq!( + plan.result_columns.len(), + 1, + "RowSet should only have one result column (rowid)" + ); + program.emit_insn(Insn::RowSetAdd { + rowset_reg: *rowset_reg, + value_reg: result_columns_start_reg, + }); + } QueryDestination::Unset => unreachable!("Unset query destination should not be reached"), } diff --git a/core/translate/schema.rs b/core/translate/schema.rs index 2db9d5106..38a5c2c61 100644 --- a/core/translate/schema.rs +++ b/core/translate/schema.rs @@ -307,6 +307,7 @@ pub enum SchemaEntryType { Table, Index, View, + Trigger, } impl SchemaEntryType { @@ -315,6 +316,7 @@ impl SchemaEntryType { SchemaEntryType::Table => "table", SchemaEntryType::Index => "index", SchemaEntryType::View => "view", + SchemaEntryType::Trigger => "trigger", } } } @@ -677,7 +679,7 @@ pub fn translate_drop_table( let table_reg = program.emit_string8_new_reg(normalize_ident(tbl_name.name.as_str()).to_string()); // r3 program.mark_last_insn_constant(); - let table_type = program.emit_string8_new_reg("trigger".to_string()); // r4 + let _table_type = program.emit_string8_new_reg("trigger".to_string()); // r4 program.mark_last_insn_constant(); let row_id_reg = program.alloc_register(); // r5 @@ -692,7 +694,7 @@ pub fn translate_drop_table( db: 0, }); - // 1. Remove all entries from the schema table related to the table we are dropping, except for triggers + // 1. Remove all entries from the schema table related to the table we are dropping (including triggers) // loop to beginning of schema table let end_metadata_label = program.allocate_label(); let metadata_loop = program.allocate_label(); @@ -716,18 +718,6 @@ pub fn translate_drop_table( flags: CmpInsFlags::default(), collation: program.curr_collation(), }); - program.emit_column_or_rowid( - sqlite_schema_cursor_id_0, - 0, - table_name_and_root_page_register, - ); - program.emit_insn(Insn::Eq { - lhs: table_name_and_root_page_register, - rhs: table_type, - target_pc: next_label, - flags: CmpInsFlags::default(), - collation: program.curr_collation(), - }); program.emit_insn(Insn::RowId { cursor_id: sqlite_schema_cursor_id_0, dest: row_id_reg, diff --git a/core/translate/trigger.rs b/core/translate/trigger.rs new file mode 100644 index 000000000..0d8d64b2d --- /dev/null +++ b/core/translate/trigger.rs @@ -0,0 +1,293 @@ +use crate::translate::emitter::Resolver; +use crate::translate::schema::{emit_schema_entry, SchemaEntryType, SQLITE_TABLEID}; +use crate::translate::ProgramBuilder; +use crate::translate::ProgramBuilderOpts; +use crate::util::normalize_ident; +use crate::vdbe::builder::CursorType; +use crate::vdbe::insn::{Cookie, Insn}; +use crate::{bail_parse_error, Result}; +use turso_parser::ast::{self, QualifiedName}; + +/// Reconstruct SQL string from CREATE TRIGGER AST +#[allow(clippy::too_many_arguments)] +pub(crate) fn create_trigger_to_sql( + temporary: bool, + if_not_exists: bool, + trigger_name: &QualifiedName, + time: Option, + event: &ast::TriggerEvent, + tbl_name: &QualifiedName, + for_each_row: bool, + when_clause: Option<&ast::Expr>, + commands: &[ast::TriggerCmd], +) -> String { + let mut sql = String::new(); + sql.push_str("CREATE"); + if temporary { + sql.push_str(" TEMP"); + } + sql.push_str(" TRIGGER"); + if if_not_exists { + sql.push_str(" IF NOT EXISTS"); + } + sql.push(' '); + sql.push_str(trigger_name.name.as_str()); + sql.push(' '); + + if let Some(t) = time { + match t { + ast::TriggerTime::Before => sql.push_str("BEFORE "), + ast::TriggerTime::After => sql.push_str("AFTER "), + ast::TriggerTime::InsteadOf => sql.push_str("INSTEAD OF "), + } + } + + match event { + ast::TriggerEvent::Delete => sql.push_str("DELETE"), + ast::TriggerEvent::Insert => sql.push_str("INSERT"), + ast::TriggerEvent::Update => sql.push_str("UPDATE"), + ast::TriggerEvent::UpdateOf(cols) => { + sql.push_str("UPDATE OF "); + for (i, col) in cols.iter().enumerate() { + if i > 0 { + sql.push_str(", "); + } + sql.push_str(col.as_str()); + } + } + } + + sql.push_str(" ON "); + sql.push_str(tbl_name.name.as_str()); + if for_each_row { + sql.push_str(" FOR EACH ROW"); + } + + if let Some(when) = when_clause { + sql.push_str(" WHEN "); + sql.push_str(&when.to_string()); + } + + sql.push_str(" BEGIN"); + for cmd in commands { + sql.push(' '); + sql.push_str(&cmd.to_string()); + sql.push(';'); + } + sql.push_str(" END"); + + sql +} + +/// Translate CREATE TRIGGER statement +#[allow(clippy::too_many_arguments)] +pub fn translate_create_trigger( + trigger_name: QualifiedName, + resolver: &Resolver, + temporary: bool, + if_not_exists: bool, + time: Option, + tbl_name: QualifiedName, + mut program: ProgramBuilder, + sql: String, +) -> Result { + program.begin_write_operation(); + let normalized_trigger_name = normalize_ident(trigger_name.name.as_str()); + let normalized_table_name = normalize_ident(tbl_name.name.as_str()); + + // Check if trigger already exists + if resolver + .schema + .get_trigger_for_table(&normalized_table_name, &normalized_trigger_name) + .is_some() + { + if if_not_exists { + return Ok(program); + } + bail_parse_error!("Trigger {} already exists", normalized_trigger_name); + } + + // Verify the table exists + if resolver.schema.get_table(&normalized_table_name).is_none() { + bail_parse_error!("no such table: {}", normalized_table_name); + } + + if time + .as_ref() + .is_some_and(|t| *t == ast::TriggerTime::InsteadOf) + { + bail_parse_error!("INSTEAD OF triggers are not supported yet"); + } + + if temporary { + bail_parse_error!("TEMPORARY triggers are not supported yet"); + } + + let opts = ProgramBuilderOpts { + num_cursors: 1, + approx_num_insns: 30, + approx_num_labels: 1, + }; + program.extend(&opts); + + // Open cursor to sqlite_schema table + let table = resolver.schema.get_btree_table(SQLITE_TABLEID).unwrap(); + let sqlite_schema_cursor_id = program.alloc_cursor_id(CursorType::BTreeTable(table.clone())); + program.emit_insn(Insn::OpenWrite { + cursor_id: sqlite_schema_cursor_id, + root_page: 1i64.into(), + db: 0, + }); + + // Add the trigger entry to sqlite_schema + emit_schema_entry( + &mut program, + resolver, + sqlite_schema_cursor_id, + None, // cdc_table_cursor_id, no cdc for triggers + SchemaEntryType::Trigger, + &normalized_trigger_name, + &normalized_table_name, + 0, // triggers don't have a root page + Some(sql.clone()), + )?; + + // Update schema version + program.emit_insn(Insn::SetCookie { + db: 0, + cookie: Cookie::SchemaVersion, + value: (resolver.schema.schema_version + 1) as i32, + p5: 0, + }); + + // Parse schema to load the new trigger + program.emit_insn(Insn::ParseSchema { + db: sqlite_schema_cursor_id, + where_clause: Some(format!("name = '{normalized_trigger_name}'")), + }); + + Ok(program) +} + +/// Translate DROP TRIGGER statement +pub fn translate_drop_trigger( + schema: &crate::schema::Schema, + trigger_name: &str, + if_exists: bool, + mut program: ProgramBuilder, +) -> Result { + program.begin_write_operation(); + let normalized_trigger_name = normalize_ident(trigger_name); + + // Check if trigger exists + if schema.get_trigger(&normalized_trigger_name).is_none() { + if if_exists { + return Ok(program); + } + bail_parse_error!("no such trigger: {}", normalized_trigger_name); + } + + let opts = ProgramBuilderOpts { + num_cursors: 1, + approx_num_insns: 30, + approx_num_labels: 1, + }; + program.extend(&opts); + + // Open cursor to sqlite_schema table + let table = schema.get_btree_table(SQLITE_TABLEID).unwrap(); + let sqlite_schema_cursor_id = program.alloc_cursor_id(CursorType::BTreeTable(table.clone())); + program.emit_insn(Insn::OpenWrite { + cursor_id: sqlite_schema_cursor_id, + root_page: 1i64.into(), + db: 0, + }); + + let search_loop_label = program.allocate_label(); + let skip_non_trigger_label = program.allocate_label(); + let done_label = program.allocate_label(); + let rewind_done_label = program.allocate_label(); + + // Find and delete the trigger from sqlite_schema + program.emit_insn(Insn::Rewind { + cursor_id: sqlite_schema_cursor_id, + pc_if_empty: rewind_done_label, + }); + + program.preassign_label_to_next_insn(search_loop_label); + + // Check if this is the trigger we're looking for + // sqlite_schema columns: type, name, tbl_name, rootpage, sql + // Column 0: type (should be "trigger") + // Column 1: name (should match trigger_name) + let type_reg = program.alloc_register(); + let name_reg = program.alloc_register(); + program.emit_insn(Insn::Column { + cursor_id: sqlite_schema_cursor_id, + column: 0, + dest: type_reg, + default: None, + }); + program.emit_insn(Insn::Column { + cursor_id: sqlite_schema_cursor_id, + column: 1, + dest: name_reg, + default: None, + }); + + // Check if type == "trigger" + let type_str_reg = program.emit_string8_new_reg("trigger".to_string()); + program.emit_insn(Insn::Ne { + lhs: type_reg, + rhs: type_str_reg, + target_pc: skip_non_trigger_label, + flags: crate::vdbe::insn::CmpInsFlags::default(), + collation: program.curr_collation(), + }); + + // Check if name matches + let trigger_name_str_reg = program.emit_string8_new_reg(normalized_trigger_name.clone()); + program.emit_insn(Insn::Ne { + lhs: name_reg, + rhs: trigger_name_str_reg, + target_pc: skip_non_trigger_label, + flags: crate::vdbe::insn::CmpInsFlags::default(), + collation: program.curr_collation(), + }); + + // Found it! Delete the row + program.emit_insn(Insn::Delete { + cursor_id: sqlite_schema_cursor_id, + table_name: SQLITE_TABLEID.to_string(), + is_part_of_update: false, + }); + program.emit_insn(Insn::Goto { + target_pc: done_label, + }); + + program.preassign_label_to_next_insn(skip_non_trigger_label); + // Continue to next row + program.emit_insn(Insn::Next { + cursor_id: sqlite_schema_cursor_id, + pc_if_next: search_loop_label, + }); + + program.preassign_label_to_next_insn(done_label); + + program.preassign_label_to_next_insn(rewind_done_label); + + // Update schema version + program.emit_insn(Insn::SetCookie { + db: 0, + cookie: Cookie::SchemaVersion, + value: (schema.schema_version + 1) as i32, + p5: 0, + }); + + program.emit_insn(Insn::DropTrigger { + db: 0, + trigger_name: normalized_trigger_name.clone(), + }); + + Ok(program) +} diff --git a/core/translate/trigger_exec.rs b/core/translate/trigger_exec.rs new file mode 100644 index 000000000..31512e37d --- /dev/null +++ b/core/translate/trigger_exec.rs @@ -0,0 +1,812 @@ +use crate::schema::{BTreeTable, Trigger}; +use crate::translate::emitter::Resolver; +use crate::translate::expr::translate_expr; +use crate::translate::{translate_inner, ProgramBuilder, ProgramBuilderOpts}; +use crate::util::normalize_ident; +use crate::vdbe::insn::Insn; +use crate::{bail_parse_error, QueryMode, Result, Statement}; +use parking_lot::RwLock; +use std::collections::HashSet; +use std::num::NonZero; +use std::sync::Arc; +use turso_parser::ast::{self, Expr, TriggerEvent, TriggerTime}; + +/// Context for trigger execution +#[derive(Debug)] +pub struct TriggerContext { + /// Table the trigger is attached to + pub table: Arc, + /// NEW row registers (for INSERT/UPDATE). The last element is always the rowid. + pub new_registers: Option>, + /// OLD row registers (for UPDATE/DELETE). The last element is always the rowid. + pub old_registers: Option>, +} + +impl TriggerContext { + pub fn new( + table: Arc, + new_registers: Option>, + old_registers: Option>, + ) -> Self { + Self { + table, + new_registers, + old_registers, + } + } +} + +#[derive(Debug)] +struct ParamMap(Vec>); + +impl ParamMap { + pub fn len(&self) -> usize { + self.0.len() + } +} + +/// Context for compiling trigger subprograms - maps NEW/OLD to parameter indices +#[derive(Debug)] +struct TriggerSubprogramContext { + /// Map from column index to parameter index for NEW values (1-indexed) + new_param_map: Option, + /// Map from column index to parameter index for OLD values (1-indexed) + old_param_map: Option, + table: Arc, +} + +impl TriggerSubprogramContext { + pub fn get_new_param(&self, idx: usize) -> Option> { + self.new_param_map + .as_ref() + .and_then(|map| map.0.get(idx).copied()) + } + + pub fn get_new_rowid_param(&self) -> Option> { + self.new_param_map + .as_ref() + .and_then(|map| map.0.last().copied()) + } + + pub fn get_old_param(&self, idx: usize) -> Option> { + self.old_param_map + .as_ref() + .and_then(|map| map.0.get(idx).copied()) + } + + pub fn get_old_rowid_param(&self) -> Option> { + self.old_param_map + .as_ref() + .and_then(|map| map.0.last().copied()) + } +} + +/// Rewrite NEW and OLD references in trigger expressions to use Variable instructions (parameters) +fn rewrite_trigger_expr_for_subprogram( + expr: &mut ast::Expr, + table: &BTreeTable, + ctx: &TriggerSubprogramContext, +) -> Result<()> { + use crate::translate::expr::walk_expr_mut; + use crate::translate::expr::WalkControl; + + walk_expr_mut(expr, &mut |e: &mut ast::Expr| -> Result { + match e { + Expr::Qualified(ns, col) | Expr::DoublyQualified(_, ns, col) => { + let ns = normalize_ident(ns.as_str()); + let col = normalize_ident(col.as_str()); + + // Handle NEW.column references + if ns.eq_ignore_ascii_case("new") { + if let Some(new_params) = &ctx.new_param_map { + // Check if this is a rowid alias column first + if let Some((idx, col_def)) = table.get_column(&col) { + if col_def.is_rowid_alias() { + // Rowid alias columns map to the rowid parameter, not the column register + *e = Expr::Variable(format!( + "{}", + ctx.get_new_rowid_param() + .expect("NEW parameters must be provided") + )); + return Ok(WalkControl::Continue); + } + if idx < new_params.len() { + *e = Expr::Variable(format!( + "{}", + ctx.get_new_param(idx) + .expect("NEW parameters must be provided") + .get() + )); + return Ok(WalkControl::Continue); + } else { + crate::bail_parse_error!("no such column in NEW: {}", col); + } + } + // Handle NEW.rowid + if crate::translate::planner::ROWID_STRS + .iter() + .any(|s| s.eq_ignore_ascii_case(&col)) + { + *e = Expr::Variable(format!( + "{}", + ctx.get_new_rowid_param() + .expect("NEW parameters must be provided") + )); + return Ok(WalkControl::Continue); + } + bail_parse_error!("no such column in NEW: {}", col); + } else { + bail_parse_error!( + "NEW references are only valid in INSERT and UPDATE triggers" + ); + } + } + + // Handle OLD.column references + if ns.eq_ignore_ascii_case("old") { + if let Some(old_params) = &ctx.old_param_map { + if let Some((idx, _)) = table.get_column(&col) { + if idx < old_params.len() { + *e = Expr::Variable(format!( + "{}", + ctx.get_old_param(idx) + .expect("OLD parameters must be provided") + .get() + )); + return Ok(WalkControl::Continue); + } else { + crate::bail_parse_error!("no such column in OLD: {}", col); + } + } + // Handle OLD.rowid + if crate::translate::planner::ROWID_STRS + .iter() + .any(|s| s.eq_ignore_ascii_case(&col)) + { + *e = Expr::Variable(format!( + "{}", + ctx.get_old_rowid_param() + .expect("OLD parameters must be provided") + )); + return Ok(WalkControl::Continue); + } + bail_parse_error!("no such column in OLD: {}", col); + } else { + bail_parse_error!( + "OLD references are only valid in UPDATE and DELETE triggers" + ); + } + } + + // Handle unqualified column references - they refer to NEW if available, else OLD + if let Some((idx, _)) = table.get_column(&col) { + if let Some(new_params) = &ctx.new_param_map { + if idx < new_params.len() { + *e = Expr::Variable(format!( + "{}", + ctx.get_new_param(idx) + .expect("NEW parameters must be provided") + .get() + )); + return Ok(WalkControl::Continue); + } + } + if let Some(old_params) = &ctx.old_param_map { + if idx < old_params.len() { + *e = Expr::Variable(format!( + "{}", + ctx.get_old_param(idx) + .expect("OLD parameters must be provided") + .get() + )); + return Ok(WalkControl::Continue); + } + } + } + + Ok(WalkControl::Continue) + } + _ => Ok(WalkControl::Continue), + } + })?; + Ok(()) +} + +/// Convert TriggerCmd to Stmt, rewriting NEW/OLD to Variable expressions (for subprogram compilation) +fn trigger_cmd_to_stmt_for_subprogram( + cmd: &ast::TriggerCmd, + subprogram_ctx: &TriggerSubprogramContext, +) -> Result { + use turso_parser::ast::{InsertBody, QualifiedName}; + + match cmd { + ast::TriggerCmd::Insert { + or_conflict, + tbl_name, + col_names, + select, + upsert, + returning, + } => { + // Rewrite NEW/OLD references in the SELECT + let mut select_clone = select.clone(); + rewrite_expressions_in_select_for_subprogram(&mut select_clone, subprogram_ctx)?; + + let body = InsertBody::Select(select_clone, upsert.clone()); + Ok(ast::Stmt::Insert { + with: None, + or_conflict: *or_conflict, + tbl_name: QualifiedName { + db_name: None, + name: tbl_name.clone(), + alias: None, + }, + columns: col_names.clone(), + body, + returning: returning.clone(), + }) + } + ast::TriggerCmd::Update { + or_conflict, + tbl_name, + sets, + from, + where_clause, + } => { + // Rewrite NEW/OLD references in SET clauses and WHERE clause + let mut sets_clone = sets.clone(); + for set in &mut sets_clone { + rewrite_trigger_expr_for_subprogram( + &mut set.expr, + &subprogram_ctx.table, + subprogram_ctx, + )?; + } + + let mut where_clause_clone = where_clause.clone(); + if let Some(ref mut where_expr) = where_clause_clone { + rewrite_trigger_expr_for_subprogram( + where_expr, + &subprogram_ctx.table, + subprogram_ctx, + )?; + } + + Ok(ast::Stmt::Update(ast::Update { + with: None, + or_conflict: *or_conflict, + tbl_name: QualifiedName { + db_name: None, + name: tbl_name.clone(), + alias: None, + }, + indexed: None, + sets: sets_clone, + from: from.clone(), + where_clause: where_clause_clone, + returning: vec![], + order_by: vec![], + limit: None, + })) + } + ast::TriggerCmd::Delete { + tbl_name, + where_clause, + } => { + // Rewrite NEW/OLD references in WHERE clause + let mut where_clause_clone = where_clause.clone(); + if let Some(ref mut where_expr) = where_clause_clone { + rewrite_trigger_expr_for_subprogram( + where_expr, + &subprogram_ctx.table, + subprogram_ctx, + )?; + } + + Ok(ast::Stmt::Delete { + tbl_name: QualifiedName { + db_name: None, + name: tbl_name.clone(), + alias: None, + }, + where_clause: where_clause_clone, + limit: None, + returning: vec![], + indexed: None, + order_by: vec![], + with: None, + }) + } + ast::TriggerCmd::Select(select) => { + // Rewrite NEW/OLD references in the SELECT + let mut select_clone = select.clone(); + rewrite_expressions_in_select_for_subprogram(&mut select_clone, subprogram_ctx)?; + Ok(ast::Stmt::Select(select_clone)) + } + } +} + +/// Rewrite NEW/OLD references in all expressions within a SELECT statement for subprogram +fn rewrite_expressions_in_select_for_subprogram( + select: &mut ast::Select, + ctx: &TriggerSubprogramContext, +) -> Result<()> { + use crate::translate::expr::walk_expr_mut; + + // Rewrite expressions in the SELECT body + match &mut select.body.select { + ast::OneSelect::Select { + columns, + where_clause, + group_by, + .. + } => { + // Rewrite in columns + for col in columns { + if let ast::ResultColumn::Expr(ref mut expr, _) = col { + walk_expr_mut(expr, &mut |e: &mut ast::Expr| { + rewrite_trigger_expr_single_for_subprogram(e, ctx)?; + Ok(crate::translate::expr::WalkControl::Continue) + })?; + } + } + + // Rewrite in WHERE clause + if let Some(ref mut where_expr) = where_clause { + walk_expr_mut(where_expr, &mut |e: &mut ast::Expr| { + rewrite_trigger_expr_single_for_subprogram(e, ctx)?; + Ok(crate::translate::expr::WalkControl::Continue) + })?; + } + + // Rewrite in GROUP BY expressions and HAVING clause + if let Some(ref mut group_by) = group_by { + for expr in &mut group_by.exprs { + walk_expr_mut(expr, &mut |e: &mut ast::Expr| { + rewrite_trigger_expr_single_for_subprogram(e, ctx)?; + Ok(crate::translate::expr::WalkControl::Continue) + })?; + } + + // Rewrite in HAVING clause + if let Some(ref mut having_expr) = group_by.having { + walk_expr_mut(having_expr, &mut |e: &mut ast::Expr| { + rewrite_trigger_expr_single_for_subprogram(e, ctx)?; + Ok(crate::translate::expr::WalkControl::Continue) + })?; + } + } + } + ast::OneSelect::Values(values) => { + for row in values { + for expr in row { + walk_expr_mut(expr, &mut |e: &mut ast::Expr| { + rewrite_trigger_expr_single_for_subprogram(e, ctx)?; + Ok(crate::translate::expr::WalkControl::Continue) + })?; + } + } + } + } + + Ok(()) +} + +/// Rewrite a single NEW/OLD reference for subprogram (called from walk_expr_mut) +fn rewrite_trigger_expr_single_for_subprogram( + e: &mut ast::Expr, + ctx: &TriggerSubprogramContext, +) -> Result<()> { + match e { + Expr::Qualified(ns, col) | Expr::DoublyQualified(_, ns, col) => { + let ns = normalize_ident(ns.as_str()); + let col = normalize_ident(col.as_str()); + + // Handle NEW.column references + if ns.eq_ignore_ascii_case("new") { + if let Some(new_params) = &ctx.new_param_map { + if let Some((idx, col_def)) = ctx.table.get_column(&col) { + if col_def.is_rowid_alias() { + *e = Expr::Variable(format!( + "{}", + ctx.get_new_rowid_param() + .expect("NEW parameters must be provided") + )); + return Ok(()); + } + if idx < new_params.len() { + *e = Expr::Variable(format!( + "{}", + ctx.get_new_param(idx) + .expect("NEW parameters must be provided") + .get() + )); + return Ok(()); + } else { + crate::bail_parse_error!("no such column in NEW: {}", col); + } + } + // Handle NEW.rowid + if crate::translate::planner::ROWID_STRS + .iter() + .any(|s| s.eq_ignore_ascii_case(&col)) + { + *e = Expr::Variable(format!( + "{}", + ctx.get_new_rowid_param() + .expect("NEW parameters must be provided") + )); + return Ok(()); + } + bail_parse_error!("no such column in NEW: {}", col); + } else { + bail_parse_error!( + "NEW references are only valid in INSERT and UPDATE triggers" + ); + } + } + + // Handle OLD.column references + if ns.eq_ignore_ascii_case("old") { + if let Some(old_params) = &ctx.old_param_map { + if let Some((idx, col_def)) = ctx.table.get_column(&col) { + if col_def.is_rowid_alias() { + *e = Expr::Variable(format!( + "{}", + ctx.get_old_rowid_param() + .expect("OLD parameters must be provided") + )); + return Ok(()); + } + if idx < old_params.len() { + *e = Expr::Variable(format!( + "{}", + ctx.get_old_param(idx) + .expect("OLD parameters must be provided") + .get() + )); + return Ok(()); + } else { + crate::bail_parse_error!("no such column in OLD: {}", col) + } + } + // Handle OLD.rowid + if crate::translate::planner::ROWID_STRS + .iter() + .any(|s| s.eq_ignore_ascii_case(&col)) + { + *e = Expr::Variable(format!( + "{}", + ctx.get_old_rowid_param() + .expect("OLD parameters must be provided") + )); + return Ok(()); + } + bail_parse_error!("no such column in OLD: {}", col); + } else { + bail_parse_error!( + "OLD references are only valid in UPDATE and DELETE triggers" + ); + } + } + + crate::bail_parse_error!("no such column: {ns}.{col}"); + } + _ => {} + } + Ok(()) +} + +/// Execute trigger commands by compiling them as a subprogram and emitting Program instruction +/// Returns true if there are triggers that will fire. +fn execute_trigger_commands( + program: &mut ProgramBuilder, + resolver: &mut Resolver, + trigger: &Arc, + ctx: &TriggerContext, + connection: &Arc, +) -> Result { + if connection.trigger_is_compiling(trigger) { + // Do not recursively compile the same trigger + return Ok(false); + } + connection.start_trigger_compilation(trigger.clone()); + // Build parameter mapping: parameters are 1-indexed and sequential + // Order: [NEW values..., OLD values..., rowid] + // So if we have 2 NEW columns, 2 OLD columns: NEW params are 1,2; OLD params are 3,4; rowid is 5 + let num_new = ctx.new_registers.as_ref().map(|r| r.len()).unwrap_or(0); + + let new_param_map = ctx + .new_registers + .as_ref() + .map(|new_regs| { + (1..=new_regs.len()) + .map(|i| NonZero::new(i).unwrap()) + .collect() + }) + .map(ParamMap); + + let old_param_map = ctx + .old_registers + .as_ref() + .map(|old_regs| { + (1..=old_regs.len()) + .map(|i| NonZero::new(i + num_new).unwrap()) + .collect() + }) + .map(ParamMap); + + let subprogram_ctx = TriggerSubprogramContext { + new_param_map, + old_param_map, + table: ctx.table.clone(), + }; + let mut subprogram_builder = ProgramBuilder::new_for_trigger( + QueryMode::Normal, + program.capture_data_changes_mode().clone(), + ProgramBuilderOpts { + num_cursors: 1, + approx_num_insns: 32, + approx_num_labels: 2, + }, + trigger.clone(), + ); + for command in trigger.commands.iter() { + let stmt = trigger_cmd_to_stmt_for_subprogram(command, &subprogram_ctx)?; + subprogram_builder.prologue(); + subprogram_builder = translate_inner( + stmt, + resolver, + subprogram_builder, + connection, + "trigger subprogram", + )?; + } + subprogram_builder.epilogue(resolver.schema); + let built_subprogram = subprogram_builder.build(connection.clone(), true, "trigger subprogram"); + + let mut params = Vec::with_capacity( + ctx.new_registers.as_ref().map(|r| r.len()).unwrap_or(0) + + ctx.old_registers.as_ref().map(|r| r.len()).unwrap_or(0), + ); + if let Some(new_regs) = &ctx.new_registers { + params.extend( + new_regs + .iter() + .copied() + .map(|reg_idx| crate::types::Value::Integer(reg_idx as i64)), + ); + } + if let Some(old_regs) = &ctx.old_registers { + params.extend( + old_regs + .iter() + .copied() + .map(|reg_idx| crate::types::Value::Integer(reg_idx as i64)), + ); + } + + let turso_stmt = Statement::new( + built_subprogram, + connection.mv_store().cloned(), + connection.pager.load().clone(), + QueryMode::Normal, + ); + program.emit_insn(Insn::Program { + params, + program: Arc::new(RwLock::new(turso_stmt)), + }); + connection.end_trigger_compilation(); + + Ok(true) +} + +/// Check if there are any triggers for a given event (regardless of time). +/// This is used during plan preparation to determine if materialization is needed. +pub fn has_relevant_triggers_type_only( + schema: &crate::schema::Schema, + event: TriggerEvent, + updated_column_indices: Option<&HashSet>, + table: &BTreeTable, +) -> bool { + let mut triggers = schema.get_triggers_for_table(table.name.as_str()); + + // Filter triggers by event + triggers.any(|trigger| { + // Check event matches + let event_matches = match (&trigger.event, &event) { + (TriggerEvent::Delete, TriggerEvent::Delete) => true, + (TriggerEvent::Insert, TriggerEvent::Insert) => true, + (TriggerEvent::Update, TriggerEvent::Update) => true, + (TriggerEvent::UpdateOf(trigger_cols), TriggerEvent::Update) => { + // For UPDATE OF, we need to check if any of the specified columns + // are in the UPDATE SET clause + let updated_cols = + updated_column_indices.expect("UPDATE should contain some updated columns"); + // Check if any of the trigger's specified columns are being updated + trigger_cols.iter().any(|col_name| { + let normalized_col = normalize_ident(col_name.as_str()); + if let Some((col_idx, _)) = table.get_column(&normalized_col) { + updated_cols.contains(&col_idx) + } else { + // Column doesn't exist - according to SQLite docs, unrecognized + // column names in UPDATE OF are silently ignored + false + } + }) + } + _ => false, + }; + + event_matches + }) +} + +/// Check if there are any triggers for a given event (regardless of time). +/// This is used during plan preparation to determine if materialization is needed. +pub fn get_relevant_triggers_type_and_time<'a>( + schema: &'a crate::schema::Schema, + event: TriggerEvent, + time: TriggerTime, + updated_column_indices: Option>, + table: &'a BTreeTable, +) -> impl Iterator> + 'a + Clone { + let triggers = schema.get_triggers_for_table(table.name.as_str()); + + // Filter triggers by event + triggers + .filter(move |trigger| -> bool { + // Check event matches + let event_matches = match (&trigger.event, &event) { + (TriggerEvent::Delete, TriggerEvent::Delete) => true, + (TriggerEvent::Insert, TriggerEvent::Insert) => true, + (TriggerEvent::Update, TriggerEvent::Update) => true, + (TriggerEvent::UpdateOf(trigger_cols), TriggerEvent::Update) => { + // For UPDATE OF, we need to check if any of the specified columns + // are in the UPDATE SET clause + if let Some(ref updated_cols) = updated_column_indices { + // Check if any of the trigger's specified columns are being updated + trigger_cols.iter().any(|col_name| { + let normalized_col = normalize_ident(col_name.as_str()); + if let Some((col_idx, _)) = table.get_column(&normalized_col) { + updated_cols.contains(&col_idx) + } else { + // Column doesn't exist - according to SQLite docs, unrecognized + // column names in UPDATE OF are silently ignored + false + } + }) + } else { + false + } + } + _ => false, + }; + + if !event_matches { + return false; + } + + trigger.time == time + }) + .cloned() +} + +pub fn fire_trigger( + program: &mut ProgramBuilder, + resolver: &mut Resolver, + trigger: Arc, + ctx: &TriggerContext, + connection: &Arc, +) -> Result<()> { + // Evaluate WHEN clause if present + if let Some(mut when_expr) = trigger.when_clause.clone() { + // Rewrite NEW/OLD references in WHEN clause to use registers + rewrite_trigger_expr_for_when_clause(&mut when_expr, &ctx.table, ctx)?; + + let when_reg = program.alloc_register(); + translate_expr(program, None, &when_expr, when_reg, resolver)?; + + let skip_label = program.allocate_label(); + program.emit_insn(Insn::IfNot { + reg: when_reg, + jump_if_null: true, + target_pc: skip_label, + }); + + // Execute trigger commands if WHEN clause is true + execute_trigger_commands(program, resolver, &trigger, ctx, connection)?; + + program.preassign_label_to_next_insn(skip_label); + } else { + // No WHEN clause - always execute + execute_trigger_commands(program, resolver, &trigger, ctx, connection)?; + } + + Ok(()) +} + +/// Rewrite NEW/OLD references in WHEN clause expressions (uses Register expressions, not Variable) +fn rewrite_trigger_expr_for_when_clause( + expr: &mut ast::Expr, + table: &BTreeTable, + ctx: &TriggerContext, +) -> Result<()> { + use crate::translate::expr::walk_expr_mut; + use crate::translate::expr::WalkControl; + + walk_expr_mut(expr, &mut |e: &mut ast::Expr| -> Result { + match e { + Expr::Qualified(ns, col) | Expr::DoublyQualified(_, ns, col) => { + let ns = normalize_ident(ns.as_str()); + let col = normalize_ident(col.as_str()); + + // Handle NEW.column references + if ns.eq_ignore_ascii_case("new") { + if let Some(new_regs) = &ctx.new_registers { + if let Some((idx, _)) = table.get_column(&col) { + if idx < new_regs.len() { + *e = Expr::Register(new_regs[idx]); + return Ok(WalkControl::Continue); + } + } + // Handle NEW.rowid + if crate::translate::planner::ROWID_STRS + .iter() + .any(|s| s.eq_ignore_ascii_case(&col)) + { + *e = Expr::Register( + *ctx.new_registers + .as_ref() + .expect("NEW registers must be provided") + .last() + .expect("NEW registers must be provided"), + ); + return Ok(WalkControl::Continue); + } + bail_parse_error!("no such column in NEW: {}", col); + } else { + bail_parse_error!( + "NEW references are only valid in INSERT and UPDATE triggers" + ); + } + } + + // Handle OLD.column references + if ns.eq_ignore_ascii_case("old") { + if let Some(old_regs) = &ctx.old_registers { + if let Some((idx, _)) = table.get_column(&col) { + if idx < old_regs.len() { + *e = Expr::Register(old_regs[idx]); + return Ok(WalkControl::Continue); + } + } + // Handle OLD.rowid + if crate::translate::planner::ROWID_STRS + .iter() + .any(|s| s.eq_ignore_ascii_case(&col)) + { + *e = Expr::Register( + *ctx.old_registers + .as_ref() + .expect("OLD registers must be provided") + .last() + .expect("OLD registers must be provided"), + ); + return Ok(WalkControl::Continue); + } + bail_parse_error!("no such column in OLD: {}", col); + } else { + bail_parse_error!( + "OLD references are only valid in UPDATE and DELETE triggers" + ); + } + } + + crate::bail_parse_error!("no such column: {ns}.{col}"); + } + _ => Ok(WalkControl::Continue), + } + })?; + Ok(()) +} diff --git a/core/translate/values.rs b/core/translate/values.rs index d57152a67..b537c24ae 100644 --- a/core/translate/values.rs +++ b/core/translate/values.rs @@ -34,6 +34,9 @@ pub fn emit_values( QueryDestination::RowValueSubqueryResult { .. } => { emit_toplevel_values(program, plan, t_ctx)? } + QueryDestination::RowSet { .. } => { + unreachable!("RowSet query destination should not be used in values emission") + } QueryDestination::Unset => unreachable!("Unset query destination should not be reached"), }; Ok(reg_result_cols_start) @@ -212,6 +215,9 @@ fn emit_values_to_destination( extra_amount: num_regs - 1, }); } + QueryDestination::RowSet { .. } => { + unreachable!("RowSet query destination should not be used in values emission") + } QueryDestination::Unset => unreachable!("Unset query destination should not be reached"), } } diff --git a/core/vdbe/builder.rs b/core/vdbe/builder.rs index 8b522dfea..811f14598 100644 --- a/core/vdbe/builder.rs +++ b/core/vdbe/builder.rs @@ -1,16 +1,17 @@ +use parking_lot::RwLock; use std::{ cmp::Ordering, sync::{atomic::AtomicI64, Arc}, }; use tracing::{instrument, Level}; -use turso_parser::ast::{self, TableInternalId}; +use turso_parser::ast::{self, ResolveType, TableInternalId}; use crate::{ index_method::IndexMethodAttachment, numeric::Numeric, parameters::Parameters, - schema::{BTreeTable, Index, PseudoCursorType, Schema, Table}, + schema::{BTreeTable, Index, PseudoCursorType, Schema, Table, Trigger}, translate::{ collate::CollationSeq, emitter::TransactionMode, @@ -38,7 +39,7 @@ impl TableRefIdCounter { } } -use super::{BranchOffset, CursorID, Insn, InsnReference, JumpTarget, Program}; +use super::{BranchOffset, CursorID, ExplainState, Insn, InsnReference, JumpTarget, Program}; /// A key that uniquely identifies a cursor. /// The key is a pair of table reference id and index. @@ -89,7 +90,7 @@ pub struct ProgramBuilder { next_free_register: usize, next_free_cursor_id: usize, /// Instruction, the function to execute it with, and its original index in the vector. - insns: Vec<(Insn, usize)>, + pub insns: Vec<(Insn, usize)>, /// A span of instructions from (offset_start_inclusive, offset_end_exclusive), /// that are deemed to be compile-time constant and can be hoisted out of loops /// so that they get evaluated only once at the start of the program. @@ -127,6 +128,9 @@ pub struct ProgramBuilder { /// i.e. the individual statement may need to be aborted due to a constraint conflict, etc. /// instead of the entire transaction. needs_stmt_subtransactions: bool, + /// If this ProgramBuilder is building trigger subprogram, a ref to the trigger is stored here. + pub trigger: Option>, + pub resolve_type: ResolveType, } #[derive(Debug, Clone)] @@ -189,6 +193,22 @@ impl ProgramBuilder { query_mode: QueryMode, capture_data_changes_mode: CaptureDataChangesMode, opts: ProgramBuilderOpts, + ) -> Self { + ProgramBuilder::_new(query_mode, capture_data_changes_mode, opts, None) + } + pub fn new_for_trigger( + query_mode: QueryMode, + capture_data_changes_mode: CaptureDataChangesMode, + opts: ProgramBuilderOpts, + trigger: Arc, + ) -> Self { + ProgramBuilder::_new(query_mode, capture_data_changes_mode, opts, Some(trigger)) + } + fn _new( + query_mode: QueryMode, + capture_data_changes_mode: CaptureDataChangesMode, + opts: ProgramBuilderOpts, + trigger: Option>, ) -> Self { Self { table_reference_counter: TableRefIdCounter::new(), @@ -215,9 +235,15 @@ impl ProgramBuilder { current_parent_explain_idx: None, reg_result_cols_start: None, needs_stmt_subtransactions: false, + trigger, + resolve_type: ResolveType::Abort, } } + pub fn set_resolve_type(&mut self, resolve_type: ResolveType) { + self.resolve_type = resolve_type; + } + pub fn set_needs_stmt_subtransactions(&mut self, needs_stmt_subtransactions: bool) { self.needs_stmt_subtransactions = needs_stmt_subtransactions; } @@ -829,6 +855,9 @@ impl ProgramBuilder { Insn::VFilter { pc_if_empty, .. } => { resolve(pc_if_empty, "VFilter"); } + Insn::RowSetRead { pc_if_empty, .. } => { + resolve(pc_if_empty, "RowSetRead"); + } Insn::NoConflict { target_pc, .. } => { resolve(target_pc, "NoConflict"); } @@ -923,6 +952,15 @@ impl ProgramBuilder { /// Initialize the program with basic setup and return initial metadata and labels pub fn prologue(&mut self) { + if self.trigger.is_some() { + self.init_label = self.allocate_label(); + self.emit_insn(Insn::Init { + target_pc: self.init_label, + }); + self.preassign_label_to_next_insn(self.init_label); + self.start_offset = self.offset(); + return; + } if self.nested_level == 0 { self.init_label = self.allocate_label(); @@ -961,6 +999,13 @@ impl ProgramBuilder { /// Note that although these are the final instructions, typically an SQLite /// query will jump to the Transaction instruction via init_label. pub fn epilogue(&mut self, schema: &Schema) { + if self.trigger.is_some() { + self.emit_insn(Insn::Halt { + err_code: 0, + description: "trigger".to_string(), + }); + return; + } if self.nested_level == 0 { // "rollback" flag is used to determine if halt should rollback the transaction. self.emit_halt(self.rollback); @@ -1110,6 +1155,9 @@ impl ProgramBuilder { sql: sql.to_string(), accesses_db: !matches!(self.txn_mode, TransactionMode::None), needs_stmt_subtransactions: self.needs_stmt_subtransactions, + trigger: self.trigger.take(), + resolve_type: self.resolve_type, + explain_state: RwLock::new(ExplainState::default()), } } } diff --git a/core/vdbe/execute.rs b/core/vdbe/execute.rs index bba8b9cc9..2cc7ad665 100644 --- a/core/vdbe/execute.rs +++ b/core/vdbe/execute.rs @@ -23,7 +23,7 @@ use crate::util::{ use crate::vdbe::affinity::{apply_numeric_affinity, try_for_float, Affinity, ParsedNumber}; use crate::vdbe::insn::InsertFlags; use crate::vdbe::value::ComparisonOp; -use crate::vdbe::{registers_to_ref_values, EndStatement, TxnCleanup}; +use crate::vdbe::{registers_to_ref_values, EndStatement, StepResult, TxnCleanup}; use crate::vector::{vector32_sparse, vector_concat, vector_distance_jaccard, vector_slice}; use crate::{ error::{ @@ -39,13 +39,14 @@ use crate::{ }, translate::emitter::TransactionMode, }; -use crate::{get_cursor, CheckpointMode, Connection, DatabaseStorage, MvCursor}; +use crate::{get_cursor, CheckpointMode, Completion, Connection, DatabaseStorage, MvCursor}; use either::Either; use std::any::Any; use std::env::temp_dir; use std::ops::DerefMut; use std::{ borrow::BorrowMut, + num::NonZero, sync::{atomic::Ordering, Arc, Mutex}, }; use turso_macros::match_ignore_ascii_case; @@ -75,7 +76,7 @@ use super::{ CommitState, }; use parking_lot::RwLock; -use turso_parser::ast::{self, ForeignKeyClause, Name}; +use turso_parser::ast::{self, ForeignKeyClause, Name, ResolveType}; use turso_parser::parser::Parser; use super::{ @@ -2068,8 +2069,6 @@ pub fn halt( description: &str, ) -> Result { if err_code > 0 { - // Any non-FK constraint violation causes the statement subtransaction to roll back. - state.end_statement(&program.connection, pager, EndStatement::RollbackSavepoint)?; vtab_rollback_all(&program.connection, state)?; } match err_code { @@ -2107,12 +2106,15 @@ pub fn halt( .load(Ordering::Acquire) > 0 { - state.end_statement(&program.connection, pager, EndStatement::RollbackSavepoint)?; return Err(LimboError::Constraint( "foreign key constraint failed".to_string(), )); } + if program.is_trigger_subprogram() { + return Ok(InsnFunctionStepResult::Done); + } + if auto_commit { // In autocommit mode, a statement that leaves deferred violations must fail here, // and it also ends the transaction. @@ -2256,6 +2258,11 @@ pub fn op_transaction_inner( }, insn ); + if program.is_trigger_subprogram() { + crate::bail_parse_error!( + "Transaction instruction should not be used in trigger subprograms" + ); + } let pager = program.get_pager_from_database_index(db); loop { match state.op_transaction_state { @@ -2537,6 +2544,12 @@ pub fn op_auto_commit( } } + if program.is_trigger_subprogram() { + // Trigger subprograms never commit or rollback. + state.pc += 1; + return Ok(InsnFunctionStepResult::Step); + } + let res = program .commit_txn(pager.clone(), state, mv_store, requested_rollback) .map(Into::into); @@ -2643,6 +2656,100 @@ pub fn op_integer( Ok(InsnFunctionStepResult::Step) } +pub enum OpProgramState { + Start, + Step, +} + +/// Execute a trigger subprogram (Program opcode). +pub fn op_program( + program: &Program, + state: &mut ProgramState, + insn: &Insn, + pager: &Arc, + mv_store: Option<&Arc>, +) -> Result { + load_insn!( + Program { + params, + program: subprogram, + }, + insn + ); + loop { + match &mut state.op_program_state { + OpProgramState::Start => { + let mut statement = subprogram.write(); + statement.reset(); + let Some(ref trigger) = statement.get_trigger() else { + crate::bail_parse_error!("trigger subprogram has no trigger"); + }; + program.connection.start_trigger_execution(trigger.clone()); + + // Extract register values from params (which contain register indices encoded as negative integers) + // and bind them to the subprogram's parameters + for (param_idx, param_value) in params.iter().enumerate() { + if let Value::Integer(reg_idx) = param_value { + let reg_idx = *reg_idx as usize; + if reg_idx < state.registers.len() { + let value = state.registers[reg_idx].get_value().clone(); + let param_index = NonZero::::new(param_idx + 1).unwrap(); + statement.bind_at(param_index, value); + } else { + crate::bail_corrupt_error!( + "Register index {} out of bounds (len={})", + reg_idx, + state.registers.len() + ); + } + } else { + crate::bail_parse_error!( + "Trigger parameters should be integers, got {:?}", + param_value + ); + } + } + + state.op_program_state = OpProgramState::Step; + } + OpProgramState::Step => { + loop { + let mut statement = subprogram.write(); + let res = statement.step(); + match res { + Ok(step_result) => match step_result { + StepResult::Done => break, + StepResult::IO => { + return Ok(InsnFunctionStepResult::IO(IOCompletions::Single( + Completion::new_yield(), + ))); + } + StepResult::Row => continue, + StepResult::Interrupt | StepResult::Busy => { + return Err(LimboError::Busy); + } + }, + Err(LimboError::Constraint(constraint_err)) => { + if program.resolve_type != ResolveType::Ignore { + return Err(LimboError::Constraint(constraint_err)); + } + break; + } + Err(err) => { + return Err(err); + } + } + } + program.connection.end_trigger_execution(); + + state.op_program_state = OpProgramState::Start; + state.pc += 1; + return Ok(InsnFunctionStepResult::Step); + } + } + } +} + pub fn op_real( program: &Program, state: &mut ProgramState, @@ -6570,7 +6677,6 @@ pub fn op_idx_delete( insn ); - tracing::info!("idx_delete cursor: {:?}", program.cursor_ref[*cursor_id]); if let Some(Cursor::IndexMethod(cursor)) = &mut state.cursors[*cursor_id] { return_if_io!(cursor.delete(&state.registers[*start_reg..*start_reg + *num_regs])); state.pc += 1; @@ -7596,6 +7702,24 @@ pub fn op_drop_view( Ok(InsnFunctionStepResult::Step) } +pub fn op_drop_trigger( + program: &Program, + state: &mut ProgramState, + insn: &Insn, + pager: &Arc, + mv_store: Option<&Arc>, +) -> Result { + load_insn!(DropTrigger { db, trigger_name }, insn); + + let conn = program.connection.clone(); + conn.with_schema_mut(|schema| { + schema.remove_trigger(trigger_name)?; + Ok::<(), crate::LimboError>(()) + })?; + state.pc += 1; + Ok(InsnFunctionStepResult::Step) +} + pub fn op_close( program: &Program, state: &mut ProgramState, @@ -7606,6 +7730,9 @@ pub fn op_close( load_insn!(Close { cursor_id }, insn); let cursors = &mut state.cursors; cursors.get_mut(*cursor_id).unwrap().take(); + if let Some(deferred_seek) = state.deferred_seeks.get_mut(*cursor_id) { + deferred_seek.take(); + } state.pc += 1; Ok(InsnFunctionStepResult::Step) } diff --git a/core/vdbe/explain.rs b/core/vdbe/explain.rs index ba2e42b47..938343940 100644 --- a/core/vdbe/explain.rs +++ b/core/vdbe/explain.rs @@ -727,6 +727,23 @@ pub fn insn_to_row( 0, format!("r[{dest}]={value}"), ), + Insn::Program { + params, + .. + } => ( + "Program", + // First register that contains a param + params.first().map(|v| match v { + crate::types::Value::Integer(i) if *i < 0 => (-i - 1) as i32, + _ => 0, + }).unwrap_or(0), + // Number of registers that contain params + params.len() as i32, + 0, + Value::build_text(program.sql.clone()), + 0, + format!("subprogram={}", program.sql), + ), Insn::Real { value, dest } => ( "Real", 0, @@ -1407,6 +1424,15 @@ pub fn insn_to_row( 0, format!("DROP TABLE {table_name}"), ), + Insn::DropTrigger { db, trigger_name } => ( + "DropTrigger", + *db as i32, + 0, + 0, + Value::build_text(trigger_name.clone()), + 0, + format!("DROP TRIGGER {trigger_name}"), + ), Insn::DropView { db, view_name } => ( "DropView", *db as i32, diff --git a/core/vdbe/insn.rs b/core/vdbe/insn.rs index df70784d1..48f3d1b13 100644 --- a/core/vdbe/insn.rs +++ b/core/vdbe/insn.rs @@ -10,8 +10,9 @@ use crate::{ translate::{collate::CollationSeq, emitter::TransactionMode}, types::KeyInfo, vdbe::affinity::Affinity, - Value, + Statement, Value, }; +use parking_lot::RwLock; use strum::EnumCount; use strum_macros::{EnumDiscriminants, FromRepr, VariantArray}; use turso_macros::Description; @@ -530,6 +531,18 @@ pub enum Insn { can_fallthrough: bool, }, + /// Invoke a trigger subprogram. + /// + /// According to SQLite documentation (https://sqlite.org/opcode.html): + /// "The Program opcode invokes the trigger subprogram. The Program instruction + /// allocates and initializes a fresh register set for each invocation of the + /// subprogram, so subprograms can be reentrant and recursive. The Param opcode + /// is used by subprograms to access content in registers of the calling bytecode program." + Program { + params: Vec, + program: Arc>, + }, + /// Write an integer value into a register. Integer { value: i64, @@ -980,6 +993,13 @@ pub enum Insn { // The name of the index being dropped index: Arc, }, + /// Drop a trigger + DropTrigger { + /// The database within which this trigger needs to be dropped (P1). + db: usize, + /// The name of the trigger being dropped + trigger_name: String, + }, /// Close a cursor. Close { @@ -1329,6 +1349,7 @@ impl InsnVariants { InsnVariants::Gosub => execute::op_gosub, InsnVariants::Return => execute::op_return, InsnVariants::Integer => execute::op_integer, + InsnVariants::Program => execute::op_program, InsnVariants::Real => execute::op_real, InsnVariants::RealAffinity => execute::op_real_affinity, InsnVariants::String8 => execute::op_string8, @@ -1383,6 +1404,7 @@ impl InsnVariants { InsnVariants::Destroy => execute::op_destroy, InsnVariants::ResetSorter => execute::op_reset_sorter, InsnVariants::DropTable => execute::op_drop_table, + InsnVariants::DropTrigger => execute::op_drop_trigger, InsnVariants::DropView => execute::op_drop_view, InsnVariants::Close => execute::op_close, InsnVariants::IsNull => execute::op_is_null, diff --git a/core/vdbe/mod.rs b/core/vdbe/mod.rs index bebb41aaa..4047f6664 100644 --- a/core/vdbe/mod.rs +++ b/core/vdbe/mod.rs @@ -33,6 +33,7 @@ use crate::{ function::{AggFunc, FuncCtx}, mvcc::{database::CommitStateMachine, LocalClock}, return_if_io, + schema::Trigger, state_machine::StateMachine, storage::{pager::PagerCommitResult, sqlite3_ondisk::SmallVec}, translate::{collate::CollationSeq, plan::TableReferences}, @@ -41,7 +42,7 @@ use crate::{ execute::{ OpCheckpointState, OpColumnState, OpDeleteState, OpDeleteSubState, OpDestroyState, OpIdxInsertState, OpInsertState, OpInsertSubState, OpNewRowidState, OpNoConflictState, - OpRowIdState, OpSeekState, OpTransactionState, + OpProgramState, OpRowIdState, OpSeekState, OpTransactionState, }, metrics::StatementMetrics, }, @@ -63,6 +64,8 @@ use execute::{ InsnFunction, InsnFunctionStepResult, OpIdxDeleteState, OpIntegrityCheckState, OpOpenEphemeralState, }; +use parking_lot::RwLock; +use turso_parser::ast::ResolveType; use crate::vdbe::rowset::RowSet; use explain::{insn_to_row_with_comment, EXPLAIN_COLUMNS, EXPLAIN_QUERY_PLAN_COLUMNS}; @@ -296,6 +299,7 @@ pub struct ProgramState { /// Metrics collected during statement execution pub metrics: StatementMetrics, op_open_ephemeral_state: OpOpenEphemeralState, + op_program_state: OpProgramState, op_new_rowid_state: OpNewRowidState, op_idx_insert_state: OpIdxInsertState, op_insert_state: OpInsertState, @@ -324,6 +328,12 @@ pub struct ProgramState { rowsets: HashMap, } +impl std::fmt::Debug for Program { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Program").finish() + } +} + // SAFETY: This needs to be audited for thread safety. // See: https://github.com/tursodatabase/turso/issues/1552 unsafe impl Send for ProgramState {} @@ -360,6 +370,7 @@ impl ProgramState { op_integrity_check_state: OpIntegrityCheckState::Start, metrics: StatementMetrics::new(), op_open_ephemeral_state: OpOpenEphemeralState::Start, + op_program_state: OpProgramState::Start, op_new_rowid_state: OpNewRowidState::Start, op_idx_insert_state: OpIdxInsertState::MaybeSeek, op_insert_state: OpInsertState { @@ -515,7 +526,12 @@ impl ProgramState { match end_statement { EndStatement::ReleaseSavepoint => pager.release_savepoint(), EndStatement::RollbackSavepoint => { - pager.rollback_to_newest_savepoint()?; + let stmt_was_rolled_back = pager.rollback_to_newest_savepoint()?; + if !stmt_was_rolled_back { + // We sometimes call end_statement() on errors without explicitly knowing whether a stmt transaction + // caused the error or not. If it didn't, don't reset any FK violation counters. + return Ok(()); + } // Reset the deferred foreign key violations counter to the value it had at the start of the statement. // This is used to ensure that if an interactive transaction had deferred FK violations, they are not lost. connection.fk_deferred_violations.store( @@ -583,6 +599,17 @@ macro_rules! get_cursor { }; } +/// Tracks the state of explain mode execution, including which subprograms need to be processed. +#[derive(Default)] +pub struct ExplainState { + /// Program counter positions in the parent program where `Insn::Program` instructions occur. + parent_program_pcs: Vec, + /// Index of the subprogram currently being processed, if any. + current_subprogram_index: Option, + /// PC value when we started processing the current subprogram, to detect if we need to reset. + subprogram_start_pc: Option, +} + pub struct Program { pub max_registers: usize, // we store original indices because we don't want to create new vec from @@ -605,6 +632,9 @@ pub struct Program { /// is determined by the parser flags "mayAbort" and "isMultiWrite". Essentially this means that the individual /// statement may need to be aborted due to a constraint conflict, etc. instead of the entire transaction. pub needs_stmt_subtransactions: bool, + pub trigger: Option>, + pub resolve_type: ResolveType, + pub explain_state: RwLock, } impl Program { @@ -650,11 +680,106 @@ impl Program { // FIXME: do we need this? state.metrics.vm_steps = state.metrics.vm_steps.saturating_add(1); + let mut explain_state = self.explain_state.write(); + + // Check if we're processing a subprogram + if let Some(sub_idx) = explain_state.current_subprogram_index { + if sub_idx >= explain_state.parent_program_pcs.len() { + // All subprograms processed + *explain_state = ExplainState::default(); + return Ok(StepResult::Done); + } + + let parent_pc = explain_state.parent_program_pcs[sub_idx]; + let Insn::Program { program: p, .. } = &self.insns[parent_pc].0 else { + panic!("Expected program insn at pc {parent_pc}"); + }; + let p = &mut p.write().program; + + let subprogram_insn_count = p.insns.len(); + + // Check if the subprogram has already finished (PC is out of bounds) + // This can happen if the subprogram finished in a previous call but we're being called again + if state.pc as usize >= subprogram_insn_count { + // Subprogram is done, move to next one + explain_state.subprogram_start_pc = None; + if sub_idx + 1 < explain_state.parent_program_pcs.len() { + explain_state.current_subprogram_index = Some(sub_idx + 1); + state.pc = 0; + drop(explain_state); + return self.explain_step(state, _mv_store, pager); + } else { + *explain_state = ExplainState::default(); + return Ok(StepResult::Done); + } + } + + // Reset PC to 0 only when starting a new subprogram (when subprogram_start_pc is None) + // Once we've started, let the subprogram manage its own PC through its explain_step + if explain_state.subprogram_start_pc.is_none() { + state.pc = 0; + explain_state.subprogram_start_pc = Some(0); + } + + // Process the subprogram - it will handle its own explain_step internally + // The subprogram's explain_step will process all its instructions (including any nested subprograms) + // and return StepResult::Row for each instruction, then StepResult::Done when finished + let result = p.step(state, None, pager.clone(), QueryMode::Explain, None)?; + + match result { + StepResult::Done => { + // This subprogram is done, move to next one + explain_state.subprogram_start_pc = None; // Clear the start PC marker + if sub_idx + 1 < explain_state.parent_program_pcs.len() { + // Move to next subprogram + explain_state.current_subprogram_index = Some(sub_idx + 1); + // Reset PC to 0 for the next subprogram + state.pc = 0; + // Recursively call to process the next subprogram + drop(explain_state); + return self.explain_step(state, _mv_store, pager); + } else { + // All subprograms done + *explain_state = ExplainState::default(); + return Ok(StepResult::Done); + } + } + StepResult::Row => { + // Output a row from the subprogram + // The subprogram's step already set up the registers with PC starting at 0 + // Don't reset subprogram_start_pc - we're still processing this subprogram + drop(explain_state); + return Ok(StepResult::Row); + } + other => { + drop(explain_state); + return Ok(other); + } + } + } + + // We're processing the parent program if state.pc as usize >= self.insns.len() { - return Ok(StepResult::Done); + // Parent program is done, start processing subprograms + if explain_state.parent_program_pcs.is_empty() { + // No subprograms to process + *explain_state = ExplainState::default(); + return Ok(StepResult::Done); + } + + // Start processing the first subprogram + explain_state.current_subprogram_index = Some(0); + explain_state.subprogram_start_pc = None; // Will be set when we actually start processing + state.pc = 0; // Reset PC to 0 for the first subprogram + drop(explain_state); + return self.explain_step(state, _mv_store, pager); } let (current_insn, _) = &self.insns[state.pc as usize]; + + if matches!(current_insn, Insn::Program { .. }) { + explain_state.parent_program_pcs.push(state.pc as usize); + } let (opcode, p1, p2, p3, p4, p5, comment) = insn_to_row_with_comment( self, current_insn, @@ -747,7 +872,7 @@ impl Program { return Err(LimboError::InternalError("Connection closed".to_string())); } if state.is_interrupted() { - self.abort(mv_store, &pager, None, &mut state.auto_txn_cleanup); + self.abort(mv_store, &pager, None, state); return Ok(StepResult::Interrupt); } if let Some(io) = &state.io_completions { @@ -757,7 +882,7 @@ impl Program { } if let Some(err) = io.get_error() { let err = err.into(); - self.abort(mv_store, &pager, Some(&err), &mut state.auto_txn_cleanup); + self.abort(mv_store, &pager, Some(&err), state); return Err(err); } state.io_completions = None; @@ -799,7 +924,7 @@ impl Program { return Ok(StepResult::Busy); } Err(err) => { - self.abort(mv_store, &pager, Some(&err), &mut state.auto_txn_cleanup); + self.abort(mv_store, &pager, Some(&err), state); return Err(err); } } @@ -1059,10 +1184,21 @@ impl Program { mv_store: Option<&Arc>, pager: &Arc, err: Option<&LimboError>, - cleanup: &mut TxnCleanup, + state: &mut ProgramState, ) { + if self.is_trigger_subprogram() { + self.connection.end_trigger_execution(); + } // Errors from nested statements are handled by the parent statement. - if !self.connection.is_nested_stmt() { + if !self.connection.is_nested_stmt() && !self.is_trigger_subprogram() { + if err.is_some() { + // Any error apart from deferred FK volations causes the statement subtransaction to roll back. + let res = + state.end_statement(&self.connection, pager, EndStatement::RollbackSavepoint); + if let Err(e) = res { + tracing::error!("Error rolling back statement: {}", e); + } + } match err { // Transaction errors, e.g. trying to start a nested transaction, do not cause a rollback. Some(LimboError::TxError(_)) => {} @@ -1075,7 +1211,7 @@ impl Program { // and op_halt. Some(LimboError::Constraint(_)) => {} _ => { - if *cleanup != TxnCleanup::None || err.is_some() { + if state.auto_txn_cleanup != TxnCleanup::None || err.is_some() { if let Some(mv_store) = mv_store { if let Some(tx_id) = self.connection.get_mv_tx_id() { self.connection.auto_commit.store(true, Ordering::SeqCst); @@ -1090,7 +1226,11 @@ impl Program { } } } - *cleanup = TxnCleanup::None; + state.auto_txn_cleanup = TxnCleanup::None; + } + + pub fn is_trigger_subprogram(&self) -> bool { + self.trigger.is_some() } } diff --git a/testing/trigger.test b/testing/trigger.test new file mode 100755 index 000000000..f043b8c7c --- /dev/null +++ b/testing/trigger.test @@ -0,0 +1,761 @@ +#!/usr/bin/env tclsh + +set testdir [file dirname $argv0] +source $testdir/tester.tcl + +# Basic CREATE TRIGGER +do_execsql_test_on_specific_db {:memory:} trigger-create-basic { + CREATE TABLE test1 (x INTEGER, y TEXT); + CREATE TRIGGER t1 BEFORE INSERT ON test1 BEGIN INSERT INTO test1 VALUES (100, 'triggered'); END; + INSERT INTO test1 VALUES (1, 'hello'); + SELECT * FROM test1 ORDER BY rowid; +} {100|triggered +1|hello} + +# CREATE TRIGGER IF NOT EXISTS +do_execsql_test_on_specific_db {:memory:} trigger-create-if-not-exists { + CREATE TABLE test2 (x INTEGER PRIMARY KEY); + CREATE TRIGGER IF NOT EXISTS t2 BEFORE INSERT ON test2 BEGIN SELECT 1; END; + CREATE TRIGGER IF NOT EXISTS t2 BEFORE INSERT ON test2 BEGIN SELECT 1; END; + SELECT name FROM sqlite_schema WHERE type='trigger' AND name='t2'; +} {t2} + +# DROP TRIGGER +do_execsql_test_on_specific_db {:memory:} trigger-drop-basic { + CREATE TABLE test3 (x INTEGER PRIMARY KEY); + CREATE TRIGGER t3 BEFORE INSERT ON test3 BEGIN SELECT 1; END; + DROP TRIGGER t3; + SELECT name FROM sqlite_schema WHERE type='trigger' AND name='t3'; +} {} + +# DROP TRIGGER IF EXISTS +do_execsql_test_on_specific_db {:memory:} trigger-drop-if-exists { + CREATE TABLE test4 (x INTEGER PRIMARY KEY); + DROP TRIGGER IF EXISTS nonexistent; + CREATE TRIGGER t4 BEFORE INSERT ON test4 BEGIN SELECT 1; END; + DROP TRIGGER IF EXISTS t4; + SELECT name FROM sqlite_schema WHERE type='trigger' AND name='t4'; +} {} + +# BEFORE INSERT trigger +do_execsql_test_on_specific_db {:memory:} trigger-before-insert { + CREATE TABLE test5 (x INTEGER PRIMARY KEY, y TEXT); + CREATE TRIGGER t5 BEFORE INSERT ON test5 BEGIN UPDATE test5 SET y = 'before_' || y WHERE x = NEW.x; END; + INSERT INTO test5 VALUES (1, 'hello'); + SELECT * FROM test5; +} {1|hello} + +# AFTER INSERT trigger +do_execsql_test_on_specific_db {:memory:} trigger-after-insert { + CREATE TABLE test6 (x INTEGER PRIMARY KEY, y TEXT); + CREATE TABLE log6 (x INTEGER, y TEXT); + CREATE TRIGGER t6 AFTER INSERT ON test6 BEGIN INSERT INTO log6 VALUES (NEW.x, NEW.y); END; + INSERT INTO test6 VALUES (1, 'hello'); + SELECT * FROM log6; +} {1|hello} + +# BEFORE UPDATE trigger +do_execsql_test_on_specific_db {:memory:} trigger-before-update { + CREATE TABLE test7 (x INTEGER PRIMARY KEY, y TEXT); + INSERT INTO test7 VALUES (1, 'hello'); + CREATE TRIGGER t7 BEFORE UPDATE ON test7 BEGIN UPDATE test7 SET y = 'before_' || NEW.y WHERE x = OLD.x; END; + UPDATE test7 SET y = 'world' WHERE x = 1; + SELECT * FROM test7; +} {1|world} + +# AFTER UPDATE trigger +do_execsql_test_on_specific_db {:memory:} trigger-after-update { + CREATE TABLE test8 (x INTEGER PRIMARY KEY, y TEXT); + CREATE TABLE log8 (old_x INTEGER, old_y TEXT, new_x INTEGER, new_y TEXT); + INSERT INTO test8 VALUES (1, 'hello'); + CREATE TRIGGER t8 AFTER UPDATE ON test8 BEGIN INSERT INTO log8 VALUES (OLD.x, OLD.y, NEW.x, NEW.y); END; + UPDATE test8 SET y = 'world' WHERE x = 1; + SELECT * FROM log8; +} {1|hello|1|world} + +# BEFORE DELETE trigger +do_execsql_test_on_specific_db {:memory:} trigger-before-delete { + CREATE TABLE test9 (x INTEGER PRIMARY KEY, y TEXT); + CREATE TABLE log9 (x INTEGER, y TEXT); + INSERT INTO test9 VALUES (1, 'hello'); + CREATE TRIGGER t9 BEFORE DELETE ON test9 BEGIN INSERT INTO log9 VALUES (OLD.x, OLD.y); END; + DELETE FROM test9 WHERE x = 1; + SELECT * FROM log9; +} {1|hello} + +# AFTER DELETE trigger +do_execsql_test_on_specific_db {:memory:} trigger-after-delete { + CREATE TABLE test10 (x INTEGER PRIMARY KEY, y TEXT); + CREATE TABLE log10 (x INTEGER, y TEXT); + INSERT INTO test10 VALUES (1, 'hello'); + CREATE TRIGGER t10 AFTER DELETE ON test10 BEGIN INSERT INTO log10 VALUES (OLD.x, OLD.y); END; + DELETE FROM test10 WHERE x = 1; + SELECT * FROM log10; +} {1|hello} + +# Trigger with WHEN clause +do_execsql_test_on_specific_db {:memory:} trigger-when-clause { + CREATE TABLE test11 (x INTEGER PRIMARY KEY, y INTEGER); + CREATE TABLE log11 (x INTEGER); + CREATE TRIGGER t11 AFTER INSERT ON test11 WHEN NEW.y > 10 BEGIN INSERT INTO log11 VALUES (NEW.x); END; + INSERT INTO test11 VALUES (1, 5); + INSERT INTO test11 VALUES (2, 15); + SELECT * FROM log11; +} {2} + +# Multiple triggers on same event +do_execsql_test_on_specific_db {:memory:} trigger-multiple-same-event { + CREATE TABLE test12 (x INTEGER PRIMARY KEY); + CREATE TABLE log12 (msg TEXT); + CREATE TRIGGER t12a BEFORE INSERT ON test12 BEGIN INSERT INTO log12 VALUES ('trigger1'); END; + CREATE TRIGGER t12b BEFORE INSERT ON test12 BEGIN INSERT INTO log12 VALUES ('trigger2'); END; + INSERT INTO test12 VALUES (1); + SELECT * FROM log12 ORDER BY msg; +} {trigger1 +trigger2} + +# Triggers dropped when table is dropped +do_execsql_test_on_specific_db {:memory:} trigger-drop-table-drops-triggers { + CREATE TABLE test13 (x INTEGER PRIMARY KEY); + CREATE TRIGGER t13 BEFORE INSERT ON test13 BEGIN SELECT 1; END; + DROP TABLE test13; + SELECT name FROM sqlite_schema WHERE type='trigger' AND name='t13'; +} {} + +# NEW and OLD references +do_execsql_test_on_specific_db {:memory:} trigger-new-old-references { + CREATE TABLE test14 (x INTEGER PRIMARY KEY, y TEXT); + CREATE TABLE log14 (msg TEXT); + INSERT INTO test14 VALUES (1, 'hello'); + CREATE TRIGGER t14 AFTER UPDATE ON test14 BEGIN INSERT INTO log14 VALUES ('old=' || OLD.y || ' new=' || NEW.y); END; + UPDATE test14 SET y = 'world' WHERE x = 1; + SELECT * FROM log14; +} {"old=hello new=world"} + +# Trigger with UPDATE OF clause +do_execsql_test_on_specific_db {:memory:} trigger-update-of { + CREATE TABLE test15 (x INTEGER PRIMARY KEY, y TEXT, z TEXT); + CREATE TABLE log15 (msg TEXT); + INSERT INTO test15 VALUES (1, 'hello', 'world'); + CREATE TRIGGER t15 AFTER UPDATE OF y ON test15 BEGIN INSERT INTO log15 VALUES ('y changed'); END; + UPDATE test15 SET z = 'foo' WHERE x = 1; + SELECT * FROM log15; + UPDATE test15 SET y = 'bar' WHERE x = 1; + SELECT * FROM log15; +} {"y changed"} + +# Recursive trigger - AFTER INSERT inserting into same table +do_execsql_test_on_specific_db {:memory:} trigger-recursive-after-insert { + CREATE TABLE test16 (x INTEGER PRIMARY KEY); + CREATE TRIGGER t16 AFTER INSERT ON test16 BEGIN INSERT INTO test16 VALUES (NEW.x + 1); END; + INSERT INTO test16 VALUES (1); + SELECT * FROM test16 ORDER BY x; +} {1 +2} + +# Multiple UPDATE OF columns +do_execsql_test_on_specific_db {:memory:} trigger-update-of-multiple { + CREATE TABLE test17 (x INTEGER PRIMARY KEY, y TEXT, z TEXT); + CREATE TABLE log17 (msg TEXT); + INSERT INTO test17 VALUES (1, 'a', 'b'); + CREATE TRIGGER t17 AFTER UPDATE OF y, z ON test17 BEGIN INSERT INTO log17 VALUES ('updated'); END; + UPDATE test17 SET y = 'c' WHERE x = 1; + SELECT COUNT(*) FROM log17; + UPDATE test17 SET x = 2 WHERE x = 1; + SELECT COUNT(*) FROM log17; +} {1 +1} + +# Complex WHEN clause with AND +do_execsql_test_on_specific_db {:memory:} trigger-when-complex { + CREATE TABLE test18 (x INTEGER PRIMARY KEY, y INTEGER); + CREATE TABLE log18 (x INTEGER); + CREATE TRIGGER t18 BEFORE INSERT ON test18 WHEN NEW.y > 5 AND NEW.y < 10 BEGIN INSERT INTO log18 VALUES (NEW.x); END; + INSERT INTO test18 VALUES (1, 3); + INSERT INTO test18 VALUES (2, 7); + INSERT INTO test18 VALUES (3, 12); + SELECT * FROM log18; +} {2} + +# Nested triggers - trigger firing another trigger +do_execsql_test_on_specific_db {:memory:} trigger-nested { + CREATE TABLE test19 (x INTEGER PRIMARY KEY); + CREATE TABLE test20 (x INTEGER PRIMARY KEY); + CREATE TABLE log19 (msg TEXT); + CREATE TRIGGER t19 AFTER INSERT ON test19 BEGIN INSERT INTO test20 VALUES (NEW.x); END; + CREATE TRIGGER t20 AFTER INSERT ON test20 BEGIN INSERT INTO log19 VALUES ('t20 inserted: ' || NEW.x); END; + INSERT INTO test19 VALUES (1); + SELECT * FROM log19; +} {"t20 inserted: 1"} + +# BEFORE INSERT inserting into same table (recursive) +do_execsql_test_on_specific_db {:memory:} trigger-recursive-before-insert { + CREATE TABLE test21 (x INTEGER PRIMARY KEY); + CREATE TRIGGER t21 BEFORE INSERT ON test21 BEGIN INSERT INTO test21 VALUES (NEW.x + 100); END; + INSERT INTO test21 VALUES (1); + SELECT * FROM test21 ORDER BY x; +} {1 +101} + +# AFTER UPDATE with WHEN clause and UPDATE OF +do_execsql_test_on_specific_db {:memory:} trigger-update-of-when { + CREATE TABLE test22 (x INTEGER PRIMARY KEY, y TEXT); + CREATE TRIGGER t22 AFTER UPDATE OF y ON test22 WHEN OLD.y != NEW.y BEGIN UPDATE test22 SET y = UPPER(NEW.y) WHERE x = NEW.x; END; + INSERT INTO test22 VALUES (1, 'hello'); + UPDATE test22 SET y = 'world' WHERE x = 1; + SELECT * FROM test22; +} {1|WORLD} + +# Multiple statements in BEFORE INSERT trigger +do_execsql_test_on_specific_db {:memory:} trigger-multiple-statements-before-insert { + CREATE TABLE test23 (x INTEGER PRIMARY KEY, y TEXT); + CREATE TABLE log23a (msg TEXT); + CREATE TABLE log23b (msg TEXT); + CREATE TRIGGER t23 BEFORE INSERT ON test23 BEGIN INSERT INTO log23a VALUES ('before1'); INSERT INTO log23b VALUES ('before2'); END; + INSERT INTO test23 VALUES (1, 'hello'); + SELECT * FROM log23a; + SELECT * FROM log23b; +} {before1 +before2} + +# Multiple statements in AFTER INSERT trigger +do_execsql_test_on_specific_db {:memory:} trigger-multiple-statements-after-insert { + CREATE TABLE test24 (x INTEGER PRIMARY KEY, y TEXT); + CREATE TABLE log24a (msg TEXT); + CREATE TABLE log24b (msg TEXT); + CREATE TRIGGER t24 AFTER INSERT ON test24 BEGIN INSERT INTO log24a VALUES ('after1'); INSERT INTO log24b VALUES ('after2'); END; + INSERT INTO test24 VALUES (1, 'hello'); + SELECT * FROM log24a; + SELECT * FROM log24b; +} {after1 +after2} + +# Multiple statements in BEFORE UPDATE trigger +do_execsql_test_on_specific_db {:memory:} trigger-multiple-statements-before-update { + CREATE TABLE test25 (x INTEGER PRIMARY KEY, y TEXT); + CREATE TABLE log25a (msg TEXT); + CREATE TABLE log25b (msg TEXT); + INSERT INTO test25 VALUES (1, 'hello'); + CREATE TRIGGER t25 BEFORE UPDATE ON test25 BEGIN INSERT INTO log25a VALUES ('before_update1'); INSERT INTO log25b VALUES ('before_update2'); END; + UPDATE test25 SET y = 'world' WHERE x = 1; + SELECT * FROM log25a; + SELECT * FROM log25b; +} {before_update1 +before_update2} + +# Multiple statements in AFTER UPDATE trigger +do_execsql_test_on_specific_db {:memory:} trigger-multiple-statements-after-update { + CREATE TABLE test26 (x INTEGER PRIMARY KEY, y TEXT); + CREATE TABLE log26a (msg TEXT); + CREATE TABLE log26b (msg TEXT); + INSERT INTO test26 VALUES (1, 'hello'); + CREATE TRIGGER t26 AFTER UPDATE ON test26 BEGIN INSERT INTO log26a VALUES ('after_update1'); INSERT INTO log26b VALUES ('after_update2'); END; + UPDATE test26 SET y = 'world' WHERE x = 1; + SELECT * FROM log26a; + SELECT * FROM log26b; +} {after_update1 +after_update2} + +# Multiple statements in BEFORE DELETE trigger +do_execsql_test_on_specific_db {:memory:} trigger-multiple-statements-before-delete { + CREATE TABLE test27 (x INTEGER PRIMARY KEY, y TEXT); + CREATE TABLE log27a (msg TEXT); + CREATE TABLE log27b (msg TEXT); + INSERT INTO test27 VALUES (1, 'hello'); + CREATE TRIGGER t27 BEFORE DELETE ON test27 BEGIN INSERT INTO log27a VALUES ('before_delete1'); INSERT INTO log27b VALUES ('before_delete2'); END; + DELETE FROM test27 WHERE x = 1; + SELECT * FROM log27a; + SELECT * FROM log27b; +} {before_delete1 +before_delete2} + +# Multiple statements in AFTER DELETE trigger +do_execsql_test_on_specific_db {:memory:} trigger-multiple-statements-after-delete { + CREATE TABLE test28 (x INTEGER PRIMARY KEY, y TEXT); + CREATE TABLE log28a (msg TEXT); + CREATE TABLE log28b (msg TEXT); + INSERT INTO test28 VALUES (1, 'hello'); + CREATE TRIGGER t28 AFTER DELETE ON test28 BEGIN INSERT INTO log28a VALUES ('after_delete1'); INSERT INTO log28b VALUES ('after_delete2'); END; + DELETE FROM test28 WHERE x = 1; + SELECT * FROM log28a; + SELECT * FROM log28b; +} {after_delete1 +after_delete2} + +# Multiple statements with mixed operations in BEFORE INSERT trigger +do_execsql_test_on_specific_db {:memory:} trigger-multiple-statements-mixed-before-insert { + CREATE TABLE test29 (x INTEGER PRIMARY KEY, y TEXT); + CREATE TABLE log29 (x INTEGER, y TEXT); + CREATE TABLE counter29 (cnt INTEGER); + INSERT INTO counter29 VALUES (0); + CREATE TRIGGER t29 BEFORE INSERT ON test29 BEGIN INSERT INTO log29 VALUES (NEW.x, NEW.y); UPDATE counter29 SET cnt = cnt + 1; END; + INSERT INTO test29 VALUES (1, 'hello'); + INSERT INTO test29 VALUES (2, 'world'); + SELECT * FROM log29 ORDER BY x; + SELECT * FROM counter29; +} {1|hello +2|world +2} + +# Multiple statements with conditional logic in trigger +do_execsql_test_on_specific_db {:memory:} trigger-multiple-statements-conditional { + CREATE TABLE test30 (x INTEGER PRIMARY KEY, y INTEGER); + CREATE TABLE log30 (msg TEXT); + CREATE TRIGGER t30 AFTER INSERT ON test30 BEGIN INSERT INTO log30 VALUES ('inserted: ' || NEW.x); INSERT INTO log30 VALUES ('value: ' || NEW.y); INSERT INTO log30 VALUES ('done'); END; + INSERT INTO test30 VALUES (1, 10); + INSERT INTO test30 VALUES (2, 20); + SELECT * FROM log30 ORDER BY msg; +} {done +done +"inserted: 1" +"inserted: 2" +"value: 10" +"value: 20"} + +# Trigger that updates the same row being updated (recursive update) +do_execsql_test_on_specific_db {:memory:} trigger-update-same-row { + CREATE TABLE test31 (x INTEGER PRIMARY KEY, y INTEGER, z INTEGER); + INSERT INTO test31 VALUES (1, 10, 0); + CREATE TRIGGER t31 AFTER UPDATE OF y ON test31 BEGIN UPDATE test31 SET z = z + 1 WHERE x = NEW.x; END; + UPDATE test31 SET y = 20 WHERE x = 1; + SELECT * FROM test31; +} {1|20|1} + +# Trigger that deletes the row being inserted +do_execsql_test_on_specific_db {:memory:} trigger-delete-inserted-row { + CREATE TABLE test32 (x INTEGER PRIMARY KEY, y INTEGER); + CREATE TABLE log32 (msg TEXT); + CREATE TRIGGER t32 AFTER INSERT ON test32 BEGIN DELETE FROM test32 WHERE x = NEW.x AND NEW.y < 0; INSERT INTO log32 VALUES ('processed: ' || NEW.x); END; + INSERT INTO test32 VALUES (1, 10); + INSERT INTO test32 VALUES (2, -5); + SELECT * FROM test32; + SELECT * FROM log32 ORDER BY msg; +} {1|10 +"processed: 1" +"processed: 2"} + +# Trigger chain: INSERT triggers UPDATE which triggers DELETE +do_execsql_test_on_specific_db {:memory:} trigger-chain-insert-update-delete { + CREATE TABLE test34 (x INTEGER PRIMARY KEY, y INTEGER); + CREATE TABLE log34 (msg TEXT); + CREATE TRIGGER t34a AFTER INSERT ON test34 BEGIN UPDATE test34 SET y = y + 1 WHERE x = NEW.x; END; + CREATE TRIGGER t34b AFTER UPDATE ON test34 BEGIN DELETE FROM test34 WHERE y > 100; INSERT INTO log34 VALUES ('updated: ' || NEW.x); END; + INSERT INTO test34 VALUES (1, 50); + INSERT INTO test34 VALUES (2, 100); + SELECT * FROM test34 ORDER BY x; + SELECT * FROM log34 ORDER BY msg; +} {1|51 +"updated: 1" +"updated: 2"} + +# Trigger that inserts into the same table (recursive insert) +do_execsql_test_on_specific_db {:memory:} trigger-recursive-insert { + CREATE TABLE test35 (x INTEGER PRIMARY KEY, y INTEGER, depth INTEGER); + CREATE TRIGGER t35 AFTER INSERT ON test35 WHEN NEW.depth < 3 BEGIN INSERT INTO test35 VALUES (NEW.x * 10, NEW.y + 1, NEW.depth + 1); END; + INSERT INTO test35 VALUES (1, 0, 0); + SELECT * FROM test35 ORDER BY x; +} {1|0|0 +10|1|1} + +# Trigger that updates OLD values in BEFORE UPDATE +do_execsql_test_on_specific_db {:memory:} trigger-update-old-reference { + CREATE TABLE test36 (x INTEGER PRIMARY KEY, y INTEGER, z INTEGER); + CREATE TABLE log36 (old_y INTEGER, new_y INTEGER); + INSERT INTO test36 VALUES (1, 10, 0); + CREATE TRIGGER t36 BEFORE UPDATE OF y ON test36 BEGIN INSERT INTO log36 VALUES (OLD.y, NEW.y); UPDATE test36 SET z = OLD.y + NEW.y WHERE x = NEW.x; END; + UPDATE test36 SET y = 20 WHERE x = 1; + SELECT * FROM test36; + SELECT * FROM log36; +} {1|20|30 +10|20} + +# Trigger that causes constraint violation in another table +do_execsql_test_on_specific_db {:memory:} trigger-constraint-violation { + CREATE TABLE test37a (x INTEGER PRIMARY KEY); + CREATE TABLE test37b (x INTEGER PRIMARY KEY, y INTEGER); + INSERT INTO test37b VALUES (1, 100); + CREATE TRIGGER t37 AFTER INSERT ON test37a BEGIN INSERT OR IGNORE INTO test37b VALUES (NEW.x, NEW.x * 10); END; + INSERT INTO test37a VALUES (1); + SELECT * FROM test37b ORDER BY x; +} {1|100} + +# Multiple triggers on same event with interdependencies +do_execsql_test_on_specific_db {:memory:} trigger-multiple-interdependent { + CREATE TABLE test38 (x INTEGER PRIMARY KEY, y INTEGER, z INTEGER); + CREATE TRIGGER t38a AFTER INSERT ON test38 BEGIN UPDATE test38 SET y = 100 WHERE x = NEW.x; END; + CREATE TRIGGER t38b AFTER INSERT ON test38 BEGIN UPDATE test38 SET z = 200 WHERE x = NEW.x; END; + INSERT INTO test38 VALUES (1, 10, 20); + SELECT * FROM test38; +} {1|100|200} + +# Trigger that references both OLD and NEW in UPDATE +do_execsql_test_on_specific_db {:memory:} trigger-old-new-arithmetic { + CREATE TABLE test39 (x INTEGER PRIMARY KEY, y INTEGER, delta INTEGER); + INSERT INTO test39 VALUES (1, 10, 0); + CREATE TRIGGER t39 BEFORE UPDATE OF y ON test39 BEGIN UPDATE test39 SET delta = NEW.y - OLD.y WHERE x = NEW.x; END; + UPDATE test39 SET y = 25 WHERE x = 1; + SELECT * FROM test39; +} {1|25|15} + +# Trigger that performs DELETE based on NEW values +do_execsql_test_on_specific_db {:memory:} trigger-delete-on-insert-condition { + CREATE TABLE test40 (x INTEGER PRIMARY KEY, y INTEGER); + CREATE TABLE archive40 (x INTEGER, y INTEGER); + INSERT INTO test40 VALUES (1, 10); + INSERT INTO test40 VALUES (2, 20); + CREATE TRIGGER t40 AFTER INSERT ON test40 BEGIN DELETE FROM test40 WHERE y < NEW.y; INSERT INTO archive40 SELECT * FROM test40 WHERE x = NEW.x; END; + INSERT INTO test40 VALUES (3, 15); + SELECT * FROM test40 ORDER BY x; + SELECT * FROM archive40 ORDER BY x; +} {2|20 +3|15 +3|15} + +# Trigger with WHEN clause that modifies the condition column +do_execsql_test_on_specific_db {:memory:} trigger-when-clause-modification { + CREATE TABLE test41 (x INTEGER PRIMARY KEY, y INTEGER, processed INTEGER DEFAULT 0); + CREATE TRIGGER t41 AFTER INSERT ON test41 WHEN NEW.y > 50 BEGIN UPDATE test41 SET processed = 1 WHERE x = NEW.x; END; + INSERT INTO test41 (x, y) VALUES (1, 30); + INSERT INTO test41 (x, y) VALUES (2, 60); + INSERT INTO test41 (x, y) VALUES (3, 70); + SELECT * FROM test41 ORDER BY x; +} {1|30|0 +2|60|1 +3|70|1} + +# Trigger that creates circular dependency between two tables +do_execsql_test_on_specific_db {:memory:} trigger-circular-dependency { + CREATE TABLE test42a (x INTEGER PRIMARY KEY, y INTEGER); + CREATE TABLE test42b (x INTEGER PRIMARY KEY, y INTEGER); + CREATE TRIGGER t42a AFTER INSERT ON test42a BEGIN INSERT OR IGNORE INTO test42b VALUES (NEW.x, NEW.y + 1); END; + CREATE TRIGGER t42b AFTER INSERT ON test42b BEGIN INSERT OR IGNORE INTO test42a VALUES (NEW.x + 100, NEW.y + 1); END; + INSERT INTO test42a VALUES (1, 10); + SELECT * FROM test42a ORDER BY x; + SELECT * FROM test42b ORDER BY x; +} {1|10 +101|12 +1|11} + +# BEFORE trigger modifying primary key value +do_execsql_test_on_specific_db {:memory:} trigger-before-modify-primary-key { + CREATE TABLE test43 (x INTEGER PRIMARY KEY, y INTEGER); + CREATE TRIGGER t43 BEFORE INSERT ON test43 BEGIN UPDATE test43 SET x = NEW.x + 1000 WHERE x = NEW.x; END; + INSERT INTO test43 VALUES (1, 10); + SELECT * FROM test43; +} {1|10} + +# UPDATE OF trigger modifying the same column it watches +do_execsql_test_on_specific_db {:memory:} trigger-update-of-modify-same-column { + CREATE TABLE test44 (x INTEGER PRIMARY KEY, y INTEGER); + CREATE TABLE log44 (msg TEXT); + INSERT INTO test44 VALUES (1, 10); + CREATE TRIGGER t44 AFTER UPDATE OF y ON test44 BEGIN UPDATE test44 SET y = y * 2 WHERE x = NEW.x; INSERT INTO log44 VALUES ('triggered'); END; + UPDATE test44 SET y = 5 WHERE x = 1; + SELECT * FROM test44; + SELECT COUNT(*) FROM log44; +} {1|10 +1} + +# BEFORE trigger modifying column used in UPDATE OF clause +do_execsql_test_on_specific_db {:memory:} trigger-before-modify-update-of-column { + CREATE TABLE test45 (x INTEGER PRIMARY KEY, y INTEGER, z INTEGER); + CREATE TABLE log45 (msg TEXT); + INSERT INTO test45 VALUES (1, 10, 0); + CREATE TRIGGER t45a BEFORE UPDATE OF y ON test45 BEGIN UPDATE test45 SET y = y + 100 WHERE x = NEW.x; END; + CREATE TRIGGER t45b AFTER UPDATE OF y ON test45 BEGIN INSERT INTO log45 VALUES ('y=' || NEW.y); END; + UPDATE test45 SET y = 20 WHERE x = 1; + SELECT * FROM test45; + SELECT * FROM log45; +} {1|20|0 +y=110 +y=20} + +# Trigger modifying column used in WHEN clause +do_execsql_test_on_specific_db {:memory:} trigger-modify-when-clause-column { + CREATE TABLE test47 (x INTEGER PRIMARY KEY, y INTEGER, z INTEGER); + CREATE TABLE log47 (msg TEXT); + INSERT INTO test47 VALUES (1, 10, 0); + CREATE TRIGGER t47 BEFORE UPDATE ON test47 WHEN NEW.y > 5 BEGIN UPDATE test47 SET y = y - 10 WHERE x = NEW.x; INSERT INTO log47 VALUES ('modified'); END; + UPDATE test47 SET y = 15 WHERE x = 1; + SELECT * FROM test47; + SELECT COUNT(*) FROM log47; +} {1|15|0 +1} + +# Trigger causing unique constraint violation with INSERT OR IGNORE +do_execsql_test_on_specific_db {:memory:} trigger-unique-violation-ignore { + CREATE TABLE test48 (x INTEGER PRIMARY KEY, y INTEGER UNIQUE); + CREATE TABLE log48 (x INTEGER); + INSERT INTO test48 VALUES (1, 100); + CREATE TRIGGER t48 AFTER INSERT ON test48 BEGIN INSERT OR IGNORE INTO test48 VALUES (NEW.x + 1, NEW.y); INSERT INTO log48 VALUES (NEW.x); END; + INSERT INTO test48 VALUES (2, 200); + SELECT * FROM test48 ORDER BY x; + SELECT * FROM log48 ORDER BY x; +} {1|100 +2|200 +2} + +# Multiple triggers modifying same column in different ways +do_execsql_test_on_specific_db {:memory:} trigger-multiple-modify-same-column { + CREATE TABLE test49 (x INTEGER PRIMARY KEY, y INTEGER); + CREATE TRIGGER t49a BEFORE INSERT ON test49 BEGIN UPDATE test49 SET y = y + 1 WHERE x = NEW.x; END; + CREATE TRIGGER t49b BEFORE INSERT ON test49 BEGIN UPDATE test49 SET y = y * 2 WHERE x = NEW.x; END; + CREATE TRIGGER t49c BEFORE INSERT ON test49 BEGIN UPDATE test49 SET y = y + 10 WHERE x = NEW.x; END; + INSERT INTO test49 VALUES (1, 5); + SELECT * FROM test49; +} {1|5} + +# BEFORE trigger modifying NEW values used in WHEN clause +do_execsql_test_on_specific_db {:memory:} trigger-before-modify-new-in-when { + CREATE TABLE test51 (x INTEGER PRIMARY KEY, y INTEGER); + CREATE TABLE log51 (msg TEXT); + INSERT INTO test51 VALUES (1, 5); + CREATE TRIGGER t51a BEFORE UPDATE ON test51 BEGIN UPDATE test51 SET y = y + 20 WHERE x = NEW.x; END; + CREATE TRIGGER t51b AFTER UPDATE ON test51 WHEN NEW.y > 10 BEGIN INSERT INTO log51 VALUES ('y=' || NEW.y); END; + UPDATE test51 SET y = 8 WHERE x = 1; + SELECT * FROM test51; + SELECT * FROM log51; +} {1|8 +y=25} + +# UPDATE OF on non-existent column (should be silently ignored) +do_execsql_test_on_specific_db {:memory:} trigger-update-of-nonexistent-column { + CREATE TABLE test52 (x INTEGER PRIMARY KEY, y INTEGER); + CREATE TABLE log52 (msg TEXT); + INSERT INTO test52 VALUES (1, 10); + CREATE TRIGGER t52 AFTER UPDATE OF nonexistent ON test52 BEGIN INSERT INTO log52 VALUES ('triggered'); END; + UPDATE test52 SET y = 20 WHERE x = 1; + SELECT COUNT(*) FROM log52; +} {0} + +# Trigger modifying same column multiple times in one trigger body +do_execsql_test_on_specific_db {:memory:} trigger-modify-column-multiple-times { + CREATE TABLE test53 (x INTEGER PRIMARY KEY, y INTEGER); + INSERT INTO test53 VALUES (1, 10); + CREATE TRIGGER t53 AFTER UPDATE OF y ON test53 BEGIN UPDATE test53 SET y = y + 1 WHERE x = NEW.x; UPDATE test53 SET y = y * 2 WHERE x = NEW.x; UPDATE test53 SET y = y + 5 WHERE x = NEW.x; END; + UPDATE test53 SET y = 5 WHERE x = 1; + SELECT * FROM test53; +} {1|17} + +# Trigger that modifies rowid indirectly through updates +do_execsql_test_on_specific_db {:memory:} trigger-modify-rowid-indirect { + CREATE TABLE test55 (x INTEGER PRIMARY KEY, y INTEGER); + CREATE TABLE log55 (old_rowid INTEGER, new_rowid INTEGER); + INSERT INTO test55 VALUES (1, 10); + INSERT INTO test55 VALUES (2, 20); + CREATE TRIGGER t55 AFTER UPDATE ON test55 BEGIN INSERT INTO log55 VALUES (OLD.x, NEW.x); END; + UPDATE test55 SET x = 3 WHERE x = 1; + SELECT * FROM test55 ORDER BY x; + SELECT * FROM log55; +} {2|20 +3|10 +1|3} + +# Trigger that references OLD.rowid and NEW.rowid with rowid alias column +do_execsql_test_on_specific_db {:memory:} trigger-rowid-alias-old-new { + CREATE TABLE test55b (id INTEGER PRIMARY KEY, y INTEGER); + CREATE TABLE log55b (old_id INTEGER, new_id INTEGER, old_rowid INTEGER, new_rowid INTEGER); + INSERT INTO test55b VALUES (1, 10); + INSERT INTO test55b VALUES (2, 20); + CREATE TRIGGER t55b AFTER UPDATE ON test55b BEGIN INSERT INTO log55b VALUES (OLD.id, NEW.id, OLD.rowid, NEW.rowid); END; + UPDATE test55b SET id = 3 WHERE id = 1; + SELECT * FROM test55b ORDER BY id; + SELECT * FROM log55b; +} {2|20 +3|10 +1|3|1|3} + +# Trigger with BEFORE UPDATE that modifies rowid alias in WHEN clause +do_execsql_test_on_specific_db {:memory:} trigger-before-update-rowid-alias-when { + CREATE TABLE test55c (id INTEGER PRIMARY KEY, y INTEGER); + CREATE TABLE log55c (msg TEXT); + INSERT INTO test55c VALUES (1, 10); + CREATE TRIGGER t55c BEFORE UPDATE ON test55c WHEN NEW.id > 5 BEGIN UPDATE test55c SET y = y + 100 WHERE id = NEW.id; END; + UPDATE test55c SET id = 10, y = 20 WHERE id = 1; + SELECT * FROM test55c; + SELECT COUNT(*) FROM log55c; +} {10|20 +0} + +# Trigger referencing OLD.rowid in DELETE with rowid alias column +do_execsql_test_on_specific_db {:memory:} trigger-delete-old-rowid-alias { + CREATE TABLE test55d (pk INTEGER PRIMARY KEY, y INTEGER); + CREATE TABLE log55d (deleted_pk INTEGER, deleted_rowid INTEGER); + INSERT INTO test55d VALUES (1, 10); + INSERT INTO test55d VALUES (2, 20); + CREATE TRIGGER t55d AFTER DELETE ON test55d BEGIN INSERT INTO log55d VALUES (OLD.pk, OLD.rowid); END; + DELETE FROM test55d WHERE pk = 1; + SELECT * FROM test55d; + SELECT * FROM log55d; +} {2|20 +1|1} + +# Trigger with NEW.rowid in INSERT with rowid alias +do_execsql_test_on_specific_db {:memory:} trigger-insert-new-rowid-alias { + CREATE TABLE test55e (myid INTEGER PRIMARY KEY, y INTEGER); + CREATE TABLE log55e (new_myid INTEGER, new_rowid INTEGER); + CREATE TRIGGER t55e AFTER INSERT ON test55e BEGIN INSERT INTO log55e VALUES (NEW.myid, NEW.rowid); END; + INSERT INTO test55e VALUES (5, 50); + INSERT INTO test55e VALUES (10, 100); + SELECT * FROM test55e ORDER BY myid; + SELECT * FROM log55e ORDER BY new_myid; +} {5|50 +10|100 +5|5 +10|10} + +# Trigger modifying rowid through UPDATE with complex WHERE using rowid alias +do_execsql_test_on_specific_db {:memory:} trigger-update-rowid-complex-where { + CREATE TABLE test55f (rid INTEGER PRIMARY KEY, y INTEGER); + CREATE TABLE log55f (old_rid INTEGER, new_rid INTEGER); + INSERT INTO test55f VALUES (1, 10); + INSERT INTO test55f VALUES (2, 20); + INSERT INTO test55f VALUES (3, 30); + CREATE TRIGGER t55f AFTER UPDATE ON test55f BEGIN INSERT INTO log55f VALUES (OLD.rid, NEW.rid); UPDATE test55f SET y = y + 1 WHERE rid = NEW.rid; END; + UPDATE test55f SET rid = 100 WHERE rid = 2; + SELECT * FROM test55f ORDER BY rid; + SELECT * FROM log55f; +} {1|10 +3|30 +100|21 +2|100} + +# Trigger using both rowid and rowid alias in same expression +do_execsql_test_on_specific_db {:memory:} trigger-rowid-and-alias-mixed { + CREATE TABLE test55g (id INTEGER PRIMARY KEY, y INTEGER); + CREATE TABLE log55g (id_val INTEGER, rowid_val INTEGER, match INTEGER); + INSERT INTO test55g VALUES (1, 10); + CREATE TRIGGER t55g AFTER INSERT ON test55g BEGIN INSERT INTO log55g VALUES (NEW.id, NEW.rowid, NEW.id = NEW.rowid); END; + INSERT INTO test55g VALUES (5, 50); + SELECT * FROM log55g; +} {5|5|1} + +# Trigger that causes cascading updates through multiple tables +do_execsql_test_on_specific_db {:memory:} trigger-cascading-updates { + CREATE TABLE test57a (x INTEGER PRIMARY KEY, y INTEGER); + CREATE TABLE test57b (x INTEGER PRIMARY KEY, y INTEGER); + CREATE TABLE log57 (msg TEXT); + INSERT INTO test57a VALUES (1, 10); + INSERT INTO test57b VALUES (1, 100); + CREATE TRIGGER t57a AFTER UPDATE ON test57a BEGIN UPDATE test57b SET y = NEW.y * 10 WHERE x = NEW.x; END; + CREATE TRIGGER t57b AFTER UPDATE ON test57b BEGIN INSERT INTO log57 VALUES ('b updated: ' || NEW.y); END; + UPDATE test57a SET y = 20 WHERE x = 1; + SELECT * FROM test57a; + SELECT * FROM test57b; + SELECT * FROM log57; +} {1|20 +1|200 +"b updated: 200"} + +# Trigger modifying columns used in unique constraint +do_execsql_test_on_specific_db {:memory:} trigger-modify-unique-column { + CREATE TABLE test58 (x INTEGER PRIMARY KEY, y INTEGER UNIQUE); + CREATE TABLE log58 (msg TEXT); + INSERT INTO test58 VALUES (1, 10); + INSERT INTO test58 VALUES (2, 20); + CREATE TRIGGER t58 AFTER UPDATE ON test58 BEGIN UPDATE test58 SET y = y + 1 WHERE x != NEW.x; INSERT INTO log58 VALUES ('updated'); END; + UPDATE test58 SET y = 15 WHERE x = 1; + SELECT * FROM test58 ORDER BY x; + SELECT COUNT(*) FROM log58; +} {1|15 +2|21 +1} + +# BEFORE INSERT trigger that modifies NEW.x before insertion +do_execsql_test_on_specific_db {:memory:} trigger-before-insert-modify-new { + CREATE TABLE test59 (x INTEGER PRIMARY KEY, y INTEGER); + CREATE TRIGGER t59 BEFORE INSERT ON test59 BEGIN UPDATE test59 SET x = NEW.x + 100 WHERE x = NEW.x; END; + INSERT INTO test59 VALUES (1, 10); + SELECT * FROM test59; +} {1|10} + +# Trigger with UPDATE OF multiple columns, modifying one of them +do_execsql_test_on_specific_db {:memory:} trigger-update-of-multiple-modify-one { + CREATE TABLE test60 (x INTEGER PRIMARY KEY, y INTEGER, z INTEGER); + CREATE TABLE log60 (msg TEXT); + INSERT INTO test60 VALUES (1, 10, 100); + CREATE TRIGGER t60 AFTER UPDATE OF y, z ON test60 BEGIN UPDATE test60 SET y = y + 50 WHERE x = NEW.x; INSERT INTO log60 VALUES ('triggered'); END; + UPDATE test60 SET y = 20 WHERE x = 1; + SELECT * FROM test60; + SELECT COUNT(*) FROM log60; + UPDATE test60 SET z = 200 WHERE x = 1; + SELECT * FROM test60; + SELECT COUNT(*) FROM log60; +} {1|70|100 +1 +1|120|200 +2} + +# Trigger modifying column that's part of composite unique constraint +do_execsql_test_on_specific_db {:memory:} trigger-modify-composite-unique { + CREATE TABLE test62 (x INTEGER, y INTEGER, UNIQUE(x, y)); + CREATE TABLE log62 (msg TEXT); + INSERT INTO test62 VALUES (1, 10); + INSERT INTO test62 VALUES (2, 20); + CREATE TRIGGER t62 AFTER UPDATE ON test62 BEGIN UPDATE test62 SET y = y + 1 WHERE x = NEW.x + 1; INSERT INTO log62 VALUES ('updated'); END; + UPDATE test62 SET y = 15 WHERE x = 1; + SELECT * FROM test62 ORDER BY x; + SELECT COUNT(*) FROM log62; +} {1|15 +2|21 +1} + +# Trigger with WHEN clause that becomes false after BEFORE trigger modification +do_execsql_test_on_specific_db {:memory:} trigger-when-becomes-false-after-before { + CREATE TABLE test63 (x INTEGER PRIMARY KEY, y INTEGER); + CREATE TABLE log63 (msg TEXT); + INSERT INTO test63 VALUES (1, 10); + CREATE TRIGGER t63a BEFORE UPDATE ON test63 BEGIN UPDATE test63 SET y = y - 15 WHERE x = NEW.x; END; + CREATE TRIGGER t63b AFTER UPDATE ON test63 WHEN NEW.y > 0 BEGIN INSERT INTO log63 VALUES ('y=' || NEW.y); END; + UPDATE test63 SET y = 20 WHERE x = 1; + SELECT * FROM test63; + SELECT COUNT(*) FROM log63; +} {1|20 +1} + +# Trigger that inserts into same table with different rowid +do_execsql_test_on_specific_db {:memory:} trigger-recursive-different-rowid { + CREATE TABLE test64 (x INTEGER PRIMARY KEY, y INTEGER); + CREATE TRIGGER t64 AFTER INSERT ON test64 WHEN NEW.y < 100 BEGIN INSERT INTO test64 VALUES (NEW.x + 1000, NEW.y + 10); END; + INSERT INTO test64 VALUES (1, 5); + INSERT INTO test64 VALUES (2, 50); + SELECT * FROM test64 ORDER BY x; +} {1|5 +2|50 +1001|15 +1002|60} + +# Trigger modifying primary key through UPDATE in BEFORE trigger +do_execsql_test_on_specific_db {:memory:} trigger-before-update-primary-key { + CREATE TABLE test65 (x INTEGER PRIMARY KEY, y INTEGER); + INSERT INTO test65 VALUES (1, 10); + CREATE TRIGGER t65 BEFORE UPDATE ON test65 BEGIN UPDATE test65 SET x = NEW.x + 100 WHERE x = OLD.x; END; + UPDATE test65 SET y = 20 WHERE x = 1; + SELECT * FROM test65 ORDER BY x; +} {101|10} + +# Trigger modifying row during BEFORE UPDATE - parent expression behavior +# The parent UPDATE's SET clause expressions (c0 = c1+1) use OLD values for columns +# referenced in the expression (c1), but the final row gets the modified values from +# the BEFORE trigger for columns not in the parent's SET clause (c1, c2). +do_execsql_test_on_specific_db {:memory:} trigger-before-update-parent-expression-old-values { + CREATE TABLE test66 (c0 INTEGER, c1 INTEGER, c2 INTEGER); + CREATE TRIGGER tu66 BEFORE UPDATE ON test66 BEGIN UPDATE test66 SET c1=666, c2=666; END; + INSERT INTO test66 VALUES (1,1,1); + UPDATE test66 SET c0 = c1+1; + SELECT * FROM test66; +} {2|666|666} + +# Multiple BEFORE INSERT triggers - last added trigger fires first +# SQLite evaluates triggers in reverse order of creation (LIFO) +do_execsql_test_on_specific_db {:memory:} trigger-multiple-before-insert-lifo { + CREATE TABLE t67(c0 INTEGER, c1 INTEGER, c2 INTEGER, whodunnit TEXT); + CREATE TRIGGER t67_first BEFORE INSERT ON t67 BEGIN INSERT INTO t67 VALUES (NEW.c0+1, NEW.c1+2, NEW.c2+3, 't67_first'); END; + CREATE TRIGGER t67_second BEFORE INSERT ON t67 BEGIN INSERT INTO t67 VALUES (NEW.c0+2, NEW.c1+3, NEW.c2+4, 't67_second'); END; + INSERT INTO t67 VALUES (1, 1, 1, 'jussi'); + SELECT rowid, * FROM t67 ORDER BY rowid; +} {1|4|6|8|t67_first +2|3|4|5|t67_second +3|4|6|8|t67_second +4|2|3|4|t67_first +5|1|1|1|jussi} \ No newline at end of file diff --git a/tests/fuzz/mod.rs b/tests/fuzz/mod.rs index d116c054c..95668f00c 100644 --- a/tests/fuzz/mod.rs +++ b/tests/fuzz/mod.rs @@ -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], + limbo_rows: &[Vec], + 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: \n"); + } + if let Some(limbo_row) = limbo_rows.get(idx) { + diff.push_str(&format!(" Limbo: {limbo_row:?}\n")); + } else { + diff.push_str(" Limbo: \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::>() .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::>() + .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};"); } diff --git a/tests/integration/mod.rs b/tests/integration/mod.rs index dcb8e3b0f..b03df00be 100644 --- a/tests/integration/mod.rs +++ b/tests/integration/mod.rs @@ -5,6 +5,7 @@ mod index_method; mod pragma; mod query_processing; mod storage; +mod trigger; mod wal; #[cfg(test)] diff --git a/tests/integration/trigger.rs b/tests/integration/trigger.rs new file mode 100644 index 000000000..22122e0b7 --- /dev/null +++ b/tests/integration/trigger.rs @@ -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"); +}